Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 0 additions & 35 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
"@radix-ui/react-dropdown-menu": "^2.1.19",
"@radix-ui/react-popover": "^1.1.18",
"@radix-ui/react-tabs": "^1.1.16",
"@radix-ui/react-toast": "^1.2.18",
"@radix-ui/react-tooltip": "^1.2.8",
"@tamagui/config": "^2.7.6",
"@tamagui/core": "^2.7.6",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/Toast/Toast.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
align-items: center;
gap: sp.$spacing-gap;
width: min(96vw, 720px);
/* Required by Radix to be a list */
/* Tamagui's ToastViewport (#581) renders a View, not Radix's <ol> - these
resets are vestigial now but harmless to keep. */
list-style: none;
padding: 0;
margin: 0;
Expand Down
14 changes: 8 additions & 6 deletions frontend/src/components/Toast/ToastManager.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
import * as RadixToast from '@radix-ui/react-toast';
import { ToastProvider } from 'tamagui';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor, within } from 'storybook/test';
import ToastManager from './ToastManager';

/**
* `ToastManager` renders a `messages[]` prop as Radix `Toast.Root`s plus the
* shared `Toast.Viewport`. It must be mounted under a Radix `Toast.Provider`
* (normally supplied by `ToastContext`) - the story provides one directly.
* `ToastManager` renders a `messages[]` prop as Tamagui `Toast`s plus the
* shared `ToastViewport` (#581). It must be mounted under a Tamagui
* `ToastProvider` (normally supplied by `ToastContext`, alongside the global
* `TamaguiProvider` from `.storybook/preview.tsx`) - the story provides one
* directly.
*/
const meta: Meta<typeof ToastManager> = {
title: 'Shared/ToastManager',
component: ToastManager,
tags: ['autodocs'],
decorators: [
(Story) => (
<RadixToast.Provider>
<ToastProvider>
<Story />
</RadixToast.Provider>
</ToastProvider>
),
],
args: {
Expand Down
109 changes: 96 additions & 13 deletions frontend/src/components/Toast/ToastManager.test.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,36 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as RadixToast from '@radix-ui/react-toast';
import { TamaguiProvider, ToastProvider } from 'tamagui';
import ToastManager from './ToastManager';
import tamaguiConfig from '../../../tamagui.config';

function Wrapper({ children }: { children: React.ReactNode }) {
return <RadixToast.Provider>{children}</RadixToast.Provider>;
// ToastManager's Toast/ToastViewport (#581) need both a TamaguiProvider
// ancestor (for tokens/theme) and a ToastProvider (duration/swipe context) -
// ToastContext.tsx supplies both in the app; tests need their own. `duration`
// defaults to Tamagui's own default (5000ms) but is overridable per test -
// the fake-timer tests below pass a short one so they don't wait 5s.
function Wrapper({
children,
duration,
}: {
children: React.ReactNode;
duration?: number;
}) {
return (
<TamaguiProvider config={tamaguiConfig} defaultTheme="light">
<ToastProvider duration={duration}>{children}</ToastProvider>
</TamaguiProvider>
);
}

function renderManager(props: Parameters<typeof ToastManager>[0]) {
return render(<ToastManager {...props} />, { wrapper: Wrapper });
function renderManager(
props: Parameters<typeof ToastManager>[0],
duration?: number
) {
return render(<ToastManager {...props} />, {
wrapper: ({ children }) => <Wrapper duration={duration}>{children}</Wrapper>,
});
}

describe('ToastManager', () => {
Expand All @@ -35,26 +56,88 @@ describe('ToastManager', () => {
expect(screen.getByText('Second message')).toBeInTheDocument();
});

it('each toast has role="status" (Radix default for type=background)', () => {
// Unlike Radix (where only a transient hidden announce copy carries
// role="status", and the visible <li> carries no role at all), Tamagui's
// fork puts role="status" on *both* the persistent visible toast
// (aria-live="off", so it doesn't double-announce) and the transient
// announce copy (aria-live="polite" for type="background", matching
// Radix's politeness exactly) - a different DOM shape, same announcement
// behavior. getAllByRole (not getByRole) because both are legitimately
// present at once here.
it('exposes role="status" with background-type ("polite") live-region politeness', () => {
renderManager({ messages: [{ id: '1', message: 'Hello' }], onDismiss: vi.fn() });
expect(screen.getByRole('status')).toBeInTheDocument();
const statusElements = screen.getAllByRole('status');
expect(statusElements.length).toBeGreaterThanOrEqual(1);
expect(statusElements.some((el) => el.getAttribute('aria-live') === 'polite')).toBe(true);
// None should be "assertive" - that's reserved for type="foreground", which this never uses.
expect(statusElements.some((el) => el.getAttribute('aria-live') === 'assertive')).toBe(false);
});

it('calls onDismiss when toast closes', async () => {
const onDismiss = vi.fn();
renderManager({ messages: [{ id: 'abc', message: 'Bye' }], onDismiss });

// Radix renders a close button; simulate closing
// Neither Radix nor this Tamagui swap render a close button by default
// (closeButton defaults to false) - dismissal here is via the provider's
// duration timeout, covered by the fake-timer test below rather than a
// click in this test.
const closeButton = screen.queryByRole('button');
if (closeButton) {
await userEvent.click(closeButton);
expect(onDismiss).toHaveBeenCalledWith('abc');
}
// If no close button rendered, onDismiss fires via duration timeout — covered by integration
});

it('renders viewport as an ordered list', () => {
const { container } = renderManager({ messages: [], onDismiss: vi.fn() });
expect(container.querySelector('ol')).toBeInTheDocument();
// #581 acceptance criterion: "Toasts auto-dismiss after the configured
// duration ... and clean up their timers on unmount" - untested even under
// the original Radix implementation (no prior test exercised this at all).
it('auto-dismisses after the configured duration', () => {
vi.useFakeTimers();
try {
const onDismiss = vi.fn();
renderManager({ messages: [{ id: 'abc', message: 'Bye' }], onDismiss }, 100);

expect(onDismiss).not.toHaveBeenCalled();

act(() => {
vi.advanceTimersByTime(150);
});

expect(onDismiss).toHaveBeenCalledWith('abc');
} finally {
vi.useRealTimers();
}
});

// Unmounting before the timer fires must not call onDismiss (or throw) -
// the timer needs to be cleared, not just left to fire into a gone
// component. This is the "clean up their timers on unmount" half of the
// same acceptance criterion.
it('does not call onDismiss after unmounting before the duration elapses', () => {
vi.useFakeTimers();
try {
const onDismiss = vi.fn();
const { unmount } = renderManager(
{ messages: [{ id: 'abc', message: 'Bye' }], onDismiss },
100
);

unmount();

act(() => {
vi.advanceTimersByTime(150);
});

expect(onDismiss).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});

// Accepted a11y gap (#581): Radix's Toast.Root/Viewport render as a real
// <li>/<ol> (native list navigation for screen readers across stacked
// toasts); Tamagui's are View-based with no way to override the rendered
// tag from this call site. See the comment in ToastManager.tsx - the
// "renders viewport as an ordered list" / "renders each toast as a list
// item" assertions this file used to have no longer hold, deliberately.
});
23 changes: 19 additions & 4 deletions frontend/src/components/Toast/ToastManager.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as Toast from '@radix-ui/react-toast';
import { Toast, ToastViewport } from 'tamagui';
import styles from './Toast.module.scss';
import type { Toast as ToastMessage } from '../../context/toastContextDef';

Expand All @@ -11,8 +11,23 @@ export default function ToastManager({ messages, onDismiss }: ToastManagerProps)
return (
<>
{messages.map(({ id, message }) => (
<Toast.Root
// `unstyled` skips Tamagui's default background/padding/elevation so
// .toast (SCSS) stays the sole source of visual truth, same as the
// Radix Toast.Root this replaces never applied its own visuals either
// (see ProgressBar, #580, for the same pattern).
//
// Accepted a11y gap (#581): Radix's Toast.Root/Viewport render as a
// real <li>/<ol> (Primitive.li / Primitive.ol in its source), giving
// screen readers native list navigation ("item 2 of 3") across
// stacked toasts. Tamagui's Toast/ToastViewport are View-based
// (styled(YStack, ...)) with no tag-override escape hatch - there is
// no way to make them render as li/ol from this call site. The
// announcement itself (role="status", aria-live politeness below)
// is preserved; only the native list *structure* is lost. Recorded
// here rather than silently dropped, per #587's baseline.
<Toast
key={id}
unstyled
open
onOpenChange={(open) => { if (!open) onDismiss(id); }}
className={styles.toast}
Expand All @@ -24,9 +39,9 @@ export default function ToastManager({ messages, onDismiss }: ToastManagerProps)
<Toast.Description className={styles.message}>{message}</Toast.Description>
</div>
</div>
</Toast.Root>
</Toast>
))}
<Toast.Viewport className={styles.toastViewport} />
<ToastViewport unstyled className={styles.toastViewport} />
</>
);
}
6 changes: 3 additions & 3 deletions frontend/src/context/ToastContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useCallback } from 'react';
import type { ReactElement, ReactNode } from 'react';
import * as RadixToast from '@radix-ui/react-toast';
import { ToastProvider as TamaguiToastProvider } from 'tamagui';
import { v4 as uuidv4 } from 'uuid';
import { ToastContext } from './toastContextDef';
import type { Toast } from './toastContextDef';
Expand All @@ -26,11 +26,11 @@ export function ToastProvider({ children, duration = 3300 }: ProviderProps): Rea
const toastsEnabled = useFeatureFlag('toastsFeature');

return (
<RadixToast.Provider duration={duration}>
<TamaguiToastProvider duration={duration}>
<ToastContext.Provider value={{ toasts, showToast }}>
{children}
{toastsEnabled && <ToastManager messages={toasts} onDismiss={dismissToast} />}
</ToastContext.Provider>
</RadixToast.Provider>
</TamaguiToastProvider>
);
}
Loading