@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.
@@ -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
+ })