Skip to content

Commit aaa6fa4

Browse files
committed
feat(mosaic): add Table, Checkbox and EmptyState components
1 parent 87f161a commit aaa6fa4

32 files changed

Lines changed: 2071 additions & 70 deletions

‎.changeset/mosaic-table.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/headless/src/hooks/use-data-table.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,5 +436,53 @@ describe('useDataTable', () => {
436436

437437
expect(result.current.rowSelection).toEqual(controlled);
438438
});
439+
440+
it('row.toggleSelected({ range: true }) selects every row between the last toggled row and this one', () => {
441+
const { result } = renderHook(() => useDataTable({ data: DATA }));
442+
443+
act(() => result.current.rows[0].toggleSelected());
444+
act(() => result.current.rows[2].toggleSelected({ range: true }));
445+
446+
expect(result.current.rowSelection).toEqual({ '0': true, '1': true, '2': true });
447+
});
448+
449+
it('row.toggleSelected({ range: true }) selects a range above the last toggled row', () => {
450+
const { result } = renderHook(() => useDataTable({ data: DATA }));
451+
452+
act(() => result.current.rows[2].toggleSelected());
453+
act(() => result.current.rows[0].toggleSelected({ range: true }));
454+
455+
expect(result.current.rowSelection).toEqual({ '0': true, '1': true, '2': true });
456+
});
457+
458+
it('row.toggleSelected({ range: true }) deselects the range when this row is selected', () => {
459+
const { result } = renderHook(() =>
460+
useDataTable({ data: DATA, defaultRowSelection: { '0': true, '1': true, '2': true } }),
461+
);
462+
463+
act(() => result.current.rows[0].toggleSelected());
464+
act(() => result.current.rows[2].toggleSelected({ range: true }));
465+
466+
expect(result.current.rowSelection).toEqual({ '0': false, '1': false, '2': false });
467+
});
468+
469+
it('row.toggleSelected({ range: true }) toggles only this row when no row was toggled before', () => {
470+
const { result } = renderHook(() => useDataTable({ data: DATA }));
471+
472+
act(() => result.current.rows[2].toggleSelected({ range: true }));
473+
474+
expect(result.current.rowSelection).toEqual({ '2': true });
475+
});
476+
477+
it('row.toggleSelected({ range: true }) anchors on the row that ended the last range', () => {
478+
const { result } = renderHook(() => useDataTable({ data: DATA }));
479+
480+
act(() => result.current.rows[0].toggleSelected());
481+
act(() => result.current.rows[1].toggleSelected({ range: true }));
482+
act(() => result.current.rows[2].toggleSelected({ range: true }));
483+
act(() => result.current.rows[1].toggleSelected({ range: true }));
484+
485+
expect(result.current.rowSelection).toEqual({ '0': true, '1': false, '2': false });
486+
});
439487
});
440488
});

‎packages/headless/src/hooks/use-data-table.ts‎

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { useCallback, useMemo } from 'react';
3+
import { useCallback, useMemo, useRef } from 'react';
44

55
import { useControllableState } from './use-controllable-state';
66

@@ -46,7 +46,7 @@ export interface DataTableRow<TData> {
4646
id: string;
4747
original: TData;
4848
getIsSelected: () => boolean;
49-
toggleSelected: () => void;
49+
toggleSelected: (options?: { range?: boolean }) => void;
5050
}
5151

5252
export interface UseDataTableReturn<TData> {
@@ -136,19 +136,31 @@ export function useDataTable<TData>(opts: UseDataTableOptions<TData>): UseDataTa
136136
// ── Rows ────────────────────────────────────────────────────────────────────
137137

138138
const { getRowId } = opts;
139-
const rows = useMemo<DataTableRow<TData>[]>(
140-
() =>
141-
opts.data.map((original, i) => {
142-
const id = getRowId ? getRowId(original, i) : String(i);
143-
return {
144-
id,
145-
original,
146-
getIsSelected: () => !!rowSelection[id],
147-
toggleSelected: () => setRowSelection(old => ({ ...old, [id]: !old[id] })),
148-
};
149-
}),
150-
[opts.data, getRowId, rowSelection, setRowSelection],
151-
);
139+
const selectionAnchor = useRef<string | null>(null);
140+
const rows = useMemo<DataTableRow<TData>[]>(() => {
141+
const ids = opts.data.map((original, i) => (getRowId ? getRowId(original, i) : String(i)));
142+
return opts.data.map((original, i) => {
143+
const id = ids[i];
144+
return {
145+
id,
146+
original,
147+
getIsSelected: () => !!rowSelection[id],
148+
toggleSelected: options => {
149+
const anchor = selectionAnchor.current;
150+
const anchorIndex = options?.range && anchor !== null ? ids.indexOf(anchor) : -1;
151+
selectionAnchor.current = id;
152+
setRowSelection(old => {
153+
const selected = !old[id];
154+
if (anchorIndex === -1) {
155+
return { ...old, [id]: selected };
156+
}
157+
const rangeIds = ids.slice(Math.min(anchorIndex, i), Math.max(anchorIndex, i) + 1);
158+
return { ...old, ...Object.fromEntries(rangeIds.map(rangeId => [rangeId, selected])) };
159+
});
160+
},
161+
};
162+
});
163+
}, [opts.data, getRowId, rowSelection, setRowSelection]);
152164

153165
// ── Pagination helpers ──────────────────────────────────────────────────────
154166

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import * as stylex from '@stylexjs/stylex';
2+
3+
export const checkboxInputMarker = stylex.defineMarker();
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import * as stylex from '@stylexjs/stylex';
2+
3+
import { colorVars, durationVars, radiusVars, space } from '../../tokens.stylex';
4+
import { checkboxInputMarker } from './checkbox.markers.stylex';
5+
6+
const hoverBorderColor = 'light-dark(#bebebe, #525252)';
7+
8+
export const styles = stylex.create({
9+
root: {
10+
display: 'inline-flex',
11+
flexShrink: 0,
12+
position: 'relative',
13+
verticalAlign: 'middle',
14+
},
15+
input: {
16+
borderColor: {
17+
default: colorVars['--cl-color-border'],
18+
':checked': colorVars['--cl-color-brand'],
19+
':indeterminate': colorVars['--cl-color-brand'],
20+
'@media (hover: hover)': {
21+
':hover:not(:disabled):not(:checked):not(:indeterminate)': hoverBorderColor,
22+
},
23+
},
24+
borderRadius: radiusVars['--cl-radius-sm'],
25+
borderStyle: 'solid',
26+
borderWidth: '1px',
27+
appearance: 'none',
28+
backgroundColor: {
29+
default: colorVars['--cl-color-background'],
30+
':checked': colorVars['--cl-color-brand'],
31+
':indeterminate': colorVars['--cl-color-brand'],
32+
},
33+
cursor: { default: 'pointer', ':disabled': 'not-allowed' },
34+
display: 'block',
35+
flexShrink: 0,
36+
opacity: { default: null, ':disabled': 0.5 },
37+
transitionDuration: durationVars['--cl-duration-fast'],
38+
transitionProperty: 'background-color, border-color',
39+
transitionTimingFunction: 'linear',
40+
},
41+
indicator: {
42+
inset: 0,
43+
alignItems: 'center',
44+
color: colorVars['--cl-color-brand-foreground'],
45+
display: 'none',
46+
justifyContent: 'center',
47+
pointerEvents: 'none',
48+
position: 'absolute',
49+
},
50+
checkedIndicator: {
51+
display: {
52+
default: 'none',
53+
[stylex.when.siblingBefore(':checked:not(:indeterminate)', checkboxInputMarker)]: 'flex',
54+
},
55+
},
56+
indeterminateIndicator: {
57+
display: {
58+
default: 'none',
59+
[stylex.when.siblingBefore(':indeterminate', checkboxInputMarker)]: 'flex',
60+
},
61+
},
62+
});
63+
64+
export const sizes = stylex.create({
65+
sm: { height: space['3.5'], width: space['3.5'] },
66+
md: { height: space['4'], width: space['4'] },
67+
});
68+
69+
export const indicatorSizes = stylex.create({
70+
sm: { fontSize: space['2.5'] },
71+
md: { fontSize: space['3'] },
72+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import * as stylex from '@stylexjs/stylex';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import React from 'react';
5+
import { describe, expect, it, vi } from 'vitest';
6+
7+
import { Checkbox } from './checkbox';
8+
9+
const atoms = stylex.create({
10+
spaced: { marginTop: '8px' },
11+
});
12+
13+
describe('Mosaic Checkbox', () => {
14+
it('renders a native checkbox wrapped in the slot element', () => {
15+
render(<Checkbox aria-label='Select row' />);
16+
const input = screen.getByRole('checkbox', { name: 'Select row' });
17+
expect(input).toHaveClass('cl-checkbox-input');
18+
expect(input.parentElement).toHaveClass('cl-checkbox');
19+
});
20+
21+
it('toggles and reports changes', async () => {
22+
const onChange = vi.fn();
23+
render(
24+
<Checkbox
25+
aria-label='Select row'
26+
onChange={onChange}
27+
/>,
28+
);
29+
const input = screen.getByRole('checkbox');
30+
await userEvent.click(input);
31+
expect(input).toBeChecked();
32+
expect(onChange).toHaveBeenCalledTimes(1);
33+
});
34+
35+
it('supports a controlled checked state', () => {
36+
render(
37+
<Checkbox
38+
aria-label='Select row'
39+
checked
40+
onChange={() => {}}
41+
/>,
42+
);
43+
expect(screen.getByRole('checkbox')).toBeChecked();
44+
});
45+
46+
it('applies the indeterminate state through the DOM property', () => {
47+
const { rerender } = render(
48+
<Checkbox
49+
aria-label='Select all'
50+
indeterminate
51+
/>,
52+
);
53+
const input = screen.getByRole<HTMLInputElement>('checkbox');
54+
expect(input.indeterminate).toBe(true);
55+
56+
rerender(<Checkbox aria-label='Select all' />);
57+
expect(input.indeterminate).toBe(false);
58+
});
59+
60+
it('reflects the size axis on the slot element', () => {
61+
render(<Checkbox aria-label='Select row' />);
62+
expect(screen.getByRole('checkbox').parentElement).toHaveAttribute('data-size', 'md');
63+
});
64+
65+
it('disables the input', () => {
66+
render(
67+
<Checkbox
68+
aria-label='Select row'
69+
disabled
70+
/>,
71+
);
72+
expect(screen.getByRole('checkbox')).toBeDisabled();
73+
});
74+
75+
it('merges xstyle atoms onto the slot element', () => {
76+
render(
77+
<Checkbox
78+
aria-label='Select row'
79+
xstyle={atoms.spaced}
80+
/>,
81+
);
82+
expect(screen.getByRole('checkbox').parentElement).toHaveClass(
83+
'cl-checkbox',
84+
stylex.props(atoms.spaced).className ?? '',
85+
);
86+
});
87+
88+
it('forwards the ref and arbitrary input props to the input', () => {
89+
const ref = React.createRef<HTMLInputElement>();
90+
render(
91+
<Checkbox
92+
ref={ref}
93+
aria-label='Select row'
94+
name='rows'
95+
value='1'
96+
/>,
97+
);
98+
const input = screen.getByRole('checkbox');
99+
expect(ref.current).toBe(input);
100+
expect(input).toHaveAttribute('name', 'rows');
101+
expect(input).toHaveAttribute('value', '1');
102+
});
103+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import * as stylex from '@stylexjs/stylex';
2+
import React from 'react';
3+
4+
import type { MosaicStyleProps } from '../../props';
5+
import { mergeStyleProps, themeProps } from '../../props';
6+
import { focusOutline } from '../../utils/focus-outline.styles';
7+
import { reset } from '../../utils/reset.styles';
8+
import { Icon } from '../icon';
9+
import { checkboxInputMarker } from './checkbox.markers.stylex';
10+
import { indicatorSizes, sizes, styles } from './checkbox.styles';
11+
12+
export interface CheckboxProps
13+
extends Omit<React.ComponentPropsWithoutRef<'input'>, 'type' | 'size' | 'className' | 'style'>, MosaicStyleProps {
14+
size?: 'sm' | 'md';
15+
indeterminate?: boolean;
16+
}
17+
18+
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(function MosaicCheckbox(
19+
{ size = 'md', indeterminate = false, disabled, xstyle, ...rest },
20+
ref,
21+
) {
22+
const setInputRef = React.useCallback(
23+
(node: HTMLInputElement | null) => {
24+
if (node) {
25+
node.indeterminate = indeterminate;
26+
}
27+
if (typeof ref === 'function') {
28+
ref(node);
29+
} else if (ref) {
30+
ref.current = node;
31+
}
32+
},
33+
[indeterminate, ref],
34+
);
35+
36+
return (
37+
<span
38+
{...mergeStyleProps(
39+
themeProps('checkbox', { size, disabled }),
40+
stylex.props(reset.base, styles.root, sizes[size], indicatorSizes[size], xstyle),
41+
)}
42+
>
43+
<input
44+
ref={setInputRef}
45+
type='checkbox'
46+
disabled={disabled}
47+
{...mergeStyleProps(
48+
themeProps('checkbox-input'),
49+
stylex.props(reset.base, focusOutline.visible, styles.input, sizes[size], checkboxInputMarker),
50+
rest,
51+
)}
52+
/>
53+
<span
54+
aria-hidden
55+
{...mergeStyleProps(
56+
themeProps('checkbox-indicator', { state: 'checked' }),
57+
stylex.props(reset.base, styles.indicator, styles.checkedIndicator),
58+
)}
59+
>
60+
<Icon
61+
name='check'
62+
size='inherit'
63+
/>
64+
</span>
65+
<span
66+
aria-hidden
67+
{...mergeStyleProps(
68+
themeProps('checkbox-indicator', { state: 'indeterminate' }),
69+
stylex.props(reset.base, styles.indicator, styles.indeterminateIndicator),
70+
)}
71+
>
72+
<Icon
73+
name='minus'
74+
size='inherit'
75+
/>
76+
</span>
77+
</span>
78+
);
79+
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { Checkbox } from './checkbox';
2+
export type { CheckboxProps } from './checkbox';

0 commit comments

Comments
 (0)