Skip to content

[2/7] Extract a shared Modal component and migrate all five dialogs - #273

Open
alex-clickhouse wants to merge 2 commits into
alex-clickhouse/task-board-apifrom
alex-clickhouse/task-board-modal
Open

[2/7] Extract a shared Modal component and migrate all five dialogs#273
alex-clickhouse wants to merge 2 commits into
alex-clickhouse/task-board-apifrom
alex-clickhouse/task-board-modal

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #272 — base is alex-clickhouse/task-board-api, so the diff here is only the Modal work. GitHub retargets this to main when #272 merges.

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:

Escape Focus trap Scroll lock role="dialog"
ShortcutsModal
TaskCreateDialog
TaskStatusManager
SkillsPage
SkillDetailPage

Two 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.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 fires after those, so dismissing a dialog could also stop a generation running behind it. ShortcutsModal already 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, not click. A click fires on the backdrop when a drag that started inside the panel — selecting text in a textarea — is released outside it. On click that throws the dialog away mid-interaction.

Also fixed

  • Focus moves into the panel on open and returns to where it came from on close. Previously, dismissing a dialog restarted keyboard navigation at the top of the document.
  • The two dialogs holding real typing (new task, new skill) no longer discard it on a stray backdrop click.
  • First prefers-reduced-motion handling in the app — the new modal animation plus the transform-based ones already in index.css.

Notes for review

  • The footer slot takes a layout override (footerClassName) because TaskStatusManager'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 via form={id}, so Enter-to-submit still works from any field.
  • Migrating SkillsPage/SkillDetailPage unifies them onto the house border-border-subtle + rounded-xl — a small deliberate visual change, since a shared component with two looks defeats the point.
  • First createPortal in the app; dialogs now render at document.body instead of inside whatever happened to contain them.

Testing

Interaction 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:

The scroll lock read modalStack.length to decide if it was the last dialog out, but React runs effect cleanups in declaration order — so the closing dialog was still in the stack, the count never reached 0, and overflow: hidden was never lifted. Closing any dialog left the page unscrollable. tsc and vite build were both clean, and the page looks entirely normal until you try to scroll.

🤖 Generated with Claude Code

@alex-clickhouse
alex-clickhouse requested a review from Copilot August 5, 2026 10:38
@alex-clickhouse alex-clickhouse changed the title Extract a shared Modal component and migrate all five dialogs [2/6] Extract a shared Modal component and migrate all five dialogs Aug 5, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.tsx implementing a portal-based dialog with capture-phase Escape handling, focus trap/restore, and scroll locking with refcounting.
  • Migrated ShortcutsModal, TaskCreateDialog, TaskStatusManager, SkillsPage create dialog, and SkillDetailPage delete confirmation to the shared Modal.
  • Added modal entrance animations and prefers-reduced-motion handling in web/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.

Comment thread web/src/components/ui/Modal.tsx
Comment thread web/src/components/ui/Modal.tsx
Comment thread web/src/components/Tasks/TaskCreateDialog.tsx
@alex-clickhouse alex-clickhouse changed the title [2/6] Extract a shared Modal component and migrate all five dialogs [2/7] Extract a shared Modal component and migrate all five dialogs Aug 5, 2026
alex-clickhouse and others added 2 commits August 5, 2026 11:51
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
alex-clickhouse force-pushed the alex-clickhouse/task-board-modal branch from be57e5f to 2dc04a5 Compare August 5, 2026 12:07
@alex-clickhouse
alex-clickhouse marked this pull request as ready for review August 5, 2026 12:26
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.

2 participants