@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.
- 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.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +3 -1
- package/dist/Dialog/Dialog.js.map +1 -1
- package/dist/Field/Field.d.ts.map +1 -1
- package/dist/Field/Field.js +1 -1
- package/dist/Field/Field.js.map +1 -1
- package/dist/Field/FieldGroup.d.ts.map +1 -1
- package/dist/Field/FieldGroup.js +1 -1
- package/dist/Field/FieldGroup.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/SortableTableHeader/SortableTableHeader.d.ts.map +1 -1
- package/dist/SortableTableHeader/SortableTableHeader.js +7 -1
- package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -1
- 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/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +5 -1
- package/dist/Tooltip/Tooltip.js.map +1 -1
- 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/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +56 -0
- package/dist/mountStory.testHelpers.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 +18 -2
- package/src/Field/Field.tsx +10 -2
- package/src/Field/FieldGroup.tsx +4 -2
- 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/SortableTableHeader/SortableTableHeader.tsx +6 -1
- 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/Tooltip/Tooltip.tsx +5 -1
- 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/mountStory.testHelpers.ts +63 -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,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
|
+
})
|