Skip to content

Replace Radix Dialog/AlertDialog with Tamagui (#582) - #790

Draft
gaidheal1 wants to merge 1 commit into
claude/issue-581-toast-tamaguifrom
claude/toast-tamagui-issue-582-zgkzlf
Draft

Replace Radix Dialog/AlertDialog with Tamagui (#582)#790
gaidheal1 wants to merge 1 commit into
claude/issue-581-toast-tamaguifrom
claude/toast-tamagui-issue-582-zgkzlf

Conversation

@gaidheal1

Copy link
Copy Markdown
Member

Summary

Closes #582 (base branch note below). Ports Modal and AlertDialog off @radix-ui/react-dialog/@radix-ui/react-alert-dialog onto Tamagui's own Dialog/AlertDialog, per #591's decision (full Tamagui) and #578's epic. This is based on claude/issue-581-toast-tamagui rather than development, per the requester's instruction to branch off it (and the issue's own note to sequence #582 after #580/#581).

  • No @radix-ui/react-alert-dialog import remains anywhere, and the package is removed from package.json/lockfile.
  • @radix-ui/react-dialog still remains as a dependency - it's still used by DetailSurface.tsx (the map entity detail card), which is explicitly out of scope: its own docstring says this exact primitive swap is meant to happen in a separate step ("once the project's in-progress Radix → Tamagui migration reaches this component"), and it isn't one of the 5 consumer files Replace Radix Dialog and AlertDialog with a shared overlay primitive #582 lists. Converting it here would expand scope into Map-related code the issue doesn't mention.
  • Public props of both Modal and AlertDialog are unchanged; all consumers (12 files, more than the issue's original 5 - PlayerItemList, SupportFlowModal, TutorialModal, NotesPanel, LogOfflineActivityModal via Modal; ActivityInput, UnifiedTimerHome via AlertDialog; plus TasksPanel/ActivitiesPanel/ProjectsPanel/SkillsPanel/CategoriesPanel transitively via PlayerItemList) work untouched.

Tamagui's Dialog/AlertDialog turned out to be a much closer match to Radix's own API than the earlier PoC (.claude/plans/issue-629-tamagui-poc.md, which only covered Tooltip/Button) suggested was likely - it reuses hand-built FocusScope/Dismissable/RemoveScroll/Portal packages (not @tamagui/popper, which is what had Tooltip's confirmed focus bug), and exposes the same trapFocus/onOpenAutoFocus/onCloseAutoFocus/onEscapeKeyDown/onPointerDownOutside surface Radix's Content does. AlertDialog.Content also has Radix's exact "prevent click-outside" and "default-focus-Cancel" behavior baked in already.

Two integration gaps still needed explicit handling, both found empirically (via actual failing tests, not guessed) rather than assumed from the API surface:

  1. Focus restore without a Trigger. Neither component renders a Dialog.Trigger (Modal is always-open/consumer-mounted, AlertDialog is driven by a controlled open prop) - Tamagui's built-in close-focus-restore is triggerRef.current?.focus(), which always no-ops here, same as it silently did under Radix. Added a shared useReturnFocusOnClose hook (src/components/Overlay/useReturnFocusOnClose.ts) that captures whatever had focus right before the overlay opened - read during render (React's documented "adjust state from a changed prop" pattern, not a ref mutation - this repo's react-hooks/refs lint rule rejects the ref-mutation version) so it runs before FocusScope's own mount effect could steal focus first - and returns an onCloseAutoFocus handler that restores it. Covered by new tests in both Modal.test.tsx and AlertDialog.test.tsx.
  2. asChild + the app's plain Button. Tamagui's Dialog.Close/AlertDialog.Cancel/.Action compose their click-to-close behavior onto an onPress prop, which the app's Button (a plain function component, not RN-style, no forwardRef) never receives as a real DOM onClick - confirmed via a React "unknown event handler onPress" console warning and a failing click test. Replaced with plain Buttons and explicit onClick handlers instead of relying on asChild's composed behavior. Same root cause (no forwardRef) breaks AlertDialog's built-in cancelRef-based auto-focus-on-open, so that's reimplemented via an element id + onOpenAutoFocus instead.

Also found and fixed a duplicate ARIA landmark: Dialog.Portal/AlertDialog.Portal render as a literal <dialog> HTML tag, which carries an implicit dialog role from the browser, stacking with Content's own explicit role="dialog"/"alertdialog" - getByRole('dialog') was finding two elements. Fixed with role="presentation" on both Portals.

Acceptance criteria status

  • No @radix-ui/react-dialog/@radix-ui/react-alert-dialog import in Modal/AlertDialog (package fully removed for alert-dialog; react-dialog stays installed for the out-of-scope DetailSurface)
  • Public props of both components unchanged; consumers untouched
  • Focus trapped while open (Tamagui's FocusScope, trapFocus={context.open}) and restored to the triggering element on close (new useReturnFocusOnClose hook, tested)
  • role="dialog" vs role="alertdialog" preserved, title/description associations intact (aria-labelledby/aria-describedby wired via Tamagui's own context IDs)
  • Escape closes both; click-outside closes Modal but not AlertDialog (AlertDialog.Content already prevents this internally; new test asserts it)
  • AlertDialog's initial focus lands on Cancel (reimplemented via onOpenAutoFocus + id, tested)
  • Background scroll locked while open (Tamagui's RemoveScroll, unchanged from the context.open-gated default)
  • Modal.test.tsx, AlertDialog.test.tsx, SupportFlowModal.test.tsx, TutorialModal.test.tsx all pass (plus the other 8 affected consumer suites)
  • Storybook stories/test:a11y - stories updated (stale Radix references fixed in docs/comments) and the global TamaguiProvider decorator in .storybook/preview.tsx already covers them, but I could not actually run the Storybook/Playwright browser test projects in this sandbox - the pinned Playwright build (chromium_headless_shell-1234) isn't the version pre-installed here (-1194), a pre-existing environment gap unrelated to this diff (confirmed by reproducing the same failure against an unrelated story on the base branch). Please run npm run test:a11y / npm run test:storybook in CI or locally to confirm.
  • Manual keyboard-only and screen-reader verification - not performed; this sandbox has no interactive browser/AT to verify with. Automated coverage above exercises focus trap, focus restore, Escape, click-outside, and Cancel auto-focus, but a real manual pass is still worth doing given this is called out as the highest-a11y-risk swap in the epic.

Test plan

  • npm run test (vitest run --project unit) - full suite green except one pre-existing, unrelated flake (UnifiedTimerHome > selecting a suggestion while unlabelled-running..., confirmed failing identically on the base branch before this change)
  • npx tsc --noEmit - clean
  • npm run lint (eslint src) - clean
  • npm run build:production - builds successfully
  • npm run test:a11y / npm run test:storybook - blocked by the sandbox's Playwright browser version, see above
  • Manual keyboard + screen reader pass

Generated by Claude Code

Ports Modal and AlertDialog off @radix-ui/react-dialog/react-alert-dialog
onto Tamagui's own Dialog/AlertDialog, which reuse the same portal/overlay/
focus-trap/dismissable-layer machinery Radix uses under the hood, so both
components keep their public prop shapes and a11y contract (focus trap,
role="dialog"/"alertdialog", Escape-to-close, click-outside behavior,
scroll lock) unchanged for all 12 consumer files.

Two integration gaps needed explicit handling rather than relying on
Tamagui's defaults, both verified against the real test suite:
- Neither component renders a `Trigger`, so Tamagui's built-in
  triggerRef-based focus-restore-on-close always no-ops; a shared
  useReturnFocusOnClose hook captures the pre-open focus target during
  render (before FocusScope's own mount effect can steal it) and restores
  it via onCloseAutoFocus.
- Tamagui's Dialog.Close/AlertDialog.Cancel/.Action compose their
  auto-close behavior onto an `onPress` prop, which the app's plain
  (non-forwardRef) Button never receives as a real onClick under asChild -
  replaced with plain Buttons and explicit onClick handlers, with
  Cancel's auto-focus reimplemented via id + onOpenAutoFocus since the
  same asChild/ref gap breaks Tamagui's internal cancelRef too.

Also fixes a duplicate ARIA dialog landmark: Tamagui's Portal renders as a
literal <dialog> HTML tag, which carries an implicit "dialog" role
alongside Content's own explicit role - role="presentation" on both
Portals collapses it back to one landmark.

Test suites for Modal/AlertDialog and all 12 consumers now wrap renders in
TamaguiProvider (matching the #580/#581 pattern) and pass unchanged.
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