@talelk/newui-organisms 1.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/AppSidebar/AppSidebar.test.tsx +36 -0
- package/src/organisms/AppSidebar/AppSidebar.tsx +57 -12
- package/src/organisms/AuthCard/AuthCard.tsx +41 -32
- package/src/organisms/CommandPalette/CommandPalette.a11y.test.tsx +58 -0
- package/src/organisms/CommandPalette/CommandPalette.tsx +13 -2
- package/src/organisms/DataState/DataState.a11y.test.tsx +59 -0
- package/src/organisms/DataState/DataState.browser.test.tsx +52 -0
- package/src/organisms/DataState/DataState.test.tsx +45 -0
- package/src/organisms/DataState/DataState.tsx +111 -0
- package/src/organisms/DataState/index.ts +6 -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.test.tsx +73 -1
- package/src/organisms/DataTable/DataTable.tsx +609 -149
- package/src/organisms/DetailHeader/DetailHeader.a11y.test.tsx +53 -0
- package/src/organisms/DetailHeader/DetailHeader.browser.test.tsx +50 -0
- package/src/organisms/DetailHeader/DetailHeader.tsx +9 -1
- package/src/organisms/FilterBar/FilterBar.a11y.test.tsx +58 -0
- package/src/organisms/FilterBar/FilterBar.browser.test.tsx +46 -0
- package/src/organisms/FilterBar/FilterBar.test.tsx +2 -1
- package/src/organisms/FilterBar/FilterBar.tsx +24 -5
- package/src/organisms/KanbanBoard/KanbanBoard.a11y.test.tsx +75 -0
- package/src/organisms/KanbanBoard/KanbanBoard.browser.test.tsx +84 -0
- package/src/organisms/KanbanBoard/KanbanBoard.tsx +220 -165
- package/src/organisms/MobileNav/MobileNav.a11y.test.tsx +49 -0
- package/src/organisms/MobileNav/MobileNav.tsx +1 -1
- package/src/organisms/PageToolbar/PageToolbar.tsx +63 -14
- package/src/organisms/Section/Section.a11y.test.tsx +49 -0
- package/src/organisms/Section/Section.browser.test.tsx +63 -0
- package/src/organisms/Section/Section.test.tsx +41 -0
- package/src/organisms/Section/Section.tsx +108 -0
- package/src/organisms/Section/index.ts +1 -0
- package/src/organisms/SelectionBar/SelectionBar.a11y.test.tsx +48 -0
- package/src/organisms/SelectionBar/SelectionBar.browser.test.tsx +57 -0
- package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
- package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
- package/src/organisms/SelectionBar/index.ts +5 -0
- package/src/organisms/SettingsDialog/SettingsDialog.tsx +13 -4
- package/src/organisms/Sidebar/Sidebar.a11y.test.tsx +71 -0
- package/src/organisms/Sidebar/Sidebar.tsx +17 -10
- package/src/organisms/StatGrid/StatGrid.test.tsx +13 -1
- package/src/organisms/StatGrid/StatGrid.tsx +98 -71
- package/src/organisms/VirtualList/VirtualList.a11y.test.tsx +109 -0
- package/src/organisms/VirtualList/VirtualList.tsx +56 -22
- package/src/organisms/index.ts +3 -0
- package/src/organisms/Sidebar/__screenshots__/Sidebar.browser.test.tsx/Sidebar--browser--real-CSS--reserves-its-declared-width-1.png +0 -0
|
@@ -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,45 @@
|
|
|
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 { DataState } from './DataState'
|
|
5
|
+
|
|
6
|
+
describe('DataState', () => {
|
|
7
|
+
it('renders ready children unchanged', () => {
|
|
8
|
+
render(() => (
|
|
9
|
+
<DataState status="ready">
|
|
10
|
+
<p>Rows</p>
|
|
11
|
+
</DataState>
|
|
12
|
+
))
|
|
13
|
+
expect(screen.getByText('Rows')).toBeTruthy()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('announces loading and renders the requested skeleton', () => {
|
|
17
|
+
const { container } = render(() => (
|
|
18
|
+
<DataState status="loading" skeleton="cards" />
|
|
19
|
+
))
|
|
20
|
+
expect(screen.getByRole('status', { name: 'Loading' })).toBeTruthy()
|
|
21
|
+
expect(container.querySelectorAll('[data-slot="skeleton"]').length).toBe(4)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders an empty state action', () => {
|
|
25
|
+
render(() => (
|
|
26
|
+
<DataState
|
|
27
|
+
status="empty"
|
|
28
|
+
title="No expenses"
|
|
29
|
+
action={<button type="button">Clear filters</button>}
|
|
30
|
+
/>
|
|
31
|
+
))
|
|
32
|
+
expect(screen.getByText('No expenses')).toBeTruthy()
|
|
33
|
+
expect(screen.getByRole('button', { name: 'Clear filters' })).toBeTruthy()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('offers retry on error', async () => {
|
|
37
|
+
const onRetry = vi.fn()
|
|
38
|
+
render(() => (
|
|
39
|
+
<DataState status="error" error="The request failed" onRetry={onRetry} />
|
|
40
|
+
))
|
|
41
|
+
expect(screen.getByRole('alert')).toBeTruthy()
|
|
42
|
+
await userEvent.click(screen.getByRole('button', { name: 'Try again' }))
|
|
43
|
+
expect(onRetry).toHaveBeenCalled()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Button, type IconProp, Skeleton } from '@talelk/newui-atoms'
|
|
2
|
+
import { EmptyState } from '@talelk/newui-molecules'
|
|
3
|
+
import { For, type JSX, Show } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
export type DataStatus = 'ready' | 'loading' | 'empty' | 'error'
|
|
6
|
+
export type DataSkeleton = 'rows' | 'cards' | 'chart'
|
|
7
|
+
|
|
8
|
+
export interface DataStateProps {
|
|
9
|
+
status: DataStatus
|
|
10
|
+
/** Skeleton shape while loading. @default 'rows' */
|
|
11
|
+
skeleton?: DataSkeleton
|
|
12
|
+
/** Skeleton row count. @default 5 */
|
|
13
|
+
rows?: number
|
|
14
|
+
title?: JSX.Element
|
|
15
|
+
description?: JSX.Element
|
|
16
|
+
action?: JSX.Element
|
|
17
|
+
/** Icon shown in the empty state. @default 'lucide-inbox' */
|
|
18
|
+
icon?: IconProp
|
|
19
|
+
error?: JSX.Element
|
|
20
|
+
onRetry?: () => void
|
|
21
|
+
children?: JSX.Element
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function Loading(props: { skeleton: DataSkeleton; rows: number }): JSX.Element {
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
data-slot="data-state-loading"
|
|
28
|
+
role="status"
|
|
29
|
+
aria-label="Loading"
|
|
30
|
+
class="p-card-pad"
|
|
31
|
+
>
|
|
32
|
+
<Show when={props.skeleton === 'rows'}>
|
|
33
|
+
<div class="flex flex-col gap-2">
|
|
34
|
+
<For each={Array.from({ length: props.rows })}>
|
|
35
|
+
{() => <Skeleton lines={1} class="h-9" />}
|
|
36
|
+
</For>
|
|
37
|
+
</div>
|
|
38
|
+
</Show>
|
|
39
|
+
<Show when={props.skeleton === 'cards'}>
|
|
40
|
+
<div class="grid grid-cols-1 gap-3 min-[360px]:grid-cols-2 xl:grid-cols-4">
|
|
41
|
+
<For each={Array.from({ length: 4 })}>
|
|
42
|
+
{() => <Skeleton lines={3} class="h-24" />}
|
|
43
|
+
</For>
|
|
44
|
+
</div>
|
|
45
|
+
</Show>
|
|
46
|
+
<Show when={props.skeleton === 'chart'}>
|
|
47
|
+
<Skeleton lines={4} class="h-64" />
|
|
48
|
+
</Show>
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The one loading / empty / error contract for data organisms.
|
|
55
|
+
*
|
|
56
|
+
* It composes the existing `Skeleton` and `EmptyState` molecules, so every
|
|
57
|
+
* region uses the same tone and action placement. `ready` renders its children
|
|
58
|
+
* unchanged.
|
|
59
|
+
*/
|
|
60
|
+
export function DataState(props: DataStateProps): JSX.Element {
|
|
61
|
+
return (
|
|
62
|
+
<Show
|
|
63
|
+
when={props.status === 'ready'}
|
|
64
|
+
fallback={<StateFallback {...props} />}
|
|
65
|
+
>
|
|
66
|
+
{props.children}
|
|
67
|
+
</Show>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function StateFallback(props: DataStateProps): JSX.Element {
|
|
72
|
+
return (
|
|
73
|
+
<Show
|
|
74
|
+
when={props.status === 'loading'}
|
|
75
|
+
fallback={
|
|
76
|
+
<Show
|
|
77
|
+
when={props.status === 'error'}
|
|
78
|
+
fallback={
|
|
79
|
+
<div data-slot="data-state-empty">
|
|
80
|
+
<EmptyState
|
|
81
|
+
icon={props.icon ?? 'lucide-inbox'}
|
|
82
|
+
title={props.title ?? 'Nothing here yet'}
|
|
83
|
+
description={props.description}
|
|
84
|
+
action={props.action}
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
}
|
|
88
|
+
>
|
|
89
|
+
<div data-slot="data-state-error" role="alert">
|
|
90
|
+
<EmptyState
|
|
91
|
+
icon="lucide-triangle-alert"
|
|
92
|
+
title={props.title ?? 'Could not load this'}
|
|
93
|
+
description={props.error ?? props.description}
|
|
94
|
+
action={
|
|
95
|
+
props.onRetry ? (
|
|
96
|
+
<Button variant="outline" onClick={props.onRetry}>
|
|
97
|
+
Try again
|
|
98
|
+
</Button>
|
|
99
|
+
) : (
|
|
100
|
+
props.action
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
/>
|
|
104
|
+
</div>
|
|
105
|
+
</Show>
|
|
106
|
+
}
|
|
107
|
+
>
|
|
108
|
+
<Loading skeleton={props.skeleton ?? 'rows'} rows={props.rows ?? 5} />
|
|
109
|
+
</Show>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -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
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, screen, waitFor } from '@solidjs/testing-library'
|
|
1
|
+
import { render, screen, waitFor, within } from '@solidjs/testing-library'
|
|
2
2
|
import userEvent from '@testing-library/user-event'
|
|
3
3
|
import { describe, expect, it, vi } from 'vitest'
|
|
4
4
|
import { DataTable, type DataTableColumn } from './DataTable'
|
|
@@ -25,6 +25,16 @@ const COLUMNS: DataTableColumn<Row>[] = [
|
|
|
25
25
|
},
|
|
26
26
|
]
|
|
27
27
|
|
|
28
|
+
const MOBILE_COLUMNS: DataTableColumn<Row>[] = [
|
|
29
|
+
{ accessorKey: 'name', header: 'Name', meta: { mobile: 'title' } },
|
|
30
|
+
{
|
|
31
|
+
accessorKey: 'amount',
|
|
32
|
+
header: 'Amount',
|
|
33
|
+
cell: (context) => `$${context.getValue()}`,
|
|
34
|
+
meta: { align: 'end', mobile: 'amount' },
|
|
35
|
+
},
|
|
36
|
+
]
|
|
37
|
+
|
|
28
38
|
function table(props: Partial<Parameters<typeof DataTable<Row>>[0]> = {}) {
|
|
29
39
|
return render(() => (
|
|
30
40
|
<DataTable
|
|
@@ -82,6 +92,68 @@ describe('DataTable', () => {
|
|
|
82
92
|
})
|
|
83
93
|
})
|
|
84
94
|
|
|
95
|
+
it('renders mobile cards from column meta', () => {
|
|
96
|
+
table({ columns: MOBILE_COLUMNS })
|
|
97
|
+
const cards = document.querySelector('[data-slot="data-table-cards"]')
|
|
98
|
+
expect(cards).toBeTruthy()
|
|
99
|
+
expect(within(cards as HTMLElement).getByText('Alpha')).toBeTruthy()
|
|
100
|
+
expect(within(cards as HTMLElement).getByText('$30')).toBeTruthy()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('skips ineligible rows in selection and select-all', async () => {
|
|
104
|
+
const onRowsAction = vi.fn()
|
|
105
|
+
table({
|
|
106
|
+
columns: MOBILE_COLUMNS,
|
|
107
|
+
rowSelectable: (row) => row.id !== '2',
|
|
108
|
+
onRowsAction,
|
|
109
|
+
})
|
|
110
|
+
const tableRegion = screen.getByRole('region', { name: 'Rows table' })
|
|
111
|
+
expect(
|
|
112
|
+
within(tableRegion)
|
|
113
|
+
.getAllByRole('checkbox', { name: 'Select 2' })
|
|
114
|
+
.every((box) => box.hasAttribute('disabled')),
|
|
115
|
+
).toBe(true)
|
|
116
|
+
await userEvent.click(
|
|
117
|
+
within(tableRegion).getByRole('checkbox', {
|
|
118
|
+
name: 'Select all rows on this page',
|
|
119
|
+
}),
|
|
120
|
+
)
|
|
121
|
+
const bulk = await screen.findByRole('button', {
|
|
122
|
+
name: /Update selected \(2\)/,
|
|
123
|
+
})
|
|
124
|
+
await userEvent.click(bulk)
|
|
125
|
+
expect(onRowsAction).toHaveBeenCalledWith(['1', '3'])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('keeps a stable titled toolbar when an external filter bar owns search', () => {
|
|
129
|
+
render(() => (
|
|
130
|
+
<DataTable title="Rows" rows={ROWS} columns={COLUMNS} hideSearch />
|
|
131
|
+
))
|
|
132
|
+
const toolbar = document.querySelector('[data-slot="data-table-toolbar"]')
|
|
133
|
+
expect(toolbar?.textContent).toContain('Rows')
|
|
134
|
+
expect(
|
|
135
|
+
document.querySelector('[data-slot="data-table-columns"]'),
|
|
136
|
+
).toBeTruthy()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('renders the shared loading and error states', () => {
|
|
140
|
+
const loading = render(() => (
|
|
141
|
+
<DataTable title="Rows" rows={[]} columns={COLUMNS} loading />
|
|
142
|
+
))
|
|
143
|
+
expect(screen.getByRole('status', { name: 'Loading' })).toBeTruthy()
|
|
144
|
+
loading.unmount()
|
|
145
|
+
render(() => (
|
|
146
|
+
<DataTable title="Rows" rows={[]} columns={COLUMNS} error="Failed" />
|
|
147
|
+
))
|
|
148
|
+
expect(screen.getByRole('alert')).toBeTruthy()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('shows the mobile selection bar once a row is selected', async () => {
|
|
152
|
+
table({ onRowsAction: () => {} })
|
|
153
|
+
await userEvent.click(screen.getByRole('checkbox', { name: 'Select 1' }))
|
|
154
|
+
expect(document.querySelector('[data-slot="selection-bar"]')).toBeTruthy()
|
|
155
|
+
})
|
|
156
|
+
|
|
85
157
|
it('can be a read-only table with no selection column', () => {
|
|
86
158
|
table({ selectable: false })
|
|
87
159
|
expect(screen.queryByRole('checkbox', { name: /Select/ })).toBeNull()
|