feat(web): shared native-dialog Modal primitive + migrate form modals (TASK-2023) (#881)

* feat(web): shared native-dialog Modal primitive + migrate form modals (TASK-2023)

* fix(web): hide closed native-dialog Modal (specificity vs UA rule) — Codex P1
This commit is contained in:
xarmian
2026-07-09 15:33:37 -04:00
committed by GitHub
parent bcf5e6519e
commit 9664cd6ee5
10 changed files with 350 additions and 532 deletions
+5 -2
View File
@@ -714,12 +714,15 @@ dialog.attachment-image-lightbox .attachment-image-lightbox-close:hover {
.overlay,
.palette,
.modal,
.modal-backdrop {
.modal-backdrop,
dialog[open] {
display: none !important;
}
/* Modals / dialogs / tooltips. Most KeyboardShortcuts-style overlays
carry role="dialog" on the backdrop. */
carry role="dialog" on the backdrop. The shared Modal primitive uses a
native <dialog> (implicit dialog role, no literal role attribute), so it
is covered by the `dialog[open]` selector above. */
[role="dialog"],
[role="tooltip"],
[role="menu"] {
@@ -3,6 +3,7 @@
import { copyToClipboard } from '$lib/utils/clipboard';
import { defaultInstallTab, type InstallTab } from '$lib/utils/platform';
import { api, PadApiError } from '$lib/api/client';
import Modal from '$lib/components/common/Modal.svelte';
import type { ClaimCodeResponse } from '$lib/types';
interface Props {
@@ -297,12 +298,6 @@
toastStore.show(success ? label : 'Failed to copy', success ? 'success' : 'error');
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && open) {
open = false;
}
}
// --- Display helpers -----------------------------------------------------
let title = $derived(
@@ -325,19 +320,13 @@
const CONNECTED_APPS_HREF = '/console/connected-apps';
</script>
<svelte:window onkeydown={handleKeydown} />
<Modal open={open} onclose={() => (open = false)} labelledby="connect-ws-title" maxWidth="560px">
<div class="modal-header">
<h2 id="connect-ws-title">{title}</h2>
<button class="close-btn" type="button" onclick={() => (open = false)}>&#10005;</button>
</div>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay" onclick={() => (open = false)}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h2>{title}</h2>
<button class="close-btn" type="button" onclick={() => (open = false)}>&#10005;</button>
</div>
<div class="modal-body">
<div class="modal-body">
<!-- Primary tab strip -->
<div class="primary-tabs" role="tablist">
{#each visibleTabs as tab (tab.id)}
@@ -719,36 +708,10 @@
<a href={CONNECTED_APPS_HREF}>Connected agents &rarr;</a>
{/if}
</div>
</div>
</div>
</div>
{/if}
</Modal>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 50;
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 10vh;
}
.modal {
width: 100%;
max-width: 560px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
overflow: hidden;
max-height: 85vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
+22 -121
View File
@@ -14,27 +14,9 @@
import { page } from '$app/state';
import { openChildrenDialog } from '$lib/stores/openChildrenDialog.svelte';
import Modal from '$lib/components/common/Modal.svelte';
let active = $derived(openChildrenDialog.active);
let cancelBtn: HTMLButtonElement | undefined = $state();
let modalEl: HTMLDivElement | undefined = $state();
let previouslyFocused: HTMLElement | null = null;
// On open: remember whatever had focus so we can restore it on
// close, then move focus to the safe action (Cancel) — keyboard
// users land on a defined target and the destructive "Override"
// action stays one Tab away. On close: restore the original focus
// so the user lands back where they were (drag handle, status
// dropdown, etc.).
$effect(() => {
if (active) {
previouslyFocused = (document.activeElement as HTMLElement) ?? null;
cancelBtn?.focus();
} else if (previouslyFocused) {
previouslyFocused.focus();
previouslyFocused = null;
}
});
function onCancel() {
openChildrenDialog.cancel();
@@ -44,47 +26,16 @@
openChildrenDialog.confirm();
}
// Focus trap. While the dialog is open, Tab / Shift-Tab cycle
// between the focusable elements WITHIN the modal — anything
// outside is off-limits until the user cancels or confirms.
// Cheap implementation: collect focusable descendants on each
// Tab press (modal contents are small + static while open) and
// wrap selection at the ends.
function trapTab(e: KeyboardEvent) {
if (!active || e.key !== 'Tab' || !modalEl) return;
const nodes = modalEl.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"]), input, select, textarea'
);
if (nodes.length === 0) return;
const first = nodes[0];
const last = nodes[nodes.length - 1];
const current = document.activeElement as HTMLElement | null;
if (e.shiftKey && current === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && current === last) {
e.preventDefault();
first.focus();
} else if (current && !modalEl.contains(current)) {
// Focus escaped (e.g. via programmatic blur) — re-anchor.
e.preventDefault();
first.focus();
}
}
// The native <dialog> (via <Modal>) owns Escape, the focus trap, and
// focus save/restore. We keep a window listener only for the Cmd/Ctrl+Enter
// shortcut that confirms the override — gated on `active` so it's inert
// while the dialog is closed.
function onKeydown(e: KeyboardEvent) {
if (!active) return;
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
return;
}
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onConfirm();
return;
}
trapTab(e);
}
// Item URLs are /[username]/[workspace]/[collection]/[slug]. The
@@ -102,24 +53,19 @@
<svelte:window onkeydown={onKeydown} />
{#if active}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="overlay"
onclick={onCancel}
role="dialog"
aria-modal="true"
aria-labelledby="open-children-title"
tabindex="-1"
>
<div class="modal" bind:this={modalEl} onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h2 id="open-children-title">Children still open</h2>
<button class="close-btn" type="button" onclick={onCancel} aria-label="Cancel"
>&#10005;</button
>
</div>
<Modal
open={!!active}
onclose={onCancel}
labelledby="open-children-title"
maxWidth="540px"
>
{#if active}
<div class="modal-header">
<h2 id="open-children-title">Children still open</h2>
<button class="close-btn" type="button" onclick={onCancel} aria-label="Cancel"
>&#10005;</button
>
</div>
<div class="modal-body">
<p class="lead">
@@ -172,58 +118,21 @@
</div>
<div class="modal-footer">
<!-- svelte-ignore a11y_autofocus -->
<button
bind:this={cancelBtn}
type="button"
class="btn btn-secondary"
autofocus
onclick={onCancel}>Cancel</button
>
<button type="button" class="btn btn-danger" onclick={onConfirm}>
Override and mark {active.details.attempted_value}
</button>
</div>
</div>
</div>
{/if}
{/if}
</Modal>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 60;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 12vh var(--space-4) var(--space-4);
animation: overlay-in 140ms ease-out;
}
.modal {
width: 100%;
max-width: 540px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
max-height: 75vh;
animation: modal-in 160ms ease-out;
}
@keyframes overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modal-in {
from { transform: translateY(8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.overlay, .modal { animation: none; }
}
.modal-header {
display: flex;
align-items: center;
@@ -384,14 +293,6 @@
}
@media (max-width: 600px) {
.overlay {
padding: var(--space-3);
align-items: stretch;
}
.modal {
max-width: 100%;
max-height: calc(100vh - var(--space-6));
}
.modal-header,
.modal-body,
.modal-footer {
+8 -45
View File
@@ -2,6 +2,7 @@
import { api } from '$lib/api/client';
import { toastStore } from '$lib/stores/toast.svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import Modal from '$lib/components/common/Modal.svelte';
import type { CollectionGrant, ItemGrant, ShareLink } from '$lib/types';
interface Props {
@@ -183,12 +184,6 @@
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && open) {
open = false;
}
}
function handleShareKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && email.trim()) {
e.preventDefault();
@@ -216,19 +211,13 @@
}
</script>
<svelte:window onkeydown={handleKeydown} />
<Modal open={open} onclose={() => (open = false)} labelledby="share-dialog-title" maxWidth="480px">
<div class="modal-header">
<h2 id="share-dialog-title">Share {targetName}</h2>
<button class="close-btn" type="button" onclick={() => (open = false)}>&#10005;</button>
</div>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay" onclick={() => (open = false)}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h2>Share {targetName}</h2>
<button class="close-btn" type="button" onclick={() => (open = false)}>&#10005;</button>
</div>
<div class="modal-body">
<div class="modal-body">
<!-- Add people section -->
<div class="add-section">
<span class="section-label">Add people</span>
@@ -388,36 +377,10 @@
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
</Modal>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 50;
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 10vh;
}
.modal {
width: 100%;
max-width: 480px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
overflow: hidden;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
+26 -96
View File
@@ -15,9 +15,8 @@
PLAN-1542 / TASK-1550.
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { tick } from 'svelte';
import type { AdminUser } from '$lib/stores/admin.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import UserSettingsForm from './UserSettingsForm.svelte';
import UserWorkspacesTab from './UserWorkspacesTab.svelte';
import UserOverviewTab from './UserOverviewTab.svelte';
@@ -44,11 +43,6 @@
// at first render, which would lock it).
let activeTab = $state<UserModalTab>('overview');
// Element refs for focus management.
let modalEl = $state<HTMLDivElement | null>(null);
let closeBtnEl = $state<HTMLButtonElement | null>(null);
let previousFocus: HTMLElement | null = null;
const TABS: { key: UserModalTab; label: string }[] = [
{ key: 'overview', label: 'Overview' },
{ key: 'workspaces', label: 'Workspaces' },
@@ -84,65 +78,24 @@
writeHashTab(t);
}
// Open transition tracker — Svelte 5 $effect re-runs on any read
// dependency change. We only want to capture previousFocus / hydrate
// the tab on the open=false → true transition, not whenever
// initialTab or any other reactive value changes mid-open (Codex
// review on PR #605).
// Open transition tracker — hydrate the active tab on the open=false → true
// transition only, not whenever initialTab or any other reactive value
// changes mid-open (Codex review on PR #605). Focus save/restore, Escape,
// and the Tab focus-trap are now handled by the native <dialog> in <Modal>.
// `wasOpen` is a plain let (not $state) — edge-detection only — and this
// effect writes `activeTab` without reading it, so it can't self-invalidate
// (CONVE-1688).
let wasOpen = false;
$effect(() => {
if (open && !wasOpen) {
previousFocus = (document.activeElement as HTMLElement) ?? null;
const fromHash = parseHashTab();
activeTab = fromHash ?? initialTab ?? 'overview';
tick().then(() => closeBtnEl?.focus());
wasOpen = true;
} else if (!open && wasOpen) {
if (previousFocus && document.contains(previousFocus)) {
previousFocus.focus();
}
previousFocus = null;
wasOpen = false;
}
});
// Keyboard handling — ESC closes; Tab is trapped within the modal so
// focus can't escape into the table behind it.
function handleKeydown(e: KeyboardEvent) {
if (!open) return;
if (e.key === 'Escape') {
e.preventDefault();
closeModal();
return;
}
if (e.key === 'Tab' && modalEl) {
// Filter out elements inside hidden tabpanels — otherwise the
// modal's three off-screen panels (tabindex=0) make `last`
// point at the wrong element and Tab can escape the trap on
// non-Settings tabs (Codex review on PR #605).
const all = modalEl.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
const focusables: HTMLElement[] = [];
for (const el of all) {
if (el.closest('[hidden]')) continue;
if (el.offsetParent === null && el !== modalEl) continue;
focusables.push(el);
}
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
}
function closeModal() {
open = false;
onClose();
@@ -161,32 +114,26 @@
}
});
onMount(() => {});
onDestroy(() => {});
</script>
<svelte:window onkeydown={handleKeydown} />
{#if open && user}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="user-modal-backdrop" onclick={closeModal}>
<div
class="user-modal"
bind:this={modalEl}
role="dialog"
tabindex="-1"
aria-modal="true"
aria-labelledby="user-modal-title"
onclick={(e) => e.stopPropagation()}
>
<Modal
open={open && !!user}
onclose={closeModal}
labelledby="user-modal-title"
placement="center"
maxWidth="720px"
--modal-bg="var(--bg-primary)"
--modal-radius="var(--radius)"
--modal-shadow="0 8px 32px rgba(0, 0, 0, 0.4)"
>
{#if user}
<div class="user-modal">
<header class="user-modal-header">
<h2 id="user-modal-title">
{user.name || user.username || user.email}
{#if user.disabled_at}<span class="badge disabled">disabled</span>{/if}
</h2>
<button
bind:this={closeBtnEl}
type="button"
class="user-modal-close"
aria-label="Close"
@@ -239,35 +186,18 @@
</div>
</div>
</div>
</div>
{/if}
{/if}
</Modal>
<style>
.user-modal-backdrop {
position: fixed;
inset: 0;
background: color-mix(in srgb, #000 50%, transparent);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4, 16px);
}
/* Modal width is a CSS variable so the Workspaces tab (T1552) can
widen to 960px once it has real per-workspace data without forking
the layout for every tab. */
/* Fills the <Modal> dialog box (which owns the surface chrome / max-height).
Kept as a flex column so the header/tabs pin and the body scrolls. */
.user-modal {
--user-modal-width: 720px;
width: 100%;
max-width: var(--user-modal-width);
max-height: 90vh;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.user-modal-header {
@@ -17,6 +17,7 @@
import QuickActionsEditor, { type EditableQuickAction } from './QuickActionsEditor.svelte';
import { placeholderContext, type PreviewContext } from '$lib/utils/quick-action-preview';
import { toastStore } from '$lib/stores/toast.svelte';
import Modal from '$lib/components/common/Modal.svelte';
interface Props {
open: boolean;
@@ -395,32 +396,22 @@
}
</script>
<svelte:window
onkeydown={(e) => {
if (e.key === 'Escape' && open) onclose();
}}
/>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay" onclick={onclose}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
{#if step === 'editor'}
<div class="header-left">
<button class="back-btn" type="button" onclick={goBack} aria-label="Back to templates">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<h2>New Collection</h2>
</div>
{:else}
<h2>New Collection</h2>
{/if}
<button class="close-btn" type="button" onclick={onclose}>&#10005;</button>
<Modal {open} {onclose} labelledby="create-collection-title" maxWidth="520px">
<div class="modal-header">
{#if step === 'editor'}
<div class="header-left">
<button class="back-btn" type="button" onclick={goBack} aria-label="Back to templates">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<h2 id="create-collection-title">New Collection</h2>
</div>
{:else}
<h2 id="create-collection-title">New Collection</h2>
{/if}
<button class="close-btn" type="button" onclick={onclose}>&#10005;</button>
</div>
{#if step === 'templates'}
<div class="modal-body">
@@ -567,51 +558,9 @@
</button>
</div>
{/if}
</div>
</div>
{/if}
</Modal>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 50;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 10vh var(--space-4) var(--space-4);
animation: overlay-in 140ms ease-out;
}
.modal {
width: 100%;
max-width: 520px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
overflow: hidden;
max-height: 80vh;
overflow-y: auto;
animation: modal-in 160ms ease-out;
}
@keyframes overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modal-in {
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* Honor prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
.overlay, .modal { animation: none; }
}
/* -- Header ------------------------------------------------------------ */
.modal-header {
@@ -677,6 +626,11 @@
display: flex;
flex-direction: column;
gap: var(--space-4);
/* The Modal primitive is a fixed-height flex column; the body scrolls
within it (header/footer stay pinned) rather than the whole modal
scrolling as it did before the native-<dialog> migration. */
overflow-y: auto;
min-height: 0;
}
.error-banner {
@@ -975,16 +929,6 @@
/* ── Responsive ────────────────────────────────────────────────────────── */
@media (max-width: 640px) {
.overlay {
padding: var(--space-3);
align-items: stretch;
}
.modal {
max-width: 100%;
max-height: calc(100vh - var(--space-6));
}
.template-grid {
grid-template-columns: 1fr;
}
@@ -21,6 +21,7 @@
type PreviewContext
} from '$lib/utils/quick-action-preview';
import { toastStore } from '$lib/stores/toast.svelte';
import Modal from '$lib/components/common/Modal.svelte';
interface Props {
open: boolean;
@@ -519,21 +520,11 @@
}
</script>
<svelte:window
onkeydown={(e) => {
if (e.key === 'Escape' && open) onclose();
}}
/>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay" onclick={onclose}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h2>Edit Collection</h2>
<button class="close-btn" type="button" onclick={onclose}>&#10005;</button>
</div>
<Modal {open} {onclose} labelledby="edit-collection-title" maxWidth="680px">
<div class="modal-header">
<h2 id="edit-collection-title">Edit Collection</h2>
<button class="close-btn" type="button" onclick={onclose}>&#10005;</button>
</div>
<div class="tab-bar">
<button
@@ -726,50 +717,9 @@
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</div>
{/if}
</Modal>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 50;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 8vh var(--space-4) var(--space-4);
animation: overlay-in 140ms ease-out;
}
.modal {
width: 100%;
max-width: 680px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
max-height: 82vh;
animation: modal-in 160ms ease-out;
}
@keyframes overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modal-in {
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.overlay, .modal { animation: none; }
}
/* ── Header ─────────────────────────────────────────────────────────────── */
.modal-header {
@@ -1159,16 +1109,6 @@
/* ── Responsive ────────────────────────────────────────────────────────── */
@media (max-width: 640px) {
.overlay {
padding: var(--space-3);
align-items: stretch;
}
.modal {
max-width: 100%;
max-height: calc(100vh - var(--space-6));
}
.modal-header,
.tab-content,
.modal-footer {
+222
View File
@@ -0,0 +1,222 @@
<!--
Modal — shared dialog primitive built on the native <dialog> element and
dialog.showModal() (PLAN-1984 Web-UX audit / TASK-2023).
Why native <dialog>:
- Focus trap, Escape-to-dismiss, and the top-layer (renders above every
stacking context, no z-index juggling) all come for free with showModal().
- We add the two things the platform doesn't: focus SAVE/RESTORE across
open/close, and single-source-of-truth open state driven by the `open`
prop.
State model (CONVE-1688): the open/close effect READS `open` (a prop) and
the DOM's `dialogEl.open`, and WRITES nothing reactive — no $state is both
written and read inside it, so the effect can't self-invalidate. Focus
bookkeeping (`previouslyFocused`) is a plain `let`, never $state.
The dialog is always mounted; visibility is toggled via showModal()/close()
(a not-open <dialog> is display:none via the UA stylesheet). Consumers must
NOT wrap <Modal> in {#if open} — pass `open` and let the primitive drive it.
Inner content is gated on `open` here, so consumers keep their fresh-on-open
reset semantics.
Consumers own their own header/body/footer markup (passed as children) so
each migrated modal keeps its exact styling. Wire `labelledby` to the id of
the heading element inside your children for the aria-labelledby link, or
pass `ariaLabel` when there's no visible heading.
-->
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/** Whether the modal is shown. Single source of truth — drive it from parent state. */
open: boolean;
/** Called when the user dismisses via Escape, backdrop click, or the primitive needs the parent to close. The parent must flip `open` to false. */
onclose: () => void;
/** id of the heading element inside `children` — wired to the dialog's aria-labelledby. */
labelledby?: string;
/** Accessible name when there's no visible heading to point at. */
ariaLabel?: string;
/** Dismiss when the backdrop (area outside the dialog box) is clicked. Default true. */
closeOnBackdrop?: boolean;
/** Max width of the dialog box (any CSS length). Default 480px. */
maxWidth?: string;
/** Vertical placement: 'top' sits it ~10vh from the top (matches most existing modals); 'center' vertically centers. Default 'top'. */
placement?: 'top' | 'center';
/** Extra class(es) appended to the dialog element. */
class?: string;
children: Snippet;
}
let {
open,
onclose,
labelledby,
ariaLabel,
closeOnBackdrop = true,
maxWidth = '480px',
placement = 'top',
class: klass = '',
children
}: Props = $props();
// bind:this target — $state so the open/close effect re-runs once the
// element is mounted. The effect only READS this; it never writes it.
let dialogEl = $state<HTMLDialogElement>();
// Plain variable (NOT $state): focus bookkeeping read/written only inside
// the effect + teardown, never in reactive position.
let previouslyFocused: HTMLElement | null = null;
function restoreFocus() {
if (previouslyFocused && document.contains(previouslyFocused)) {
previouslyFocused.focus();
}
previouslyFocused = null;
}
// Drive the native dialog from the `open` prop. Reads `open` (prop) and
// `el.open` (DOM) only — writes no reactive state, so it can't loop.
$effect(() => {
const el = dialogEl;
if (!el) return;
if (open && !el.open) {
previouslyFocused = (document.activeElement as HTMLElement | null) ?? null;
el.showModal();
} else if (!open && el.open) {
el.close();
restoreFocus();
}
});
// If the component is torn down while open, make sure focus is returned.
$effect(() => {
return () => {
if (dialogEl?.open) {
dialogEl.close();
}
restoreFocus();
};
});
// Escape fires a `cancel` event. Prevent the native close so the parent's
// `open` stays the single source of truth: we ask the parent to close, the
// parent flips `open`, and the effect above performs the actual close +
// focus restore. This keeps focus-restore on ONE path.
function handleCancel(e: Event) {
e.preventDefault();
onclose();
}
// A click whose target is the dialog element itself landed on the backdrop
// (the ::backdrop pseudo dispatches its clicks to the dialog). Clicks on the
// content target descendant nodes, so this cleanly distinguishes the two.
function handleClick(e: MouseEvent) {
if (closeOnBackdrop && e.target === dialogEl) {
onclose();
}
}
</script>
<dialog
bind:this={dialogEl}
class={['modal', klass]}
style:--modal-max-width={maxWidth}
data-placement={placement}
aria-labelledby={labelledby}
aria-label={ariaLabel}
oncancel={handleCancel}
onclick={handleClick}
>
{#if open}
{@render children()}
{/if}
</dialog>
<style>
dialog.modal {
position: fixed;
inset: 0;
margin: auto;
padding: 0;
width: min(var(--modal-max-width, 480px), calc(100vw - 2 * var(--space-4, 16px)));
max-width: var(--modal-max-width, 480px);
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
color: var(--text-primary);
/* Surface tokens are overridable via the `--modal-*` custom properties
(e.g. `<Modal --modal-bg="var(--bg-primary)">`) so migrated modals keep
their exact original surface where it differed from the default. */
background: var(--modal-bg, var(--bg-secondary));
border: var(--modal-border, 1px solid var(--border));
border-radius: var(--modal-radius, var(--radius-lg));
box-shadow: var(--modal-shadow, 0 20px 60px rgba(0, 0, 0, 0.5));
}
/* The dialog is always mounted (visibility driven by showModal()/close()).
Our `display: flex` above ties the UA `dialog:not([open]) { display: none }`
rule on specificity and would win by source order, leaving a CLOSED dialog
rendered as an empty bordered/shadowed box that can intercept clicks. This
higher-specificity rule restores the hidden-when-closed behavior. */
dialog.modal:not([open]) {
display: none;
}
/* Sit near the top (the dominant idiom across the app's modals) rather than
dead-center. margin-inline stays `auto` from the rule above, so horizontal
centering is preserved. */
dialog.modal[data-placement='top'] {
margin-top: 10vh;
margin-bottom: auto;
}
dialog.modal::backdrop {
background: rgba(0, 0, 0, 0.5);
}
dialog.modal[open] {
animation: modal-in 160ms ease-out;
}
dialog.modal[open]::backdrop {
animation: backdrop-in 140ms ease-out;
}
@keyframes modal-in {
from {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
dialog.modal[open],
dialog.modal[open]::backdrop {
animation: none;
}
}
@media (max-width: 640px) {
dialog.modal {
max-height: calc(100vh - var(--space-6, 24px));
}
dialog.modal[data-placement='top'] {
margin-top: var(--space-4, 16px);
}
}
</style>
@@ -3,6 +3,7 @@
import { marked } from 'marked';
import { api, type ImportURLResponse } from '$lib/api/client';
import { toastStore } from '$lib/stores/toast.svelte';
import Modal from '$lib/components/common/Modal.svelte';
// Context the modal observes about the editor at the moment of
// insert. `wasEmpty` mirrors editor.isEmpty BEFORE we splice in
@@ -48,12 +49,6 @@
}
});
function handleKeydown(e: KeyboardEvent) {
if (open && e.key === 'Escape') {
open = false;
}
}
async function handleFetch() {
errorMessage = '';
result = null;
@@ -123,19 +118,22 @@
}
</script>
<svelte:window onkeydown={handleKeydown} />
<Modal
open={open}
onclose={handleCancel}
labelledby="import-url-title"
placement="center"
maxWidth="720px"
--modal-bg="var(--bg-primary)"
--modal-radius="var(--radius)"
--modal-shadow="0 16px 48px rgba(0, 0, 0, 0.4)"
>
<div class="modal-header">
<h2 id="import-url-title">Insert from URL</h2>
<button class="close-btn" type="button" onclick={handleCancel}>&#10005;</button>
</div>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay" onclick={handleCancel}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h2>Insert from URL</h2>
<button class="close-btn" type="button" onclick={handleCancel}>&#10005;</button>
</div>
<div class="modal-body">
<div class="modal-body">
<p class="intro-copy">
Paste a URL. Pad fetches the page server-side and converts the readable
content (or OpenAPI spec) to markdown. Nothing is saved until you click
@@ -198,29 +196,9 @@
Insert at cursor
</button>
</div>
</div>
</div>
{/if}
</Modal>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-primary);
border-radius: var(--radius);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
width: min(720px, 95vw);
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
@@ -6,6 +6,7 @@
import { toastStore } from '$lib/stores/toast.svelte';
import type { Workspace, WorkspaceTemplate } from '$lib/types';
import { groupTemplatesByCategory } from '$lib/utils/templates';
import Modal from '$lib/components/common/Modal.svelte';
interface Props {
/**
@@ -198,15 +199,17 @@
}
</script>
{#if uiStore.createWorkspaceOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={close}></div>
<div class="modal" role="dialog">
<div class="modal-header">
<h2>New Workspace</h2>
<button class="modal-close" onclick={close}>✕</button>
</div>
<Modal
open={uiStore.createWorkspaceOpen}
onclose={close}
labelledby="create-workspace-title"
placement="center"
maxWidth="480px"
>
<div class="modal-header">
<h2 id="create-workspace-title">New Workspace</h2>
<button class="modal-close" onclick={close}>✕</button>
</div>
<div class="modal-tabs">
<button class="tab" class:active={mode === 'create'} onclick={() => mode = 'create'}>Create</button>
@@ -362,38 +365,9 @@
</button>
{/if}
</div>
</div>
{/if}
</Modal>
<style>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
}
.modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 480px;
max-height: 85vh;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
z-index: 201;
display: flex;
flex-direction: column;
overflow: hidden;
animation: modal-in 0.15s ease-out;
}
@keyframes modal-in {
from { opacity: 0; transform: translate(-50%, -48%); }
to { opacity: 1; transform: translate(-50%, -50%); }
}
.modal-header {
display: flex;
align-items: center;