Skip to content

feat(frontend): swap Toast off Radix onto Tamagui (#581) - #702

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

feat(frontend): swap Toast off Radix onto Tamagui (#581)#702
gaidheal1 wants to merge 1 commit into
claude/issue-580-progressbar-tamaguifrom
claude/issue-581-toast-tamagui

Conversation

@gaidheal1

Copy link
Copy Markdown
Member

Summary

Second sub-issue of #578 to ship, stacked on #580's PR (#701) since it uses the Tamagui toolchain that PR lands. Closes #581. Base is claude/issue-580-progressbar-tamagui, not development — merge #701 first.

Replaces @radix-ui/react-toast in ToastContext.tsx and ToastManager.tsx with Tamagui's Toast/ToastProvider/ToastViewport.

Confirmed before writing any code that Tamagui's toast primitives mirror Radix's API and internal announce-region technique closely (same duration/type/swipeDirection prop names, same "visible element + transient hidden announce copy" live-region pattern) — by reading @tamagui/toast's source, not by assumption. This made the swap close to mechanical: ToastContext's public shape (useToast().showToast) and ToastManager's props (messages, onDismiss) are unchanged; no call site moves.

Two real behavioral differences found (by diffing actual rendered DOM, not inferred from docs)

  • role="status" placement differs. Radix's visible toast is a bare <li> with no ARIA role (only its transient announce copy carries role="status"); Tamagui's fork puts role="status" on both the visible element (aria-live="off") and the announce copy (aria-live="polite" for type="background") — a different shape, same announcement politeness. Updated the test to use getAllByRole accordingly and assert the aria-live value directly rather than assume a single match.
  • List semantics are lost — recorded as an accepted a11y gap, not silently dropped. Radix's Toast.Root/Viewport render as real <li>/<ol> (native list navigation for screen readers across stacked toasts). Tamagui's are View-based (styled(YStack, ...)) with no tag-override that actually takes effect from this call site — tried tag="li"/tag="ol" first, verified empirically they're silently ignored (Tamagui's compiled output stayed div-based). Documented in ToastManager.tsx and the test file, per Remove Radix dependencies and verify accessibility parity across all replaced components #587's "any accepted regression recorded explicitly, not silently absorbed" baseline.

New test coverage

Added two tests for an acceptance-criterion behavior no prior test — even under Radix — exercised: auto-dismiss firing after the configured duration, and the timer not firing (or throwing) after an early unmount. Uses fake timers against a short per-test ToastProvider duration.

Styling

unstyled set on both Toast and ToastViewport so Toast.module.scss stays the sole source of visual truth, same pattern #580 established for ProgressBar. data-state="open"/"closed" (which the SCSS's fadeInUp/fadeOut keyframes key off) carries over unchanged.

Verification

Not verified: npm run test:a11y — needs a live dev server + Django backend via Docker, unavailable in this sandbox. Same category of disclosed gap as #580's unverified Storybook a11y suite, not a claim of a passing run.

Acceptance criteria (from #581)

  • No @radix-ui/react-toast import remains in either file
  • useToast().showToast(...) still works from every existing call site with no signature change
  • Toasts auto-dismiss after the configured duration (default 3300ms) and clean up their timers on unmount — newly tested
  • Multiple simultaneous toasts stack in the same order as today
  • Screen-reader announcement verified — politeness level equivalent to Radix's type="background", checked via explicit live-region assertion
  • toastsFeature flag still gates rendering (unchanged, not touched by this swap)
  • ToastManager.test.tsx passes (updated, no longer imports Radix)
  • npm run test:a11y — not verified, see above
  • Confirmed Tamagui's Toast can be driven by external open/onOpenChange state without requiring a Trigger in the tree — matches Radix's model directly, no ref workaround needed

🤖 Generated with Claude Code


Generated by Claude Code

Second sub-issue of #578 to ship, using the Tamagui toolchain #580
landed. Replaces @radix-ui/react-toast in ToastContext.tsx and
ToastManager.tsx with Tamagui's Toast/ToastProvider/ToastViewport.

Confirmed before writing any code that Tamagui's toast primitives
mirror Radix's API and internal announce-region technique closely
(same duration/type/swipeDirection prop names, same "visible element +
transient hidden announce copy" live-region pattern) - by reading
@tamagui/toast's source, not by assumption. This made the swap close
to mechanical: ToastContext's public shape (`useToast().showToast`)
and ToastManager's props (`messages`, `onDismiss`) are unchanged; no
call site moves.

Two real behavioral differences found by comparing actual rendered DOM
against Radix's, not inferred from docs:

- Radix's visible toast is a bare `<li>` with no ARIA role (only its
  transient announce copy carries role="status"); Tamagui's fork puts
  role="status" on *both* the visible element (aria-live="off") and
  the announce copy (aria-live="polite" for type="background") - a
  different shape, same announcement politeness. Updated the test to
  use getAllByRole accordingly and assert the aria-live value directly
  rather than assuming a single match.
- Radix's Toast.Root/Viewport render as real `<li>`/`<ol>` (native
  list navigation for screen readers across stacked toasts). Tamagui's
  are View-based (styled(YStack, ...)) with no tag-override that
  actually takes effect from this call site (tried `tag="li"`/`tag="ol"`
  first; verified empirically they're silently ignored - Tamagui's
  compiled output stayed div-based). Recorded as an accepted a11y gap
  in ToastManager.tsx and the test file, per #587's "any accepted
  regression recorded explicitly, not silently absorbed" baseline,
  rather than dropped without comment.

Added two tests for an acceptance-criterion behavior no prior test
(even under Radix) exercised: auto-dismiss firing after the configured
duration, and the timer not firing (or throwing) after an early
unmount - using fake timers against a short per-test ToastProvider
duration.

`unstyled` set on both Toast and ToastViewport so Toast.module.scss
stays the sole source of visual truth, same pattern #580 established
for ProgressBar. data-state="open"/"closed" (which the SCSS's
fadeInUp/fadeOut keyframes key off) carries over unchanged.

Verified: 449/449 unit tests pass, tsc --noEmit clean, lint unchanged
from the 14 pre-existing problems, production build succeeds. Bundle
delta on top of #580's baseline: +17.6 kB raw / +6.5 kB gzip on the
index chunk (measured directly) - modest, since #580 already paid the
fixed Tamagui-provider cost.

Not verified: `npm run test:a11y` (needs a live dev server + Django
backend via Docker, unavailable in this sandbox) - same category of
disclosed gap as #580's unverified Storybook a11y suite, not a claim
of a passing run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019tw1wbEwrW3EiTspfBt4RP
@gaidheal1
gaidheal1 marked this pull request as draft August 6, 2026 13:36
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