@talelk/newui-organisms 0.0.0-stage → 2.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 +136 -0
- package/src/organisms/AppSidebar/AppSidebar.tsx +259 -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 +89 -0
- package/src/organisms/AuthCard/index.ts +1 -0
- package/src/organisms/CommandPalette/CommandPalette.test.tsx +196 -0
- package/src/organisms/CommandPalette/CommandPalette.tsx +468 -0
- package/src/organisms/CommandPalette/index.ts +1 -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.test.tsx +161 -0
- package/src/organisms/DataTable/DataTable.tsx +612 -0
- package/src/organisms/DataTable/index.ts +1 -0
- package/src/organisms/DetailHeader/DetailHeader.test.tsx +55 -0
- package/src/organisms/DetailHeader/DetailHeader.tsx +95 -0
- package/src/organisms/DetailHeader/index.ts +1 -0
- package/src/organisms/FilterBar/FilterBar.test.tsx +152 -0
- package/src/organisms/FilterBar/FilterBar.tsx +251 -0
- package/src/organisms/FilterBar/index.ts +1 -0
- package/src/organisms/KanbanBoard/KanbanBoard.test.tsx +255 -0
- package/src/organisms/KanbanBoard/KanbanBoard.tsx +605 -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 +126 -0
- package/src/organisms/PageToolbar/index.ts +1 -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.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.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 +548 -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 +732 -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 +95 -0
- package/src/organisms/StatGrid/StatGrid.tsx +151 -0
- package/src/organisms/StatGrid/index.ts +1 -0
- package/src/organisms/VirtualList/VirtualList.test.tsx +80 -0
- package/src/organisms/VirtualList/VirtualList.tsx +113 -0
- package/src/organisms/VirtualList/index.ts +1 -0
- package/src/organisms/index.ts +16 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export {
|
|
2
|
+
Sidebar,
|
|
3
|
+
SidebarCard,
|
|
4
|
+
type SidebarCardAction,
|
|
5
|
+
type SidebarCardProps,
|
|
6
|
+
SidebarCollapseToggle,
|
|
7
|
+
type SidebarCollapseToggleProps,
|
|
8
|
+
SidebarHeader,
|
|
9
|
+
type SidebarHeaderProps,
|
|
10
|
+
SidebarItem,
|
|
11
|
+
type SidebarItemProps,
|
|
12
|
+
SidebarLabel,
|
|
13
|
+
type SidebarLabelProps,
|
|
14
|
+
type SidebarProps,
|
|
15
|
+
SidebarSearch,
|
|
16
|
+
type SidebarSearchProps,
|
|
17
|
+
SidebarSection,
|
|
18
|
+
type SidebarSectionProps,
|
|
19
|
+
SidebarTrigger,
|
|
20
|
+
type SidebarTriggerProps,
|
|
21
|
+
useSidebar,
|
|
22
|
+
} from './Sidebar'
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import axe, { type AxeResults } from 'axe-core'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { type Stat, StatGrid } from './StatGrid'
|
|
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
|
+
const STATS: Stat[] = [
|
|
22
|
+
{
|
|
23
|
+
label: 'Revenue',
|
|
24
|
+
value: '$4.2m',
|
|
25
|
+
delta: { value: '+5.1%', direction: 'up', good: true },
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
label: 'Open deals',
|
|
29
|
+
value: '38',
|
|
30
|
+
hint: 'vs 42 last quarter',
|
|
31
|
+
delta: { value: '-0.4 pts', direction: 'down', good: false },
|
|
32
|
+
},
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
describe('StatGrid accessibility', () => {
|
|
36
|
+
it('has no axe violations for a KPI row', async () => {
|
|
37
|
+
const { container } = render(() => <StatGrid stats={STATS} columns={2} />)
|
|
38
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
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 { StatGrid } from './StatGrid'
|
|
5
|
+
|
|
6
|
+
describe('StatGrid', () => {
|
|
7
|
+
it('renders a label, a value and a hint per stat', () => {
|
|
8
|
+
render(() => (
|
|
9
|
+
<StatGrid
|
|
10
|
+
stats={[
|
|
11
|
+
{
|
|
12
|
+
label: 'Net revenue',
|
|
13
|
+
value: '$220.2K',
|
|
14
|
+
hint: 'vs last month',
|
|
15
|
+
delta: { value: '5.1%', direction: 'up' },
|
|
16
|
+
},
|
|
17
|
+
]}
|
|
18
|
+
/>
|
|
19
|
+
))
|
|
20
|
+
expect(screen.getByText('Net revenue')).toBeTruthy()
|
|
21
|
+
expect(screen.getByText('$220.2K')).toBeTruthy()
|
|
22
|
+
expect(screen.getByText('vs last month')).toBeTruthy()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('tones a fall by whether it is good, not by its direction', () => {
|
|
26
|
+
const down = render(() => (
|
|
27
|
+
<StatGrid
|
|
28
|
+
stats={[
|
|
29
|
+
{
|
|
30
|
+
label: 'Monthly churn',
|
|
31
|
+
value: '2.1%',
|
|
32
|
+
delta: { value: '0.4 pts', direction: 'down', good: true },
|
|
33
|
+
},
|
|
34
|
+
]}
|
|
35
|
+
/>
|
|
36
|
+
))
|
|
37
|
+
// The pill pairing is the contrast contract's ink-*-8 on surface-*-2.
|
|
38
|
+
expect(screen.getByText('0.4 pts').className).toContain('text-ink-green-8')
|
|
39
|
+
expect(screen.getByText('0.4 pts').className).toContain(
|
|
40
|
+
'bg-surface-green-2',
|
|
41
|
+
)
|
|
42
|
+
expect(screen.getByText('0.4 pts').className).toContain('rounded-full')
|
|
43
|
+
down.unmount()
|
|
44
|
+
|
|
45
|
+
render(() => (
|
|
46
|
+
<StatGrid
|
|
47
|
+
stats={[
|
|
48
|
+
{
|
|
49
|
+
label: 'Revenue',
|
|
50
|
+
value: '$1',
|
|
51
|
+
delta: { value: '2%', direction: 'down' },
|
|
52
|
+
},
|
|
53
|
+
]}
|
|
54
|
+
/>
|
|
55
|
+
))
|
|
56
|
+
expect(screen.getByText('2%').className).toContain('text-ink-red-8')
|
|
57
|
+
expect(screen.getByText('2%').className).toContain('bg-surface-red-2')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('is a button when it navigates, a card when it does not', async () => {
|
|
61
|
+
const onClick = vi.fn()
|
|
62
|
+
const plain = render(() => (
|
|
63
|
+
<StatGrid stats={[{ label: 'Static', value: '1' }]} />
|
|
64
|
+
))
|
|
65
|
+
expect(plain.container.querySelector('button')).toBeNull()
|
|
66
|
+
plain.unmount()
|
|
67
|
+
|
|
68
|
+
render(() => <StatGrid stats={[{ label: 'Live', value: '2', onClick }]} />)
|
|
69
|
+
await userEvent.click(screen.getByRole('button', { name: /Live/ }))
|
|
70
|
+
expect(onClick).toHaveBeenCalled()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('uses literal grid classes so Tailwind can see them', () => {
|
|
74
|
+
const { container } = render(() => (
|
|
75
|
+
<StatGrid stats={[{ label: 'One', value: '1' }]} columns={3} />
|
|
76
|
+
))
|
|
77
|
+
const className = container.querySelector(
|
|
78
|
+
'[data-slot="stat-grid"]',
|
|
79
|
+
)?.className
|
|
80
|
+
expect(className).toContain('min-[360px]:grid-cols-2')
|
|
81
|
+
expect(className).toContain('xl:grid-cols-3')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('compact cards use the tight padding and omit the icon tile', () => {
|
|
85
|
+
const { container } = render(() => (
|
|
86
|
+
<StatGrid
|
|
87
|
+
compact
|
|
88
|
+
stats={[{ label: 'One', value: '1', icon: 'lucide-house' }]}
|
|
89
|
+
/>
|
|
90
|
+
))
|
|
91
|
+
const card = container.querySelector('[data-slot="stat"]')
|
|
92
|
+
expect(card?.className).toContain('p-2.5')
|
|
93
|
+
expect(card?.querySelector('.bg-surface-gray-2')).toBeNull()
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { cn, Icon, type IconProp, Skeleton } from '@talelk/newui-atoms'
|
|
2
|
+
import { For, type JSX, Show } from 'solid-js'
|
|
3
|
+
import { Dynamic } from 'solid-js/web'
|
|
4
|
+
|
|
5
|
+
export interface Stat {
|
|
6
|
+
label: string
|
|
7
|
+
value: JSX.Element
|
|
8
|
+
/** One line of context: the comparison, the period, the owner. */
|
|
9
|
+
hint?: JSX.Element
|
|
10
|
+
/** Signed change, rendered in a tone. `+5.1%` / `-0.4 pts`. */
|
|
11
|
+
delta?: { value: string; direction: 'up' | 'down' | 'flat'; good?: boolean }
|
|
12
|
+
icon?: IconProp
|
|
13
|
+
onClick?: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface StatGridProps {
|
|
17
|
+
stats: Stat[]
|
|
18
|
+
/** Columns at the widest breakpoint. Defaults to the number of stats, capped. */
|
|
19
|
+
columns?: 2 | 3 | 4
|
|
20
|
+
/** Tighter cards without the icon tile; for table-led screens. */
|
|
21
|
+
compact?: boolean
|
|
22
|
+
/** Replaces the cards with the shared skeleton geometry. */
|
|
23
|
+
loading?: boolean
|
|
24
|
+
class?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A row of KPI cards.
|
|
29
|
+
*
|
|
30
|
+
* The delta is deliberately not coloured by direction but by *whether it is
|
|
31
|
+
* good*: churn going down is good, revenue going down is not, and a grid that
|
|
32
|
+
* paints every minus red gets that wrong on half the dashboards it is used on.
|
|
33
|
+
* `good` defaults to the intuitive reading and can be overridden per stat.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* Literal classes, not a template string: Tailwind scans for whole class names,
|
|
37
|
+
* so `sm:grid-cols-${n}` would compile to nothing and `css:check` would be right
|
|
38
|
+
* to fail it.
|
|
39
|
+
*/
|
|
40
|
+
const COLUMNS: Record<2 | 3 | 4, string> = {
|
|
41
|
+
2: 'min-[360px]:grid-cols-2',
|
|
42
|
+
3: 'min-[360px]:grid-cols-2 xl:grid-cols-3',
|
|
43
|
+
4: 'min-[360px]:grid-cols-2 xl:grid-cols-4',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Compact table-led screens earn a four-up row on tablets. */
|
|
47
|
+
const COMPACT_COLUMNS: Record<2 | 3 | 4, string> = {
|
|
48
|
+
2: 'min-[360px]:grid-cols-2',
|
|
49
|
+
3: 'min-[360px]:grid-cols-2 md:grid-cols-3',
|
|
50
|
+
4: 'min-[360px]:grid-cols-2 md:grid-cols-4',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function StatGrid(props: StatGridProps): JSX.Element {
|
|
54
|
+
const columns = (): 2 | 3 | 4 =>
|
|
55
|
+
props.columns ?? (Math.min(4, Math.max(2, props.stats.length)) as 2 | 3 | 4)
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Show
|
|
59
|
+
when={!props.loading}
|
|
60
|
+
fallback={
|
|
61
|
+
<div
|
|
62
|
+
data-slot="stat-grid"
|
|
63
|
+
data-loading
|
|
64
|
+
class={`grid gap-card-gap ${props.compact ? COMPACT_COLUMNS[columns()] : COLUMNS[columns()]} ${props.class ?? ''}`}
|
|
65
|
+
>
|
|
66
|
+
<For each={Array.from({ length: columns() })}>
|
|
67
|
+
{() => <Skeleton lines={3} class="h-24" />}
|
|
68
|
+
</For>
|
|
69
|
+
</div>
|
|
70
|
+
}
|
|
71
|
+
>
|
|
72
|
+
<div
|
|
73
|
+
data-slot="stat-grid"
|
|
74
|
+
class={`grid gap-card-gap ${props.compact ? COMPACT_COLUMNS[columns()] : COLUMNS[columns()]} ${props.class ?? ''}`}
|
|
75
|
+
>
|
|
76
|
+
<For each={props.stats}>
|
|
77
|
+
{(stat) => (
|
|
78
|
+
// A stat that navigates is a button, not a div with a click handler:
|
|
79
|
+
// it has to be reachable by keyboard and announced as interactive.
|
|
80
|
+
<Dynamic
|
|
81
|
+
component={stat.onClick ? 'button' : 'div'}
|
|
82
|
+
{...(stat.onClick ? { type: 'button' as const } : {})}
|
|
83
|
+
data-slot="stat"
|
|
84
|
+
onClick={stat.onClick}
|
|
85
|
+
class={cn(
|
|
86
|
+
'flex flex-col rounded-card border border-outline-gray-1 bg-surface-elevation-1 text-start',
|
|
87
|
+
props.compact ? 'gap-1 p-2.5' : 'gap-control-gap p-card-pad',
|
|
88
|
+
stat.onClick &&
|
|
89
|
+
'cursor-pointer transition-[border-color,box-shadow] hover:border-outline-gray-3 hover:shadow-sm focus-visible:focus-ring motion-reduce:transition-none',
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
<div class="flex items-start gap-2">
|
|
93
|
+
<Show when={props.compact ? undefined : stat.icon}>
|
|
94
|
+
{(icon) => (
|
|
95
|
+
<span class="grid size-8 shrink-0 place-items-center rounded-4 bg-surface-gray-2">
|
|
96
|
+
<Icon icon={icon()} class="size-4 text-ink-gray-7" />
|
|
97
|
+
</span>
|
|
98
|
+
)}
|
|
99
|
+
</Show>
|
|
100
|
+
<span class="min-w-0 flex-1 text-sm-medium [overflow-wrap:anywhere] text-ink-gray-7">
|
|
101
|
+
{stat.label}
|
|
102
|
+
</span>
|
|
103
|
+
<Show when={stat.delta}>
|
|
104
|
+
{(delta) => (
|
|
105
|
+
<span
|
|
106
|
+
class="inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs-medium tabular-nums"
|
|
107
|
+
classList={{
|
|
108
|
+
'bg-surface-green-2 text-ink-green-8':
|
|
109
|
+
delta().direction !== 'flat' && isGood(delta()),
|
|
110
|
+
'bg-surface-red-2 text-ink-red-8':
|
|
111
|
+
delta().direction !== 'flat' && !isGood(delta()),
|
|
112
|
+
'bg-surface-gray-2 text-ink-gray-7':
|
|
113
|
+
delta().direction === 'flat',
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
<Icon
|
|
117
|
+
icon={
|
|
118
|
+
delta().direction === 'up'
|
|
119
|
+
? 'lucide-arrow-up'
|
|
120
|
+
: delta().direction === 'down'
|
|
121
|
+
? 'lucide-arrow-down'
|
|
122
|
+
: 'lucide-minus'
|
|
123
|
+
}
|
|
124
|
+
class="size-3"
|
|
125
|
+
/>
|
|
126
|
+
{delta().value}
|
|
127
|
+
</span>
|
|
128
|
+
)}
|
|
129
|
+
</Show>
|
|
130
|
+
</div>
|
|
131
|
+
<span class="text-metric tabular-nums [overflow-wrap:anywhere] text-ink-gray-9">
|
|
132
|
+
{stat.value}
|
|
133
|
+
</span>
|
|
134
|
+
<Show when={stat.hint}>
|
|
135
|
+
<span class="text-xs [overflow-wrap:anywhere] text-ink-gray-7">
|
|
136
|
+
{stat.hint}
|
|
137
|
+
</span>
|
|
138
|
+
</Show>
|
|
139
|
+
</Dynamic>
|
|
140
|
+
)}
|
|
141
|
+
</For>
|
|
142
|
+
</div>
|
|
143
|
+
</Show>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A flat delta is neutral; otherwise the direction is good unless overridden. */
|
|
148
|
+
function isGood(delta: NonNullable<Stat['delta']>): boolean {
|
|
149
|
+
if (delta.good !== undefined) return delta.good
|
|
150
|
+
return delta.direction !== 'down'
|
|
151
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './StatGrid'
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { render, screen } from '@solidjs/testing-library'
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { VirtualList } from './VirtualList'
|
|
4
|
+
|
|
5
|
+
const ROWS = Array.from({ length: 1000 }, (_, index) => ({
|
|
6
|
+
id: `ROW-${index + 1}`,
|
|
7
|
+
title: `Row ${index + 1}`,
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* jsdom has no layout, so the viewport reports a zero rect and the virtualiser
|
|
12
|
+
* renders nothing. The tests give every element a 440px box, which is the only
|
|
13
|
+
* measurement the list needs.
|
|
14
|
+
*/
|
|
15
|
+
class MeasuredObserver {
|
|
16
|
+
constructor(private readonly callback: ResizeObserverCallback) {}
|
|
17
|
+
observe(target: Element) {
|
|
18
|
+
const rect = target.getBoundingClientRect()
|
|
19
|
+
this.callback(
|
|
20
|
+
[
|
|
21
|
+
{
|
|
22
|
+
target,
|
|
23
|
+
contentRect: rect,
|
|
24
|
+
borderBoxSize: [{ inlineSize: rect.width, blockSize: rect.height }],
|
|
25
|
+
contentBoxSize: [{ inlineSize: rect.width, blockSize: rect.height }],
|
|
26
|
+
} as unknown as ResizeObserverEntry,
|
|
27
|
+
],
|
|
28
|
+
this as unknown as ResizeObserver,
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
unobserve() {}
|
|
32
|
+
disconnect() {}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function withLayout() {
|
|
36
|
+
const rect = {
|
|
37
|
+
width: 600,
|
|
38
|
+
height: 440,
|
|
39
|
+
top: 0,
|
|
40
|
+
left: 0,
|
|
41
|
+
right: 600,
|
|
42
|
+
bottom: 440,
|
|
43
|
+
x: 0,
|
|
44
|
+
y: 0,
|
|
45
|
+
toJSON: () => ({}),
|
|
46
|
+
} as DOMRect
|
|
47
|
+
const spy = vi
|
|
48
|
+
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
|
49
|
+
.mockReturnValue(rect)
|
|
50
|
+
// The jsdom shim observes nothing; the virtualiser needs one measurement.
|
|
51
|
+
vi.stubGlobal('ResizeObserver', MeasuredObserver)
|
|
52
|
+
return spy
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
vi.unstubAllGlobals()
|
|
57
|
+
vi.restoreAllMocks()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('VirtualList', () => {
|
|
61
|
+
it('keeps a bounded DOM, with row positions declared for assistive tech', () => {
|
|
62
|
+
const spy = withLayout()
|
|
63
|
+
render(() => (
|
|
64
|
+
<VirtualList label="Rows" items={ROWS} itemSize={72} height="440px">
|
|
65
|
+
{(row) => <span>{row.title}</span>}
|
|
66
|
+
</VirtualList>
|
|
67
|
+
))
|
|
68
|
+
const viewport = screen.getByRole('region', { name: 'Rows' })
|
|
69
|
+
expect(viewport.getAttribute('data-slot')).toBe('virtual-list')
|
|
70
|
+
expect(viewport.getAttribute('tabindex')).toBe('0')
|
|
71
|
+
|
|
72
|
+
const rows = viewport.querySelectorAll('li')
|
|
73
|
+
expect(rows.length).toBeGreaterThan(0)
|
|
74
|
+
expect(rows.length).toBeLessThan(30)
|
|
75
|
+
const first = rows[0] as HTMLLIElement
|
|
76
|
+
expect(first.getAttribute('aria-posinset')).toBe('1')
|
|
77
|
+
expect(first.getAttribute('aria-setsize')).toBe('1000')
|
|
78
|
+
spy.mockRestore()
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createVirtualizer } from '@tanstack/solid-virtual'
|
|
2
|
+
import { For, type JSX, Show, untrack } from 'solid-js'
|
|
3
|
+
import { DataState } from '../DataState'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A bounded DOM for a long list.
|
|
7
|
+
*
|
|
8
|
+
* A feed of a thousand rows does not need a thousand nodes: this keeps about a
|
|
9
|
+
* viewport's worth mounted and positions them, which is what makes the task
|
|
10
|
+
* activity stream scroll to the end with fewer than thirty `li`s in the document.
|
|
11
|
+
*
|
|
12
|
+
* The scroll region is focusable on purpose — a region that scrolls and cannot
|
|
13
|
+
* be reached from the keyboard cannot be read past its first screen (WCAG
|
|
14
|
+
* 2.1.1), and that is the same contract `ScrollArea` documents. `Home`/`End`/
|
|
15
|
+
* arrows scroll it natively once it has focus.
|
|
16
|
+
*
|
|
17
|
+
* Rows are fixed-height by design: a virtualiser that has to measure every row
|
|
18
|
+
* to know where the next one starts defeats the point on the lists this is for.
|
|
19
|
+
*/
|
|
20
|
+
export interface VirtualListProps<T> {
|
|
21
|
+
items: readonly T[]
|
|
22
|
+
/** Accessible name of the scroll region. */
|
|
23
|
+
label: string
|
|
24
|
+
/** Row height in pixels. Fixed, so the total height is known up front. */
|
|
25
|
+
itemSize: number
|
|
26
|
+
/** The viewport's height as a CSS length. Default `24rem`. */
|
|
27
|
+
height?: string
|
|
28
|
+
/** Rows rendered beyond the viewport. Default `6`. */
|
|
29
|
+
overscan?: number
|
|
30
|
+
/** Replaces the list with the shared loading skeleton. */
|
|
31
|
+
loading?: boolean
|
|
32
|
+
/** Replaces the list with the shared error state. */
|
|
33
|
+
error?: JSX.Element
|
|
34
|
+
onRetry?: () => void
|
|
35
|
+
emptyTitle?: JSX.Element
|
|
36
|
+
emptyDescription?: JSX.Element
|
|
37
|
+
emptyAction?: JSX.Element
|
|
38
|
+
/** One row's content; the `li` and its position belong to the list. */
|
|
39
|
+
children: (item: T, index: number) => JSX.Element
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function VirtualList<T>(props: VirtualListProps<T>): JSX.Element {
|
|
43
|
+
let viewport: HTMLElement | undefined
|
|
44
|
+
const virtualizer = createVirtualizer<HTMLElement, HTMLLIElement>({
|
|
45
|
+
get count() {
|
|
46
|
+
return props.items.length
|
|
47
|
+
},
|
|
48
|
+
getScrollElement: () => viewport ?? null,
|
|
49
|
+
estimateSize: () => props.itemSize,
|
|
50
|
+
// The virtualiser is created once; a changing overscan is not a supported
|
|
51
|
+
// dynamic input, and reading it untracked says so.
|
|
52
|
+
overscan: untrack(() => props.overscan) ?? 6,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const status = () =>
|
|
56
|
+
props.loading
|
|
57
|
+
? ('loading' as const)
|
|
58
|
+
: props.error
|
|
59
|
+
? ('error' as const)
|
|
60
|
+
: props.items.length === 0
|
|
61
|
+
? ('empty' as const)
|
|
62
|
+
: ('ready' as const)
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<section
|
|
66
|
+
ref={viewport}
|
|
67
|
+
aria-label={props.label}
|
|
68
|
+
tabindex="0"
|
|
69
|
+
style={{ height: props.height ?? '24rem' }}
|
|
70
|
+
class="relative overflow-auto rounded-3 focus-visible:focus-ring"
|
|
71
|
+
data-slot="virtual-list"
|
|
72
|
+
>
|
|
73
|
+
<Show
|
|
74
|
+
when={status() === 'ready'}
|
|
75
|
+
fallback={
|
|
76
|
+
<DataState
|
|
77
|
+
status={status()}
|
|
78
|
+
skeleton="rows"
|
|
79
|
+
rows={5}
|
|
80
|
+
title={props.emptyTitle}
|
|
81
|
+
description={props.emptyDescription}
|
|
82
|
+
action={props.emptyAction}
|
|
83
|
+
error={props.error}
|
|
84
|
+
onRetry={props.onRetry}
|
|
85
|
+
/>
|
|
86
|
+
}
|
|
87
|
+
>
|
|
88
|
+
<ul
|
|
89
|
+
class="relative m-0 list-none p-0"
|
|
90
|
+
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
|
91
|
+
>
|
|
92
|
+
<For each={virtualizer.getVirtualItems()}>
|
|
93
|
+
{(row) => (
|
|
94
|
+
<li
|
|
95
|
+
class="absolute inset-x-0 top-0"
|
|
96
|
+
style={{
|
|
97
|
+
height: `${props.itemSize}px`,
|
|
98
|
+
transform: `translateY(${row.start}px)`,
|
|
99
|
+
}}
|
|
100
|
+
aria-posinset={row.index + 1}
|
|
101
|
+
aria-setsize={props.items.length}
|
|
102
|
+
>
|
|
103
|
+
{/* The virtualiser's count is the array's length, so the index
|
|
104
|
+
is a valid key for the mapped row. */}
|
|
105
|
+
{props.children(props.items[row.index] as T, row.index)}
|
|
106
|
+
</li>
|
|
107
|
+
)}
|
|
108
|
+
</For>
|
|
109
|
+
</ul>
|
|
110
|
+
</Show>
|
|
111
|
+
</section>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './VirtualList'
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from './AppSidebar'
|
|
2
|
+
export * from './AuthCard'
|
|
3
|
+
export * from './CommandPalette'
|
|
4
|
+
export * from './DataState'
|
|
5
|
+
export * from './DataTable'
|
|
6
|
+
export * from './DetailHeader'
|
|
7
|
+
export * from './FilterBar'
|
|
8
|
+
export * from './KanbanBoard'
|
|
9
|
+
export * from './MobileNav'
|
|
10
|
+
export * from './PageToolbar'
|
|
11
|
+
export * from './Section'
|
|
12
|
+
export * from './SelectionBar'
|
|
13
|
+
export * from './SettingsDialog'
|
|
14
|
+
export * from './Sidebar'
|
|
15
|
+
export * from './StatGrid'
|
|
16
|
+
export * from './VirtualList'
|