[2/7] Extract a shared Modal component and migrate all five dialogs - #273
Open
alex-clickhouse wants to merge 2 commits into
Open
[2/7] Extract a shared Modal component and migrate all five dialogs#273alex-clickhouse wants to merge 2 commits into
alex-clickhouse wants to merge 2 commits into
Conversation
This was referenced Aug 5, 2026
There was a problem hiding this comment.
Pull request overview
Introduces a shared Modal UI primitive for the web app and migrates the existing dialogs to use it, consolidating dialog behavior (Escape handling, focus management, scroll lock, and portal rendering) into a single implementation.
Changes:
- Added
web/src/components/ui/Modal.tsximplementing a portal-based dialog with capture-phase Escape handling, focus trap/restore, and scroll locking with refcounting. - Migrated
ShortcutsModal,TaskCreateDialog,TaskStatusManager,SkillsPagecreate dialog, andSkillDetailPagedelete confirmation to the sharedModal. - Added modal entrance animations and
prefers-reduced-motionhandling inweb/src/index.css.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| web/src/components/ui/Modal.tsx | New shared modal primitive (portal, Escape capture, focus trap/restore, scroll lock, sizing, header/footer slots). |
| web/src/components/ShortcutsModal.tsx | Replaced custom overlay/Escape handling with Modal. |
| web/src/components/Tasks/TaskCreateDialog.tsx | Migrated task creation dialog to Modal and moved submit button into modal footer via form association. |
| web/src/components/Tasks/TaskStatusManager.tsx | Migrated status manager to Modal, using footer slot for the pinned add-status UI. |
| web/src/pages/SkillsPage.tsx | Migrated “New Skill” dialog to Modal and disabled backdrop-close to prevent data loss. |
| web/src/pages/SkillDetailPage.tsx | Migrated delete confirmation to Modal. |
| web/src/index.css | Added modal animations plus reduced-motion overrides. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
3 tasks
Five dialogs each hand-rolled the same backdrop. One of them handled Escape, none trapped focus, none locked body scroll, none carried dialog semantics for a screen reader, and two drifted onto a different border and corner radius than the rest. Behaviour every dialog needs belongs in one component. Three details in components/ui/Modal.tsx are load-bearing rather than boilerplate: Escape is handled in the capture phase. App.tsx and ChatPage.tsx install document-level shortcut handlers that also claim Escape — to clear a search box, to stop generation. A bubble-phase listener would fire after them, so dismissing a dialog could also stop a generation running behind it. ShortcutsModal already knew this and had a capture-phase handler with a comment explaining why; that reasoning now applies to all five. Only the topmost dialog reacts. Every open Modal installs a listener, so without a stack one Escape would close a confirmation and the dialog that raised it together. The same refcount keeps an inner dialog from restoring body scroll while an outer one is still open. The backdrop closes on mousedown, not click. A click fires on the backdrop when a drag that *started* inside the panel — selecting text, say — is released outside it, which would throw the dialog away mid-interaction. Also: focus moves into the panel on open and returns to its origin on close, so keyboard navigation doesn't restart from the top of the document; and the two dialogs that hold real typing (new task, new skill) no longer discard it on a stray backdrop click. No behaviour change beyond those fixes. The footer slot takes a layout override because TaskStatusManager's footer is a form, not a button row. Motion follows the existing @Keyframes convention and is dropped under prefers-reduced-motion, along with the transform-based animations already in index.css — the first reduced-motion handling in the app. Verified: tsc -b clean, npm run build clean, eslint unchanged (the 1 error + 5 warnings in these files are all pre-existing). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The lock's cleanup asked modalStack.length whether it was the last dialog out. React runs effect cleanups in declaration order, and the scroll-lock effect is declared before the one that unregisters from the stack — so at cleanup time the closing dialog is still in the stack, the length is never 0, and body overflow stayed 'hidden' forever. Closing any dialog left the page unscrollable. Refcount the lock independently instead of inferring it from the stack, so it no longer depends on the order two effects happen to be declared in. Found by the Modal specs rather than by hand — typecheck and build were both clean, and the page looks normal until you try to scroll it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alex-clickhouse
force-pushed
the
alex-clickhouse/task-board-modal
branch
from
August 5, 2026 12:07
be57e5f to
2dc04a5
Compare
alex-clickhouse
marked this pull request as ready for review
August 5, 2026 12:26
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Groundwork for the task board, but it stands on its own: the board needs a dialog primitive, and the app didn't have one.
Why
Five dialogs each hand-rolled the same backdrop:
role="dialog"ShortcutsModalTaskCreateDialogTaskStatusManagerSkillsPageSkillDetailPageTwo had also drifted onto a different border colour and corner radius than the other three.
Three details that aren't boilerplate
Escape is handled in the capture phase.
App.tsxandChatPage.tsxinstall document-level shortcut handlers that also claim Escape — to clear a search box, to stop generation. A bubble-phase listener fires after those, so dismissing a dialog could also stop a generation running behind it.ShortcutsModalalready knew this and carried a capture-phase handler with a comment explaining why; that reasoning now covers all five.Only the topmost dialog reacts. Every open Modal installs a listener, so without a stack one Escape would close a confirmation and the dialog that raised it. The same refcount stops an inner dialog from restoring body scroll while an outer one is still open.
The backdrop closes on
mousedown, notclick. A click fires on the backdrop when a drag that started inside the panel — selecting text in a textarea — is released outside it. Onclickthat throws the dialog away mid-interaction.Also fixed
prefers-reduced-motionhandling in the app — the new modal animation plus the transform-based ones already inindex.css.Notes for review
footerClassName) becauseTaskStatusManager's footer is a form, not a button row. Everything else uses the default right-aligned row.TaskCreateDialog's submit button sits in the footer but stays wired to the form viaform={id}, so Enter-to-submit still works from any field.SkillsPage/SkillDetailPageunifies them onto the houseborder-border-subtle+rounded-xl— a small deliberate visual change, since a shared component with two looks defeats the point.createPortalin the app; dialogs now render atdocument.bodyinstead of inside whatever happened to contain them.Testing
npx tsc -b— cleannpm run build— cleannpx eslint— unchanged; the 1 error + 5 warnings in these files are all pre-existing and untouchedInteraction behaviour is the whole point of this component and none of it is reachable by a type checker, so it's asserted rather than eyeballed: portal target, ARIA semantics, Escape (including that it does not leak to the app-level handlers), backdrop dismissal, the drag-release-outside case, focus entry/wrap/restore, stacked-dialog Escape isolation, and scroll-lock refcounting.
Writing those specs caught a bug in the first version of this PR, fixed here in
be57e5f:🤖 Generated with Claude Code