@charcuterie/ui 2.7.0 → 2.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/README.md +11 -0
- package/dist/AdaptiveGrid/AdaptiveGrid.d.ts +94 -0
- package/dist/AdaptiveGrid/AdaptiveGrid.d.ts.map +1 -0
- package/dist/AdaptiveGrid/AdaptiveGrid.js +73 -0
- package/dist/AdaptiveGrid/AdaptiveGrid.js.map +1 -0
- package/dist/AdaptiveGrid/chooseColumns.d.ts +206 -0
- package/dist/AdaptiveGrid/chooseColumns.d.ts.map +1 -0
- package/dist/AdaptiveGrid/chooseColumns.js +156 -0
- package/dist/AdaptiveGrid/chooseColumns.js.map +1 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts +155 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts.map +1 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.js +153 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.js.map +1 -0
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +11 -6
- package/dist/Button/Button.js.map +1 -1
- package/dist/ButtonLink/ButtonLink.d.ts +59 -0
- package/dist/ButtonLink/ButtonLink.d.ts.map +1 -0
- package/dist/ButtonLink/ButtonLink.js +69 -0
- package/dist/ButtonLink/ButtonLink.js.map +1 -0
- package/dist/Combobox/Combobox.d.ts.map +1 -1
- package/dist/Combobox/Combobox.js +1 -1
- package/dist/Combobox/Combobox.js.map +1 -1
- package/dist/Dialog/Dialog.js +1 -1
- package/dist/Dialog/Dialog.js.map +1 -1
- package/dist/LogViewer/LogViewer.d.ts.map +1 -1
- package/dist/LogViewer/LogViewer.js +33 -1
- package/dist/LogViewer/LogViewer.js.map +1 -1
- package/dist/RouterLink/AnchorLink.d.ts +17 -0
- package/dist/RouterLink/AnchorLink.d.ts.map +1 -0
- package/dist/RouterLink/AnchorLink.js +16 -0
- package/dist/RouterLink/AnchorLink.js.map +1 -0
- package/dist/RouterLink/RouterLinkProvider.d.ts +13 -0
- package/dist/RouterLink/RouterLinkProvider.d.ts.map +1 -0
- package/dist/RouterLink/RouterLinkProvider.js +47 -0
- package/dist/RouterLink/RouterLinkProvider.js.map +1 -0
- package/dist/RouterLink/routerLink.d.ts +45 -0
- package/dist/RouterLink/routerLink.d.ts.map +1 -0
- package/dist/RouterLink/routerLink.js +31 -0
- package/dist/RouterLink/routerLink.js.map +1 -0
- package/dist/TextLink/TextLink.d.ts +69 -0
- package/dist/TextLink/TextLink.d.ts.map +1 -0
- package/dist/TextLink/TextLink.js +79 -0
- package/dist/TextLink/TextLink.js.map +1 -0
- package/dist/controlStyles.d.ts +36 -1
- package/dist/controlStyles.d.ts.map +1 -1
- package/dist/controlStyles.js +30 -0
- package/dist/controlStyles.js.map +1 -1
- package/dist/index.d.ts +16 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/intentStyles.d.ts +17 -0
- package/dist/intentStyles.d.ts.map +1 -1
- package/dist/intentStyles.js +17 -0
- package/dist/intentStyles.js.map +1 -1
- package/dist/reactRouter/ReactRouterLink.d.ts +23 -0
- package/dist/reactRouter/ReactRouterLink.d.ts.map +1 -0
- package/dist/reactRouter/ReactRouterLink.js +23 -0
- package/dist/reactRouter/ReactRouterLink.js.map +1 -0
- package/dist/reactRouter/index.d.ts +29 -0
- package/dist/reactRouter/index.d.ts.map +1 -0
- package/dist/reactRouter/index.js +29 -0
- package/dist/reactRouter/index.js.map +1 -0
- package/package.json +15 -3
- package/src/AdaptiveGrid/AdaptiveGrid.mdx +169 -0
- package/src/AdaptiveGrid/AdaptiveGrid.stories.tsx +240 -0
- package/src/AdaptiveGrid/AdaptiveGrid.test.tsx +243 -0
- package/src/AdaptiveGrid/AdaptiveGrid.tsx +150 -0
- package/src/AdaptiveGrid/chooseColumns.test.ts +253 -0
- package/src/AdaptiveGrid/chooseColumns.ts +270 -0
- package/src/AdaptiveGrid/useAdaptiveColumns.ts +355 -0
- package/src/Button/Button.tsx +10 -22
- package/src/ButtonLink/ButtonLink.mdx +75 -0
- package/src/ButtonLink/ButtonLink.stories.tsx +281 -0
- package/src/ButtonLink/ButtonLink.test.tsx +203 -0
- package/src/ButtonLink/ButtonLink.tsx +149 -0
- package/src/Combobox/Combobox.tsx +4 -1
- package/src/Dialog/Dialog.tsx +1 -1
- package/src/LogViewer/LogViewer.test.tsx +47 -0
- package/src/LogViewer/LogViewer.tsx +35 -1
- package/src/RouterLink/AnchorLink.tsx +23 -0
- package/src/RouterLink/RouterLink.mdx +178 -0
- package/src/RouterLink/RouterLinkProvider.tsx +68 -0
- package/src/RouterLink/routerLink.ts +56 -0
- package/src/TextLink/TextLink.mdx +93 -0
- package/src/TextLink/TextLink.stories.tsx +260 -0
- package/src/TextLink/TextLink.test.tsx +230 -0
- package/src/TextLink/TextLink.tsx +194 -0
- package/src/controlStyles.ts +69 -1
- package/src/icons.storyHelpers.tsx +32 -0
- package/src/index.ts +41 -0
- package/src/intentStyles.ts +19 -0
- package/src/reactRouter/ReactRouterLink.tsx +30 -0
- package/src/reactRouter/index.ts +29 -0
- package/src/sourceRules.test.ts +41 -6
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import { Children } from "react"
|
|
3
|
+
|
|
4
|
+
import { toClassName } from "../toClassName.ts"
|
|
5
|
+
import { getContentMaxInlineSize } from "./chooseColumns.ts"
|
|
6
|
+
import type { BlockSizeResolver } from "./useAdaptiveColumns.ts"
|
|
7
|
+
import { useAdaptiveColumns } from "./useAdaptiveColumns.ts"
|
|
8
|
+
|
|
9
|
+
export type AdaptiveGridProps = {
|
|
10
|
+
/**
|
|
11
|
+
* Where the available block size is read from. Defaults to the
|
|
12
|
+
* viewport, which is the only thing that can answer "will this
|
|
13
|
+
* scroll".
|
|
14
|
+
*/
|
|
15
|
+
blockSizeResolver?: BlockSizeResolver
|
|
16
|
+
children: ReactNode
|
|
17
|
+
/**
|
|
18
|
+
* Everything down the page that is not this grid, in CSS px —
|
|
19
|
+
* header, rails, filter rows. Subtracted before asking how many
|
|
20
|
+
* items stack.
|
|
21
|
+
*/
|
|
22
|
+
chromeBlockSize?: number
|
|
23
|
+
className?: string
|
|
24
|
+
/**
|
|
25
|
+
* Draw this many columns instead of the automatic answer — the
|
|
26
|
+
* manual override, for a picker or a page that hoisted
|
|
27
|
+
* `useAdaptiveColumns` itself.
|
|
28
|
+
*/
|
|
29
|
+
columns?: number
|
|
30
|
+
/**
|
|
31
|
+
* One item's block size in CSS px. **The one genuinely
|
|
32
|
+
* app-specific number.** Measure it off a running page and err
|
|
33
|
+
* high: a page that scrolls when it did not have to is the
|
|
34
|
+
* complaint this component exists to answer.
|
|
35
|
+
*/
|
|
36
|
+
itemBlockSize: number
|
|
37
|
+
/**
|
|
38
|
+
* How many items to lay out. Defaults to the number of children,
|
|
39
|
+
* and is worth passing explicitly when the rendered count churns
|
|
40
|
+
* — a grid that reflowed every time one item finished loading
|
|
41
|
+
* would be worse than one that is an item out.
|
|
42
|
+
*/
|
|
43
|
+
itemCount?: number
|
|
44
|
+
/** The most columns the automatic answer will take. */
|
|
45
|
+
maxColumns?: number
|
|
46
|
+
/** The narrowest a column may be, in CSS px. */
|
|
47
|
+
minColumnInlineSize?: number
|
|
48
|
+
/**
|
|
49
|
+
* The `localStorage` key a manual choice is remembered under.
|
|
50
|
+
* Namespace it to the app; omit it and nothing is written.
|
|
51
|
+
*/
|
|
52
|
+
storageKey?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A wrapping grid that spends height before it spends width.
|
|
57
|
+
*
|
|
58
|
+
* The fleet's grids are `auto-fill, minmax()`: they take every
|
|
59
|
+
* column the window allows and land on seven items strung across an
|
|
60
|
+
* ultrawide. This one adds a column only when the items will not
|
|
61
|
+
* stack inside the viewport, and caps the content at a reading
|
|
62
|
+
* measure the rest of the time —
|
|
63
|
+
*
|
|
64
|
+
* > "The idea is that, if you're going to vertically scroll, it's
|
|
65
|
+
* > best to widen the displayed items. If not, then keep them in a
|
|
66
|
+
* > smaller grid, so they all still fit on screen."
|
|
67
|
+
*
|
|
68
|
+
* The rule itself is `chooseColumns`; the measuring is
|
|
69
|
+
* `useAdaptiveColumns`. This is the markup, and it exists because
|
|
70
|
+
* three separate things about that markup are easy to get wrong and
|
|
71
|
+
* silent when you do.
|
|
72
|
+
*
|
|
73
|
+
* ### Two boxes, and why it is not one
|
|
74
|
+
*
|
|
75
|
+
* The outer box is measured and **never capped**; the inner box
|
|
76
|
+
* carries the cap and the tracks. Collapsing them is a real bug
|
|
77
|
+
* with no error message: the cap narrows the content when the item
|
|
78
|
+
* count is low, so an element that is both measured and capped
|
|
79
|
+
* feeds the fold its own output and can never widen again. One
|
|
80
|
+
* column, forever.
|
|
81
|
+
*
|
|
82
|
+
* ### `min-w-0` on every child
|
|
83
|
+
*
|
|
84
|
+
* `[&>*]:min-w-0`, because a grid item's automatic minimum size is
|
|
85
|
+
* `min-content` — so one long unbroken string (a file path, a URL)
|
|
86
|
+
* pushes its track wider than its share and shoves the whole grid
|
|
87
|
+
* out of the viewport. The rule that stops it has to be on the
|
|
88
|
+
* children, and they belong to the caller, so it is applied from
|
|
89
|
+
* here rather than asked for.
|
|
90
|
+
*
|
|
91
|
+
* ### The tracks are an inline style
|
|
92
|
+
*
|
|
93
|
+
* `repeat(N, minmax(0, 1fr))` is interpolated, which a className
|
|
94
|
+
* may not be: Tailwind v4 scans source *text* for complete class
|
|
95
|
+
* strings, so `grid-cols-${columns}` generates nothing at all — no
|
|
96
|
+
* error, no warning, an unstyled element. A class-per-count would
|
|
97
|
+
* need every count to survive the scan; an inline style needs
|
|
98
|
+
* none.
|
|
99
|
+
*/
|
|
100
|
+
export const AdaptiveGrid = ({
|
|
101
|
+
blockSizeResolver,
|
|
102
|
+
children,
|
|
103
|
+
chromeBlockSize,
|
|
104
|
+
className,
|
|
105
|
+
columns,
|
|
106
|
+
itemBlockSize,
|
|
107
|
+
itemCount,
|
|
108
|
+
maxColumns,
|
|
109
|
+
minColumnInlineSize,
|
|
110
|
+
storageKey,
|
|
111
|
+
}: AdaptiveGridProps): ReactNode => {
|
|
112
|
+
const layout = useAdaptiveColumns({
|
|
113
|
+
blockSizeResolver,
|
|
114
|
+
chromeBlockSize,
|
|
115
|
+
itemBlockSize,
|
|
116
|
+
itemCount: itemCount ?? Children.count(children),
|
|
117
|
+
maxAutoColumns: maxColumns,
|
|
118
|
+
minColumnInlineSize,
|
|
119
|
+
storageKey,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const drawnColumns = columns ?? layout.columns
|
|
123
|
+
|
|
124
|
+
// Folded from what is DRAWN rather than read off the hook, so an
|
|
125
|
+
// override widens the page cap with it. A four-column grid inside
|
|
126
|
+
// a one-column cap is the same bug as no cap at all, upside down.
|
|
127
|
+
const contentMaxInlineSize = getContentMaxInlineSize({
|
|
128
|
+
columns: drawnColumns,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
className={toClassName(
|
|
134
|
+
"flex justify-center",
|
|
135
|
+
className,
|
|
136
|
+
)}
|
|
137
|
+
ref={layout.containerRef}
|
|
138
|
+
>
|
|
139
|
+
<div
|
|
140
|
+
className="grid w-full gap-4 [&>*]:min-w-0"
|
|
141
|
+
style={{
|
|
142
|
+
gridTemplateColumns: `repeat(${drawnColumns}, minmax(0, 1fr))`,
|
|
143
|
+
maxInlineSize: contentMaxInlineSize,
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
{children}
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
)
|
|
150
|
+
}
|