Shared Modal component with focus trap and animation #315

Closed
opened 2026-10-07 20:43:20 -04:00 by Salastil · 3 comments
Owner

There are two scrims (.lightbox-backdrop: z 200, black 0.85, used by 12 components; .modal-scrim: z 60, black 0.6, inset:0 covering the title-bar drag area, used by 5), at least 9 container classes, widths 380–640px, 4 shadows, some centred and some top-aligned. A close X exists only on ProfileCard, RoomSearch, DiscordEvents and Lightbox. No dialog traps focus or restores it on close; several don't move focus in.

Fix: one Modal.tsx: scrim below the title bar, header with title + X, body, footer, scale-and-fade entry/exit, focus moved in, trapped and restored, registered on the Escape layer stack. Migrate ReasonPrompt, ExportDialog, LeaveConfirm, DiscordModal, ForwardPicker, FileDrop, EventSource, ProfileCard, RoomSearch, SpaceRooms, PollComposer, VerificationDialog, the MembershipGate rules dialog and the CallStage screen picker.

Part of the v1.0 UI pass.

There are two scrims (`.lightbox-backdrop`: z 200, black 0.85, used by 12 components; `.modal-scrim`: z 60, black 0.6, `inset:0` covering the title-bar drag area, used by 5), at least 9 container classes, widths 380–640px, 4 shadows, some centred and some top-aligned. A close X exists only on ProfileCard, RoomSearch, DiscordEvents and Lightbox. No dialog traps focus or restores it on close; several don't move focus in. **Fix:** one `Modal.tsx`: scrim below the title bar, header with title + X, body, footer, scale-and-fade entry/exit, focus moved in, trapped and restored, registered on the Escape layer stack. Migrate ReasonPrompt, ExportDialog, LeaveConfirm, DiscordModal, ForwardPicker, FileDrop, EventSource, ProfileCard, RoomSearch, SpaceRooms, PollComposer, VerificationDialog, the MembershipGate rules dialog and the CallStage screen picker. _Part of the v1.0 UI pass._
Salastil added this to the v1.0 milestone 2026-10-07 20:43:20 -04:00
Salastil added the feature label 2026-10-07 20:43:21 -04:00
Author
Owner

Mostly done in 5cb30ce; leaving this open for the rest of the migration.

Before (left) and after (right), the export dialog:

dialogs

Done

  • Focus, for every dialog at once. lib/dialogs.ts watches for anything marked role="dialog" aria-modal="true" (a dozen things are): it takes focus when it opens (its own data-autofocus field first, else its first control), Tab and Shift+Tab stay inside the topmost one, and closing gives focus back to what had it - or to the message box when that is gone (a menu entry that opened the dialog is not there to return to). Checked with real key presses: 12 Tabs and 4 Shift+Tabs never left the dialog, and Escape closed it with focus back in the message box.
  • Modal.tsx, the common frame: heading with icon, title and a close button, Escape on the layer stack (#270), grows in (#283). The reason prompt, the export dialog and the leave confirmation use it.
  • The scrim stops below the title bar (it covered the 44px drag region, so the window could not be moved or closed while a dialog was open): the right-hand picture shows the title bar and its search still reachable.

Not done yet: moving the rest onto Modal.tsx - forward picker, event source, profile card, room search, space rooms, Discord modals and events, poll composer, the membership rules dialog, the screen picker, file drop. They already get the focus handling, the Escape stack and the below-title-bar scrim where they use modal-scrim; what they lack is the shared heading and close button.

Mostly done in [5cb30ce](https://git.salastil.com/Salastil/moho/commit/5cb30cec2426594f57e7b463f8acdde69d9ae05e); leaving this open for the rest of the migration. Before (left) and after (right), the export dialog: ![dialogs](https://git.salastil.com/attachments/82973059-3ddf-42f0-96b6-004ef9a224de) **Done** - **Focus, for every dialog at once.** `lib/dialogs.ts` watches for anything marked `role="dialog" aria-modal="true"` (a dozen things are): it takes focus when it opens (its own `data-autofocus` field first, else its first control), **Tab and Shift+Tab stay inside the topmost one**, and closing gives focus back to what had it - or to the message box when that is gone (a menu entry that opened the dialog is not there to return to). Checked with real key presses: 12 Tabs and 4 Shift+Tabs never left the dialog, and Escape closed it with focus back in the message box. - **`Modal.tsx`, the common frame**: heading with icon, title and a **close button**, Escape on the layer stack (#270), grows in (#283). The reason prompt, the export dialog and the leave confirmation use it. - **The scrim stops below the title bar** (it covered the 44px drag region, so the window could not be moved or closed while a dialog was open): the right-hand picture shows the title bar and its search still reachable. **Not done yet:** moving the rest onto `Modal.tsx` - forward picker, event source, profile card, room search, space rooms, Discord modals and events, poll composer, the membership rules dialog, the screen picker, file drop. They already get the focus handling, the Escape stack and the below-title-bar scrim where they use `modal-scrim`; what they lack is the shared heading and close button.
Author
Owner

Progress: 420a2f7. Leaving this open - three dialogs are not on the frame yet.

Moved onto Modal (scrim below the title bar, one header with its title and an X, scale-and-fade, Escape on the layer stack, focus kept in and handed back): the forward picker, the message source, the bot's form, the poll, space rooms, folder settings, the membership rules, and the screen picker (which had no close button and no Escape - a click outside was its only way out). The events panes (list, create, share) now use the same scrim as every other dialog instead of the heavier lightbox one. A dialog also now focuses its first text box rather than its close button.

Still to do: ProfileCard, RoomSearch and the three events panes keep their own custom headers (an avatar and a name, a search box, buttons with a count) inside the shared scrim; they have an X and Escape and are focus-trapped already, but are not built from Modal itself.

Screen picker - before (left), after (right):

picker

Poll - before (left), after (right):

poll

Progress: [420a2f7](https://git.salastil.com/Salastil/moho/commit/420a2f793c6eaa3988115ad497e3417dc65216c4). Leaving this open - three dialogs are not on the frame yet. Moved onto `Modal` (scrim below the title bar, one header with its title and an X, scale-and-fade, Escape on the layer stack, focus kept in and handed back): the forward picker, the message source, the bot's form, the poll, space rooms, folder settings, the membership rules, and the screen picker (which had no close button and no Escape - a click outside was its only way out). The events panes (list, create, share) now use the same scrim as every other dialog instead of the heavier lightbox one. A dialog also now focuses its first text box rather than its close button. **Still to do:** ProfileCard, RoomSearch and the three events panes keep their own custom headers (an avatar and a name, a search box, buttons with a count) inside the shared scrim; they have an X and Escape and are focus-trapped already, but are not built from `Modal` itself. Screen picker - before (left), after (right): ![picker](https://git.salastil.com/attachments/f2718d9c-3179-4c62-aed9-145930e7aab0) Poll - before (left), after (right): ![poll](https://git.salastil.com/attachments/7ffac6bc-ede9-4945-98b1-d536c058d84b)
Author
Owner

Finished: 6a329c6.

The last five dialogs are on Modal now: the profile card, room search, and the events list, create event and invite friends panes. They keep their own headings - a face and a name, a search box, a count with a button - through a new header prop on Modal, which replaces the title row while the scrim (below the title bar), the scale-and-fade, the Escape stack, and the focus trap and restore stay the shared ones. Their own Escape handlers and the special z-index for the invite pane are gone: the layer stack already gives Escape to whichever is on top (the menu, then the invite pane, then the events list).

Every dialog in the app but the Ctrl+K switcher (a command palette with its own anchoring, deliberately) is built from the one component. Checked in the app with the profile card: it is a role=dialog on the shared frame with its head intact, focus moves inside, Escape closes it and focus returns to the message box. The other four are the same change and are checked by the type system; I did not open them against a live Matrix or Discord account.

No picture: the dialogs look as they did, which is the point.

Finished: [6a329c6](https://git.salastil.com/Salastil/moho/commit/6a329c61e9538b1bfeeabc3d34169adb3612c19d). The last five dialogs are on `Modal` now: the **profile card**, **room search**, and the **events list**, **create event** and **invite friends** panes. They keep their own headings - a face and a name, a search box, a count with a button - through a new `header` prop on `Modal`, which replaces the title row while the scrim (below the title bar), the scale-and-fade, the Escape stack, and the focus trap and restore stay the shared ones. Their own Escape handlers and the special z-index for the invite pane are gone: the layer stack already gives Escape to whichever is on top (the menu, then the invite pane, then the events list). Every dialog in the app but the Ctrl+K switcher (a command palette with its own anchoring, deliberately) is built from the one component. Checked in the app with the profile card: it is a `role=dialog` on the shared frame with its head intact, focus moves inside, Escape closes it and focus returns to the message box. The other four are the same change and are checked by the type system; I did not open them against a live Matrix or Discord account. No picture: the dialogs look as they did, which is the point.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Salastil/moho#315