@talelk/newui-organisms 2.0.0 → 2.1.1
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 +206 -21
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@talelk/newui-organisms",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Application regions, sidebars, tables and widgets built from atoms and molecules.",
|
|
6
6
|
"exports": {
|
|
@@ -11,11 +11,12 @@
|
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
|
-
"src"
|
|
14
|
+
"src",
|
|
15
|
+
"skills/newui-organisms"
|
|
15
16
|
],
|
|
16
17
|
"dependencies": {
|
|
17
|
-
"@talelk/newui-atoms": "1.0
|
|
18
|
-
"@talelk/newui-molecules": "2.
|
|
18
|
+
"@talelk/newui-atoms": "1.2.0",
|
|
19
|
+
"@talelk/newui-molecules": "2.2.0",
|
|
19
20
|
"@tanstack/solid-table": "^9.3.1",
|
|
20
21
|
"@tanstack/solid-virtual": "^3.13.47"
|
|
21
22
|
},
|
|
@@ -35,6 +36,9 @@
|
|
|
35
36
|
"url": "git+https://github.com/talelk/newui.git",
|
|
36
37
|
"directory": "packages/organisms"
|
|
37
38
|
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"tanstack-intent"
|
|
41
|
+
],
|
|
38
42
|
"scripts": {
|
|
39
43
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
40
44
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: newui-organisms
|
|
3
|
+
description: >
|
|
4
|
+
Use when composing page regions with @talelk/newui-organisms — DataTable
|
|
5
|
+
(search, sort, pagination, selection, column pinning, inline editing),
|
|
6
|
+
AppSidebar, Sidebar, FilterBar, StatGrid, KanbanBoard, VirtualList,
|
|
7
|
+
CommandPalette, DetailHeader, Section, SelectionBar, DataState, MobileNav,
|
|
8
|
+
AuthCard, SettingsDialog. Use when the user asks for a data table, app
|
|
9
|
+
sidebar, board, KPI grid or command palette in newui.
|
|
10
|
+
metadata:
|
|
11
|
+
type: core
|
|
12
|
+
domain: ui
|
|
13
|
+
library: '@talelk/newui-organisms'
|
|
14
|
+
library_version: '2.1.1'
|
|
15
|
+
sources:
|
|
16
|
+
- talelk/newui:packages/organisms/src/organisms/DataTable/DataTable.tsx
|
|
17
|
+
- talelk/newui:packages/organisms/src/organisms/AppSidebar/AppSidebar.tsx
|
|
18
|
+
- talelk/newui:packages/organisms/src/organisms/DataState/DataState.tsx
|
|
19
|
+
- talelk/newui:packages/organisms/src/organisms/SelectionBar/SelectionBar.tsx
|
|
20
|
+
- talelk/newui:spec/data-table.md
|
|
21
|
+
- talelk/newui:spec/sidebar.md
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
# newui organisms
|
|
25
|
+
|
|
26
|
+
Page regions composed from the molecules and atoms. Read the `newui` skill
|
|
27
|
+
for the token vocabulary first.
|
|
28
|
+
|
|
29
|
+
## Setup
|
|
30
|
+
|
|
31
|
+
```tsx no-check
|
|
32
|
+
import { DataTable } from '@talelk/newui-organisms'
|
|
33
|
+
import { createSignal } from 'solid-js'
|
|
34
|
+
|
|
35
|
+
interface Row { id: string; title: string; amount: number }
|
|
36
|
+
|
|
37
|
+
export function Ledger(props: { rows: Row[] }) {
|
|
38
|
+
return (
|
|
39
|
+
<DataTable
|
|
40
|
+
title="Ledger"
|
|
41
|
+
rows={props.rows}
|
|
42
|
+
columns={[
|
|
43
|
+
{ accessorKey: 'id', header: 'Ref' },
|
|
44
|
+
{ accessorKey: 'title', header: 'Title' },
|
|
45
|
+
{ accessorKey: 'amount', header: 'Amount', meta: { align: 'end' } },
|
|
46
|
+
]}
|
|
47
|
+
actionLabel="Export selected"
|
|
48
|
+
onRowsAction={(ids) => exportRows(ids)}
|
|
49
|
+
/>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Core patterns
|
|
55
|
+
|
|
56
|
+
**Selection exists to feed the bulk action.** Checkboxes render only when
|
|
57
|
+
`onRowsAction` is set; a read table sets `selectable={false}` (or omits the
|
|
58
|
+
action).
|
|
59
|
+
|
|
60
|
+
**App-owned states: pinning, edits, data.** `pinnedStart`/`pinnedEnd` freeze
|
|
61
|
+
one column per scroll edge; pinning changes go through
|
|
62
|
+
`onPinnedColumnsChange` — the table never mutates it. With `editable` +
|
|
63
|
+
`onCellChange`, columns marked `meta.editable` render click-to-edit cells;
|
|
64
|
+
edits commit through the callback and the table shows whatever the next
|
|
65
|
+
`rows` carry. The table never mutates data itself.
|
|
66
|
+
|
|
67
|
+
**Loading, empty and error come from `DataState`, not ad-hoc markup.** Pass
|
|
68
|
+
`loading`, `error` + `onRetry` to `DataTable` (or wrap custom regions in
|
|
69
|
+
`DataState`); the shared surfaces keep the sweep's contracts.
|
|
70
|
+
|
|
71
|
+
**One sidebar model.** `AppShell` (templates) renders `AppSidebar` for you —
|
|
72
|
+
do not mount `Sidebar` beside it. Compose `Sidebar` directly only in pages
|
|
73
|
+
that are not `AppShell`-shaped.
|
|
74
|
+
|
|
75
|
+
## Common mistakes
|
|
76
|
+
|
|
77
|
+
**Don't nest AppShell and Sidebar.** Two navigation regions fail the a11y
|
|
78
|
+
sweep's `no-duplicate-controls` rule.
|
|
79
|
+
|
|
80
|
+
**Don't format values in editable columns' raw fields.** `meta.editable`
|
|
81
|
+
edits the row's raw field; the formatted `cell` render returns after commit.
|
|
82
|
+
Editing a formatted string would corrupt it.
|
|
83
|
+
|
|
84
|
+
**Don't build sticky columns by hand.** Pinning already carries the sticky
|
|
85
|
+
chrome, divider and hover/selected tint; a hand-rolled `sticky` cell will
|
|
86
|
+
miss the row tint and fail the visual contract.
|
|
87
|
+
|
|
88
|
+
## Completion
|
|
89
|
+
|
|
90
|
+
`pnpm verify:affected` covers the organisms suites; `spec/data-table.md` and
|
|
91
|
+
`spec/sidebar.md` are the contracts for the two largest regions.
|
package/src/exports.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ const PUBLIC = [
|
|
|
5
5
|
'AppSidebar',
|
|
6
6
|
'AuthCard',
|
|
7
7
|
'CommandPalette',
|
|
8
|
+
'DataState',
|
|
8
9
|
'DataTable',
|
|
9
10
|
'DetailHeader',
|
|
10
11
|
'FilterBar',
|
|
@@ -12,6 +13,8 @@ const PUBLIC = [
|
|
|
12
13
|
'MobileNav',
|
|
13
14
|
'PageToolbar',
|
|
14
15
|
'SettingsDialog',
|
|
16
|
+
'Section',
|
|
17
|
+
'SelectionBar',
|
|
15
18
|
'Sidebar',
|
|
16
19
|
'StatGrid',
|
|
17
20
|
'VirtualList',
|
|
@@ -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 { SidebarItem } from '../Sidebar'
|
|
5
|
+
import { AppSidebar } from './AppSidebar'
|
|
6
|
+
|
|
7
|
+
const AXE_OPTIONS = {
|
|
8
|
+
rules: {
|
|
9
|
+
'color-contrast': { enabled: false },
|
|
10
|
+
region: { enabled: false },
|
|
11
|
+
},
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function summarize(results: AxeResults) {
|
|
15
|
+
return results.violations.map((violation) => ({
|
|
16
|
+
id: violation.id,
|
|
17
|
+
impact: violation.impact,
|
|
18
|
+
targets: violation.nodes.map((node) => node.target),
|
|
19
|
+
}))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function workspace(props: { collapsed?: boolean } = {}) {
|
|
23
|
+
return (
|
|
24
|
+
<AppSidebar
|
|
25
|
+
brand={<span>Acme workspace</span>}
|
|
26
|
+
brandCollapsed={
|
|
27
|
+
<span>
|
|
28
|
+
<span class="sr-only">Acme home</span>A
|
|
29
|
+
</span>
|
|
30
|
+
}
|
|
31
|
+
tenant={<button type="button">Acme · Production</button>}
|
|
32
|
+
tenantCollapsed={
|
|
33
|
+
<button type="button" aria-label="Switch tenant">
|
|
34
|
+
A
|
|
35
|
+
</button>
|
|
36
|
+
}
|
|
37
|
+
collapsed={props.collapsed}
|
|
38
|
+
groups={[
|
|
39
|
+
{
|
|
40
|
+
label: 'Workspace',
|
|
41
|
+
items: [
|
|
42
|
+
{ label: 'Projects', icon: 'lucide-folder-kanban', active: true },
|
|
43
|
+
{ label: 'Customers', icon: 'lucide-users', count: 4 },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
label: 'Reports',
|
|
48
|
+
items: [{ label: 'Revenue', icon: 'lucide-chart-no-axes-combined' }],
|
|
49
|
+
},
|
|
50
|
+
]}
|
|
51
|
+
settings={
|
|
52
|
+
<SidebarItem label="Settings" icon="lucide-settings">
|
|
53
|
+
Settings
|
|
54
|
+
</SidebarItem>
|
|
55
|
+
}
|
|
56
|
+
account={
|
|
57
|
+
<SidebarItem label="Rhea Kapoor" icon="lucide-user-round">
|
|
58
|
+
Rhea Kapoor
|
|
59
|
+
</SidebarItem>
|
|
60
|
+
}
|
|
61
|
+
/>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('AppSidebar accessibility', () => {
|
|
66
|
+
it('has no axe violations for the expanded navigation', async () => {
|
|
67
|
+
const { container } = render(() => workspace())
|
|
68
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('has no axe violations for the collapsed rail', async () => {
|
|
72
|
+
const { container } = render(() => workspace({ collapsed: true }))
|
|
73
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -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 { CommandGroup, CommandItem, CommandPalette } from './CommandPalette'
|
|
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('CommandPalette accessibility', () => {
|
|
22
|
+
it('has no axe violations for an open palette of grouped commands', async () => {
|
|
23
|
+
// The dialog portals to the body, so the audit runs there — the same
|
|
24
|
+
// shape the SettingsDialog a11y test uses.
|
|
25
|
+
render(() => (
|
|
26
|
+
<CommandPalette defaultOpen label="Commands">
|
|
27
|
+
<CommandGroup label="Pages">
|
|
28
|
+
<CommandItem value="intro" icon="lucide-house">
|
|
29
|
+
Introduction
|
|
30
|
+
</CommandItem>
|
|
31
|
+
<CommandItem value="blocks" keywords="patterns sections">
|
|
32
|
+
Blocks
|
|
33
|
+
</CommandItem>
|
|
34
|
+
</CommandGroup>
|
|
35
|
+
<CommandGroup label="Actions">
|
|
36
|
+
<CommandItem value="theme" shortcut="⌘B">
|
|
37
|
+
Toggle theme
|
|
38
|
+
</CommandItem>
|
|
39
|
+
<CommandItem value="soon" disabled>
|
|
40
|
+
Coming soon
|
|
41
|
+
</CommandItem>
|
|
42
|
+
</CommandGroup>
|
|
43
|
+
</CommandPalette>
|
|
44
|
+
))
|
|
45
|
+
expect(summarize(await axe.run(document.body, AXE_OPTIONS))).toEqual([])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('has no axe violations while commands are loading', async () => {
|
|
49
|
+
render(() => (
|
|
50
|
+
<CommandPalette defaultOpen label="Commands" loading>
|
|
51
|
+
<CommandGroup label="Pages">
|
|
52
|
+
<CommandItem value="intro">Introduction</CommandItem>
|
|
53
|
+
</CommandGroup>
|
|
54
|
+
</CommandPalette>
|
|
55
|
+
))
|
|
56
|
+
expect(summarize(await axe.run(document.body, AXE_OPTIONS))).toEqual([])
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import axe, { type AxeResults } from 'axe-core'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { DataState } from './DataState'
|
|
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('DataState accessibility', () => {
|
|
22
|
+
it('has no axe violations while loading', async () => {
|
|
23
|
+
const { container } = render(() => (
|
|
24
|
+
<DataState status="loading" skeleton="cards" />
|
|
25
|
+
))
|
|
26
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('has no axe violations for the empty state', async () => {
|
|
30
|
+
const { container } = render(() => (
|
|
31
|
+
<DataState
|
|
32
|
+
status="empty"
|
|
33
|
+
title="No expenses"
|
|
34
|
+
description="Try a different month."
|
|
35
|
+
action={<button type="button">Clear filters</button>}
|
|
36
|
+
/>
|
|
37
|
+
))
|
|
38
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('has no axe violations for the error state', async () => {
|
|
42
|
+
const { container } = render(() => (
|
|
43
|
+
<DataState status="error" error="The request failed" onRetry={() => {}} />
|
|
44
|
+
))
|
|
45
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('has no axe violations when ready', async () => {
|
|
49
|
+
const { container } = render(() => (
|
|
50
|
+
<DataState status="ready">
|
|
51
|
+
<ul>
|
|
52
|
+
<li>Alpha</li>
|
|
53
|
+
<li>Beta</li>
|
|
54
|
+
</ul>
|
|
55
|
+
</DataState>
|
|
56
|
+
))
|
|
57
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { DataState } from './DataState'
|
|
4
|
+
|
|
5
|
+
function nextFrame() {
|
|
6
|
+
return new Promise((resolve) => requestAnimationFrame(() => resolve(null)))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('DataState (browser, real CSS)', () => {
|
|
10
|
+
it('renders loading skeletons as real painted rows', async () => {
|
|
11
|
+
const { container } = render(() => (
|
|
12
|
+
<DataState status="loading" skeleton="rows" rows={3} />
|
|
13
|
+
))
|
|
14
|
+
await nextFrame()
|
|
15
|
+
const rows = container.querySelectorAll(
|
|
16
|
+
'[data-slot="skeleton"]',
|
|
17
|
+
) as unknown as HTMLElement[]
|
|
18
|
+
expect(rows.length).toBe(3)
|
|
19
|
+
const row = rows[0] as HTMLElement
|
|
20
|
+
// `h-9` per row — a dropped height utility collapses the placeholder.
|
|
21
|
+
expect(row.getBoundingClientRect().height).toBeGreaterThanOrEqual(32)
|
|
22
|
+
// The paint lives on the shimmering line inside the row wrapper.
|
|
23
|
+
const line = row.querySelector('[data-slot="skeleton-line"]') as HTMLElement
|
|
24
|
+
expect(line).toBeTruthy()
|
|
25
|
+
expect(getComputedStyle(line).backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('paints the empty state icon tile', () => {
|
|
29
|
+
const { container } = render(() => (
|
|
30
|
+
<DataState status="empty" title="No expenses" />
|
|
31
|
+
))
|
|
32
|
+
const tile = container.querySelector(
|
|
33
|
+
'[data-slot="empty-state-icon"]',
|
|
34
|
+
) as HTMLElement
|
|
35
|
+
expect(tile.getBoundingClientRect().width).toBeGreaterThan(0)
|
|
36
|
+
expect(getComputedStyle(tile).backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('keeps the retry button a real target', async () => {
|
|
40
|
+
const { container } = render(() => (
|
|
41
|
+
<DataState status="error" error="The request failed" onRetry={() => {}} />
|
|
42
|
+
))
|
|
43
|
+
await nextFrame()
|
|
44
|
+
const button = Array.from(container.querySelectorAll('button')).find(
|
|
45
|
+
(node) => node.textContent === 'Try again',
|
|
46
|
+
) as HTMLElement
|
|
47
|
+
expect(button.getBoundingClientRect().height).toBeGreaterThanOrEqual(24)
|
|
48
|
+
expect(getComputedStyle(button).backgroundColor).not.toBe(
|
|
49
|
+
'rgba(0, 0, 0, 0)',
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import axe, { type AxeResults } from 'axe-core'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { DataTable, type DataTableColumn } from './DataTable'
|
|
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 Row {
|
|
22
|
+
id: string
|
|
23
|
+
name: string
|
|
24
|
+
amount: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ROWS: Row[] = [
|
|
28
|
+
{ id: '1', name: 'Alpha', amount: 10 },
|
|
29
|
+
{ id: '2', name: 'Beta', amount: 20 },
|
|
30
|
+
{ id: '3', name: 'Gamma', amount: 30 },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
const COLUMNS: DataTableColumn<Row>[] = [
|
|
34
|
+
{ accessorKey: 'name', header: 'Name' },
|
|
35
|
+
{
|
|
36
|
+
accessorKey: 'amount',
|
|
37
|
+
header: 'Amount',
|
|
38
|
+
cell: (context) => `$${context.getValue()}`,
|
|
39
|
+
meta: { align: 'end' },
|
|
40
|
+
},
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
describe('DataTable accessibility', () => {
|
|
44
|
+
it('has no axe violations for a selectable table', async () => {
|
|
45
|
+
const { container } = render(() => (
|
|
46
|
+
<DataTable
|
|
47
|
+
title="Expenses"
|
|
48
|
+
rows={ROWS}
|
|
49
|
+
columns={COLUMNS}
|
|
50
|
+
pageSize={10}
|
|
51
|
+
actionLabel="Approve"
|
|
52
|
+
onRowsAction={() => {}}
|
|
53
|
+
recordsLabel="expenses"
|
|
54
|
+
/>
|
|
55
|
+
))
|
|
56
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('has no axe violations for a read-only table', async () => {
|
|
60
|
+
const { container } = render(() => (
|
|
61
|
+
<DataTable
|
|
62
|
+
title="Expenses"
|
|
63
|
+
rows={ROWS}
|
|
64
|
+
columns={COLUMNS}
|
|
65
|
+
pageSize={10}
|
|
66
|
+
selectable={false}
|
|
67
|
+
/>
|
|
68
|
+
))
|
|
69
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { render, screen } from '@solidjs/testing-library'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { createSignal } from 'solid-js'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { DataTable, type DataTableColumn } from './DataTable'
|
|
6
|
+
|
|
7
|
+
interface Row {
|
|
8
|
+
id: string
|
|
9
|
+
name: string
|
|
10
|
+
amount: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ROWS: Row[] = [
|
|
14
|
+
{ id: '1', name: 'Alpha', amount: 10 },
|
|
15
|
+
{ id: '2', name: 'Beta', amount: 20 },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const COLUMNS: DataTableColumn<Row>[] = [
|
|
19
|
+
{ accessorKey: 'name', header: 'Name' },
|
|
20
|
+
{
|
|
21
|
+
accessorKey: 'amount',
|
|
22
|
+
header: 'Amount',
|
|
23
|
+
meta: { align: 'end', editable: true, editInput: 'number' },
|
|
24
|
+
},
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
describe('DataTable pinning', () => {
|
|
28
|
+
it('freezes the named columns to each scroll edge', () => {
|
|
29
|
+
const { container } = render(() => (
|
|
30
|
+
<DataTable
|
|
31
|
+
title="Rows"
|
|
32
|
+
rows={ROWS}
|
|
33
|
+
columns={COLUMNS}
|
|
34
|
+
pageSize={10}
|
|
35
|
+
selectable={false}
|
|
36
|
+
pinnedStart="name"
|
|
37
|
+
pinnedEnd="amount"
|
|
38
|
+
/>
|
|
39
|
+
))
|
|
40
|
+
const head = container.querySelector('thead') as HTMLElement
|
|
41
|
+
const startTh = head.querySelector('th[scope="col"]') as HTMLElement
|
|
42
|
+
expect(startTh.className).toContain('sticky')
|
|
43
|
+
expect(startTh.className).toContain('start-0')
|
|
44
|
+
const endTh = head.querySelectorAll('th[scope="col"]')[1] as HTMLElement
|
|
45
|
+
expect(endTh.className).toContain('end-0')
|
|
46
|
+
// Body cells carry the same stickiness as their header.
|
|
47
|
+
const firstTd = container.querySelector('tbody td') as HTMLElement
|
|
48
|
+
expect(firstTd.className).toContain('sticky')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('leaves unpinned tables in plain flow', () => {
|
|
52
|
+
const { container } = render(() => (
|
|
53
|
+
<DataTable
|
|
54
|
+
title="Rows"
|
|
55
|
+
rows={ROWS}
|
|
56
|
+
columns={COLUMNS}
|
|
57
|
+
pageSize={10}
|
|
58
|
+
selectable={false}
|
|
59
|
+
/>
|
|
60
|
+
))
|
|
61
|
+
const th = container.querySelector('th[scope="col"]') as HTMLElement
|
|
62
|
+
expect(th.className).not.toContain('sticky')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('answers onPinnedColumnsChange only from the app (no in-table UI mutates pinning)', () => {
|
|
66
|
+
const onPinnedColumnsChange = vi.fn()
|
|
67
|
+
render(() => (
|
|
68
|
+
<DataTable
|
|
69
|
+
title="Rows"
|
|
70
|
+
rows={ROWS}
|
|
71
|
+
columns={COLUMNS}
|
|
72
|
+
pageSize={10}
|
|
73
|
+
selectable={false}
|
|
74
|
+
pinnedStart="name"
|
|
75
|
+
onPinnedColumnsChange={onPinnedColumnsChange}
|
|
76
|
+
/>
|
|
77
|
+
))
|
|
78
|
+
// Sorting (the only in-table toggle) does not move pinning.
|
|
79
|
+
const sortButton = screen.getByRole('button', { name: /Name/ })
|
|
80
|
+
userEvent.click(sortButton)
|
|
81
|
+
expect(onPinnedColumnsChange).not.toHaveBeenCalled()
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('DataTable inline editing', () => {
|
|
86
|
+
it('renders a click-to-edit cell that reports the raw value', async () => {
|
|
87
|
+
const onCellChange = vi.fn()
|
|
88
|
+
const { container } = render(() => (
|
|
89
|
+
<DataTable
|
|
90
|
+
title="Rows"
|
|
91
|
+
rows={ROWS}
|
|
92
|
+
columns={COLUMNS}
|
|
93
|
+
pageSize={10}
|
|
94
|
+
selectable={false}
|
|
95
|
+
editable
|
|
96
|
+
onCellChange={onCellChange}
|
|
97
|
+
/>
|
|
98
|
+
))
|
|
99
|
+
const trigger = screen.getByRole('button', {
|
|
100
|
+
name: /— edit Amount of 1$/,
|
|
101
|
+
})
|
|
102
|
+
expect(trigger.textContent).toBe('10')
|
|
103
|
+
await userEvent.click(trigger)
|
|
104
|
+
const input = screen.getByRole('spinbutton', {
|
|
105
|
+
name: 'Edit Amount of 1',
|
|
106
|
+
}) as HTMLInputElement
|
|
107
|
+
expect(document.activeElement).toBe(input)
|
|
108
|
+
await userEvent.clear(input)
|
|
109
|
+
await userEvent.type(input, '15{Enter}')
|
|
110
|
+
expect(onCellChange).toHaveBeenCalledWith('1', 'amount', '15')
|
|
111
|
+
// The edit trigger is gone once editing closes.
|
|
112
|
+
expect(
|
|
113
|
+
container.querySelector('[data-slot="data-table-edit-trigger"]'),
|
|
114
|
+
).toBeTruthy()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('escapes an edit without reporting', async () => {
|
|
118
|
+
const onCellChange = vi.fn()
|
|
119
|
+
render(() => (
|
|
120
|
+
<DataTable
|
|
121
|
+
title="Rows"
|
|
122
|
+
rows={ROWS}
|
|
123
|
+
columns={COLUMNS}
|
|
124
|
+
pageSize={10}
|
|
125
|
+
selectable={false}
|
|
126
|
+
editable
|
|
127
|
+
onCellChange={onCellChange}
|
|
128
|
+
/>
|
|
129
|
+
))
|
|
130
|
+
await userEvent.click(
|
|
131
|
+
screen.getByRole('button', { name: /— edit Amount of 1$/ }),
|
|
132
|
+
)
|
|
133
|
+
await userEvent.keyboard('999{Escape}')
|
|
134
|
+
expect(onCellChange).not.toHaveBeenCalled()
|
|
135
|
+
// The cell shows the untouched value again.
|
|
136
|
+
expect(
|
|
137
|
+
screen.getByRole('button', { name: /— edit Amount of 1$/ }).textContent,
|
|
138
|
+
).toBe('10')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('renders the updated value when the app owns the mutation', async () => {
|
|
142
|
+
const [rows, setRows] = createSignal<Row[]>(ROWS)
|
|
143
|
+
const { container } = render(() => (
|
|
144
|
+
<DataTable
|
|
145
|
+
title="Rows"
|
|
146
|
+
rows={rows()}
|
|
147
|
+
columns={COLUMNS}
|
|
148
|
+
pageSize={10}
|
|
149
|
+
selectable={false}
|
|
150
|
+
editable
|
|
151
|
+
onCellChange={(id, columnId, value) =>
|
|
152
|
+
setRows((current) =>
|
|
153
|
+
current.map((row) =>
|
|
154
|
+
row.id === id ? { ...row, [columnId]: Number(value) } : row,
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
/>
|
|
159
|
+
))
|
|
160
|
+
await userEvent.click(
|
|
161
|
+
screen.getByRole('button', { name: /— edit Amount of 2$/ }),
|
|
162
|
+
)
|
|
163
|
+
const input = screen.getByRole('spinbutton', {
|
|
164
|
+
name: 'Edit Amount of 2',
|
|
165
|
+
}) as HTMLInputElement
|
|
166
|
+
await userEvent.clear(input)
|
|
167
|
+
await userEvent.type(input, '25{Enter}')
|
|
168
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
169
|
+
// The editor closed onto the app-owned new value.
|
|
170
|
+
const body = container.querySelector('tbody') as HTMLElement
|
|
171
|
+
expect(body.textContent).toContain('25')
|
|
172
|
+
expect(
|
|
173
|
+
screen.queryByRole('spinbutton', { name: 'Edit Amount of 2' }),
|
|
174
|
+
).toBeNull()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('renders plain cells when not editable', () => {
|
|
178
|
+
render(() => (
|
|
179
|
+
<DataTable
|
|
180
|
+
title="Rows"
|
|
181
|
+
rows={ROWS}
|
|
182
|
+
columns={COLUMNS}
|
|
183
|
+
pageSize={10}
|
|
184
|
+
selectable={false}
|
|
185
|
+
/>
|
|
186
|
+
))
|
|
187
|
+
expect(
|
|
188
|
+
screen.queryByRole('button', { name: /— edit Amount of 1$/ }),
|
|
189
|
+
).toBeNull()
|
|
190
|
+
})
|
|
191
|
+
})
|