Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-table.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
alexcarpenter marked this conversation as resolved.
Comment on lines +1 to +2

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dumb question, but do we only need to add to changelog if user button is affected since that's the only exported thing from the package rn?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not dumb at all. The changeset is a changelog entry for people upgrading @clerk/mosaic, and none of this reaches the package entry point yet (only UserButton and MosaicProvider are exported), so nothing changes for a consumer and it stays empty. Whichever PR exports Table will carry the real entry.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahh. thanks for the explanation 😌

48 changes: 48 additions & 0 deletions packages/headless/src/hooks/use-data-table.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
});
42 changes: 27 additions & 15 deletions packages/headless/src/hooks/use-data-table.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useRef } from 'react';

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

Expand Down Expand Up @@ -46,7 +46,7 @@ export interface DataTableRow<TData> {
id: string;
original: TData;
getIsSelected: () => boolean;
toggleSelected: () => void;
toggleSelected: (options?: { range?: boolean }) => void;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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

const { getRowId } = opts;
const rows = useMemo<DataTableRow<TData>[]>(
() =>
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<string | null>(null);
const rows = useMemo<DataTableRow<TData>[]>(() => {
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 ──────────────────────────────────────────────────────

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import * as stylex from '@stylexjs/stylex';

export const checkboxInputMarker = stylex.defineMarker();
81 changes: 81 additions & 0 deletions packages/mosaic/src/components/checkbox/checkbox.styles.ts
Original file line number Diff line number Diff line change
@@ -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: {
Comment thread
alexcarpenter marked this conversation as resolved.
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'] },
});
109 changes: 109 additions & 0 deletions packages/mosaic/src/components/checkbox/checkbox.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Checkbox aria-label='Select row' />);
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(
<Checkbox
aria-label='Select row'
onChange={onChange}
/>,
);
const input = screen.getByRole('checkbox');
await userEvent.click(input);
expect(input).toBeChecked();
expect(onChange).toHaveBeenCalledTimes(1);
});

it('supports a controlled checked state', () => {
render(
<Checkbox
aria-label='Select row'
checked
onChange={() => {}}
/>,
);
expect(screen.getByRole('checkbox')).toBeChecked();
});

it('applies the indeterminate state through the DOM property', () => {
const { rerender } = render(
<Checkbox
aria-label='Select all'
indeterminate
/>,
);
const input = screen.getByRole<HTMLInputElement>('checkbox');
expect(input.indeterminate).toBe(true);

rerender(<Checkbox aria-label='Select all' />);
expect(input.indeterminate).toBe(false);
});

it('reflects the size axis on the slot element', () => {
render(<Checkbox aria-label='Select row' />);
expect(screen.getByRole('checkbox').parentElement).toHaveAttribute('data-size', 'md');
});

it('disables the input', () => {
render(
<Checkbox
aria-label='Select row'
disabled
/>,
);
expect(screen.getByRole('checkbox')).toBeDisabled();
});

it('merges xstyle atoms onto the slot element', () => {
render(
<Checkbox
aria-label='Select row'
xstyle={atoms.spaced}
/>,
);
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(<Checkbox aria-label='Select row' />);
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<HTMLInputElement>();
render(
<Checkbox
ref={ref}
aria-label='Select row'
name='rows'
value='1'
/>,
);
const input = screen.getByRole('checkbox');
expect(ref.current).toBe(input);
expect(input).toHaveAttribute('name', 'rows');
expect(input).toHaveAttribute('value', '1');
});
});
Loading
Loading