@charcuterie/ui 3.31.0 → 3.33.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 +14 -0
- package/dist/ActionTiles/ActionTiles.d.ts +115 -0
- package/dist/ActionTiles/ActionTiles.d.ts.map +1 -0
- package/dist/ActionTiles/ActionTiles.js +115 -0
- package/dist/ActionTiles/ActionTiles.js.map +1 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts +16 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts.map +1 -1
- package/dist/AdaptiveGrid/useAdaptiveColumns.js +1 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.js.map +1 -1
- package/dist/RadioGroup/RadioGroup.d.ts +10 -0
- package/dist/RadioGroup/RadioGroup.d.ts.map +1 -1
- package/dist/RadioGroup/RadioGroup.js +12 -39
- package/dist/RadioGroup/RadioGroup.js.map +1 -1
- package/dist/RadioGroup/RadioGroupOption.d.ts.map +1 -1
- package/dist/RadioGroup/RadioGroupOption.js +3 -33
- package/dist/RadioGroup/RadioGroupOption.js.map +1 -1
- package/dist/VirtualizedGrid/VirtualizedGrid.d.ts +35 -0
- package/dist/VirtualizedGrid/VirtualizedGrid.d.ts.map +1 -1
- package/dist/VirtualizedGrid/VirtualizedGrid.js +37 -1
- package/dist/VirtualizedGrid/VirtualizedGrid.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tileStyles.d.ts +83 -0
- package/dist/tileStyles.d.ts.map +1 -0
- package/dist/tileStyles.js +98 -0
- package/dist/tileStyles.js.map +1 -0
- package/package.json +1 -1
- package/src/ActionTiles/ActionTiles.mdx +131 -0
- package/src/ActionTiles/ActionTiles.stories.tsx +311 -0
- package/src/ActionTiles/ActionTiles.test.tsx +292 -0
- package/src/ActionTiles/ActionTiles.tsx +292 -0
- package/src/AdaptiveGrid/useAdaptiveColumns.ts +17 -0
- package/src/RadioGroup/RadioGroup.mdx +7 -0
- package/src/RadioGroup/RadioGroup.tsx +16 -42
- package/src/RadioGroup/RadioGroupOption.tsx +8 -38
- package/src/VirtualizedGrid/VirtualizedGrid.mdx +21 -0
- package/src/VirtualizedGrid/VirtualizedGrid.stories.tsx +64 -0
- package/src/VirtualizedGrid/VirtualizedGrid.test.tsx +122 -3
- package/src/VirtualizedGrid/VirtualizedGrid.tsx +37 -1
- package/src/index.ts +5 -0
- package/src/sourceRules.test.ts +9 -1
- package/src/tileStyles.ts +118 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
2
|
+
import type { CSSProperties, ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
import { FOCUS_RING_CLASS } from "../intentStyles.ts"
|
|
5
|
+
import { useRouterLink } from "../RouterLink/RouterLinkProvider.tsx"
|
|
6
|
+
import { getIsRoutedHref } from "../RouterLink/routerLink.ts"
|
|
7
|
+
import {
|
|
8
|
+
TILE_BOX_CLASS,
|
|
9
|
+
TILE_COLUMNS_CLASS,
|
|
10
|
+
TILE_GAP_CLASS,
|
|
11
|
+
TILE_HINT_TEXT_CLASS,
|
|
12
|
+
TILE_MIN_INLINE_SIZE_PROPERTY,
|
|
13
|
+
TILE_PADDING_CLASS,
|
|
14
|
+
TILE_TEXT_SIZE_CLASS,
|
|
15
|
+
} from "../tileStyles.ts"
|
|
16
|
+
import { toClassName } from "../toClassName.ts"
|
|
17
|
+
import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
|
|
18
|
+
|
|
19
|
+
export type ActionTileItem = {
|
|
20
|
+
/**
|
|
21
|
+
* A line of help under the name. Drawn one step down the type ramp
|
|
22
|
+
* in `content-muted`, **inside** the tile — so `getByRole` computes
|
|
23
|
+
* it into the accessible name. A hint a screen reader never reads
|
|
24
|
+
* is a hint half the audience does not have.
|
|
25
|
+
*/
|
|
26
|
+
hint?: ReactNode
|
|
27
|
+
/**
|
|
28
|
+
* Makes this tile a real `<a href>`: middle-click, ctrl-click,
|
|
29
|
+
* "open in new tab", "copy link address" and the status bar all
|
|
30
|
+
* come from the element rather than from the paint. Routed through
|
|
31
|
+
* the injected `RouterLink` when the destination is same-origin and
|
|
32
|
+
* not a fragment.
|
|
33
|
+
*
|
|
34
|
+
* Without one, the tile is a `<button>` that calls the set's
|
|
35
|
+
* `onChoose`. A set may mix the two.
|
|
36
|
+
*/
|
|
37
|
+
href?: string
|
|
38
|
+
/**
|
|
39
|
+
* Leading content, above the name. A `Badge`, an app's own icon, a
|
|
40
|
+
* count.
|
|
41
|
+
*
|
|
42
|
+
* `aria-hidden` by construction: the name it sits above is inside
|
|
43
|
+
* the same tile, so an announced icon is the label read twice.
|
|
44
|
+
*/
|
|
45
|
+
icon?: ReactNode
|
|
46
|
+
isDisabled?: boolean
|
|
47
|
+
/** Opens in a new tab, and says so to a screen reader. `href` only. */
|
|
48
|
+
isExternal?: boolean
|
|
49
|
+
label: ReactNode
|
|
50
|
+
/**
|
|
51
|
+
* Identity. The React key, and what `onChoose` is handed.
|
|
52
|
+
*
|
|
53
|
+
* Required even on a tile that navigates, so a set can mix the two
|
|
54
|
+
* without one half losing its key — the same reason `RadioItem`
|
|
55
|
+
* carries one.
|
|
56
|
+
*/
|
|
57
|
+
value: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type ActionTilesProps = {
|
|
61
|
+
className?: string
|
|
62
|
+
items: readonly ActionTileItem[]
|
|
63
|
+
/** The set's accessible name. Required. */
|
|
64
|
+
label: string
|
|
65
|
+
/**
|
|
66
|
+
* The narrowest a tile track may be, in CSS px.
|
|
67
|
+
*
|
|
68
|
+
* It is the grid's floor and not the tile's width: tracks below it
|
|
69
|
+
* are not created, and the ones that are share the row evenly.
|
|
70
|
+
*/
|
|
71
|
+
minTileInlineSize?: number
|
|
72
|
+
/** Fired by a tile that carries no `href`. */
|
|
73
|
+
onChoose?: (value: string) => void
|
|
74
|
+
/** What `isExternal` announces. Not shown. */
|
|
75
|
+
newTabLabel?: string
|
|
76
|
+
size?: ControlSize
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A set of ACTIONS drawn as tiles — a bordered card carrying a name
|
|
81
|
+
* and a line of help, in a grid that gains columns with its
|
|
82
|
+
* container.
|
|
83
|
+
*
|
|
84
|
+
* This is `RadioGroup`'s `itemShape="tile"` with the radio taken
|
|
85
|
+
* away. The
|
|
86
|
+
* [2026-08-25 record](../../../../docs/decisions/2026-08-25-a-choice-tile-is-a-radiogroup-shape-not-a-third-component.md)
|
|
87
|
+
* that made the choice tile a `RadioGroup` shape named this case
|
|
88
|
+
* under *"What this deliberately does not cover"* — mux-magic's "Pick
|
|
89
|
+
* a tool" tiles are `<a href>` and points-market's are `<Link>`, and
|
|
90
|
+
* *"a link is not a radio, and giving one `aria-checked` would be
|
|
91
|
+
* worse than the paint it replaced"*. So the box is shared through
|
|
92
|
+
* `tileStyles.ts` and nothing else is.
|
|
93
|
+
*
|
|
94
|
+
* ### Which of the two you want
|
|
95
|
+
*
|
|
96
|
+
* Ask what a press does.
|
|
97
|
+
*
|
|
98
|
+
* - It **records a value** the user will then submit, or that
|
|
99
|
+
* filters what is below it — `RadioGroup itemShape="tile"`. The
|
|
100
|
+
* set announces "3 of 6", the arrow keys move the choice, and one
|
|
101
|
+
* tile stays selected.
|
|
102
|
+
* - It **goes somewhere or starts something** — this. Nothing stays
|
|
103
|
+
* selected, because nothing was chosen: the page moved on. Tab
|
|
104
|
+
* reaches each tile, Enter and Space press it, and a `href` tile
|
|
105
|
+
* is a real anchor.
|
|
106
|
+
*
|
|
107
|
+
* A first step that opens the next step is the second kind, which is
|
|
108
|
+
* why QueuePilot's "Queue type" chooser is here and its Tonight
|
|
109
|
+
* activity filter is not.
|
|
110
|
+
*
|
|
111
|
+
* ### Why this is not a `Button` with a `className`
|
|
112
|
+
*
|
|
113
|
+
* A `Button` is sized by `h-(--control-height-md)` and carries **no
|
|
114
|
+
* block padding at all** — one line of text, by contract. An app that
|
|
115
|
+
* wants a wrapping two-line card reaches for `height: auto`, which
|
|
116
|
+
* deletes the height and leaves `padding: 0` down the block axis:
|
|
117
|
+
* a title flush against the top border and a description flush
|
|
118
|
+
* against the bottom one, with nothing to report it. That shipped in
|
|
119
|
+
* QueuePilot and is what this component exists to have made
|
|
120
|
+
* impossible.
|
|
121
|
+
*
|
|
122
|
+
* ### `role="group"`, and no roving tabindex
|
|
123
|
+
*
|
|
124
|
+
* These are ordinary buttons and anchors, so the platform already
|
|
125
|
+
* owns the keyboard: Tab reaches each one, Enter and Space press it,
|
|
126
|
+
* and a link opens in a new tab on ctrl-click. A radio group borrows
|
|
127
|
+
* one tab stop and moves the choice with the arrow keys because
|
|
128
|
+
* exactly one of its options is true at a time; nothing here is
|
|
129
|
+
* true, so imposing that model would only cost a keyboard user the
|
|
130
|
+
* ability to Tab to the second tile.
|
|
131
|
+
*/
|
|
132
|
+
export const ActionTiles = ({
|
|
133
|
+
className,
|
|
134
|
+
items,
|
|
135
|
+
label,
|
|
136
|
+
minTileInlineSize = 200,
|
|
137
|
+
newTabLabel = "(opens in a new tab)",
|
|
138
|
+
onChoose,
|
|
139
|
+
size = "md",
|
|
140
|
+
}: ActionTilesProps): ReactNode => {
|
|
141
|
+
const RouterLink = useRouterLink()
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
// biome-ignore lint/a11y/useSemanticElements: the semantic element for `group` is `<fieldset>`, which is a form-control grouping — it drags `<legend>` semantics, a border and form-reset behaviour onto a set of buttons and links that is not a form. Same call `Menu`'s group, `AccordionSection` and `BoardLaneList` already made.
|
|
145
|
+
<div
|
|
146
|
+
aria-label={label}
|
|
147
|
+
className={toClassName(
|
|
148
|
+
"grid",
|
|
149
|
+
TILE_COLUMNS_CLASS,
|
|
150
|
+
TILE_GAP_CLASS[size],
|
|
151
|
+
className,
|
|
152
|
+
)}
|
|
153
|
+
role="group"
|
|
154
|
+
style={
|
|
155
|
+
{
|
|
156
|
+
[TILE_MIN_INLINE_SIZE_PROPERTY]: `${minTileInlineSize}px`,
|
|
157
|
+
} as CSSProperties
|
|
158
|
+
}
|
|
159
|
+
>
|
|
160
|
+
{items.map(
|
|
161
|
+
({
|
|
162
|
+
hint,
|
|
163
|
+
href,
|
|
164
|
+
icon,
|
|
165
|
+
isDisabled = false,
|
|
166
|
+
isExternal = false,
|
|
167
|
+
label: tileLabel,
|
|
168
|
+
value,
|
|
169
|
+
}) => {
|
|
170
|
+
const tileClassName = toClassName(
|
|
171
|
+
// `flex`, not `inline-flex`: a grid item stretches to its
|
|
172
|
+
// track, and an inline box would sit at max-content
|
|
173
|
+
// inside it. `min-w-0` with `wrap-anywhere` below,
|
|
174
|
+
// because a grid item's automatic minimum is its
|
|
175
|
+
// min-content width — one unbroken token would otherwise
|
|
176
|
+
// set the track's floor and shove the grid out of its
|
|
177
|
+
// container.
|
|
178
|
+
"flex min-w-0 flex-col gap-0.5 text-start wrap-anywhere transition-colors duration-(--duration-fast) ease-standard",
|
|
179
|
+
TILE_BOX_CLASS,
|
|
180
|
+
TILE_PADDING_CLASS[size],
|
|
181
|
+
TILE_TEXT_SIZE_CLASS[size],
|
|
182
|
+
// Disabled turns the whole tile down with `opacity-60`,
|
|
183
|
+
// the same family treatment as `Checkbox`, `Switch` and
|
|
184
|
+
// the radio tile, rather than fading the border to a
|
|
185
|
+
// token that disappeared on a pale theme.
|
|
186
|
+
isDisabled
|
|
187
|
+
? "cursor-not-allowed opacity-60"
|
|
188
|
+
: "cursor-pointer hover:border-border-strong",
|
|
189
|
+
FOCUS_RING_CLASS,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
const content = (
|
|
193
|
+
<>
|
|
194
|
+
{icon === undefined ? null : (
|
|
195
|
+
<span aria-hidden className="flex">
|
|
196
|
+
{icon}
|
|
197
|
+
</span>
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
<span className="font-semibold text-content-primary">
|
|
201
|
+
{tileLabel}
|
|
202
|
+
</span>
|
|
203
|
+
|
|
204
|
+
{hint === undefined ? null : (
|
|
205
|
+
<span
|
|
206
|
+
className={toClassName(
|
|
207
|
+
"font-normal text-content-muted",
|
|
208
|
+
TILE_HINT_TEXT_CLASS[size],
|
|
209
|
+
)}
|
|
210
|
+
>
|
|
211
|
+
{hint}
|
|
212
|
+
</span>
|
|
213
|
+
)}
|
|
214
|
+
|
|
215
|
+
{isExternal && href !== undefined ? (
|
|
216
|
+
<VisuallyHidden>
|
|
217
|
+
{newTabLabel}
|
|
218
|
+
</VisuallyHidden>
|
|
219
|
+
) : null}
|
|
220
|
+
</>
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if (href !== undefined && !isDisabled) {
|
|
224
|
+
// An external destination or a fragment is never the
|
|
225
|
+
// router's — pushing `https://…` or `#credits` onto the
|
|
226
|
+
// history stack navigates the SPA to a route that does
|
|
227
|
+
// not exist. Same destination test as `ButtonLink`.
|
|
228
|
+
const LinkElement =
|
|
229
|
+
isExternal || !getIsRoutedHref(href)
|
|
230
|
+
? "a"
|
|
231
|
+
: RouterLink
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<LinkElement
|
|
235
|
+
className={tileClassName}
|
|
236
|
+
href={href}
|
|
237
|
+
key={value}
|
|
238
|
+
// `noreferrer` alongside `noopener` on purpose:
|
|
239
|
+
// `noopener` closes the `window.opener` hole, and
|
|
240
|
+
// `noreferrer` is what keeps a private app's URL out
|
|
241
|
+
// of the destination's logs.
|
|
242
|
+
rel={
|
|
243
|
+
isExternal
|
|
244
|
+
? "noopener noreferrer"
|
|
245
|
+
: undefined
|
|
246
|
+
}
|
|
247
|
+
target={isExternal ? "_blank" : undefined}
|
|
248
|
+
>
|
|
249
|
+
{content}
|
|
250
|
+
</LinkElement>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (href !== undefined) {
|
|
255
|
+
// `isDisabled` drops `href` entirely rather than shipping
|
|
256
|
+
// a focusable anchor that silently ignores clicks — an
|
|
257
|
+
// anchor with no `href` is inert by the platform's own
|
|
258
|
+
// rules, and the explicit `role`/`aria-disabled` keeps it
|
|
259
|
+
// *announced* as a link that is currently unavailable
|
|
260
|
+
// rather than vanishing from the tree. `ButtonLink`'s
|
|
261
|
+
// rule, unchanged.
|
|
262
|
+
return (
|
|
263
|
+
// biome-ignore lint/a11y/useValidAnchor: dropping `href` is the point. A disabled anchor that keeps one is focusable and silently ignores clicks; without one it is inert and out of the tab order by the platform's own rules, and the explicit `role`/`aria-disabled` keeps it announced rather than removed from the tree. `ButtonLink` makes the same call.
|
|
264
|
+
<a
|
|
265
|
+
aria-disabled="true"
|
|
266
|
+
className={tileClassName}
|
|
267
|
+
key={value}
|
|
268
|
+
role="link"
|
|
269
|
+
>
|
|
270
|
+
{content}
|
|
271
|
+
</a>
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
<button
|
|
277
|
+
className={tileClassName}
|
|
278
|
+
disabled={isDisabled}
|
|
279
|
+
key={value}
|
|
280
|
+
onClick={() => {
|
|
281
|
+
onChoose?.(value)
|
|
282
|
+
}}
|
|
283
|
+
type="button"
|
|
284
|
+
>
|
|
285
|
+
{content}
|
|
286
|
+
</button>
|
|
287
|
+
)
|
|
288
|
+
},
|
|
289
|
+
)}
|
|
290
|
+
</div>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
@@ -240,6 +240,22 @@ export const useAdaptiveColumns = <
|
|
|
240
240
|
* ⚠️ Never the element `contentMaxInlineSize` is applied to.
|
|
241
241
|
*/
|
|
242
242
|
containerRef: RefObject<ContainerElement | null>
|
|
243
|
+
/**
|
|
244
|
+
* Has the container been given a box yet?
|
|
245
|
+
*
|
|
246
|
+
* False in two situations that look identical from here and are
|
|
247
|
+
* both "no measurement has happened": the frame before
|
|
248
|
+
* `ResizeObserver` delivers its first record, and any time the
|
|
249
|
+
* container sits inside a `display: none` ancestor — which is
|
|
250
|
+
* what the `hidden` attribute is, and what a tab panel, a
|
|
251
|
+
* collapsed disclosure and a closed `<details>` all use.
|
|
252
|
+
*
|
|
253
|
+
* The distinction matters because a zero inline size is not a
|
|
254
|
+
* measurement of zero. It is the absence of one, and a consumer
|
|
255
|
+
* that treats it as a number lays out for a 0px container and
|
|
256
|
+
* then reads the correction as a change.
|
|
257
|
+
*/
|
|
258
|
+
isLaidOut: boolean
|
|
243
259
|
/** What was picked, `"auto"` included. */
|
|
244
260
|
choice: ColumnChoice
|
|
245
261
|
setChoice: (choice: ColumnChoice) => void
|
|
@@ -350,6 +366,7 @@ export const useAdaptiveColumns = <
|
|
|
350
366
|
gutterInlineSize,
|
|
351
367
|
singleInlineSize,
|
|
352
368
|
}),
|
|
369
|
+
isLaidOut: availableInlineSize > 0,
|
|
353
370
|
setChoice,
|
|
354
371
|
}
|
|
355
372
|
}
|
|
@@ -59,6 +59,13 @@ tile with its border taken away is a row.
|
|
|
59
59
|
no radio affordance, options too short to carry a sentence. A tile is this control with a
|
|
60
60
|
border, which is a different reason.
|
|
61
61
|
|
|
62
|
+
[`ActionTiles`](/docs/components-actions-actiontiles--docs) is the same tile that **acts**
|
|
63
|
+
rather than holds a value, and it *does* earn its own file: invert every clause of the
|
|
64
|
+
paragraph above — no `radio` role, no roving tabindex, no selection-follows-focus, no
|
|
65
|
+
`isReadOnly` — and the box is all that is left in common. The two read it from one
|
|
66
|
+
`tileStyles.ts`. Ask what a press does: records a value something below reads, or goes
|
|
67
|
+
somewhere and is done.
|
|
68
|
+
|
|
62
69
|
<Canvas of={RadioGroupStories.Tiles} />
|
|
63
70
|
|
|
64
71
|
Four apps in the fleet had each hand-painted this shape — ten instances between them — and
|
|
@@ -7,6 +7,11 @@ import type { ControlSize } from "@charcuterie/tokens"
|
|
|
7
7
|
import type { CSSProperties, ReactNode } from "react"
|
|
8
8
|
import { useCallback, useEffect, useRef } from "react"
|
|
9
9
|
|
|
10
|
+
import {
|
|
11
|
+
TILE_COLUMNS_CLASS,
|
|
12
|
+
TILE_GAP_CLASS,
|
|
13
|
+
TILE_MIN_INLINE_SIZE_PROPERTY,
|
|
14
|
+
} from "../tileStyles.ts"
|
|
10
15
|
import { toClassName } from "../toClassName.ts"
|
|
11
16
|
import { RadioGroupOption } from "./RadioGroupOption.tsx"
|
|
12
17
|
|
|
@@ -85,47 +90,6 @@ const GAP_CLASS: Record<ControlSize, string> = {
|
|
|
85
90
|
lg: "gap-2",
|
|
86
91
|
}
|
|
87
92
|
|
|
88
|
-
/**
|
|
89
|
-
* Wider than the row gaps: rows are separated by their leading, and
|
|
90
|
-
* tiles by nothing but the gap, so the same 6px that reads as a list
|
|
91
|
-
* reads as a seam between two cards.
|
|
92
|
-
*/
|
|
93
|
-
const TILE_GAP_CLASS: Record<ControlSize, string> = {
|
|
94
|
-
sm: "gap-1.5",
|
|
95
|
-
md: "gap-2",
|
|
96
|
-
lg: "gap-3",
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* `auto-fill`, and deliberately not `auto-fit`.
|
|
101
|
-
*
|
|
102
|
-
* `auto-fit` collapses the empty tracks and lets the ones that
|
|
103
|
-
* remain share the whole row, so six tiles in a 2560px container
|
|
104
|
-
* become six 420px slabs — the full-width-row shape in a new
|
|
105
|
-
* costume. `auto-fill` keeps the empty tracks, so a tile stays a
|
|
106
|
-
* tile and the grid simply has room to spare.
|
|
107
|
-
*
|
|
108
|
-
* `min(…, 100%)` is what stops the floor overflowing a container
|
|
109
|
-
* narrower than one tile, which is the Narrow View and is otherwise
|
|
110
|
-
* a horizontal scrollbar on a phone.
|
|
111
|
-
*
|
|
112
|
-
* The floor arrives as a custom property rather than an interpolated
|
|
113
|
-
* class because Tailwind scans source *text* for complete class
|
|
114
|
-
* strings: `` `grid-cols-[…${n}px…]` `` generates nothing, paints
|
|
115
|
-
* nothing and reports nothing. One written-out literal reading
|
|
116
|
-
* `var(--charcuterie-tile-min-inline-size)` covers every width an
|
|
117
|
-
* app can ask for — the same reason `Card`'s accent edge goes
|
|
118
|
-
* through one property.
|
|
119
|
-
*
|
|
120
|
-
* This is **not** `useAdaptiveColumns`, and the difference is the
|
|
121
|
-
* question being asked. That hook buys a column with height, for an
|
|
122
|
-
* unbounded gallery that will scroll; a radio group is a bounded set
|
|
123
|
-
* of options inside a form section, where the only question is how
|
|
124
|
-
* many fit across the box it was given.
|
|
125
|
-
*/
|
|
126
|
-
const TILE_COLUMNS_CLASS =
|
|
127
|
-
"grid-cols-[repeat(auto-fill,minmax(min(var(--charcuterie-tile-min-inline-size),100%),1fr))]"
|
|
128
|
-
|
|
129
93
|
/**
|
|
130
94
|
* One choice out of many, each on its own row with a label the size
|
|
131
95
|
* of prose — the stacked sibling of `SegmentedControl`.
|
|
@@ -157,6 +121,16 @@ const TILE_COLUMNS_CLASS =
|
|
|
157
121
|
* and `isReadOnly` are the ones already tested here. `SegmentedControl`
|
|
158
122
|
* earns its own file by being a different control — one connected
|
|
159
123
|
* strip, no radio affordance; a tile is this control with a border.
|
|
124
|
+
*
|
|
125
|
+
* ### A tile that ACTS is `ActionTiles`
|
|
126
|
+
*
|
|
127
|
+
* Same box, read from `tileStyles.ts`, and nothing else in common:
|
|
128
|
+
* no `radio` role, no roving tabindex, no selection to follow focus,
|
|
129
|
+
* no read-only. Ask what a press does. It records a value something
|
|
130
|
+
* below reads — this. It goes somewhere or starts something, and
|
|
131
|
+
* nothing stays selected — `ActionTiles`. Inverting every clause of
|
|
132
|
+
* the paragraph above is what earned that one its own file
|
|
133
|
+
* (`docs/decisions/2026-09-01-a-tile-that-acts-is-its-own-component-and-shares-only-the-box.md`).
|
|
160
134
|
*/
|
|
161
135
|
export const RadioGroup = ({
|
|
162
136
|
className,
|
|
@@ -292,7 +266,7 @@ export const RadioGroup = ({
|
|
|
292
266
|
style={
|
|
293
267
|
isTile
|
|
294
268
|
? ({
|
|
295
|
-
|
|
269
|
+
[TILE_MIN_INLINE_SIZE_PROPERTY]: `${minTileInlineSize}px`,
|
|
296
270
|
} as CSSProperties)
|
|
297
271
|
: undefined
|
|
298
272
|
}
|
|
@@ -3,6 +3,12 @@ import type { ReactNode } from "react"
|
|
|
3
3
|
import { useEffect } from "react"
|
|
4
4
|
|
|
5
5
|
import { FOCUS_RING_CLASS } from "../intentStyles.ts"
|
|
6
|
+
import {
|
|
7
|
+
TILE_BOX_CLASS,
|
|
8
|
+
TILE_HINT_TEXT_CLASS,
|
|
9
|
+
TILE_PADDING_CLASS,
|
|
10
|
+
TILE_TEXT_SIZE_CLASS,
|
|
11
|
+
} from "../tileStyles.ts"
|
|
6
12
|
import { toClassName } from "../toClassName.ts"
|
|
7
13
|
import type {
|
|
8
14
|
RadioItem,
|
|
@@ -42,42 +48,6 @@ const DOT_SIZE_CLASS: Record<ControlSize, string> = {
|
|
|
42
48
|
lg: "size-2.5",
|
|
43
49
|
}
|
|
44
50
|
|
|
45
|
-
const TEXT_SIZE_CLASS: Record<ControlSize, string> = {
|
|
46
|
-
sm: "text-sm",
|
|
47
|
-
md: "text-md",
|
|
48
|
-
lg: "text-lg",
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* One step down from the option's own text, and never below `xs`:
|
|
53
|
-
* a hint that keeps shrinking with the density axis is the 11px
|
|
54
|
-
* caption the 2026-08-10 ramp rebuild exists to have removed.
|
|
55
|
-
*/
|
|
56
|
-
const HINT_TEXT_CLASS: Record<ControlSize, string> = {
|
|
57
|
-
sm: "text-xs",
|
|
58
|
-
md: "text-xs",
|
|
59
|
-
lg: "text-sm",
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* A tile is a card, so its padding is a card's rather than a
|
|
64
|
-
* control's — enough that the border reads as a box around content
|
|
65
|
-
* and not as an outline on a line of text.
|
|
66
|
-
*/
|
|
67
|
-
const TILE_PADDING_CLASS: Record<ControlSize, string> = {
|
|
68
|
-
sm: "px-3 py-2.5",
|
|
69
|
-
md: "px-3.5 py-3",
|
|
70
|
-
lg: "px-4 py-3.5",
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* The box, and only the box. Every state below it is a **border and
|
|
75
|
-
* a surface**, never colour alone — the same reason `Stepper`
|
|
76
|
-
* refuses to say "done" with a hue.
|
|
77
|
-
*/
|
|
78
|
-
const TILE_BOX_CLASS =
|
|
79
|
-
"rounded-lg border border-border-subtle bg-surface-raised"
|
|
80
|
-
|
|
81
51
|
/**
|
|
82
52
|
* Its own file for the same reason `SegmentedOption` is: **both
|
|
83
53
|
* registrations are effects**, and an effect cannot run in a loop.
|
|
@@ -152,7 +122,7 @@ export const RadioGroupOption = ({
|
|
|
152
122
|
? "items-center"
|
|
153
123
|
: "items-start",
|
|
154
124
|
),
|
|
155
|
-
|
|
125
|
+
TILE_TEXT_SIZE_CLASS[size],
|
|
156
126
|
// Disabled turns the whole option down with `opacity-60`, the
|
|
157
127
|
// same family treatment as `Checkbox` and `Switch`, rather than
|
|
158
128
|
// fading the ring to a token that disappeared on a pale theme.
|
|
@@ -252,7 +222,7 @@ export const RadioGroupOption = ({
|
|
|
252
222
|
<span
|
|
253
223
|
className={toClassName(
|
|
254
224
|
"font-normal text-content-muted",
|
|
255
|
-
|
|
225
|
+
TILE_HINT_TEXT_CLASS[size],
|
|
256
226
|
)}
|
|
257
227
|
>
|
|
258
228
|
{hint}
|
|
@@ -59,6 +59,27 @@ wrong and every row is offset by the chrome above it.
|
|
|
59
59
|
|
|
60
60
|
<Canvas of={VirtualizedGridStories.InShellScrollRegion} />
|
|
61
61
|
|
|
62
|
+
## It waits for a box before it measures anything
|
|
63
|
+
|
|
64
|
+
`display: none` gives an element no boxes, and the `hidden` attribute is `display: none`.
|
|
65
|
+
So a grid inside a hidden tab panel, a collapsed disclosure or a closed `<details>` cannot
|
|
66
|
+
be measured at all: its container reads 0px wide and every mounted row reads 0px tall.
|
|
67
|
+
|
|
68
|
+
That is the absence of a measurement, not a measurement of zero, and the difference is not
|
|
69
|
+
academic. Revealing the panel corrects every one of those numbers at once, and a
|
|
70
|
+
virtualizer is entitled to read a correction as a **resize** — the one event it may move
|
|
71
|
+
the scroll position for. It then compensates for growth that never happened and scrolls the
|
|
72
|
+
page. Docket's Triage bar is where this was found: switching tabs moved `Main` by up to
|
|
73
|
+
1,190px and took the tab bar off the top of the screen.
|
|
74
|
+
|
|
75
|
+
So the grid mounts nothing until its container has a box. Put a grid in a tab panel and the
|
|
76
|
+
panel holds no rows while it is put away; the rows arrive with the box and are measured once,
|
|
77
|
+
correctly. The cost is that a revealed grid re-measures from its estimate rather than
|
|
78
|
+
resuming a cache, which is exactly what a routed tab already does.
|
|
79
|
+
|
|
80
|
+
<Canvas of={VirtualizedGridStories.InHiddenTabPanel} />
|
|
81
|
+
|
|
82
|
+
|
|
62
83
|
## The column rule is inherited, not reimplemented
|
|
63
84
|
|
|
64
85
|
<Canvas of={VirtualizedGridStories.AllVariants} />
|
|
@@ -10,6 +10,7 @@ import { Header } from "../Header/Header.tsx"
|
|
|
10
10
|
import { Main } from "../Main/Main.tsx"
|
|
11
11
|
import { SegmentedControl } from "../SegmentedControl/SegmentedControl.tsx"
|
|
12
12
|
import { Shell } from "../Shell/Shell.tsx"
|
|
13
|
+
import { Tabs } from "../Tabs/Tabs.tsx"
|
|
13
14
|
import { VirtualizedGrid } from "./VirtualizedGrid.tsx"
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -130,6 +131,69 @@ export const InShellScrollRegion: Story = {
|
|
|
130
131
|
),
|
|
131
132
|
}
|
|
132
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Two grids in a tab bar, one of them hidden — the shape that found
|
|
136
|
+
* the scroll bug.
|
|
137
|
+
*
|
|
138
|
+
* A `hidden` panel is `display: none`, so the grid inside it has no
|
|
139
|
+
* box: its container measures 0px wide (one column) and every
|
|
140
|
+
* mounted row measures 0px tall. Revealing the panel corrects all
|
|
141
|
+
* of that at once, and a virtualizer is entitled to read a
|
|
142
|
+
* correction as a resize and compensate the scroll position for it.
|
|
143
|
+
*
|
|
144
|
+
* Docket's Triage bar is where it showed: clicking a tab scrolled
|
|
145
|
+
* `Main` down 266px and put the tab bar off the top of the screen.
|
|
146
|
+
* So the claim this story exists to hold is one number — **switching
|
|
147
|
+
* tabs leaves `Main.scrollTop` where it was.**
|
|
148
|
+
*
|
|
149
|
+
* The chrome above the grid is not decoration either. The false
|
|
150
|
+
* adjustment is roughly the distance from the top of the scroll
|
|
151
|
+
* region to the top of the grid, so a story with the grid flush
|
|
152
|
+
* against the top of `Main` would have had nothing to measure and
|
|
153
|
+
* would have passed while the bug was still there.
|
|
154
|
+
*/
|
|
155
|
+
export const InHiddenTabPanel: Story = {
|
|
156
|
+
parameters: { layout: "fullscreen" },
|
|
157
|
+
render: (gridProps) => (
|
|
158
|
+
<Shell contentWidth="full">
|
|
159
|
+
<Header heading="Bays" />
|
|
160
|
+
|
|
161
|
+
<Main>
|
|
162
|
+
<p className="text-content-secondary text-sm">
|
|
163
|
+
Switching tabs must not move the page.
|
|
164
|
+
</p>
|
|
165
|
+
|
|
166
|
+
<Tabs
|
|
167
|
+
label="Bay views"
|
|
168
|
+
tabs={[
|
|
169
|
+
{
|
|
170
|
+
content: (
|
|
171
|
+
<VirtualizedGrid
|
|
172
|
+
{...gridProps}
|
|
173
|
+
label="Loaded bays"
|
|
174
|
+
/>
|
|
175
|
+
),
|
|
176
|
+
key: "loaded",
|
|
177
|
+
label: "Loaded",
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
content: (
|
|
181
|
+
<VirtualizedGrid
|
|
182
|
+
{...gridProps}
|
|
183
|
+
items={MANY_ITEMS.slice(0, 48)}
|
|
184
|
+
label="Reserved bays"
|
|
185
|
+
/>
|
|
186
|
+
),
|
|
187
|
+
key: "reserved",
|
|
188
|
+
label: "Reserved",
|
|
189
|
+
},
|
|
190
|
+
]}
|
|
191
|
+
/>
|
|
192
|
+
</Main>
|
|
193
|
+
</Shell>
|
|
194
|
+
),
|
|
195
|
+
}
|
|
196
|
+
|
|
133
197
|
/**
|
|
134
198
|
* The same 2,000 items at three window heights.
|
|
135
199
|
*
|