@charcuterie/ui 2.7.0 → 2.9.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.
Files changed (117) hide show
  1. package/README.md +11 -0
  2. package/dist/AdaptiveGrid/AdaptiveGrid.d.ts +94 -0
  3. package/dist/AdaptiveGrid/AdaptiveGrid.d.ts.map +1 -0
  4. package/dist/AdaptiveGrid/AdaptiveGrid.js +73 -0
  5. package/dist/AdaptiveGrid/AdaptiveGrid.js.map +1 -0
  6. package/dist/AdaptiveGrid/chooseColumns.d.ts +206 -0
  7. package/dist/AdaptiveGrid/chooseColumns.d.ts.map +1 -0
  8. package/dist/AdaptiveGrid/chooseColumns.js +156 -0
  9. package/dist/AdaptiveGrid/chooseColumns.js.map +1 -0
  10. package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts +155 -0
  11. package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts.map +1 -0
  12. package/dist/AdaptiveGrid/useAdaptiveColumns.js +153 -0
  13. package/dist/AdaptiveGrid/useAdaptiveColumns.js.map +1 -0
  14. package/dist/Button/Button.d.ts.map +1 -1
  15. package/dist/Button/Button.js +11 -6
  16. package/dist/Button/Button.js.map +1 -1
  17. package/dist/ButtonLink/ButtonLink.d.ts +59 -0
  18. package/dist/ButtonLink/ButtonLink.d.ts.map +1 -0
  19. package/dist/ButtonLink/ButtonLink.js +69 -0
  20. package/dist/ButtonLink/ButtonLink.js.map +1 -0
  21. package/dist/Combobox/Combobox.d.ts.map +1 -1
  22. package/dist/Combobox/Combobox.js +1 -1
  23. package/dist/Combobox/Combobox.js.map +1 -1
  24. package/dist/Dialog/Dialog.d.ts.map +1 -1
  25. package/dist/Dialog/Dialog.js +3 -1
  26. package/dist/Dialog/Dialog.js.map +1 -1
  27. package/dist/Field/Field.d.ts.map +1 -1
  28. package/dist/Field/Field.js +1 -1
  29. package/dist/Field/Field.js.map +1 -1
  30. package/dist/Field/FieldGroup.d.ts.map +1 -1
  31. package/dist/Field/FieldGroup.js +1 -1
  32. package/dist/Field/FieldGroup.js.map +1 -1
  33. package/dist/LogViewer/LogViewer.d.ts.map +1 -1
  34. package/dist/LogViewer/LogViewer.js +33 -1
  35. package/dist/LogViewer/LogViewer.js.map +1 -1
  36. package/dist/RouterLink/AnchorLink.d.ts +17 -0
  37. package/dist/RouterLink/AnchorLink.d.ts.map +1 -0
  38. package/dist/RouterLink/AnchorLink.js +16 -0
  39. package/dist/RouterLink/AnchorLink.js.map +1 -0
  40. package/dist/RouterLink/RouterLinkProvider.d.ts +13 -0
  41. package/dist/RouterLink/RouterLinkProvider.d.ts.map +1 -0
  42. package/dist/RouterLink/RouterLinkProvider.js +47 -0
  43. package/dist/RouterLink/RouterLinkProvider.js.map +1 -0
  44. package/dist/RouterLink/routerLink.d.ts +45 -0
  45. package/dist/RouterLink/routerLink.d.ts.map +1 -0
  46. package/dist/RouterLink/routerLink.js +31 -0
  47. package/dist/RouterLink/routerLink.js.map +1 -0
  48. package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -1
  49. package/dist/SortableTableHeader/SortableTableHeader.js +7 -1
  50. package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -1
  51. package/dist/TextLink/TextLink.d.ts +69 -0
  52. package/dist/TextLink/TextLink.d.ts.map +1 -0
  53. package/dist/TextLink/TextLink.js +79 -0
  54. package/dist/TextLink/TextLink.js.map +1 -0
  55. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  56. package/dist/Tooltip/Tooltip.js +5 -1
  57. package/dist/Tooltip/Tooltip.js.map +1 -1
  58. package/dist/controlStyles.d.ts +36 -1
  59. package/dist/controlStyles.d.ts.map +1 -1
  60. package/dist/controlStyles.js +30 -0
  61. package/dist/controlStyles.js.map +1 -1
  62. package/dist/index.d.ts +16 -2
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +10 -2
  65. package/dist/index.js.map +1 -1
  66. package/dist/intentStyles.d.ts +17 -0
  67. package/dist/intentStyles.d.ts.map +1 -1
  68. package/dist/intentStyles.js +17 -0
  69. package/dist/intentStyles.js.map +1 -1
  70. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  71. package/dist/mountStory.testHelpers.js +56 -0
  72. package/dist/mountStory.testHelpers.js.map +1 -1
  73. package/dist/reactRouter/ReactRouterLink.d.ts +23 -0
  74. package/dist/reactRouter/ReactRouterLink.d.ts.map +1 -0
  75. package/dist/reactRouter/ReactRouterLink.js +23 -0
  76. package/dist/reactRouter/ReactRouterLink.js.map +1 -0
  77. package/dist/reactRouter/index.d.ts +29 -0
  78. package/dist/reactRouter/index.d.ts.map +1 -0
  79. package/dist/reactRouter/index.js +29 -0
  80. package/dist/reactRouter/index.js.map +1 -0
  81. package/package.json +15 -3
  82. package/src/AdaptiveGrid/AdaptiveGrid.mdx +169 -0
  83. package/src/AdaptiveGrid/AdaptiveGrid.stories.tsx +240 -0
  84. package/src/AdaptiveGrid/AdaptiveGrid.test.tsx +243 -0
  85. package/src/AdaptiveGrid/AdaptiveGrid.tsx +150 -0
  86. package/src/AdaptiveGrid/chooseColumns.test.ts +253 -0
  87. package/src/AdaptiveGrid/chooseColumns.ts +270 -0
  88. package/src/AdaptiveGrid/useAdaptiveColumns.ts +355 -0
  89. package/src/Button/Button.tsx +10 -22
  90. package/src/ButtonLink/ButtonLink.mdx +75 -0
  91. package/src/ButtonLink/ButtonLink.stories.tsx +281 -0
  92. package/src/ButtonLink/ButtonLink.test.tsx +203 -0
  93. package/src/ButtonLink/ButtonLink.tsx +149 -0
  94. package/src/Combobox/Combobox.tsx +4 -1
  95. package/src/Dialog/Dialog.tsx +18 -2
  96. package/src/Field/Field.tsx +10 -2
  97. package/src/Field/FieldGroup.tsx +4 -2
  98. package/src/LogViewer/LogViewer.test.tsx +47 -0
  99. package/src/LogViewer/LogViewer.tsx +35 -1
  100. package/src/RouterLink/AnchorLink.tsx +23 -0
  101. package/src/RouterLink/RouterLink.mdx +178 -0
  102. package/src/RouterLink/RouterLinkProvider.tsx +68 -0
  103. package/src/RouterLink/routerLink.ts +56 -0
  104. package/src/SortableTableHeader/SortableTableHeader.tsx +6 -1
  105. package/src/TextLink/TextLink.mdx +93 -0
  106. package/src/TextLink/TextLink.stories.tsx +260 -0
  107. package/src/TextLink/TextLink.test.tsx +230 -0
  108. package/src/TextLink/TextLink.tsx +194 -0
  109. package/src/Tooltip/Tooltip.tsx +5 -1
  110. package/src/controlStyles.ts +69 -1
  111. package/src/icons.storyHelpers.tsx +32 -0
  112. package/src/index.ts +41 -0
  113. package/src/intentStyles.ts +19 -0
  114. package/src/mountStory.testHelpers.ts +63 -0
  115. package/src/reactRouter/ReactRouterLink.tsx +30 -0
  116. package/src/reactRouter/index.ts +29 -0
  117. package/src/sourceRules.test.ts +41 -6
@@ -0,0 +1,169 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as AdaptiveGridStories from "./AdaptiveGrid.stories";
4
+
5
+ <Meta of={AdaptiveGridStories} />
6
+
7
+ # AdaptiveGrid
8
+
9
+ **Height is spent before width.** A column is something this grid is *forced* into when
10
+ the items will not stack inside the window — never something it takes because a monitor
11
+ is wide.
12
+
13
+ That is the opposite of every wrapping grid in the fleet today. `auto-fill, minmax()`
14
+ takes every column the width allows, which is how you end up with seven items strung
15
+ across an ultrawide and nothing below the fold.
16
+
17
+ <Canvas of={AdaptiveGridStories.Default} />
18
+
19
+ ## Props
20
+
21
+ <Controls />
22
+
23
+ ## The rule in one sentence
24
+
25
+ > If you're going to vertically scroll anyway, widen the items. If you're not, keep them
26
+ > in a smaller grid so they all still fit on screen.
27
+
28
+ Mechanically:
29
+
30
+ 1. Ask how many items stack in the space available — the viewport's block size, minus
31
+ whatever chrome sits above and below.
32
+ 2. Divide the item count by that. **That** is how many columns are needed.
33
+ 3. Only then ask how many columns actually fit across the container, and take the smaller
34
+ of the two.
35
+
36
+ Step 3 can only ever *reduce* the answer. Width is a veto, not a reason.
37
+
38
+ ## The part that reads backwards
39
+
40
+ <Canvas of={AdaptiveGridStories.AllVariants} />
41
+
42
+ Same nine items, same width, three heights — and the **taller** window takes **fewer**
43
+ columns. A 1440x900 laptop gets three columns while a larger 1920x1080 monitor gets two.
44
+
45
+ That is not a bug and it is not a tuned breakpoint. The taller window stacks the same nine
46
+ items in fewer stacks, so it needs fewer stacks. If you "fix" it, you have thrown the rule
47
+ away and rebuilt `auto-fill`.
48
+
49
+ ## The content cap widens as columns are added
50
+
51
+ The other half of the complaint this answers is pages that are "1 column, but waaaaaaay
52
+ too wide". So the cap around the grid is not a constant:
53
+
54
+ | Columns | Cap |
55
+ | --- | --- |
56
+ | 1 | 56rem |
57
+ | 2 | 72rem |
58
+ | 3 | 106rem |
59
+ | 4 | 140rem |
60
+
61
+ One column stays at a reading measure, because a 2000px-wide card is not a better card —
62
+ it is one line of text the eye has to track from bezel to bezel. The page earns its width
63
+ by having something to fill it with.
64
+
65
+ The three numbers behind that table are `contentInlineSize` in `@charcuterie/tokens`,
66
+ beside `screen` and `containerQuery`, and every part is overridable per call. The
67
+ arithmetic lives in `getContentMaxInlineSize` and is not a token.
68
+
69
+ ## When to reach for it
70
+
71
+ Reach for it when **all** of these are true:
72
+
73
+ - The items wrap, and there could be few or many of them.
74
+ - They are roughly the same height, and you can measure one.
75
+ - Scrolling is a cost rather than the point — a rack of bays, a fleet of hosts, a set of
76
+ status cards. Ideally the user never scrolls at all.
77
+
78
+ Do **not** reach for it for a feed, a search-results list, or anything else that is
79
+ inherently long. The rule optimises for "it all fits", and content that will never all fit
80
+ gets nothing from it.
81
+
82
+ ## The one number you must measure
83
+
84
+ `itemBlockSize` is the only required option and the only genuinely app-specific one.
85
+ Measure it off a running page rather than deriving it from padding, and **err high** — over-
86
+ estimating makes the grid reach for a column slightly early, and a page that scrolls when
87
+ it did not have to is the entire complaint. It only has to be right to within about one
88
+ item; being one out shifts a borderline window by one column, which is taste.
89
+
90
+ `chromeBlockSize` is everything down the page that is not the grid: header, rails, filter
91
+ rows, labels. It defaults to `0`.
92
+
93
+ ## It measures its container, not the window
94
+
95
+ <Canvas of={AdaptiveGridStories.Responsive} />
96
+
97
+ Three grids, one browser window, three different answers. The inline size comes off a
98
+ `ResizeObserver` on the grid's own container, so a grid sitting beside a rail is told the
99
+ truth about the room it has — the container-query thesis this library is built on, applied
100
+ to a layout decision instead of a card's internals.
101
+
102
+ The block size is the exception, and it has to be: the question is "will this scroll", and
103
+ an element in normal flow is exactly as tall as its contents, so measuring the grid always
104
+ answers "it fits." That number comes from the viewport, behind an injectable
105
+ `blockSizeResolver` for Electron windows, fixed-height kiosk panels, and tests.
106
+
107
+ ⚠️ **Never apply the returned cap to the element being measured.** If you wire
108
+ `useAdaptiveColumns` by hand, the observed box and the capped box must be different
109
+ elements. Collapse them and the fold reads back its own narrowed output: one column,
110
+ forever, with no error anywhere. `AdaptiveGrid` enforces this structurally — an uncapped
111
+ outer box it measures, an inner box it caps.
112
+
113
+ ## Composing with a shell
114
+
115
+ `AdaptiveGrid` caps itself, so it is correct standing alone. If you want the **page chrome**
116
+ to widen with the grid rather than the grid rattling around inside a fixed column, hoist
117
+ the hook to the page and hand the pieces out:
118
+
119
+ ```tsx
120
+ const layout = useAdaptiveColumns({
121
+ chromeBlockSize: 260,
122
+ itemBlockSize: 150,
123
+ itemCount: bays.length,
124
+ storageKey: "rip-deck.layout-columns",
125
+ })
126
+
127
+ <Main maxInlineSize={layout.contentMaxInlineSize} ref={layout.containerRef}>
128
+ <AdaptiveGrid columns={layout.columns} itemBlockSize={150}>
129
+ {bays.map(…)}
130
+ </AdaptiveGrid>
131
+ </Main>
132
+ ```
133
+
134
+ The `Main` region takes a caller-supplied max inline size; the ref goes on whatever box
135
+ sits **outside** that cap.
136
+
137
+ ## Manual override
138
+
139
+ <Canvas of={AdaptiveGridStories.Interactive} />
140
+
141
+ `"auto"` is always in the list. A picker that only offers numbers once a number has been
142
+ chosen is a one-way door — it strands someone on a layout they tried once with no route
143
+ back to the mode that adapts.
144
+
145
+ Pass `storageKey` to remember the choice across visits, namespaced to your app; the
146
+ library will not invent that string for you. Omit it and the choice lasts the session and
147
+ is written nowhere. Every unreadable stored value — a corrupt key, a browser that refuses
148
+ storage, a count the picker cannot offer — falls back to `"auto"`, so a bad value costs a
149
+ preference rather than a page.
150
+
151
+ ## Why the tracks are an inline style
152
+
153
+ `repeat(N, minmax(0, 1fr))` is interpolated, and a className may not be. Tailwind v4 scans
154
+ source *text* for complete class strings, so `grid-cols-${columns}` generates nothing at
155
+ all — no error, no warning, an unstyled element. A class-per-count would need every count
156
+ to survive the scan; an inline style needs none. `tailwindCandidates.test.ts` rejects the
157
+ interpolated form outright.
158
+
159
+ ## `min-w-0` is not optional
160
+
161
+ Every child gets `min-w-0`, applied by the component rather than asked for. A grid item's
162
+ automatic minimum size is `min-content`, so one long unbroken string — a file path, a URL —
163
+ widens its own track and shoves the whole grid past the viewport. The rule that stops it
164
+ has to sit on children the caller owns, which is why it is not your job to remember.
165
+
166
+ You still owe your own items a wrap or a `truncate`; `min-w-0` lets the track get small
167
+ enough for that clipping to matter.
168
+
169
+ <Canvas of={AdaptiveGridStories.AllStates} />
@@ -0,0 +1,240 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import { useState } from "react"
3
+
4
+ import {
5
+ StoryCell,
6
+ StoryGrid,
7
+ StoryRow,
8
+ StorySection,
9
+ } from "../board.storyHelpers.tsx"
10
+ import { Card } from "../Card/Card.tsx"
11
+ import { SegmentedControl } from "../SegmentedControl/SegmentedControl.tsx"
12
+ import { AdaptiveGrid } from "./AdaptiveGrid.tsx"
13
+ import { getColumnChoices } from "./chooseColumns.ts"
14
+ import type { BlockSizeResolver } from "./useAdaptiveColumns.ts"
15
+
16
+ /**
17
+ * A fixed block size, so a story shows the rule rather than the
18
+ * height of whichever frame Storybook happened to render it in.
19
+ *
20
+ * This is also the seam that makes the DOM tests deterministic: the
21
+ * source app forced the viewport with
22
+ * `Object.defineProperty(window, "innerHeight", …)`, because in a
23
+ * real chromium `innerHeight` is a read-only accessor and a plain
24
+ * assignment is a silent no-op. Injecting a resolver retires that
25
+ * trick entirely.
26
+ */
27
+ const fixedBlockSize = (
28
+ blockSize: number,
29
+ ): BlockSizeResolver => ({
30
+ get: () => blockSize,
31
+ subscribe: () => () => {},
32
+ })
33
+
34
+ /**
35
+ * `getColumnChoices({ maxColumns: 4 })`, as the picker's items —
36
+ * `"auto"` first, because a picker that only offers numbers once a
37
+ * number has been chosen is a one-way door.
38
+ */
39
+ const COLUMN_ITEMS = getColumnChoices({
40
+ maxColumns: 4,
41
+ }).map((choice) => ({
42
+ label: String(choice),
43
+ value: String(choice),
44
+ }))
45
+
46
+ const NINE_ITEMS = Array.from(
47
+ { length: 9 },
48
+ (_, index) => `Bay ${index + 1}`,
49
+ )
50
+
51
+ const Item = ({ label }: { label: string }) => (
52
+ <Card padding="md" surface="raised">
53
+ <p className="font-semibold text-content-primary">
54
+ {label}
55
+ </p>
56
+
57
+ {/*
58
+ * An unbroken string with no wrap opportunity — the thing a
59
+ * grid item's `min-content` minimum turns into a horizontal
60
+ * scrollbar. `truncate` clips it; `[&>*]:min-w-0` inside
61
+ * `AdaptiveGrid` is what lets the track get small enough for
62
+ * the clipping to matter.
63
+ */}
64
+ <p className="truncate text-content-muted text-sm">
65
+ /mnt/Bunnies/Family/Media/very/long/unbroken/path/that/would/push/a/track/wide
66
+ </p>
67
+ </Card>
68
+ )
69
+
70
+ const meta = {
71
+ title: "Components/AdaptiveGrid",
72
+ component: AdaptiveGrid,
73
+ parameters: { layout: "padded" },
74
+ args: {
75
+ children: NINE_ITEMS.map((label) => (
76
+ <Item key={label} label={label} />
77
+ )),
78
+ chromeBlockSize: 260,
79
+ itemBlockSize: 150,
80
+ },
81
+ } satisfies Meta<typeof AdaptiveGrid>
82
+
83
+ export default meta
84
+
85
+ type Story = StoryObj<typeof meta>
86
+
87
+ /**
88
+ * Nine items in a 1080px-tall window. Five stack, so two columns
89
+ * carry nine — and the content cap widens from 56rem to 72rem to
90
+ * hold them.
91
+ */
92
+ export const Default: Story = {
93
+ args: { blockSizeResolver: fixedBlockSize(1080) },
94
+ }
95
+
96
+ /**
97
+ * The same nine items, the same width, three window heights.
98
+ *
99
+ * This is the whole rule in one board, and the middle panel is the
100
+ * part that reads backwards: a **taller** window takes **fewer**
101
+ * columns, because height is what a column is bought with.
102
+ */
103
+ export const AllVariants: Story = {
104
+ render: (gridProps) => (
105
+ <StorySection title="Same items, same width — only the height changes.">
106
+ {[
107
+ {
108
+ blockSize: 1440,
109
+ label: "1440px tall — 2 columns",
110
+ },
111
+ { blockSize: 900, label: "900px tall — 3 columns" },
112
+ { blockSize: 600, label: "600px tall — 3 columns" },
113
+ ].map((panel) => (
114
+ <StoryRow key={panel.label}>
115
+ <div className="flex w-full flex-col gap-2">
116
+ <span className="font-mono text-content-muted text-xs">
117
+ {panel.label}
118
+ </span>
119
+
120
+ <AdaptiveGrid
121
+ {...gridProps}
122
+ blockSizeResolver={fixedBlockSize(
123
+ panel.blockSize,
124
+ )}
125
+ >
126
+ {NINE_ITEMS.map((label) => (
127
+ <Item key={label} label={label} />
128
+ ))}
129
+ </AdaptiveGrid>
130
+ </div>
131
+ </StoryRow>
132
+ ))}
133
+ </StorySection>
134
+ ),
135
+ }
136
+
137
+ /**
138
+ * One item is one column at every size — the case that would
139
+ * otherwise stretch a lone card across an ultrawide.
140
+ */
141
+ export const AllStates: Story = {
142
+ args: { blockSizeResolver: fixedBlockSize(900) },
143
+ render: (gridProps) => (
144
+ <StoryGrid columns={1}>
145
+ <StoryCell align="stretch" label="one item">
146
+ <AdaptiveGrid {...gridProps}>
147
+ <Item label="Bay 1" />
148
+ </AdaptiveGrid>
149
+ </StoryCell>
150
+
151
+ <StoryCell align="stretch" label="nine items">
152
+ <AdaptiveGrid {...gridProps}>
153
+ {NINE_ITEMS.map((label) => (
154
+ <Item key={label} label={label} />
155
+ ))}
156
+ </AdaptiveGrid>
157
+ </StoryCell>
158
+ </StoryGrid>
159
+ ),
160
+ }
161
+
162
+ /**
163
+ * Three container widths, with the column floor dropped to 160px so
164
+ * the panels differ inside a docs page.
165
+ *
166
+ * The honest demonstration that the inline size is read from the
167
+ * **container** and not the window: the browser never moves, and
168
+ * the cap still falls from three columns to one.
169
+ */
170
+ export const Responsive: Story = {
171
+ args: {
172
+ blockSizeResolver: fixedBlockSize(600),
173
+ minColumnInlineSize: 160,
174
+ },
175
+ render: (gridProps) => (
176
+ <div className="flex flex-wrap items-start gap-6">
177
+ {[
178
+ { inlineSize: "15rem", label: "15rem container" },
179
+ { inlineSize: "24rem", label: "24rem container" },
180
+ { inlineSize: "34rem", label: "34rem container" },
181
+ ].map((panel) => (
182
+ <div
183
+ // `shrink-0`, or flex shrinks the widest panel back to
184
+ // the viewport and all three grids answer the same.
185
+ className="flex shrink-0 flex-col gap-2"
186
+ key={panel.label}
187
+ style={{ inlineSize: panel.inlineSize }}
188
+ >
189
+ <span className="font-mono text-content-muted text-xs">
190
+ {panel.label}
191
+ </span>
192
+
193
+ <AdaptiveGrid {...gridProps}>
194
+ {NINE_ITEMS.slice(0, 6).map((label) => (
195
+ <Item key={label} label={label} />
196
+ ))}
197
+ </AdaptiveGrid>
198
+ </div>
199
+ ))}
200
+ </div>
201
+ ),
202
+ }
203
+
204
+ /**
205
+ * The manual override beside the automatic answer.
206
+ *
207
+ * A picker that only offered numbers once a number had been chosen
208
+ * would strand a person on a layout they tried once, so `"auto"` is
209
+ * always in the list.
210
+ */
211
+ export const Interactive: Story = {
212
+ args: { blockSizeResolver: fixedBlockSize(1080) },
213
+ render: (gridProps) => {
214
+ const [choice, setChoice] = useState<string>("auto")
215
+
216
+ return (
217
+ <StorySection title="Pick a count, or hand it back to auto.">
218
+ <SegmentedControl
219
+ items={COLUMN_ITEMS}
220
+ label="Columns"
221
+ onChange={(value) => {
222
+ setChoice(value ?? "auto")
223
+ }}
224
+ selectedValue={choice}
225
+ />
226
+
227
+ <AdaptiveGrid
228
+ {...gridProps}
229
+ columns={
230
+ choice === "auto" ? undefined : Number(choice)
231
+ }
232
+ >
233
+ {NINE_ITEMS.map((label) => (
234
+ <Item key={label} label={label} />
235
+ ))}
236
+ </AdaptiveGrid>
237
+ </StorySection>
238
+ )
239
+ },
240
+ }
@@ -0,0 +1,243 @@
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 "./AdaptiveGrid.stories.tsx"
7
+
8
+ /**
9
+ * The half of the rule that needs a browser.
10
+ *
11
+ * `chooseColumns.test.ts` proves the arithmetic at eleven sizes in
12
+ * Node. What only a real chromium can prove is that the arithmetic
13
+ * is fed the right numbers: that the inline size comes off a
14
+ * `ResizeObserver` on a container rather than off the window, that
15
+ * the measured box is not the capped box, and that `min-w-0`
16
+ * actually reaches children the caller owns.
17
+ *
18
+ * ### Neither viewport dimension is trusted here
19
+ *
20
+ * The runner's window is 414x896, so a test that let the grid read
21
+ * it would assert "one column" forever and prove nothing.
22
+ *
23
+ * The block size is pinned by the stories, which inject a
24
+ * `blockSizeResolver`. That seam is also what retires the source
25
+ * app's worst test gotcha: it had to write
26
+ * `Object.defineProperty(window, "innerHeight", { configurable:
27
+ * true, value })`, because under vitest browser mode `innerWidth`
28
+ * and `innerHeight` are read-only accessors backed by the real
29
+ * window and a plain assignment is a silent no-op that leaves the
30
+ * hook quietly reading the live viewport.
31
+ *
32
+ * The inline size is driven the honest way instead — by resizing
33
+ * the canvas the grid is measuring and waiting for the
34
+ * `ResizeObserver` to deliver. That is not a workaround; it is the
35
+ * only assertion in the file that proves the observer is wired at
36
+ * all.
37
+ */
38
+
39
+ const { AllStates, AllVariants, Default, Responsive } =
40
+ composeStories(stories)
41
+
42
+ /**
43
+ * The grids this component drew.
44
+ *
45
+ * Matched on `max-inline-size` rather than on
46
+ * `grid-template-columns`, which looks like the more obvious
47
+ * choice and is wrong: `StoryGrid` sets an inline
48
+ * `gridTemplateColumns` too, so the obvious selector returns the
49
+ * story furniture first and every assertion measures the board
50
+ * instead of the component.
51
+ */
52
+ const getGrids = (canvasElement: HTMLElement) => [
53
+ ...canvasElement.querySelectorAll<HTMLElement>(
54
+ "[style*='max-inline-size']",
55
+ ),
56
+ ]
57
+
58
+ /**
59
+ * The nth grid, or a thrown error naming what was missing.
60
+ *
61
+ * A helper rather than a `!`, because a non-null assertion in a test
62
+ * turns "the component rendered nothing" into a `TypeError` about
63
+ * `undefined`, several frames from the thing that actually went
64
+ * wrong.
65
+ */
66
+ const getGrid = (
67
+ canvasElement: HTMLElement,
68
+ index = 0,
69
+ ): HTMLElement => {
70
+ const grid = getGrids(canvasElement)[index]
71
+
72
+ if (!grid) {
73
+ throw new Error(
74
+ `No AdaptiveGrid at index ${index}; found ${getGrids(canvasElement).length}.`,
75
+ )
76
+ }
77
+
78
+ return grid
79
+ }
80
+
81
+ const getFirstChild = (grid: HTMLElement): Element => {
82
+ const child = grid.firstElementChild
83
+
84
+ if (!child) {
85
+ throw new Error("The grid rendered no children.")
86
+ }
87
+
88
+ return child
89
+ }
90
+
91
+ const getTrackCount = (grid: HTMLElement) =>
92
+ getComputedStyle(grid)
93
+ .gridTemplateColumns.split(" ")
94
+ .filter(Boolean).length
95
+
96
+ const mountAtInlineSize = async (
97
+ story: Parameters<typeof mountStory>[0],
98
+ inlineSize: number,
99
+ ) => {
100
+ const mounted = await mountStory(story)
101
+
102
+ mounted.canvasElement.style.inlineSize = `${inlineSize}px`
103
+
104
+ return mounted
105
+ }
106
+
107
+ test("the grid re-columns when its container resizes", async () => {
108
+ // The observer, end to end. Nine items in a 1080px-tall window
109
+ // need two stacks; at 700px wide only one column fits, and at
110
+ // 1280px the second one it wanted is finally allowed.
111
+ const { canvasElement } = await mountAtInlineSize(
112
+ Default,
113
+ 700,
114
+ )
115
+
116
+ await waitFor(async () => {
117
+ await expect(
118
+ getTrackCount(getGrid(canvasElement)),
119
+ ).toBe(1)
120
+ })
121
+
122
+ canvasElement.style.inlineSize = "1280px"
123
+
124
+ await waitFor(async () => {
125
+ await expect(
126
+ getTrackCount(getGrid(canvasElement)),
127
+ ).toBe(2)
128
+ })
129
+ })
130
+
131
+ test("height buys the column, and width only caps it", async () => {
132
+ // 3440px of width and 1440px of height: nine items stack seven
133
+ // deep, so two columns carry them and the third the width would
134
+ // have allowed is refused. This is the "don't go 9-wide just
135
+ // because you're on an ultrawide" row of the spec, drawn.
136
+ const { canvasElement } = await mountAtInlineSize(
137
+ AllVariants,
138
+ 3440,
139
+ )
140
+
141
+ await waitFor(async () => {
142
+ await expect(
143
+ getGrids(canvasElement).map(getTrackCount),
144
+ ).toEqual([2, 3, 3])
145
+ })
146
+ })
147
+
148
+ test("the content cap widens with the columns", async () => {
149
+ const { canvasElement } = await mountAtInlineSize(
150
+ Default,
151
+ 1280,
152
+ )
153
+
154
+ // 34rem x 2 + 4rem. One column would have been 56rem — narrower
155
+ // than two columns, which is the entire point: the page is only
156
+ // allowed to get wide once there is something to fill it with.
157
+ await waitFor(async () => {
158
+ await expect(
159
+ getGrids(canvasElement)[0]?.style.maxInlineSize,
160
+ ).toBe("72rem")
161
+ })
162
+ })
163
+
164
+ test("the measured box is never the capped box", async () => {
165
+ const { canvasElement } = await mountAtInlineSize(
166
+ Default,
167
+ 1280,
168
+ )
169
+
170
+ const grid = getGrid(canvasElement)
171
+
172
+ const measured = grid.parentElement
173
+
174
+ // The bug this shape exists to prevent has no error message: if
175
+ // the cap were applied to the element the `ResizeObserver`
176
+ // watches, the fold would read back its own narrowed output and
177
+ // could never widen again — one column, forever. So the observed
178
+ // ancestor must carry no inline-size cap of its own.
179
+ await expect(measured?.style.maxInlineSize).toBeFalsy()
180
+
181
+ await waitFor(async () => {
182
+ await expect(
183
+ measured?.getBoundingClientRect().width,
184
+ ).toBeGreaterThan(grid.getBoundingClientRect().width)
185
+ })
186
+ })
187
+
188
+ test("one item is one column at a reading measure", async () => {
189
+ const { canvasElement } = await mountAtInlineSize(
190
+ AllStates,
191
+ 1280,
192
+ )
193
+
194
+ await waitFor(async () => {
195
+ const single = getGrid(canvasElement)
196
+
197
+ await expect(getTrackCount(single)).toBe(1)
198
+
199
+ // Not stretched across the monitor it was given.
200
+ await expect(single.style.maxInlineSize).toBe("56rem")
201
+ })
202
+ })
203
+
204
+ test("a long unbroken string does not push a track wide", async () => {
205
+ const { canvasElement } = await mountAtInlineSize(
206
+ Default,
207
+ 1280,
208
+ )
209
+
210
+ // A grid item's automatic minimum size is `min-content`, so the
211
+ // long path in each card would otherwise widen its own track and
212
+ // shove the grid past its container. `[&>*]:min-w-0` is what
213
+ // stops it, and this asserts it reached children the story — not
214
+ // the component — owns.
215
+ await waitFor(async () => {
216
+ const grid = getGrid(canvasElement)
217
+
218
+ await expect(
219
+ getComputedStyle(getFirstChild(grid)).minWidth,
220
+ ).toBe("0px")
221
+
222
+ await expect(grid.scrollWidth).toBeLessThanOrEqual(
223
+ grid.clientWidth,
224
+ )
225
+ })
226
+ })
227
+
228
+ test("the inline size is read from the container, not the window", async () => {
229
+ const { canvasElement } = await mountAtInlineSize(
230
+ Responsive,
231
+ 1280,
232
+ )
233
+
234
+ // Three grids, one browser window, three different answers. A
235
+ // hook reading `window.innerWidth` would have returned the same
236
+ // number three times — which is what the source app's did, and
237
+ // the one part of this port that is not a lift.
238
+ await waitFor(async () => {
239
+ await expect(
240
+ getGrids(canvasElement).map(getTrackCount),
241
+ ).toEqual([1, 2, 3])
242
+ })
243
+ })