cursedbelt 5.25.0 → 5.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/react/components/Badge.d.ts +6 -6
  2. package/dist/react/components/Badge.d.ts.map +1 -1
  3. package/dist/react/components/Badge.js +13 -5
  4. package/dist/react/components/Badge.js.map +1 -1
  5. package/dist/react/components/DataTable/DataTable.d.ts +125 -5
  6. package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
  7. package/dist/react/components/DataTable/DataTable.js +335 -121
  8. package/dist/react/components/DataTable/DataTable.js.map +1 -1
  9. package/dist/react/components/DataTable/RowActions.d.ts +3 -1
  10. package/dist/react/components/DataTable/RowActions.d.ts.map +1 -1
  11. package/dist/react/components/DataTable/RowActions.js +9 -2
  12. package/dist/react/components/DataTable/RowActions.js.map +1 -1
  13. package/dist/react/components/DataTable/SortHeader.d.ts +4 -1
  14. package/dist/react/components/DataTable/SortHeader.d.ts.map +1 -1
  15. package/dist/react/components/DataTable/SortHeader.js +7 -1
  16. package/dist/react/components/DataTable/SortHeader.js.map +1 -1
  17. package/dist/react/components/DataTable/cells/CellRenderer.d.ts.map +1 -1
  18. package/dist/react/components/DataTable/cells/CellRenderer.js +4 -1
  19. package/dist/react/components/DataTable/cells/CellRenderer.js.map +1 -1
  20. package/dist/react/components/DataTable/index.d.ts +2 -2
  21. package/dist/react/components/DataTable/index.d.ts.map +1 -1
  22. package/dist/react/components/DataTable/index.js +1 -1
  23. package/dist/react/components/DataTable/index.js.map +1 -1
  24. package/dist/react/components/DataTable/lazyPart.d.ts +27 -0
  25. package/dist/react/components/DataTable/lazyPart.d.ts.map +1 -0
  26. package/dist/react/components/DataTable/lazyPart.js +46 -0
  27. package/dist/react/components/DataTable/lazyPart.js.map +1 -0
  28. package/dist/react/components/DataTable/types.d.ts +106 -0
  29. package/dist/react/components/DataTable/types.d.ts.map +1 -1
  30. package/dist/react/components/DataTable/types.js.map +1 -1
  31. package/dist/react/components/auth/SignInCard.d.ts +49 -0
  32. package/dist/react/components/auth/SignInCard.d.ts.map +1 -0
  33. package/dist/react/components/auth/SignInCard.js +21 -0
  34. package/dist/react/components/auth/SignInCard.js.map +1 -0
  35. package/dist/react/components/auth/index.d.ts +1 -0
  36. package/dist/react/components/auth/index.d.ts.map +1 -1
  37. package/dist/react/components/auth/index.js +1 -0
  38. package/dist/react/components/auth/index.js.map +1 -1
  39. package/dist/react/index.d.ts +1 -1
  40. package/dist/react/index.d.ts.map +1 -1
  41. package/dist/react/index.js +1 -1
  42. package/dist/react/index.js.map +1 -1
  43. package/dist/styles-areas/analytics.css +9 -6
  44. package/dist/styles-areas/core.css +8 -7
  45. package/dist/styles-areas/data-table.css +8 -5
  46. package/dist/styles-utilities.css +75 -0
  47. package/package.json +2 -2
  48. package/src/react/components/Badge.capsule.spec.ts +178 -0
  49. package/src/react/components/Badge.tsx +13 -5
  50. package/src/react/components/DataTable/DataTable.spec.tsx +6 -2
  51. package/src/react/components/DataTable/DataTable.tsx +518 -104
  52. package/src/react/components/DataTable/RowActions.tsx +18 -10
  53. package/src/react/components/DataTable/SortHeader.tsx +19 -0
  54. package/src/react/components/DataTable/cells/CellRenderer.tsx +2 -0
  55. package/src/react/components/DataTable/index.ts +10 -1
  56. package/src/react/components/DataTable/lazyPart.spec.tsx +99 -0
  57. package/src/react/components/DataTable/lazyPart.tsx +58 -0
  58. package/src/react/components/DataTable/rowHooks.spec.tsx +289 -0
  59. package/src/react/components/DataTable/selection.spec.tsx +6 -2
  60. package/src/react/components/DataTable/tableSystem.spec.tsx +6 -2
  61. package/src/react/components/DataTable/types.ts +110 -0
  62. package/src/react/components/DataTable/virtualize.spec.tsx +38 -2
  63. package/src/react/components/auth/SignInCard.spec.tsx +48 -0
  64. package/src/react/components/auth/SignInCard.tsx +90 -0
  65. package/src/react/components/auth/index.ts +1 -0
  66. package/src/react/index.ts +5 -0
  67. package/src/styles-areas/analytics.css +9 -6
  68. package/src/styles-areas/core.css +8 -7
  69. package/src/styles-areas/data-table.css +8 -5
  70. package/src/styles-utilities.css +75 -0
@@ -1,6 +1,15 @@
1
1
  import { EllipsisVertical, Pencil, Trash2 } from 'lucide-react';
2
- import { DropdownMenu, type DropdownMenuItem } from '../DropdownMenu.js';
2
+ import type { DropdownMenuItem } from '../DropdownMenu.js';
3
3
  import { IconButton } from '../IconButton.js';
4
+ import { lazyPart } from './lazyPart.js';
5
+
6
+ // The kebab's menu is Radix's Menu, and most tables never draw a kebab — so it loads
7
+ // with the first row that has one, not with the table (forge task 2309). Edit and
8
+ // Delete are plain buttons and stay synchronous.
9
+ const MenuPart = lazyPart(() => import('../DropdownMenu.js').then((m) => m.DropdownMenu));
10
+
11
+ /** Load the kebab's menu now — see `preloadDataTableParts`. */
12
+ export const preloadRowMenu = MenuPart.preload;
4
13
 
5
14
  export interface RowActionsProps<TRow> {
6
15
  row: TRow;
@@ -19,6 +28,11 @@ export interface RowActionsProps<TRow> {
19
28
  * surfaces the intent.
20
29
  */
21
30
  export function RowActions<TRow>({ row, onEdit, onDelete, menu }: RowActionsProps<TRow>) {
31
+ const kebab = (
32
+ <IconButton label='Row actions' className='size-8 pointer-coarse:min-w-11 [&_svg]:size-4'>
33
+ <EllipsisVertical />
34
+ </IconButton>
35
+ );
22
36
  return (
23
37
  // biome-ignore lint/a11y/noStaticElementInteractions: bubble fence only — these handlers never act, they stop row-click; the real interactions are the IconButtons/menu inside
24
38
  // biome-ignore lint/a11y/useKeyWithClickEvents: bubble fence only — keyboard interaction lives on the IconButtons/menu inside
@@ -54,15 +68,9 @@ export function RowActions<TRow>({ row, onEdit, onDelete, menu }: RowActionsProp
54
68
  </IconButton>
55
69
  ) : null}
56
70
  {menu && menu.length > 0 ? (
57
- <DropdownMenu
58
- align='end'
59
- trigger={
60
- <IconButton label='Row actions' className='size-8 pointer-coarse:min-w-11 [&_svg]:size-4'>
61
- <EllipsisVertical />
62
- </IconButton>
63
- }
64
- items={menu}
65
- />
71
+ // Until the menu arrives the kebab is already there, the same button in the same
72
+ // place — so nothing shifts and nothing flashes; a click in those milliseconds opens nothing.
73
+ <MenuPart align='end' trigger={kebab} items={menu} fallback={kebab} />
66
74
  ) : null}
67
75
  </div>
68
76
  );
@@ -44,6 +44,9 @@ export interface SortHeaderProps<TRow> {
44
44
  * disagreed with the sort chips.
45
45
  */
46
46
  onSort?: () => void;
47
+ /** Sorting is unavailable right now — the header stays, inert, and says this on hover
48
+ * (`DataTable`'s `sortDisabledReason`). */
49
+ disabledReason?: string;
47
50
  }
48
51
 
49
52
  /**
@@ -58,6 +61,7 @@ export function SortHeader<TRow>({
58
61
  sortable,
59
62
  sortIndex = -1,
60
63
  onSort,
64
+ disabledReason,
61
65
  }: SortHeaderProps<TRow>) {
62
66
  // The label truncates inside its own track, sortable or not. A header is the
63
67
  // one piece of a table with no wrapping story of its own — it is a single
@@ -67,6 +71,21 @@ export function SortHeader<TRow>({
67
71
  // fleet table, 2026-08-11). `title` keeps the full label one hover away.
68
72
  if (!sortable) return withLabelTip(label, <span className='block truncate'>{label}</span>);
69
73
  const sorted = column.getIsSorted();
74
+ if (disabledReason)
75
+ // Visible and explained, never hidden — and `aria-disabled` rather than `disabled`, because
76
+ // a disabled button fires no pointer events and the reason could never be hovered.
77
+ return withLabelTip(
78
+ disabledReason,
79
+ <button
80
+ // guardrails-ignore no-raw-action-button: column-header sort toggle, inert — see above.
81
+ type='button'
82
+ aria-disabled
83
+ aria-label={`${label} — ${disabledReason}`}
84
+ className={`-mx-1 -my-2.5 inline-flex max-w-full cursor-not-allowed items-center gap-1 rounded px-1 py-2.5 uppercase focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${TAP_TARGET_HEIGHT}`}
85
+ >
86
+ <span className='min-w-0 truncate'>{label}</span>
87
+ </button>,
88
+ );
70
89
  return withLabelTip(
71
90
  label,
72
91
  <button
@@ -344,6 +344,8 @@ export function getCellRenderer(type: string): FC<CellRendererProps> {
344
344
  * by the table cell, {@link CardView}, the detail `Drawer`, and the layout view.
345
345
  */
346
346
  export function CellRenderer<TRow extends Record<string, unknown>>(props: CellRendererProps<TRow>) {
347
+ // A column that draws itself wins over its type — see `ColDef.render`.
348
+ if (props.col.render) return <>{props.col.render(props.row)}</>;
347
349
  const Comp = getCellRenderer(props.col.type);
348
350
  return <Comp {...(props as CellRendererProps)} />;
349
351
  }
@@ -17,6 +17,7 @@ export {
17
17
  type DataTableControls,
18
18
  type DataTableProps,
19
19
  nextSortStack,
20
+ preloadDataTableParts,
20
21
  } from './DataTable.js';
21
22
  // 🔴 `TableFilterBuilder`, not `FilterBuilder` — see its header. The bare name belonged to
22
23
  // two different components in two repos, and a shared basename that is not a shared
@@ -67,7 +68,15 @@ export { TableToolbar, type TableToolbarProps } from './Toolbar.js';
67
68
  // grows its own (owner, 2026-08-23).
68
69
  export { TableViewToggle, type TableViewToggleProps } from './TableViewToggle.js';
69
70
  export { resolveColumnAlign } from './types.js';
70
- export type { ColDef, FieldOption, TableViewMode } from './types.js';
71
+ export type {
72
+ ColDef,
73
+ DataTableHandle,
74
+ DataTableRowProps,
75
+ DataTableSelectAll,
76
+ DataTableServerWindow,
77
+ FieldOption,
78
+ TableViewMode,
79
+ } from './types.js';
71
80
  // Remembers rows-vs-cards per account through the EXISTING deep-link resume —
72
81
  // not a second store (unit 45 item 4).
73
82
  export {
@@ -0,0 +1,99 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
3
+ import { type ComponentType, useState } from 'react';
4
+ import { Button } from '../Button.js';
5
+ import { lazyPart } from './lazyPart.js';
6
+
7
+ afterEach(cleanup);
8
+
9
+ /** A loader the test resolves by hand, counting how often it was asked. */
10
+ function deferredLoader<P>(component: ComponentType<P>) {
11
+ let resolve: (c: ComponentType<P>) => void = () => undefined;
12
+ const loader = {
13
+ calls: 0,
14
+ load: () => {
15
+ loader.calls += 1;
16
+ return new Promise<ComponentType<P>>((r) => {
17
+ resolve = r;
18
+ });
19
+ },
20
+ arrive: async () => {
21
+ await act(async () => {
22
+ resolve(component);
23
+ });
24
+ },
25
+ };
26
+ return loader;
27
+ }
28
+
29
+ /** A part with state of its own, so a remount is visible as that state resetting. */
30
+ function Counter({ label }: { label: string }) {
31
+ const [n, setN] = useState(0);
32
+ return (
33
+ <Button tooltip='Count' onClick={() => setN(n + 1)}>
34
+ {label} {n}
35
+ </Button>
36
+ );
37
+ }
38
+
39
+ describe('lazyPart — a table part that loads when it is drawn', () => {
40
+ test('is not fetched until something renders it', () => {
41
+ const loader = deferredLoader(Counter);
42
+ lazyPart(loader.load);
43
+ expect(loader.calls).toBe(0);
44
+ });
45
+
46
+ test('draws its fallback, then the part — fetched once however many mount it', async () => {
47
+ const loader = deferredLoader(Counter);
48
+ const Part = lazyPart(loader.load);
49
+ render(
50
+ <>
51
+ <Part label='a' fallback={<span>waiting</span>} />
52
+ <Part label='b' fallback={<span>waiting</span>} />
53
+ </>,
54
+ );
55
+ expect(screen.getAllByText('waiting')).toHaveLength(2);
56
+ await loader.arrive();
57
+ expect(screen.getByText('a 0')).toBeTruthy();
58
+ expect(screen.getByText('b 0')).toBeTruthy();
59
+ expect(loader.calls).toBe(1);
60
+ });
61
+
62
+ test('🔴 a mounted part keeps its state across later renders — the chunk landing never remounts it', async () => {
63
+ const loader = deferredLoader(Counter);
64
+ const Part = lazyPart(loader.load);
65
+ const { rerender } = render(<Part label='n' />);
66
+ await loader.arrive();
67
+ fireEvent.click(screen.getByText('n 0'));
68
+ fireEvent.click(screen.getByText('n 1'));
69
+ // Now `loaded` is set; a re-render that re-decided direct-vs-lazy would swap the
70
+ // element type here and reset the count to 0.
71
+ rerender(<Part label='n' />);
72
+ expect(screen.getByText('n 2')).toBeTruthy();
73
+ });
74
+
75
+ test('after preload(), a new mount draws on its FIRST render — no fallback, no tick', async () => {
76
+ const loader = deferredLoader(Counter);
77
+ const Part = lazyPart(loader.load);
78
+ const preloaded = Part.preload();
79
+ await loader.arrive();
80
+ await preloaded;
81
+ render(<Part label='now' fallback={<span>waiting</span>} />);
82
+ expect(screen.queryByText('waiting')).toBeNull();
83
+ expect(screen.getByText('now 0')).toBeTruthy();
84
+ expect(loader.calls).toBe(1);
85
+ });
86
+
87
+ test('a failed fetch is asked again, not remembered as a failure', async () => {
88
+ let calls = 0;
89
+ const Part = lazyPart<{ label: string }>(() => {
90
+ calls += 1;
91
+ return calls === 1 ? Promise.reject(new Error('offline')) : Promise.resolve(Counter);
92
+ });
93
+ await expect(Part.preload()).rejects.toThrow('offline');
94
+ await Part.preload();
95
+ render(<Part label='again' />);
96
+ expect(screen.getByText('again 0')).toBeTruthy();
97
+ expect(calls).toBe(2);
98
+ });
99
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * A part of the table that loads only when the table draws it — and that a caller
3
+ * can load AHEAD of time, after which it draws on the very first render (forge task 2309).
4
+ *
5
+ * The toolbar, the pager and the row kebab's menu reach Radix's Select, Menu and
6
+ * Popover: ~20 kB gzip that a read-only list never opens. `lazyWithReload` keeps
7
+ * them off every bundle that does not draw them. But `React.lazy` suspends on its
8
+ * first render even when the chunk is already in memory, so a part would arrive a
9
+ * tick late — in a spec that queries the search box right after `render(…)`, and
10
+ * for an app that knows it is about to draw a toolbar. `preload()` closes both:
11
+ * once it has resolved, a NEW mount renders the module directly, with no Suspense.
12
+ *
13
+ * 🔴 The choice between the direct module and the lazy one is made ONCE per mount
14
+ * (`useState`'s initializer). Re-deciding per render would swap the element type
15
+ * the moment the chunk lands, and React would REMOUNT the part — a half-typed search,
16
+ * an open menu, gone.
17
+ */
18
+ import { type Attributes, type ComponentType, type ReactNode, Suspense, useState } from 'react';
19
+ import { lazyWithReload } from '../../lazy/staleChunk.js';
20
+
21
+ export interface LazyPart<P> {
22
+ (props: P & { fallback?: ReactNode }): ReactNode;
23
+ /** Fetch the part now. Resolves when a new mount would draw it synchronously. */
24
+ preload: () => Promise<void>;
25
+ }
26
+
27
+ export function lazyPart<P extends object>(load: () => Promise<ComponentType<P>>): LazyPart<P> {
28
+ let loaded: ComponentType<P> | null = null;
29
+ let pending: Promise<ComponentType<P>> | null = null;
30
+ const loadOnce = (): Promise<ComponentType<P>> => {
31
+ pending ??= load().then(
32
+ (component) => {
33
+ loaded = component;
34
+ return component;
35
+ },
36
+ (error: unknown) => {
37
+ pending = null; // a failed fetch may be asked again, not remembered forever
38
+ throw error;
39
+ },
40
+ );
41
+ return pending;
42
+ };
43
+ const Lazy = lazyWithReload(() => loadOnce().then((component) => ({ default: component })));
44
+
45
+ function Part({ fallback = null, ...props }: P & { fallback?: ReactNode }) {
46
+ const [Direct] = useState(() => loaded);
47
+ if (Direct) return <Direct {...(props as P)} />;
48
+ return (
49
+ <Suspense fallback={fallback}>
50
+ <Lazy {...(props as P & Attributes)} />
51
+ </Suspense>
52
+ );
53
+ }
54
+ Part.preload = async () => {
55
+ await loadOnce();
56
+ };
57
+ return Part;
58
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * The row-level hooks a list needs to render through `DataTable` rather than beside it —
3
+ * forge task 2302, measured against apps/music's hand-rolled track list: per-row attributes and
4
+ * handlers (`getRowProps`), a cell drawn by a function (`ColDef.render`), container-query column
5
+ * hiding (`ColDef.hideBelow` — its LAYOUT is gated in `e2e/table-window.spec.ts`, this pins the
6
+ * wiring), and header semantics a server-ordered list needs (controlled `sorting`,
7
+ * `sortDescFirst`, `enableSortingRemoval`, `sortDisabledReason`, a caller-owned `selectAll`).
8
+ */
9
+ import { afterEach, beforeAll, describe, expect, mock, test } from 'bun:test';
10
+ import { cleanup, fireEvent, render } from '@testing-library/react';
11
+ import type { SortingState } from '@tanstack/react-table';
12
+ import { useState } from 'react';
13
+ import { DataTable, mergeRowProps, nextSortStack, preloadDataTableParts } from './DataTable.js';
14
+ import type { ColDef } from './types.js';
15
+
16
+ // The toolbar, pager and row menu are lazy (forge task 2309); loaded here, they draw on the
17
+ // first render, as these specs query them. `lazyPart.spec.tsx` covers the lazy path itself.
18
+ beforeAll(preloadDataTableParts);
19
+
20
+ afterEach(cleanup);
21
+
22
+ interface Song extends Record<string, unknown> {
23
+ id: string;
24
+ title: string;
25
+ plays: number;
26
+ }
27
+
28
+ const SONGS: Song[] = [
29
+ { id: 's1', title: 'Alpha', plays: 3 },
30
+ { id: 's2', title: 'Beta', plays: 9 },
31
+ ];
32
+
33
+ const COLUMNS: ColDef<Song>[] = [
34
+ { key: 'title', label: 'Title', type: 'text', sortable: true },
35
+ { key: 'plays', label: 'Plays', type: 'number', sortable: true, sortDescFirst: true },
36
+ ];
37
+
38
+ const rows = (root: HTMLElement) => [...root.querySelectorAll<HTMLTableRowElement>('tbody tr')];
39
+
40
+ describe('getRowProps — a row is more than its cells', () => {
41
+ test('data-* identity, aria and handlers land on the row, with its absolute row number', () => {
42
+ const onDoubleClick = mock();
43
+ const { container } = render(
44
+ <DataTable<Song>
45
+ columns={COLUMNS}
46
+ data={SONGS}
47
+ getRowId={(row) => row.id}
48
+ mobileCards={false}
49
+ searchable={false}
50
+ getRowProps={(row, rowIndex) => ({
51
+ 'data-track-id': row.id,
52
+ 'data-n': rowIndex,
53
+ 'aria-current': row.id === 's2' ? 'true' : undefined,
54
+ className: 'track-row',
55
+ onDoubleClick: () => onDoubleClick(row.id),
56
+ })}
57
+ />,
58
+ );
59
+ const [first, second] = rows(container);
60
+ expect(first?.dataset.trackId).toBe('s1');
61
+ expect(second?.dataset.n).toBe('1');
62
+ expect(second?.getAttribute('aria-current')).toBe('true');
63
+ expect(first?.getAttribute('aria-current')).toBeNull();
64
+ // Joined, not replaced: the table's own border class is still there beside the caller's.
65
+ expect(first?.className).toContain('track-row');
66
+ expect(first?.className).toContain('border-b');
67
+ fireEvent.doubleClick(second!);
68
+ expect(onDoubleClick).toHaveBeenCalledWith('s2');
69
+ });
70
+
71
+ test('a handler the table also sets runs the table\'s first, then the caller\'s — neither is lost', () => {
72
+ const order: string[] = [];
73
+ const { container } = render(
74
+ <DataTable<Song>
75
+ columns={COLUMNS}
76
+ data={SONGS}
77
+ mobileCards={false}
78
+ searchable={false}
79
+ onRowClick={(row) => order.push(`table:${row.id}`)}
80
+ getRowProps={(row) => ({ onClick: () => order.push(`caller:${row.id}`) })}
81
+ />,
82
+ );
83
+ fireEvent.click(rows(container)[0]!);
84
+ expect(order).toEqual(['table:s1', 'caller:s1']);
85
+ });
86
+
87
+ test('mergeRowProps: classes join, styles spread, the caller wins every other attribute', () => {
88
+ const merged = mergeRowProps(
89
+ { className: 'a', style: { height: 56 }, tabIndex: 0, draggable: false },
90
+ { className: 'b', style: { color: 'red' }, draggable: true },
91
+ );
92
+ expect(merged.className).toBe('a b');
93
+ expect(merged.style).toEqual({ height: 56, color: 'red' });
94
+ expect(merged.tabIndex).toBe(0);
95
+ expect(merged.draggable).toBe(true);
96
+ });
97
+ });
98
+
99
+ describe('ColDef.render — a cell drawn by the caller', () => {
100
+ test('wins over the type in the grid, reading the whole row', () => {
101
+ const columns: ColDef<Song>[] = [
102
+ { key: 'title', label: 'Title', type: 'text', render: (row) => <b data-testid={`t-${row.id}`}>{row.title}!</b> },
103
+ ];
104
+ const { getByTestId } = render(<DataTable<Song> columns={columns} data={SONGS} mobileCards={false} searchable={false} />);
105
+ expect(getByTestId('t-s2').textContent).toBe('Beta!');
106
+ });
107
+
108
+ test('and in the phone cards, which pass through the same seam', () => {
109
+ const columns: ColDef<Song>[] = [
110
+ { key: 'title', label: 'Title', type: 'text', render: (row) => <i data-testid={`c-${row.id}`}>{row.title}</i> },
111
+ ];
112
+ const { container } = render(<DataTable<Song> columns={columns} data={SONGS} searchable={false} />);
113
+ expect(container.querySelector('[data-cb-table-cards] [data-testid="c-s1"]')).not.toBeNull();
114
+ });
115
+ });
116
+
117
+ describe('ColDef.hideBelow — the grid sheds a column in a narrow container', () => {
118
+ test('the header cell and every body cell carry the same container-query hide', () => {
119
+ const columns: ColDef<Song>[] = [
120
+ { key: 'title', label: 'Title', type: 'text' },
121
+ { key: 'plays', label: 'Plays', type: 'number', hideBelow: 'md' },
122
+ ];
123
+ const { container } = render(<DataTable<Song> columns={columns} data={SONGS} mobileCards={false} searchable={false} />);
124
+ const heads = [...container.querySelectorAll('thead th')];
125
+ expect(heads[0]?.className).not.toContain('@max-');
126
+ expect(heads[1]?.className).toContain('@max-4xl:w-0!');
127
+ for (const row of rows(container)) {
128
+ const cells = row.querySelectorAll('td');
129
+ expect(cells[1]?.className).toContain('@max-4xl:w-0!');
130
+ expect(cells[0]?.className).not.toContain('@max-');
131
+ }
132
+ });
133
+ });
134
+
135
+ describe('header semantics for a list the SERVER orders', () => {
136
+ test('nextSortStack: descFirst starts descending; removable:false flips instead of dropping the key', () => {
137
+ expect(nextSortStack([], 'plays', false, { descFirst: true })).toEqual([{ id: 'plays', desc: true }]);
138
+ expect(nextSortStack([{ id: 'plays', desc: true }], 'plays', false, { descFirst: true })).toEqual([
139
+ { id: 'plays', desc: false },
140
+ ]);
141
+ // The third click: out, by default…
142
+ expect(nextSortStack([{ id: 'plays', desc: false }], 'plays', false, { descFirst: true })).toEqual([]);
143
+ // …and back to the first direction when the list always has an order.
144
+ expect(
145
+ nextSortStack([{ id: 'plays', desc: false }], 'plays', false, { descFirst: true, removable: false }),
146
+ ).toEqual([{ id: 'plays', desc: true }]);
147
+ // The old three-argument call is unchanged.
148
+ expect(nextSortStack([], 'title', true)).toEqual([{ id: 'title', desc: false }]);
149
+ });
150
+
151
+ test('controlled sorting: a click REPORTS the next sort, and the header shows what came back', () => {
152
+ const seen: SortingState[] = [];
153
+ function Host() {
154
+ const [sorting, setSorting] = useState<SortingState>([{ id: 'title', desc: false }]);
155
+ return (
156
+ <DataTable<Song>
157
+ columns={COLUMNS}
158
+ data={SONGS}
159
+ mobileCards={false}
160
+ searchable={false}
161
+ multiSort={false}
162
+ enableSortingRemoval={false}
163
+ sorting={sorting}
164
+ onSortingChange={(next) => {
165
+ seen.push(next);
166
+ setSorting(next);
167
+ }}
168
+ />
169
+ );
170
+ }
171
+ const { container, getByRole } = render(<Host />);
172
+ const sortOf = (label: string) =>
173
+ [...container.querySelectorAll('thead th')].find((th) => th.textContent?.includes(label))?.getAttribute('aria-sort');
174
+ expect(sortOf('Title')).toBe('ascending');
175
+ fireEvent.click(getByRole('button', { name: 'Plays' }));
176
+ expect(seen.at(-1)).toEqual([{ id: 'plays', desc: true }]);
177
+ expect(sortOf('Plays')).toBe('descending');
178
+ expect(sortOf('Title')).toBeNull();
179
+ fireEvent.click(getByRole('button', { name: 'Plays' }));
180
+ fireEvent.click(getByRole('button', { name: 'Plays' }));
181
+ // Never "unsorted": desc → asc → desc.
182
+ expect(seen.map((s) => s[0]?.desc)).toEqual([true, false, true]);
183
+ });
184
+
185
+ test('a controlled sort the caller does NOT take back leaves the header where the caller keeps it', () => {
186
+ const onSortingChange = mock();
187
+ const { container, getByRole } = render(
188
+ <DataTable<Song>
189
+ columns={COLUMNS}
190
+ data={SONGS}
191
+ mobileCards={false}
192
+ searchable={false}
193
+ sorting={[{ id: 'title', desc: false }]}
194
+ onSortingChange={onSortingChange}
195
+ />,
196
+ );
197
+ fireEvent.click(getByRole('button', { name: 'Plays' }));
198
+ expect(onSortingChange).toHaveBeenCalledTimes(1);
199
+ const title = [...container.querySelectorAll('thead th')].find((th) => th.textContent?.includes('Title'));
200
+ expect(title?.getAttribute('aria-sort')).toBe('ascending');
201
+ });
202
+
203
+ test('sortDisabledReason: the headers stay, inert, and say why', () => {
204
+ const onSortingChange = mock();
205
+ const { getByRole } = render(
206
+ <DataTable<Song>
207
+ columns={COLUMNS}
208
+ data={SONGS}
209
+ mobileCards={false}
210
+ searchable={false}
211
+ onSortingChange={onSortingChange}
212
+ sortDisabledReason='The list is in play order'
213
+ />,
214
+ );
215
+ const header = getByRole('button', { name: 'Plays — The list is in play order' });
216
+ expect(header.getAttribute('aria-disabled')).toBe('true');
217
+ fireEvent.click(header);
218
+ expect(onSortingChange).not.toHaveBeenCalled();
219
+ });
220
+
221
+ test('selectAll: the header box is the caller\'s — its state, its name, what a press does', () => {
222
+ const onChange = mock();
223
+ const { getByRole } = render(
224
+ <DataTable<Song>
225
+ columns={COLUMNS}
226
+ data={SONGS}
227
+ getRowId={(row) => row.id}
228
+ mobileCards={false}
229
+ searchable={false}
230
+ selectedIds={new Set(['s1'])}
231
+ onSelectionChange={() => {}}
232
+ selectAll={{ checked: 'indeterminate', label: '1 selected — press to select all 5,409', onChange }}
233
+ />,
234
+ );
235
+ const box = getByRole('checkbox', { name: '1 selected — press to select all 5,409' });
236
+ expect(box.getAttribute('aria-checked')).toBe('mixed');
237
+ fireEvent.click(box);
238
+ expect(onChange).toHaveBeenCalledWith(true);
239
+ });
240
+ });
241
+
242
+ describe('scrollClassName — a name for the box that scrolls', () => {
243
+ test('lands on the scroll container, not the outer box', () => {
244
+ const { container } = render(
245
+ <DataTable<Song>
246
+ columns={COLUMNS}
247
+ data={SONGS}
248
+ mobileCards={false}
249
+ searchable={false}
250
+ serverWindow={{ start: 0, total: 2, rowHeight: 40 }}
251
+ className='outer'
252
+ scrollClassName='track-list'
253
+ />,
254
+ );
255
+ const scroller = container.querySelector('.track-list');
256
+ expect(scroller?.querySelector('table')).not.toBeNull();
257
+ expect(container.querySelector('.outer')?.classList.contains('track-list')).toBe(false);
258
+ // …and says it is the design system's scroller, as ScrollArea does.
259
+ expect(scroller?.hasAttribute('data-cb-table-scroll')).toBe(true);
260
+ });
261
+ });
262
+
263
+ describe('ColDef.leading — a drag handle leads the row, before the checkbox', () => {
264
+ test('header and body put the leading column first, the checkbox second, the rest after', () => {
265
+ const columns: ColDef<Song>[] = [
266
+ { key: 'title', label: 'Title', type: 'text' },
267
+ { key: 'grip', label: 'Grip', type: 'text', leading: true, render: (row) => <i data-grip={row.id} /> },
268
+ ];
269
+ const { container } = render(
270
+ <DataTable<Song>
271
+ columns={columns}
272
+ data={SONGS}
273
+ getRowId={(row) => row.id}
274
+ mobileCards={false}
275
+ searchable={false}
276
+ selectedIds={new Set()}
277
+ onSelectionChange={() => {}}
278
+ />,
279
+ );
280
+ const head = [...container.querySelectorAll('thead th')];
281
+ expect(head[0]?.textContent).toBe('Grip');
282
+ expect(head[1]?.querySelector('[role="checkbox"]')).not.toBeNull();
283
+ expect(head[2]?.textContent).toBe('Title');
284
+ const cells = [...rows(container)[0]!.children];
285
+ expect(cells[0]?.querySelector('[data-grip="s1"]')).not.toBeNull();
286
+ expect(cells[1]?.querySelector('[role="checkbox"]')).not.toBeNull();
287
+ expect(cells[2]?.textContent).toBe('Alpha');
288
+ });
289
+ });
@@ -1,10 +1,14 @@
1
- import { afterEach, describe, expect, test } from 'bun:test';
1
+ import { afterEach, beforeAll, describe, expect, test } from 'bun:test';
2
2
  import { cleanup, fireEvent, render } from '@testing-library/react';
3
3
  import { useState } from 'react';
4
4
  import { hoverHint } from '../../../testing/tooltip.js';
5
- import { DataTable, type DataTableProps } from './DataTable.js';
5
+ import { DataTable, type DataTableProps, preloadDataTableParts } from './DataTable.js';
6
6
  import type { ColDef } from './types.js';
7
7
 
8
+ // The toolbar, pager and row menu are lazy (forge task 2309); loaded here, they draw on the
9
+ // first render, as these specs query them. `lazyPart.spec.tsx` covers the lazy path itself.
10
+ beforeAll(preloadDataTableParts);
11
+
8
12
  afterEach(cleanup);
9
13
 
10
14
  interface Game extends Record<string, unknown> {
@@ -6,14 +6,18 @@
6
6
  * table's contract and is already 500 lines; a red here says "the new table
7
7
  * system broke", which is a different sentence from "the table broke".
8
8
  */
9
- import { describe, expect, mock, test } from 'bun:test';
9
+ import { beforeAll, describe, expect, mock, test } from 'bun:test';
10
10
  import { fireEvent, render, screen, within } from '@testing-library/react';
11
11
  import { RESUME_KEY_PATTERN } from 'cursedbelt-server/resume';
12
- import { DataTable, nextSortStack } from './DataTable.js';
12
+ import { DataTable, nextSortStack, preloadDataTableParts } from './DataTable.js';
13
13
  import { CB_ROW_MIME, isRowDrag, readRowDrag, rowDropTargetProps, writeRowDrag } from './rowDrag.js';
14
14
  import type { ColDef } from './types.js';
15
15
  import { tableViewResumeKey } from './useTableViewMode.js';
16
16
 
17
+ // The toolbar, pager and row menu are lazy (forge task 2309); loaded here, they draw on the
18
+ // first render, as these specs query them. `lazyPart.spec.tsx` covers the lazy path itself.
19
+ beforeAll(preloadDataTableParts);
20
+
17
21
  interface Track extends Record<string, unknown> {
18
22
  id: string;
19
23
  title: string;