@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.
Files changed (96) 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.js +1 -1
  25. package/dist/Dialog/Dialog.js.map +1 -1
  26. package/dist/LogViewer/LogViewer.d.ts.map +1 -1
  27. package/dist/LogViewer/LogViewer.js +33 -1
  28. package/dist/LogViewer/LogViewer.js.map +1 -1
  29. package/dist/RouterLink/AnchorLink.d.ts +17 -0
  30. package/dist/RouterLink/AnchorLink.d.ts.map +1 -0
  31. package/dist/RouterLink/AnchorLink.js +16 -0
  32. package/dist/RouterLink/AnchorLink.js.map +1 -0
  33. package/dist/RouterLink/RouterLinkProvider.d.ts +13 -0
  34. package/dist/RouterLink/RouterLinkProvider.d.ts.map +1 -0
  35. package/dist/RouterLink/RouterLinkProvider.js +47 -0
  36. package/dist/RouterLink/RouterLinkProvider.js.map +1 -0
  37. package/dist/RouterLink/routerLink.d.ts +45 -0
  38. package/dist/RouterLink/routerLink.d.ts.map +1 -0
  39. package/dist/RouterLink/routerLink.js +31 -0
  40. package/dist/RouterLink/routerLink.js.map +1 -0
  41. package/dist/TextLink/TextLink.d.ts +69 -0
  42. package/dist/TextLink/TextLink.d.ts.map +1 -0
  43. package/dist/TextLink/TextLink.js +79 -0
  44. package/dist/TextLink/TextLink.js.map +1 -0
  45. package/dist/controlStyles.d.ts +36 -1
  46. package/dist/controlStyles.d.ts.map +1 -1
  47. package/dist/controlStyles.js +30 -0
  48. package/dist/controlStyles.js.map +1 -1
  49. package/dist/index.d.ts +16 -2
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +10 -2
  52. package/dist/index.js.map +1 -1
  53. package/dist/intentStyles.d.ts +17 -0
  54. package/dist/intentStyles.d.ts.map +1 -1
  55. package/dist/intentStyles.js +17 -0
  56. package/dist/intentStyles.js.map +1 -1
  57. package/dist/reactRouter/ReactRouterLink.d.ts +23 -0
  58. package/dist/reactRouter/ReactRouterLink.d.ts.map +1 -0
  59. package/dist/reactRouter/ReactRouterLink.js +23 -0
  60. package/dist/reactRouter/ReactRouterLink.js.map +1 -0
  61. package/dist/reactRouter/index.d.ts +29 -0
  62. package/dist/reactRouter/index.d.ts.map +1 -0
  63. package/dist/reactRouter/index.js +29 -0
  64. package/dist/reactRouter/index.js.map +1 -0
  65. package/package.json +15 -3
  66. package/src/AdaptiveGrid/AdaptiveGrid.mdx +169 -0
  67. package/src/AdaptiveGrid/AdaptiveGrid.stories.tsx +240 -0
  68. package/src/AdaptiveGrid/AdaptiveGrid.test.tsx +243 -0
  69. package/src/AdaptiveGrid/AdaptiveGrid.tsx +150 -0
  70. package/src/AdaptiveGrid/chooseColumns.test.ts +253 -0
  71. package/src/AdaptiveGrid/chooseColumns.ts +270 -0
  72. package/src/AdaptiveGrid/useAdaptiveColumns.ts +355 -0
  73. package/src/Button/Button.tsx +10 -22
  74. package/src/ButtonLink/ButtonLink.mdx +75 -0
  75. package/src/ButtonLink/ButtonLink.stories.tsx +281 -0
  76. package/src/ButtonLink/ButtonLink.test.tsx +203 -0
  77. package/src/ButtonLink/ButtonLink.tsx +149 -0
  78. package/src/Combobox/Combobox.tsx +4 -1
  79. package/src/Dialog/Dialog.tsx +1 -1
  80. package/src/LogViewer/LogViewer.test.tsx +47 -0
  81. package/src/LogViewer/LogViewer.tsx +35 -1
  82. package/src/RouterLink/AnchorLink.tsx +23 -0
  83. package/src/RouterLink/RouterLink.mdx +178 -0
  84. package/src/RouterLink/RouterLinkProvider.tsx +68 -0
  85. package/src/RouterLink/routerLink.ts +56 -0
  86. package/src/TextLink/TextLink.mdx +93 -0
  87. package/src/TextLink/TextLink.stories.tsx +260 -0
  88. package/src/TextLink/TextLink.test.tsx +230 -0
  89. package/src/TextLink/TextLink.tsx +194 -0
  90. package/src/controlStyles.ts +69 -1
  91. package/src/icons.storyHelpers.tsx +32 -0
  92. package/src/index.ts +41 -0
  93. package/src/intentStyles.ts +19 -0
  94. package/src/reactRouter/ReactRouterLink.tsx +30 -0
  95. package/src/reactRouter/index.ts +29 -0
  96. 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
+ }