Skip to content

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

Merged
xarmian merged 2 commits into
mainfrom
feat/task-2023-dialog-primitive
Jul 9, 2026
Merged

feat(web): shared native-dialog Modal primitive + migrate form modals (TASK-2023)#881
xarmian merged 2 commits into
mainfrom
feat/task-2023-dialog-primitive

Conversation

@xarmian

@xarmian xarmian commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Part of the PLAN-1984 Web-UX audit (24 hand-rolled backdrop modals, only 2 trapped Tab focus, none restored focus, most lacked dialog ARIA).

Adds one shared Modal primitive built on the native <dialog> element + dialog.showModal(), and migrates the form modals onto it.

The primitive — web/src/lib/components/common/Modal.svelte

Native showModal() gives us the focus trap, Escape dismissal, and the top-layer (no z-index juggling) for free. The primitive adds:

  • Focus save/restore — saves document.activeElement on open, restores it on close (incl. teardown-while-open).
  • Single-source-of-truth open — an $effect reads the open prop + the DOM's dialogEl.open and writes no reactive state, so it can't self-invalidate (CONVE-1688). Escape routes through oncancelonclose so the parent owns the state.
  • ARIAaria-labelledby (wired to each modal's heading id) / ariaLabel.
  • Backdrop-click dismiss (closeOnBackdrop, default on), prefers-reduced-motion respected.
  • Overridable surface via --modal-bg / --modal-radius / --modal-shadow CSS vars so modals that differed keep their exact look.

Prop/snippet API: <Modal {open} {onclose} labelledby ariaLabel closeOnBackdrop maxWidth placement={'top'|'center'} class --modal-bg/-radius/-shadow>{children}</Modal> — children carry each modal's own header/body/footer markup.

Migrated (8)

  • ShareDialog, ConnectWorkspaceModal, ImportFromUrlModal, CreateCollectionModal, EditCollectionModal, CreateWorkspaceModal, admin/UserModal, OpenChildrenDialog

Each swap is structural — existing styling, props, and behavior preserved. Removed ~400 lines of duplicated backdrop/overlay boilerplate, per-modal svelte:window Escape handlers, and two hand-rolled focus traps (UserModal, OpenChildrenDialog). app.css print rule extended with dialog[open] so native dialogs stay hidden in print.

Skipped (out of scope / follow-up)

  • Specialized overlays left alone by design: command palette, bottom sheets, image lightbox, attachment-crop, notification panel, emoji picker, quick-actions menu.
  • Route-inline modals (higher regression risk, large files): connected-apps revoke confirm, [collection] unsaved-card leave dialog, graph overlays. The roles page already uses native showModal().

Notes

  • Minor refinement: CreateCollectionModal now scrolls its body within a fixed header/footer (was whole-modal scroll) — a consequence of the flex-column dialog.
  • No component-test harness exists (vitest runs in node, no jsdom/testing-library, and showModal() is unavailable there), so no unit test was added for the primitive.

Test plan

  • npm run check — 0 errors (only pre-existing unrelated warnings)
  • npm run build — succeeds
  • npm run test — 128/128 pass
  • Codex review — CLEAN (caught + fixed one P1: closed always-mounted dialog rendered as an empty box because dialog.modal{display:flex} tied the UA dialog:not([open]){display:none} rule on specificity; fixed with an explicit dialog.modal:not([open]){display:none}).

Refs TASK-2023, PLAN-1984.

@xarmian xarmian merged commit 9664cd6 into main Jul 9, 2026
3 of 4 checks passed
@xarmian xarmian deleted the feat/task-2023-dialog-primitive branch July 9, 2026 19:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant