Skip to content

refactor(website): unify modals onto BaseDialog, drop MUI modal and react-confirm-alert#6780

Open
corneliusroemer wants to merge 8 commits into
mainfrom
unify-modal
Open

refactor(website): unify modals onto BaseDialog, drop MUI modal and react-confirm-alert#6780
corneliusroemer wants to merge 8 commits into
mainfrom
unify-modal

Conversation

@corneliusroemer

@corneliusroemer corneliusroemer commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Relates to #1548 (removing a MUI component)
Fixes #6744 (make background behind modal inert to keyboard navigation)

Summary

The website had four different modal/dialog mechanisms and three dependencies powering them:

Mechanism Used by Dismissible on backdrop click?
BaseDialog (Headless UI) ~10 call sites yes
Modal (@mui/material/Modal) SeqSet create form yes
confirmAlert (react-confirm-alert) + ModalBox confirmation dialogs configurable
hand-rolled native <dialog> + ModalBox edit data use terms no

This PR consolidates everything onto a single modal component, BaseDialog, and removes the two extra dependencies (@mui/material/Modal usage and react-confirm-alert).

Testing

Clicked through the various changed modals. All seem to behave well. The keyboard navigation works great with background inert.

Detailed Claude generated description

What changed

One unified modal: BaseDialog

  • Added a dismissible prop (default true). When false, backdrop clicks and Escape no longer close the dialog — it can only be closed via the close button or an explicit action. This folds the old "sticky Modal" behaviour into a single prop, and the component documents when to use each mode:
    • default (dismissible) — lightweight, informational modals where an accidental close is harmless (details, citations).
    • dismissible={false} — confirmations and involved modals (forms) where the user might lose significant state to an accidental backdrop click.

Migrated every other mechanism onto it

  • The MUI-based Modal (only used by the SeqSet create form) → <BaseDialog dismissible={false}>; deleted common/Modal.tsx.
  • EditDataUseTermsButton (hand-rolled native <dialog> + ModalBox) → <BaseDialog dismissible={false}>.
  • RevokeButton (confirmAlert + ModalBox, has a reason input) → declarative <BaseDialog dismissible={false}> with local state.

Confirmations are now declarative

  • Replaced the imperative displayConfirmationDialog / react-confirm-alert with a useConfirmDialog() hook built on BaseDialog. It returns { confirm, confirmDialog }: render confirmDialog once and call confirm({ dialogText, onConfirmation, ... }) from handlers. One hook instance per component covers any number of confirm actions (only one shows at a time).
  • Migrated all 11 call sites across ReviewPage, GroupPage, EditPage, DataUploadForm, SeqSetItem, SeqSetItemActions.

Cleanup

  • Deleted now-unused common/Modal.tsx and common/ModalBox.tsx.
  • Removed the react-confirm-alert dependency and its .react-confirm-alert-overlay CSS.

Scroll fix

  • When title='', BaseDialog previously rendered an empty <h3> (leading-6 + mb-4), adding ~2.5rem of dead vertical space that could push the panel past the viewport and cause a small spurious scroll. It now uses sr-only instead of mb-4 when there is no title, so it takes no layout space while keeping the DialogTitle element for accessibility.

Note: @mui/material itself is not fully removed — Pagination still uses it (SearchPagination, SeqSetList, SeqSetItem). Dropping MUI entirely is a separate follow-up.

Behaviour change worth flagging

The generic confirmation dialog is now not dismissible by backdrop click (it was closeOnClickOutside: true before), matching the principle that confirmations shouldn't be lost accidentally. Users still cancel via the Cancel or close (✕) button.

Testing

  • npm run check-types: clean (in website/src).
  • eslint on src: clean.
  • prettier: clean.
  • Unit tests: 683 passed.
  • Manually verified against the main preview backend on localhost (SeqSet create modal, revoke flow, confirmations) — including that the scroll glitch is gone.
  • Playwright integration tests drive these dialogs via button text / dialog text / input placeholder (Confirm, Discard, Approve, Continue under Open terms, Revoke this sequence, "Are you sure you want to revoke this sequence?", "Enter reason for revocation"), all of which are preserved.

🤖 Generated with Claude Code

🚀 Preview: Add preview label to enable

corneliusroemer and others added 2 commits June 29, 2026 12:08
…eact-confirm-alert

Consolidate all modal/dialog mechanisms onto a single component, `BaseDialog`,
and remove the two extra dependencies they relied on.

- Add a `dismissible` prop to `BaseDialog` (default true). When false, backdrop
  clicks and Escape no longer close the dialog (it can still be closed via the
  close button or an explicit action). This replaces the separate "sticky"
  Modal behavior. Documented when to use each mode.
- Replace the MUI-based `Modal` (only used by the SeqSet create form) with
  `<BaseDialog dismissible={false}>` and delete `common/Modal.tsx`.
- Migrate `EditDataUseTermsButton` (hand-rolled native <dialog> + ModalBox) and
  `RevokeButton` (confirmAlert + ModalBox) to `<BaseDialog dismissible={false}>`.
- Replace the imperative `displayConfirmationDialog` / react-confirm-alert with a
  declarative `useConfirmDialog()` hook built on BaseDialog, and migrate all
  call sites (ReviewPage, GroupPage, EditPage, DataUploadForm, SeqSetItem,
  SeqSetItemActions).
- Delete now-unused `common/ModalBox.tsx`, remove the `react-confirm-alert`
  dependency and its overlay CSS.

Button labels, dialog texts and the revocation reason input are unchanged so the
existing Playwright integration tests keep passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When `title=''`, BaseDialog still rendered an empty `<h3>` (leading-6 + mb-4),
adding ~2.5rem of dead vertical space that could push the panel past the
viewport and cause a small scroll. Apply `sr-only` instead of `mb-4` when there
is no title, so it takes no layout space while keeping the DialogTitle element
for accessibility.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@claude claude Bot added the website Tasks related to the web application label Jun 29, 2026
Comment thread website/src/components/ReviewPage/ReviewPage.tsx Outdated
…matting

Address review feedback: revert the confirmation dialog call sites to the
original imperative `displayConfirmationDialog({...})` pattern instead of the
`useConfirmDialog()` hook, so callers don't have to render a `{confirmDialog}`
element. The dialog is still consolidated onto `BaseDialog` — it is now mounted
imperatively in a transient React root (replacing react-confirm-alert) rather
than via a hook.

Also fix Prettier formatting in RevokeButton.tsx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@corneliusroemer corneliusroemer added the preview Triggers a deployment to argocd label Jun 29, 2026
corneliusroemer and others added 3 commits June 29, 2026 13:45
…ackground

Go back to the declarative `useConfirmDialog()` hook (rendering BaseDialog within
the component tree) instead of mounting it imperatively in a transient React root.

The imperative `createRoot`-on-body approach lost Headless UI's background
inerting, focus trapping and scroll lock: that behavior is coordinated through
React context, so the Dialog must be part of the app's React tree. Mounting it in
a detached root left the rest of the page interactive while a confirmation was
open. Rendering the dialog in-tree restores correct inert/focus behavior.

The RevokeButton Prettier formatting fix from the previous commit is kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The create and edit SeqSet modals wrap the same SeqSetForm but looked different:
the create modal used `max-w-3xl` (~768px) while the edit/export/citations modals
use `min-h-[60vh]` plus a `min-w-[1000px]` spacer (~1000px). Make the create modal
use the same wrapper so they match.

Also set `dismissible={false}` on both form modals: they hold significant
user-entered state, so a backdrop click should not discard it. The edit modal was
previously dismissible, which was inconsistent with the create modal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	website/src/components/Edit/EditPage.tsx
@corneliusroemer corneliusroemer marked this pull request as ready for review June 29, 2026 13:41
Copilot AI review requested due to automatic review settings June 29, 2026 13:41
@claude

claude Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Claude finished @corneliusroemer's task in 2m 27s —— View job


Code Review

  • Gather context and understand the PR
  • Read changed files
  • Review BaseDialog.tsx changes
  • Review ConfirmationDialog.tsx refactor
  • Review migrated components
  • Post inline comments and summary

This is a clean, well-motivated consolidation. Removing two modal mechanisms and two dependencies while preserving correct Headless UI inert/focus-trap behaviour (by keeping dialogs in-tree) is the right call, and the discussion thread confirms the trade-off was thought through.

Two inline comments posted — both minor:

  1. ConfirmationDialog.tsx:41 — the void (async () => { ... })() pattern silently drops promise rejections; if onConfirmation throws, close() is never called and the dialog stays open with no feedback. Worth deciding whether "keep open on error" or "always close" is the intended contract.

  2. BaseDialog.tsx:49 — dialogs passing title='' get an empty DialogTitle that Headless UI uses for aria-labelledby, so screen readers announce an unnamed dialog even when there's a visible <h3> inside. Not blocking, but a follow-up accessibility task.

Everything else looks good: the dismissible prop is well-designed with sensible defaults, the scroll-fix for empty titles is correct, useConfirmDialog correctly returns { confirm, confirmDialog } with a stable useCallback-wrapped confirm, and all 11 migrated call sites follow the declarative pattern consistently.

<Button
variant='primary'
onClick={() =>
void (async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The void (async () => { ... })() pattern discards the promise entirely. If onConfirmation() rejects, the rejection becomes an unhandled promise rejection (potentially logged as an uncaught error in console), and close() is never called — the dialog stays open with no visible feedback.

Callers that handle errors internally (e.g. via toast in onSuccess/onError) are fine, but a caller that throws from onConfirmation will leave the dialog hanging open silently. Worth either documenting this contract or adding a minimal catch:

Suggested change
void (async () => {
onClick={() =>
void (async () => {
try {
await options.onConfirmation();
} finally {
close();
}
})()

Though "always close on confirm click regardless of error" vs "keep open if action failed" is a design decision — just ensure it's intentional.

<DialogTitle as='h3' className='text-2xl font-bold leading-6 text-gray-900 mb-4'>
<DialogTitle
as='h3'
className={`text-2xl font-bold leading-6 text-gray-900 ${title ? 'mb-4' : 'sr-only'}`}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When title='', Headless UI still wires up aria-labelledby on the <Dialog> to point to this DialogTitle element, which will have empty text content. Screen readers will announce the dialog with no name, even though there may be a visible <h3> heading inside.

Dialogs that pass title='' and supply their own heading (like RevokeButton, ConfirmationDialog, EditDataUseTermsButton) would benefit from either:

  • Passing the actual title text to BaseDialog so it appears in DialogTitle, or
  • Keeping title='' but adding aria-label to the wrapping <Dialog> as a fallback.

Not blocking, but worth a follow-up for the dialogs with non-trivial content.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR consolidates the website’s various modal/confirmation implementations onto a single Headless UI-based BaseDialog, removing react-confirm-alert and replacing the one remaining MUI Modal usage. This supports the linked accessibility goal of making background content inert to keyboard navigation while a modal is open.

Changes:

  • Introduces a dismissible option on BaseDialog and migrates remaining modal implementations (MUI Modal, react-confirm-alert, and native <dialog>) to BaseDialog.
  • Replaces imperative confirmation dialogs with a declarative useConfirmDialog() hook and updates call sites accordingly.
  • Removes now-unused modal components/styles and drops the react-confirm-alert dependency.

Reviewed changes

Copilot reviewed 16 out of 17 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
website/src/styles/base.css Removes react-confirm-alert overlay CSS.
website/src/components/User/GroupPage.tsx Migrates group membership confirmations to useConfirmDialog().
website/src/components/Submission/FileUpload/FolderUploadComponent.tsx Migrates discard/replace confirmations to useConfirmDialog() and renders the dialog declaratively.
website/src/components/Submission/DataUploadForm.tsx Migrates “Open terms” confirmation to useConfirmDialog().
website/src/components/SequenceDetailsPage/RevokeButton.tsx Replaces react-confirm-alert revoke dialog with BaseDialog + local state.
website/src/components/SeqSetCitations/SeqSetListActions.tsx Replaces MUI modal wrapper with BaseDialog for SeqSet creation.
website/src/components/SeqSetCitations/SeqSetItemActions.tsx Migrates delete confirmation to useConfirmDialog() and sets edit dialog as non-dismissible.
website/src/components/SeqSetCitations/SeqSetItem.tsx Migrates DOI creation confirmation to useConfirmDialog().
website/src/components/ReviewPage/ReviewPage.tsx Migrates approve/discard confirmations to useConfirmDialog().
website/src/components/Edit/EditPage.tsx Migrates submit confirmation to useConfirmDialog().
website/src/components/DataUseTerms/EditDataUseTermsButton.tsx Replaces native <dialog> usage with BaseDialog + state.
website/src/components/ConfirmationDialog.tsx Replaces imperative confirm API with useConfirmDialog() hook built on BaseDialog.
website/src/components/common/ModalBox.tsx Deletes unused ModalBox component.
website/src/components/common/Modal.tsx Deletes unused MUI-based modal wrapper.
website/src/components/common/BaseDialog.tsx Adds dismissible prop and improves title spacing behavior.
website/package.json Removes react-confirm-alert dependency.
website/package-lock.json Removes react-confirm-alert from lockfile (and related lockfile normalization).
Files not reviewed (1)
  • website/package-lock.json: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +47 to 52
<DialogTitle
as='h3'
className={`text-2xl font-bold leading-6 text-gray-900 ${title ? 'mb-4' : 'sr-only'}`}
>
{title}
</DialogTitle>
Comment on lines +38 to 41
className='min-h-[60vh]'
>
<div className='min-w-[1000px]'></div>
<SeqSetForm clientConfig={clientConfig} accessToken={accessToken} />
Comment on lines +38 to +46
<Button
variant='primary'
onClick={() =>
void (async () => {
await options.onConfirmation();
close();
})()
}
>
theosanderson and others added 2 commits June 29, 2026 16:59
PR to Cornelius's PR
#6780

Replace the useConfirmDialog() hook (which returned { confirm,
confirmDialog } and required every call site to both call confirm() and
render {confirmDialog}) with a module-level store and a single
ConfirmDialogContainer host mounted once in BaseLayout. Call sites now
just import and call confirm() — no element to render, and no risk of an
early return omitting the dialog.

A module-level store rather than React context is required because each
Astro client:* island is an isolated React tree that context cannot
cross. The host renders in-tree (not a detached createRoot), so Headless
UI still makes the background inert and traps focus.

🚀 Preview: https://confirmation.loculus.org

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@corneliusroemer corneliusroemer removed the preview Triggers a deployment to argocd label Jun 30, 2026
} else void addAdditionalFiles();
};

return fileUploadState === undefined || fileUploadState.type === 'awaitingUrls' ? (

@tombch tombch Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think the large diff in this file is not really needed? Its only a few lines that actually required changing right

(otherwise looks good to me)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

website Tasks related to the web application

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Confirmation dialogs/modals should make background greyed out items inert for keyboard navigation

4 participants