mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
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:
+5
-2
@@ -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)}>✕</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)}>✕</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 →</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;
|
||||
|
||||
@@ -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"
|
||||
>✕</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"
|
||||
>✕</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 {
|
||||
|
||||
@@ -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)}>✕</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)}>✕</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;
|
||||
|
||||
@@ -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}>✕</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}>✕</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}>✕</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}>✕</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 {
|
||||
|
||||
@@ -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}>✕</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}>✕</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;
|
||||
|
||||
Reference in New Issue
Block a user