From 5d8bfc6c38cf02702aedbe56a7f3477b88510d8c Mon Sep 17 00:00:00 2001 From: Preston Booth Date: Tue, 22 Sep 2026 07:55:44 -0600 Subject: [PATCH 01/16] feat(mosaic): add action bar component --- .changeset/mosaic-action-bar.md | 5 + .../action-bar/action-bar.styles.ts | 82 ++++++++++ .../components/action-bar/action-bar.test.tsx | 73 +++++++++ .../src/components/action-bar/action-bar.tsx | 148 ++++++++++++++++++ .../mosaic/src/components/action-bar/index.ts | 7 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 7 + packages/swingset/src/stories/action-bar.mdx | 49 ++++++ .../src/stories/action-bar.stories.tsx | 80 ++++++++++ 9 files changed, 452 insertions(+) create mode 100644 .changeset/mosaic-action-bar.md create mode 100644 packages/mosaic/src/components/action-bar/action-bar.styles.ts create mode 100644 packages/mosaic/src/components/action-bar/action-bar.test.tsx create mode 100644 packages/mosaic/src/components/action-bar/action-bar.tsx create mode 100644 packages/mosaic/src/components/action-bar/index.ts create mode 100644 packages/swingset/src/stories/action-bar.mdx create mode 100644 packages/swingset/src/stories/action-bar.stories.tsx diff --git a/.changeset/mosaic-action-bar.md b/.changeset/mosaic-action-bar.md new file mode 100644 index 00000000000..a763ac19988 --- /dev/null +++ b/.changeset/mosaic-action-bar.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Add an ActionBar component for contextual actions on a current selection. diff --git a/packages/mosaic/src/components/action-bar/action-bar.styles.ts b/packages/mosaic/src/components/action-bar/action-bar.styles.ts new file mode 100644 index 00000000000..a96738d8a0f --- /dev/null +++ b/packages/mosaic/src/components/action-bar/action-bar.styles.ts @@ -0,0 +1,82 @@ +import * as stylex from '@stylexjs/stylex'; + +import { + colorVars, + durationVars, + easingVars, + fontWeightVars, + radiusVars, + shadowVars, + space, + typeScaleVars, +} from '../../tokens.stylex'; + +const reduceMotion = '@media (prefers-reduced-motion: reduce)'; + +export const styles = stylex.create({ + positioner: { + display: 'flex', + justifyContent: 'center', + pointerEvents: 'none', + zIndex: 1, + bottom: space['2'], + }, + // Anchored to the nearest positioned ancestor — for a bounded container. + positionerAbsolute: { + insetInline: 0, + position: 'absolute', + }, + // Pinned to the foot of the nearest scroll container, so it stays in view while its surface + // scrolls. `margin-block-start: auto` drops it to the bottom when the column has room to spare. + positionerSticky: { + marginBlockStart: 'auto', + position: 'sticky', + }, + bar: { + borderRadius: radiusVars['--cl-radius-lg'], + gap: space['1'], + paddingBlock: space['1'], + paddingInline: space['1'], + alignItems: 'center', + backgroundColor: colorVars['--cl-color-background'], + boxShadow: shadowVars['--cl-shadow-lg'], + display: 'flex', + opacity: { + default: 1, + ':is([data-open="false"])': 0, + }, + pointerEvents: { + default: 'auto', + ':is([data-open="false"])': 'none', + }, + transform: { + default: 'translateY(0)', + ':is([data-open="false"])': 'translateY(0.25rem)', + }, + transitionDuration: { + default: `${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-base']}`, + [reduceMotion]: `${durationVars['--cl-duration-instant']}, ${durationVars['--cl-duration-instant']}`, + ':is([data-open="false"])': `${durationVars['--cl-duration-instant']}, ${durationVars['--cl-duration-instant']}`, + }, + transitionProperty: 'opacity, transform', + transitionTimingFunction: { + default: `linear, ${easingVars['--cl-ease-enter']}`, + }, + maxWidth: 'calc(100% - 2 * var(--cl-spacing))', + }, + count: { + paddingInline: space['2'], + color: colorVars['--cl-color-foreground'], + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + whiteSpace: 'nowrap', + }, + separator: { + marginBlock: space['1'], + marginInline: space['0.5'], + alignSelf: 'stretch', + backgroundColor: colorVars['--cl-color-border'], + width: '1px', + }, +}); diff --git a/packages/mosaic/src/components/action-bar/action-bar.test.tsx b/packages/mosaic/src/components/action-bar/action-bar.test.tsx new file mode 100644 index 00000000000..3eead27ce7b --- /dev/null +++ b/packages/mosaic/src/components/action-bar/action-bar.test.tsx @@ -0,0 +1,73 @@ +import * as stylex from '@stylexjs/stylex'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ActionBar } from './action-bar'; + +const testStyles = stylex.create({ + positioner: { + bottom: '20px', + }, +}); + +describe('Mosaic ActionBar', () => { + it('renders a toolbar with its count and reflects data-open', () => { + render( + + 3 selected + , + ); + const bar = screen.getByRole('toolbar', { name: 'Bulk actions' }); + expect(bar).toHaveClass('cl-action-bar'); + expect(bar).toHaveAttribute('data-open', 'true'); + const count = screen.getByText('3 selected'); + expect(count).toHaveClass('cl-action-bar-count'); + expect(count.querySelector('.cl-icon')).not.toBeInTheDocument(); + }); + + it('marks the bar inert while closed', () => { + render( + + 0 selected + , + ); + const bar = screen.getByRole('toolbar', { name: 'Bulk actions', hidden: true }); + expect(bar).toHaveAttribute('data-open', 'false'); + expect(bar.inert).toBe(true); + }); + + it('styles the positioner independently from the bar', () => { + render( + , + ); + expect(screen.getByRole('toolbar', { name: 'Bulk actions' }).parentElement).toHaveClass( + ...(stylex.props(testStyles.positioner).className as string).split(' '), + ); + }); + + it('dismisses with a labelled button', async () => { + const onDismiss = vi.fn(); + render( + + + , + ); + await userEvent.click(screen.getByRole('button', { name: 'Clear selection' })); + expect(onDismiss).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/mosaic/src/components/action-bar/action-bar.tsx b/packages/mosaic/src/components/action-bar/action-bar.tsx new file mode 100644 index 00000000000..7e420719505 --- /dev/null +++ b/packages/mosaic/src/components/action-bar/action-bar.tsx @@ -0,0 +1,148 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import { useRender } from '../../primitives/utils'; +import type { MosaicComponentProps, MosaicElementProps, XStyle } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../utils/reset.styles'; +import { Button } from '../button'; +import { Icon } from '../icon'; +import { styles } from './action-bar.styles'; + +export interface ActionBarRootProps extends MosaicComponentProps<'div'> { + /** Whether the bar is shown. Toggling it animates the bar in and out. */ + open: boolean; + /** + * Pins the bar to the foot of the nearest scroll container instead of a positioned ancestor, so + * it stays in view while a long surface scrolls (a `Profile` page, a modal). Place `ActionBar.Root` + * as the last child of that scrolling column. Off, the bar is absolute within the nearest + * positioned ancestor — give that ancestor `position: relative`. + * + * @default false + */ + sticky?: boolean; + positionerXstyle?: XStyle; +} + +/** + * A floating bar of actions for a selection. It sits at the foot of its container — the nearest + * positioned ancestor, or the nearest scroll container with `sticky` — and animates in and out with + * `open`. While `open` is false it is inert and click-through, so the surface underneath stays + * usable. Compose a count, a separator, the actions, and a dismiss: + * + * @example + * 0}> + * {count} selected + * + * … + * + * + * + * + */ +const Root = React.forwardRef(function ActionBarRoot( + { open, sticky = false, render, xstyle, positionerXstyle, children, ...rest }, + ref, +) { + const barRef = React.useRef(null); + React.useEffect(() => { + if (barRef.current) { + barRef.current.inert = !open; + } + }, [open]); + + const bar = useRender({ + defaultTagName: 'div', + render, + ref: barRef, + props: { + ...mergeStyleProps(themeProps('action-bar', { open }), stylex.props(reset.base, styles.bar, xstyle), rest), + 'data-open': open, + role: 'toolbar', + children, + }, + }); + + return ( +
+ {bar} +
+ ); +}); + +export type ActionBarCountProps = MosaicComponentProps<'div'>; + +/** The leading count of what is selected, e.g. `3 selected`. */ +const Count = React.forwardRef(function ActionBarCount( + { render, xstyle, children, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('action-bar-count'), stylex.props(reset.base, styles.count, xstyle), rest), + children, + }, + }); +}); + +export type ActionBarSeparatorProps = MosaicComponentProps<'div'>; + +/** A vertical divider between groups of the bar. */ +const Separator = React.forwardRef(function ActionBarSeparator( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('action-bar-separator'), stylex.props(reset.base, styles.separator), rest), + 'aria-hidden': true, + }, + }); +}); + +export type ActionBarDismissProps = MosaicElementProps<'button'>; + +/** Dismisses the bar. A ghost icon button; defaults its label to "Clear selection". */ +const Dismiss = React.forwardRef(function ActionBarDismiss( + { xstyle, 'aria-label': ariaLabel, ...rest }, + ref, +) { + return ( + + ); +}); + +/** + * A floating bar of actions for a current selection, composed through `ActionBar.Root`, + * `ActionBar.Count`, `ActionBar.Separator`, and `ActionBar.Dismiss`. The actions themselves + * (a `Menu`, a `Button`) are whatever children you place between them. + */ +export const ActionBar = { Root, Count, Separator, Dismiss }; diff --git a/packages/mosaic/src/components/action-bar/index.ts b/packages/mosaic/src/components/action-bar/index.ts new file mode 100644 index 00000000000..fb244e5be89 --- /dev/null +++ b/packages/mosaic/src/components/action-bar/index.ts @@ -0,0 +1,7 @@ +export { ActionBar } from './action-bar'; +export type { + ActionBarCountProps, + ActionBarDismissProps, + ActionBarRootProps, + ActionBarSeparatorProps, +} from './action-bar'; diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 89807ba58c5..a0af7e29813 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -56,6 +56,7 @@ const docModules: Record> = { destructive: dynamic(() => import('../stories/destructive.mdx')), }, components: { + 'action-bar': dynamic(() => import('../stories/action-bar.mdx')), avatar: dynamic(() => import('../stories/avatar.mdx')), badge: dynamic(() => import('../stories/badge.mdx')), banner: dynamic(() => import('../stories/banner.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 3dd91747be2..f6cc9734e77 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -1,5 +1,6 @@ // Import stories explicitly to control order and avoid type casting through unknown. import { meta as accordionMeta } from '../stories/accordion.stories'; +import { Default as ActionBarDefault, meta as actionBarMeta } from '../stories/action-bar.stories'; import { meta as autocompleteMeta } from '../stories/autocomplete.stories'; import { Fallback as AvatarFallbackStory, @@ -472,6 +473,11 @@ const organizationProfileDangerSectionModule: StoryModule = { LeaveOnly: OrganizationProfileDangerSectionLeaveOnly, }; +const actionBarModule: StoryModule = { + meta: actionBarMeta, + Default: ActionBarDefault, +}; + const avatarModule: StoryModule = { meta: avatarMeta, Primary: AvatarPrimary, @@ -896,6 +902,7 @@ export const registry: StoryModule[] = [ confirmationModule, destructiveModule, // Components + actionBarModule, avatarModule, badgeModule, bannerModule, diff --git a/packages/swingset/src/stories/action-bar.mdx b/packages/swingset/src/stories/action-bar.mdx new file mode 100644 index 00000000000..a1ae780716f --- /dev/null +++ b/packages/swingset/src/stories/action-bar.mdx @@ -0,0 +1,49 @@ +import * as ActionBarStories from './action-bar.stories'; + +# ActionBar + +A floating bar of actions for a current selection. It sits at the foot of its nearest positioned +ancestor — give that ancestor `position: relative` and the bar centers over its bottom — and +animates in when opened. While closed it is inert and click-through, so the surface +underneath stays usable. + +## Example + + + +## Usage + +```tsx + 0}> + {selectedCount} selected + + {/* Change role */} + + + + +``` + +## Parts + +| Part | Element | Description | +| --------------------- | -------- | -------------------------------------------------------------------------- | +| `ActionBar.Root` | `div` | The positioner + floating surface. Takes `open`; renders `role='toolbar'`. | +| `ActionBar.Count` | `div` | The leading count of what is selected. | +| `ActionBar.Separator` | `div` | A vertical divider between groups. | +| `ActionBar.Dismiss` | `button` | Clears the selection. A ghost icon button; labelled `Clear selection`. | + +## Styling + +`ActionBar.Root` reflects `data-open` (`true` / `false`) on its surface, so the entrance +transition and the closed (click-through) state are both selectable. The actions themselves are +whatever children you place between the parts. diff --git a/packages/swingset/src/stories/action-bar.stories.tsx b/packages/swingset/src/stories/action-bar.stories.tsx new file mode 100644 index 00000000000..b099c00cbb3 --- /dev/null +++ b/packages/swingset/src/stories/action-bar.stories.tsx @@ -0,0 +1,80 @@ +import { ActionBar } from '@clerk/mosaic/components/action-bar'; +import { Button } from '@clerk/mosaic/components/button'; +import { Icon } from '@clerk/mosaic/components/icon'; +import { Menu } from '@clerk/mosaic/components/menu'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './action-bar.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'ActionBar', + status: 'wip', + layout: 'wide', + source: 'packages/mosaic/src/components/action-bar/action-bar.tsx', +}; + +/** + * The bar sits at the foot of its nearest positioned ancestor. The example gives that ancestor a + * height and `position: relative` so the bar floats over its bottom; toggle the selection to see + * it animate in and out. + */ +export function Default() { + const [count, setCount] = useState(3); + return ( +
+ + 0} + aria-label='Bulk actions' + > + {count} selected + + + + } + > + Change role + + + + + Admin + + + Member + + + + + + + setCount(0)} /> + +
+ ); +} From 7e3ce56c4c138ef01bb1c9987dd915379a75d128 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 23 Sep 2026 15:07:00 -0600 Subject: [PATCH 02/16] feat(mosaic): anchor the action bar to its table and make it a roving toolbar Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/mosaic-action-bar.md | 3 - .../action-bar/action-bar.styles.ts | 21 +- .../components/action-bar/action-bar.test.tsx | 135 +++++++++-- .../src/components/action-bar/action-bar.tsx | 227 ++++++++++++++---- .../mosaic/src/components/action-bar/index.ts | 1 + packages/swingset/src/stories/action-bar.mdx | 56 +++-- .../src/stories/action-bar.stories.tsx | 161 ++++++++----- 7 files changed, 447 insertions(+), 157 deletions(-) diff --git a/.changeset/mosaic-action-bar.md b/.changeset/mosaic-action-bar.md index a763ac19988..a845151cc84 100644 --- a/.changeset/mosaic-action-bar.md +++ b/.changeset/mosaic-action-bar.md @@ -1,5 +1,2 @@ --- -'@clerk/mosaic': patch --- - -Add an ActionBar component for contextual actions on a current selection. diff --git a/packages/mosaic/src/components/action-bar/action-bar.styles.ts b/packages/mosaic/src/components/action-bar/action-bar.styles.ts index a96738d8a0f..510d2156319 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.styles.ts +++ b/packages/mosaic/src/components/action-bar/action-bar.styles.ts @@ -14,23 +14,18 @@ import { const reduceMotion = '@media (prefers-reduced-motion: reduce)'; export const styles = stylex.create({ + anchor: { + display: 'flow-root', + }, positioner: { + alignItems: 'center', display: 'flex', justifyContent: 'center', pointerEvents: 'none', - zIndex: 1, - bottom: space['2'], - }, - // Anchored to the nearest positioned ancestor — for a bounded container. - positionerAbsolute: { - insetInline: 0, - position: 'absolute', - }, - // Pinned to the foot of the nearest scroll container, so it stays in view while its surface - // scrolls. `margin-block-start: auto` drops it to the bottom when the column has room to spare. - positionerSticky: { - marginBlockStart: 'auto', position: 'sticky', + zIndex: 1, + bottom: `calc(${space['5']} + ${space['4']})`, + height: 0, }, bar: { borderRadius: radiusVars['--cl-radius-lg'], @@ -39,8 +34,10 @@ export const styles = stylex.create({ paddingInline: space['1'], alignItems: 'center', backgroundColor: colorVars['--cl-color-background'], + blockSize: space['10'], boxShadow: shadowVars['--cl-shadow-lg'], display: 'flex', + flexShrink: 0, opacity: { default: 1, ':is([data-open="false"])': 0, diff --git a/packages/mosaic/src/components/action-bar/action-bar.test.tsx b/packages/mosaic/src/components/action-bar/action-bar.test.tsx index 3eead27ce7b..7f13d85a7f4 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.test.tsx +++ b/packages/mosaic/src/components/action-bar/action-bar.test.tsx @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { Menu } from '../menu'; import { ActionBar } from './action-bar'; const testStyles = stylex.create({ @@ -12,33 +13,52 @@ const testStyles = stylex.create({ }, }); -describe('Mosaic ActionBar', () => { - it('renders a toolbar with its count and reflects data-open', () => { - render( +function BulkActions({ open = true, onDismiss }: { open?: boolean; onDismiss?: () => void }) { + return ( + + + + + + + +
+ +
3 selected - , - ); + + + + + +
+ + ); +} + +describe('Mosaic ActionBar', () => { + it('renders a labelled toolbar tied to its table and described by its count', () => { + render(); const bar = screen.getByRole('toolbar', { name: 'Bulk actions' }); expect(bar).toHaveClass('cl-action-bar'); expect(bar).toHaveAttribute('data-open', 'true'); - const count = screen.getByText('3 selected'); - expect(count).toHaveClass('cl-action-bar-count'); - expect(count.querySelector('.cl-icon')).not.toBeInTheDocument(); + expect(bar).toHaveAttribute('aria-controls', 'members'); + expect(bar).toHaveAttribute('aria-orientation', 'horizontal'); + expect(bar).toHaveAccessibleDescription('3 selected'); + expect(screen.getByText('3 selected')).toHaveAttribute('aria-live', 'polite'); + expect(screen.getAllByRole('separator')).toHaveLength(2); }); it('marks the bar inert while closed', () => { - render( - - 0 selected - , - ); + render(); const bar = screen.getByRole('toolbar', { name: 'Bulk actions', hidden: true }); expect(bar).toHaveAttribute('data-open', 'false'); expect(bar.inert).toBe(true); @@ -57,16 +77,91 @@ describe('Mosaic ActionBar', () => { ); }); - it('dismisses with a labelled button', async () => { - const onDismiss = vi.fn(); + it('is a single tab stop', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('checkbox', { name: 'Select Kyle' })); + await user.tab(); + expect(screen.getByRole('button', { name: 'Change role' })).toHaveFocus(); + await user.tab(); + expect(document.body).toHaveFocus(); + }); + + it('moves between controls with the arrow keys, Home, and End', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('checkbox', { name: 'Select Kyle' })); + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(screen.getByRole('button', { name: 'Remove' })).toHaveFocus(); + await user.keyboard('{End}'); + expect(screen.getByRole('button', { name: 'Clear selection' })).toHaveFocus(); + await user.keyboard('{ArrowRight}'); + expect(screen.getByRole('button', { name: 'Change role' })).toHaveFocus(); + await user.keyboard('{ArrowLeft}'); + expect(screen.getByRole('button', { name: 'Clear selection' })).toHaveFocus(); + await user.keyboard('{Home}'); + expect(screen.getByRole('button', { name: 'Change role' })).toHaveFocus(); + }); + + it('returns to the last focused control when tabbed back into', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('checkbox', { name: 'Select Kyle' })); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.tab({ shift: true }); + await user.tab(); + expect(screen.getByRole('button', { name: 'Remove' })).toHaveFocus(); + }); + + it('leaves arrow keys inside a portalled menu to the menu', async () => { + const user = userEvent.setup(); render( - + + Change role + + + Admin + + + + , ); + await user.click(screen.getByRole('button', { name: 'Change role' })); + const item = await screen.findByRole('menuitem', { name: 'Admin' }); + item.focus(); + await user.keyboard('{ArrowRight}'); + expect(screen.getByRole('button', { name: 'Remove' })).not.toHaveFocus(); + }); + + it('returns focus to where it came from when the bar closes around it', async () => { + const user = userEvent.setup(); + function Harness() { + const [open, setOpen] = React.useState(true); + return ( + setOpen(false)} + /> + ); + } + render(); + const checkbox = screen.getByRole('checkbox', { name: 'Select Kyle' }); + await user.click(checkbox); + await user.tab(); + await user.keyboard('{End}{Enter}'); + expect(checkbox).toHaveFocus(); + }); + + it('dismisses with a labelled button', async () => { + const onDismiss = vi.fn(); + render(); await userEvent.click(screen.getByRole('button', { name: 'Clear selection' })); expect(onDismiss).toHaveBeenCalledOnce(); }); diff --git a/packages/mosaic/src/components/action-bar/action-bar.tsx b/packages/mosaic/src/components/action-bar/action-bar.tsx index 7e420719505..ee57457fb77 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.tsx +++ b/packages/mosaic/src/components/action-bar/action-bar.tsx @@ -9,93 +9,223 @@ import { Button } from '../button'; import { Icon } from '../icon'; import { styles } from './action-bar.styles'; +const ITEM_SELECTOR = 'button, a[href], input, select, textarea, [tabindex]'; + +const ActionBarContext = React.createContext<{ registerCount: (id: string | undefined) => void } | null>(null); + +function toolbarItems(bar: HTMLElement): HTMLElement[] { + return Array.from(bar.querySelectorAll(ITEM_SELECTOR)).filter( + item => !item.matches(':disabled') && item.closest('[role="toolbar"]') === bar, + ); +} + +function syncTabStops(items: HTMLElement[], active: HTMLElement | undefined) { + for (const item of items) { + item.tabIndex = item === active ? 0 : -1; + } +} + +export type ActionBarAnchorProps = MosaicComponentProps<'div'>; + +/** + * Wraps the surface the bar acts on — a `Table.Root` — together with `ActionBar.Root`, which goes + * last. The bar rests on the anchor's bottom edge and, while that edge is scrolled out of view, + * pins to the foot of the nearest scroll container: the content column of a `Profile` in a dialog, + * or the page when the profile is inline. + */ +const Anchor = React.forwardRef(function ActionBarAnchor( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: mergeStyleProps(themeProps('action-bar-anchor'), stylex.props(reset.base, styles.anchor, xstyle), rest), + }); +}); + export interface ActionBarRootProps extends MosaicComponentProps<'div'> { /** Whether the bar is shown. Toggling it animates the bar in and out. */ open: boolean; /** - * Pins the bar to the foot of the nearest scroll container instead of a positioned ancestor, so - * it stays in view while a long surface scrolls (a `Profile` page, a modal). Place `ActionBar.Root` - * as the last child of that scrolling column. Off, the bar is absolute within the nearest - * positioned ancestor — give that ancestor `position: relative`. - * - * @default false + * Where focus goes when the bar closes while it holds focus. Defaults to the element focus + * entered the bar from, when that is still on the page. */ - sticky?: boolean; + returnFocus?: React.RefObject; positionerXstyle?: XStyle; } /** - * A floating bar of actions for a selection. It sits at the foot of its container — the nearest - * positioned ancestor, or the nearest scroll container with `sticky` — and animates in and out with - * `open`. While `open` is false it is inert and click-through, so the surface underneath stays - * usable. Compose a count, a separator, the actions, and a dismiss: + * A floating toolbar of actions for a selection, placed as the last child of `ActionBar.Anchor`. + * It is one tab stop: the arrow keys, `Home`, and `End` move between its controls. While `open` is + * false it is inert and click-through, and if it held focus, focus returns to where it came from. + * Label it, and point `aria-controls` at the table it acts on: * * @example - * 0}> - * {count} selected - * - * … - * - * - * - * + * + * … + * 0} aria-label='Bulk actions' aria-controls={tableId}> + * {count} selected + * + * … + * + * + * + * */ const Root = React.forwardRef(function ActionBarRoot( - { open, sticky = false, render, xstyle, positionerXstyle, children, ...rest }, + { open, returnFocus, render, xstyle, positionerXstyle, children, onKeyDown, onFocus, ...rest }, ref, ) { const barRef = React.useRef(null); - React.useEffect(() => { - if (barRef.current) { - barRef.current.inert = !open; + const activeRef = React.useRef(undefined); + const originRef = React.useRef(null); + const [countId, setCountId] = React.useState(); + const context = React.useMemo(() => ({ registerCount: setCountId }), []); + + React.useLayoutEffect(() => { + const bar = barRef.current; + if (!bar) { + return; + } + if (!open) { + if (bar.contains(bar.ownerDocument.activeElement)) { + const target = returnFocus?.current ?? originRef.current; + if (target?.isConnected) { + target.focus(); + } else { + (bar.ownerDocument.activeElement as HTMLElement | null)?.blur(); + } + } + activeRef.current = undefined; + } + bar.inert = !open; + }, [open, returnFocus]); + + React.useLayoutEffect(() => { + const bar = barRef.current; + if (!bar) { + return; + } + const items = toolbarItems(bar); + if (!activeRef.current || !items.includes(activeRef.current)) { + activeRef.current = items[0]; + } + syncTabStops(items, activeRef.current); + }); + + const handleFocus = (event: React.FocusEvent) => { + onFocus?.(event); + const bar = barRef.current; + const target = event.target as HTMLElement; + if (!bar) { + return; + } + const from = event.relatedTarget as HTMLElement | null; + if (from && !bar.contains(from)) { + originRef.current = from; + } + const items = toolbarItems(bar); + if (items.includes(target)) { + activeRef.current = target; + syncTabStops(items, target); } - }, [open]); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + onKeyDown?.(event); + const bar = barRef.current; + const target = event.target as HTMLElement; + if (event.defaultPrevented || !bar || target.matches('input, textarea, select')) { + return; + } + const items = toolbarItems(bar); + const index = items.indexOf(target); + if (index === -1) { + return; + } + const forward = getComputedStyle(bar).direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + const backward = forward === 'ArrowRight' ? 'ArrowLeft' : 'ArrowRight'; + let next: number; + switch (event.key) { + case forward: + next = (index + 1) % items.length; + break; + case backward: + next = (index - 1 + items.length) % items.length; + break; + case 'Home': + next = 0; + break; + case 'End': + next = items.length - 1; + break; + default: + return; + } + event.preventDefault(); + items[next].focus(); + }; const bar = useRender({ defaultTagName: 'div', render, ref: barRef, props: { + 'aria-describedby': countId, ...mergeStyleProps(themeProps('action-bar', { open }), stylex.props(reset.base, styles.bar, xstyle), rest), 'data-open': open, role: 'toolbar', + 'aria-orientation': 'horizontal', + onKeyDown: handleKeyDown, + onFocus: handleFocus, children, }, }); return ( -
- {bar} -
+ +
+ {bar} +
+
); }); export type ActionBarCountProps = MosaicComponentProps<'div'>; -/** The leading count of what is selected, e.g. `3 selected`. */ +/** + * The leading count of what is selected, e.g. `3 selected`. It describes the toolbar and is a + * polite live region, so a change in the selection is announced. + */ const Count = React.forwardRef(function ActionBarCount( - { render, xstyle, children, ...rest }, + { render, xstyle, id: idProp, children, ...rest }, ref, ) { + const generatedId = React.useId(); + const id = idProp ?? generatedId; + const registerCount = React.useContext(ActionBarContext)?.registerCount; + React.useLayoutEffect(() => { + registerCount?.(id); + return () => registerCount?.(undefined); + }, [registerCount, id]); + return useRender({ defaultTagName: 'div', render, ref, props: { + 'aria-live': 'polite', + 'aria-atomic': true, ...mergeStyleProps(themeProps('action-bar-count'), stylex.props(reset.base, styles.count, xstyle), rest), + id, children, }, }); @@ -113,8 +243,9 @@ const Separator = React.forwardRef(func render, ref, props: { - ...mergeStyleProps(themeProps('action-bar-separator'), stylex.props(reset.base, styles.separator), rest), - 'aria-hidden': true, + ...mergeStyleProps(themeProps('action-bar-separator'), stylex.props(reset.base, styles.separator, xstyle), rest), + role: 'separator', + 'aria-orientation': 'vertical', }, }); }); @@ -141,8 +272,8 @@ const Dismiss = React.forwardRef(funct }); /** - * A floating bar of actions for a current selection, composed through `ActionBar.Root`, - * `ActionBar.Count`, `ActionBar.Separator`, and `ActionBar.Dismiss`. The actions themselves - * (a `Menu`, a `Button`) are whatever children you place between them. + * A floating toolbar of actions for a current selection, composed through `ActionBar.Anchor`, + * `ActionBar.Root`, `ActionBar.Count`, `ActionBar.Separator`, and `ActionBar.Dismiss`. The actions + * themselves (a `Menu`, a `Button`) are whatever children you place between them. */ -export const ActionBar = { Root, Count, Separator, Dismiss }; +export const ActionBar = { Anchor, Root, Count, Separator, Dismiss }; diff --git a/packages/mosaic/src/components/action-bar/index.ts b/packages/mosaic/src/components/action-bar/index.ts index fb244e5be89..9fc408619c9 100644 --- a/packages/mosaic/src/components/action-bar/index.ts +++ b/packages/mosaic/src/components/action-bar/index.ts @@ -1,5 +1,6 @@ export { ActionBar } from './action-bar'; export type { + ActionBarAnchorProps, ActionBarCountProps, ActionBarDismissProps, ActionBarRootProps, diff --git a/packages/swingset/src/stories/action-bar.mdx b/packages/swingset/src/stories/action-bar.mdx index a1ae780716f..9a42eab46c5 100644 --- a/packages/swingset/src/stories/action-bar.mdx +++ b/packages/swingset/src/stories/action-bar.mdx @@ -2,10 +2,10 @@ import * as ActionBarStories from './action-bar.stories'; # ActionBar -A floating bar of actions for a current selection. It sits at the foot of its nearest positioned -ancestor — give that ancestor `position: relative` and the bar centers over its bottom — and -animates in when opened. While closed it is inert and click-through, so the surface -underneath stays usable. +A floating toolbar of actions for a current selection. It rests on the bottom edge of the table it +acts on and, while that edge is scrolled out of view, stays pinned to the foot of the nearest scroll +container — the content column of a profile in a dialog, or the page when the profile is inline. It +animates in when opened. While closed it is inert and click-through. ## Example @@ -21,26 +21,42 @@ underneath stays usable. ## Usage ```tsx - 0}> - {selectedCount} selected - - {/* Change role */} - - - - + + {/* rows */} + 0} + aria-label='Bulk actions' + aria-controls={tableId} + > + {selectedCount} selected + + {/* Change role */} + + + + + ``` ## Parts -| Part | Element | Description | -| --------------------- | -------- | -------------------------------------------------------------------------- | -| `ActionBar.Root` | `div` | The positioner + floating surface. Takes `open`; renders `role='toolbar'`. | -| `ActionBar.Count` | `div` | The leading count of what is selected. | -| `ActionBar.Separator` | `div` | A vertical divider between groups. | -| `ActionBar.Dismiss` | `button` | Clears the selection. A ghost icon button; labelled `Clear selection`. | +| Part | Element | Description | +| --------------------- | -------- | ------------------------------------------------------------------------------------ | +| `ActionBar.Anchor` | `div` | Wraps the table and the bar. The bar never leaves the anchor's bounds. | +| `ActionBar.Root` | `div` | The positioner + floating surface, last in the anchor. Takes `open`; `role=toolbar`. | +| `ActionBar.Count` | `div` | The leading count of what is selected. Describes the toolbar; announced politely. | +| `ActionBar.Separator` | `div` | A vertical divider between groups. | +| `ActionBar.Dismiss` | `button` | Clears the selection. A ghost icon button; labelled `Clear selection`. | + +## Accessibility + +- The bar is a single tab stop. `←` / `→` move between its controls and wrap; `Home` / `End` jump to + the first and last. +- Give it an `aria-label`, and point `aria-controls` at the table's `id`. +- When the bar closes while it holds focus, focus returns to the element it came from. Pass + `returnFocus` to send it somewhere else. ## Styling diff --git a/packages/swingset/src/stories/action-bar.stories.tsx b/packages/swingset/src/stories/action-bar.stories.tsx index b099c00cbb3..9eb87deea98 100644 --- a/packages/swingset/src/stories/action-bar.stories.tsx +++ b/packages/swingset/src/stories/action-bar.stories.tsx @@ -2,7 +2,9 @@ import { ActionBar } from '@clerk/mosaic/components/action-bar'; import { Button } from '@clerk/mosaic/components/button'; import { Icon } from '@clerk/mosaic/components/icon'; import { Menu } from '@clerk/mosaic/components/menu'; -import { useState } from 'react'; +import { Table } from '@clerk/mosaic/components/table'; +import { useDataTable } from '@clerk/mosaic/primitives/hooks'; +import { useId } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -16,65 +18,116 @@ export const meta: StoryMeta = { source: 'packages/mosaic/src/components/action-bar/action-bar.tsx', }; +const members = [ + { id: 'kyle', name: 'Kyle Mac', role: 'Member' }, + { id: 'austin', name: 'Austin Calvelage', role: 'Member' }, + { id: 'colin', name: 'Colin Sidoti', role: 'Admin' }, + { id: 'max', name: 'Max Yinger', role: 'Member' }, + { id: 'preston', name: 'Preston Booth', role: 'Member' }, + { id: 'richard', name: 'Richard Hendricks', role: 'Member' }, + { id: 'monica', name: 'Monica Hall', role: 'Admin' }, + { id: 'jared', name: 'Jared Dunn', role: 'Member' }, + { id: 'dinesh', name: 'Dinesh Chugtai', role: 'Member' }, + { id: 'steve', name: 'Steve Hayes', role: 'Admin' }, +]; + /** - * The bar sits at the foot of its nearest positioned ancestor. The example gives that ancestor a - * height and `position: relative` so the bar floats over its bottom; toggle the selection to see - * it animate in and out. + * The bar rests on the table's bottom edge. The example sits in a short scroll container, as a + * `Profile` content column does in a dialog: select a few rows and scroll up, and the bar stays + * pinned to the foot of the container until the table's edge comes back into view. */ export function Default() { - const [count, setCount] = useState(3); + const tableId = useId(); + const table = useDataTable({ data: members, getRowId: row => row.id }); + const count = Object.values(table.rowSelection).filter(Boolean).length; + const clearSelection = () => table.setRowSelection({}); + return ( -
- - 0} - aria-label='Bulk actions' - > - {count} selected - - - + + + + + table.toggleAllRowsSelected()} /> - } - > - Change role - - - - - Admin - - - Member - - - - - - - setCount(0)} /> - + {count} selected + + + + } + > + Change role + + + + + Admin + + + Member + + + + + + + + + +

1–10 of 10

); } From 031f433ae444843d4de12654f53587cbd10b5322 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 23 Sep 2026 15:54:26 -0600 Subject: [PATCH 03/16] test(mosaic): cover focus return through a confirmation dialog in the action bar Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/action-bar/action-bar.test.tsx | 64 ++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/components/action-bar/action-bar.test.tsx b/packages/mosaic/src/components/action-bar/action-bar.test.tsx index 7f13d85a7f4..e824ee7f6b0 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.test.tsx +++ b/packages/mosaic/src/components/action-bar/action-bar.test.tsx @@ -1,9 +1,10 @@ import * as stylex from '@stylexjs/stylex'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { Dialog } from '../dialog'; import { Menu } from '../menu'; import { ActionBar } from './action-bar'; @@ -165,4 +166,65 @@ describe('Mosaic ActionBar', () => { await userEvent.click(screen.getByRole('button', { name: 'Clear selection' })); expect(onDismiss).toHaveBeenCalledOnce(); }); + describe('with a confirmation dialog', () => { + function ConfirmRemove() { + const [selected, setSelected] = React.useState(true); + const [confirming, setConfirming] = React.useState(false); + const selectRef = React.useRef(null); + return ( + + + + + Remove + (selected ? undefined : selectRef.current)}> + Cancel + + + + + + ); + } + + it('returns focus to the bar when the dialog is cancelled', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('checkbox', { name: 'Select Kyle' })); + await user.tab(); + await user.keyboard('{Enter}'); + await user.click(await screen.findByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Remove' })).toHaveFocus()); + }); + + it('sends focus past the closed bar when the dialog confirms', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('checkbox', { name: 'Select Kyle' })); + await user.tab(); + await user.keyboard('{Enter}'); + await user.click(await screen.findByRole('button', { name: 'Confirm' })); + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Select Kyle' })).toHaveFocus()); + }); + }); }); From a34f5dbb722d4b7813ee13559159d98d9d1cc5f4 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 23 Sep 2026 16:02:27 -0600 Subject: [PATCH 04/16] fix(mosaic): use tabular figures for the action bar count Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/mosaic/src/components/action-bar/action-bar.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/components/action-bar/action-bar.tsx b/packages/mosaic/src/components/action-bar/action-bar.tsx index ee57457fb77..7e82e4709ac 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.tsx +++ b/packages/mosaic/src/components/action-bar/action-bar.tsx @@ -5,6 +5,7 @@ import { useRender } from '../../primitives/utils'; import type { MosaicComponentProps, MosaicElementProps, XStyle } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../utils/reset.styles'; +import { tabularNumbersStyle } from '../../utils/typography.styles'; import { Button } from '../button'; import { Icon } from '../icon'; import { styles } from './action-bar.styles'; @@ -224,7 +225,11 @@ const Count = React.forwardRef(function Act props: { 'aria-live': 'polite', 'aria-atomic': true, - ...mergeStyleProps(themeProps('action-bar-count'), stylex.props(reset.base, styles.count, xstyle), rest), + ...mergeStyleProps( + themeProps('action-bar-count'), + stylex.props(reset.base, styles.count, tabularNumbersStyle.enabled, xstyle), + rest, + ), id, children, }, From fe5d3194e463b2754f8dbfec80409a75a5b26ec4 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 23 Sep 2026 16:11:56 -0600 Subject: [PATCH 05/16] fix(mosaic): keep action bar focus through menus and dialogs opened from it Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/action-bar/action-bar.test.tsx | 66 ++++++++++++++- .../src/components/action-bar/action-bar.tsx | 82 ++++++++++++++----- packages/swingset/src/stories/action-bar.mdx | 5 +- .../src/stories/action-bar.stories.tsx | 5 +- 4 files changed, 134 insertions(+), 24 deletions(-) diff --git a/packages/mosaic/src/components/action-bar/action-bar.test.tsx b/packages/mosaic/src/components/action-bar/action-bar.test.tsx index e824ee7f6b0..5eb6283a946 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.test.tsx +++ b/packages/mosaic/src/components/action-bar/action-bar.test.tsx @@ -166,6 +166,68 @@ describe('Mosaic ActionBar', () => { await userEvent.click(screen.getByRole('button', { name: 'Clear selection' })); expect(onDismiss).toHaveBeenCalledOnce(); }); + describe('with a menu', () => { + function ChangeRole() { + const [selected, setSelected] = React.useState(true); + const selectAllRef = React.useRef(null); + return ( + + + + + Change role + + setSelected(false)} + > + Admin + + + + + + + + ); + } + + it('sends focus to returnFocus when a menu item closes the bar', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('checkbox', { name: 'Select all' })); + await user.tab(); + await user.keyboard('{Enter}'); + await screen.findByRole('menuitem', { name: 'Admin' }); + await user.keyboard('{Enter}'); + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Select all' })).toHaveFocus()); + expect(screen.getByRole('toolbar', { hidden: true }).inert).toBe(true); + }); + + it('keeps a tab stop after tabbing out of an open menu', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('checkbox', { name: 'Select all' })); + await user.tab(); + await user.keyboard('{Enter}'); + await screen.findByRole('menuitem', { name: 'Admin' }); + await user.tab(); + await waitFor(() => expect(screen.queryByRole('menuitem')).not.toBeInTheDocument()); + const stops = Array.from(screen.getByRole('toolbar').querySelectorAll('button')).filter( + button => button.tabIndex === 0, + ); + expect(stops).toHaveLength(1); + }); + }); + describe('with a confirmation dialog', () => { function ConfirmRemove() { const [selected, setSelected] = React.useState(true); @@ -189,7 +251,7 @@ describe('Mosaic ActionBar', () => { role='alertdialog' > Remove - (selected ? undefined : selectRef.current)}> + Cancel - - - - + Change role + + + + + Admin + + + Member + + + + + + + + + + ); +} + +/** Select rows and scroll: the bar stays pinned until the table's bottom edge comes into view. */ +export function Default() { + return ( +
+

1–10 of 10

); } + +function MembersProfile(props: Partial>) { + return ( + + Workspace + + + } + > + Members + + + + +
+ Members + +

1–18 of 18

+
+
+
+
+ ); +} + +/** In a profile dialog the bar pins to the foot of the content column. */ +export function InDialog() { + return ( + + }>Manage workspace + + + + + ); +} + +/** Inline, the page scrolls, so the bar pins to the foot of the viewport. */ +export function Inline() { + return ; +} From 5cabfffbb97290293d365167ec60b8826723c847 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 23 Sep 2026 16:53:19 -0600 Subject: [PATCH 08/16] refactor(mosaic): portal the action bar and track its table with floating-ui Positions against the table with shift and limitShift instead of CSS sticky, so no clipping ancestor can strand it, and uses Composite for roving focus. Adds ActionBar.Action, with a full-strength icon for destructive actions. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../action-bar/action-bar.styles.ts | 17 +- .../components/action-bar/action-bar.test.tsx | 114 +++--- .../src/components/action-bar/action-bar.tsx | 336 +++++++++++------- .../mosaic/src/components/action-bar/index.ts | 2 +- packages/swingset/src/stories/action-bar.mdx | 82 +++-- .../src/stories/action-bar.stories.tsx | 51 +-- 6 files changed, 374 insertions(+), 228 deletions(-) diff --git a/packages/mosaic/src/components/action-bar/action-bar.styles.ts b/packages/mosaic/src/components/action-bar/action-bar.styles.ts index 1350b3a032d..e3026c5ea64 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.styles.ts +++ b/packages/mosaic/src/components/action-bar/action-bar.styles.ts @@ -14,18 +14,16 @@ import { const reduceMotion = '@media (prefers-reduced-motion: reduce)'; export const styles = stylex.create({ - anchor: { - display: 'flow-root', - }, positioner: { - alignItems: 'center', - display: 'flex', - justifyContent: 'center', pointerEvents: 'none', - position: 'sticky', zIndex: 1, - bottom: `calc(${space['5']} + ${space['4']})`, - height: 0, + width: 'max-content', + }, + items: { + display: 'contents', + }, + destructive: { + '--_cl-icon-color': colorVars['--cl-color-negative'], }, bar: { borderRadius: radiusVars['--cl-radius-lg'], @@ -66,7 +64,6 @@ export const styles = stylex.create({ default: easingVars['--cl-ease-enter'], ':is([data-open="false"])': easingVars['--cl-ease-exit'], }, - maxWidth: 'calc(100% - 2 * var(--cl-spacing))', }, count: { paddingInline: space['2'], diff --git a/packages/mosaic/src/components/action-bar/action-bar.test.tsx b/packages/mosaic/src/components/action-bar/action-bar.test.tsx index 5eb6283a946..083d2c55394 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.test.tsx +++ b/packages/mosaic/src/components/action-bar/action-bar.test.tsx @@ -2,7 +2,7 @@ import * as stylex from '@stylexjs/stylex'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Dialog } from '../dialog'; import { Menu } from '../menu'; @@ -15,9 +15,13 @@ const testStyles = stylex.create({ }); function BulkActions({ open = true, onDismiss }: { open?: boolean; onDismiss?: () => void }) { + const tableRef = React.useRef(null); return ( - - + <> +
@@ -31,20 +35,36 @@ function BulkActions({ open = true, onDismiss }: { open?: boolean; onDismiss?: (
3 selected - - + Change role + Remove -
+ + ); } +function useAnchor() { + return React.useRef(null); +} + +beforeEach(() => { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 100, 40)); + vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1024); + vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(768); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + describe('Mosaic ActionBar', () => { it('renders a labelled toolbar tied to its table and described by its count', () => { render(); @@ -66,13 +86,17 @@ describe('Mosaic ActionBar', () => { }); it('styles the positioner independently from the bar', () => { - render( - , - ); + function Positioned() { + return ( + + ); + } + render(); expect(screen.getByRole('toolbar', { name: 'Bulk actions' }).parentElement).toHaveClass( ...(stylex.props(testStyles.positioner).className as string).split(' '), ); @@ -85,7 +109,7 @@ describe('Mosaic ActionBar', () => { await user.tab(); expect(screen.getByRole('button', { name: 'Change role' })).toHaveFocus(); await user.tab(); - expect(document.body).toHaveFocus(); + expect(screen.getByRole('button', { name: 'After' })).toHaveFocus(); }); it('moves between controls with the arrow keys, Home, and End', async () => { @@ -118,22 +142,26 @@ describe('Mosaic ActionBar', () => { it('leaves arrow keys inside a portalled menu to the menu', async () => { const user = userEvent.setup(); - render( - - - Change role - - - Admin - - - - - , - ); + function WithMenu() { + return ( + + + }>Change role + + + Admin + + + + Remove + + ); + } + render(); await user.click(screen.getByRole('button', { name: 'Change role' })); const item = await screen.findByRole('menuitem', { name: 'Admin' }); item.focus(); @@ -171,7 +199,7 @@ describe('Mosaic ActionBar', () => { const [selected, setSelected] = React.useState(true); const selectAllRef = React.useRef(null); return ( - + <> { /> - Change role + }>Change role { - + Remove - + ); } @@ -212,7 +241,7 @@ describe('Mosaic ActionBar', () => { expect(screen.getByRole('toolbar', { hidden: true }).inert).toBe(true); }); - it('keeps a tab stop after tabbing out of an open menu', async () => { + it('lets Tab leave an open menu and Shift+Tab come back to the bar', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('checkbox', { name: 'Select all' })); @@ -220,11 +249,9 @@ describe('Mosaic ActionBar', () => { await user.keyboard('{Enter}'); await screen.findByRole('menuitem', { name: 'Admin' }); await user.tab(); - await waitFor(() => expect(screen.queryByRole('menuitem')).not.toBeInTheDocument()); - const stops = Array.from(screen.getByRole('toolbar').querySelectorAll('button')).filter( - button => button.tabIndex === 0, - ); - expect(stops).toHaveLength(1); + await waitFor(() => expect(screen.getByRole('button', { name: 'After' })).toHaveFocus()); + await user.tab({ shift: true }); + expect(screen.getByRole('button', { name: 'Change role' })).toHaveFocus(); }); }); @@ -234,7 +261,7 @@ describe('Mosaic ActionBar', () => { const [confirming, setConfirming] = React.useState(false); const selectRef = React.useRef(null); return ( - + <> { /> @@ -250,7 +278,7 @@ describe('Mosaic ActionBar', () => { onOpenChange={setConfirming} role='alertdialog' > - Remove + }>Remove Cancel + ); }); /** Bulk actions for a table selection. */ -export const ActionBar = { Anchor, Root, Count, Separator, Dismiss }; +export const ActionBar = { Root, Action, Count, Separator, Dismiss }; diff --git a/packages/mosaic/src/components/action-bar/index.ts b/packages/mosaic/src/components/action-bar/index.ts index 9fc408619c9..4008e2405a7 100644 --- a/packages/mosaic/src/components/action-bar/index.ts +++ b/packages/mosaic/src/components/action-bar/index.ts @@ -1,6 +1,6 @@ export { ActionBar } from './action-bar'; export type { - ActionBarAnchorProps, + ActionBarActionProps, ActionBarCountProps, ActionBarDismissProps, ActionBarRootProps, diff --git a/packages/swingset/src/stories/action-bar.mdx b/packages/swingset/src/stories/action-bar.mdx index c3e5d6d67ac..28f3c85a339 100644 --- a/packages/swingset/src/stories/action-bar.mdx +++ b/packages/swingset/src/stories/action-bar.mdx @@ -5,7 +5,8 @@ import * as ActionBarStories from './action-bar.stories'; A floating toolbar of actions for a current selection. It rests on the bottom edge of the table it acts on and, while that edge is scrolled out of view, stays pinned to the foot of the nearest scroll container — the content column of a profile in a dialog, or the page when the profile is inline. It -animates in when opened. While closed it is inert and click-through. +is portalled, so no ancestor clips it. It animates in when opened, and while closed it is inert and +click-through. ## Example @@ -35,46 +36,65 @@ animates in when opened. While closed it is inert and click-through. ## Usage ```tsx - - {/* rows */} - 0} - aria-label='Bulk actions' - aria-controls={tableId} - > - {selectedCount} selected - - {/* Change role */} - - - - - +{/* rows */} + 0} + anchor={tableRef} + aria-label='Bulk actions' + aria-controls={tableId} + returnFocus={selectAllRef} +> + {selectedCount} selected + + + }>Change role + {/* … */} + + + + + + + ``` +Inside a modal dialog, pass the dialog's popup element as `portalRoot`, so the bar stays inside the +dialog. + ## Parts -| Part | Element | Description | -| --------------------- | -------- | ------------------------------------------------------------------------------------ | -| `ActionBar.Anchor` | `div` | Wraps the table and the bar. The bar never leaves the anchor's bounds. | -| `ActionBar.Root` | `div` | The positioner + floating surface, last in the anchor. Takes `open`; `role=toolbar`. | -| `ActionBar.Count` | `div` | The leading count of what is selected. Describes the toolbar; announced politely. | -| `ActionBar.Separator` | `div` | A vertical divider between groups. | -| `ActionBar.Dismiss` | `button` | Clears the selection. A ghost icon button; labelled `Clear selection`. | +| Part | Element | Description | +| --------------------- | -------- | -------------------------------------------------------------------------------------------- | +| `ActionBar.Root` | `div` | The positioner + floating surface, portalled. Takes `open` and `anchor`; `role=toolbar`. | +| `ActionBar.Action` | `button` | A ghost button in the bar's arrow-key navigation. `render` it from a menu or dialog trigger. | +| `ActionBar.Count` | `div` | The leading count of what is selected. Describes the toolbar; announced politely. | +| `ActionBar.Separator` | `div` | A vertical divider between groups. | +| `ActionBar.Dismiss` | `button` | Clears the selection. An icon `ActionBar.Action`; labelled `Clear selection`. | + +## Props + +### `ActionBar.Root` + +| Prop | Type | Default | Description | +| ------------- | ------------------------ | --------------------- | --------------------------------------------------------- | +| `open` | `boolean` | — (required) | Whether the bar is shown. | +| `anchor` | `RefObject` | — (required) | The table the bar rests on. | +| `portalRoot` | `HTMLElement \| null` | `document.body` | Where the bar is portalled. `null` waits for the element. | +| `returnFocus` | `RefObject` | where focus came from | Where focus goes when the bar closes while holding it. | + +### `ActionBar.Action` + +Takes `Button`'s props. `color='negative'` draws the icon at full strength. ## Accessibility -- The bar is a single tab stop. `←` / `→` move between its controls and wrap; `Home` / `End` jump to - the first and last. +- The bar is a single tab stop, in tab order right after where `ActionBar.Root` renders. `←` / `→` + move between its actions and wrap; `Home` / `End` jump to the first and last. - Give it an `aria-label`, and point `aria-controls` at the table's `id`. - When the bar closes while it holds focus, including from a menu or dialog opened inside it, focus - moves to `returnFocus`. Pass the table's select-all checkbox. Without it, focus returns to the - element it came from. + moves to `returnFocus`. Pass the table's select-all checkbox. ## Styling `ActionBar.Root` reflects `data-open` (`true` / `false`) on its surface, so the entrance -transition and the closed (click-through) state are both selectable. The actions themselves are -whatever children you place between the parts. +transition and the closed (click-through) state are both selectable. diff --git a/packages/swingset/src/stories/action-bar.stories.tsx b/packages/swingset/src/stories/action-bar.stories.tsx index 11f503ec53d..4c6e76983a8 100644 --- a/packages/swingset/src/stories/action-bar.stories.tsx +++ b/packages/swingset/src/stories/action-bar.stories.tsx @@ -7,7 +7,7 @@ import { Profile } from '@clerk/mosaic/components/profile'; import { Table } from '@clerk/mosaic/components/table'; import { useDataTable } from '@clerk/mosaic/primitives/hooks'; import type { ComponentProps } from 'react'; -import { useId, useRef } from 'react'; +import { useId, useRef, useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -42,16 +42,20 @@ const members = [ { id: 'ron', name: 'Ron LaFlamme', role: 'Member' }, ]; -function MembersTable({ rows = 10 }: { rows?: number }) { +function MembersTable({ rows = 10, portalRoot }: { rows?: number; portalRoot?: HTMLElement | null }) { const tableId = useId(); + const tableRef = useRef(null); const selectAllRef = useRef(null); const table = useDataTable({ data: members.slice(0, rows), getRowId: row => row.id }); const count = Object.values(table.rowSelection).filter(Boolean).length; const clearSelection = () => table.setRowSelection({}); return ( - - + <> + 0} + anchor={tableRef} + portalRoot={portalRoot} aria-label='Bulk actions' aria-controls={tableId} returnFocus={selectAllRef} @@ -91,14 +97,7 @@ function MembersTable({ rows = 10 }: { rows?: number }) { {count} selected - - } - > + }> Change role - + - + ); } @@ -148,7 +145,10 @@ export function Default() { ); } -function MembersProfile(props: Partial>) { +function MembersProfile({ + portalRoot, + ...props +}: Partial> & { portalRoot?: HTMLElement | null }) { return ( >) {
Members - +

1–18 of 18

@@ -181,13 +184,17 @@ function MembersProfile(props: Partial>) { ); } -/** In a profile dialog the bar pins to the foot of the content column. */ +/** In a profile dialog the bar is portalled into the dialog and pins to the foot of the content column. */ export function InDialog() { + const [popup, setPopup] = useState(null); return ( }>Manage workspace - - + + ); From 092a97cec05ccded971b4a3ebba971617a042b15 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 23 Sep 2026 17:01:38 -0600 Subject: [PATCH 09/16] fix(mosaic): let menus and dialogs stack above the action bar Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/mosaic/src/components/action-bar/action-bar.styles.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/mosaic/src/components/action-bar/action-bar.styles.ts b/packages/mosaic/src/components/action-bar/action-bar.styles.ts index e3026c5ea64..cf9eae21334 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.styles.ts +++ b/packages/mosaic/src/components/action-bar/action-bar.styles.ts @@ -16,7 +16,6 @@ const reduceMotion = '@media (prefers-reduced-motion: reduce)'; export const styles = stylex.create({ positioner: { pointerEvents: 'none', - zIndex: 1, width: 'max-content', }, items: { From 20938b2a951ad4a8d73130319778ba0d1a342d4c Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 23 Sep 2026 17:10:38 -0600 Subject: [PATCH 10/16] fix(mosaic): hold the action bar count through its exit and hang it lower off the table Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/action-bar/action-bar.test.tsx | 19 ++++++++++++++++++ .../src/components/action-bar/action-bar.tsx | 20 +++++++++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/mosaic/src/components/action-bar/action-bar.test.tsx b/packages/mosaic/src/components/action-bar/action-bar.test.tsx index 083d2c55394..fd9044b6b0f 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.test.tsx +++ b/packages/mosaic/src/components/action-bar/action-bar.test.tsx @@ -85,6 +85,25 @@ describe('Mosaic ActionBar', () => { expect(bar.inert).toBe(true); }); + it('holds its last contents while it closes', () => { + function Selection({ count }: { count: number }) { + return ( + 0} + anchor={useAnchor()} + aria-label='Bulk actions' + > + {count} selected + + ); + } + const { rerender } = render(); + rerender(); + expect(screen.getByText('2 selected')).toBeInTheDocument(); + rerender(); + expect(screen.getByText('5 selected')).toBeInTheDocument(); + }); + it('styles the positioner independently from the bar', () => { function Positioned() { return ( diff --git a/packages/mosaic/src/components/action-bar/action-bar.tsx b/packages/mosaic/src/components/action-bar/action-bar.tsx index c8e35ccbd46..9de40b4ee15 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.tsx +++ b/packages/mosaic/src/components/action-bar/action-bar.tsx @@ -28,7 +28,10 @@ import { styles } from './action-bar.styles'; const ITEM_ATTRIBUTE = 'data-action-bar-item'; const EDGE_GAP = 16; -const ActionBarContext = React.createContext<{ registerCount: (id: string | undefined) => void } | null>(null); +const ActionBarContext = React.createContext<{ + open: boolean; + registerCount: (id: string | undefined) => void; +} | null>(null); function scrollingAncestors(element: Element): Element[] { return getOverflowAncestors(element).filter( @@ -72,7 +75,7 @@ const Root = React.forwardRef(function Actio const lastFocusedRef = React.useRef(null); const [activeIndex, setActiveIndex] = React.useState(0); const [countId, setCountId] = React.useState(); - const context = React.useMemo(() => ({ registerCount: setCountId }), []); + const context = React.useMemo(() => ({ open, registerCount: setCountId }), [open]); const { refs, @@ -85,7 +88,7 @@ const Root = React.forwardRef(function Actio open, placement: 'bottom', middleware: [ - offset(({ rects }) => -rects.floating.height / 2), + offset(({ rects }) => -rects.floating.height / 4), { name: 'shiftIntoScrollport', fn: state => @@ -317,14 +320,19 @@ const Action = React.forwardRef(functio export type ActionBarCountProps = MosaicComponentProps<'div'>; -/** The selected count. Describes the toolbar and announces changes. */ +/** The selected count. Describes the toolbar, announces changes, and holds its text while the bar closes. */ const Count = React.forwardRef(function ActionBarCount( { render, xstyle, id: idProp, children, ...rest }, ref, ) { const generatedId = React.useId(); const id = idProp ?? generatedId; - const registerCount = React.useContext(ActionBarContext)?.registerCount; + const context = React.useContext(ActionBarContext); + const registerCount = context?.registerCount; + const openChildrenRef = React.useRef(children); + if (context?.open !== false) { + openChildrenRef.current = children; + } React.useLayoutEffect(() => { registerCount?.(id); return () => registerCount?.(undefined); @@ -343,7 +351,7 @@ const Count = React.forwardRef(function Act rest, ), id, - children, + children: openChildrenRef.current, }, }); }); From fd024447bb37bf4b62553c420650602ab4d20972 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 23 Sep 2026 17:41:11 -0600 Subject: [PATCH 11/16] refactor(mosaic): use shared DOM utils in the action bar Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/action-bar/action-bar.tsx | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/packages/mosaic/src/components/action-bar/action-bar.tsx b/packages/mosaic/src/components/action-bar/action-bar.tsx index 9de40b4ee15..e3639dd7bf0 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.tsx +++ b/packages/mosaic/src/components/action-bar/action-bar.tsx @@ -12,10 +12,12 @@ import { useFloating, useMergeRefs, } from '@floating-ui/react'; +import { activeElement, contains, getDocument, getTarget } from '@floating-ui/react/utils'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; import { mergeProps, useRender } from '../../primitives/utils'; +import { getComputedStyle } from '../../primitives/utils/dom'; import type { MosaicComponentProps, XStyle } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../utils/reset.styles'; @@ -119,11 +121,11 @@ const Root = React.forwardRef(function Actio if (!open || !barElement) { return; } - const doc = barElement.ownerDocument; + const doc = getDocument(barElement); const recordOrigin = (event: FocusEvent) => { - const target = event.target as HTMLElement; + const target = getTarget(event) as HTMLElement; if ( - !barElement.contains(target) && + !contains(barElement, target) && target !== lastFocusedRef.current && !target.hasAttribute('data-floating-ui-focus-guard') ) { @@ -141,10 +143,11 @@ const Root = React.forwardRef(function Actio } const target = returnFocus?.current ?? originRef.current; bar.inert = true; - if (target?.isConnected && !bar.contains(target)) { + const active = activeElement(getDocument(bar)) as HTMLElement | null; + if (target?.isConnected && !contains(bar, target)) { target.focus(); - } else if (bar.contains(bar.ownerDocument.activeElement)) { - (bar.ownerDocument.activeElement as HTMLElement).blur(); + } else if (contains(bar, active)) { + active?.blur(); } }, [returnFocus]); @@ -158,9 +161,9 @@ const Root = React.forwardRef(function Actio return; } setActiveIndex(0); - const doc = bar.ownerDocument; - const active = doc.activeElement as HTMLElement | null; - if (bar.contains(active)) { + const doc = getDocument(bar); + const active = activeElement(doc) as HTMLElement | null; + if (contains(bar, active)) { restoreFocus(); return; } @@ -169,10 +172,10 @@ const Root = React.forwardRef(function Actio return; } let frame = requestAnimationFrame(function watch() { - const current = doc.activeElement; + const current = activeElement(doc); if (current === active && active.isConnected) { frame = requestAnimationFrame(watch); - } else if (!current || current === doc.body || bar.contains(current)) { + } else if (!current || current === doc.body || contains(bar, current)) { restoreFocus(); } else { bar.inert = true; @@ -184,20 +187,16 @@ const Root = React.forwardRef(function Actio const handleFocus = (event: React.FocusEvent) => { onFocus?.(event); const bar = barRef.current; - const target = event.target as HTMLElement; + const target = getTarget(event.nativeEvent) as HTMLElement; lastFocusedRef.current = target; - if (!bar?.contains(target)) { - return; - } - if (!open) { + if (bar && !open && contains(bar, target)) { restoreFocus(); - return; } }; const handleItemsKeyDown = (event: React.KeyboardEvent) => { const bar = barRef.current; - if (!bar?.contains(event.target as Node)) { + if (!bar || !contains(bar, getTarget(event.nativeEvent) as Element)) { event.stopPropagation(); return; } From 85d232dc354a31b52b9409377ed06e3f6c680829 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 23 Sep 2026 17:42:27 -0600 Subject: [PATCH 12/16] chore(mosaic): trim action bar comments Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/action-bar/action-bar.tsx | 17 ++++------------- .../swingset/src/stories/action-bar.stories.tsx | 3 --- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/packages/mosaic/src/components/action-bar/action-bar.tsx b/packages/mosaic/src/components/action-bar/action-bar.tsx index e3639dd7bf0..1bbbbd3c050 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.tsx +++ b/packages/mosaic/src/components/action-bar/action-bar.tsx @@ -51,18 +51,17 @@ function enabledItems(bar: HTMLElement): HTMLElement[] { } export interface ActionBarRootProps extends MosaicComponentProps<'div'> { - /** Whether the bar is shown. Toggling it animates the bar in and out. */ + /** Whether the bar is shown. */ open: boolean; - /** The table the bar acts on. The bar rests on its bottom edge and stays in view while it scrolls. */ + /** The table the bar rests on. */ anchor: React.RefObject; - /** Where the bar is portalled; `null` waits for the element. Inside a modal dialog, pass the dialog's popup. */ + /** Where the bar is portalled. Inside a modal dialog, pass its popup. */ portalRoot?: HTMLElement | null; - /** Where focus goes when the bar closes while holding it. Defaults to where focus came from. */ + /** Where focus goes when the bar closes while holding it. */ returnFocus?: React.RefObject; positionerXstyle?: XStyle; } -/** A floating toolbar of actions for a selection. */ const Root = React.forwardRef(function ActionBarRoot( { open, anchor, portalRoot, returnFocus, render, xstyle, positionerXstyle, children, onFocus, ...rest }, ref, @@ -285,10 +284,6 @@ const Root = React.forwardRef(function Actio export type ActionBarActionProps = ButtonProps; -/** - * A control in the bar: a ghost button that joins the bar's arrow-key navigation. Pass it as the - * `render` of a `Menu.Trigger` or `Dialog.Trigger` to open one from the bar. - */ const Action = React.forwardRef(function ActionBarAction( { color, xstyle, ...rest }, ref, @@ -319,7 +314,6 @@ const Action = React.forwardRef(functio export type ActionBarCountProps = MosaicComponentProps<'div'>; -/** The selected count. Describes the toolbar, announces changes, and holds its text while the bar closes. */ const Count = React.forwardRef(function ActionBarCount( { render, xstyle, id: idProp, children, ...rest }, ref, @@ -357,7 +351,6 @@ const Count = React.forwardRef(function Act export type ActionBarSeparatorProps = MosaicComponentProps<'div'>; -/** A vertical divider between groups of the bar. */ const Separator = React.forwardRef(function ActionBarSeparator( { render, xstyle, ...rest }, ref, @@ -376,7 +369,6 @@ const Separator = React.forwardRef(func export type ActionBarDismissProps = Omit; -/** Clears the selection. Labelled "Clear selection" by default. */ const Dismiss = React.forwardRef(function ActionBarDismiss( { 'aria-label': ariaLabel, ...rest }, ref, @@ -393,5 +385,4 @@ const Dismiss = React.forwardRef(funct ); }); -/** Bulk actions for a table selection. */ export const ActionBar = { Root, Action, Count, Separator, Dismiss }; diff --git a/packages/swingset/src/stories/action-bar.stories.tsx b/packages/swingset/src/stories/action-bar.stories.tsx index 4c6e76983a8..39f4621c14e 100644 --- a/packages/swingset/src/stories/action-bar.stories.tsx +++ b/packages/swingset/src/stories/action-bar.stories.tsx @@ -135,7 +135,6 @@ function MembersTable({ rows = 10, portalRoot }: { rows?: number; portalRoot?: H ); } -/** Select rows and scroll: the bar stays pinned until the table's bottom edge comes into view. */ export function Default() { return (
@@ -184,7 +183,6 @@ function MembersProfile({ ); } -/** In a profile dialog the bar is portalled into the dialog and pins to the foot of the content column. */ export function InDialog() { const [popup, setPopup] = useState(null); return ( @@ -200,7 +198,6 @@ export function InDialog() { ); } -/** Inline, the page scrolls, so the bar pins to the foot of the viewport. */ export function Inline() { return ; } From ec75b577a5b9e233d074a2e739095b26aec6f3fe Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 23 Sep 2026 17:51:43 -0600 Subject: [PATCH 13/16] feat(mosaic): announce the action bar from a status region outside it Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/action-bar/action-bar.test.tsx | 33 +++++++++++++- .../src/components/action-bar/action-bar.tsx | 43 +++++++++++++++++-- packages/swingset/src/stories/action-bar.mdx | 20 ++++++--- .../src/stories/action-bar.stories.tsx | 2 + 4 files changed, 87 insertions(+), 11 deletions(-) diff --git a/packages/mosaic/src/components/action-bar/action-bar.test.tsx b/packages/mosaic/src/components/action-bar/action-bar.test.tsx index fd9044b6b0f..a784fb96791 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.test.tsx +++ b/packages/mosaic/src/components/action-bar/action-bar.test.tsx @@ -74,7 +74,6 @@ describe('Mosaic ActionBar', () => { expect(bar).toHaveAttribute('aria-controls', 'members'); expect(bar).toHaveAttribute('aria-orientation', 'horizontal'); expect(bar).toHaveAccessibleDescription('3 selected'); - expect(screen.getByText('3 selected')).toHaveAttribute('aria-live', 'polite'); expect(screen.getAllByRole('separator')).toHaveLength(2); }); @@ -85,6 +84,38 @@ describe('Mosaic ActionBar', () => { expect(bar.inert).toBe(true); }); + it('announces the bar opening, then selection changes, from outside the bar', () => { + function Selection({ count }: { count: number }) { + return ( + 0} + anchor={useAnchor()} + aria-label='Bulk actions' + announcement={`${count} selected`} + openAnnouncement={`${count} selected, bulk actions follow the table`} + > + {count} selected + + ); + } + const { rerender } = render(); + const status = screen.getByRole('status'); + expect(status).toHaveTextContent(''); + expect(status.closest('[role="toolbar"]')).toBeNull(); + + rerender(); + expect(status).toHaveTextContent('1 selected, bulk actions follow the table'); + + rerender(); + expect(status).toHaveTextContent(/^3 selected$/); + + rerender(); + expect(status).toHaveTextContent(''); + + rerender(); + expect(status).toHaveTextContent('2 selected, bulk actions follow the table'); + }); + it('holds its last contents while it closes', () => { function Selection({ count }: { count: number }) { return ( diff --git a/packages/mosaic/src/components/action-bar/action-bar.tsx b/packages/mosaic/src/components/action-bar/action-bar.tsx index 1bbbbd3c050..e9c6b23a602 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.tsx +++ b/packages/mosaic/src/components/action-bar/action-bar.tsx @@ -25,6 +25,7 @@ import { tabularNumbersStyle } from '../../utils/typography.styles'; import type { ButtonProps } from '../button'; import { Button } from '../button'; import { Icon } from '../icon'; +import { VisuallyHidden } from '../visually-hidden'; import { styles } from './action-bar.styles'; const ITEM_ATTRIBUTE = 'data-action-bar-item'; @@ -59,11 +60,28 @@ export interface ActionBarRootProps extends MosaicComponentProps<'div'> { portalRoot?: HTMLElement | null; /** Where focus goes when the bar closes while holding it. */ returnFocus?: React.RefObject; + /** Announced when the selection changes while the bar is open, e.g. `3 selected`. */ + announcement?: string; + /** Announced when the bar opens, e.g. `1 selected, bulk actions follow the table`. Defaults to `announcement`. */ + openAnnouncement?: string; positionerXstyle?: XStyle; } const Root = React.forwardRef(function ActionBarRoot( - { open, anchor, portalRoot, returnFocus, render, xstyle, positionerXstyle, children, onFocus, ...rest }, + { + open, + anchor, + portalRoot, + returnFocus, + announcement, + openAnnouncement, + render, + xstyle, + positionerXstyle, + children, + onFocus, + ...rest + }, ref, ) { const barRef = React.useRef(null); @@ -73,6 +91,20 @@ const Root = React.forwardRef(function Actio setBarElement(node); }, []); const originRef = React.useRef(null); + const wasOpenRef = React.useRef(false); + const [status, setStatus] = React.useState(''); + + React.useLayoutEffect(() => { + const wasOpen = wasOpenRef.current; + wasOpenRef.current = open; + if (!open) { + setStatus(''); + } else if (!wasOpen) { + setStatus(openAnnouncement ?? announcement ?? ''); + } else { + setStatus(announcement ?? ''); + } + }, [open, announcement, openAnnouncement]); const lastFocusedRef = React.useRef(null); const [activeIndex, setActiveIndex] = React.useState(0); const [countId, setCountId] = React.useState(); @@ -215,6 +247,13 @@ const Root = React.forwardRef(function Actio return ( + + {status} + (function Act render, ref, props: { - 'aria-live': 'polite', - 'aria-atomic': true, ...mergeStyleProps( themeProps('action-bar-count'), stylex.props(reset.base, styles.count, tabularNumbersStyle.enabled, xstyle), diff --git a/packages/swingset/src/stories/action-bar.mdx b/packages/swingset/src/stories/action-bar.mdx index 28f3c85a339..906cc18c481 100644 --- a/packages/swingset/src/stories/action-bar.mdx +++ b/packages/swingset/src/stories/action-bar.mdx @@ -43,6 +43,8 @@ click-through. aria-label='Bulk actions' aria-controls={tableId} returnFocus={selectAllRef} + announcement={`${selectedCount} selected`} + openAnnouncement={`${selectedCount} selected, bulk actions follow the table`} > {selectedCount} selected @@ -67,7 +69,7 @@ dialog. | --------------------- | -------- | -------------------------------------------------------------------------------------------- | | `ActionBar.Root` | `div` | The positioner + floating surface, portalled. Takes `open` and `anchor`; `role=toolbar`. | | `ActionBar.Action` | `button` | A ghost button in the bar's arrow-key navigation. `render` it from a menu or dialog trigger. | -| `ActionBar.Count` | `div` | The leading count of what is selected. Describes the toolbar; announced politely. | +| `ActionBar.Count` | `div` | The leading count of what is selected. Describes the toolbar. | | `ActionBar.Separator` | `div` | A vertical divider between groups. | | `ActionBar.Dismiss` | `button` | Clears the selection. An icon `ActionBar.Action`; labelled `Clear selection`. | @@ -75,12 +77,14 @@ dialog. ### `ActionBar.Root` -| Prop | Type | Default | Description | -| ------------- | ------------------------ | --------------------- | --------------------------------------------------------- | -| `open` | `boolean` | — (required) | Whether the bar is shown. | -| `anchor` | `RefObject` | — (required) | The table the bar rests on. | -| `portalRoot` | `HTMLElement \| null` | `document.body` | Where the bar is portalled. `null` waits for the element. | -| `returnFocus` | `RefObject` | where focus came from | Where focus goes when the bar closes while holding it. | +| Prop | Type | Default | Description | +| ------------------ | ------------------------ | --------------------- | ----------------------------------------------------------- | +| `open` | `boolean` | — (required) | Whether the bar is shown. | +| `anchor` | `RefObject` | — (required) | The table the bar rests on. | +| `portalRoot` | `HTMLElement \| null` | `document.body` | Where the bar is portalled. `null` waits for the element. | +| `returnFocus` | `RefObject` | where focus came from | Where focus goes when the bar closes while holding it. | +| `announcement` | `string` | — | Announced when the selection changes while the bar is open. | +| `openAnnouncement` | `string` | `announcement` | Announced when the bar opens. | ### `ActionBar.Action` @@ -91,6 +95,8 @@ Takes `Button`'s props. `color='negative'` draws the icon at full strength. - The bar is a single tab stop, in tab order right after where `ActionBar.Root` renders. `←` / `→` move between its actions and wrap; `Home` / `End` jump to the first and last. - Give it an `aria-label`, and point `aria-controls` at the table's `id`. +- A status region outside the bar announces `openAnnouncement` when the bar opens and + `announcement` when the selection changes after that. Closing is silent. - When the bar closes while it holds focus, including from a menu or dialog opened inside it, focus moves to `returnFocus`. Pass the table's select-all checkbox. diff --git a/packages/swingset/src/stories/action-bar.stories.tsx b/packages/swingset/src/stories/action-bar.stories.tsx index 39f4621c14e..ab965523cd4 100644 --- a/packages/swingset/src/stories/action-bar.stories.tsx +++ b/packages/swingset/src/stories/action-bar.stories.tsx @@ -93,6 +93,8 @@ function MembersTable({ rows = 10, portalRoot }: { rows?: number; portalRoot?: H aria-label='Bulk actions' aria-controls={tableId} returnFocus={selectAllRef} + announcement={`${count} selected`} + openAnnouncement={`${count} selected, bulk actions follow the table`} > {count} selected From ff184adde0f61b144fafb8df7736c074f240d7eb Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 24 Sep 2026 09:56:53 -0600 Subject: [PATCH 14/16] refactor(mosaic): mount the action bar on open and drop Home/End Uses useTransitionStatus and useAnimationsFinished to mount the bar only while open or animating out, which removes the inert handling, the focus watcher, and origin tracking. returnFocus is now the only focus return. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../action-bar/action-bar.styles.ts | 12 +- .../components/action-bar/action-bar.test.tsx | 71 +++-- .../src/components/action-bar/action-bar.tsx | 291 ++++++------------ packages/swingset/src/stories/action-bar.mdx | 29 +- 4 files changed, 157 insertions(+), 246 deletions(-) diff --git a/packages/mosaic/src/components/action-bar/action-bar.styles.ts b/packages/mosaic/src/components/action-bar/action-bar.styles.ts index cf9eae21334..238cf4e7b1c 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.styles.ts +++ b/packages/mosaic/src/components/action-bar/action-bar.styles.ts @@ -37,23 +37,23 @@ export const styles = stylex.create({ flexShrink: 0, opacity: { default: 1, - ':is([data-open="false"])': 0, + ':where([data-starting-style], [data-ending-style])': 0, }, pointerEvents: { default: 'auto', - ':is([data-open="false"])': 'none', + ':where([data-ending-style])': 'none', }, transform: { default: 'translateY(0)', [reduceMotion]: { default: 'translateY(0)', - ':is([data-open="false"])': 'translateY(0)', + ':where([data-starting-style], [data-ending-style])': 'translateY(0)', }, - ':is([data-open="false"])': 'translateY(0.25rem)', + ':where([data-starting-style], [data-ending-style])': 'translateY(0.25rem)', }, transitionDuration: { default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}`, - ':is([data-open="false"])': durationVars['--cl-duration-fast'], + ':where([data-ending-style])': durationVars['--cl-duration-fast'], }, transitionProperty: { default: 'opacity, transform', @@ -61,7 +61,7 @@ export const styles = stylex.create({ }, transitionTimingFunction: { default: easingVars['--cl-ease-enter'], - ':is([data-open="false"])': easingVars['--cl-ease-exit'], + ':where([data-ending-style])': easingVars['--cl-ease-exit'], }, }, count: { diff --git a/packages/mosaic/src/components/action-bar/action-bar.test.tsx b/packages/mosaic/src/components/action-bar/action-bar.test.tsx index a784fb96791..678a1a1a0fb 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.test.tsx +++ b/packages/mosaic/src/components/action-bar/action-bar.test.tsx @@ -2,7 +2,7 @@ import * as stylex from '@stylexjs/stylex'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; import { Dialog } from '../dialog'; import { Menu } from '../menu'; @@ -16,6 +16,7 @@ const testStyles = stylex.create({ function BulkActions({ open = true, onDismiss }: { open?: boolean; onDismiss?: () => void }) { const tableRef = React.useRef(null); + const selectRef = React.useRef(null); return ( <>
@@ -36,6 +38,7 @@ function BulkActions({ open = true, onDismiss }: { open?: boolean; onDismiss?: ( @@ -77,11 +80,9 @@ describe('Mosaic ActionBar', () => { expect(screen.getAllByRole('separator')).toHaveLength(2); }); - it('marks the bar inert while closed', () => { + it('renders nothing while closed', () => { render(); - const bar = screen.getByRole('toolbar', { name: 'Bulk actions', hidden: true }); - expect(bar).toHaveAttribute('data-open', 'false'); - expect(bar.inert).toBe(true); + expect(screen.queryByRole('toolbar', { hidden: true })).not.toBeInTheDocument(); }); it('announces the bar opening, then selection changes, from outside the bar', () => { @@ -116,7 +117,14 @@ describe('Mosaic ActionBar', () => { expect(status).toHaveTextContent('2 selected, bulk actions follow the table'); }); - it('holds its last contents while it closes', () => { + it('holds its last contents while it animates out', () => { + Object.defineProperty(HTMLElement.prototype, 'getAnimations', { + configurable: true, + value: () => [{ finished: new Promise(() => {}) }], + }); + onTestFinished(() => { + delete (HTMLElement.prototype as { getAnimations?: unknown }).getAnimations; + }); function Selection({ count }: { count: number }) { return ( { } const { rerender } = render(); rerender(); + expect(screen.getByRole('toolbar', { hidden: true })).toHaveAttribute('data-ending-style'); expect(screen.getByText('2 selected')).toBeInTheDocument(); rerender(); expect(screen.getByText('5 selected')).toBeInTheDocument(); @@ -162,21 +171,19 @@ describe('Mosaic ActionBar', () => { expect(screen.getByRole('button', { name: 'After' })).toHaveFocus(); }); - it('moves between controls with the arrow keys, Home, and End', async () => { + it('moves between controls with the arrow keys, wrapping at the ends', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('checkbox', { name: 'Select Kyle' })); await user.tab(); await user.keyboard('{ArrowRight}'); expect(screen.getByRole('button', { name: 'Remove' })).toHaveFocus(); - await user.keyboard('{End}'); + await user.keyboard('{ArrowRight}'); expect(screen.getByRole('button', { name: 'Clear selection' })).toHaveFocus(); await user.keyboard('{ArrowRight}'); expect(screen.getByRole('button', { name: 'Change role' })).toHaveFocus(); await user.keyboard('{ArrowLeft}'); expect(screen.getByRole('button', { name: 'Clear selection' })).toHaveFocus(); - await user.keyboard('{Home}'); - expect(screen.getByRole('button', { name: 'Change role' })).toHaveFocus(); }); it('returns to the last focused control when tabbed back into', async () => { @@ -219,7 +226,7 @@ describe('Mosaic ActionBar', () => { expect(screen.getByRole('button', { name: 'Remove' })).not.toHaveFocus(); }); - it('returns focus to where it came from when the bar closes around it', async () => { + it('sends focus to returnFocus when the bar closes around it', async () => { const user = userEvent.setup(); function Harness() { const [open, setOpen] = React.useState(true); @@ -234,8 +241,8 @@ describe('Mosaic ActionBar', () => { const checkbox = screen.getByRole('checkbox', { name: 'Select Kyle' }); await user.click(checkbox); await user.tab(); - await user.keyboard('{End}{Enter}'); - expect(checkbox).toHaveFocus(); + await user.keyboard('{ArrowLeft}{Enter}'); + await waitFor(() => expect(checkbox).toHaveFocus()); }); it('dismisses with a labelled button', async () => { @@ -288,7 +295,7 @@ describe('Mosaic ActionBar', () => { await screen.findByRole('menuitem', { name: 'Admin' }); await user.keyboard('{Enter}'); await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Select all' })).toHaveFocus()); - expect(screen.getByRole('toolbar', { hidden: true }).inert).toBe(true); + await waitFor(() => expect(screen.queryByRole('toolbar', { hidden: true })).not.toBeInTheDocument()); }); it('lets Tab leave an open menu and Shift+Tab come back to the bar', async () => { @@ -305,10 +312,10 @@ describe('Mosaic ActionBar', () => { }); }); - describe('with a confirmation dialog', () => { + describe('with a confirmation dialog beside the bar', () => { function ConfirmRemove() { const [selected, setSelected] = React.useState(true); - const [confirming, setConfirming] = React.useState(false); + const handle = React.useMemo(() => Dialog.createHandle(), []); const selectRef = React.useRef(null); return ( <> @@ -323,26 +330,22 @@ describe('Mosaic ActionBar', () => { aria-label='Bulk actions' returnFocus={selectRef} > - } > - }>Remove - - Cancel - - - + Remove + + + (selected ? undefined : selectRef.current)}> + Cancel + setSelected(false)}>Confirm + + ); } diff --git a/packages/mosaic/src/components/action-bar/action-bar.tsx b/packages/mosaic/src/components/action-bar/action-bar.tsx index e9c6b23a602..cc375726069 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.tsx +++ b/packages/mosaic/src/components/action-bar/action-bar.tsx @@ -16,6 +16,7 @@ import { activeElement, contains, getDocument, getTarget } from '@floating-ui/re import * as stylex from '@stylexjs/stylex'; import React from 'react'; +import { useAnimationsFinished, useTransitionStatus } from '../../primitives/hooks'; import { mergeProps, useRender } from '../../primitives/utils'; import { getComputedStyle } from '../../primitives/utils/dom'; import type { MosaicComponentProps, XStyle } from '../../props'; @@ -28,13 +29,9 @@ import { Icon } from '../icon'; import { VisuallyHidden } from '../visually-hidden'; import { styles } from './action-bar.styles'; -const ITEM_ATTRIBUTE = 'data-action-bar-item'; const EDGE_GAP = 16; -const ActionBarContext = React.createContext<{ - open: boolean; - registerCount: (id: string | undefined) => void; -} | null>(null); +const ActionBarContext = React.createContext<{ countId: string } | null>(null); function scrollingAncestors(element: Element): Element[] { return getOverflowAncestors(element).filter( @@ -45,12 +42,6 @@ function scrollingAncestors(element: Element): Element[] { ); } -function enabledItems(bar: HTMLElement): HTMLElement[] { - return Array.from(bar.querySelectorAll(`[${ITEM_ATTRIBUTE}]`)).filter( - item => !item.matches(':disabled'), - ); -} - export interface ActionBarRootProps extends MosaicComponentProps<'div'> { /** Whether the bar is shown. */ open: boolean; @@ -79,21 +70,20 @@ const Root = React.forwardRef(function Actio xstyle, positionerXstyle, children, - onFocus, ...rest }, ref, ) { const barRef = React.useRef(null); - const [barElement, setBarElement] = React.useState(null); - const setBar = React.useCallback((node: HTMLDivElement | null) => { - barRef.current = node; - setBarElement(node); - }, []); - const originRef = React.useRef(null); + const countId = React.useId(); + const context = React.useMemo(() => ({ countId }), [countId]); + const openChildrenRef = React.useRef(children); + if (open) { + openChildrenRef.current = children; + } + const wasOpenRef = React.useRef(false); const [status, setStatus] = React.useState(''); - React.useLayoutEffect(() => { const wasOpen = wasOpenRef.current; wasOpenRef.current = open; @@ -105,21 +95,16 @@ const Root = React.forwardRef(function Actio setStatus(announcement ?? ''); } }, [open, announcement, openAnnouncement]); - const lastFocusedRef = React.useRef(null); - const [activeIndex, setActiveIndex] = React.useState(0); - const [countId, setCountId] = React.useState(); - const context = React.useMemo(() => ({ open, registerCount: setCountId }), [open]); const { refs, - elements, floatingStyles, middlewareData, - update, context: floatingContext, } = useFloating({ open, placement: 'bottom', + whileElementsMounted: autoUpdate, middleware: [ offset(({ rects }) => -rects.floating.height / 4), { @@ -141,110 +126,36 @@ const Root = React.forwardRef(function Actio refs.setReference(anchor.current); }); - React.useEffect(() => { - if (!open || !elements.reference || !elements.floating) { - return; - } - return autoUpdate(elements.reference, elements.floating, update); - }, [open, elements.reference, elements.floating, update]); + const { mounted, transitionStatus, setMounted } = useTransitionStatus(open); + const runOnAnimationsFinished = useAnimationsFinished(barRef, open); - React.useEffect(() => { - if (!open || !barElement) { - return; - } - const doc = getDocument(barElement); - const recordOrigin = (event: FocusEvent) => { - const target = getTarget(event) as HTMLElement; - if ( - !contains(barElement, target) && - target !== lastFocusedRef.current && - !target.hasAttribute('data-floating-ui-focus-guard') - ) { - originRef.current = target; - } - }; - doc.addEventListener('focusin', recordOrigin); - return () => doc.removeEventListener('focusin', recordOrigin); - }, [open, barElement]); + const focusWithinRef = React.useRef(false); - const restoreFocus = React.useCallback(() => { - const bar = barRef.current; - if (!bar) { + React.useEffect(() => { + if (transitionStatus !== 'ending') { return; } - const target = returnFocus?.current ?? originRef.current; - bar.inert = true; - const active = activeElement(getDocument(bar)) as HTMLElement | null; - if (target?.isConnected && !contains(bar, target)) { - target.focus(); - } else if (contains(bar, active)) { - active?.blur(); - } - }, [returnFocus]); - - React.useLayoutEffect(() => { const bar = barRef.current; - if (!bar) { - return; + if (bar && contains(bar, activeElement(getDocument(bar)))) { + returnFocus?.current?.focus(); } - if (open) { - bar.inert = false; - return; - } - setActiveIndex(0); - const doc = getDocument(bar); - const active = activeElement(doc) as HTMLElement | null; - if (contains(bar, active)) { - restoreFocus(); - return; - } - if (!active || active !== lastFocusedRef.current) { - bar.inert = true; - return; - } - let frame = requestAnimationFrame(function watch() { - const current = activeElement(doc); - if (current === active && active.isConnected) { - frame = requestAnimationFrame(watch); - } else if (!current || current === doc.body || contains(bar, current)) { - restoreFocus(); - } else { - bar.inert = true; + return runOnAnimationsFinished(() => { + if (focusWithinRef.current) { + returnFocus?.current?.focus(); } + setMounted(false); }); - return () => cancelAnimationFrame(frame); - }, [open, restoreFocus, barElement]); + }, [transitionStatus, runOnAnimationsFinished, returnFocus, setMounted]); - const handleFocus = (event: React.FocusEvent) => { - onFocus?.(event); - const bar = barRef.current; - const target = getTarget(event.nativeEvent) as HTMLElement; - lastFocusedRef.current = target; - if (bar && !open && contains(bar, target)) { - restoreFocus(); - } - }; - - const handleItemsKeyDown = (event: React.KeyboardEvent) => { + const guardPortalledKeys = (event: React.KeyboardEvent) => { const bar = barRef.current; if (!bar || !contains(bar, getTarget(event.nativeEvent) as Element)) { event.stopPropagation(); - return; } - if (event.key !== 'Home' && event.key !== 'End') { - return; - } - const items = enabledItems(bar); - const target = event.key === 'Home' ? items[0] : items[items.length - 1]; - if (!target) { - return; - } - event.preventDefault(); - const all = Array.from(bar.querySelectorAll(`[${ITEM_ATTRIBUTE}]`)); - setActiveIndex(all.indexOf(target)); - target.focus(); }; + const floatingRef = useMergeRefs([refs.setFloating, ref]); + return ( (function Actio > {status} - - -
+ - ) => { - const { ref: compositeRef, ...merged } = mergeProps<'div'>( - { - 'aria-describedby': countId, - ...mergeStyleProps( - themeProps('action-bar', { open }), - stylex.props(reset.base, styles.bar, xstyle), - rest, - ), - }, - compositeProps as Record, - ); - // eslint-disable-next-line react-hooks/rules-of-hooks - return useRender({ - defaultTagName: 'div', - render, - ref: [setBar, compositeRef as React.Ref], - props: { - ...merged, - 'data-open': open, - role: 'toolbar', - onFocus: handleFocus, - children: ( -
- {children} -
- ), - }, - }); +
-
-
- + {...mergeStyleProps( + themeProps('action-bar-positioner'), + stylex.props(reset.base, styles.positioner, positionerXstyle), + )} + > + ) => { + const { ref: compositeRef, ...merged } = mergeProps<'div'>( + { + 'aria-describedby': countId, + ...mergeStyleProps( + themeProps('action-bar', { open }), + stylex.props(reset.base, styles.bar, xstyle), + rest, + ), + }, + compositeProps as Record, + ); + // eslint-disable-next-line react-hooks/rules-of-hooks + return useRender({ + defaultTagName: 'div', + render, + ref: [barRef, compositeRef as React.Ref], + props: { + ...merged, + ...(transitionStatus === 'starting' + ? { 'data-starting-style': '', style: { transition: 'none' } } + : null), + ...(transitionStatus === 'ending' ? { 'data-ending-style': '' } : null), + 'data-open': open, + role: 'toolbar', + onFocus: () => { + focusWithinRef.current = true; + }, + onBlur: () => { + focusWithinRef.current = false; + }, + children: ( +
+ {open ? children : openChildrenRef.current} +
+ ), + }, + }); + }} + /> +
+
+
+ ) : null}
); }); @@ -340,7 +259,6 @@ const Action = React.forwardRef(functio size='md' color={color} {...merged} - {...{ [ITEM_ATTRIBUTE]: '' }} // eslint-disable-next-line react-hooks/rules-of-hooks ref={useMergeRefs([ref, itemRef as React.Ref])} xstyle={[color === 'negative' && styles.destructive, xstyle]} @@ -351,25 +269,13 @@ const Action = React.forwardRef(functio ); }); -export type ActionBarCountProps = MosaicComponentProps<'div'>; +export type ActionBarCountProps = Omit, 'id'>; const Count = React.forwardRef(function ActionBarCount( - { render, xstyle, id: idProp, children, ...rest }, + { render, xstyle, ...rest }, ref, ) { - const generatedId = React.useId(); - const id = idProp ?? generatedId; - const context = React.useContext(ActionBarContext); - const registerCount = context?.registerCount; - const openChildrenRef = React.useRef(children); - if (context?.open !== false) { - openChildrenRef.current = children; - } - React.useLayoutEffect(() => { - registerCount?.(id); - return () => registerCount?.(undefined); - }, [registerCount, id]); - + const countId = React.useContext(ActionBarContext)?.countId; return useRender({ defaultTagName: 'div', render, @@ -380,8 +286,7 @@ const Count = React.forwardRef(function Act stylex.props(reset.base, styles.count, tabularNumbersStyle.enabled, xstyle), rest, ), - id, - children: openChildrenRef.current, + id: countId, }, }); }); diff --git a/packages/swingset/src/stories/action-bar.mdx b/packages/swingset/src/stories/action-bar.mdx index 906cc18c481..832304f1bbe 100644 --- a/packages/swingset/src/stories/action-bar.mdx +++ b/packages/swingset/src/stories/action-bar.mdx @@ -5,8 +5,7 @@ import * as ActionBarStories from './action-bar.stories'; A floating toolbar of actions for a current selection. It rests on the bottom edge of the table it acts on and, while that edge is scrolled out of view, stays pinned to the foot of the nearest scroll container — the content column of a profile in a dialog, or the page when the profile is inline. It -is portalled, so no ancestor clips it. It animates in when opened, and while closed it is inert and -click-through. +is portalled, so no ancestor clips it. It mounts when opened and unmounts once it has animated out. ## Example @@ -63,6 +62,10 @@ click-through. Inside a modal dialog, pass the dialog's popup element as `portalRoot`, so the bar stays inside the dialog. +To confirm a destructive action, render the `Dialog` beside the bar rather than inside it. Put a +`Dialog.Trigger` with a `handle` in the bar, and point the popup's `finalFocus` at the select-all +checkbox for when the confirmation closes the bar. + ## Parts | Part | Element | Description | @@ -77,14 +80,14 @@ dialog. ### `ActionBar.Root` -| Prop | Type | Default | Description | -| ------------------ | ------------------------ | --------------------- | ----------------------------------------------------------- | -| `open` | `boolean` | — (required) | Whether the bar is shown. | -| `anchor` | `RefObject` | — (required) | The table the bar rests on. | -| `portalRoot` | `HTMLElement \| null` | `document.body` | Where the bar is portalled. `null` waits for the element. | -| `returnFocus` | `RefObject` | where focus came from | Where focus goes when the bar closes while holding it. | -| `announcement` | `string` | — | Announced when the selection changes while the bar is open. | -| `openAnnouncement` | `string` | `announcement` | Announced when the bar opens. | +| Prop | Type | Default | Description | +| ------------------ | ------------------------ | --------------- | ----------------------------------------------------------- | +| `open` | `boolean` | — (required) | Whether the bar is shown. | +| `anchor` | `RefObject` | — (required) | The table the bar rests on. | +| `portalRoot` | `HTMLElement \| null` | `document.body` | Where the bar is portalled. `null` waits for the element. | +| `returnFocus` | `RefObject` | — | Where focus goes when the bar closes while holding it. | +| `announcement` | `string` | — | Announced when the selection changes while the bar is open. | +| `openAnnouncement` | `string` | `announcement` | Announced when the bar opens. | ### `ActionBar.Action` @@ -93,7 +96,7 @@ Takes `Button`'s props. `color='negative'` draws the icon at full strength. ## Accessibility - The bar is a single tab stop, in tab order right after where `ActionBar.Root` renders. `←` / `→` - move between its actions and wrap; `Home` / `End` jump to the first and last. + move between its actions and wrap. - Give it an `aria-label`, and point `aria-controls` at the table's `id`. - A status region outside the bar announces `openAnnouncement` when the bar opens and `announcement` when the selection changes after that. Closing is silent. @@ -102,5 +105,5 @@ Takes `Button`'s props. `color='negative'` draws the icon at full strength. ## Styling -`ActionBar.Root` reflects `data-open` (`true` / `false`) on its surface, so the entrance -transition and the closed (click-through) state are both selectable. +`ActionBar.Root` reflects `data-open` (`true` / `false`) on its surface, and `data-starting-style` / +`data-ending-style` while it animates in and out. From 20642b7100c9eb4d629368a580124285e43c5eef Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 24 Sep 2026 13:49:36 -0600 Subject: [PATCH 15/16] refactor(mosaic): rename action bar returnFocus to finalFocus and accept a function Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/action-bar/action-bar.test.tsx | 37 ++++++++++++++++--- .../src/components/action-bar/action-bar.tsx | 19 ++++++---- packages/swingset/src/stories/action-bar.mdx | 20 +++++----- .../src/stories/action-bar.stories.tsx | 2 +- 4 files changed, 54 insertions(+), 24 deletions(-) diff --git a/packages/mosaic/src/components/action-bar/action-bar.test.tsx b/packages/mosaic/src/components/action-bar/action-bar.test.tsx index 678a1a1a0fb..ef60962aabb 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.test.tsx +++ b/packages/mosaic/src/components/action-bar/action-bar.test.tsx @@ -38,7 +38,7 @@ function BulkActions({ open = true, onDismiss }: { open?: boolean; onDismiss?: ( @@ -226,7 +226,7 @@ describe('Mosaic ActionBar', () => { expect(screen.getByRole('button', { name: 'Remove' })).not.toHaveFocus(); }); - it('sends focus to returnFocus when the bar closes around it', async () => { + it('sends focus to finalFocus when the bar closes around it', async () => { const user = userEvent.setup(); function Harness() { const [open, setOpen] = React.useState(true); @@ -245,6 +245,31 @@ describe('Mosaic ActionBar', () => { await waitFor(() => expect(checkbox).toHaveFocus()); }); + it('resolves a function finalFocus when it closes', async () => { + const user = userEvent.setup(); + function Harness() { + const [open, setOpen] = React.useState(true); + return ( + <> + + screen.getByRole('button', { name: 'Next row' })} + > + setOpen(false)} /> + + + ); + } + render(); + await user.click(screen.getByRole('button', { name: 'Next row' })); + await user.tab(); + await user.keyboard('{Enter}'); + await waitFor(() => expect(screen.getByRole('button', { name: 'Next row' })).toHaveFocus()); + }); + it('dismisses with a labelled button', async () => { const onDismiss = vi.fn(); render(); @@ -266,7 +291,7 @@ describe('Mosaic ActionBar', () => { open={selected} anchor={selectAllRef} aria-label='Bulk actions' - returnFocus={selectAllRef} + finalFocus={selectAllRef} > }>Change role @@ -286,7 +311,7 @@ describe('Mosaic ActionBar', () => { ); } - it('sends focus to returnFocus when a menu item closes the bar', async () => { + it('sends focus to finalFocus when a menu item closes the bar', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('checkbox', { name: 'Select all' })); @@ -328,7 +353,7 @@ describe('Mosaic ActionBar', () => { open={selected} anchor={selectRef} aria-label='Bulk actions' - returnFocus={selectRef} + finalFocus={selectRef} > { await waitFor(() => expect(screen.getByRole('button', { name: 'Remove' })).toHaveFocus()); }); - it('sends focus to returnFocus when the dialog confirms', async () => { + it('sends focus to finalFocus when the dialog confirms', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('checkbox', { name: 'Select Kyle' })); diff --git a/packages/mosaic/src/components/action-bar/action-bar.tsx b/packages/mosaic/src/components/action-bar/action-bar.tsx index cc375726069..d4a85a45516 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.tsx +++ b/packages/mosaic/src/components/action-bar/action-bar.tsx @@ -12,7 +12,7 @@ import { useFloating, useMergeRefs, } from '@floating-ui/react'; -import { activeElement, contains, getDocument, getTarget } from '@floating-ui/react/utils'; +import { activeElement, contains, getDocument, getTarget, useLatestRef } from '@floating-ui/react/utils'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; @@ -49,8 +49,8 @@ export interface ActionBarRootProps extends MosaicComponentProps<'div'> { anchor: React.RefObject; /** Where the bar is portalled. Inside a modal dialog, pass its popup. */ portalRoot?: HTMLElement | null; - /** Where focus goes when the bar closes while holding it. */ - returnFocus?: React.RefObject; + /** Where focus goes when the bar closes while holding it: an element ref, or a function returning one. */ + finalFocus?: React.RefObject | (() => HTMLElement | null); /** Announced when the selection changes while the bar is open, e.g. `3 selected`. */ announcement?: string; /** Announced when the bar opens, e.g. `1 selected, bulk actions follow the table`. Defaults to `announcement`. */ @@ -63,7 +63,7 @@ const Root = React.forwardRef(function Actio open, anchor, portalRoot, - returnFocus, + finalFocus, announcement, openAnnouncement, render, @@ -130,22 +130,27 @@ const Root = React.forwardRef(function Actio const runOnAnimationsFinished = useAnimationsFinished(barRef, open); const focusWithinRef = React.useRef(false); + const finalFocusRef = useLatestRef(finalFocus); React.useEffect(() => { if (transitionStatus !== 'ending') { return; } + const moveFocus = () => { + const target = finalFocusRef.current; + (typeof target === 'function' ? target() : target?.current)?.focus(); + }; const bar = barRef.current; if (bar && contains(bar, activeElement(getDocument(bar)))) { - returnFocus?.current?.focus(); + moveFocus(); } return runOnAnimationsFinished(() => { if (focusWithinRef.current) { - returnFocus?.current?.focus(); + moveFocus(); } setMounted(false); }); - }, [transitionStatus, runOnAnimationsFinished, returnFocus, setMounted]); + }, [transitionStatus, runOnAnimationsFinished, finalFocusRef, setMounted]); const guardPortalledKeys = (event: React.KeyboardEvent) => { const bar = barRef.current; diff --git a/packages/swingset/src/stories/action-bar.mdx b/packages/swingset/src/stories/action-bar.mdx index 832304f1bbe..1d1a3b05ca9 100644 --- a/packages/swingset/src/stories/action-bar.mdx +++ b/packages/swingset/src/stories/action-bar.mdx @@ -41,7 +41,7 @@ is portalled, so no ancestor clips it. It mounts when opened and unmounts once i anchor={tableRef} aria-label='Bulk actions' aria-controls={tableId} - returnFocus={selectAllRef} + finalFocus={selectAllRef} announcement={`${selectedCount} selected`} openAnnouncement={`${selectedCount} selected, bulk actions follow the table`} > @@ -80,14 +80,14 @@ checkbox for when the confirmation closes the bar. ### `ActionBar.Root` -| Prop | Type | Default | Description | -| ------------------ | ------------------------ | --------------- | ----------------------------------------------------------- | -| `open` | `boolean` | — (required) | Whether the bar is shown. | -| `anchor` | `RefObject` | — (required) | The table the bar rests on. | -| `portalRoot` | `HTMLElement \| null` | `document.body` | Where the bar is portalled. `null` waits for the element. | -| `returnFocus` | `RefObject` | — | Where focus goes when the bar closes while holding it. | -| `announcement` | `string` | — | Announced when the selection changes while the bar is open. | -| `openAnnouncement` | `string` | `announcement` | Announced when the bar opens. | +| Prop | Type | Default | Description | +| ------------------ | ----------------------------------------------------- | --------------- | ----------------------------------------------------------- | +| `open` | `boolean` | — (required) | Whether the bar is shown. | +| `anchor` | `RefObject` | — (required) | The table the bar rests on. | +| `portalRoot` | `HTMLElement \| null` | `document.body` | Where the bar is portalled. `null` waits for the element. | +| `finalFocus` | `RefObject \| () => HTMLElement \| null` | — | Where focus goes when the bar closes while holding it. | +| `announcement` | `string` | — | Announced when the selection changes while the bar is open. | +| `openAnnouncement` | `string` | `announcement` | Announced when the bar opens. | ### `ActionBar.Action` @@ -101,7 +101,7 @@ Takes `Button`'s props. `color='negative'` draws the icon at full strength. - A status region outside the bar announces `openAnnouncement` when the bar opens and `announcement` when the selection changes after that. Closing is silent. - When the bar closes while it holds focus, including from a menu or dialog opened inside it, focus - moves to `returnFocus`. Pass the table's select-all checkbox. + moves to `finalFocus`: the table's select-all checkbox, or a function that picks an element when the bar closes. ## Styling diff --git a/packages/swingset/src/stories/action-bar.stories.tsx b/packages/swingset/src/stories/action-bar.stories.tsx index ab965523cd4..cd80168f809 100644 --- a/packages/swingset/src/stories/action-bar.stories.tsx +++ b/packages/swingset/src/stories/action-bar.stories.tsx @@ -92,7 +92,7 @@ function MembersTable({ rows = 10, portalRoot }: { rows?: number; portalRoot?: H portalRoot={portalRoot} aria-label='Bulk actions' aria-controls={tableId} - returnFocus={selectAllRef} + finalFocus={selectAllRef} announcement={`${count} selected`} openAnnouncement={`${count} selected, bulk actions follow the table`} > From 39a7a62ccbb7a701904a8acb19e0e73188c90422 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 24 Sep 2026 14:32:39 -0600 Subject: [PATCH 16/16] test(mosaic): drop the action bar exit-hold test Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/action-bar/action-bar.test.tsx | 29 +------------------ 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/packages/mosaic/src/components/action-bar/action-bar.test.tsx b/packages/mosaic/src/components/action-bar/action-bar.test.tsx index ef60962aabb..4062f773f84 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.test.tsx +++ b/packages/mosaic/src/components/action-bar/action-bar.test.tsx @@ -2,7 +2,7 @@ import * as stylex from '@stylexjs/stylex'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Dialog } from '../dialog'; import { Menu } from '../menu'; @@ -117,33 +117,6 @@ describe('Mosaic ActionBar', () => { expect(status).toHaveTextContent('2 selected, bulk actions follow the table'); }); - it('holds its last contents while it animates out', () => { - Object.defineProperty(HTMLElement.prototype, 'getAnimations', { - configurable: true, - value: () => [{ finished: new Promise(() => {}) }], - }); - onTestFinished(() => { - delete (HTMLElement.prototype as { getAnimations?: unknown }).getAnimations; - }); - function Selection({ count }: { count: number }) { - return ( - 0} - anchor={useAnchor()} - aria-label='Bulk actions' - > - {count} selected - - ); - } - const { rerender } = render(); - rerender(); - expect(screen.getByRole('toolbar', { hidden: true })).toHaveAttribute('data-ending-style'); - expect(screen.getByText('2 selected')).toBeInTheDocument(); - rerender(); - expect(screen.getByText('5 selected')).toBeInTheDocument(); - }); - it('styles the positioner independently from the bar', () => { function Positioned() { return (