@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
|
@@ -33,11 +33,14 @@ const COLLAPSE_QUERY = '(max-width: 639px)'
|
|
|
33
33
|
interface SidebarContextValue {
|
|
34
34
|
collapsed: Accessor<boolean>
|
|
35
35
|
toggle: () => void
|
|
36
|
+
/** Whether `⌘B`/`Ctrl+B` is bound here, so chrome only advertises a real hint. */
|
|
37
|
+
shortcut: Accessor<boolean>
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
const SidebarContext = createContext<SidebarContextValue>({
|
|
39
41
|
collapsed: () => false,
|
|
40
42
|
toggle: () => {},
|
|
43
|
+
shortcut: () => false,
|
|
41
44
|
})
|
|
42
45
|
|
|
43
46
|
/** Collapsed state + toggle, shared with every part inside a `Sidebar`. */
|
|
@@ -83,6 +86,7 @@ export const Sidebar: ParentComponent<SidebarProps> = (props) => {
|
|
|
83
86
|
const [internal, setInternal] = createSignal<boolean | null>(null)
|
|
84
87
|
|
|
85
88
|
const collapsible = () => local.collapsible ?? true
|
|
89
|
+
const shortcut = () => local.shortcut !== false && collapsible()
|
|
86
90
|
const shouldCollapse = () =>
|
|
87
91
|
collapsible() &&
|
|
88
92
|
(local.collapsed !== undefined
|
|
@@ -96,7 +100,7 @@ export const Sidebar: ParentComponent<SidebarProps> = (props) => {
|
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
onMount(() => {
|
|
99
|
-
if (
|
|
103
|
+
if (!shortcut()) return
|
|
100
104
|
const onKey = (event: KeyboardEvent) => {
|
|
101
105
|
if (event.key.toLowerCase() !== 'b') return
|
|
102
106
|
if (!event.metaKey && !event.ctrlKey) return
|
|
@@ -108,7 +112,9 @@ export const Sidebar: ParentComponent<SidebarProps> = (props) => {
|
|
|
108
112
|
})
|
|
109
113
|
|
|
110
114
|
return (
|
|
111
|
-
<SidebarContext.Provider
|
|
115
|
+
<SidebarContext.Provider
|
|
116
|
+
value={{ collapsed: shouldCollapse, toggle, shortcut }}
|
|
117
|
+
>
|
|
112
118
|
<nav
|
|
113
119
|
data-slot="sidebar"
|
|
114
120
|
data-state={shouldCollapse() ? 'collapsed' : 'expanded'}
|
|
@@ -162,7 +168,7 @@ export const SidebarHeader: ParentComponent<SidebarHeaderProps> = (props) => {
|
|
|
162
168
|
<div
|
|
163
169
|
data-slot="sidebar-header"
|
|
164
170
|
class={cn(
|
|
165
|
-
'flex h-12 shrink-0 items-center justify-between gap-2 px-
|
|
171
|
+
'flex h-12 shrink-0 items-center justify-between gap-2 px-2',
|
|
166
172
|
local.class,
|
|
167
173
|
)}
|
|
168
174
|
>
|
|
@@ -235,11 +241,11 @@ export const SidebarLabel: ParentComponent<SidebarLabelProps> = (props) => {
|
|
|
235
241
|
return (
|
|
236
242
|
<div
|
|
237
243
|
data-slot="sidebar-label"
|
|
238
|
-
class={cn('relative flex h-
|
|
244
|
+
class={cn('relative flex h-8 items-center px-2 pt-2', local.class)}
|
|
239
245
|
>
|
|
240
246
|
<div
|
|
241
247
|
class={cn(
|
|
242
|
-
'text-
|
|
248
|
+
'text-xs font-medium text-ink-gray-6',
|
|
243
249
|
collapsed() ? 'w-0 overflow-hidden opacity-0' : 'w-auto opacity-100',
|
|
244
250
|
)}
|
|
245
251
|
>
|
|
@@ -319,9 +325,9 @@ export const SidebarItem: ParentComponent<SidebarItemProps> = (props) => {
|
|
|
319
325
|
|
|
320
326
|
const rowClasses = () =>
|
|
321
327
|
cn(
|
|
322
|
-
'relative flex h-8 w-full items-center rounded-control text-interface text-ink-gray-
|
|
328
|
+
'group relative flex h-8 w-full items-center rounded-control text-interface text-ink-gray-7',
|
|
323
329
|
'transition-colors hover:bg-surface-gray-2 motion-reduce:transition-none',
|
|
324
|
-
'data-[active]:bg-surface-blue-1 data-[active]:text-ink-blue-8',
|
|
330
|
+
'data-[active]:bg-surface-blue-1 data-[active]:font-medium data-[active]:text-ink-blue-8',
|
|
325
331
|
local.class,
|
|
326
332
|
)
|
|
327
333
|
const innerClasses = () =>
|
|
@@ -439,7 +445,7 @@ export function SidebarSearch(props: SidebarSearchProps): JSX.Element {
|
|
|
439
445
|
}
|
|
440
446
|
|
|
441
447
|
export interface SidebarTriggerProps {
|
|
442
|
-
/** Tooltip copy. Defaults to Collapse/Expand
|
|
448
|
+
/** Tooltip and accessible copy. Defaults to Collapse sidebar/Expand sidebar. */
|
|
443
449
|
label?: string
|
|
444
450
|
class?: string
|
|
445
451
|
}
|
|
@@ -453,9 +459,10 @@ export interface SidebarTriggerProps {
|
|
|
453
459
|
* `SidebarItem` at the bottom of the list); this is the compact one.
|
|
454
460
|
*/
|
|
455
461
|
export function SidebarTrigger(props: SidebarTriggerProps): JSX.Element {
|
|
456
|
-
const { collapsed, toggle } = useSidebar()
|
|
462
|
+
const { collapsed, toggle, shortcut } = useSidebar()
|
|
457
463
|
const text = () =>
|
|
458
464
|
props.label ?? (collapsed() ? 'Expand sidebar' : 'Collapse sidebar')
|
|
465
|
+
const hint = () => (shortcut() ? `${text()} · ⌘B` : text())
|
|
459
466
|
|
|
460
467
|
return (
|
|
461
468
|
<TooltipRoot placement="bottom" openDelay={300}>
|
|
@@ -478,7 +485,7 @@ export function SidebarTrigger(props: SidebarTriggerProps): JSX.Element {
|
|
|
478
485
|
)}
|
|
479
486
|
/>
|
|
480
487
|
</TooltipTrigger>
|
|
481
|
-
<TooltipContent>{
|
|
488
|
+
<TooltipContent>{hint()}</TooltipContent>
|
|
482
489
|
</TooltipRoot>
|
|
483
490
|
)
|
|
484
491
|
}
|
|
@@ -77,7 +77,19 @@ describe('StatGrid', () => {
|
|
|
77
77
|
const className = container.querySelector(
|
|
78
78
|
'[data-slot="stat-grid"]',
|
|
79
79
|
)?.className
|
|
80
|
-
expect(className).toContain('
|
|
80
|
+
expect(className).toContain('min-[360px]:grid-cols-2')
|
|
81
81
|
expect(className).toContain('xl:grid-cols-3')
|
|
82
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
|
+
})
|
|
83
95
|
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cn, Icon, type IconProp } from '@talelk/newui-atoms'
|
|
1
|
+
import { cn, Icon, type IconProp, Skeleton } from '@talelk/newui-atoms'
|
|
2
2
|
import { For, type JSX, Show } from 'solid-js'
|
|
3
3
|
import { Dynamic } from 'solid-js/web'
|
|
4
4
|
|
|
@@ -17,6 +17,10 @@ export interface StatGridProps {
|
|
|
17
17
|
stats: Stat[]
|
|
18
18
|
/** Columns at the widest breakpoint. Defaults to the number of stats, capped. */
|
|
19
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
|
|
20
24
|
class?: string
|
|
21
25
|
}
|
|
22
26
|
|
|
@@ -34,9 +38,16 @@ export interface StatGridProps {
|
|
|
34
38
|
* to fail it.
|
|
35
39
|
*/
|
|
36
40
|
const COLUMNS: Record<2 | 3 | 4, string> = {
|
|
37
|
-
2: '
|
|
38
|
-
3: '
|
|
39
|
-
4: '
|
|
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',
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
export function StatGrid(props: StatGridProps): JSX.Element {
|
|
@@ -44,76 +55,92 @@ export function StatGrid(props: StatGridProps): JSX.Element {
|
|
|
44
55
|
props.columns ?? (Math.min(4, Math.max(2, props.stats.length)) as 2 | 3 | 4)
|
|
45
56
|
|
|
46
57
|
return (
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
}
|
|
50
71
|
>
|
|
51
|
-
<
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
stat
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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}
|
|
76
133
|
</span>
|
|
77
|
-
<Show when={stat.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
classList={{
|
|
82
|
-
'bg-surface-green-2 text-ink-green-8':
|
|
83
|
-
delta().direction !== 'flat' && isGood(delta()),
|
|
84
|
-
'bg-surface-red-2 text-ink-red-8':
|
|
85
|
-
delta().direction !== 'flat' && !isGood(delta()),
|
|
86
|
-
'bg-surface-gray-2 text-ink-gray-7':
|
|
87
|
-
delta().direction === 'flat',
|
|
88
|
-
}}
|
|
89
|
-
>
|
|
90
|
-
<Icon
|
|
91
|
-
icon={
|
|
92
|
-
delta().direction === 'up'
|
|
93
|
-
? 'lucide-arrow-up'
|
|
94
|
-
: delta().direction === 'down'
|
|
95
|
-
? 'lucide-arrow-down'
|
|
96
|
-
: 'lucide-minus'
|
|
97
|
-
}
|
|
98
|
-
class="size-3"
|
|
99
|
-
/>
|
|
100
|
-
{delta().value}
|
|
101
|
-
</span>
|
|
102
|
-
)}
|
|
134
|
+
<Show when={stat.hint}>
|
|
135
|
+
<span class="text-xs [overflow-wrap:anywhere] text-ink-gray-7">
|
|
136
|
+
{stat.hint}
|
|
137
|
+
</span>
|
|
103
138
|
</Show>
|
|
104
|
-
</
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
<span class="text-xs [overflow-wrap:anywhere] text-ink-gray-7">
|
|
110
|
-
{stat.hint}
|
|
111
|
-
</span>
|
|
112
|
-
</Show>
|
|
113
|
-
</Dynamic>
|
|
114
|
-
)}
|
|
115
|
-
</For>
|
|
116
|
-
</div>
|
|
139
|
+
</Dynamic>
|
|
140
|
+
)}
|
|
141
|
+
</For>
|
|
142
|
+
</div>
|
|
143
|
+
</Show>
|
|
117
144
|
)
|
|
118
145
|
}
|
|
119
146
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { render, screen } from '@solidjs/testing-library'
|
|
2
|
+
import axe, { type AxeResults } from 'axe-core'
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { VirtualList } from './VirtualList'
|
|
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 ROWS = Array.from({ length: 1000 }, (_, index) => ({
|
|
22
|
+
id: `ROW-${index + 1}`,
|
|
23
|
+
title: `Row ${index + 1}`,
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
interface Row {
|
|
27
|
+
id: string
|
|
28
|
+
title: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const EMPTY: Row[] = []
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* jsdom has no layout, so the viewport reports a zero rect and the virtualiser
|
|
35
|
+
* renders nothing. The tests give every element a 440px box, which is the only
|
|
36
|
+
* measurement the list needs.
|
|
37
|
+
*/
|
|
38
|
+
class MeasuredObserver {
|
|
39
|
+
constructor(private readonly callback: ResizeObserverCallback) {}
|
|
40
|
+
observe(target: Element) {
|
|
41
|
+
const rect = target.getBoundingClientRect()
|
|
42
|
+
this.callback(
|
|
43
|
+
[
|
|
44
|
+
{
|
|
45
|
+
target,
|
|
46
|
+
contentRect: rect,
|
|
47
|
+
borderBoxSize: [{ inlineSize: rect.width, blockSize: rect.height }],
|
|
48
|
+
contentBoxSize: [{ inlineSize: rect.width, blockSize: rect.height }],
|
|
49
|
+
} as unknown as ResizeObserverEntry,
|
|
50
|
+
],
|
|
51
|
+
this as unknown as ResizeObserver,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
unobserve() {}
|
|
55
|
+
disconnect() {}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function withLayout() {
|
|
59
|
+
const rect = {
|
|
60
|
+
width: 600,
|
|
61
|
+
height: 440,
|
|
62
|
+
top: 0,
|
|
63
|
+
left: 0,
|
|
64
|
+
right: 600,
|
|
65
|
+
bottom: 440,
|
|
66
|
+
x: 0,
|
|
67
|
+
y: 0,
|
|
68
|
+
toJSON: () => ({}),
|
|
69
|
+
} as DOMRect
|
|
70
|
+
const spy = vi
|
|
71
|
+
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
|
72
|
+
.mockReturnValue(rect)
|
|
73
|
+
// The jsdom shim observes nothing; the virtualiser needs one measurement.
|
|
74
|
+
vi.stubGlobal('ResizeObserver', MeasuredObserver)
|
|
75
|
+
return spy
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
vi.unstubAllGlobals()
|
|
80
|
+
vi.restoreAllMocks()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('VirtualList accessibility', () => {
|
|
84
|
+
it('has no axe violations for a virtualised list of rows', async () => {
|
|
85
|
+
withLayout()
|
|
86
|
+
const { container } = render(() => (
|
|
87
|
+
<VirtualList label="Rows" items={ROWS} itemSize={72} height="440px">
|
|
88
|
+
{(row) => <span>{row.title}</span>}
|
|
89
|
+
</VirtualList>
|
|
90
|
+
))
|
|
91
|
+
expect(screen.getAllByRole('listitem').length).toBeGreaterThan(0)
|
|
92
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('has no axe violations for the empty state', async () => {
|
|
96
|
+
const { container } = render(() => (
|
|
97
|
+
<VirtualList
|
|
98
|
+
label="Rows"
|
|
99
|
+
items={EMPTY}
|
|
100
|
+
itemSize={72}
|
|
101
|
+
emptyTitle="No rows yet"
|
|
102
|
+
emptyDescription="Create the first one."
|
|
103
|
+
>
|
|
104
|
+
{(row) => <span>{row.title}</span>}
|
|
105
|
+
</VirtualList>
|
|
106
|
+
))
|
|
107
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createVirtualizer } from '@tanstack/solid-virtual'
|
|
2
|
-
import { For, type JSX, untrack } from 'solid-js'
|
|
2
|
+
import { For, type JSX, Show, untrack } from 'solid-js'
|
|
3
|
+
import { DataState } from '../DataState'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* A bounded DOM for a long list.
|
|
@@ -26,6 +27,14 @@ export interface VirtualListProps<T> {
|
|
|
26
27
|
height?: string
|
|
27
28
|
/** Rows rendered beyond the viewport. Default `6`. */
|
|
28
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
|
|
29
38
|
/** One row's content; the `li` and its position belong to the list. */
|
|
30
39
|
children: (item: T, index: number) => JSX.Element
|
|
31
40
|
}
|
|
@@ -43,6 +52,15 @@ export function VirtualList<T>(props: VirtualListProps<T>): JSX.Element {
|
|
|
43
52
|
overscan: untrack(() => props.overscan) ?? 6,
|
|
44
53
|
})
|
|
45
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
|
+
|
|
46
64
|
return (
|
|
47
65
|
<section
|
|
48
66
|
ref={viewport}
|
|
@@ -52,28 +70,44 @@ export function VirtualList<T>(props: VirtualListProps<T>): JSX.Element {
|
|
|
52
70
|
class="relative overflow-auto rounded-3 focus-visible:focus-ring"
|
|
53
71
|
data-slot="virtual-list"
|
|
54
72
|
>
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
}
|
|
58
87
|
>
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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>
|
|
77
111
|
</section>
|
|
78
112
|
)
|
|
79
113
|
}
|
package/src/organisms/index.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
export * from './AppSidebar'
|
|
2
2
|
export * from './AuthCard'
|
|
3
3
|
export * from './CommandPalette'
|
|
4
|
+
export * from './DataState'
|
|
4
5
|
export * from './DataTable'
|
|
5
6
|
export * from './DetailHeader'
|
|
6
7
|
export * from './FilterBar'
|
|
7
8
|
export * from './KanbanBoard'
|
|
8
9
|
export * from './MobileNav'
|
|
9
10
|
export * from './PageToolbar'
|
|
11
|
+
export * from './Section'
|
|
12
|
+
export * from './SelectionBar'
|
|
10
13
|
export * from './SettingsDialog'
|
|
11
14
|
export * from './Sidebar'
|
|
12
15
|
export * from './StatGrid'
|