@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,334 @@
1
+ import { Button, Checkbox, TextInput } from '@talelk/newui-atoms'
2
+ import {
3
+ Pagination,
4
+ ScrollTable,
5
+ ScrollTableHead,
6
+ } from '@talelk/newui-molecules'
7
+ import {
8
+ type ColumnDef,
9
+ columnFilteringFeature,
10
+ columnVisibilityFeature,
11
+ createFilteredRowModel,
12
+ createPaginatedRowModel,
13
+ createSortedRowModel,
14
+ createTable,
15
+ flexRender,
16
+ globalFilteringFeature,
17
+ rowPaginationFeature,
18
+ rowSelectionFeature,
19
+ rowSortingFeature,
20
+ sortFn_alphanumeric,
21
+ sortFn_datetime,
22
+ sortFn_text,
23
+ tableFeatures,
24
+ } from '@tanstack/solid-table'
25
+ import {
26
+ createEffect,
27
+ createMemo,
28
+ For,
29
+ type JSX,
30
+ on,
31
+ Show,
32
+ untrack,
33
+ } from 'solid-js'
34
+
35
+ /**
36
+ * Built on the first table, not when the module is evaluated.
37
+ *
38
+ * A page can import this entry for `ScrollTable` alone (the accounting ledger
39
+ * does); if the feature set were a module-level `const`, evaluating the module
40
+ * would build it — and rollup could not drop the table engine behind it.
41
+ */
42
+ function createFeatures() {
43
+ return tableFeatures({
44
+ columnFilteringFeature,
45
+ columnVisibilityFeature,
46
+ globalFilteringFeature,
47
+ rowPaginationFeature,
48
+ rowSelectionFeature,
49
+ rowSortingFeature,
50
+ filteredRowModel: createFilteredRowModel(),
51
+ paginatedRowModel: createPaginatedRowModel(),
52
+ sortedRowModel: createSortedRowModel(),
53
+ sortFns: {
54
+ alphanumeric: sortFn_alphanumeric,
55
+ datetime: sortFn_datetime,
56
+ text: sortFn_text,
57
+ },
58
+ })
59
+ }
60
+
61
+ type Features = ReturnType<typeof createFeatures>
62
+
63
+ export type DataTableColumn<T extends object> = ColumnDef<Features, T>
64
+
65
+ export interface DataTableProps<T extends { id: string }> {
66
+ title: string
67
+ rows: T[]
68
+ columns: DataTableColumn<T>[]
69
+ actionLabel?: string
70
+ onRowsAction?: (ids: string[]) => void
71
+ /** Pages whose FilterBar owns search hide the table's own. */
72
+ hideSearch?: boolean
73
+ /** Selection checkboxes + bulk action. Default on; off for read tables. */
74
+ selectable?: boolean
75
+ pageSize?: number
76
+ /** Footer noun: "32 transactions". Defaults to "records". */
77
+ recordsLabel?: string
78
+ emptyTitle?: string
79
+ emptyHint?: string
80
+ /** Empty-state reset for externally filtered pages (beats Clear search). */
81
+ onClearFilters?: () => void
82
+ }
83
+
84
+ /**
85
+ * A collection as a table: search, sort, page, hide columns, select rows.
86
+ *
87
+ * TanStack Table is the engine; this is the kit's composition of it. The surface
88
+ * lives in `@talelk/newui-organisms`: it composes controls and owns the TanStack Table
89
+ * engine. The headless `@talelk/newui-data` package remains independent of rendering.
90
+ */
91
+ export function DataTable<T extends { id: string }>(
92
+ props: DataTableProps<T>,
93
+ ): JSX.Element {
94
+ const selectable = () => props.selectable !== false
95
+ const selection: DataTableColumn<T> = {
96
+ id: 'select',
97
+ enableHiding: false,
98
+ enableSorting: false,
99
+ header: (context) => (
100
+ <Checkbox
101
+ aria-label="Select all rows on this page"
102
+ checked={context.table.getIsAllPageRowsSelected()}
103
+ indeterminate={context.table.getIsSomePageRowsSelected()}
104
+ onCheckedChange={(value) =>
105
+ context.table.toggleAllPageRowsSelected(value)
106
+ }
107
+ />
108
+ ),
109
+ cell: (context) => (
110
+ <Checkbox
111
+ aria-label={`Select ${context.row.original.id}`}
112
+ checked={context.row.getIsSelected()}
113
+ onCheckedChange={(value) => context.row.toggleSelected(value)}
114
+ />
115
+ ),
116
+ }
117
+ const resolvedColumns = createMemo(() =>
118
+ selectable() ? [selection, ...props.columns] : props.columns,
119
+ )
120
+ const table = createTable<Features, T>({
121
+ features: createFeatures(),
122
+ get data() {
123
+ return props.rows
124
+ },
125
+ get columns() {
126
+ return resolvedColumns()
127
+ },
128
+ getRowId: (row) => row.id,
129
+ initialState: {
130
+ // Initial only: later pageSize changes do not repaginate the table.
131
+ pagination: {
132
+ pageIndex: 0,
133
+ pageSize: untrack(() => props.pageSize) ?? 10,
134
+ },
135
+ },
136
+ })
137
+ // Externally filtered pages hand over a new row set per filter change;
138
+ // staying on page 3 of a 6-row result would show an empty page.
139
+ createEffect(
140
+ on(
141
+ () => props.rows,
142
+ () => table.setPageIndex(0),
143
+ { defer: true },
144
+ ),
145
+ )
146
+ const alignEnd = (meta: unknown): boolean =>
147
+ (meta as { align?: string } | undefined)?.align === 'end'
148
+ const selected = () =>
149
+ table.getFilteredSelectedRowModel().rows.map((row) => row.original.id)
150
+ return (
151
+ <section
152
+ class="min-w-0 rounded-card border border-outline-gray-1 bg-surface-elevation-1"
153
+ aria-label={props.title}
154
+ data-slot="data-table"
155
+ >
156
+ <div class="flex flex-wrap items-center justify-between gap-control-gap p-card-pad-compact">
157
+ <Show when={!props.hideSearch}>
158
+ <div class="w-full sm:w-72">
159
+ <TextInput
160
+ type="search"
161
+ aria-label={`Search ${props.title.toLowerCase()}`}
162
+ placeholder={`Search ${props.title.toLowerCase()}…`}
163
+ value={String(table.atoms.globalFilter.get() ?? '')}
164
+ onInput={(event) => {
165
+ table.setGlobalFilter(event.currentTarget.value)
166
+ table.setPageIndex(0)
167
+ }}
168
+ />
169
+ </div>
170
+ </Show>
171
+ <div class="ms-auto flex flex-wrap items-center gap-2">
172
+ <Show when={props.onRowsAction && selected().length}>
173
+ <Button
174
+ variant="outline"
175
+ onClick={() => {
176
+ props.onRowsAction?.(selected())
177
+ table.resetRowSelection()
178
+ }}
179
+ >
180
+ {props.actionLabel ?? 'Update selected'} ({selected().length})
181
+ </Button>
182
+ </Show>
183
+ <details class="relative">
184
+ <summary class="cursor-pointer rounded-3 border border-outline-gray-2 px-3 py-1.5 text-interface focus-visible:focus-ring">
185
+ Columns
186
+ </summary>
187
+ <div class="absolute end-0 z-floating mt-2 flex min-w-44 flex-col gap-2 rounded-6 border border-outline-gray-1 bg-surface-elevation-2 p-3 shadow-md">
188
+ <For
189
+ each={table
190
+ .getAllLeafColumns()
191
+ .filter((column) => column.getCanHide())}
192
+ >
193
+ {(column) => (
194
+ <Checkbox
195
+ label={column.id}
196
+ checked={column.getIsVisible()}
197
+ onCheckedChange={(value) => column.toggleVisibility(value)}
198
+ />
199
+ )}
200
+ </For>
201
+ </div>
202
+ </details>
203
+ </div>
204
+ </div>
205
+ <ScrollTable label={`${props.title} table`}>
206
+ <table class="w-full min-w-[720px] border-collapse text-start">
207
+ <ScrollTableHead>
208
+ <For each={table.getHeaderGroups()}>
209
+ {(group) => (
210
+ <tr>
211
+ <For each={group.headers}>
212
+ {(header) => (
213
+ <th
214
+ scope="col"
215
+ class={
216
+ alignEnd(header.column.columnDef.meta)
217
+ ? 'px-4 py-3 text-end'
218
+ : 'px-4 py-3 text-start'
219
+ }
220
+ aria-sort={
221
+ header.column.getIsSorted() === 'asc'
222
+ ? 'ascending'
223
+ : header.column.getIsSorted() === 'desc'
224
+ ? 'descending'
225
+ : undefined
226
+ }
227
+ >
228
+ <Show when={!header.isPlaceholder}>
229
+ <Show
230
+ when={header.column.getCanSort()}
231
+ fallback={flexRender(
232
+ header.column.columnDef.header,
233
+ header.getContext(),
234
+ )}
235
+ >
236
+ <button
237
+ type="button"
238
+ class="inline-flex items-center gap-2 rounded-3 focus-visible:focus-ring"
239
+ onClick={header.column.getToggleSortingHandler()}
240
+ >
241
+ {flexRender(
242
+ header.column.columnDef.header,
243
+ header.getContext(),
244
+ )}
245
+ <span aria-hidden="true">
246
+ <span
247
+ aria-hidden="true"
248
+ class="lucide-arrow-up-down inline-block size-4 align-middle"
249
+ />
250
+ </span>
251
+ </button>
252
+ </Show>
253
+ </Show>
254
+ </th>
255
+ )}
256
+ </For>
257
+ </tr>
258
+ )}
259
+ </For>
260
+ </ScrollTableHead>
261
+ <tbody>
262
+ <For each={table.getRowModel().rows}>
263
+ {(row) => (
264
+ <tr
265
+ class="border-b border-outline-gray-1 hover:bg-surface-gray-1"
266
+ data-selected={row.getIsSelected() ? '' : undefined}
267
+ >
268
+ <For each={row.getVisibleCells()}>
269
+ {(cell) => (
270
+ <td
271
+ class={
272
+ alignEnd(cell.column.columnDef.meta)
273
+ ? 'px-4 py-3 text-end text-interface'
274
+ : 'px-4 py-3 text-interface'
275
+ }
276
+ >
277
+ {flexRender(
278
+ cell.column.columnDef.cell,
279
+ cell.getContext(),
280
+ )}
281
+ </td>
282
+ )}
283
+ </For>
284
+ </tr>
285
+ )}
286
+ </For>
287
+ </tbody>
288
+ </table>
289
+ </ScrollTable>
290
+ <Show when={table.getFilteredRowModel().rows.length === 0}>
291
+ <div class="p-10 text-center">
292
+ <h3 class="font-semibold">
293
+ {props.emptyTitle ?? 'No matching records'}
294
+ </h3>
295
+ <p class="mt-1 text-ink-gray-7">
296
+ {props.emptyHint ?? 'Try a different search or clear your filters.'}
297
+ </p>
298
+ <Show
299
+ when={props.onClearFilters}
300
+ fallback={
301
+ <Button class="mt-3" onClick={() => table.resetGlobalFilter()}>
302
+ Clear search
303
+ </Button>
304
+ }
305
+ >
306
+ {(clear) => (
307
+ <Button class="mt-3" onClick={() => clear()()}>
308
+ Reset filters
309
+ </Button>
310
+ )}
311
+ </Show>
312
+ </div>
313
+ </Show>
314
+ <div class="flex flex-wrap items-center justify-between gap-control-gap p-card-pad-compact">
315
+ <span class="text-caption text-ink-gray-7" role="status">
316
+ <Show when={selectable()}>{selected().length} selected · </Show>
317
+ {table.getFilteredRowModel().rows.length}{' '}
318
+ {props.recordsLabel ?? 'records'}
319
+ </span>
320
+ <Pagination.Root
321
+ page={table.atoms.pagination.get().pageIndex + 1}
322
+ pageSize={table.atoms.pagination.get().pageSize}
323
+ count={table.getFilteredRowModel().rows.length}
324
+ onPageChange={(page) => table.setPageIndex(page - 1)}
325
+ >
326
+ <Pagination.Range />
327
+ <Pagination.PrevTrigger />
328
+ <Pagination.Pages />
329
+ <Pagination.NextTrigger />
330
+ </Pagination.Root>
331
+ </div>
332
+ </section>
333
+ )
334
+ }
@@ -0,0 +1 @@
1
+ export * from './DataTable'
@@ -0,0 +1,55 @@
1
+ import { render, screen } from '@solidjs/testing-library'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { DetailHeader } from './DetailHeader'
5
+
6
+ describe('DetailHeader', () => {
7
+ it('renders the trail, the title, the status and the subtitle', () => {
8
+ render(() => (
9
+ <DetailHeader
10
+ breadcrumbs={[{ label: 'Files', route: '/files' }, { label: 'Report' }]}
11
+ title="Q3 report.pdf"
12
+ subtitle="1.2 MB · updated 2h ago"
13
+ status={{ label: 'Shared', theme: 'blue' }}
14
+ />
15
+ ))
16
+
17
+ expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toBeTruthy()
18
+ expect(
19
+ screen.getByRole('heading', { level: 1, name: 'Q3 report.pdf' }),
20
+ ).toBeTruthy()
21
+ expect(screen.getByText('Shared')).toBeTruthy()
22
+ expect(screen.getByText('1.2 MB · updated 2h ago')).toBeTruthy()
23
+ })
24
+
25
+ it('puts the primary action last and calls the right handler', async () => {
26
+ const onShare = vi.fn()
27
+ const onDownload = vi.fn()
28
+ render(() => (
29
+ <DetailHeader
30
+ title="Report"
31
+ actions={[
32
+ { label: 'Share', onClick: onShare },
33
+ { label: 'Download', onClick: onDownload },
34
+ ]}
35
+ />
36
+ ))
37
+
38
+ const buttons = screen.getAllByRole('button')
39
+ expect(buttons.at(-1)?.textContent).toBe('Download')
40
+
41
+ await userEvent.click(screen.getByRole('button', { name: 'Share' }))
42
+ expect(onShare).toHaveBeenCalled()
43
+ expect(onDownload).not.toHaveBeenCalled()
44
+ })
45
+
46
+ it('renders one action as primary with no outline sibling', () => {
47
+ render(() => <DetailHeader title="Report" actions={[{ label: 'Edit' }]} />)
48
+ expect(screen.getAllByRole('button')).toHaveLength(1)
49
+ })
50
+
51
+ it('omits the trail when there is none', () => {
52
+ const { container } = render(() => <DetailHeader title="Report" />)
53
+ expect(container.querySelector('nav')).toBeNull()
54
+ })
55
+ })
@@ -0,0 +1,87 @@
1
+ import { Badge, Button, type ButtonTheme } from '@talelk/newui-atoms'
2
+ import { type BreadcrumbItem, Breadcrumbs } from '@talelk/newui-molecules'
3
+ import { For, type JSX, Show } from 'solid-js'
4
+
5
+ export interface DetailAction {
6
+ label: string
7
+ onClick?: () => void
8
+ }
9
+
10
+ export interface DetailHeaderProps {
11
+ /** The trail above the title. Omitted → no breadcrumbs. */
12
+ breadcrumbs?: BreadcrumbItem[]
13
+ title: JSX.Element
14
+ /** Muted line under the title — a record id, an owner, a timestamp. */
15
+ subtitle?: JSX.Element
16
+ /** Status pill beside the title. */
17
+ status?: { label: string; theme?: ButtonTheme }
18
+ /**
19
+ * Trailing actions in order. Everything but the last renders as an outline
20
+ * button; the last is the primary one.
21
+ */
22
+ actions?: DetailAction[]
23
+ /** Anything else on the trailing edge, before the actions. */
24
+ children?: JSX.Element
25
+ class?: string
26
+ }
27
+
28
+ /**
29
+ * The top of a record page: where it sits, what it is, what state it is in, and
30
+ * what you can do to it.
31
+ *
32
+ * The order is the point. A reader arrives from a list and needs three questions
33
+ * answered in that order — the trail first, so they know this is the record they
34
+ * clicked; then the name; then the status, because a status only means something
35
+ * once you know what it belongs to. Actions sit on the trailing edge with the
36
+ * primary one last, so the least-common (often destructive) action is never the
37
+ * one nearest the title.
38
+ */
39
+ export function DetailHeader(props: DetailHeaderProps): JSX.Element {
40
+ const secondary = () => (props.actions ?? []).slice(0, -1)
41
+ const primary = () => (props.actions ?? []).at(-1)
42
+
43
+ return (
44
+ <header class={`flex flex-col gap-field-gap ${props.class ?? ''}`}>
45
+ <Show when={props.breadcrumbs}>
46
+ {(crumbs) => <Breadcrumbs items={crumbs()} />}
47
+ </Show>
48
+
49
+ <div class="flex flex-wrap items-start justify-between gap-card-gap">
50
+ <div class="flex min-w-0 flex-col gap-1">
51
+ <div class="flex min-w-0 items-center gap-control-gap">
52
+ <h1 class="truncate text-metric text-ink-gray-9">{props.title}</h1>
53
+ <Show when={props.status}>
54
+ {(status) => (
55
+ <Badge
56
+ theme={status().theme ?? 'gray'}
57
+ label={status().label}
58
+ />
59
+ )}
60
+ </Show>
61
+ </div>
62
+ <Show when={props.subtitle}>
63
+ <p class="truncate text-base text-ink-gray-7">{props.subtitle}</p>
64
+ </Show>
65
+ </div>
66
+
67
+ <div class="flex shrink-0 flex-wrap items-center gap-control-gap">
68
+ {props.children}
69
+ <For each={secondary()}>
70
+ {(action) => (
71
+ <Button variant="outline" onClick={action.onClick}>
72
+ {action.label}
73
+ </Button>
74
+ )}
75
+ </For>
76
+ <Show when={primary()}>
77
+ {(action) => (
78
+ <Button variant="solid" onClick={action().onClick}>
79
+ {action().label}
80
+ </Button>
81
+ )}
82
+ </Show>
83
+ </div>
84
+ </div>
85
+ </header>
86
+ )
87
+ }
@@ -0,0 +1 @@
1
+ export * from './DetailHeader'
@@ -0,0 +1,151 @@
1
+ import { render, screen } from '@solidjs/testing-library'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { FilterBar } from './FilterBar'
5
+
6
+ describe('FilterBar', () => {
7
+ it('renders a labelled search box', () => {
8
+ render(() => <FilterBar query={{ placeholder: 'Search files' }} />)
9
+ expect(screen.getByLabelText('Search files')).toBeTruthy()
10
+ })
11
+
12
+ it('reports typing', async () => {
13
+ const onInput = vi.fn()
14
+ render(() => <FilterBar query={{ placeholder: 'Search', onInput }} />)
15
+ await userEvent.type(screen.getByLabelText('Search'), 'inv')
16
+ expect(onInput).toHaveBeenLastCalledWith('inv')
17
+ })
18
+
19
+ it('gives every select an accessible name from its label', () => {
20
+ render(() => (
21
+ <FilterBar
22
+ selects={[
23
+ {
24
+ label: 'Owner',
25
+ options: [{ label: 'Anyone', value: 'any' }],
26
+ },
27
+ ]}
28
+ />
29
+ ))
30
+ expect(screen.getByLabelText('Owner')).toBeTruthy()
31
+ })
32
+
33
+ it('reports a select change', async () => {
34
+ const onValueChange = vi.fn()
35
+ render(() => (
36
+ <FilterBar
37
+ selects={[
38
+ {
39
+ label: 'Owner',
40
+ value: 'any',
41
+ onValueChange,
42
+ options: [
43
+ { label: 'Anyone', value: 'any' },
44
+ { label: 'Me', value: 'me' },
45
+ ],
46
+ },
47
+ ]}
48
+ />
49
+ ))
50
+
51
+ await userEvent.click(screen.getByLabelText('Owner'))
52
+ await userEvent.click(await screen.findByRole('option', { name: 'Me' }))
53
+ expect(onValueChange).toHaveBeenCalledWith('me')
54
+ })
55
+
56
+ it('renders no view switcher unless one is given', () => {
57
+ const { container } = render(() => <FilterBar />)
58
+ expect(container.querySelector('[data-slot="tab-trigger"]')).toBeNull()
59
+ })
60
+
61
+ it('puts the controls on one surface', () => {
62
+ const { container } = render(() => (
63
+ <FilterBar query={{ placeholder: 'Search' }} />
64
+ ))
65
+ const bar = container.querySelector('[data-slot="filter-bar"]')
66
+ expect(bar?.className).toContain('bg-surface-elevation-1')
67
+ expect(bar?.className).toContain('border-outline-gray-1')
68
+ })
69
+
70
+ it('focuses the search box on / and ignores / while editing elsewhere', async () => {
71
+ render(() => (
72
+ <>
73
+ <input aria-label="Other field" />
74
+ <FilterBar query={{ placeholder: 'Search tickets' }} />
75
+ </>
76
+ ))
77
+ const search = screen.getByLabelText('Search tickets')
78
+ await userEvent.keyboard('/')
79
+ expect(document.activeElement).toBe(search)
80
+
81
+ const other = screen.getByLabelText('Other field')
82
+ other.focus()
83
+ await userEvent.keyboard('/')
84
+ expect(document.activeElement).toBe(other)
85
+ })
86
+
87
+ it('states the result count, singular and plural', () => {
88
+ const one = render(() => <FilterBar resultCount={1} />)
89
+ expect(screen.getByText('1 result')).toBeTruthy()
90
+ one.unmount()
91
+ render(() => <FilterBar resultCount={24} />)
92
+ expect(screen.getByText('24 results')).toBeTruthy()
93
+ })
94
+
95
+ it('renders chips and removes one by its labelled button', async () => {
96
+ const onRemove = vi.fn()
97
+ render(() => <FilterBar chips={[{ label: 'Status: Open', onRemove }]} />)
98
+ expect(screen.getByText(/Status: Open/)).toBeTruthy()
99
+ await userEvent.click(
100
+ screen.getByRole('button', { name: 'Remove filter: Status: Open' }),
101
+ )
102
+ expect(onRemove).toHaveBeenCalled()
103
+ })
104
+
105
+ it('renders a chip without onRemove as a plain pill', () => {
106
+ render(() => <FilterBar chips={[{ label: 'Region: Europe' }]} />)
107
+ expect(screen.getByText('Region: Europe')).toBeTruthy()
108
+ expect(screen.queryByRole('button', { name: /Remove filter/ })).toBeNull()
109
+ })
110
+
111
+ it('shows Clear all only while a filter is active, and reports it', async () => {
112
+ const onClearAll = vi.fn()
113
+ const idle = render(() => <FilterBar onClearAll={onClearAll} />)
114
+ expect(screen.queryByRole('button', { name: 'Clear all' })).toBeNull()
115
+ idle.unmount()
116
+
117
+ render(() => (
118
+ <FilterBar
119
+ onClearAll={onClearAll}
120
+ selects={[
121
+ {
122
+ label: 'Owner',
123
+ value: 'me',
124
+ options: [{ label: 'Me', value: 'me' }],
125
+ },
126
+ ]}
127
+ />
128
+ ))
129
+ await userEvent.click(screen.getByRole('button', { name: 'Clear all' }))
130
+ expect(onClearAll).toHaveBeenCalled()
131
+ })
132
+
133
+ it('renders and reports the view switcher', async () => {
134
+ const onValueChange = vi.fn()
135
+ render(() => (
136
+ <FilterBar
137
+ tabs={{
138
+ value: 'list',
139
+ onValueChange,
140
+ options: [
141
+ { label: 'List', value: 'list' },
142
+ { label: 'Board', value: 'board' },
143
+ ],
144
+ }}
145
+ />
146
+ ))
147
+
148
+ await userEvent.click(screen.getByRole('tab', { name: 'Board' }))
149
+ expect(onValueChange).toHaveBeenCalledWith('board')
150
+ })
151
+ })