diff --git a/.changeset/mosaic-action-bar.md b/.changeset/mosaic-action-bar.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-action-bar.md @@ -0,0 +1,2 @@ +--- +--- 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..238cf4e7b1c --- /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: { + pointerEvents: 'none', + width: 'max-content', + }, + items: { + display: 'contents', + }, + destructive: { + '--_cl-icon-color': colorVars['--cl-color-negative'], + }, + bar: { + borderRadius: radiusVars['--cl-radius-lg'], + gap: space['1'], + paddingBlock: space['1'], + 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, + ':where([data-starting-style], [data-ending-style])': 0, + }, + pointerEvents: { + default: 'auto', + ':where([data-ending-style])': 'none', + }, + transform: { + default: 'translateY(0)', + [reduceMotion]: { + default: 'translateY(0)', + ':where([data-starting-style], [data-ending-style])': 'translateY(0)', + }, + ':where([data-starting-style], [data-ending-style])': 'translateY(0.25rem)', + }, + transitionDuration: { + default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}`, + ':where([data-ending-style])': durationVars['--cl-duration-fast'], + }, + transitionProperty: { + default: 'opacity, transform', + [reduceMotion]: 'opacity', + }, + transitionTimingFunction: { + default: easingVars['--cl-ease-enter'], + ':where([data-ending-style])': easingVars['--cl-ease-exit'], + }, + }, + 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..4062f773f84 --- /dev/null +++ b/packages/mosaic/src/components/action-bar/action-bar.test.tsx @@ -0,0 +1,371 @@ +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 { Dialog } from '../dialog'; +import { Menu } from '../menu'; +import { ActionBar } from './action-bar'; + +const testStyles = stylex.create({ + positioner: { + bottom: '20px', + }, +}); + +function BulkActions({ open = true, onDismiss }: { open?: boolean; onDismiss?: () => void }) { + const tableRef = React.useRef(null); + const selectRef = React.useRef(null); + return ( + <> + + + + + + +
+ +
+ + 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(); + const bar = screen.getByRole('toolbar', { name: 'Bulk actions' }); + expect(bar).toHaveClass('cl-action-bar'); + expect(bar).toHaveAttribute('data-open', 'true'); + expect(bar).toHaveAttribute('aria-controls', 'members'); + expect(bar).toHaveAttribute('aria-orientation', 'horizontal'); + expect(bar).toHaveAccessibleDescription('3 selected'); + expect(screen.getAllByRole('separator')).toHaveLength(2); + }); + + it('renders nothing while closed', () => { + render(); + expect(screen.queryByRole('toolbar', { hidden: true })).not.toBeInTheDocument(); + }); + + 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('styles the positioner independently from the bar', () => { + function Positioned() { + return ( + + ); + } + render(); + expect(screen.getByRole('toolbar', { name: 'Bulk actions' }).parentElement).toHaveClass( + ...(stylex.props(testStyles.positioner).className as string).split(' '), + ); + }); + + 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(screen.getByRole('button', { name: 'After' })).toHaveFocus(); + }); + + 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('{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(); + }); + + 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(); + 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(); + await user.keyboard('{ArrowRight}'); + expect(screen.getByRole('button', { name: 'Remove' })).not.toHaveFocus(); + }); + + it('sends focus to finalFocus 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('{ArrowLeft}{Enter}'); + 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(); + 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 + + + + Remove + + + + ); + } + + 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' })); + 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()); + 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 () => { + 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.getByRole('button', { name: 'After' })).toHaveFocus()); + await user.tab({ shift: true }); + expect(screen.getByRole('button', { name: 'Change role' })).toHaveFocus(); + }); + }); + + describe('with a confirmation dialog beside the bar', () => { + function ConfirmRemove() { + const [selected, setSelected] = React.useState(true); + const handle = React.useMemo(() => Dialog.createHandle(), []); + const selectRef = React.useRef(null); + return ( + <> + + + } + > + Remove + + + + (selected ? undefined : selectRef.current)}> + Cancel + setSelected(false)}>Confirm + + + + ); + } + + 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 to finalFocus 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()); + }); + }); +}); 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..d4a85a45516 --- /dev/null +++ b/packages/mosaic/src/components/action-bar/action-bar.tsx @@ -0,0 +1,335 @@ +import { + autoUpdate, + Composite, + CompositeItem, + FloatingFocusManager, + FloatingPortal, + getOverflowAncestors, + hide, + limitShift, + offset, + shift, + useFloating, + useMergeRefs, +} from '@floating-ui/react'; +import { activeElement, contains, getDocument, getTarget, useLatestRef } from '@floating-ui/react/utils'; +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'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../utils/reset.styles'; +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 EDGE_GAP = 16; + +const ActionBarContext = React.createContext<{ countId: string } | null>(null); + +function scrollingAncestors(element: Element): Element[] { + return getOverflowAncestors(element).filter( + (node): node is Element => + node instanceof Element && + node.scrollHeight > node.clientHeight && + /auto|scroll|overlay/.test(getComputedStyle(node).overflowY), + ); +} + +export interface ActionBarRootProps extends MosaicComponentProps<'div'> { + /** Whether the bar is shown. */ + open: boolean; + /** The table the bar rests on. */ + 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: 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`. */ + openAnnouncement?: string; + positionerXstyle?: XStyle; +} + +const Root = React.forwardRef(function ActionBarRoot( + { + open, + anchor, + portalRoot, + finalFocus, + announcement, + openAnnouncement, + render, + xstyle, + positionerXstyle, + children, + ...rest + }, + ref, +) { + const barRef = 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; + if (!open) { + setStatus(''); + } else if (!wasOpen) { + setStatus(openAnnouncement ?? announcement ?? ''); + } else { + setStatus(announcement ?? ''); + } + }, [open, announcement, openAnnouncement]); + + const { + refs, + floatingStyles, + middlewareData, + context: floatingContext, + } = useFloating({ + open, + placement: 'bottom', + whileElementsMounted: autoUpdate, + middleware: [ + offset(({ rects }) => -rects.floating.height / 4), + { + name: 'shiftIntoScrollport', + fn: state => + shift({ + mainAxis: false, + crossAxis: true, + boundary: scrollingAncestors(state.elements.reference as Element), + padding: EDGE_GAP, + limiter: limitShift({ mainAxis: false, crossAxis: true }), + }).fn(state), + }, + hide(), + ], + }); + + React.useLayoutEffect(() => { + refs.setReference(anchor.current); + }); + + const { mounted, transitionStatus, setMounted } = useTransitionStatus(open); + 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)))) { + moveFocus(); + } + return runOnAnimationsFinished(() => { + if (focusWithinRef.current) { + moveFocus(); + } + setMounted(false); + }); + }, [transitionStatus, runOnAnimationsFinished, finalFocusRef, setMounted]); + + const guardPortalledKeys = (event: React.KeyboardEvent) => { + const bar = barRef.current; + if (!bar || !contains(bar, getTarget(event.nativeEvent) as Element)) { + event.stopPropagation(); + } + }; + + const floatingRef = useMergeRefs([refs.setFloating, ref]); + + return ( + + + {status} + + {mounted ? ( + + +
+ ) => { + 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} +
+ ); +}); + +export type ActionBarActionProps = ButtonProps; + +const Action = React.forwardRef(function ActionBarAction( + { color, xstyle, ...rest }, + ref, +) { + return ( + ) => { + const { ref: itemRef, ...merged } = mergeProps<'button'>( + rest as Record, + itemProps as Record, + ); + return ( +