Skip to content

refactor(console): merge Sheet into Dialog, adopt Web Animations API for exit, sentinel focus guards#311

Open
maxnoller wants to merge 12 commits intomainfrom
worktree-agent-a8c5134f
Open

refactor(console): merge Sheet into Dialog, adopt Web Animations API for exit, sentinel focus guards#311
maxnoller wants to merge 12 commits intomainfrom
worktree-agent-a8c5134f

Conversation

@maxnoller
Copy link
Copy Markdown
Member

Summary

  • useAnimationsFinished replaces useExitAnimation: uses el.getAnimations() + Promise.all(a.finished) so multiple concurrent animations are all awaited; cancelled animations (which reject) still trigger unmount. Falls back to immediate call when no animations are running (jsdom, prefers-reduced-motion).
  • FocusGuard sentinels replace the focusout / requestAnimationFrame pull-back in useFocusTrap: two invisible <span tabIndex={0}> elements are rendered before and after DialogContent's children; when Tab/Shift+Tab exits the container the sentinel fires onFocus and redirects focus back in. The hook retains initial focus setup and return-focus-on-close.
  • Sheet merged into Dialog: Dialog gains isDrawer?: boolean; DialogContent gains side?: 'left' | 'right' | 'top' | 'bottom'. When isDrawer is true, DialogContent renders a <section> with slide-in/out via CSS transform class toggle (matching the old Sheet behaviour). Drawer CSS rules added to dialog/index.module.css. Re-export aliases (Drawer, DrawerContent, etc.) added at the bottom of dialog/index.tsx.
  • Sidebar updated to use Dialog + DialogContent with isDrawer/side instead of Sheet/SheetContent.
  • sheet/ directory deleted.
  • Toast updated to use useAnimationsFinished (it also depended on the deleted useExitAnimation).

Test plan

  • npx tsc --noEmit passes (verified — zero errors)
  • Dialog open/close animations still play; exit animation waits for all running CSS animations to finish
  • Drawer slides in/out correctly from all four sides
  • Mobile sidebar opens and closes correctly; backdrop click, Escape, and Ctrl+B all work
  • Tab/Shift+Tab focus stays trapped inside open dialogs and drawers
  • Focus returns to the trigger element on close
  • Toast exit animations still work; toasts unmount after animation completes

…for exit, sentinel focus guards

- Replace useExitAnimation (CSS event listeners) with useAnimationsFinished
  (Web Animations API getAnimations() / Promise.all(a.finished)) in Dialog
  and Toast; cancelled animations still trigger unmount via the rejection path.
- Remove the focusout / requestAnimationFrame pull-back from useFocusTrap;
  add FocusGuard sentinel components rendered at the boundaries of DialogContent
  so Tab/Shift+Tab at the edge redirects focus back into the panel.
- Merge Sheet into Dialog: Dialog gains isDrawer? + side? props; DialogContent
  renders a centered modal or a side-sliding <section> drawer depending on
  context. Add Drawer / DrawerContent / DrawerHeader / DrawerTitle /
  DrawerDescription / DrawerClose re-export aliases.
- Update Sidebar to use Dialog (isDrawer) instead of Sheet; delete the
  entire sheet/ component directory.
…etAnimations polyfill

- New: useAnimationsFinished.test.ts — covers exiting=false, no-anim, multi-anim, rejection, and unmount-before-finish cases
- New: FocusGuard.test.tsx — verifies aria-hidden, tabIndex=0, and onFocus callback
- dialog.test.tsx — polyfill HTMLElement.getAnimations for jsdom; add describe('Dialog — drawer mode') covering section/role, data-state, data-side, Escape, overlay click, aria-labelledby, and no-unmount-on-close
- useFocusTrap.test.tsx — replace stale focusout-isConnected test with a FocusGuard sentinel focus-redirect test
- sidebar.test.tsx, toast.test.tsx, gateway.test.tsx — add getAnimations polyfill so pre-existing tests pass after Web Animations API was introduced
Remove unused `cleanup` and `afterEach` imports (TS6133) and the
orphaned `afterEach(cleanup)` call they powered.
…dialog utilities

- Add console/src/test-setup.ts with a single getAnimations stub and register it
  via setupFiles in vitest.config.ts; remove the copy-pasted polyfill block from
  dialog, sidebar, toast, gateway and useAnimationsFinished test files.
- Export FOCUSABLE_SELECTORS from hooks/useFocusTrap.ts and import it in
  components/dialog/index.tsx, removing the duplicate local definition.
- Guard the open->closed exiting effect in DialogContent with !isDrawer so drawers
  never enter the JS-unmount path; remove the now-redundant cleanup effect that
  immediately cleared exiting for drawers.
- Lift focusFirst/focusLast in DialogContent into stable useCallback hooks so
  FocusGuard onFocus handlers are not recreated on every render.
@maxnoller maxnoller requested a review from furukama April 14, 2026 10:33
@maxnoller
Copy link
Copy Markdown
Member Author

Code review

Found 2 issues:

  1. Sidebar's DialogHeader now renders visibly in the mobile drawer. The old SheetHeader applied styles.srOnly (screen-reader-only) automatically; the migrated DialogHeader applies styles.header (display: grid; gap: 8px), so "Navigation" and "Sidebar navigation panel." will appear as a visible block at the top of the sidebar — a visual regression vs main.

}
>
<DialogHeader>
<DialogTitle>Navigation</DialogTitle>
<DialogDescription>Sidebar navigation panel.</DialogDescription>
</DialogHeader>
{children}
</DialogContent>

  1. The Drawer wrapper and DrawerClose/DrawerContent/DrawerDescription/DrawerHeader/DrawerTitle re-exports have zero callers anywhere in console/src — the only drawer consumer (Sidebar) imports from Dialog/DialogContent directly. AGENTS.md §3.2 YAGNI says "Do not add config keys, interfaces, or feature flags without a concrete caller."

// ---------------------------------------------------------------------------
// Drawer — Dialog with isDrawer=true, same API, different presentation.
// ---------------------------------------------------------------------------
export const Drawer = (
props: Omit<Parameters<typeof Dialog>[0], 'isDrawer'> & {
children: ReactNode;
},
) => <Dialog {...props} isDrawer />;
export {
DialogClose as DrawerClose,
DialogContent as DrawerContent,
DialogDescription as DrawerDescription,
DialogHeader as DrawerHeader,
DialogTitle as DrawerTitle,
};

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Max Noller and others added 5 commits April 17, 2026 22:18
Sidebar's DialogHeader was rendering "Navigation" / "Sidebar navigation
panel." visibly after the Sheet→Dialog migration, regressing the old
SheetHeader's automatic srOnly behavior. Add a visuallyHidden prop to
DialogHeader and use it in the mobile sidebar.

Also drop the Drawer/DrawerClose/DrawerContent/DrawerDescription/
DrawerHeader/DrawerTitle re-exports, which had no callers (AGENTS.md
§3.2 YAGNI).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
#	console/src/components/dialog/index.tsx
#	console/src/components/sheet/index.tsx
…b stale Sheet refs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolve sidebar conflict: keep the Dialog-based mobile drawer (this PR)
while adopting the PanelLeft icon and collapsible='none' variant from main.
Drop SheetSide since the sheet module is removed by this refactor.
@maxnoller maxnoller force-pushed the worktree-agent-a8c5134f branch from 5a8c44e to 582c999 Compare April 22, 2026 10:15
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.

1 participant