@duro-app/ui 0.40.0 → 0.42.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/dist/components/Icon/Icon.d.ts +1 -1
- package/dist/components/Icon/Icon.d.ts.map +1 -1
- package/dist/components/SideNav/SideNav.d.ts +16 -1
- package/dist/components/SideNav/SideNav.d.ts.map +1 -1
- package/dist/components/SideNav/SideNav.meta.d.ts.map +1 -1
- package/dist/components/SideNav/SideNav.stories.d.ts +1 -0
- package/dist/components/SideNav/SideNav.stories.d.ts.map +1 -1
- package/dist/components/SideNav/styles.css.d.ts +48 -0
- package/dist/components/SideNav/styles.css.d.ts.map +1 -1
- package/dist/components/VirtualTable/VirtualTable.d.ts +57 -0
- package/dist/components/VirtualTable/VirtualTable.d.ts.map +1 -0
- package/dist/components/VirtualTable/VirtualTable.meta.d.ts +3 -0
- package/dist/components/VirtualTable/VirtualTable.meta.d.ts.map +1 -0
- package/dist/components/VirtualTable/VirtualTable.stories.d.ts +14 -0
- package/dist/components/VirtualTable/VirtualTable.stories.d.ts.map +1 -0
- package/dist/components/VirtualTable/styles.css.d.ts +128 -0
- package/dist/components/VirtualTable/styles.css.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2286 -1936
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/components/Icon/Icon.tsx +46 -0
- package/src/components/SideNav/SideNav.meta.ts +17 -11
- package/src/components/SideNav/SideNav.stories.tsx +47 -3
- package/src/components/SideNav/SideNav.tsx +34 -2
- package/src/components/SideNav/styles.css.ts +51 -1
- package/src/components/VirtualTable/VirtualTable.meta.ts +43 -0
- package/src/components/VirtualTable/VirtualTable.stories.tsx +89 -0
- package/src/components/VirtualTable/VirtualTable.tsx +275 -0
- package/src/components/VirtualTable/styles.css.ts +135 -0
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -54,7 +54,8 @@
|
|
|
54
54
|
}
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@
|
|
57
|
+
"@tanstack/react-virtual": "^3.14.6",
|
|
58
|
+
"@duro-app/tokens": "^0.42.0"
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
60
61
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -21,6 +21,12 @@ export type IconName =
|
|
|
21
21
|
| 'git-branch'
|
|
22
22
|
| 'menu'
|
|
23
23
|
| 'pin'
|
|
24
|
+
// People / access / admin glyphs
|
|
25
|
+
| 'users'
|
|
26
|
+
| 'user-plus'
|
|
27
|
+
| 'mail'
|
|
28
|
+
| 'file-text'
|
|
29
|
+
| 'plug'
|
|
24
30
|
// Color-mode glyphs
|
|
25
31
|
| 'sun'
|
|
26
32
|
| 'moon'
|
|
@@ -153,6 +159,46 @@ const strokeIcons: Partial<Record<IconName, ReactNode>> = {
|
|
|
153
159
|
<line x1="3" y1="18" x2="21" y2="18" />
|
|
154
160
|
</>
|
|
155
161
|
),
|
|
162
|
+
// ---- people / access / admin ----
|
|
163
|
+
users: (
|
|
164
|
+
<>
|
|
165
|
+
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
|
166
|
+
<circle cx="9" cy="7" r="4" />
|
|
167
|
+
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
|
168
|
+
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
169
|
+
</>
|
|
170
|
+
),
|
|
171
|
+
'user-plus': (
|
|
172
|
+
<>
|
|
173
|
+
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
|
174
|
+
<circle cx="8.5" cy="7" r="4" />
|
|
175
|
+
<line x1="20" y1="8" x2="20" y2="14" />
|
|
176
|
+
<line x1="23" y1="11" x2="17" y2="11" />
|
|
177
|
+
</>
|
|
178
|
+
),
|
|
179
|
+
mail: (
|
|
180
|
+
<>
|
|
181
|
+
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
|
182
|
+
<polyline points="22,6 12,13 2,6" />
|
|
183
|
+
</>
|
|
184
|
+
),
|
|
185
|
+
'file-text': (
|
|
186
|
+
<>
|
|
187
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
188
|
+
<polyline points="14 2 14 8 20 8" />
|
|
189
|
+
<line x1="16" y1="13" x2="8" y2="13" />
|
|
190
|
+
<line x1="16" y1="17" x2="8" y2="17" />
|
|
191
|
+
<polyline points="10 9 9 9 8 9" />
|
|
192
|
+
</>
|
|
193
|
+
),
|
|
194
|
+
plug: (
|
|
195
|
+
<>
|
|
196
|
+
<path d="M12 22v-5" />
|
|
197
|
+
<path d="M9 8V2" />
|
|
198
|
+
<path d="M15 8V2" />
|
|
199
|
+
<path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z" />
|
|
200
|
+
</>
|
|
201
|
+
),
|
|
156
202
|
pin: (
|
|
157
203
|
<>
|
|
158
204
|
<line x1="12" y1="17" x2="12" y2="22" />
|
|
@@ -2,27 +2,33 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Vertical side navigation
|
|
6
|
-
whenToUse: [
|
|
5
|
+
'Vertical side navigation. Use collapsible `Group`s (chevron accordion) or static `Section`s (always-open headers) — or a mix. `Item`s take an optional `icon` and show an active left-marker. Supports controlled and uncontrolled active item. Compound component — Root is required.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'App-level sidebar navigation',
|
|
8
|
+
'Settings navigation with grouped sections',
|
|
9
|
+
'Flat menu with labelled, always-open regions — use Section instead of Group',
|
|
10
|
+
],
|
|
7
11
|
whenNotToUse: [
|
|
8
12
|
'In-page content switching — use Tabs',
|
|
9
13
|
'Top-level horizontal nav — use Inline with LinkButton',
|
|
10
14
|
],
|
|
11
15
|
anatomy: {
|
|
12
|
-
required: ['Root', '
|
|
16
|
+
required: ['Root', 'Item'],
|
|
17
|
+
optional: ['Group', 'Section'],
|
|
13
18
|
},
|
|
14
19
|
relatedTo: [
|
|
15
20
|
{component: 'Tabs', relationship: 'Tabs switch content in-place; SideNav navigates pages'},
|
|
16
21
|
{component: 'PageShell', relationship: 'Often placed alongside PageShell'},
|
|
17
22
|
],
|
|
18
|
-
example: `<SideNav.Root defaultValue="
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
<SideNav.Item value="
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
example: `<SideNav.Root defaultValue="identities">
|
|
24
|
+
{/* Static, always-open sections with icons (flat menu) */}
|
|
25
|
+
<SideNav.Section label="People & access">
|
|
26
|
+
<SideNav.Item value="identities" icon={<Icon name="users" size={18} />}>Identities</SideNav.Item>
|
|
27
|
+
<SideNav.Item value="grants" icon={<Icon name="key" size={18} />}>Grants</SideNav.Item>
|
|
28
|
+
</SideNav.Section>
|
|
29
|
+
{/* Or collapsible groups (accordion) */}
|
|
30
|
+
<SideNav.Group label="Advanced" defaultExpanded={false}>
|
|
31
|
+
<SideNav.Item value="plugins" icon={<Icon name="plug" size={18} />}>Plugins</SideNav.Item>
|
|
26
32
|
</SideNav.Group>
|
|
27
33
|
</SideNav.Root>`,
|
|
28
34
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
2
|
import {expect, fn} from 'storybook/test'
|
|
3
3
|
import {SideNav} from './SideNav'
|
|
4
|
+
import {Icon} from '../Icon/Icon'
|
|
4
5
|
|
|
5
6
|
const meta: Meta<typeof SideNav.Root> = {
|
|
6
7
|
title: 'Components/SideNav',
|
|
@@ -31,7 +32,7 @@ export const Default: Story = {
|
|
|
31
32
|
const nav = canvas.getByRole('navigation')
|
|
32
33
|
await expect(nav).toBeInTheDocument()
|
|
33
34
|
|
|
34
|
-
const activeItem = canvas.
|
|
35
|
+
const activeItem = canvas.getByRole('button', {name: 'Dashboard'})
|
|
35
36
|
await expect(activeItem).toHaveAttribute('aria-current', 'page')
|
|
36
37
|
},
|
|
37
38
|
}
|
|
@@ -74,7 +75,7 @@ export const DefaultExpandedWithActiveItem: Story = {
|
|
|
74
75
|
play: async ({canvas}) => {
|
|
75
76
|
const trigger = canvas.getByText('Settings')
|
|
76
77
|
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
77
|
-
const active = canvas.
|
|
78
|
+
const active = canvas.getByRole('button', {name: 'Security'})
|
|
78
79
|
await expect(active).toBeInTheDocument()
|
|
79
80
|
await expect(active).toHaveAttribute('aria-current', 'page')
|
|
80
81
|
},
|
|
@@ -91,9 +92,52 @@ export const Interactive: Story = {
|
|
|
91
92
|
</SideNav.Root>
|
|
92
93
|
),
|
|
93
94
|
play: async ({canvas, userEvent, args}) => {
|
|
94
|
-
const aboutItem = canvas.
|
|
95
|
+
const aboutItem = canvas.getByRole('button', {name: 'About'})
|
|
95
96
|
await userEvent.click(aboutItem)
|
|
96
97
|
await expect(args.onValueChange).toHaveBeenCalledWith('about')
|
|
97
98
|
await expect(aboutItem).toHaveAttribute('aria-current', 'page')
|
|
98
99
|
},
|
|
99
100
|
}
|
|
101
|
+
|
|
102
|
+
// Flat menu: static (non-collapsible) Section headers + per-item icons + the
|
|
103
|
+
// active left-marker. This is the AppChrome-style layout — no chevrons, every
|
|
104
|
+
// section always open.
|
|
105
|
+
export const FlatWithIcons: Story = {
|
|
106
|
+
render: (args) => (
|
|
107
|
+
<SideNav.Root {...args} defaultValue="grants">
|
|
108
|
+
<SideNav.Section label="People & access">
|
|
109
|
+
<SideNav.Item value="identities" icon={<Icon name="users" size={18} />}>
|
|
110
|
+
Identities
|
|
111
|
+
</SideNav.Item>
|
|
112
|
+
<SideNav.Item value="grants" icon={<Icon name="key" size={18} />}>
|
|
113
|
+
Grants
|
|
114
|
+
</SideNav.Item>
|
|
115
|
+
</SideNav.Section>
|
|
116
|
+
<SideNav.Section label="Requests & invites">
|
|
117
|
+
<SideNav.Item value="invitations" icon={<Icon name="mail" size={18} />}>
|
|
118
|
+
Invitations
|
|
119
|
+
</SideNav.Item>
|
|
120
|
+
<SideNav.Item value="invites" icon={<Icon name="user-plus" size={18} />}>
|
|
121
|
+
User Invites
|
|
122
|
+
</SideNav.Item>
|
|
123
|
+
</SideNav.Section>
|
|
124
|
+
</SideNav.Root>
|
|
125
|
+
),
|
|
126
|
+
play: async ({canvas, userEvent, args}) => {
|
|
127
|
+
// Static section headers render as plain text (no toggle button).
|
|
128
|
+
await expect(canvas.getByText('People & access')).toBeInTheDocument()
|
|
129
|
+
await expect(canvas.queryByRole('button', {name: 'People & access'})).toBeNull()
|
|
130
|
+
|
|
131
|
+
// All items are visible immediately (nothing is collapsed).
|
|
132
|
+
const grants = canvas.getByRole('button', {name: 'Grants'})
|
|
133
|
+
await expect(grants).toHaveAttribute('aria-current', 'page')
|
|
134
|
+
await expect(canvas.getByRole('button', {name: 'Identities'})).toBeInTheDocument()
|
|
135
|
+
await expect(canvas.getByRole('button', {name: 'User Invites'})).toBeInTheDocument()
|
|
136
|
+
|
|
137
|
+
// Selecting another item moves the active state.
|
|
138
|
+
const identities = canvas.getByRole('button', {name: 'Identities'})
|
|
139
|
+
await userEvent.click(identities)
|
|
140
|
+
await expect(args.onValueChange).toHaveBeenCalledWith('identities')
|
|
141
|
+
await expect(identities).toHaveAttribute('aria-current', 'page')
|
|
142
|
+
},
|
|
143
|
+
}
|
|
@@ -150,14 +150,39 @@ function Group({children, label, groupKey, defaultExpanded}: GroupProps) {
|
|
|
150
150
|
)
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
// --- Section ---
|
|
154
|
+
|
|
155
|
+
interface SectionProps {
|
|
156
|
+
children: ReactNode
|
|
157
|
+
label: string
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A static, non-collapsible grouping: an always-visible uppercase header with
|
|
162
|
+
* its items below. Unlike `Group` there is no chevron, toggle, or hidden state
|
|
163
|
+
* — use it when every section should stay open at a glance (a flat menu with
|
|
164
|
+
* labelled regions rather than an accordion).
|
|
165
|
+
*/
|
|
166
|
+
function Section({children, label}: SectionProps) {
|
|
167
|
+
return (
|
|
168
|
+
<html.div style={styles.section}>
|
|
169
|
+
<html.div style={styles.sectionLabel}>{label}</html.div>
|
|
170
|
+
{children}
|
|
171
|
+
</html.div>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
153
175
|
// --- Item ---
|
|
154
176
|
|
|
155
177
|
interface ItemProps {
|
|
156
178
|
value: string
|
|
157
179
|
children: ReactNode
|
|
180
|
+
/** Optional leading glyph (e.g. a design-system `<Icon />`). Inherits the
|
|
181
|
+
* item's text color, so it turns accent when the item is active. */
|
|
182
|
+
icon?: ReactNode
|
|
158
183
|
}
|
|
159
184
|
|
|
160
|
-
function Item({value, children}: ItemProps) {
|
|
185
|
+
function Item({value, children, icon}: ItemProps) {
|
|
161
186
|
const {activeValue, onSelect, registerItem} = useSideNav()
|
|
162
187
|
const isActive = activeValue === value
|
|
163
188
|
|
|
@@ -173,7 +198,13 @@ function Item({value, children}: ItemProps) {
|
|
|
173
198
|
style={[styles.item, isActive && styles.itemActive]}
|
|
174
199
|
aria-current={isActive ? 'page' : undefined}
|
|
175
200
|
>
|
|
176
|
-
{
|
|
201
|
+
<html.span aria-hidden style={[styles.marker, isActive && styles.markerActive]} />
|
|
202
|
+
{icon ? (
|
|
203
|
+
<html.span aria-hidden style={styles.itemIcon}>
|
|
204
|
+
{icon}
|
|
205
|
+
</html.span>
|
|
206
|
+
) : null}
|
|
207
|
+
<html.span style={styles.itemLabel}>{children}</html.span>
|
|
177
208
|
</html.button>
|
|
178
209
|
)
|
|
179
210
|
}
|
|
@@ -181,5 +212,6 @@ function Item({value, children}: ItemProps) {
|
|
|
181
212
|
export const SideNav = {
|
|
182
213
|
Root,
|
|
183
214
|
Group,
|
|
215
|
+
Section,
|
|
184
216
|
Item,
|
|
185
217
|
}
|
|
@@ -51,12 +51,31 @@ export const styles = css.create({
|
|
|
51
51
|
chevronOpen: {
|
|
52
52
|
transform: 'rotate(90deg)',
|
|
53
53
|
},
|
|
54
|
+
// Static (non-collapsible) section — see SideNav.Section.
|
|
55
|
+
section: {
|
|
56
|
+
display: 'flex',
|
|
57
|
+
flexDirection: 'column',
|
|
58
|
+
},
|
|
59
|
+
sectionLabel: {
|
|
60
|
+
paddingTop: spacing.md,
|
|
61
|
+
paddingBottom: spacing.xs,
|
|
62
|
+
paddingLeft: spacing.md,
|
|
63
|
+
paddingRight: spacing.md,
|
|
64
|
+
fontFamily: typography.fontFamily,
|
|
65
|
+
fontSize: typography.fontSizeXs,
|
|
66
|
+
fontWeight: typography.fontWeightSemibold,
|
|
67
|
+
textTransform: 'uppercase' as const,
|
|
68
|
+
letterSpacing: '0.06em',
|
|
69
|
+
color: colors.textMuted,
|
|
70
|
+
},
|
|
54
71
|
item: {
|
|
72
|
+
position: 'relative',
|
|
55
73
|
display: 'flex',
|
|
56
74
|
alignItems: 'center',
|
|
75
|
+
gap: spacing.sm,
|
|
57
76
|
paddingTop: '6px',
|
|
58
77
|
paddingBottom: '6px',
|
|
59
|
-
paddingLeft: spacing.
|
|
78
|
+
paddingLeft: spacing.md,
|
|
60
79
|
paddingRight: spacing.md,
|
|
61
80
|
fontFamily: typography.fontFamily,
|
|
62
81
|
fontSize: typography.fontSizeSm,
|
|
@@ -92,5 +111,36 @@ export const styles = css.create({
|
|
|
92
111
|
itemActive: {
|
|
93
112
|
color: colors.accent,
|
|
94
113
|
fontWeight: typography.fontWeightMedium,
|
|
114
|
+
backgroundColor: colors.bgCardHover,
|
|
115
|
+
},
|
|
116
|
+
// Active indicator — a bar at the rail's left edge that grows in on select.
|
|
117
|
+
marker: {
|
|
118
|
+
position: 'absolute',
|
|
119
|
+
left: 0,
|
|
120
|
+
top: '50%',
|
|
121
|
+
width: '3px',
|
|
122
|
+
height: 0,
|
|
123
|
+
transform: 'translateY(-50%)',
|
|
124
|
+
borderTopRightRadius: radii.full,
|
|
125
|
+
borderBottomRightRadius: radii.full,
|
|
126
|
+
backgroundColor: colors.accent,
|
|
127
|
+
transitionProperty: 'height',
|
|
128
|
+
transitionDuration: duration.fast,
|
|
129
|
+
transitionTimingFunction: easing.standard,
|
|
130
|
+
},
|
|
131
|
+
markerActive: {
|
|
132
|
+
height: '18px',
|
|
133
|
+
},
|
|
134
|
+
itemIcon: {
|
|
135
|
+
display: 'inline-flex',
|
|
136
|
+
alignItems: 'center',
|
|
137
|
+
justifyContent: 'center',
|
|
138
|
+
flexShrink: 0,
|
|
139
|
+
width: '18px',
|
|
140
|
+
height: '18px',
|
|
141
|
+
},
|
|
142
|
+
itemLabel: {
|
|
143
|
+
flexGrow: 1,
|
|
144
|
+
minWidth: 0,
|
|
95
145
|
},
|
|
96
146
|
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'Sortable data table that windows its rows above a threshold (default 150) with @tanstack/react-virtual, shows a floating position indicator, and reports the visible page so the caller can mirror it in the URL. Below the threshold it renders every row in one scroll (no pagination). URL-decoupled: pass initialPage + onVisiblePageChange. Web-only.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'Long lists/tables (hundreds to tens of thousands of rows) held client-side',
|
|
8
|
+
'When you want a scroll position reflected in the URL + a "rows X–Y of Z" indicator',
|
|
9
|
+
'Admin tables that may grow — small today renders all rows, large windows automatically',
|
|
10
|
+
],
|
|
11
|
+
whenNotToUse: [
|
|
12
|
+
'Small static tables where semantic markup matters — use Table / Table.FromTanstack',
|
|
13
|
+
'Server-paginated data (this windows an already-loaded array, it does not fetch on scroll)',
|
|
14
|
+
],
|
|
15
|
+
anatomy: {
|
|
16
|
+
required: ['data', 'columns'],
|
|
17
|
+
optional: [
|
|
18
|
+
'sorting',
|
|
19
|
+
'onSortingChange',
|
|
20
|
+
'initialPage',
|
|
21
|
+
'onVisiblePageChange',
|
|
22
|
+
'rangeLabel',
|
|
23
|
+
'onRowClick',
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
relatedTo: [
|
|
27
|
+
{
|
|
28
|
+
component: 'Table',
|
|
29
|
+
relationship: 'Table is semantic + paginated; VirtualTable windows large lists',
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
example: `<VirtualTable
|
|
33
|
+
data={rows}
|
|
34
|
+
columns={columns}
|
|
35
|
+
sorting={sorting}
|
|
36
|
+
onSortingChange={setSorting}
|
|
37
|
+
initialPage={Number(params.get('page')) || 1}
|
|
38
|
+
onVisiblePageChange={({page}) =>
|
|
39
|
+
setParams((p) => { const n = new URLSearchParams(p); page > 1 ? n.set('page', String(page)) : n.delete('page'); return n }, {replace: true, preventScrollReset: true})}
|
|
40
|
+
rangeLabel={({from, to, total}) => t('table.range', {from, to, total})}
|
|
41
|
+
onRowClick={(row) => navigate(\`/things/\${row.id}\`)}
|
|
42
|
+
/>`,
|
|
43
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
+
import {expect, fn, waitFor} from 'storybook/test'
|
|
3
|
+
import {createColumnHelper} from '@tanstack/react-table'
|
|
4
|
+
import {VirtualTable} from './VirtualTable'
|
|
5
|
+
|
|
6
|
+
interface Item {
|
|
7
|
+
id: string
|
|
8
|
+
name: string
|
|
9
|
+
value: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const col = createColumnHelper<Item>()
|
|
13
|
+
const columns = [
|
|
14
|
+
col.accessor('name', {header: 'Name', enableSorting: true}),
|
|
15
|
+
col.accessor('value', {header: 'Value', enableSorting: true}),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const makeData = (n: number): Item[] =>
|
|
19
|
+
Array.from({length: n}, (_, i) => ({id: `r${i}`, name: `Row ${i}`, value: i}))
|
|
20
|
+
|
|
21
|
+
const meta: Meta<typeof VirtualTable<Item>> = {
|
|
22
|
+
title: 'Components/VirtualTable',
|
|
23
|
+
component: VirtualTable,
|
|
24
|
+
args: {columns, getRowId: (r: Item) => r.id},
|
|
25
|
+
}
|
|
26
|
+
export default meta
|
|
27
|
+
type Story = StoryObj<typeof VirtualTable<Item>>
|
|
28
|
+
|
|
29
|
+
// Below the threshold: every row rendered, one scroll, no windowing/indicator.
|
|
30
|
+
export const SmallList: Story = {
|
|
31
|
+
args: {data: makeData(10)},
|
|
32
|
+
play: async ({canvas}) => {
|
|
33
|
+
// 1 header row + 10 body rows.
|
|
34
|
+
await waitFor(() => expect(canvas.getAllByRole('row')).toHaveLength(11))
|
|
35
|
+
await expect(canvas.getByText('Row 0')).toBeInTheDocument()
|
|
36
|
+
await expect(canvas.getByText('Row 9')).toBeInTheDocument()
|
|
37
|
+
// No floating position indicator at this size.
|
|
38
|
+
await expect(canvas.queryByText(/\/ 10$/)).toBeNull()
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Above the threshold: only a window of rows hits the DOM + the indicator shows.
|
|
43
|
+
export const Virtualized: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
data: makeData(500),
|
|
46
|
+
maxHeight: 300,
|
|
47
|
+
rangeLabel: ({from, to, total}) => `${from}-${to} of ${total}`,
|
|
48
|
+
onVisiblePageChange: fn(),
|
|
49
|
+
},
|
|
50
|
+
play: async ({canvas}) => {
|
|
51
|
+
// Windowed: far fewer than 500 body rows are in the DOM.
|
|
52
|
+
await waitFor(() => {
|
|
53
|
+
const rows = canvas.getAllByRole('row')
|
|
54
|
+
expect(rows.length).toBeGreaterThan(1) // header + some
|
|
55
|
+
expect(rows.length).toBeLessThan(80) // NOT 501 — proof of windowing
|
|
56
|
+
})
|
|
57
|
+
// The floating position indicator is present, starting at row 1.
|
|
58
|
+
await expect(canvas.getByText(/^1-\d+ of 500$/)).toBeInTheDocument()
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Scrolling reports a new page (for URL sync) once past a page boundary.
|
|
63
|
+
export const ScrollReportsPage: Story = {
|
|
64
|
+
args: {
|
|
65
|
+
data: makeData(500),
|
|
66
|
+
maxHeight: 300,
|
|
67
|
+
pageSize: 50,
|
|
68
|
+
rangeLabel: ({page, pages}) => `page ${page}/${pages}`,
|
|
69
|
+
onVisiblePageChange: fn(),
|
|
70
|
+
},
|
|
71
|
+
play: async ({canvas, args}) => {
|
|
72
|
+
const scroller = canvas.getByRole('table')
|
|
73
|
+
await waitFor(() => expect(canvas.getByText('page 1/10')).toBeInTheDocument())
|
|
74
|
+
// Wait past the 300ms mount suppress-write window (which stops the restore
|
|
75
|
+
// scroll from clobbering the URL) before scrolling for real.
|
|
76
|
+
await new Promise((r) => setTimeout(r, 400))
|
|
77
|
+
// Scroll well past the first 50-row page (row height ~44 → page ≈ 2200px).
|
|
78
|
+
scroller.scrollTo({top: 6000})
|
|
79
|
+
scroller.dispatchEvent(new Event('scroll'))
|
|
80
|
+
// The indicator reflects the new page (proves curPage recomputed on scroll)…
|
|
81
|
+
await waitFor(() => expect(canvas.queryByText('page 1/10')).toBeNull(), {timeout: 3000})
|
|
82
|
+
await expect(canvas.getByText(/^page \d+\/10$/).textContent).not.toBe('page 1/10')
|
|
83
|
+
// …and the visible-page callback fired so a caller could sync the URL.
|
|
84
|
+
await waitFor(() => expect(args.onVisiblePageChange).toHaveBeenCalled(), {timeout: 4000})
|
|
85
|
+
const calls = (args.onVisiblePageChange as ReturnType<typeof fn>).mock.calls
|
|
86
|
+
const maxPage = Math.max(...calls.map((c) => (c[0] as {page: number}).page))
|
|
87
|
+
await expect(maxPage).toBeGreaterThan(1)
|
|
88
|
+
},
|
|
89
|
+
}
|