@talelk/newui-organisms 0.0.0-stage → 1.0.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 (56) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +12 -2
  3. package/package.json +38 -3
  4. package/src/exports.test.ts +27 -0
  5. package/src/index.ts +1 -0
  6. package/src/organisms/AppSidebar/AppSidebar.test.tsx +100 -0
  7. package/src/organisms/AppSidebar/AppSidebar.tsx +214 -0
  8. package/src/organisms/AppSidebar/index.ts +1 -0
  9. package/src/organisms/AuthCard/AuthCard.a11y.test.tsx +36 -0
  10. package/src/organisms/AuthCard/AuthCard.test.tsx +56 -0
  11. package/src/organisms/AuthCard/AuthCard.tsx +80 -0
  12. package/src/organisms/AuthCard/index.ts +1 -0
  13. package/src/organisms/CommandPalette/CommandPalette.test.tsx +196 -0
  14. package/src/organisms/CommandPalette/CommandPalette.tsx +457 -0
  15. package/src/organisms/CommandPalette/index.ts +1 -0
  16. package/src/organisms/DataTable/DataTable.test.tsx +89 -0
  17. package/src/organisms/DataTable/DataTable.tsx +334 -0
  18. package/src/organisms/DataTable/index.ts +1 -0
  19. package/src/organisms/DetailHeader/DetailHeader.test.tsx +55 -0
  20. package/src/organisms/DetailHeader/DetailHeader.tsx +87 -0
  21. package/src/organisms/DetailHeader/index.ts +1 -0
  22. package/src/organisms/FilterBar/FilterBar.test.tsx +151 -0
  23. package/src/organisms/FilterBar/FilterBar.tsx +232 -0
  24. package/src/organisms/FilterBar/index.ts +1 -0
  25. package/src/organisms/KanbanBoard/KanbanBoard.test.tsx +255 -0
  26. package/src/organisms/KanbanBoard/KanbanBoard.tsx +550 -0
  27. package/src/organisms/KanbanBoard/drag.test.ts +448 -0
  28. package/src/organisms/KanbanBoard/drag.ts +407 -0
  29. package/src/organisms/KanbanBoard/index.ts +17 -0
  30. package/src/organisms/MobileNav/MobileNav.browser.test.tsx +39 -0
  31. package/src/organisms/MobileNav/MobileNav.test.tsx +108 -0
  32. package/src/organisms/MobileNav/MobileNav.tsx +120 -0
  33. package/src/organisms/MobileNav/index.ts +6 -0
  34. package/src/organisms/PageToolbar/PageToolbar.a11y.test.tsx +35 -0
  35. package/src/organisms/PageToolbar/PageToolbar.test.tsx +42 -0
  36. package/src/organisms/PageToolbar/PageToolbar.tsx +77 -0
  37. package/src/organisms/PageToolbar/index.ts +1 -0
  38. package/src/organisms/SettingsDialog/SettingsDialog.a11y.test.tsx +58 -0
  39. package/src/organisms/SettingsDialog/SettingsDialog.browser.test.tsx +43 -0
  40. package/src/organisms/SettingsDialog/SettingsDialog.ssr.test.tsx +27 -0
  41. package/src/organisms/SettingsDialog/SettingsDialog.test.tsx +202 -0
  42. package/src/organisms/SettingsDialog/SettingsDialog.tsx +539 -0
  43. package/src/organisms/SettingsDialog/index.ts +20 -0
  44. package/src/organisms/Sidebar/Sidebar.browser.test.tsx +52 -0
  45. package/src/organisms/Sidebar/Sidebar.test.tsx +308 -0
  46. package/src/organisms/Sidebar/Sidebar.tsx +725 -0
  47. package/src/organisms/Sidebar/__screenshots__/Sidebar.browser.test.tsx/Sidebar--browser--real-CSS--reserves-its-declared-width-1.png +0 -0
  48. package/src/organisms/Sidebar/index.ts +22 -0
  49. package/src/organisms/StatGrid/StatGrid.a11y.test.tsx +40 -0
  50. package/src/organisms/StatGrid/StatGrid.test.tsx +83 -0
  51. package/src/organisms/StatGrid/StatGrid.tsx +124 -0
  52. package/src/organisms/StatGrid/index.ts +1 -0
  53. package/src/organisms/VirtualList/VirtualList.test.tsx +80 -0
  54. package/src/organisms/VirtualList/VirtualList.tsx +79 -0
  55. package/src/organisms/VirtualList/index.ts +1 -0
  56. package/src/organisms/index.ts +13 -0
@@ -0,0 +1,457 @@
1
+ import { Icon, type IconProp } from '@talelk/newui-atoms'
2
+ import { cn } from '@talelk/newui-atoms/internal'
3
+ import { Dialog } from '@talelk/newui-molecules'
4
+ import {
5
+ type Accessor,
6
+ createContext,
7
+ createEffect,
8
+ createMemo,
9
+ createSignal,
10
+ type JSX,
11
+ onCleanup,
12
+ onMount,
13
+ Show,
14
+ useContext,
15
+ } from 'solid-js'
16
+
17
+ /**
18
+ * A command palette: ⌘K, a query, and a list of things you can do.
19
+ *
20
+ * The state is Zag's `dialog` machine (focus trap, Escape, backdrop, scroll
21
+ * lock) plus a filtered list. The list is *not* a Zag listbox, and that is a
22
+ * decision rather than a shortcut: a listbox collection is built once from a
23
+ * static set of items, while a palette is rebuilt on every keystroke — items
24
+ * come and go as the query narrows, and the highlight has to survive that. So
25
+ * the dialog is the machine's and the highlight is ours, expressed with
26
+ * `aria-activedescendant`, which is what keeps focus in the input while the
27
+ * arrows move down the list.
28
+ *
29
+ * Items self-register. That is what lets a consumer write groups and items as
30
+ * plain JSX in any nesting, with the keyboard walking one flat, filtered list
31
+ * across all of them.
32
+ */
33
+
34
+ /**
35
+ * Everything about an item is read through a function, not captured: an item is
36
+ * registered once and lives until it unmounts, so a plain field would freeze the
37
+ * first value a prop ever had.
38
+ */
39
+ interface CommandRecord {
40
+ value: Accessor<string>
41
+ /** Extra search text: words that should match without being shown. */
42
+ keywords: Accessor<string>
43
+ label: Accessor<string>
44
+ disabled: Accessor<boolean>
45
+ matches: Accessor<boolean>
46
+ select: () => void
47
+ }
48
+
49
+ interface CommandContextValue {
50
+ query: Accessor<string>
51
+ setQuery: (value: string) => void
52
+ register: (record: CommandRecord) => void
53
+ unregister: (record: CommandRecord) => void
54
+ visible: Accessor<CommandRecord[]>
55
+ highlighted: Accessor<string | undefined>
56
+ highlight: (value: string | undefined) => void
57
+ move: (delta: number) => void
58
+ accept: () => void
59
+ itemId: (value: string) => string
60
+ }
61
+
62
+ const CommandContext = createContext<CommandContextValue>()
63
+
64
+ function useCommand(): CommandContextValue {
65
+ const context = useContext(CommandContext)
66
+ if (!context)
67
+ throw new Error('CommandPalette parts must be used within <CommandPalette>')
68
+ return context
69
+ }
70
+
71
+ /**
72
+ * The palette is a WAI-ARIA combobox: focus stays in the field, and the option
73
+ * the keyboard is on is named by `aria-activedescendant`. Three lint rules assume
74
+ * the opposite — that focus moves onto the option — so they are off for this file
75
+ * rather than worked around:
76
+ *
77
+ * - `useFocusableInteractive` / `useKeyWithClickEvents`: a focusable option would
78
+ * pull focus out of the field the moment you arrow down, which is exactly what
79
+ * the pattern avoids. The field owns the keys; the click handler is for
80
+ * pointers.
81
+ * - `useSemanticElements`: the options are rich rows (icon, description,
82
+ * shortcut) and the collection is rebuilt on every keystroke, which no native
83
+ * select or datalist expresses.
84
+ */
85
+
86
+ /**
87
+ * Both sides are reduced to letters and digits before comparing, so the way
88
+ * people actually type a compound name finds it: `date picker`, `date-picker`
89
+ * and `DatePicker` are one query, and no one has to know where the case
90
+ * boundary is.
91
+ */
92
+ function fold(text: string): string {
93
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, '')
94
+ }
95
+
96
+ /** Rank: a label that starts with the query beats one that merely contains it. */
97
+ function score(label: string, keywords: string, query: string): number | null {
98
+ const needle = fold(query)
99
+ if (needle === '') return 0
100
+ const index = fold(`${label} ${keywords}`).indexOf(needle)
101
+ if (index === -1) return null
102
+ return (fold(label).startsWith(needle) ? 0 : 100) + index
103
+ }
104
+
105
+ let commandId = 0
106
+
107
+ export interface CommandPaletteProps {
108
+ open?: boolean
109
+ defaultOpen?: boolean
110
+ onOpenChange?: (open: boolean) => void
111
+ /**
112
+ * Bind ⌘K / Ctrl+K. Default `true`; the listener only opens, never closes —
113
+ * pressing it inside an open palette should feel like nothing happened rather
114
+ * than like a toggle you cannot see.
115
+ */
116
+ shortcut?: boolean
117
+ placeholder?: string
118
+ /** Accessible name of both the dialog and the list. Default `Commands`. */
119
+ label?: string
120
+ /** Max width class. Default `md`. */
121
+ size?: 'sm' | 'md' | 'lg' | 'xl'
122
+ /** Rendered when nothing matches. */
123
+ empty?: JSX.Element
124
+ class?: string
125
+ children: JSX.Element
126
+ }
127
+
128
+ export function CommandPalette(props: CommandPaletteProps): JSX.Element {
129
+ commandId += 1
130
+ const id = `command-${commandId}`
131
+ const [uncontrolledOpen, setUncontrolledOpen] = createSignal(
132
+ props.defaultOpen ?? false,
133
+ )
134
+ const isControlled = () => props.open !== undefined
135
+ const open = () => props.open ?? uncontrolledOpen()
136
+ const setOpen = (next: boolean) => {
137
+ if (!isControlled()) setUncontrolledOpen(next)
138
+ props.onOpenChange?.(next)
139
+ }
140
+
141
+ const [query, setQuery] = createSignal('')
142
+ const [highlighted, setHighlighted] = createSignal<string>()
143
+ /**
144
+ * The registry is a signal, not a plain array with a counter beside it.
145
+ *
146
+ * That is not tidiness: `isEmpty` reads the registry *first*, and with a plain
147
+ * array the first render short-circuits on `length === 0` before touching
148
+ * anything reactive — so the empty state's condition records no dependency at
149
+ * all and never re-runs. It renders nothing, forever, while every other read
150
+ * of the same state updates.
151
+ */
152
+ const [registry, setRegistry] = createSignal<CommandRecord[]>([])
153
+
154
+ const visible = createMemo(() => {
155
+ const current = query()
156
+ return registry()
157
+ .filter((record) => record.matches())
158
+ .map((record) => ({
159
+ record,
160
+ rank: score(record.label(), record.keywords(), current) ?? 0,
161
+ }))
162
+ .sort((a, b) => a.rank - b.rank)
163
+ .map((entry) => entry.record)
164
+ })
165
+
166
+ /** Registered but none matching: not the same as "nothing has mounted yet". */
167
+ const isEmpty = () => registry().length > 0 && visible().length === 0
168
+
169
+ const register = (record: CommandRecord) =>
170
+ setRegistry((entries) => [...entries, record])
171
+ const unregister = (record: CommandRecord) =>
172
+ setRegistry((entries) => entries.filter((entry) => entry !== record))
173
+
174
+ const move = (delta: number) => {
175
+ const items = visible()
176
+ if (items.length === 0) return
177
+ const current = items.findIndex((entry) => entry.value() === highlighted())
178
+ let index = current === -1 ? (delta > 0 ? -1 : 0) : current
179
+ // A disabled entry is stepped over rather than landed on: it is in the list
180
+ // because it explains why it cannot be used, not because it is a target.
181
+ for (let step = 0; step < items.length; step += 1) {
182
+ index = (index + delta + items.length) % items.length
183
+ if (!items[index]?.disabled()) break
184
+ }
185
+ setHighlighted(items[index]?.value())
186
+ }
187
+
188
+ const accept = () => {
189
+ const items = visible()
190
+ const chosen =
191
+ items.find((entry) => entry.value() === highlighted()) ?? items[0]
192
+ if (!chosen || chosen.disabled()) return
193
+ setOpen(false)
194
+ chosen.select()
195
+ }
196
+
197
+ // The first match is the highlighted one: a palette you can open and confirm
198
+ // without looking at the list is the point of having one.
199
+ createEffect(() => {
200
+ const items = visible()
201
+ if (!items.some((entry) => entry.value() === highlighted()))
202
+ setHighlighted(items.find((entry) => !entry.disabled())?.value())
203
+ })
204
+ // A fresh palette starts empty, not showing the last search.
205
+ createEffect(() => {
206
+ if (!open()) setQuery('')
207
+ })
208
+
209
+ // Zag's trap moves focus into the dialog content; this moves it one step
210
+ // further, into the only control there is. An effect, not the `autofocus`
211
+ // attribute, because the content does not exist until `open` flips.
212
+ createEffect(() => {
213
+ if (open()) field?.focus()
214
+ })
215
+
216
+ onMount(() => {
217
+ if (props.shortcut === false) return
218
+ const onKey = (event: KeyboardEvent) => {
219
+ if (event.key.toLowerCase() !== 'k') return
220
+ if (!event.metaKey && !event.ctrlKey) return
221
+ event.preventDefault()
222
+ if (!open()) setOpen(true)
223
+ }
224
+ window.addEventListener('keydown', onKey)
225
+ onCleanup(() => window.removeEventListener('keydown', onKey))
226
+ })
227
+
228
+ const context: CommandContextValue = {
229
+ query,
230
+ setQuery,
231
+ register,
232
+ unregister,
233
+ visible,
234
+ highlighted,
235
+ highlight: setHighlighted,
236
+ move,
237
+ accept,
238
+ itemId: (value) => `${id}-${value.replace(/\W+/g, '-')}`,
239
+ }
240
+
241
+ let field: HTMLInputElement | undefined
242
+ const activeId = () => {
243
+ const value = highlighted()
244
+ return value === undefined ? undefined : context.itemId(value)
245
+ }
246
+
247
+ return (
248
+ <CommandContext.Provider value={context}>
249
+ <Dialog.Root open={open()} onOpenChange={setOpen}>
250
+ <Dialog.Content
251
+ bare
252
+ position="top"
253
+ size={props.size ?? 'md'}
254
+ class={cn('max-h-[60vh] overflow-hidden', props.class)}
255
+ >
256
+ {/* A modal dialog has to be named: the field carries the label for the
257
+ listbox, but the dialog itself is what a screen reader announces on
258
+ open. */}
259
+ <Dialog.Title class="sr-only">
260
+ {props.label ?? 'Commands'}
261
+ </Dialog.Title>
262
+ <div data-slot="command-palette" role="presentation">
263
+ <div class="flex items-center gap-2 border-b border-outline-gray-1 px-3">
264
+ <Icon
265
+ icon="lucide-search"
266
+ class="size-4 shrink-0 text-ink-gray-5"
267
+ aria-hidden="true"
268
+ />
269
+ <input
270
+ ref={field}
271
+ type="text"
272
+ role="combobox"
273
+ aria-expanded="true"
274
+ aria-controls={`${id}-list`}
275
+ aria-activedescendant={activeId()}
276
+ aria-autocomplete="list"
277
+ aria-label={props.label ?? 'Commands'}
278
+ placeholder={props.placeholder ?? 'Search commands…'}
279
+ value={query()}
280
+ onInput={(event) => setQuery(event.currentTarget.value)}
281
+ onKeyDown={(event) => {
282
+ if (event.key === 'ArrowDown') {
283
+ event.preventDefault()
284
+ move(1)
285
+ } else if (event.key === 'ArrowUp') {
286
+ event.preventDefault()
287
+ move(-1)
288
+ } else if (event.key === 'Enter') {
289
+ event.preventDefault()
290
+ accept()
291
+ }
292
+ }}
293
+ class="h-11 min-w-0 flex-1 bg-transparent text-base text-ink-gray-9 placeholder:text-ink-gray-5 focus:outline-none"
294
+ />
295
+ </div>
296
+
297
+ <div
298
+ id={`${id}-list`}
299
+ role="listbox"
300
+ aria-label={props.label ?? 'Commands'}
301
+ class="max-h-[50vh] overflow-y-auto p-1.5"
302
+ >
303
+ {/*
304
+ The items are *always* rendered, never behind a `Show` that
305
+ reads the filtered list: an item registers itself when it mounts,
306
+ so hiding the list until the registry is non-empty deadlocks —
307
+ no items, so no registry, so the empty state, forever.
308
+ */}
309
+ {props.children}
310
+ <Show when={isEmpty()}>
311
+ {/* A listbox may only contain options/groups: the empty
312
+ message is a disabled option, the one shape a screen
313
+ reader can address inside the list. */}
314
+ <div
315
+ role="option"
316
+ aria-disabled="true"
317
+ class="px-2 py-6 text-center text-base text-ink-gray-6"
318
+ >
319
+ {props.empty ?? 'Nothing matches.'}
320
+ </div>
321
+ </Show>
322
+ </div>
323
+ </div>
324
+ </Dialog.Content>
325
+ </Dialog.Root>
326
+ </CommandContext.Provider>
327
+ )
328
+ }
329
+
330
+ export interface CommandGroupProps {
331
+ /** Group heading; also the group's accessible name. */
332
+ label: string
333
+ children: JSX.Element
334
+ }
335
+
336
+ /**
337
+ * A titled run of items.
338
+ *
339
+ * The heading is hidden while the query is filtering: the grouping is an aid to
340
+ * browsing, and once you are searching for one thing the order is relevance.
341
+ */
342
+ export function CommandGroup(props: CommandGroupProps): JSX.Element {
343
+ const context = useCommand()
344
+ return (
345
+ <div role="group" aria-label={props.label}>
346
+ <Show when={context.query() === ''}>
347
+ <div
348
+ data-slot="command-group-label"
349
+ class="px-2 pt-2 pb-1 text-sm-medium text-ink-gray-6"
350
+ >
351
+ {props.label}
352
+ </div>
353
+ </Show>
354
+ {props.children}
355
+ </div>
356
+ )
357
+ }
358
+
359
+ export interface CommandItemProps {
360
+ /** Stable identity; also what `onSelect` reports. */
361
+ value: string
362
+ /** Extra search text for words that are not in the visible label. */
363
+ keywords?: string
364
+ icon?: IconProp
365
+ /** Trailing hint, e.g. `⌘B`. Purely informational. */
366
+ shortcut?: string
367
+ description?: string
368
+ disabled?: boolean
369
+ onSelect?: (value: string) => void
370
+ children: JSX.Element
371
+ }
372
+
373
+ export function CommandItem(props: CommandItemProps): JSX.Element {
374
+ const context = useCommand()
375
+ const [element, setElement] = createSignal<HTMLDivElement>()
376
+
377
+ const label = () =>
378
+ typeof props.children === 'string'
379
+ ? props.children
380
+ : (element()?.textContent ?? '')
381
+
382
+ const value = () => props.value
383
+ const record: CommandRecord = {
384
+ value,
385
+ keywords: () => props.keywords ?? '',
386
+ label,
387
+ disabled: () => props.disabled ?? false,
388
+ matches: () =>
389
+ score(label(), props.keywords ?? '', context.query()) !== null,
390
+ select: () => props.onSelect?.(value()),
391
+ }
392
+
393
+ onMount(() => {
394
+ context.register(record)
395
+ onCleanup(() => context.unregister(record))
396
+ })
397
+
398
+ const selected = () => context.highlighted() === props.value
399
+ const matches = () => record.matches()
400
+
401
+ /* The item stays mounted while it does not match — it is hidden, not removed,
402
+ * because unmounting it would unregister it, and the registry is how the
403
+ * palette tells "nothing has mounted yet" from "nothing matches". The wrapper
404
+ * carries the `hidden` *attribute* and no display utility, so nothing can
405
+ * out-rank it: a `flex` class on the same element would win in the browser
406
+ * while looking right in a test. */
407
+ return (
408
+ <div hidden={!matches()}>
409
+ <div
410
+ ref={setElement}
411
+ id={context.itemId(props.value)}
412
+ role="option"
413
+ aria-selected={selected()}
414
+ aria-disabled={props.disabled ? true : undefined}
415
+ data-slot="command-item"
416
+ data-selected={selected() ? '' : undefined}
417
+ data-disabled={props.disabled ? '' : undefined}
418
+ onClick={() => {
419
+ if (props.disabled) return
420
+ props.onSelect?.(props.value)
421
+ }}
422
+ onPointerMove={() => {
423
+ if (!props.disabled) context.highlight(props.value)
424
+ }}
425
+ class={cn(
426
+ 'flex cursor-pointer items-center gap-2 rounded-control px-2 py-2 text-base text-ink-gray-8 transition-colors',
427
+ 'data-[selected]:bg-surface-gray-2',
428
+ 'data-[disabled]:cursor-not-allowed data-[disabled]:text-ink-gray-4',
429
+ )}
430
+ >
431
+ <Show when={props.icon}>
432
+ {(icon) => (
433
+ <Icon
434
+ icon={icon()}
435
+ class="size-4 shrink-0 text-ink-gray-6"
436
+ aria-hidden="true"
437
+ />
438
+ )}
439
+ </Show>
440
+ <span class="min-w-0 flex-1 truncate">{props.children}</span>
441
+ <Show when={props.description}>
442
+ <span class="shrink-0 text-sm text-ink-gray-5">
443
+ {props.description}
444
+ </span>
445
+ </Show>
446
+ <Show when={props.shortcut}>
447
+ <kbd
448
+ data-slot="command-shortcut"
449
+ class="shrink-0 rounded-control border border-outline-gray-2 px-1.5 py-0.5 font-sans text-xs text-ink-gray-6"
450
+ >
451
+ {props.shortcut}
452
+ </kbd>
453
+ </Show>
454
+ </div>
455
+ </div>
456
+ )
457
+ }
@@ -0,0 +1 @@
1
+ export * from './CommandPalette'
@@ -0,0 +1,89 @@
1
+ import { render, screen, waitFor } from '@solidjs/testing-library'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { DataTable, type DataTableColumn } from './DataTable'
5
+
6
+ interface Row {
7
+ id: string
8
+ name: string
9
+ amount: number
10
+ }
11
+
12
+ const ROWS: Row[] = [
13
+ { id: '1', name: 'Alpha', amount: 10 },
14
+ { id: '2', name: 'Beta', amount: 20 },
15
+ { id: '3', name: 'Gamma', amount: 30 },
16
+ ]
17
+
18
+ const COLUMNS: DataTableColumn<Row>[] = [
19
+ { accessorKey: 'name', header: 'Name' },
20
+ {
21
+ accessorKey: 'amount',
22
+ header: 'Amount',
23
+ cell: (context) => `$${context.getValue()}`,
24
+ meta: { align: 'end' },
25
+ },
26
+ ]
27
+
28
+ function table(props: Partial<Parameters<typeof DataTable<Row>>[0]> = {}) {
29
+ return render(() => (
30
+ <DataTable
31
+ title="Rows"
32
+ rows={ROWS}
33
+ columns={COLUMNS}
34
+ pageSize={10}
35
+ {...props}
36
+ />
37
+ ))
38
+ }
39
+
40
+ describe('DataTable', () => {
41
+ it('renders headers, cells and a labelled region', () => {
42
+ table()
43
+ expect(screen.getByRole('region', { name: 'Rows' })).toBeTruthy()
44
+ expect(screen.getByRole('columnheader', { name: 'Name' })).toBeTruthy()
45
+ expect(screen.getByText('Alpha')).toBeTruthy()
46
+ expect(screen.getByText('$30')).toBeTruthy()
47
+ // Header row + three records.
48
+ expect(screen.getAllByRole('row')).toHaveLength(4)
49
+ })
50
+
51
+ it('filters as you type and reports the count', async () => {
52
+ table()
53
+ await userEvent.type(
54
+ screen.getByRole('searchbox', { name: 'Search rows' }),
55
+ 'beta',
56
+ )
57
+ await waitFor(() => expect(screen.queryByText('Alpha')).toBeNull())
58
+ expect(screen.getByText('Beta')).toBeTruthy()
59
+ expect(screen.getByRole('status').textContent).toContain('1')
60
+ })
61
+
62
+ it('selects rows and hands the ids to the bulk action', async () => {
63
+ const onRowsAction = vi.fn()
64
+ table({ onRowsAction, actionLabel: 'Archive selected' })
65
+ await userEvent.click(screen.getByRole('checkbox', { name: 'Select 1' }))
66
+ const bulk = await screen.findByRole('button', {
67
+ name: /Archive selected \(1\)/,
68
+ })
69
+ await userEvent.click(bulk)
70
+ expect(onRowsAction).toHaveBeenCalledWith(['1'])
71
+ })
72
+
73
+ it('sorts from the header and marks the column', async () => {
74
+ table()
75
+ const header = screen.getByRole('button', { name: 'Amount' })
76
+ await userEvent.click(header)
77
+ await waitFor(() => {
78
+ const cell = screen.getByRole('columnheader', { name: 'Amount' })
79
+ expect(['ascending', 'descending']).toContain(
80
+ cell.getAttribute('aria-sort'),
81
+ )
82
+ })
83
+ })
84
+
85
+ it('can be a read-only table with no selection column', () => {
86
+ table({ selectable: false })
87
+ expect(screen.queryByRole('checkbox', { name: /Select/ })).toBeNull()
88
+ })
89
+ })