From 8257493ea379a9d1b8f791ca1e5a1be749bf497c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 16:58:16 +0000 Subject: [PATCH] feat: replace Radix Dialog/AlertDialog with Tamagui (#582) 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 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. --- frontend/package-lock.json | 28 ------ frontend/package.json | 1 - .../ActivitiesPanel/ActivitiesPanel.test.tsx | 14 ++- .../ActivityInput/ActivityInput.test.tsx | 20 ++++- .../AlertDialog/AlertDialog.stories.tsx | 10 +-- .../AlertDialog/AlertDialog.test.tsx | 85 ++++++++++++++++++- .../components/AlertDialog/AlertDialog.tsx | 69 ++++++++++----- .../CategoriesPanel/CategoriesPanel.test.tsx | 20 ++++- .../LogOfflineActivityModal.test.tsx | 14 ++- .../src/components/Modal/Modal.stories.tsx | 10 +-- frontend/src/components/Modal/Modal.test.tsx | 59 +++++++++++-- frontend/src/components/Modal/Modal.tsx | 64 ++++++++++---- .../components/NotesPanel/NotesPanel.test.tsx | 19 ++++- .../Overlay/useReturnFocusOnClose.ts | 34 ++++++++ .../PlayerItemList/PlayerItemList.test.tsx | 19 ++++- .../ProjectsPanel/ProjectsPanel.test.tsx | 20 ++++- .../SkillsPanel/SkillsPanel.test.tsx | 20 ++++- .../SupportFlow/SupportFlowModal.test.tsx | 25 +++++- .../components/TasksPanel/TasksPanel.test.tsx | 22 +++-- .../TutorialModal/TutorialModal.test.tsx | 19 ++++- .../UnifiedTimerHome.test.tsx | 20 ++++- 21 files changed, 484 insertions(+), 108 deletions(-) create mode 100644 frontend/src/components/Overlay/useReturnFocusOnClose.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 34b41cb5..2dc44083 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,6 @@ "version": "0.12.0", "dependencies": { "@radix-ui/react-accordion": "^1.2.15", - "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.19", "@radix-ui/react-popover": "^1.1.18", @@ -2136,33 +2135,6 @@ } } }, - "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.23", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.23.tgz", - "integrity": "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-dialog": "1.1.23", - "@radix-ui/react-primitive": "2.1.10" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", diff --git a/frontend/package.json b/frontend/package.json index f16ffa5d..80c580fa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,7 +24,6 @@ }, "dependencies": { "@radix-ui/react-accordion": "^1.2.15", - "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.19", "@radix-ui/react-popover": "^1.1.18", diff --git a/frontend/src/components/ActivitiesPanel/ActivitiesPanel.test.tsx b/frontend/src/components/ActivitiesPanel/ActivitiesPanel.test.tsx index 99bfd72f..d7f49d77 100644 --- a/frontend/src/components/ActivitiesPanel/ActivitiesPanel.test.tsx +++ b/frontend/src/components/ActivitiesPanel/ActivitiesPanel.test.tsx @@ -1,15 +1,23 @@ import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TamaguiProvider } from "tamagui"; import { TooltipProvider } from "../Tooltip/Tooltip"; import ActivitiesPanel from "./ActivitiesPanel"; +import tamaguiConfig from "../../../tamagui.config"; +// ActivitiesPanel renders Modal via PlayerItemList (#582), which needs a +// TamaguiProvider ancestor - unlike Radix's Dialog.Root, it isn't usable +// standalone. The app root (src/main.tsx) provides this in production; +// tests need their own. function renderActivitiesPanel() { return render( - - - + + + + + ); } diff --git a/frontend/src/components/ActivityInput/ActivityInput.test.tsx b/frontend/src/components/ActivityInput/ActivityInput.test.tsx index a449e0a7..ddfe8a5f 100644 --- a/frontend/src/components/ActivityInput/ActivityInput.test.tsx +++ b/frontend/src/components/ActivityInput/ActivityInput.test.tsx @@ -1,8 +1,26 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render as rtlRender, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TamaguiProvider } from 'tamagui'; import ActivityInput from './ActivityInput'; +import tamaguiConfig from '../../../tamagui.config'; + +// ActivityInput renders AlertDialog (#582), which needs a TamaguiProvider +// ancestor - unlike Radix's AlertDialog.Root, it isn't usable standalone. +// The app root (src/main.tsx) provides this in production; tests need +// their own. +function render(...args: Parameters) { + const [ui, options] = args; + return rtlRender(ui, { + wrapper: ({ children }) => ( + + {children} + + ), + ...options, + }); +} const mockUseGame = vi.fn(); const mockUseSupportFlow = vi.fn(); diff --git a/frontend/src/components/AlertDialog/AlertDialog.stories.tsx b/frontend/src/components/AlertDialog/AlertDialog.stories.tsx index 87559782..2f2e0c09 100644 --- a/frontend/src/components/AlertDialog/AlertDialog.stories.tsx +++ b/frontend/src/components/AlertDialog/AlertDialog.stories.tsx @@ -4,15 +4,15 @@ import AlertDialog from './AlertDialog'; /** * `AlertDialog` interrupts the user with a confirm/destructive prompt that - * requires an explicit decision before continuing (Radix `AlertDialog` - * under the hood). Use it in place of `window.confirm`. For non-blocking - * informational overlays, use `Modal`. + * requires an explicit decision before continuing (Tamagui `AlertDialog` + * under the hood, #582). Use it in place of `window.confirm`. For + * non-blocking informational overlays, use `Modal`. */ const meta: Meta = { title: 'Shared/AlertDialog', component: AlertDialog, tags: ['autodocs'], - // AlertDialog renders via a Radix Portal into document.body. With inline + // AlertDialog renders via a Tamagui Portal into document.body. With inline // docs rendering that portal escapes the story canvas and covers the whole // docs page, so render each story in its own iframe instead. parameters: { @@ -40,7 +40,7 @@ export const Destructive: Story = { confirmLabel: 'Delete', }, play: async ({ canvasElement }) => { - // AlertDialog renders via a Radix Portal into document.body, outside the canvas. + // AlertDialog renders via a Tamagui Portal into document.body, outside the canvas. const body = within(canvasElement.ownerDocument.body); const dialog = await body.findByRole('alertdialog', { name: 'Delete this activity?' }); await expect(dialog).toBeVisible(); diff --git a/frontend/src/components/AlertDialog/AlertDialog.test.tsx b/frontend/src/components/AlertDialog/AlertDialog.test.tsx index e237b11d..081557f4 100644 --- a/frontend/src/components/AlertDialog/AlertDialog.test.tsx +++ b/frontend/src/components/AlertDialog/AlertDialog.test.tsx @@ -1,7 +1,22 @@ +import { useState } from 'react'; import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { TamaguiProvider } from 'tamagui'; import AlertDialog from './AlertDialog'; +import tamaguiConfig from '../../../tamagui.config'; + +// AlertDialog's underlying Tamagui `AlertDialog` (#582) needs a +// TamaguiProvider ancestor - unlike Radix's AlertDialog.Root, it isn't +// usable standalone. The app root (src/main.tsx) provides this in +// production; tests need their own. +function renderWithProvider(ui: React.ReactElement) { + return render( + + {ui} + + ); +} function renderDialog(overrides: Partial> = {}) { const props = { @@ -12,7 +27,7 @@ function renderDialog(overrides: Partial); + renderWithProvider(); return props; } @@ -57,4 +72,72 @@ describe('AlertDialog', () => { expect(descId).toBeTruthy(); expect(document.getElementById(descId!)).toHaveTextContent('Are you sure you want to proceed?'); }); + + it('focuses the Cancel button when opened, not the confirm action', async () => { + renderDialog({ confirmLabel: 'Delete', variant: 'destructive' }); + await vi.waitFor(() => { + expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus(); + }); + }); + + it('does not close when clicking outside the dialog', async () => { + // The modal dismissable layer disables pointer events on the rest of + // the page while open (real browser behaviour, not a test artefact) - + // skip userEvent's pointer-events guard so the click still dispatches, + // to assert it's a no-op rather than that it's unreachable. + const user = userEvent.setup({ pointerEventsCheck: 0 }); + const onCancel = vi.fn(); + + function Harness() { + return ( + <> + + {}} + onCancel={onCancel} + /> + + ); + } + + renderWithProvider(); + await user.click(screen.getByRole('button', { name: 'Outside' })); + + expect(onCancel).not.toHaveBeenCalled(); + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + }); + + it('restores focus to the previously focused element on close', async () => { + const user = userEvent.setup(); + + function Harness() { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)} + onCancel={() => setOpen(false)} + /> + + ); + } + + renderWithProvider(); + + const openButton = screen.getByRole('button', { name: 'Open' }); + openButton.focus(); + await user.click(openButton); + + const cancelButton = await screen.findByRole('button', { name: 'Cancel' }); + await user.click(cancelButton); + + expect(openButton).toHaveFocus(); + }); }); diff --git a/frontend/src/components/AlertDialog/AlertDialog.tsx b/frontend/src/components/AlertDialog/AlertDialog.tsx index d5413777..0a05b09d 100644 --- a/frontend/src/components/AlertDialog/AlertDialog.tsx +++ b/frontend/src/components/AlertDialog/AlertDialog.tsx @@ -1,7 +1,15 @@ import React from 'react'; -import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; +import { AlertDialog as AlertDialogPrimitive } from 'tamagui'; import styles from './AlertDialog.module.scss'; import Button from '../Button/Button'; +import { useReturnFocusOnClose } from '../Overlay/useReturnFocusOnClose'; + +// Tamagui's AlertDialog.Cancel default-focuses itself on open via an +// internal `cancelRef`, which `Cancel asChild` composes onto its child - but +// that only works if the child forwards refs. Our app-wide `Button` is a +// plain function component (no forwardRef), so that ref chain silently +// stays null. Focusing by id below sidesteps it entirely. +const CANCEL_BUTTON_ID = 'alert-dialog-cancel-button'; interface AlertDialogProps { open: boolean; @@ -28,11 +36,29 @@ export default function AlertDialog({ onCancel, variant = 'default', }: AlertDialogProps) { + const onCloseAutoFocus = useReturnFocusOnClose(open); + return ( - { if (!o) onCancel(); }}> - - - + { if (!o) onCancel(); }}> + {/* + role="presentation" strips the implicit ARIA `dialog` role the + browser assigns to Portal's underlying HTML tag - without + it, this wrapper and Content below (which sets the real + `role="alertdialog"`, wired to the actual title/description) both + expose as dialog-family landmarks, so `getByRole('alertdialog')`/ + assistive tech see two nested dialogs for what's semantically one. + */} + + + { + event.preventDefault(); + document.getElementById(CANCEL_BUTTON_ID)?.focus(); + }} + > {title} @@ -40,22 +66,27 @@ export default function AlertDialog({ {description}
- - - - - - + {/* + Plain Buttons with explicit onClick handlers, not + `AlertDialog.Cancel`/`.Action` asChild - both compose their + click behaviour onto Tamagui's `onPress` prop, which our + app-wide `Button` (a plain function component, not RN-style) + never receives as a real DOM `onClick`, so it silently + wouldn't fire through asChild. `onOpenChange`/Escape/outside + -click still route through the Root's own onOpenChange above. + */} + +
-
+ ); } diff --git a/frontend/src/components/CategoriesPanel/CategoriesPanel.test.tsx b/frontend/src/components/CategoriesPanel/CategoriesPanel.test.tsx index bad828fe..302c016e 100644 --- a/frontend/src/components/CategoriesPanel/CategoriesPanel.test.tsx +++ b/frontend/src/components/CategoriesPanel/CategoriesPanel.test.tsx @@ -1,8 +1,26 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; +import { render as rtlRender, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TamaguiProvider } from "tamagui"; import CategoriesPanel from "./CategoriesPanel"; +import tamaguiConfig from "../../../tamagui.config"; + +// CategoriesPanel renders Modal via PlayerItemList (#582), which needs a +// TamaguiProvider ancestor - unlike Radix's Dialog.Root, it isn't usable +// standalone. The app root (src/main.tsx) provides this in production; +// tests need their own. +function render(...args: Parameters) { + const [ui, options] = args; + return rtlRender(ui, { + wrapper: ({ children }) => ( + + {children} + + ), + ...options, + }); +} const mockUseCategories = vi.fn(); const mockUseCreateCategory = vi.fn(); diff --git a/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.test.tsx b/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.test.tsx index b8e3763b..ddbff6e3 100644 --- a/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.test.tsx +++ b/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.test.tsx @@ -1,9 +1,11 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TamaguiProvider } from "tamagui"; import { TooltipProvider } from "../Tooltip/Tooltip"; import LogOfflineActivityModal from "./LogOfflineActivityModal"; +import tamaguiConfig from "../../../tamagui.config"; const logMutate = vi.fn(); const fetchPlayerAndCharacter = vi.fn(); @@ -37,11 +39,17 @@ vi.mock("../EntitySearchInput/EntitySearchInput", () => ({ ), })); +// LogOfflineActivityModal renders Modal (#582), which needs a +// TamaguiProvider ancestor - unlike Radix's Dialog.Root, it isn't usable +// standalone. The app root (src/main.tsx) provides this in production; +// tests need their own. function renderModal(onClose = vi.fn()) { return render( - - - + + + + + ); } diff --git a/frontend/src/components/Modal/Modal.stories.tsx b/frontend/src/components/Modal/Modal.stories.tsx index 385cc7a6..e11df371 100644 --- a/frontend/src/components/Modal/Modal.stories.tsx +++ b/frontend/src/components/Modal/Modal.stories.tsx @@ -4,10 +4,10 @@ import Modal from './Modal'; import Button from '../Button/Button'; /** - * `Modal` is an always-open Radix `Dialog` intended to be conditionally - * mounted by the parent (mount when shown, unmount on `onClose`). Use it for - * informational overlays; for confirm/destructive prompts use `AlertDialog` - * instead. + * `Modal` is an always-open Tamagui `Dialog` (#582) intended to be + * conditionally mounted by the parent (mount when shown, unmount on + * `onClose`). Use it for informational overlays; for confirm/destructive + * prompts use `AlertDialog` instead. */ const meta: Meta = { title: 'Shared/Modal', @@ -24,7 +24,7 @@ type Story = StoryObj; export const Default: Story = { play: async ({ canvasElement }) => { - // Modal renders via a Radix Portal into document.body, outside the canvas. + // Modal renders via a Tamagui Portal into document.body, outside the canvas. const body = within(canvasElement.ownerDocument.body); const dialog = await body.findByRole('dialog', { name: 'Modal title' }); await expect(dialog).toBeVisible(); diff --git a/frontend/src/components/Modal/Modal.test.tsx b/frontend/src/components/Modal/Modal.test.tsx index f9889767..9be2cd11 100644 --- a/frontend/src/components/Modal/Modal.test.tsx +++ b/frontend/src/components/Modal/Modal.test.tsx @@ -1,11 +1,25 @@ +import { useState } from 'react'; import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { TamaguiProvider } from 'tamagui'; import Modal from './Modal'; +import tamaguiConfig from '../../../tamagui.config'; + +// Modal's underlying Tamagui `Dialog` (#582) needs a TamaguiProvider +// ancestor - unlike Radix's Dialog.Root, it isn't usable standalone. The +// app root (src/main.tsx) provides this in production; tests need their own. +function renderModal(ui: React.ReactElement) { + return render( + + {ui} + + ); +} describe('Modal', () => { it('renders modal with title and children', () => { - render( + renderModal( {}}>

Modal content

@@ -16,7 +30,7 @@ describe('Modal', () => { }); it('renders close button with correct aria-label', () => { - render( + renderModal( {}}>

Content

@@ -30,7 +44,7 @@ describe('Modal', () => { const user = userEvent.setup(); const handleClose = vi.fn(); - render( + renderModal(

Content

@@ -46,7 +60,7 @@ describe('Modal', () => { const user = userEvent.setup(); const handleClose = vi.fn(); - render( + renderModal(

Content

@@ -61,7 +75,7 @@ describe('Modal', () => { const user = userEvent.setup(); const handleClose = vi.fn(); - render( + renderModal(

Content

@@ -77,7 +91,7 @@ describe('Modal', () => { const user = userEvent.setup(); const handleClose = vi.fn(); - render( + renderModal(

Content

@@ -92,7 +106,7 @@ describe('Modal', () => { it('works when onClose is not provided', async () => { const user = userEvent.setup(); - render( + renderModal(

Content

@@ -104,7 +118,7 @@ describe('Modal', () => { }); it('renders multiple children correctly', () => { - render( + renderModal( {}}>

First paragraph

Second paragraph

@@ -116,4 +130,33 @@ describe('Modal', () => { expect(screen.getByText('Second paragraph')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Action Button' })).toBeInTheDocument(); }); + + it('restores focus to the previously focused element on close', async () => { + const user = userEvent.setup(); + + function Harness() { + const [open, setOpen] = useState(false); + return ( + <> + + {open && ( + setOpen(false)}> +

Content

+
+ )} + + ); + } + + renderModal(); + + const openButton = screen.getByRole('button', { name: 'Open' }); + openButton.focus(); + await user.click(openButton); + + const closeButton = await screen.findByLabelText('Close modal'); + await user.click(closeButton); + + expect(openButton).toHaveFocus(); + }); }); diff --git a/frontend/src/components/Modal/Modal.tsx b/frontend/src/components/Modal/Modal.tsx index a0bf0c00..c29e145e 100644 --- a/frontend/src/components/Modal/Modal.tsx +++ b/frontend/src/components/Modal/Modal.tsx @@ -1,7 +1,8 @@ import React from "react"; -import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { Dialog } from "tamagui"; import styles from "./Modal.module.scss"; import Button from "../Button/Button"; +import { useReturnFocusOnClose } from "../Overlay/useReturnFocusOnClose"; interface ModalProps { title?: string; @@ -28,14 +29,29 @@ export default function Modal({ className, style, }: ModalProps) { + // Modal is always-open by design (the consumer mounts/unmounts it) rather + // than exposing an `open` prop, so `open` is always true here - the return + // point for this hook is simply "whatever had focus when Modal mounted". + const onCloseAutoFocus = useReturnFocusOnClose(true); + return ( - { if (!open) onClose?.(); }}> - - - { if (!open) onClose?.(); }}> + {/* + role="presentation" strips the implicit ARIA `dialog` role the + browser assigns to Portal's underlying HTML tag - without + it, this wrapper and Content below (which sets the real + `role="dialog"`, wired to the actual title/description) both expose + as "dialog" landmarks, so `getByRole('dialog')`/assistive tech see + two nested dialogs for what's semantically one. + */} + + +
{onBack ? ( @@ -47,17 +63,29 @@ export default function Modal({ ← {backLabel} ) : null} - + {/* asChild wraps our own literal

(needed for the + `.modalHeader h2` selector) rather than letting Title render + its own tag - `unstyled` is dropped here since it has no + variant to cancel on DialogTitleFrame and, under asChild, + leaks through as a literal (invalid) DOM attribute instead + of being consumed. */} +

{title}

- - - - +
+ {/* + Plain Button with an explicit onClick, not `Dialog.Close + asChild` - Close composes its auto-close via Tamagui's `onPress` + prop, which our app-wide `Button` (a plain function component, + not RN-style) never receives as a real DOM `onClick`, so the + close-on-click behaviour silently wouldn't fire through asChild. + */} +

{children} @@ -67,8 +95,8 @@ export default function Modal({ {footer}
)} -
-
-
+ + +
); } diff --git a/frontend/src/components/NotesPanel/NotesPanel.test.tsx b/frontend/src/components/NotesPanel/NotesPanel.test.tsx index 3e068266..715faaab 100644 --- a/frontend/src/components/NotesPanel/NotesPanel.test.tsx +++ b/frontend/src/components/NotesPanel/NotesPanel.test.tsx @@ -1,8 +1,25 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; +import { render as rtlRender, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TamaguiProvider } from "tamagui"; import NotesPanel from "./NotesPanel"; +import tamaguiConfig from "../../../tamagui.config"; + +// NotesPanel renders Modal (#582), which needs a TamaguiProvider ancestor - +// unlike Radix's Dialog.Root, it isn't usable standalone. The app root +// (src/main.tsx) provides this in production; tests need their own. +function render(...args: Parameters) { + const [ui, options] = args; + return rtlRender(ui, { + wrapper: ({ children }) => ( + + {children} + + ), + ...options, + }); +} const mockUseNotes = vi.fn(); const mockUseCreateNote = vi.fn(); diff --git a/frontend/src/components/Overlay/useReturnFocusOnClose.ts b/frontend/src/components/Overlay/useReturnFocusOnClose.ts new file mode 100644 index 00000000..3231ad8a --- /dev/null +++ b/frontend/src/components/Overlay/useReturnFocusOnClose.ts @@ -0,0 +1,34 @@ +import { useState } from 'react'; + +/** + * Tamagui's Dialog/AlertDialog restore focus to a `Trigger` element when the + * overlay closes - but Modal and AlertDialog have no `Trigger` in their tree + * (they're driven by mount and by a controlled `open` prop instead, see + * #582), so `context.triggerRef` is always empty and Tamagui's own restore + * silently no-ops. + * + * This captures whatever had focus right before the overlay opened, and + * returns an `onCloseAutoFocus` handler that restores it there instead. + * Captured via the "adjust state during render" pattern (react.dev's + * documented way to derive state from a changing prop), not a ref mutation + * or an effect - it needs to run before FocusScope's own mount-time + * auto-focus (a descendant effect that would otherwise steal focus first), + * and effects run children-before-parents, so an effect here would already + * be too late. + */ +export function useReturnFocusOnClose(open: boolean) { + const [wasOpen, setWasOpen] = useState(false); + const [returnFocusTarget, setReturnFocusTarget] = useState(null); + + if (open && !wasOpen) { + setWasOpen(true); + setReturnFocusTarget((document.activeElement as HTMLElement | null) ?? null); + } else if (!open && wasOpen) { + setWasOpen(false); + } + + return function onCloseAutoFocus(event: Event) { + event.preventDefault(); + returnFocusTarget?.focus?.(); + }; +} diff --git a/frontend/src/components/PlayerItemList/PlayerItemList.test.tsx b/frontend/src/components/PlayerItemList/PlayerItemList.test.tsx index 6142c2df..41dc824b 100644 --- a/frontend/src/components/PlayerItemList/PlayerItemList.test.tsx +++ b/frontend/src/components/PlayerItemList/PlayerItemList.test.tsx @@ -1,8 +1,25 @@ -import { render, screen, within } from "@testing-library/react"; +import { render as rtlRender, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; +import { TamaguiProvider } from "tamagui"; import PlayerItemList from "./PlayerItemList"; +import tamaguiConfig from "../../../tamagui.config"; + +// PlayerItemList renders Modal (#582), which needs a TamaguiProvider +// ancestor - unlike Radix's Dialog.Root, it isn't usable standalone. The +// app root (src/main.tsx) provides this in production; tests need their own. +function render(...args: Parameters) { + const [ui, options] = args; + return rtlRender(ui, { + wrapper: ({ children }) => ( + + {children} + + ), + ...options, + }); +} describe("PlayerItemList", () => { const items = [ diff --git a/frontend/src/components/ProjectsPanel/ProjectsPanel.test.tsx b/frontend/src/components/ProjectsPanel/ProjectsPanel.test.tsx index be0a916f..7b598e9c 100644 --- a/frontend/src/components/ProjectsPanel/ProjectsPanel.test.tsx +++ b/frontend/src/components/ProjectsPanel/ProjectsPanel.test.tsx @@ -1,8 +1,26 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; +import { render as rtlRender, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TamaguiProvider } from "tamagui"; import ProjectsPanel from "./ProjectsPanel"; +import tamaguiConfig from "../../../tamagui.config"; + +// ProjectsPanel renders Modal via PlayerItemList (#582), which needs a +// TamaguiProvider ancestor - unlike Radix's Dialog.Root, it isn't usable +// standalone. The app root (src/main.tsx) provides this in production; +// tests need their own. +function render(...args: Parameters) { + const [ui, options] = args; + return rtlRender(ui, { + wrapper: ({ children }) => ( + + {children} + + ), + ...options, + }); +} const mockUseProjects = vi.fn(); const mockUseCreateProject = vi.fn(); diff --git a/frontend/src/components/SkillsPanel/SkillsPanel.test.tsx b/frontend/src/components/SkillsPanel/SkillsPanel.test.tsx index a927c541..51a5a455 100644 --- a/frontend/src/components/SkillsPanel/SkillsPanel.test.tsx +++ b/frontend/src/components/SkillsPanel/SkillsPanel.test.tsx @@ -1,8 +1,26 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; +import { render as rtlRender, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TamaguiProvider } from "tamagui"; import SkillsPanel from "./SkillsPanel"; +import tamaguiConfig from "../../../tamagui.config"; + +// SkillsPanel renders Modal via PlayerItemList (#582), which needs a +// TamaguiProvider ancestor - unlike Radix's Dialog.Root, it isn't usable +// standalone. The app root (src/main.tsx) provides this in production; +// tests need their own. +function render(...args: Parameters) { + const [ui, options] = args; + return rtlRender(ui, { + wrapper: ({ children }) => ( + + {children} + + ), + ...options, + }); +} const mockUseSkills = vi.fn(); const mockUseCreateSkill = vi.fn(); diff --git a/frontend/src/components/SupportFlow/SupportFlowModal.test.tsx b/frontend/src/components/SupportFlow/SupportFlowModal.test.tsx index 7651e19a..e894b0d1 100644 --- a/frontend/src/components/SupportFlow/SupportFlowModal.test.tsx +++ b/frontend/src/components/SupportFlow/SupportFlowModal.test.tsx @@ -1,12 +1,29 @@ // SupportFlow/SupportFlowModal.test.tsx import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render as rtlRender, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useReducer } from "react"; import type { Dispatch } from "react"; +import { TamaguiProvider } from "tamagui"; import SupportFlowModal from "./SupportFlowModal"; import type { FlowState } from "./SupportFlowModal"; import { supportFlowReducer } from "./supportFlowReducer"; +import tamaguiConfig from "../../../tamagui.config"; + +// SupportFlowModal renders Modal (#582), which needs a TamaguiProvider +// ancestor - unlike Radix's Dialog.Root, it isn't usable standalone. The +// app root (src/main.tsx) provides this in production; tests need their own. +function render(...args: Parameters) { + const [ui, options] = args; + return rtlRender(ui, { + wrapper: ({ children }) => ( + + {children} + + ), + ...options, + }); +} const mockUseGame = vi.fn(); @@ -61,14 +78,16 @@ function Fixture({ describe("SupportFlowModal", () => { it("renders nothing when modal is closed", () => { const state: FlowState = { isOpen: false }; - const { container } = render( + render( {}} onConfirmActivity={() => {}} /> ); - expect(container.firstChild).toBeNull(); + // Not container.firstChild - the TamaguiProvider test wrapper always + // renders its own wrapping element, closed or not. + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); it("opens welcome message screen", async () => { diff --git a/frontend/src/components/TasksPanel/TasksPanel.test.tsx b/frontend/src/components/TasksPanel/TasksPanel.test.tsx index a503b5cf..8b6e1358 100644 --- a/frontend/src/components/TasksPanel/TasksPanel.test.tsx +++ b/frontend/src/components/TasksPanel/TasksPanel.test.tsx @@ -2,15 +2,23 @@ import type { ComponentProps } from "react"; import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TamaguiProvider } from "tamagui"; import { TooltipProvider } from "../Tooltip/Tooltip"; import TasksPanel from "./TasksPanel"; +import tamaguiConfig from "../../../tamagui.config"; +// TasksPanel renders Modal via PlayerItemList (#582), which needs a +// TamaguiProvider ancestor - unlike Radix's Dialog.Root, it isn't usable +// standalone. The app root (src/main.tsx) provides this in production; +// tests need their own. function renderTasksPanel(props: ComponentProps = {}) { return render( - - - + + + + + ); } @@ -403,9 +411,11 @@ describe("TasksPanel", () => { data: [parentTask, newSubtask], }); rerender( - - - , + + + + + , ); const reopenedDialog = await screen.findByRole("dialog"); diff --git a/frontend/src/components/TutorialModal/TutorialModal.test.tsx b/frontend/src/components/TutorialModal/TutorialModal.test.tsx index e9b4fb6d..0ec5cd9a 100644 --- a/frontend/src/components/TutorialModal/TutorialModal.test.tsx +++ b/frontend/src/components/TutorialModal/TutorialModal.test.tsx @@ -1,6 +1,23 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render as rtlRender, screen, fireEvent, waitFor } from '@testing-library/react'; +import { TamaguiProvider } from 'tamagui'; import TutorialModal from './TutorialModal'; +import tamaguiConfig from '../../../tamagui.config'; + +// TutorialModal renders Modal (#582), which needs a TamaguiProvider +// ancestor - unlike Radix's Dialog.Root, it isn't usable standalone. The +// app root (src/main.tsx) provides this in production; tests need their own. +function render(...args: Parameters) { + const [ui, options] = args; + return rtlRender(ui, { + wrapper: ({ children }) => ( + + {children} + + ), + ...options, + }); +} const mockUseTutorialSteps = vi.fn(); const mockApiFetch = vi.fn(); diff --git a/frontend/src/components/UnifiedTimerHome/UnifiedTimerHome.test.tsx b/frontend/src/components/UnifiedTimerHome/UnifiedTimerHome.test.tsx index b13dc507..3003f4b4 100644 --- a/frontend/src/components/UnifiedTimerHome/UnifiedTimerHome.test.tsx +++ b/frontend/src/components/UnifiedTimerHome/UnifiedTimerHome.test.tsx @@ -1,9 +1,27 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render as rtlRender, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TamaguiProvider } from 'tamagui'; import UnifiedTimerHome from './UnifiedTimerHome'; +import tamaguiConfig from '../../../tamagui.config'; + +// UnifiedTimerHome renders AlertDialog (#582), which needs a +// TamaguiProvider ancestor - unlike Radix's AlertDialog.Root, it isn't +// usable standalone. The app root (src/main.tsx) provides this in +// production; tests need their own. +function render(...args: Parameters) { + const [ui, options] = args; + return rtlRender(ui, { + wrapper: ({ children }) => ( + + {children} + + ), + ...options, + }); +} const mockUseGame = vi.fn(); const mockUseSupportFlow = vi.fn();