@charcuterie/ui 3.7.0 → 3.8.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/VirtualizedGrid/VirtualizedGrid.d.ts +147 -0
- package/dist/VirtualizedGrid/VirtualizedGrid.d.ts.map +1 -0
- package/dist/VirtualizedGrid/VirtualizedGrid.js +177 -0
- package/dist/VirtualizedGrid/VirtualizedGrid.js.map +1 -0
- package/dist/VirtualizedGrid/gridGap.d.ts +20 -0
- package/dist/VirtualizedGrid/gridGap.d.ts.map +1 -0
- package/dist/VirtualizedGrid/gridGap.js +20 -0
- package/dist/VirtualizedGrid/gridGap.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/VirtualizedGrid/VirtualizedGrid.mdx +110 -0
- package/src/VirtualizedGrid/VirtualizedGrid.stories.tsx +258 -0
- package/src/VirtualizedGrid/VirtualizedGrid.test.tsx +182 -0
- package/src/VirtualizedGrid/VirtualizedGrid.tsx +340 -0
- package/src/VirtualizedGrid/gridGap.ts +19 -0
- package/src/index.ts +3 -0
- package/src/sourceRules.test.ts +10 -1
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
import { useState } from "react"
|
|
3
|
+
|
|
4
|
+
import { getColumnChoices } from "../AdaptiveGrid/chooseColumns.ts"
|
|
5
|
+
import type { BlockSizeResolver } from "../AdaptiveGrid/useAdaptiveColumns.ts"
|
|
6
|
+
import { StorySection } from "../board.storyHelpers.tsx"
|
|
7
|
+
import { Card } from "../Card/Card.tsx"
|
|
8
|
+
import { SegmentedControl } from "../SegmentedControl/SegmentedControl.tsx"
|
|
9
|
+
import { VirtualizedGrid } from "./VirtualizedGrid.tsx"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A fixed block size, so a story shows the rule rather than the
|
|
13
|
+
* height of whichever frame Storybook happened to render it in.
|
|
14
|
+
* The same seam `AdaptiveGrid`'s stories inject, and for the same
|
|
15
|
+
* reason: in a real chromium `innerHeight` is a read-only accessor
|
|
16
|
+
* and assigning to it is a silent no-op.
|
|
17
|
+
*/
|
|
18
|
+
const fixedBlockSize = (
|
|
19
|
+
blockSize: number,
|
|
20
|
+
): BlockSizeResolver => ({
|
|
21
|
+
get: () => blockSize,
|
|
22
|
+
subscribe: () => () => {},
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const COLUMN_ITEMS = getColumnChoices({
|
|
26
|
+
maxColumns: 4,
|
|
27
|
+
}).map((choice) => ({
|
|
28
|
+
label: String(choice),
|
|
29
|
+
value: String(choice),
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
type Bay = {
|
|
33
|
+
id: string
|
|
34
|
+
label: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Two thousand of them, which is the point.
|
|
39
|
+
*
|
|
40
|
+
* The number is not decoration: it is roughly what QueuePilot's
|
|
41
|
+
* Pending page holds, and it is the size at which rendering every
|
|
42
|
+
* item stops being viable. A story with twelve items would mount
|
|
43
|
+
* the same component and prove nothing about it.
|
|
44
|
+
*/
|
|
45
|
+
const MANY_ITEMS: Bay[] = Array.from(
|
|
46
|
+
{ length: 2000 },
|
|
47
|
+
(_, index) => ({
|
|
48
|
+
id: `bay-${index + 1}`,
|
|
49
|
+
label: `Bay ${index + 1}`,
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const Item = ({ label }: { label: string }) => (
|
|
54
|
+
<Card padding="md" surface="raised">
|
|
55
|
+
<p className="font-semibold text-content-primary">
|
|
56
|
+
{label}
|
|
57
|
+
</p>
|
|
58
|
+
|
|
59
|
+
{/*
|
|
60
|
+
* An unbroken string with no wrap opportunity — the thing a
|
|
61
|
+
* grid item's `min-content` minimum turns into a horizontal
|
|
62
|
+
* scrollbar. `truncate` clips it; `[&>*]:min-w-0` on the row
|
|
63
|
+
* is what lets the track get small enough for the clipping to
|
|
64
|
+
* matter.
|
|
65
|
+
*/}
|
|
66
|
+
<p className="truncate text-content-muted text-sm">
|
|
67
|
+
/mnt/Bunnies/Family/Media/very/long/unbroken/path/that/would/push/a/track/wide
|
|
68
|
+
</p>
|
|
69
|
+
</Card>
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const meta = {
|
|
73
|
+
title: "Components/Layout/VirtualizedGrid",
|
|
74
|
+
component: VirtualizedGrid<Bay>,
|
|
75
|
+
parameters: { layout: "padded" },
|
|
76
|
+
args: {
|
|
77
|
+
blockSizeResolver: fixedBlockSize(1080),
|
|
78
|
+
chromeBlockSize: 260,
|
|
79
|
+
getItemKey: (item: Bay) => item.id,
|
|
80
|
+
itemBlockSize: 150,
|
|
81
|
+
items: MANY_ITEMS,
|
|
82
|
+
label: "Bays",
|
|
83
|
+
renderItem: (item: Bay) => <Item label={item.label} />,
|
|
84
|
+
},
|
|
85
|
+
argTypes: {
|
|
86
|
+
blockSizeResolver: { control: false },
|
|
87
|
+
getItemKey: { control: false },
|
|
88
|
+
items: { control: false },
|
|
89
|
+
renderItem: { control: false },
|
|
90
|
+
},
|
|
91
|
+
} satisfies Meta<typeof VirtualizedGrid<Bay>>
|
|
92
|
+
|
|
93
|
+
export default meta
|
|
94
|
+
|
|
95
|
+
type Story = StoryObj<typeof meta>
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Two thousand items, of which a few dozen exist.
|
|
99
|
+
*
|
|
100
|
+
* The scrollbar is sized for all 2,000 — `getTotalSize()` is the
|
|
101
|
+
* full list — so the page behaves as though every item were there.
|
|
102
|
+
* Inspect the DOM and count: the row elements are the ones on
|
|
103
|
+
* screen plus `overscanRows` above and below.
|
|
104
|
+
*/
|
|
105
|
+
export const Default: Story = {}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The same 2,000 items at three window heights.
|
|
109
|
+
*
|
|
110
|
+
* Inherited from `AdaptiveGrid` rather than reimplemented, so the
|
|
111
|
+
* rule that reads backwards holds here too: a **taller** window
|
|
112
|
+
* takes **fewer** columns, because height is what a column is
|
|
113
|
+
* bought with. Windowing changes how many rows are mounted, never
|
|
114
|
+
* how many columns are drawn.
|
|
115
|
+
*/
|
|
116
|
+
export const AllVariants: Story = {
|
|
117
|
+
render: (gridProps) => (
|
|
118
|
+
<StorySection title="Same items, same width — only the height changes.">
|
|
119
|
+
{[
|
|
120
|
+
{
|
|
121
|
+
blockSize: 1440,
|
|
122
|
+
label: "1440px tall",
|
|
123
|
+
},
|
|
124
|
+
{ blockSize: 900, label: "900px tall" },
|
|
125
|
+
{ blockSize: 600, label: "600px tall" },
|
|
126
|
+
].map((panel) => (
|
|
127
|
+
<div
|
|
128
|
+
className="flex w-full flex-col gap-2"
|
|
129
|
+
key={panel.label}
|
|
130
|
+
>
|
|
131
|
+
<span className="font-mono text-content-muted text-xs">
|
|
132
|
+
{panel.label}
|
|
133
|
+
</span>
|
|
134
|
+
|
|
135
|
+
<VirtualizedGrid
|
|
136
|
+
{...gridProps}
|
|
137
|
+
blockSizeResolver={fixedBlockSize(
|
|
138
|
+
panel.blockSize,
|
|
139
|
+
)}
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
))}
|
|
143
|
+
</StorySection>
|
|
144
|
+
),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Empty, one item, and two thousand.
|
|
149
|
+
*
|
|
150
|
+
* The empty case draws a zero-height box and no rows — a windowed
|
|
151
|
+
* grid must not reserve space for a list that is not there, and it
|
|
152
|
+
* is the state a filter lands on. One item is one column, the same
|
|
153
|
+
* answer `AdaptiveGrid` gives, and the case that would otherwise
|
|
154
|
+
* stretch a lone card across an ultrawide.
|
|
155
|
+
*/
|
|
156
|
+
export const AllStates: Story = {
|
|
157
|
+
render: (gridProps) => (
|
|
158
|
+
<div className="flex flex-col gap-6">
|
|
159
|
+
{[
|
|
160
|
+
{ items: [], label: "no items" },
|
|
161
|
+
{
|
|
162
|
+
items: MANY_ITEMS.slice(0, 1),
|
|
163
|
+
label: "one item",
|
|
164
|
+
},
|
|
165
|
+
{ items: MANY_ITEMS, label: "2,000 items" },
|
|
166
|
+
].map((panel) => (
|
|
167
|
+
<div
|
|
168
|
+
className="flex w-full flex-col gap-2"
|
|
169
|
+
key={panel.label}
|
|
170
|
+
>
|
|
171
|
+
<span className="font-mono text-content-muted text-xs">
|
|
172
|
+
{panel.label}
|
|
173
|
+
</span>
|
|
174
|
+
|
|
175
|
+
<VirtualizedGrid
|
|
176
|
+
{...gridProps}
|
|
177
|
+
items={panel.items}
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
))}
|
|
181
|
+
</div>
|
|
182
|
+
),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Three container widths, with the column floor dropped to 160px.
|
|
187
|
+
*
|
|
188
|
+
* The browser never moves and the column count still falls, which
|
|
189
|
+
* is the honest demonstration that the inline size comes off a
|
|
190
|
+
* `ResizeObserver` on the container rather than off the window.
|
|
191
|
+
*/
|
|
192
|
+
export const Responsive: Story = {
|
|
193
|
+
args: {
|
|
194
|
+
blockSizeResolver: fixedBlockSize(600),
|
|
195
|
+
minColumnInlineSize: 160,
|
|
196
|
+
},
|
|
197
|
+
render: (gridProps) => (
|
|
198
|
+
<div className="flex flex-wrap items-start gap-6">
|
|
199
|
+
{[
|
|
200
|
+
{ inlineSize: "15rem", label: "15rem container" },
|
|
201
|
+
{ inlineSize: "24rem", label: "24rem container" },
|
|
202
|
+
{ inlineSize: "34rem", label: "34rem container" },
|
|
203
|
+
].map((panel) => (
|
|
204
|
+
<div
|
|
205
|
+
// `shrink-0`, or flex shrinks the widest panel back to
|
|
206
|
+
// the viewport and all three grids answer the same.
|
|
207
|
+
className="flex shrink-0 flex-col gap-2"
|
|
208
|
+
key={panel.label}
|
|
209
|
+
style={{ inlineSize: panel.inlineSize }}
|
|
210
|
+
>
|
|
211
|
+
<span className="font-mono text-content-muted text-xs">
|
|
212
|
+
{panel.label}
|
|
213
|
+
</span>
|
|
214
|
+
|
|
215
|
+
<VirtualizedGrid
|
|
216
|
+
{...gridProps}
|
|
217
|
+
items={MANY_ITEMS.slice(0, 60)}
|
|
218
|
+
/>
|
|
219
|
+
</div>
|
|
220
|
+
))}
|
|
221
|
+
</div>
|
|
222
|
+
),
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The manual override beside the automatic answer, on a windowed
|
|
227
|
+
* list.
|
|
228
|
+
*
|
|
229
|
+
* Changing the count re-rows 2,000 items — the row count is
|
|
230
|
+
* `ceil(items / columns)` — so this is also the case that proves
|
|
231
|
+
* the virtualizer is re-measured rather than left holding
|
|
232
|
+
* positions for a layout that no longer exists.
|
|
233
|
+
*/
|
|
234
|
+
export const Interactive: Story = {
|
|
235
|
+
render: (gridProps) => {
|
|
236
|
+
const [choice, setChoice] = useState<string>("auto")
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<StorySection title="Pick a count, or hand it back to auto.">
|
|
240
|
+
<SegmentedControl
|
|
241
|
+
items={COLUMN_ITEMS}
|
|
242
|
+
label="Columns"
|
|
243
|
+
onChange={(value) => {
|
|
244
|
+
setChoice(value ?? "auto")
|
|
245
|
+
}}
|
|
246
|
+
selectedValue={choice}
|
|
247
|
+
/>
|
|
248
|
+
|
|
249
|
+
<VirtualizedGrid
|
|
250
|
+
{...gridProps}
|
|
251
|
+
columns={
|
|
252
|
+
choice === "auto" ? undefined : Number(choice)
|
|
253
|
+
}
|
|
254
|
+
/>
|
|
255
|
+
</StorySection>
|
|
256
|
+
)
|
|
257
|
+
},
|
|
258
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, waitFor } from "storybook/test"
|
|
3
|
+
import { test } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
6
|
+
import * as stories from "./VirtualizedGrid.stories.tsx"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What windowing has to be true for.
|
|
10
|
+
*
|
|
11
|
+
* `chooseColumns.test.ts` already proves the column arithmetic and
|
|
12
|
+
* `AdaptiveGrid.test.tsx` already proves it is fed real measured
|
|
13
|
+
* numbers. Neither is re-proved here — this component calls the
|
|
14
|
+
* same hook, and a second copy of those assertions would only
|
|
15
|
+
* guarantee that a copy stays in step.
|
|
16
|
+
*
|
|
17
|
+
* What is new, and what these cover:
|
|
18
|
+
*
|
|
19
|
+
* - **Far fewer elements exist than items.** The claim the
|
|
20
|
+
* component is for. Asserted as a bound rather than an exact
|
|
21
|
+
* count, because the exact count is viewport height divided by
|
|
22
|
+
* row height and would pin the test to the runner's window.
|
|
23
|
+
* - **The page is still as tall as the whole list.** A windowed
|
|
24
|
+
* grid that shortens its own scrollbar has traded one bug for a
|
|
25
|
+
* worse one. Here that height lives in `padding-block-end`.
|
|
26
|
+
* - **Scrolling swaps which rows exist.** The distinguishing
|
|
27
|
+
* test: a component that merely rendered the first N items
|
|
28
|
+
* would pass the two above and fail this one.
|
|
29
|
+
* - **The true length reaches assistive technology.**
|
|
30
|
+
* `aria-setsize` is the only thing standing between a reader
|
|
31
|
+
* and "list, 40 items" on a list of 2,000.
|
|
32
|
+
* - **An empty list reserves no space.** The state a filter lands
|
|
33
|
+
* on.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const { AllStates, Default } = composeStories(stories)
|
|
37
|
+
|
|
38
|
+
const getLists = (canvasElement: HTMLElement) => [
|
|
39
|
+
...canvasElement.querySelectorAll<HTMLElement>("ul"),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
const getList = (
|
|
43
|
+
canvasElement: HTMLElement,
|
|
44
|
+
index = 0,
|
|
45
|
+
): HTMLElement => {
|
|
46
|
+
const list = getLists(canvasElement)[index]
|
|
47
|
+
|
|
48
|
+
if (!list) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`No VirtualizedGrid at index ${index}; found ${getLists(canvasElement).length}.`,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return list
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const getCells = (canvasElement: HTMLElement) => [
|
|
58
|
+
...canvasElement.querySelectorAll<HTMLElement>("li"),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Which rows are mounted.
|
|
63
|
+
*
|
|
64
|
+
* `data-index` names a row and sits on every cell in it, so this
|
|
65
|
+
* is a set rather than a list — several cells report the same row,
|
|
66
|
+
* and that is the attribute working rather than a duplicate.
|
|
67
|
+
*/
|
|
68
|
+
const getRowIndexes = (canvasElement: HTMLElement) => [
|
|
69
|
+
...new Set(
|
|
70
|
+
[
|
|
71
|
+
...canvasElement.querySelectorAll<HTMLElement>(
|
|
72
|
+
"[data-index]",
|
|
73
|
+
),
|
|
74
|
+
].map((cell) => Number(cell.dataset.index)),
|
|
75
|
+
),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
test("2,000 items mount as a few dozen cells", async () => {
|
|
79
|
+
const { canvasElement } = await mountStory(Default)
|
|
80
|
+
|
|
81
|
+
await waitFor(async () => {
|
|
82
|
+
await expect(
|
|
83
|
+
getCells(canvasElement).length,
|
|
84
|
+
).toBeGreaterThan(0)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// The bound is deliberately loose — the runner's window is
|
|
88
|
+
// 414x896, and the honest claim is "two orders of magnitude
|
|
89
|
+
// fewer", not a number that changes when someone resizes the
|
|
90
|
+
// harness.
|
|
91
|
+
await expect(getCells(canvasElement).length).toBeLessThan(
|
|
92
|
+
200,
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test("the page is still as tall as the whole list", async () => {
|
|
97
|
+
const { canvasElement } = await mountStory(Default)
|
|
98
|
+
|
|
99
|
+
await waitFor(async () => {
|
|
100
|
+
await expect(
|
|
101
|
+
getRowIndexes(canvasElement).length,
|
|
102
|
+
).toBeGreaterThan(0)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// At the top of the list, everything still to come is
|
|
106
|
+
// `padding-block-end`. 2,000 items at 150px plus a 16px gap, in
|
|
107
|
+
// however many columns the runner chose, is tens of thousands of
|
|
108
|
+
// pixels — far more than the handful of mounted rows occupy,
|
|
109
|
+
// which is the whole assertion.
|
|
110
|
+
const list = getList(canvasElement)
|
|
111
|
+
|
|
112
|
+
await expect(
|
|
113
|
+
Number.parseFloat(list.style.paddingBlockEnd),
|
|
114
|
+
).toBeGreaterThan(10_000)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test("scrolling changes which rows exist", async () => {
|
|
118
|
+
const { canvasElement } = await mountStory(Default)
|
|
119
|
+
|
|
120
|
+
await waitFor(async () => {
|
|
121
|
+
await expect(
|
|
122
|
+
getRowIndexes(canvasElement).length,
|
|
123
|
+
).toBeGreaterThan(0)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const before = getRowIndexes(canvasElement)
|
|
127
|
+
|
|
128
|
+
window.scrollTo(0, 6000)
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
await waitFor(async () => {
|
|
132
|
+
// Not "some overlap changed" — no row that was mounted
|
|
133
|
+
// before may still be mounted, which is what separates
|
|
134
|
+
// windowing from a list that merely grew.
|
|
135
|
+
await expect(
|
|
136
|
+
getRowIndexes(canvasElement).some((index) =>
|
|
137
|
+
before.includes(index),
|
|
138
|
+
),
|
|
139
|
+
).toBe(false)
|
|
140
|
+
})
|
|
141
|
+
} finally {
|
|
142
|
+
// The window is shared with every later test in the file, and
|
|
143
|
+
// a canvas left scrolled would mount a different set of rows
|
|
144
|
+
// in the next one.
|
|
145
|
+
window.scrollTo(0, 0)
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test("a screen reader is told the real length", async () => {
|
|
150
|
+
const { canvasElement } = await mountStory(Default)
|
|
151
|
+
|
|
152
|
+
await waitFor(async () => {
|
|
153
|
+
await expect(
|
|
154
|
+
getCells(canvasElement).length,
|
|
155
|
+
).toBeGreaterThan(0)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const [firstCell] = getCells(canvasElement)
|
|
159
|
+
|
|
160
|
+
await expect(
|
|
161
|
+
firstCell?.getAttribute("aria-setsize"),
|
|
162
|
+
).toBe("2000")
|
|
163
|
+
|
|
164
|
+
await expect(
|
|
165
|
+
firstCell?.getAttribute("aria-posinset"),
|
|
166
|
+
).toBe("1")
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test("an empty list reserves no space and mounts no rows", async () => {
|
|
170
|
+
const { canvasElement } = await mountStory(AllStates)
|
|
171
|
+
|
|
172
|
+
// `AllStates` draws three grids in order: none, one, 2,000.
|
|
173
|
+
const empty = getList(canvasElement, 0)
|
|
174
|
+
|
|
175
|
+
await waitFor(async () => {
|
|
176
|
+
await expect(empty.style.paddingBlockEnd).toBe("0px")
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
await expect(empty.style.paddingBlockStart).toBe("0px")
|
|
180
|
+
|
|
181
|
+
await expect(empty.querySelectorAll("li").length).toBe(0)
|
|
182
|
+
})
|