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 776276f8f42..e746c980d4a 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -47,6 +47,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 3a7740c8233..c8662720f27 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, @@ -397,6 +398,11 @@ const comboboxModule: StoryModule = { Scrolling: ComboboxScrolling, }; +const actionBarModule: StoryModule = { + meta: actionBarMeta, + Default: ActionBarDefault, +}; + const avatarModule: StoryModule = { meta: avatarMeta, Primary: AvatarPrimary, @@ -791,6 +797,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)} /> + +
+ ); +}