diff --git a/.changeset/mosaic-table.md b/.changeset/mosaic-table.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-table.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/src/hooks/use-data-table.test.ts b/packages/headless/src/hooks/use-data-table.test.ts index 510e82754c8..7d8f2ad507e 100644 --- a/packages/headless/src/hooks/use-data-table.test.ts +++ b/packages/headless/src/hooks/use-data-table.test.ts @@ -436,5 +436,53 @@ describe('useDataTable', () => { expect(result.current.rowSelection).toEqual(controlled); }); + + it('row.toggleSelected({ range: true }) selects every row between the last toggled row and this one', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + act(() => result.current.rows[0].toggleSelected()); + act(() => result.current.rows[2].toggleSelected({ range: true })); + + expect(result.current.rowSelection).toEqual({ '0': true, '1': true, '2': true }); + }); + + it('row.toggleSelected({ range: true }) selects a range above the last toggled row', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + act(() => result.current.rows[2].toggleSelected()); + act(() => result.current.rows[0].toggleSelected({ range: true })); + + expect(result.current.rowSelection).toEqual({ '0': true, '1': true, '2': true }); + }); + + it('row.toggleSelected({ range: true }) deselects the range when this row is selected', () => { + const { result } = renderHook(() => + useDataTable({ data: DATA, defaultRowSelection: { '0': true, '1': true, '2': true } }), + ); + + act(() => result.current.rows[0].toggleSelected()); + act(() => result.current.rows[2].toggleSelected({ range: true })); + + expect(result.current.rowSelection).toEqual({ '0': false, '1': false, '2': false }); + }); + + it('row.toggleSelected({ range: true }) toggles only this row when no row was toggled before', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + act(() => result.current.rows[2].toggleSelected({ range: true })); + + expect(result.current.rowSelection).toEqual({ '2': true }); + }); + + it('row.toggleSelected({ range: true }) anchors on the row that ended the last range', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + act(() => result.current.rows[0].toggleSelected()); + act(() => result.current.rows[1].toggleSelected({ range: true })); + act(() => result.current.rows[2].toggleSelected({ range: true })); + act(() => result.current.rows[1].toggleSelected({ range: true })); + + expect(result.current.rowSelection).toEqual({ '0': true, '1': false, '2': false }); + }); }); }); diff --git a/packages/headless/src/hooks/use-data-table.ts b/packages/headless/src/hooks/use-data-table.ts index 74fda7f7dd5..9a2190eb081 100644 --- a/packages/headless/src/hooks/use-data-table.ts +++ b/packages/headless/src/hooks/use-data-table.ts @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; import { useControllableState } from './use-controllable-state'; @@ -46,7 +46,7 @@ export interface DataTableRow { id: string; original: TData; getIsSelected: () => boolean; - toggleSelected: () => void; + toggleSelected: (options?: { range?: boolean }) => void; } export interface UseDataTableReturn { @@ -136,19 +136,31 @@ export function useDataTable(opts: UseDataTableOptions): UseDataTa // ── Rows ──────────────────────────────────────────────────────────────────── const { getRowId } = opts; - const rows = useMemo[]>( - () => - opts.data.map((original, i) => { - const id = getRowId ? getRowId(original, i) : String(i); - return { - id, - original, - getIsSelected: () => !!rowSelection[id], - toggleSelected: () => setRowSelection(old => ({ ...old, [id]: !old[id] })), - }; - }), - [opts.data, getRowId, rowSelection, setRowSelection], - ); + const selectionAnchor = useRef(null); + const rows = useMemo[]>(() => { + const ids = opts.data.map((original, i) => (getRowId ? getRowId(original, i) : String(i))); + return opts.data.map((original, i) => { + const id = ids[i]; + return { + id, + original, + getIsSelected: () => !!rowSelection[id], + toggleSelected: options => { + const anchor = selectionAnchor.current; + const anchorIndex = options?.range && anchor !== null ? ids.indexOf(anchor) : -1; + selectionAnchor.current = id; + setRowSelection(old => { + const selected = !old[id]; + if (anchorIndex === -1) { + return { ...old, [id]: selected }; + } + const rangeIds = ids.slice(Math.min(anchorIndex, i), Math.max(anchorIndex, i) + 1); + return { ...old, ...Object.fromEntries(rangeIds.map(rangeId => [rangeId, selected])) }; + }); + }, + }; + }); + }, [opts.data, getRowId, rowSelection, setRowSelection]); // ── Pagination helpers ────────────────────────────────────────────────────── diff --git a/packages/mosaic/src/components/checkbox/checkbox.markers.stylex.ts b/packages/mosaic/src/components/checkbox/checkbox.markers.stylex.ts new file mode 100644 index 00000000000..73c337f050f --- /dev/null +++ b/packages/mosaic/src/components/checkbox/checkbox.markers.stylex.ts @@ -0,0 +1,3 @@ +import * as stylex from '@stylexjs/stylex'; + +export const checkboxInputMarker = stylex.defineMarker(); diff --git a/packages/mosaic/src/components/checkbox/checkbox.styles.ts b/packages/mosaic/src/components/checkbox/checkbox.styles.ts new file mode 100644 index 00000000000..ad42d4e4557 --- /dev/null +++ b/packages/mosaic/src/components/checkbox/checkbox.styles.ts @@ -0,0 +1,81 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, durationVars, radiusVars, space } from '../../tokens.stylex'; +import { checkboxInputMarker } from './checkbox.markers.stylex'; + +const hoverBorderColor = 'light-dark(#bebebe, #525252)'; + +export const styles = stylex.create({ + root: { + display: 'inline-flex', + flexShrink: 0, + position: 'relative', + verticalAlign: 'middle', + }, + input: { + borderColor: { + default: colorVars['--cl-color-border'], + ':checked': colorVars['--cl-color-brand'], + ':indeterminate': colorVars['--cl-color-brand'], + '@media (hover: hover)': { + ':hover:not(:disabled):not(:checked):not(:indeterminate)': hoverBorderColor, + }, + }, + borderRadius: radiusVars['--cl-radius-sm'], + borderStyle: 'solid', + borderWidth: '1px', + appearance: 'none', + backgroundColor: { + default: colorVars['--cl-color-background'], + ':checked': colorVars['--cl-color-brand'], + ':indeterminate': colorVars['--cl-color-brand'], + }, + cursor: { default: 'pointer', ':disabled': 'not-allowed' }, + display: 'block', + flexShrink: 0, + opacity: { default: null, ':disabled': 0.5 }, + position: 'relative', + transitionDuration: durationVars['--cl-duration-fast'], + transitionProperty: 'background-color, border-color', + transitionTimingFunction: 'linear', + }, + hitTarget: { + '::before': { + inset: `calc(-1 * ${space['3']})`, + content: '""', + display: { default: 'none', '@media (pointer: coarse)': 'block' }, + position: 'absolute', + }, + }, + indicator: { + inset: 0, + alignItems: 'center', + color: colorVars['--cl-color-brand-foreground'], + display: 'none', + justifyContent: 'center', + pointerEvents: 'none', + position: 'absolute', + }, + checkedIndicator: { + display: { + default: 'none', + [stylex.when.siblingBefore(':checked:not(:indeterminate)', checkboxInputMarker)]: 'flex', + }, + }, + indeterminateIndicator: { + display: { + default: 'none', + [stylex.when.siblingBefore(':indeterminate', checkboxInputMarker)]: 'flex', + }, + }, +}); + +export const sizes = stylex.create({ + sm: { height: space['3.5'], width: space['3.5'] }, + md: { height: space['4'], width: space['4'] }, +}); + +export const indicatorSizes = stylex.create({ + sm: { fontSize: space['2.5'] }, + md: { fontSize: space['3'] }, +}); diff --git a/packages/mosaic/src/components/checkbox/checkbox.test.tsx b/packages/mosaic/src/components/checkbox/checkbox.test.tsx new file mode 100644 index 00000000000..1bc3e031d5e --- /dev/null +++ b/packages/mosaic/src/components/checkbox/checkbox.test.tsx @@ -0,0 +1,109 @@ +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 { Checkbox } from './checkbox'; +import { styles } from './checkbox.styles'; + +const atoms = stylex.create({ + spaced: { marginTop: '8px' }, +}); + +describe('Mosaic Checkbox', () => { + it('renders a native checkbox wrapped in the slot element', () => { + render(); + const input = screen.getByRole('checkbox', { name: 'Select row' }); + expect(input).toHaveClass('cl-checkbox-input'); + expect(input.parentElement).toHaveClass('cl-checkbox'); + }); + + it('toggles and reports changes', async () => { + const onChange = vi.fn(); + render( + , + ); + const input = screen.getByRole('checkbox'); + await userEvent.click(input); + expect(input).toBeChecked(); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('supports a controlled checked state', () => { + render( + {}} + />, + ); + expect(screen.getByRole('checkbox')).toBeChecked(); + }); + + it('applies the indeterminate state through the DOM property', () => { + const { rerender } = render( + , + ); + const input = screen.getByRole('checkbox'); + expect(input.indeterminate).toBe(true); + + rerender(); + expect(input.indeterminate).toBe(false); + }); + + it('reflects the size axis on the slot element', () => { + render(); + expect(screen.getByRole('checkbox').parentElement).toHaveAttribute('data-size', 'md'); + }); + + it('disables the input', () => { + render( + , + ); + expect(screen.getByRole('checkbox')).toBeDisabled(); + }); + + it('merges xstyle atoms onto the slot element', () => { + render( + , + ); + expect(screen.getByRole('checkbox').parentElement).toHaveClass( + 'cl-checkbox', + stylex.props(atoms.spaced).className ?? '', + ); + }); + + it('widens the hit target on the input for coarse pointers', () => { + render(); + expect(screen.getByRole('checkbox')).toHaveClass(stylex.props(styles.hitTarget).className ?? ''); + }); + + it('forwards the ref and arbitrary input props to the input', () => { + const ref = React.createRef(); + render( + , + ); + const input = screen.getByRole('checkbox'); + expect(ref.current).toBe(input); + expect(input).toHaveAttribute('name', 'rows'); + expect(input).toHaveAttribute('value', '1'); + }); +}); diff --git a/packages/mosaic/src/components/checkbox/checkbox.tsx b/packages/mosaic/src/components/checkbox/checkbox.tsx new file mode 100644 index 00000000000..880392fae82 --- /dev/null +++ b/packages/mosaic/src/components/checkbox/checkbox.tsx @@ -0,0 +1,86 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicStyleProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { focusOutline } from '../../utils/focus-outline.styles'; +import { reset } from '../../utils/reset.styles'; +import { Icon } from '../icon'; +import { checkboxInputMarker } from './checkbox.markers.stylex'; +import { indicatorSizes, sizes, styles } from './checkbox.styles'; + +export interface CheckboxProps + extends Omit, 'type' | 'size' | 'className' | 'style'>, MosaicStyleProps { + size?: 'sm' | 'md'; + indeterminate?: boolean; +} + +export const Checkbox = React.forwardRef(function MosaicCheckbox( + { size = 'md', indeterminate = false, disabled, xstyle, ...rest }, + ref, +) { + const setInputRef = React.useCallback( + (node: HTMLInputElement | null) => { + if (node) { + node.indeterminate = indeterminate; + } + if (typeof ref === 'function') { + ref(node); + } else if (ref) { + ref.current = node; + } + }, + [indeterminate, ref], + ); + + return ( + + + + + + + + + + ); +}); diff --git a/packages/mosaic/src/components/checkbox/index.ts b/packages/mosaic/src/components/checkbox/index.ts new file mode 100644 index 00000000000..edcc3dade81 --- /dev/null +++ b/packages/mosaic/src/components/checkbox/index.ts @@ -0,0 +1,2 @@ +export { Checkbox } from './checkbox'; +export type { CheckboxProps } from './checkbox'; diff --git a/packages/mosaic/src/components/empty-state/empty-state.styles.ts b/packages/mosaic/src/components/empty-state/empty-state.styles.ts new file mode 100644 index 00000000000..ea79a311dfb --- /dev/null +++ b/packages/mosaic/src/components/empty-state/empty-state.styles.ts @@ -0,0 +1,41 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, space, typeScaleVars } from '../../tokens.stylex'; + +export const styles = stylex.create({ + root: { + gap: space['1'], + paddingBlock: space['10'], + paddingInline: space['6'], + alignItems: 'center', + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + textAlign: 'center', + width: '100%', + }, + icon: { + display: 'inline-flex', + marginBlockEnd: space['1'], + }, + label: { + color: colorVars['--cl-color-foreground'], + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + }, + description: { + color: colorVars['--cl-color-foreground-secondary'], + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + maxWidth: '32ch', + }, + actions: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'center', + marginBlockStart: space['2'], + }, +}); diff --git a/packages/mosaic/src/components/empty-state/empty-state.test.tsx b/packages/mosaic/src/components/empty-state/empty-state.test.tsx new file mode 100644 index 00000000000..09bb7568778 --- /dev/null +++ b/packages/mosaic/src/components/empty-state/empty-state.test.tsx @@ -0,0 +1,64 @@ +import * as stylex from '@stylexjs/stylex'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { EmptyState } from './empty-state'; + +const atoms = stylex.create({ + spaced: { marginTop: '8px' }, +}); + +describe('Mosaic EmptyState', () => { + it('renders each part with its slot class', () => { + render( + + + No API keys + Create a key to get started. + + + + , + ); + const title = screen.getByText('No API keys'); + expect(title).toHaveClass('cl-empty-state-label'); + expect(title.closest('.cl-empty-state')).not.toBeNull(); + expect(screen.getByText('Create a key to get started.')).toHaveClass('cl-empty-state-description'); + expect(screen.getByRole('button', { name: 'Create key' }).parentElement).toHaveClass('cl-empty-state-actions'); + }); + + it('frames the icon in a filled icon frame', () => { + const { container } = render( + + + No results + , + ); + const frame = container.querySelector('.cl-empty-state-icon .cl-icon-frame'); + expect(frame).not.toBeNull(); + expect(frame).toHaveAttribute('data-filled', ''); + expect(frame?.querySelector('.cl-icon')).not.toBeNull(); + }); + + it('merges xstyle atoms and forwards the ref on each part', () => { + const ref = React.createRef(); + const titleRef = React.createRef(); + render( + + + Nothing here + + , + ); + const atom = stylex.props(atoms.spaced).className ?? ''; + expect(ref.current).toHaveClass('cl-empty-state', atom); + expect(titleRef.current).toHaveClass('cl-empty-state-label', atom); + }); +}); diff --git a/packages/mosaic/src/components/empty-state/empty-state.tsx b/packages/mosaic/src/components/empty-state/empty-state.tsx new file mode 100644 index 00000000000..e511e5d04dc --- /dev/null +++ b/packages/mosaic/src/components/empty-state/empty-state.tsx @@ -0,0 +1,94 @@ +import { useRender } from '@clerk/headless/utils'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { IconName } from '../../icons/registry'; +import type { MosaicComponentProps, MosaicElementProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../utils/reset.styles'; +import { Icon, IconFrame } from '../icon'; +import { styles } from './empty-state.styles'; + +export type EmptyStateProps = MosaicComponentProps<'div'>; + +const Root = React.forwardRef(function MosaicEmptyState( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: mergeStyleProps(themeProps('empty-state'), stylex.props(reset.base, styles.root, xstyle), rest), + }); +}); + +export interface EmptyStateIconProps extends Omit, 'children'> { + name: IconName; +} + +const EmptyStateIcon = React.forwardRef(function MosaicEmptyStateIcon( + { name, xstyle, ...rest }, + ref, +) { + return ( + + + + + + ); +}); + +export type EmptyStateLabelProps = MosaicComponentProps<'p'>; + +const Label = React.forwardRef(function MosaicEmptyStateLabel( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'p', + render, + ref, + props: mergeStyleProps(themeProps('empty-state-label'), stylex.props(reset.base, styles.label, xstyle), rest), + }); +}); + +export type EmptyStateDescriptionProps = MosaicComponentProps<'p'>; + +const Description = React.forwardRef( + function MosaicEmptyStateDescription({ render, xstyle, ...rest }, ref) { + return useRender({ + defaultTagName: 'p', + render, + ref, + props: mergeStyleProps( + themeProps('empty-state-description'), + stylex.props(reset.base, styles.description, xstyle), + rest, + ), + }); + }, +); + +export type EmptyStateActionsProps = MosaicComponentProps<'div'>; + +const Actions = React.forwardRef(function MosaicEmptyStateActions( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: mergeStyleProps(themeProps('empty-state-actions'), stylex.props(reset.base, styles.actions, xstyle), rest), + }); +}); + +export const EmptyState = { Root, Icon: EmptyStateIcon, Label, Description, Actions }; diff --git a/packages/mosaic/src/components/empty-state/index.ts b/packages/mosaic/src/components/empty-state/index.ts new file mode 100644 index 00000000000..4b8b9f7afdd --- /dev/null +++ b/packages/mosaic/src/components/empty-state/index.ts @@ -0,0 +1,8 @@ +export { EmptyState } from './empty-state'; +export type { + EmptyStateActionsProps, + EmptyStateDescriptionProps, + EmptyStateIconProps, + EmptyStateProps, + EmptyStateLabelProps, +} from './empty-state'; diff --git a/packages/mosaic/src/components/scroll-area/index.ts b/packages/mosaic/src/components/scroll-area/index.ts index 5c72aeb1986..32cda3016eb 100644 --- a/packages/mosaic/src/components/scroll-area/index.ts +++ b/packages/mosaic/src/components/scroll-area/index.ts @@ -1,3 +1,3 @@ export { scrollAreaRoot, scrollAreaViewport } from './scroll-area.styles'; -export type { ScrollAreaGutter } from './scroll-area.styles'; +export type { ScrollAreaAxis, ScrollAreaGutter } from './scroll-area.styles'; export { scrollAreaVars } from './scroll-area.vars.stylex'; diff --git a/packages/mosaic/src/components/scroll-area/scroll-area.styles.ts b/packages/mosaic/src/components/scroll-area/scroll-area.styles.ts index 91891adae0d..404b1b65f14 100644 --- a/packages/mosaic/src/components/scroll-area/scroll-area.styles.ts +++ b/packages/mosaic/src/components/scroll-area/scroll-area.styles.ts @@ -49,7 +49,10 @@ const revealEnd = stylex.keyframes({ // own, since the two can never legitimately differ. Where we do NOT paint the scrollbar the // layer is zero-wide and contributes nothing. Layers composite with `add` by default, so no // `mask-composite` declaration is needed. -const maskImage = `linear-gradient(to bottom, transparent 0, #000 calc(${progressStart} * ${fadeSize}), #000 calc(100% - ${progressEnd} * ${fadeSize}), transparent 100%), linear-gradient(#000, #000)`; +const fadeStops = `transparent 0, #000 calc(${progressStart} * ${fadeSize}), #000 calc(100% - ${progressEnd} * ${fadeSize}), transparent 100%`; +const blockMaskImage = `linear-gradient(to bottom, ${fadeStops}), linear-gradient(#000, #000)`; +const inlineMaskImage = `linear-gradient(to right, ${fadeStops}), linear-gradient(#000, #000)`; +const inlineMaskImageRtl = `linear-gradient(to left, ${fadeStops}), linear-gradient(#000, #000)`; // Split by concern rather than one object per slot: the sort-keys rule reorders within an // object, so a large one ends up interleaving unrelated properties and stranding the comments @@ -65,22 +68,13 @@ const styles = stylex.create({ }, /** - * The scroll container itself. - * - * The scroll padding answers the fade: tabbing to a row below the fold would otherwise land it - * flush against the edge the mask fades out, on the one row you just moved to. Block axis only, - * like everything else here. + * The scroll container itself. Which way it scrolls, and everything that follows from that, + * lives in `axes` below. */ viewport: { - overscrollBehavior: 'contain', flexBasis: 'auto', flexGrow: 1, flexShrink: 1, - scrollPaddingBlockEnd: fadeSize, - scrollPaddingBlockStart: fadeSize, - minHeight: 0, - overflowX: 'hidden', - overflowY: 'auto', }, /** @@ -153,6 +147,7 @@ const styles = stylex.create({ */ scrollbar: { '::-webkit-scrollbar': { + height: { default: null, '@media (pointer: fine)': scrollbarWidth }, width: { default: null, '@media (pointer: fine)': scrollbarWidth }, }, '::-webkit-scrollbar-thumb': { @@ -207,30 +202,12 @@ const styles = stylex.create({ }, }, - /** Paint-only, so it can never shift the content the way a sticky shadow element does. */ + /** + * Paint-only, so it can never shift the content the way a sticky shadow element does. The + * image, its placement and its size all depend on the axis, so they live in `axes` below. + */ mask: { - maskImage, - maskPosition: 'left top, right top', maskRepeat: 'no-repeat', - // Held back from the scrollbar only where we actually paint one, which is the same pair of - // conditions the rules above run under: a fine pointer, and an engine that implements - // `::-webkit-scrollbar`. This is not a fallback branch for the scrollbar styling — there - // isn't one — it is the mask asking whether there is a lane to keep clear. Gecko answers no - // and gets the fade edge to edge, rather than an unfaded strip beside a bar we never styled - // and whose width we don't know. - // - // `not (-moz-appearance: none)` stands in for the question we actually want to ask, - // `selector(::-webkit-scrollbar)`, because StyleX 0.19 rewrites the argument of - // `@supports selector(…)` with the same `:not(#\#)` specificity bump it applies to real - // selectors. That turns the query into `selector(:not(#\#):not(#\#):not(#\#)::-webkit-scrollbar)`, - // which every engine reports as false — verified in Chrome, where the honest form returns - // true and the rewritten one returns false. Any property-based condition is left alone. - maskSize: { - default: '100% 100%, 0px 100%', - '@media (pointer: fine)': { - '@supports not (-moz-appearance: none)': `calc(100% - ${scrollbarWidth}) 100%, ${scrollbarWidth} 100%`, - }, - }, }, // Only the name is gated on timeline support. A browser that ignores `animation-timeline` @@ -247,11 +224,69 @@ const styles = stylex.create({ default: null, '@supports (animation-timeline: scroll())': `${revealStart}, ${revealEnd}`, }, - animationTimeline: 'scroll(self block), scroll(self block)', animationTimingFunction: 'linear', }, }); +// Everything that knows which way the box scrolls: the overflow pair, the scroll padding that +// answers the fade (tabbing to a row past the fold would otherwise land it flush against the +// edge the mask fades out), the mask's geometry, and the timeline the two animations read. +// +// The mask's second layer is held back from the scrollbar only where we actually paint one, +// which is the same pair of conditions the scrollbar rules run under: a fine pointer, and an +// engine that implements `::-webkit-scrollbar`. This is not a fallback branch for the scrollbar +// styling — there isn't one — it is the mask asking whether there is a lane to keep clear. Gecko +// answers no and gets the fade edge to edge, rather than an unfaded strip beside a bar we never +// styled and whose width we don't know. +// +// `not (-moz-appearance: none)` stands in for the question we actually want to ask, +// `selector(::-webkit-scrollbar)`, because StyleX 0.19 rewrites the argument of +// `@supports selector(…)` with the same `:not(#\#)` specificity bump it applies to real +// selectors. That turns the query into `selector(:not(#\#):not(#\#):not(#\#)::-webkit-scrollbar)`, +// which every engine reports as false — verified in Chrome, where the honest form returns +// true and the rewritten one returns false. Any property-based condition is left alone. +const axes = stylex.create({ + block: { + animationTimeline: 'scroll(self block), scroll(self block)', + maskImage: blockMaskImage, + maskPosition: 'left top, right top', + maskSize: { + default: '100% 100%, 0px 100%', + '@media (pointer: fine)': { + '@supports not (-moz-appearance: none)': `calc(100% - ${scrollbarWidth}) 100%, ${scrollbarWidth} 100%`, + }, + }, + scrollPaddingBlockEnd: fadeSize, + scrollPaddingBlockStart: fadeSize, + minHeight: 0, + overflowX: 'hidden', + overflowY: 'auto', + overscrollBehaviorY: 'contain', + }, + inline: { + animationTimeline: 'scroll(self inline), scroll(self inline)', + maskImage: { + default: inlineMaskImage, + ':dir(rtl)': inlineMaskImageRtl, + }, + maskPosition: 'left top, left bottom', + maskSize: { + default: '100% 100%, 100% 0px', + '@media (pointer: fine)': { + '@supports not (-moz-appearance: none)': `100% calc(100% - ${scrollbarWidth}), 100% ${scrollbarWidth}`, + }, + }, + scrollPaddingInlineEnd: fadeSize, + scrollPaddingInlineStart: fadeSize, + minWidth: 0, + overflowX: 'auto', + overflowY: 'hidden', + overscrollBehaviorX: 'contain', + }, +}); + +export type ScrollAreaAxis = keyof typeof axes; + // Gutter only — the scrollbar's own size is a theme token (`--cl-scrollbar-width`), since // Mosaic has no reason to size scrollbars differently between components. What varies per // instance is whether the space is held open, which is a layout decision about the @@ -291,10 +326,14 @@ export type ScrollAreaGutter = keyof typeof gutters; * worth it when the content can change height **in place** — a filterable or paginated * collection — so crossing the overflow threshold doesn't shift the rows sideways. Neither * does anything on platforms that overlay their scrollbars. + * @param axis - Which way the box scrolls. `block` (the default) scrolls vertically and fades + * the top and bottom edges; `inline` scrolls horizontally — a wide table on a narrow screen — + * and fades the left and right edges instead. The other axis is clipped. */ -export function scrollAreaViewport(gutter: ScrollAreaGutter = 'auto') { +export function scrollAreaViewport(gutter: ScrollAreaGutter = 'auto', axis: ScrollAreaAxis = 'block') { return [ styles.viewport, + axes[axis], styles.thumbColor, styles.scrollbar, styles.mask, diff --git a/packages/mosaic/src/components/scroll-area/scroll-area.test.ts b/packages/mosaic/src/components/scroll-area/scroll-area.test.ts index 3acd569ae2d..16f98b4f6a1 100644 --- a/packages/mosaic/src/components/scroll-area/scroll-area.test.ts +++ b/packages/mosaic/src/components/scroll-area/scroll-area.test.ts @@ -6,7 +6,7 @@ import { scrollAreaVars, scrollbarThumbVars } from './scroll-area.vars.stylex'; describe('Mosaic scroll area styles', () => { it('composes the viewport atoms into one spreadable set', () => { - expect(scrollAreaViewport()).toHaveLength(7); + expect(scrollAreaViewport()).toHaveLength(8); expect(scrollAreaRoot).toBeDefined(); }); @@ -20,6 +20,16 @@ describe('Mosaic scroll area styles', () => { expect(scrollAreaViewport()).toEqual(scrollAreaViewport('auto')); }); + // Same reasoning as the gutter: the axis decides which edges fade and which way the box + // scrolls, so a horizontal surface must not collapse into a vertical one. + it('varies the axis atom by argument', () => { + expect(scrollAreaViewport('auto', 'inline')).not.toEqual(scrollAreaViewport('auto', 'block')); + }); + + it('defaults the axis to block', () => { + expect(scrollAreaViewport()).toEqual(scrollAreaViewport('auto', 'block')); + }); + // The `--cl-*` names are the public API — a consumer's stylesheet references them by hand, // and `clerk-js` ships to apps pinned to older SDKs, so renaming one breaks themes already // in the wild. Assert the exact strings so a rename has to be a deliberate act. diff --git a/packages/mosaic/src/components/table/index.ts b/packages/mosaic/src/components/table/index.ts new file mode 100644 index 00000000000..26b143caa70 --- /dev/null +++ b/packages/mosaic/src/components/table/index.ts @@ -0,0 +1,14 @@ +export { Table } from './table'; +export type { + TableAlign, + TableBodyProps, + TableCellProps, + TableEmptyProps, + TableHeaderCellProps, + TableHeaderProps, + TableProps, + TableRowProps, + TableSelectAllCellProps, + TableSelectCellProps, + TableSort, +} from './table'; diff --git a/packages/mosaic/src/components/table/table.styles.ts b/packages/mosaic/src/components/table/table.styles.ts new file mode 100644 index 00000000000..6a87e53cd02 --- /dev/null +++ b/packages/mosaic/src/components/table/table.styles.ts @@ -0,0 +1,77 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; + +export const styles = stylex.create({ + shell: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-xl'], + borderStyle: 'solid', + borderWidth: '1px', + overflow: 'hidden', + backgroundColor: colorVars['--cl-color-background'], + width: '100%', + }, + viewport: { + overscrollBehaviorX: 'none', + }, + table: { + borderCollapse: 'collapse', + borderSpacing: 0, + textAlign: 'start', + width: '100%', + }, + header: { + backgroundColor: colorVars['--cl-color-border-subtle'], + }, + bodyRow: { + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: '1px', + }, + selectedRow: { + backgroundColor: colorVars['--cl-color-neutral-alpha-100'], + }, + headerCell: { + paddingBlock: space['2.5'], + paddingInline: space['4'], + color: colorVars['--cl-color-foreground-secondary'], + fontSize: typeScaleVars['--cl-text-xs-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + verticalAlign: 'middle', + whiteSpace: 'nowrap', + }, + sortableHeaderCell: { + paddingBlock: space['1.5'], + paddingInline: space['2'], + }, + sortButton: { + color: 'inherit', + fontWeight: 'inherit', + }, + cell: { + paddingBlock: space['3'], + paddingInline: space['4'], + color: colorVars['--cl-color-foreground'], + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + verticalAlign: 'middle', + }, + selectCell: { + lineHeight: 0, + paddingInlineEnd: 0, + paddingInlineStart: space['4'], + verticalAlign: 'middle', + width: 0, + }, + emptyCell: { + textAlign: 'center', + }, +}); + +export const aligns = stylex.create({ + start: { textAlign: 'start' }, + center: { textAlign: 'center' }, + end: { textAlign: 'end' }, +}); diff --git a/packages/mosaic/src/components/table/table.test.tsx b/packages/mosaic/src/components/table/table.test.tsx new file mode 100644 index 00000000000..74282f66abe --- /dev/null +++ b/packages/mosaic/src/components/table/table.test.tsx @@ -0,0 +1,271 @@ +import * as stylex from '@stylexjs/stylex'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { EmptyState } from '../empty-state'; +import { Table } from './table'; +import { styles } from './table.styles'; + +const atoms = stylex.create({ + spaced: { marginTop: '8px' }, +}); + +function renderTable(props: Partial> = {}) { + return render( + + + + Name + Created + + + + + Alpha + Today + + + , + ); +} + +describe('Mosaic Table', () => { + it('renders a table inside a scrolling shell', () => { + renderTable(); + const table = screen.getByRole('table'); + expect(table).toHaveClass('cl-table'); + expect(table.parentElement).toHaveClass('cl-table-viewport'); + expect(table.parentElement?.parentElement).toHaveClass('cl-table-shell'); + }); + + it('lets the keyboard reach the scrolling viewport', () => { + renderTable(); + expect(screen.getByRole('table').parentElement).toHaveAttribute('tabindex', '0'); + }); + + it('stops the viewport from rubber banding past its edges', () => { + renderTable(); + expect(screen.getByRole('table').parentElement).toHaveClass(stylex.props(styles.viewport).className ?? ''); + }); + + it('applies slot classes to every part', () => { + renderTable(); + const [header, body] = screen.getAllByRole('rowgroup'); + expect(header).toHaveClass('cl-table-header'); + expect(body).toHaveClass('cl-table-body'); + expect(screen.getAllByRole('row')[0]).toHaveClass('cl-table-row'); + expect(screen.getByRole('columnheader', { name: 'Name' })).toHaveClass('cl-table-header-cell'); + expect(screen.getByRole('cell', { name: 'Alpha' })).toHaveClass('cl-table-cell'); + }); + + it('merges xstyle atoms on the table and forwards the ref', () => { + const ref = React.createRef(); + renderTable({ ref, xstyle: atoms.spaced }); + expect(ref.current).toBe(screen.getByRole('table')); + expect(ref.current).toHaveClass('cl-table', stylex.props(atoms.spaced).className ?? ''); + }); + + describe('sorting', () => { + it('renders a plain header cell without a sort handler', () => { + renderTable(); + const cell = screen.getByRole('columnheader', { name: 'Name' }); + expect(within(cell).queryByRole('button')).toBeNull(); + expect(cell).not.toHaveAttribute('aria-sort'); + }); + + it('renders a sort button and reflects the direction when sortable', async () => { + const onSort = vi.fn(); + const { rerender } = render( + + + + + Name + + + + , + ); + const cell = screen.getByRole('columnheader', { name: 'Name' }); + expect(cell).not.toHaveAttribute('aria-sort'); + expect(cell).toHaveAttribute('data-sortable', ''); + + await userEvent.click(within(cell).getByRole('button', { name: 'Name' })); + expect(onSort).toHaveBeenCalledTimes(1); + + rerender( + + + + + Name + + + + , + ); + expect(cell).toHaveAttribute('aria-sort', 'ascending'); + expect(cell).toHaveAttribute('data-sort', 'ascending'); + + rerender( + + + + + Name + + + + , + ); + expect(cell).toHaveAttribute('aria-sort', 'descending'); + }); + }); + + describe('selection', () => { + it('renders selection cells with checkboxes', async () => { + const onSelectAll = vi.fn(); + const onSelectRow = vi.fn(); + render( + + + + + Name + + + + + + Alpha + + + , + ); + + const selectAll = screen.getByRole('checkbox', { name: 'Select all rows' }); + expect(selectAll.indeterminate).toBe(true); + expect(selectAll.closest('th')).toHaveClass('cl-table-select-cell'); + await userEvent.click(selectAll); + expect(onSelectAll).toHaveBeenCalledTimes(1); + + const selectRow = screen.getByRole('checkbox', { name: 'Select Alpha' }); + expect(selectRow).toBeChecked(); + expect(selectRow.closest('td')).toHaveClass('cl-table-select-cell'); + await userEvent.click(selectRow); + expect(onSelectRow).toHaveBeenCalledTimes(1); + }); + + it('reports whether a row toggle extends a range', async () => { + const onToggleSelected = vi.fn(); + render( + + + + + Alpha + + + , + ); + + const user = userEvent.setup(); + const selectRow = screen.getByRole('checkbox', { name: 'Select Alpha' }); + await user.click(selectRow); + expect(onToggleSelected).toHaveBeenLastCalledWith({ range: false }); + + await user.keyboard('{Shift>}'); + await user.click(selectRow); + await user.keyboard('{/Shift}'); + expect(onToggleSelected).toHaveBeenLastCalledWith({ range: true }); + }); + + it('reflects the selected row state', () => { + render( + + + + Alpha + + + Beta + + + , + ); + const [selected, unselected] = screen.getAllByRole('row'); + expect(selected).toHaveAttribute('aria-selected', 'true'); + expect(selected).toHaveAttribute('data-selected', ''); + expect(unselected).not.toHaveAttribute('aria-selected'); + expect(unselected).not.toHaveAttribute('data-selected'); + }); + }); + + describe('empty state', () => { + it('renders the empty state across every column', () => { + render( + + + + Name + Created + + + + + + + No results + Try a different search. + + + + , + ); + const cell = screen.getByRole('cell'); + expect(cell).toHaveClass('cl-table-empty'); + expect(cell).toHaveAttribute('colspan', '2'); + expect(within(cell).getByText('No results')).toHaveClass('cl-empty-state-label'); + expect(within(cell).getByText('Try a different search.')).toBeInTheDocument(); + }); + + it('spans every column when colSpan is omitted', () => { + render( + + + + + No results + + + + , + ); + expect(screen.getByRole('cell')).toHaveAttribute('colspan', '1000'); + }); + }); +}); diff --git a/packages/mosaic/src/components/table/table.tsx b/packages/mosaic/src/components/table/table.tsx new file mode 100644 index 00000000000..ffb4365f257 --- /dev/null +++ b/packages/mosaic/src/components/table/table.tsx @@ -0,0 +1,230 @@ +import { useRender } from '@clerk/headless/utils'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicComponentProps, MosaicElementProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../utils/reset.styles'; +import { Button } from '../button'; +import type { CheckboxProps } from '../checkbox'; +import { Checkbox } from '../checkbox'; +import { Icon } from '../icon'; +import { scrollAreaRoot, scrollAreaViewport } from '../scroll-area'; +import { aligns, styles } from './table.styles'; + +type TableSection = 'header' | 'body'; + +const TableSectionContext = React.createContext('body'); + +export type TableAlign = keyof typeof aligns; + +export type TableSort = 'ascending' | 'descending' | 'none'; + +export type TableProps = MosaicElementProps<'table'>; + +const Root = React.forwardRef(function MosaicTable({ xstyle, ...rest }, ref) { + return ( +
+
+ + + + ); +}); + +export type TableHeaderProps = MosaicComponentProps<'thead'>; + +const Header = React.forwardRef(function MosaicTableHeader( + { render, xstyle, ...rest }, + ref, +) { + const element = useRender({ + defaultTagName: 'thead', + render, + ref, + props: mergeStyleProps(themeProps('table-header'), stylex.props(reset.base, styles.header, xstyle), rest), + }); + return {element}; +}); + +export type TableBodyProps = MosaicComponentProps<'tbody'>; + +const Body = React.forwardRef(function MosaicTableBody( + { render, xstyle, ...rest }, + ref, +) { + const element = useRender({ + defaultTagName: 'tbody', + render, + ref, + props: mergeStyleProps(themeProps('table-body'), stylex.props(reset.base, xstyle), rest), + }); + return {element}; +}); + +export interface TableRowProps extends MosaicComponentProps<'tr'> { + selected?: boolean; +} + +const Row = React.forwardRef(function MosaicTableRow( + { selected, render, xstyle, ...rest }, + ref, +) { + const section = React.useContext(TableSectionContext); + return useRender({ + defaultTagName: 'tr', + render, + ref, + props: mergeStyleProps( + themeProps('table-row', { selected }), + stylex.props(reset.base, section === 'body' && styles.bodyRow, selected && styles.selectedRow, xstyle), + { 'aria-selected': selected ? true : undefined }, + rest, + ), + }); +}); + +export interface TableHeaderCellProps extends Omit, 'align'> { + align?: TableAlign; + sort?: TableSort; + onSort?: () => void; +} + +const sortIcons = { + ascending: 'chevron-up', + descending: 'chevron-down', + none: 'chevron-up-down', +} as const; + +const HeaderCell = React.forwardRef(function MosaicTableHeaderCell( + { align = 'start', sort = 'none', onSort, children, render, xstyle, ...rest }, + ref, +) { + const sortable = onSort !== undefined; + return useRender({ + defaultTagName: 'th', + render, + ref, + props: { + scope: 'col', + ...mergeStyleProps( + themeProps('table-header-cell', { align, sortable, sort: sortable && sort !== 'none' ? sort : undefined }), + stylex.props(reset.base, styles.headerCell, aligns[align], sortable && styles.sortableHeaderCell, xstyle), + { 'aria-sort': sortable && sort !== 'none' ? sort : undefined }, + rest, + ), + children: sortable ? ( + + ) : ( + children + ), + }, + }); +}); + +export interface TableCellProps extends Omit, 'align'> { + align?: TableAlign; +} + +const Cell = React.forwardRef(function MosaicTableCell( + { align = 'start', render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'td', + render, + ref, + props: mergeStyleProps( + themeProps('table-cell', { align }), + stylex.props(reset.base, styles.cell, aligns[align], xstyle), + rest, + ), + }); +}); + +export type TableSelectAllCellProps = CheckboxProps; + +export interface TableSelectCellProps extends CheckboxProps { + onToggleSelected?: (options: { range: boolean }) => void; +} + +const selectCellProps = (slot: string) => + mergeStyleProps(themeProps(slot), stylex.props(reset.base, styles.cell, styles.selectCell)); + +const isShiftClick = (event: Event) => 'shiftKey' in event && event.shiftKey === true; + +const SelectAllCell = React.forwardRef( + function MosaicTableSelectAllCell(props, ref) { + return ( + + ); + }, +); + +const SelectCell = React.forwardRef(function MosaicTableSelectCell( + { onToggleSelected, onChange, ...rest }, + ref, +) { + return ( + + ); +}); + +export type TableEmptyProps = MosaicElementProps<'td'>; + +const Empty = React.forwardRef(function MosaicTableEmpty( + { colSpan = 1000, xstyle, ...rest }, + ref, +) { + return ( + +
+ + + { + onChange?.(event); + onToggleSelected?.({ range: isShiftClick(event.nativeEvent) }); + }} + {...rest} + /> + + + ); +}); + +export const Table = { Root, Header, Body, Row, HeaderCell, Cell, SelectAllCell, SelectCell, Empty }; diff --git a/packages/mosaic/src/icons/registry.tsx b/packages/mosaic/src/icons/registry.tsx index daaba70791a..493a8ba4f1b 100644 --- a/packages/mosaic/src/icons/registry.tsx +++ b/packages/mosaic/src/icons/registry.tsx @@ -137,6 +137,13 @@ const Plus = glyph( />, ); +const Minus = glyph( + , +); + const Search = glyph( > = { banner: dynamic(() => import('../stories/banner.mdx')), button: dynamic(() => import('../stories/button.mdx')), card: dynamic(() => import('../stories/card.component.mdx')), + checkbox: dynamic(() => import('../stories/checkbox.mdx')), combobox: dynamic(() => import('../stories/combobox.mdx')), input: dynamic(() => import('../stories/input.mdx')), 'input-group': dynamic(() => import('../stories/input-group.mdx')), @@ -59,6 +60,7 @@ const docModules: Record> = { item: dynamic(() => import('../stories/item.mdx')), dialog: dynamic(() => import('../stories/dialog.component.mdx')), drawer: dynamic(() => import('../stories/drawer.component.mdx')), + 'empty-state': dynamic(() => import('../stories/empty-state.mdx')), heading: dynamic(() => import('../stories/heading.mdx')), icon: dynamic(() => import('../stories/icon.mdx')), 'icon-frame': dynamic(() => import('../stories/icon-frame.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 8be68b24fd2..57d5760d3fd 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -24,6 +24,12 @@ import { } from '../stories/banner.stories'; import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories'; import { Default as CardDefault, meta as cardComponentMeta } from '../stories/card.component.stories'; +import { + Default as CheckboxDefault, + meta as checkboxMeta, + Sizes as CheckboxSizes, + States as CheckboxStates, +} from '../stories/checkbox.stories'; import { meta as collapsibleMeta } from '../stories/collapsible.stories'; import { meta as comboboxPrimitiveMeta } from '../stories/combobox.primitive.stories'; import { @@ -49,6 +55,12 @@ import { meta as drawerComponentMeta, } from '../stories/drawer.component.stories'; import { meta as drawerMeta } from '../stories/drawer.stories'; +import { + Default as EmptyStateDefault, + LabelOnly as EmptyStateLabelOnly, + meta as emptyStateMeta, + NoResults as EmptyStateNoResults, +} from '../stories/empty-state.stories'; import { Default as FieldDefault, meta as fieldMeta, @@ -164,6 +176,7 @@ import { import { Default as ScrollAreaDefault, Gutter as ScrollAreaGutter, + Horizontal as ScrollAreaHorizontal, HoverReveal as ScrollAreaHoverReveal, meta as scrollAreaMeta, NotScrollable as ScrollAreaNotScrollable, @@ -188,7 +201,14 @@ import { Placeholder as SelectComponentPlaceholder, } from '../stories/select.component.stories'; import { meta as selectMeta } from '../stories/select.stories'; -import { meta as tableMeta } from '../stories/table.stories'; +import { + Default as TableDefault, + Empty as TableEmpty, + meta as tableMeta, + Overflow as TableOverflow, + Selection as TableSelection, + Sorting as TableSorting, +} from '../stories/table.stories'; import { meta as tabsMeta } from '../stories/tabs.stories'; import { Colors as TextColors, @@ -338,6 +358,20 @@ const drawerComponentModule: StoryModule = { const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: CardDefault }; +const checkboxModule: StoryModule = { + meta: checkboxMeta, + Default: CheckboxDefault, + States: CheckboxStates, + Sizes: CheckboxSizes, +}; + +const emptyStateModule: StoryModule = { + meta: emptyStateMeta, + Default: EmptyStateDefault, + NoResults: EmptyStateNoResults, + LabelOnly: EmptyStateLabelOnly, +}; + const comboboxModule: StoryModule = { meta: comboboxMeta, Default: ComboboxDefault, @@ -510,6 +544,7 @@ const scrollAreaModule: StoryModule = { Default: ScrollAreaDefault, NotScrollable: ScrollAreaNotScrollable, Gutter: ScrollAreaGutter, + Horizontal: ScrollAreaHorizontal, HoverReveal: ScrollAreaHoverReveal, ThemedScrollbar: ScrollAreaThemedScrollbar, ShadowIndicators: ScrollAreaShadowIndicators, @@ -527,8 +562,16 @@ const localizationModule: StoryModule = { PluralRules: LocalizationPluralRules, }; +const tableModule: StoryModule = { + meta: tableMeta, + Default: TableDefault, + Sorting: TableSorting, + Selection: TableSelection, + Empty: TableEmpty, + Overflow: TableOverflow, +}; + // Planned but not yet implemented; the entry reserves its sidebar slot with a todo dot. -const tableModule: StoryModule = { meta: tableMeta }; const toastModule: StoryModule = { meta: toastMeta }; const userProfileApiKeysPanelModule: StoryModule = { @@ -708,6 +751,7 @@ export const registry: StoryModule[] = [ bannerModule, buttonModule, cardComponentModule, + checkboxModule, comboboxModule, flowComponentModule, inputModule, @@ -716,6 +760,7 @@ export const registry: StoryModule[] = [ itemModule, dialogComponentModule, drawerComponentModule, + emptyStateModule, headingModule, iconModule, iconFrameModule, diff --git a/packages/swingset/src/stories/checkbox.mdx b/packages/swingset/src/stories/checkbox.mdx new file mode 100644 index 00000000000..8f450d7b4be --- /dev/null +++ b/packages/swingset/src/stories/checkbox.mdx @@ -0,0 +1,53 @@ +import * as CheckboxStories from './checkbox.stories'; + +# Checkbox + +Checkbox is a styled native ``. It forwards its ref and every input prop to the input, so it works controlled (`checked` + `onChange`) or uncontrolled (`defaultChecked`) exactly like the native element. `indeterminate` sets the DOM property of the same name, which is how a "select all" control shows a partial selection. + +## Playground + + + +## Props + + void' }, + ]} +/> + +## Usage + +```tsx +import { Checkbox } from '@clerk/mosaic/components/checkbox'; + + toggle()} +/>; +``` + +--- + +## Examples + +### States + + + +### Sizes + + diff --git a/packages/swingset/src/stories/checkbox.stories.tsx b/packages/swingset/src/stories/checkbox.stories.tsx new file mode 100644 index 00000000000..49cae6f8261 --- /dev/null +++ b/packages/swingset/src/stories/checkbox.stories.tsx @@ -0,0 +1,87 @@ +import type { CheckboxProps } from '@clerk/mosaic/components/checkbox'; +import { Checkbox } from '@clerk/mosaic/components/checkbox'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './checkbox.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + status: 'wip', + title: 'Checkbox', + source: 'packages/mosaic/src/components/checkbox/checkbox.tsx', + styles: { + _variants: { + size: { sm: {}, md: {} }, + indeterminate: { true: {}, false: {} }, + disabled: { true: {}, false: {} }, + }, + _defaultVariants: { + size: 'md', + indeterminate: 'false', + disabled: 'false', + }, + }, +}; + +function knobsAsProps(props: Record) { + const { indeterminate, disabled, ...rest } = props; + return { + ...rest, + indeterminate: indeterminate === true || indeterminate === 'true', + disabled: disabled === true || disabled === 'true', + } as unknown as CheckboxProps; +} + +export function Default(props: Record) { + return ( + + ); +} + +export function States() { + return ( +
+ + + + + +
+ ); +} + +export function Sizes() { + return ( +
+ + +
+ ); +} diff --git a/packages/swingset/src/stories/empty-state.mdx b/packages/swingset/src/stories/empty-state.mdx new file mode 100644 index 00000000000..a5f3242b8cb --- /dev/null +++ b/packages/swingset/src/stories/empty-state.mdx @@ -0,0 +1,72 @@ +import * as EmptyStateStories from './empty-state.stories'; + +# EmptyState + +EmptyState explains why a surface has nothing in it and, optionally, what to do about it. It's composed from parts via dot syntax: `EmptyState.Root` centers its children in whatever container holds it, `EmptyState.Icon` renders a named icon in a filled frame, and `EmptyState.Label`, `EmptyState.Description`, and `EmptyState.Actions` stack below it. Drop it inside [Table](/components/table)'s `Table.Empty` row, a card, or a section. + +## Example + + + +## Usage + +```tsx +import { EmptyState } from '@clerk/mosaic/components/empty-state'; + + + + No results + Try a different search. +; +``` + +## Parts + +| Part | Element | Notes | +| ------------------------ | -------- | ---------------------------------------------------------- | +| `EmptyState.Root` | `
` | Centers the stack and takes `xstyle`, `ref`, and `render`. | +| `EmptyState.Icon` | `` | Renders the icon named by `name` inside a filled frame. | +| `EmptyState.Label` | `

` | The heading line. | +| `EmptyState.Description` | `

` | Secondary copy, capped at a readable measure. | +| `EmptyState.Actions` | `

` | A wrapping row for buttons. | + +Every part takes `xstyle` and `ref` along with its element's props; all but `EmptyState.Icon` take `render` too. + +## Styling + +Each part carries a stable class and reflects no state, so target the classes directly: + +| Class | Part | +| ----------------------------- | ------------------------ | +| `.cl-empty-state` | `EmptyState.Root` | +| `.cl-empty-state-icon` | `EmptyState.Icon` | +| `.cl-empty-state-label` | `EmptyState.Label` | +| `.cl-empty-state-description` | `EmptyState.Description` | +| `.cl-empty-state-actions` | `EmptyState.Actions` | + +```css +.cl-empty-state-description { + max-width: 40ch; +} +``` + +--- + +## Examples + +### Without actions + + + +### Label only + + diff --git a/packages/swingset/src/stories/empty-state.stories.tsx b/packages/swingset/src/stories/empty-state.stories.tsx new file mode 100644 index 00000000000..ef133089adf --- /dev/null +++ b/packages/swingset/src/stories/empty-state.stories.tsx @@ -0,0 +1,53 @@ +import { Button } from '@clerk/mosaic/components/button'; +import { EmptyState } from '@clerk/mosaic/components/empty-state'; +import { Icon } from '@clerk/mosaic/components/icon'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './empty-state.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + status: 'wip', + title: 'EmptyState', + source: 'packages/mosaic/src/components/empty-state/empty-state.tsx', +}; + +export function Default() { + return ( + + + No API keys + Create a key to start making requests from your application. + + + + + ); +} + +export function NoResults() { + return ( + + + No results + No keys match your search. Try a different name. + + ); +} + +export function LabelOnly() { + return ( + + Nothing to show yet + + ); +} diff --git a/packages/swingset/src/stories/scroll-area.mdx b/packages/swingset/src/stories/scroll-area.mdx index 3419909dda2..917f0060431 100644 --- a/packages/swingset/src/stories/scroll-area.mdx +++ b/packages/swingset/src/stories/scroll-area.mdx @@ -46,14 +46,15 @@ const root = stylex.props(scrollAreaRoot); ## Parts -| Export | Goes on | Description | -| ----------------------------- | --------------------- | --------------------------------------------------------------------------------- | -| `scrollAreaViewport(gutter?)` | the scrolling element | The scroll box itself: overflow, the edge fades, the scrollbar, and a focus ring. | -| `scrollAreaRoot` | a positioned ancestor | Only needed when something anchors against the scroll box. | +| Export | Goes on | Description | +| ------------------------------------ | --------------------- | --------------------------------------------------------------------------------- | +| `scrollAreaViewport(gutter?, axis?)` | the scrolling element | The scroll box itself: overflow, the edge fades, the scrollbar, and a focus ring. | +| `scrollAreaRoot` | a positioned ancestor | Only needed when something anchors against the scroll box. | -`gutter` is the one argument — `auto` (the default) or `stable`, see [Gutter](#gutter). It is an -author-time decision rather than a theme one, since whether a region needs it depends on whether its -content can resize in place. +`gutter` is `auto` (the default) or `stable`, see [Gutter](#gutter). `axis` is `block` (the default) +or `inline`, see [Horizontal](#horizontal). Both are author-time decisions rather than theme ones: +whether a region needs a gutter depends on whether its content can resize in place, and which way it +scrolls is a property of the content itself. ## Examples @@ -84,6 +85,22 @@ Two conditions must **both** hold for the two to differ at all: space-consuming content that can stop overflowing. Where the platform overlays its scrollbars they render identically, which is why `auto` is the default. +### Horizontal + + + +`axis: 'inline'` turns the whole surface sideways: the box scrolls horizontally, the fades move to +the left and right edges, the scroll timeline reads inline progress, and the scrollbar lane along +the bottom is held clear of the mask. The other axis is clipped. It is the shape a wide +[Table](/components/table) takes on a narrow screen. + +```tsx +
{columns}
+``` + ### Reveal on hover +
+ {manyRows.map(name => ( + + {name} + + ))} +
+
+ ); +} + /** * Taking `--cl-scrollbar-thumb-idle` to zero alpha removes the bar entirely until the pointer * reaches the region. `oklch(from … / 0)` rather than `transparent`, which is transparent BLACK and diff --git a/packages/swingset/src/stories/table.mdx b/packages/swingset/src/stories/table.mdx index b852611dcf1..1397fcb085a 100644 --- a/packages/swingset/src/stories/table.mdx +++ b/packages/swingset/src/stories/table.mdx @@ -1,3 +1,108 @@ +import * as TableStories from './table.stories'; + # Table -Styled Mosaic component for tabular data, pairing with the [useDataTable](/hooks/use-data-table) hook for pagination, sorting, filtering, and row selection. Not yet implemented. +Table renders tabular data in a bordered Mosaic shell. It's composed from parts via dot syntax: `Table.Root` renders the `` inside a shell that scrolls horizontally when the columns outgrow the container, and `Table.Header`, `Table.Body`, `Table.Row`, `Table.HeaderCell`, and `Table.Cell` map onto their native elements. + +Sorting, selection, and the empty state are opt-in parts rather than state the table owns. Pair it with the [useDataTable](/hooks/use-data-table) hook, which holds sorting and row selection, and apply the sort to your rows before rendering them. Pagination is rendered by the consumer below the table with the Pagination component. + +## Example + + + +## Usage + +```tsx +import { Table } from '@clerk/mosaic/components/table'; + + + + + Name + Created + + + + {rows.map(row => ( + + {row.name} + {row.created} + + ))} + +; +``` + +## Parts + +- `Table.Root` renders the `
`. `xstyle`, `ref`, and any table props land on it; the scrolling shell around it is internal. +- `Table.Header` and `Table.Body` render `` and ``. Rows inside the body get a top border. +- `Table.Row` renders a ``. Pass `selected` to mark it `aria-selected` and tint it. +- `Table.HeaderCell` renders a `
`. Pass `onSort` to make it sortable: the label becomes a button with a direction icon, and `sort` (`'ascending'`, `'descending'`, or `'none'`) reflects the current direction as `aria-sort`. `align` moves the content to `'center'` or `'end'`. +- `Table.Cell` renders a `` with the same `align` prop. +- `Table.SelectAllCell` and `Table.SelectCell` render a narrow header or body cell holding a [Checkbox](/components/checkbox). They take Checkbox props, so pass `checked`, `indeterminate`, and an `aria-label`. `Table.SelectCell` also takes `onToggleSelected`, called with `{ range: true }` when the checkbox was shift-clicked, which fits the row's `toggleSelected` from `useDataTable` directly. +- `Table.Empty` renders a full-width row with a single cell that spans every column. Put an [EmptyState](/components/empty-state) inside it. The cell uses the largest `colSpan` browsers allow, which stretches across the columns the table actually has; pass `colSpan` yourself if you need the exact count. + +## Styling + +Each part carries a stable `.cl-*` class, and the parts with state reflect it as `data-*` attributes: + +| Class | Part | Attributes | +| ----------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `.cl-table-shell` | The bordered wrapper around the table | | +| `.cl-table-viewport` | The horizontally scrolling viewport | | +| `.cl-table` | `Table.Root` | | +| `.cl-table-header` | `Table.Header` | | +| `.cl-table-body` | `Table.Body` | | +| `.cl-table-row` | `Table.Row` | `data-selected` when `selected` | +| `.cl-table-header-cell` | `Table.HeaderCell` | `data-align` (`start` / `center` / `end`), `data-sortable` when `onSort` is set, `data-sort` (`ascending` / `descending`) | +| `.cl-table-cell` | `Table.Cell` | `data-align` (`start` / `center` / `end`) | +| `.cl-table-select-cell` | `Table.SelectAllCell`, `Table.SelectCell` | | +| `.cl-table-empty` | `Table.Empty` | | + +```css +.cl-table-row[data-selected] { + background: rgba(0, 0, 0, 0.06); +} +``` + +--- + +## Examples + +### Sorting + +Each sortable header cycles through ascending, descending, and unsorted. The story keeps sorting in `useDataTable` and sorts the rows itself before rendering. + + + +### Selection + +`Table.SelectAllCell` reads `getIsAllRowsSelected` and `getIsSomeRowsSelected` from `useDataTable`; each `Table.SelectCell` reads its row. Select a row, then shift-click another to select every row in between. + + + +### Empty + + + +### Overflow + +Wide tables scroll horizontally inside the shell instead of breaking the page layout. The viewport is the shared [Scroll Area](/styles/scroll-area) on its `inline` axis, so the left and right edges fade while there is more to reveal and the scrollbar matches the rest of Mosaic. + + diff --git a/packages/swingset/src/stories/table.stories.tsx b/packages/swingset/src/stories/table.stories.tsx index 6b0aa1d6695..ae4dc1c6b10 100644 --- a/packages/swingset/src/stories/table.stories.tsx +++ b/packages/swingset/src/stories/table.stories.tsx @@ -1,7 +1,340 @@ +import type { OnChangeFn, SortingState } from '@clerk/headless/hooks'; +import { useDataTable } from '@clerk/headless/hooks'; +import { Badge } from '@clerk/mosaic/components/badge'; +import { Button } from '@clerk/mosaic/components/button'; +import { EmptyState } from '@clerk/mosaic/components/empty-state'; +import { Icon } from '@clerk/mosaic/components/icon'; +import { Menu } from '@clerk/mosaic/components/menu'; +import type { TableSort } from '@clerk/mosaic/components/table'; +import { Table } from '@clerk/mosaic/components/table'; +import { VisuallyHidden } from '@clerk/mosaic/components/visually-hidden'; +import * as React from 'react'; + import type { StoryMeta } from '@/lib/types'; +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './table.stories?raw'; + export const meta: StoryMeta = { group: 'Components', title: 'Table', - status: 'todo', + status: 'wip', + layout: 'wide', + source: 'packages/mosaic/src/components/table/table.tsx', +}; + +type ApiKey = { + id: string; + name: string; + environment: 'Production' | 'Development'; + lastUsed: string; + lastUsedAt: number; + created: string; + createdAt: number; +}; + +const apiKeys: ApiKey[] = [ + { + id: 'k1', + name: 'Web app', + environment: 'Production', + lastUsed: '2 minutes ago', + lastUsedAt: Date.UTC(2026, 4, 1, 9, 58), + created: 'Jan 4, 2026', + createdAt: Date.UTC(2026, 0, 4), + }, + { + id: 'k2', + name: 'Mobile app', + environment: 'Production', + lastUsed: '1 hour ago', + lastUsedAt: Date.UTC(2026, 4, 1, 9), + created: 'Feb 12, 2026', + createdAt: Date.UTC(2026, 1, 12), + }, + { + id: 'k3', + name: 'CI pipeline', + environment: 'Development', + lastUsed: 'Yesterday', + lastUsedAt: Date.UTC(2026, 3, 30), + created: 'Mar 3, 2026', + createdAt: Date.UTC(2026, 2, 3), + }, + { + id: 'k4', + name: 'Local testing', + environment: 'Development', + lastUsed: 'Never', + lastUsedAt: 0, + created: 'Apr 21, 2026', + createdAt: Date.UTC(2026, 3, 21), + }, +]; + +const sortKeys = { + name: (key: ApiKey) => key.name, + lastUsed: (key: ApiKey) => key.lastUsedAt, + created: (key: ApiKey) => key.createdAt, }; + +type SortableColumn = keyof typeof sortKeys; + +const isSortableColumn = (id: string): id is SortableColumn => id in sortKeys; + +function compare(a: string | number, b: string | number) { + return typeof a === 'string' || typeof b === 'string' ? String(a).localeCompare(String(b)) : a - b; +} + +function sortFor(sorting: SortingState, column: SortableColumn): TableSort { + const current = sorting[0]; + if (!current || current.id !== column) { + return 'none'; + } + return current.desc ? 'descending' : 'ascending'; +} + +function cycleSort(setSorting: OnChangeFn, column: SortableColumn) { + setSorting(sorting => { + const current = sorting[0]; + if (!current || current.id !== column) { + return [{ id: column, desc: false }]; + } + return current.desc ? [] : [{ id: column, desc: true }]; + }); +} + +function sortRows(rows: ApiKey[], sorting: SortingState) { + const current = sorting[0]; + if (!current || !isSortableColumn(current.id)) { + return rows; + } + const key = sortKeys[current.id]; + const sorted = [...rows].sort((a, b) => compare(key(a), key(b))); + return current.desc ? sorted.reverse() : sorted; +} + +function useSortedApiKeys(data: ApiKey[]) { + const [sorting, setSorting] = React.useState([]); + const sorted = React.useMemo(() => sortRows(data, sorting), [data, sorting]); + return { sorting, setSorting, sorted }; +} + +export function Default() { + const { sorting, setSorting, sorted } = useSortedApiKeys(apiKeys); + const table = useDataTable({ + data: sorted, + getRowId: row => row.id, + sorting, + onSortingChange: setSorting, + }); + + return ( + + + + table.toggleAllRowsSelected()} + /> + cycleSort(table.setSorting, 'name')} + > + Name + + cycleSort(table.setSorting, 'lastUsed')} + > + Last used + + cycleSort(table.setSorting, 'created')} + > + Created + + + Actions + + + + + {table.rows.map(row => ( + + + + + {row.original.name} + + {row.original.environment} + + + + {row.original.lastUsed} + {row.original.created} + + + + + + Revoke + + + + + + ))} + + + ); +} + +export function Sorting() { + const { sorting, setSorting, sorted } = useSortedApiKeys(apiKeys); + + return ( + + + + cycleSort(setSorting, 'name')} + > + Name + + cycleSort(setSorting, 'created')} + > + Created + + Environment + + + + {sorted.map(key => ( + + {key.name} + {key.created} + {key.environment} + + ))} + + + ); +} + +export function Selection() { + const table = useDataTable({ data: apiKeys, getRowId: row => row.id }); + + return ( + + + + table.toggleAllRowsSelected()} + /> + Name + Last used + + + + {table.rows.map(row => ( + + + {row.original.name} + {row.original.lastUsed} + + ))} + + + ); +} + +export function Empty() { + return ( + + + + Name + Last used + Created + + + + + + + No API keys + + Create a key to start making requests from your application. + + + + + + + + + ); +} + +export function Overflow() { + const columns = ['Name', 'Environment', 'Last used', 'Created', 'Expires', 'Created by', 'Scopes', 'Requests']; + + return ( +
+ + + + {columns.map(column => ( + {column} + ))} + + + + {apiKeys.map(key => ( + + {key.name} + {key.environment} + {key.lastUsed} + {key.created} + Never + cameron@clerk.com + read, write + 12,408 + + ))} + + +
+ ); +} diff --git a/packages/swingset/src/stories/use-data-table.mdx b/packages/swingset/src/stories/use-data-table.mdx index 368f29f2077..1ba5f71f88b 100644 --- a/packages/swingset/src/stories/use-data-table.mdx +++ b/packages/swingset/src/stories/use-data-table.mdx @@ -59,12 +59,12 @@ const { Each `DataTableRow` has: -| Field | Type | Description | -| ------------------ | --------------- | ----------------------------- | -| `id` | `string` | Stable row identifier | -| `original` | `TData` | Original data item | -| `getIsSelected()` | `() => boolean` | Whether this row is selected | -| `toggleSelected()` | `() => void` | Toggle selection for this row | +| Field | Type | Description | +| -------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `id` | `string` | Stable row identifier | +| `original` | `TData` | Original data item | +| `getIsSelected()` | `() => boolean` | Whether this row is selected | +| `toggleSelected(options?)` | `(options?: { range?: boolean }) => void` | Toggle selection for this row; with `range`, apply the toggle to every row between the last toggled row and this one | ### Pagination