@talelk/newui-organisms 2.0.0 → 2.1.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/package.json +8 -4
- package/skills/newui-organisms/SKILL.md +91 -0
- package/src/exports.test.ts +3 -0
- package/src/organisms/AppSidebar/AppSidebar.a11y.test.tsx +75 -0
- package/src/organisms/CommandPalette/CommandPalette.a11y.test.tsx +58 -0
- package/src/organisms/DataState/DataState.a11y.test.tsx +59 -0
- package/src/organisms/DataState/DataState.browser.test.tsx +52 -0
- package/src/organisms/DataTable/DataTable.a11y.test.tsx +71 -0
- package/src/organisms/DataTable/DataTable.features.test.tsx +191 -0
- package/src/organisms/DataTable/DataTable.tsx +202 -20
- package/src/organisms/DetailHeader/DetailHeader.a11y.test.tsx +53 -0
- package/src/organisms/DetailHeader/DetailHeader.browser.test.tsx +50 -0
- package/src/organisms/FilterBar/FilterBar.a11y.test.tsx +58 -0
- package/src/organisms/FilterBar/FilterBar.browser.test.tsx +46 -0
- package/src/organisms/KanbanBoard/KanbanBoard.a11y.test.tsx +75 -0
- package/src/organisms/KanbanBoard/KanbanBoard.browser.test.tsx +84 -0
- package/src/organisms/MobileNav/MobileNav.a11y.test.tsx +49 -0
- package/src/organisms/Section/Section.a11y.test.tsx +49 -0
- package/src/organisms/Section/Section.browser.test.tsx +63 -0
- package/src/organisms/SelectionBar/SelectionBar.a11y.test.tsx +48 -0
- package/src/organisms/SelectionBar/SelectionBar.browser.test.tsx +57 -0
- package/src/organisms/Sidebar/Sidebar.a11y.test.tsx +71 -0
- package/src/organisms/VirtualList/VirtualList.a11y.test.tsx +109 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Button, Checkbox, cn, TextInput } from '@talelk/newui-atoms'
|
|
2
|
+
import { createControllableSignal } from '@talelk/newui-atoms/internal'
|
|
2
3
|
import {
|
|
3
4
|
Pagination,
|
|
4
5
|
ScrollTable,
|
|
@@ -7,6 +8,7 @@ import {
|
|
|
7
8
|
import {
|
|
8
9
|
type ColumnDef,
|
|
9
10
|
columnFilteringFeature,
|
|
11
|
+
columnPinningFeature,
|
|
10
12
|
columnVisibilityFeature,
|
|
11
13
|
createFilteredRowModel,
|
|
12
14
|
createPaginatedRowModel,
|
|
@@ -25,6 +27,7 @@ import {
|
|
|
25
27
|
import {
|
|
26
28
|
createEffect,
|
|
27
29
|
createMemo,
|
|
30
|
+
createSignal,
|
|
28
31
|
For,
|
|
29
32
|
type JSX,
|
|
30
33
|
on,
|
|
@@ -44,6 +47,7 @@ import { SelectionBar } from '../SelectionBar'
|
|
|
44
47
|
function createFeatures() {
|
|
45
48
|
return tableFeatures({
|
|
46
49
|
columnFilteringFeature,
|
|
50
|
+
columnPinningFeature,
|
|
47
51
|
columnVisibilityFeature,
|
|
48
52
|
globalFilteringFeature,
|
|
49
53
|
rowPaginationFeature,
|
|
@@ -72,10 +76,22 @@ export type DataTableMobileRole =
|
|
|
72
76
|
| 'status'
|
|
73
77
|
| 'meta'
|
|
74
78
|
|
|
75
|
-
/** The library reads `meta.align` and `meta.
|
|
79
|
+
/** The library reads `meta.align`, `meta.mobile` and `meta.editable`; all optional. */
|
|
76
80
|
export interface DataTableColumnMeta {
|
|
77
81
|
align?: 'end'
|
|
78
82
|
mobile?: DataTableMobileRole
|
|
83
|
+
/**
|
|
84
|
+
* The cell edits in place when the table is `editable`. `editInput` picks
|
|
85
|
+
* the editor (default `text`).
|
|
86
|
+
*/
|
|
87
|
+
editable?: boolean
|
|
88
|
+
editInput?: 'text' | 'number'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Which column is frozen to each edge of the horizontal scroll. */
|
|
92
|
+
export interface DataTablePinned {
|
|
93
|
+
start?: string
|
|
94
|
+
end?: string
|
|
79
95
|
}
|
|
80
96
|
|
|
81
97
|
export interface DataTableProps<T extends { id: string }> {
|
|
@@ -110,10 +126,97 @@ export interface DataTableProps<T extends { id: string }> {
|
|
|
110
126
|
emptyHint?: string
|
|
111
127
|
/** Empty-state reset for externally filtered pages (beats Clear search). */
|
|
112
128
|
onClearFilters?: () => void
|
|
129
|
+
/**
|
|
130
|
+
* Freeze one column to the start edge of the horizontal scroll — the
|
|
131
|
+
* identifier that must stay visible while the rest move. Controlled:
|
|
132
|
+
* changes go through `onPinnedColumnsChange`.
|
|
133
|
+
*/
|
|
134
|
+
pinnedStart?: string
|
|
135
|
+
/** Freeze one column to the end edge — typically the row actions. */
|
|
136
|
+
pinnedEnd?: string
|
|
137
|
+
defaultPinnedStart?: string
|
|
138
|
+
defaultPinnedEnd?: string
|
|
139
|
+
onPinnedColumnsChange?: (pinned: DataTablePinned) => void
|
|
140
|
+
/**
|
|
141
|
+
* Turn on inline editing: columns whose `meta.editable` is set render a
|
|
142
|
+
* click-to-edit cell. Values are app-owned — edits arrive through
|
|
143
|
+
* `onCellChange`, and the table shows whatever the next `rows` carry.
|
|
144
|
+
*/
|
|
145
|
+
editable?: boolean
|
|
146
|
+
onCellChange?: (id: string, columnId: string, value: string) => void
|
|
113
147
|
}
|
|
114
148
|
|
|
115
149
|
/**
|
|
116
|
-
* A
|
|
150
|
+
* A click-to-edit cell. Enter or blur commits; Escape cancels. The value is
|
|
151
|
+
* the row's raw field, not the formatted render.
|
|
152
|
+
*/
|
|
153
|
+
function EditableCell(props: {
|
|
154
|
+
id: string
|
|
155
|
+
columnId: string
|
|
156
|
+
value: string
|
|
157
|
+
label: string
|
|
158
|
+
type: 'text' | 'number'
|
|
159
|
+
alignEnd: boolean
|
|
160
|
+
onCommit: (id: string, columnId: string, value: string) => void
|
|
161
|
+
}): JSX.Element {
|
|
162
|
+
const [editing, setEditing] = createSignal(false)
|
|
163
|
+
// Seeded at open time (the trigger click), never read while closed.
|
|
164
|
+
const [draft, setDraft] = createSignal('')
|
|
165
|
+
const commit = (original: string) => {
|
|
166
|
+
setEditing(false)
|
|
167
|
+
const value = draft().trim()
|
|
168
|
+
if (value !== original) props.onCommit(props.id, props.columnId, value)
|
|
169
|
+
}
|
|
170
|
+
return (
|
|
171
|
+
<Show
|
|
172
|
+
when={editing()}
|
|
173
|
+
fallback={
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
data-slot="data-table-edit-trigger"
|
|
177
|
+
// The name leads with the visible text: a speech user saying what
|
|
178
|
+
// they see must still land on this control.
|
|
179
|
+
aria-label={`${props.value || '—'} — edit ${props.label} of ${props.id}`}
|
|
180
|
+
class={cn(
|
|
181
|
+
'-mx-1 w-full cursor-text rounded-3 px-1 py-0.5 text-start hover:bg-surface-gray-2 focus-visible:focus-ring',
|
|
182
|
+
props.alignEnd && 'text-end',
|
|
183
|
+
)}
|
|
184
|
+
onClick={() => {
|
|
185
|
+
setDraft(props.value)
|
|
186
|
+
setEditing(true)
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
<Show
|
|
190
|
+
when={props.value !== ''}
|
|
191
|
+
fallback={<span class="text-ink-gray-5">—</span>}
|
|
192
|
+
>
|
|
193
|
+
{props.value}
|
|
194
|
+
</Show>
|
|
195
|
+
</button>
|
|
196
|
+
}
|
|
197
|
+
>
|
|
198
|
+
<TextInput
|
|
199
|
+
aria-label={`Edit ${props.label} of ${props.id}`}
|
|
200
|
+
// The editor opens on the user's click; focus belongs there.
|
|
201
|
+
ref={(el) => queueMicrotask(() => el.focus())}
|
|
202
|
+
type={props.type}
|
|
203
|
+
value={draft()}
|
|
204
|
+
size="xs"
|
|
205
|
+
onInput={(event) => setDraft(event.currentTarget.value)}
|
|
206
|
+
onBlur={() => commit(props.value)}
|
|
207
|
+
onKeyDown={(event) => {
|
|
208
|
+
if (event.key === 'Enter') commit(props.value)
|
|
209
|
+
if (event.key === 'Escape') setEditing(false)
|
|
210
|
+
}}
|
|
211
|
+
class="min-w-0"
|
|
212
|
+
/>
|
|
213
|
+
</Show>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A collection as a table: search, sort, page, hide columns, select rows,
|
|
219
|
+
* pin one column to each scroll edge, edit cells in place.
|
|
117
220
|
*
|
|
118
221
|
* TanStack Table is the engine; this is the kit's composition of it. The surface
|
|
119
222
|
* lives in `@talelk/newui-organisms`: it composes controls and owns the TanStack Table
|
|
@@ -152,6 +255,38 @@ export function DataTable<T extends { id: string }>(
|
|
|
152
255
|
const resolvedColumns = createMemo(() =>
|
|
153
256
|
selectable() ? [selection, ...props.columns] : props.columns,
|
|
154
257
|
)
|
|
258
|
+
const [pinned] = createControllableSignal<DataTablePinned>({
|
|
259
|
+
value: () => ({
|
|
260
|
+
start: props.pinnedStart,
|
|
261
|
+
end: props.pinnedEnd,
|
|
262
|
+
}),
|
|
263
|
+
defaultValue: {
|
|
264
|
+
start: props.defaultPinnedStart,
|
|
265
|
+
end: props.defaultPinnedEnd,
|
|
266
|
+
},
|
|
267
|
+
onChange: (next) => props.onPinnedColumnsChange?.(next),
|
|
268
|
+
})
|
|
269
|
+
const editable = () =>
|
|
270
|
+
props.editable === true && props.onCellChange !== undefined
|
|
271
|
+
const editMeta = (meta: unknown) => {
|
|
272
|
+
const columnMeta = meta as DataTableColumnMeta | undefined
|
|
273
|
+
return columnMeta?.editable === true
|
|
274
|
+
? { input: columnMeta.editInput ?? 'text' }
|
|
275
|
+
: undefined
|
|
276
|
+
}
|
|
277
|
+
/** Which edge (if any) a column id is frozen to. */
|
|
278
|
+
const pinOf = (columnId: string): 'start' | 'end' | undefined => {
|
|
279
|
+
if (pinned().start === columnId) return 'start'
|
|
280
|
+
if (pinned().end === columnId) return 'end'
|
|
281
|
+
return undefined
|
|
282
|
+
}
|
|
283
|
+
/** Sticky cell chrome: opaque card surface over the scrolling columns. */
|
|
284
|
+
const pinClass = (edge: 'start' | 'end' | undefined) =>
|
|
285
|
+
edge === 'start'
|
|
286
|
+
? 'sticky start-0 z-raised bg-surface-elevation-1 group-hover/row:bg-surface-gray-1 data-[selected]:bg-surface-blue-1 data-[selected]:group-hover/row:bg-surface-blue-2 border-e border-outline-gray-1'
|
|
287
|
+
: edge === 'end'
|
|
288
|
+
? 'sticky end-0 z-raised bg-surface-elevation-1 group-hover/row:bg-surface-gray-1 data-[selected]:bg-surface-blue-1 data-[selected]:group-hover/row:bg-surface-blue-2 border-s border-outline-gray-1'
|
|
289
|
+
: ''
|
|
155
290
|
const table = createTable<Features, T>({
|
|
156
291
|
features: createFeatures(),
|
|
157
292
|
get data() {
|
|
@@ -160,6 +295,15 @@ export function DataTable<T extends { id: string }>(
|
|
|
160
295
|
get columns() {
|
|
161
296
|
return resolvedColumns()
|
|
162
297
|
},
|
|
298
|
+
get state() {
|
|
299
|
+
const pinnedValue = pinned()
|
|
300
|
+
return {
|
|
301
|
+
columnPinning: {
|
|
302
|
+
start: pinnedValue.start ? [pinnedValue.start] : [],
|
|
303
|
+
end: pinnedValue.end ? [pinnedValue.end] : [],
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
},
|
|
163
307
|
getRowId: (row) => row.id,
|
|
164
308
|
get enableRowSelection() {
|
|
165
309
|
return (row: { original: T }) =>
|
|
@@ -455,11 +599,12 @@ export function DataTable<T extends { id: string }>(
|
|
|
455
599
|
{(header) => (
|
|
456
600
|
<th
|
|
457
601
|
scope="col"
|
|
458
|
-
class={
|
|
602
|
+
class={cn(
|
|
459
603
|
alignEnd(header.column.columnDef.meta)
|
|
460
604
|
? `${cellPad()} text-end`
|
|
461
|
-
: `${cellPad()} text-start
|
|
462
|
-
|
|
605
|
+
: `${cellPad()} text-start`,
|
|
606
|
+
pinClass(pinOf(header.column.id)),
|
|
607
|
+
)}
|
|
463
608
|
aria-sort={
|
|
464
609
|
header.column.getIsSorted() === 'asc'
|
|
465
610
|
? 'ascending'
|
|
@@ -513,24 +658,61 @@ export function DataTable<T extends { id: string }>(
|
|
|
513
658
|
<For each={table.getRowModel().rows}>
|
|
514
659
|
{(row) => (
|
|
515
660
|
<tr
|
|
516
|
-
class="border-b border-outline-gray-1 hover:bg-surface-gray-1 data-[selected]:bg-surface-blue-1 data-[selected]:hover:bg-surface-blue-2"
|
|
661
|
+
class="group/row border-b border-outline-gray-1 hover:bg-surface-gray-1 data-[selected]:bg-surface-blue-1 data-[selected]:hover:bg-surface-blue-2"
|
|
517
662
|
data-selected={row.getIsSelected() ? '' : undefined}
|
|
518
663
|
>
|
|
519
664
|
<For each={row.getVisibleCells()}>
|
|
520
|
-
{(cell) =>
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
665
|
+
{(cell) => {
|
|
666
|
+
const edge = pinOf(cell.column.id)
|
|
667
|
+
const editingMeta = editable()
|
|
668
|
+
? editMeta(cell.column.columnDef.meta)
|
|
669
|
+
: undefined
|
|
670
|
+
const raw = (
|
|
671
|
+
row.original as Record<string, unknown>
|
|
672
|
+
)[cell.column.id]
|
|
673
|
+
return (
|
|
674
|
+
<td
|
|
675
|
+
class={cn(
|
|
676
|
+
alignEnd(cell.column.columnDef.meta)
|
|
677
|
+
? `${cellPad()} text-end text-interface`
|
|
678
|
+
: `${cellPad()} text-interface`,
|
|
679
|
+
pinClass(edge),
|
|
680
|
+
)}
|
|
681
|
+
data-selected={
|
|
682
|
+
row.getIsSelected() ? '' : undefined
|
|
683
|
+
}
|
|
684
|
+
>
|
|
685
|
+
<Show
|
|
686
|
+
when={editingMeta}
|
|
687
|
+
fallback={flexRender(
|
|
688
|
+
cell.column.columnDef.cell,
|
|
689
|
+
cell.getContext(),
|
|
690
|
+
)}
|
|
691
|
+
>
|
|
692
|
+
<EditableCell
|
|
693
|
+
id={row.original.id}
|
|
694
|
+
columnId={cell.column.id}
|
|
695
|
+
value={raw == null ? '' : String(raw)}
|
|
696
|
+
label={columnLabel(
|
|
697
|
+
cell.column as unknown as {
|
|
698
|
+
id: string
|
|
699
|
+
columnDef: { header?: unknown }
|
|
700
|
+
},
|
|
701
|
+
)}
|
|
702
|
+
type={
|
|
703
|
+
editingMeta?.input === 'number'
|
|
704
|
+
? 'number'
|
|
705
|
+
: 'text'
|
|
706
|
+
}
|
|
707
|
+
alignEnd={alignEnd(
|
|
708
|
+
cell.column.columnDef.meta,
|
|
709
|
+
)}
|
|
710
|
+
onCommit={props.onCellChange ?? (() => {})}
|
|
711
|
+
/>
|
|
712
|
+
</Show>
|
|
713
|
+
</td>
|
|
714
|
+
)
|
|
715
|
+
}}
|
|
534
716
|
</For>
|
|
535
717
|
</tr>
|
|
536
718
|
)}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import axe, { type AxeResults } from 'axe-core'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { DetailHeader } from './DetailHeader'
|
|
5
|
+
|
|
6
|
+
const AXE_OPTIONS = {
|
|
7
|
+
rules: {
|
|
8
|
+
'color-contrast': { enabled: false },
|
|
9
|
+
region: { enabled: false },
|
|
10
|
+
},
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function summarize(results: AxeResults) {
|
|
14
|
+
return results.violations.map((violation) => ({
|
|
15
|
+
id: violation.id,
|
|
16
|
+
impact: violation.impact,
|
|
17
|
+
targets: violation.nodes.map((node) => node.target),
|
|
18
|
+
}))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('DetailHeader accessibility', () => {
|
|
22
|
+
it('has no axe violations for a record header', async () => {
|
|
23
|
+
const { container } = render(() => (
|
|
24
|
+
<DetailHeader
|
|
25
|
+
breadcrumbs={[
|
|
26
|
+
{ label: 'Files', route: '/files' },
|
|
27
|
+
{ label: 'Reports', route: '/files/reports' },
|
|
28
|
+
{ label: 'Q3' },
|
|
29
|
+
]}
|
|
30
|
+
title="Q3 report.pdf"
|
|
31
|
+
subtitle="1.2 MB · updated 2h ago"
|
|
32
|
+
status={{ label: 'Shared', theme: 'blue' }}
|
|
33
|
+
actions={[
|
|
34
|
+
{ label: 'Share', onClick: () => {} },
|
|
35
|
+
{ label: 'Download', onClick: () => {} },
|
|
36
|
+
]}
|
|
37
|
+
/>
|
|
38
|
+
))
|
|
39
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('has no axe violations with a lower heading level', async () => {
|
|
43
|
+
const { container } = render(() => (
|
|
44
|
+
<DetailHeader
|
|
45
|
+
title="INV-1042"
|
|
46
|
+
headingLevel={2}
|
|
47
|
+
subtitle="Unpaid · due Oct 30"
|
|
48
|
+
actions={[{ label: 'Edit', onClick: () => {} }]}
|
|
49
|
+
/>
|
|
50
|
+
))
|
|
51
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { DetailHeader } from './DetailHeader'
|
|
4
|
+
|
|
5
|
+
describe('DetailHeader (browser, real CSS)', () => {
|
|
6
|
+
it('sets the title in the metric scale above the muted subtitle', () => {
|
|
7
|
+
const { container } = render(() => (
|
|
8
|
+
<DetailHeader title="Q3 report.pdf" subtitle="1.2 MB · updated 2h ago" />
|
|
9
|
+
))
|
|
10
|
+
const title = container.querySelector('h1') as HTMLElement
|
|
11
|
+
const subtitle = container.querySelector('p') as HTMLElement
|
|
12
|
+
// `text-metric` is 1.75rem — the largest type on a record page.
|
|
13
|
+
expect(Number.parseFloat(getComputedStyle(title).fontSize)).toBeGreaterThan(
|
|
14
|
+
24,
|
|
15
|
+
)
|
|
16
|
+
expect(Number.parseFloat(getComputedStyle(subtitle).fontSize)).toBeLessThan(
|
|
17
|
+
Number.parseFloat(getComputedStyle(title).fontSize),
|
|
18
|
+
)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('keeps the action buttons real, with the primary one painted', () => {
|
|
22
|
+
const { container } = render(() => (
|
|
23
|
+
<DetailHeader
|
|
24
|
+
title="Report"
|
|
25
|
+
actions={[{ label: 'Share' }, { label: 'Download' }]}
|
|
26
|
+
/>
|
|
27
|
+
))
|
|
28
|
+
const buttons = Array.from(container.querySelectorAll('button'))
|
|
29
|
+
const primary = buttons.at(-1) as HTMLElement
|
|
30
|
+
expect(primary.getBoundingClientRect().height).toBeGreaterThanOrEqual(24)
|
|
31
|
+
// `variant="solid"` must paint — a transparent primary is an invisible CTA.
|
|
32
|
+
expect(getComputedStyle(primary).backgroundColor).not.toBe(
|
|
33
|
+
'rgba(0, 0, 0, 0)',
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('draws the status pill as a rounded chip', () => {
|
|
38
|
+
const { container } = render(() => (
|
|
39
|
+
<DetailHeader
|
|
40
|
+
title="Report"
|
|
41
|
+
status={{ label: 'Shared', theme: 'blue' }}
|
|
42
|
+
/>
|
|
43
|
+
))
|
|
44
|
+
const badge = container.querySelector('[data-slot="badge"]') as HTMLElement
|
|
45
|
+
const style = getComputedStyle(badge)
|
|
46
|
+
expect(style.borderRadius).not.toBe('0px')
|
|
47
|
+
expect(style.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
|
|
48
|
+
expect(badge.getBoundingClientRect().height).toBeGreaterThan(0)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import axe, { type AxeResults } from 'axe-core'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { FilterBar } from './FilterBar'
|
|
5
|
+
|
|
6
|
+
const AXE_OPTIONS = {
|
|
7
|
+
rules: {
|
|
8
|
+
'color-contrast': { enabled: false },
|
|
9
|
+
region: { enabled: false },
|
|
10
|
+
},
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function summarize(results: AxeResults) {
|
|
14
|
+
return results.violations.map((violation) => ({
|
|
15
|
+
id: violation.id,
|
|
16
|
+
impact: violation.impact,
|
|
17
|
+
targets: violation.nodes.map((node) => node.target),
|
|
18
|
+
}))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('FilterBar accessibility', () => {
|
|
22
|
+
it('has no axe violations for a fully loaded bar', async () => {
|
|
23
|
+
const { container } = render(() => (
|
|
24
|
+
<FilterBar
|
|
25
|
+
query={{ placeholder: 'Search expenses', value: 'taxi' }}
|
|
26
|
+
selects={[
|
|
27
|
+
{
|
|
28
|
+
label: 'Owner',
|
|
29
|
+
value: 'me',
|
|
30
|
+
options: [
|
|
31
|
+
{ label: 'Anyone', value: 'any' },
|
|
32
|
+
{ label: 'Me', value: 'me' },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
]}
|
|
36
|
+
tabs={{
|
|
37
|
+
label: 'View',
|
|
38
|
+
value: 'list',
|
|
39
|
+
options: [
|
|
40
|
+
{ label: 'List', value: 'list' },
|
|
41
|
+
{ label: 'Board', value: 'board' },
|
|
42
|
+
],
|
|
43
|
+
}}
|
|
44
|
+
chips={[{ label: 'Status: Open', onRemove: () => {} }]}
|
|
45
|
+
resultCount={24}
|
|
46
|
+
onClearAll={() => {}}
|
|
47
|
+
/>
|
|
48
|
+
))
|
|
49
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('has no axe violations for a bare bar with only a search box', async () => {
|
|
53
|
+
const { container } = render(() => (
|
|
54
|
+
<FilterBar surface="bare" query={{ placeholder: 'Search tickets' }} />
|
|
55
|
+
))
|
|
56
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { FilterBar } from './FilterBar'
|
|
4
|
+
|
|
5
|
+
describe('FilterBar (browser, real CSS)', () => {
|
|
6
|
+
it('puts the controls on one painted card surface, or none when bare', () => {
|
|
7
|
+
const card = render(() => <FilterBar query={{ placeholder: 'Search' }} />)
|
|
8
|
+
const bar = card.container.querySelector(
|
|
9
|
+
'[data-slot="filter-bar"]',
|
|
10
|
+
) as HTMLElement
|
|
11
|
+
const style = getComputedStyle(bar)
|
|
12
|
+
expect(style.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
|
|
13
|
+
expect(Number.parseFloat(style.borderTopWidth)).toBeGreaterThan(0)
|
|
14
|
+
expect(Number.parseFloat(style.borderRadius)).toBeGreaterThan(0)
|
|
15
|
+
expect(style.paddingTop).not.toBe('0px')
|
|
16
|
+
card.unmount()
|
|
17
|
+
|
|
18
|
+
const bare = render(() => (
|
|
19
|
+
<FilterBar surface="bare" query={{ placeholder: 'Search' }} />
|
|
20
|
+
))
|
|
21
|
+
const bareBar = bare.container.querySelector(
|
|
22
|
+
'[data-slot="filter-bar"]',
|
|
23
|
+
) as HTMLElement
|
|
24
|
+
// `bare` is composed into a Section, so it must not paint its own card.
|
|
25
|
+
expect(getComputedStyle(bareBar).backgroundColor).toBe('rgba(0, 0, 0, 0)')
|
|
26
|
+
expect(getComputedStyle(bareBar).paddingTop).toBe('0px')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('paints the / hint on the search box', () => {
|
|
30
|
+
const { container } = render(() => (
|
|
31
|
+
<FilterBar query={{ placeholder: 'Search tickets' }} />
|
|
32
|
+
))
|
|
33
|
+
const hint = container.querySelector('kbd') as HTMLElement
|
|
34
|
+
expect(hint.getBoundingClientRect().width).toBeGreaterThan(0)
|
|
35
|
+
expect(getComputedStyle(hint).backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('states the result count in tabular figures', () => {
|
|
39
|
+
const { container } = render(() => <FilterBar resultCount={24} />)
|
|
40
|
+
const count = container.querySelector(
|
|
41
|
+
'[data-slot="filter-bar-meta"] span',
|
|
42
|
+
) as HTMLElement
|
|
43
|
+
// `tabular-nums` keeps 24 and 8 aligned as filters change the number.
|
|
44
|
+
expect(getComputedStyle(count).fontVariantNumeric).toContain('tabular-nums')
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import axe, { type AxeResults } from 'axe-core'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { KanbanBoard, type KanbanColumn } from './KanbanBoard'
|
|
5
|
+
|
|
6
|
+
const AXE_OPTIONS = {
|
|
7
|
+
rules: {
|
|
8
|
+
'color-contrast': { enabled: false },
|
|
9
|
+
region: { enabled: false },
|
|
10
|
+
},
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function summarize(results: AxeResults) {
|
|
14
|
+
return results.violations.map((violation) => ({
|
|
15
|
+
id: violation.id,
|
|
16
|
+
impact: violation.impact,
|
|
17
|
+
targets: violation.nodes.map((node) => node.target),
|
|
18
|
+
}))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Deal {
|
|
22
|
+
id: string
|
|
23
|
+
name: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const deals: Deal[] = [
|
|
27
|
+
{ id: 'D-1', name: 'Globex' },
|
|
28
|
+
{ id: 'D-2', name: 'Stark' },
|
|
29
|
+
{ id: 'D-3', name: 'Wayne' },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
function stages(): KanbanColumn<Deal>[] {
|
|
33
|
+
return [
|
|
34
|
+
{ id: 'open', name: 'Open', theme: 'gray', items: deals },
|
|
35
|
+
{ id: 'won', name: 'Won', theme: 'green', items: [] },
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const card = (deal: Deal) => <span>{deal.name}</span>
|
|
40
|
+
|
|
41
|
+
describe('KanbanBoard accessibility', () => {
|
|
42
|
+
it('has no axe violations for a board with cards and an empty column', async () => {
|
|
43
|
+
const { container } = render(() => (
|
|
44
|
+
<KanbanBoard
|
|
45
|
+
columns={stages()}
|
|
46
|
+
label="Deals"
|
|
47
|
+
itemLabel={(deal) => deal.name}
|
|
48
|
+
>
|
|
49
|
+
{card}
|
|
50
|
+
</KanbanBoard>
|
|
51
|
+
))
|
|
52
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('has no axe violations with a column summary, action and empty copy', async () => {
|
|
56
|
+
const { container } = render(() => (
|
|
57
|
+
<KanbanBoard
|
|
58
|
+
label="Deals"
|
|
59
|
+
columns={[
|
|
60
|
+
{
|
|
61
|
+
id: 'open',
|
|
62
|
+
name: 'Open',
|
|
63
|
+
items: [],
|
|
64
|
+
summary: <span>$210,000</span>,
|
|
65
|
+
action: { label: 'Add deal', onClick: () => {} },
|
|
66
|
+
},
|
|
67
|
+
]}
|
|
68
|
+
emptyColumn={() => <span>Nothing open.</span>}
|
|
69
|
+
>
|
|
70
|
+
{card}
|
|
71
|
+
</KanbanBoard>
|
|
72
|
+
))
|
|
73
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { KanbanBoard, type KanbanColumn } from './KanbanBoard'
|
|
4
|
+
|
|
5
|
+
interface Deal {
|
|
6
|
+
id: string
|
|
7
|
+
name: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const deals: Deal[] = [
|
|
11
|
+
{ id: 'D-1', name: 'Globex' },
|
|
12
|
+
{ id: 'D-2', name: 'Stark' },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
function stages(): KanbanColumn<Deal>[] {
|
|
16
|
+
return [
|
|
17
|
+
{ id: 'open', name: 'Open', theme: 'gray', items: deals },
|
|
18
|
+
{ id: 'won', name: 'Won', theme: 'green', items: [] },
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const card = (deal: Deal) => <span>{deal.name}</span>
|
|
23
|
+
|
|
24
|
+
describe('KanbanBoard (browser, real CSS)', () => {
|
|
25
|
+
it('reserves a real column width on a tinted, bordered surface', () => {
|
|
26
|
+
const { container } = render(() => (
|
|
27
|
+
<KanbanBoard columns={stages()} label="Deals">
|
|
28
|
+
{card}
|
|
29
|
+
</KanbanBoard>
|
|
30
|
+
))
|
|
31
|
+
const column = container.querySelector(
|
|
32
|
+
'[data-kanban-column="open"]',
|
|
33
|
+
) as HTMLElement
|
|
34
|
+
// `w-72` (288px) below `min-[1440px]` — the board is a panner, columns do
|
|
35
|
+
// not shrink to fit.
|
|
36
|
+
const width = column.getBoundingClientRect().width
|
|
37
|
+
expect(width).toBeGreaterThanOrEqual(256)
|
|
38
|
+
expect(width).toBeLessThanOrEqual(300)
|
|
39
|
+
const style = getComputedStyle(column)
|
|
40
|
+
expect(Number.parseFloat(style.borderTopWidth)).toBeGreaterThan(0)
|
|
41
|
+
expect(style.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('paints the status dot in the column theme', () => {
|
|
45
|
+
const { container } = render(() => (
|
|
46
|
+
<KanbanBoard columns={stages()} label="Deals">
|
|
47
|
+
{card}
|
|
48
|
+
</KanbanBoard>
|
|
49
|
+
))
|
|
50
|
+
const dot = container.querySelector(
|
|
51
|
+
'[data-kanban-column="open"] span[aria-hidden="true"]',
|
|
52
|
+
) as HTMLElement
|
|
53
|
+
const rect = dot.getBoundingClientRect()
|
|
54
|
+
expect(rect.width).toBeGreaterThan(0)
|
|
55
|
+
expect(getComputedStyle(dot).backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('gives each card the grab cursor and a real box', () => {
|
|
59
|
+
const { container } = render(() => (
|
|
60
|
+
<KanbanBoard columns={stages()} label="Deals">
|
|
61
|
+
{card}
|
|
62
|
+
</KanbanBoard>
|
|
63
|
+
))
|
|
64
|
+
const cardBox = container.querySelector(
|
|
65
|
+
'[data-kanban-id="D-1"] > [data-slot="card"]',
|
|
66
|
+
) as HTMLElement
|
|
67
|
+
expect(cardBox.getBoundingClientRect().height).toBeGreaterThan(0)
|
|
68
|
+
expect(getComputedStyle(cardBox).cursor).toBe('grab')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('draws the empty column as a dashed drop hint', () => {
|
|
72
|
+
const { container } = render(() => (
|
|
73
|
+
<KanbanBoard columns={stages()} label="Deals">
|
|
74
|
+
{card}
|
|
75
|
+
</KanbanBoard>
|
|
76
|
+
))
|
|
77
|
+
const empty = container.querySelector(
|
|
78
|
+
'[data-kanban-column="won"] ul li',
|
|
79
|
+
) as HTMLElement
|
|
80
|
+
const style = getComputedStyle(empty)
|
|
81
|
+
expect(style.borderTopStyle).toBe('dashed')
|
|
82
|
+
expect(Number.parseFloat(style.borderTopWidth)).toBeGreaterThan(0)
|
|
83
|
+
})
|
|
84
|
+
})
|