@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.
- package/LICENSE +202 -0
- package/README.md +12 -2
- package/package.json +38 -3
- package/src/exports.test.ts +27 -0
- package/src/index.ts +1 -0
- package/src/organisms/AppSidebar/AppSidebar.test.tsx +100 -0
- package/src/organisms/AppSidebar/AppSidebar.tsx +214 -0
- package/src/organisms/AppSidebar/index.ts +1 -0
- package/src/organisms/AuthCard/AuthCard.a11y.test.tsx +36 -0
- package/src/organisms/AuthCard/AuthCard.test.tsx +56 -0
- package/src/organisms/AuthCard/AuthCard.tsx +80 -0
- package/src/organisms/AuthCard/index.ts +1 -0
- package/src/organisms/CommandPalette/CommandPalette.test.tsx +196 -0
- package/src/organisms/CommandPalette/CommandPalette.tsx +457 -0
- package/src/organisms/CommandPalette/index.ts +1 -0
- package/src/organisms/DataTable/DataTable.test.tsx +89 -0
- package/src/organisms/DataTable/DataTable.tsx +334 -0
- package/src/organisms/DataTable/index.ts +1 -0
- package/src/organisms/DetailHeader/DetailHeader.test.tsx +55 -0
- package/src/organisms/DetailHeader/DetailHeader.tsx +87 -0
- package/src/organisms/DetailHeader/index.ts +1 -0
- package/src/organisms/FilterBar/FilterBar.test.tsx +151 -0
- package/src/organisms/FilterBar/FilterBar.tsx +232 -0
- package/src/organisms/FilterBar/index.ts +1 -0
- package/src/organisms/KanbanBoard/KanbanBoard.test.tsx +255 -0
- package/src/organisms/KanbanBoard/KanbanBoard.tsx +550 -0
- package/src/organisms/KanbanBoard/drag.test.ts +448 -0
- package/src/organisms/KanbanBoard/drag.ts +407 -0
- package/src/organisms/KanbanBoard/index.ts +17 -0
- package/src/organisms/MobileNav/MobileNav.browser.test.tsx +39 -0
- package/src/organisms/MobileNav/MobileNav.test.tsx +108 -0
- package/src/organisms/MobileNav/MobileNav.tsx +120 -0
- package/src/organisms/MobileNav/index.ts +6 -0
- package/src/organisms/PageToolbar/PageToolbar.a11y.test.tsx +35 -0
- package/src/organisms/PageToolbar/PageToolbar.test.tsx +42 -0
- package/src/organisms/PageToolbar/PageToolbar.tsx +77 -0
- package/src/organisms/PageToolbar/index.ts +1 -0
- package/src/organisms/SettingsDialog/SettingsDialog.a11y.test.tsx +58 -0
- package/src/organisms/SettingsDialog/SettingsDialog.browser.test.tsx +43 -0
- package/src/organisms/SettingsDialog/SettingsDialog.ssr.test.tsx +27 -0
- package/src/organisms/SettingsDialog/SettingsDialog.test.tsx +202 -0
- package/src/organisms/SettingsDialog/SettingsDialog.tsx +539 -0
- package/src/organisms/SettingsDialog/index.ts +20 -0
- package/src/organisms/Sidebar/Sidebar.browser.test.tsx +52 -0
- package/src/organisms/Sidebar/Sidebar.test.tsx +308 -0
- package/src/organisms/Sidebar/Sidebar.tsx +725 -0
- package/src/organisms/Sidebar/__screenshots__/Sidebar.browser.test.tsx/Sidebar--browser--real-CSS--reserves-its-declared-width-1.png +0 -0
- package/src/organisms/Sidebar/index.ts +22 -0
- package/src/organisms/StatGrid/StatGrid.a11y.test.tsx +40 -0
- package/src/organisms/StatGrid/StatGrid.test.tsx +83 -0
- package/src/organisms/StatGrid/StatGrid.tsx +124 -0
- package/src/organisms/StatGrid/index.ts +1 -0
- package/src/organisms/VirtualList/VirtualList.test.tsx +80 -0
- package/src/organisms/VirtualList/VirtualList.tsx +79 -0
- package/src/organisms/VirtualList/index.ts +1 -0
- 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
|
+
})
|