@charcuterie/ui 3.14.4 → 3.16.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/dist/Combobox/Combobox.d.ts.map +1 -1
- package/dist/Combobox/Combobox.js +35 -0
- package/dist/Combobox/Combobox.js.map +1 -1
- package/dist/RadioGroup/RadioGroup.d.ts +49 -1
- package/dist/RadioGroup/RadioGroup.d.ts.map +1 -1
- package/dist/RadioGroup/RadioGroup.js +58 -3
- package/dist/RadioGroup/RadioGroup.js.map +1 -1
- package/dist/RadioGroup/RadioGroupOption.d.ts +13 -2
- package/dist/RadioGroup/RadioGroupOption.d.ts.map +1 -1
- package/dist/RadioGroup/RadioGroupOption.js +62 -5
- package/dist/RadioGroup/RadioGroupOption.js.map +1 -1
- package/dist/Stepper/Stepper.d.ts.map +1 -1
- package/dist/Stepper/Stepper.js +2 -1
- package/dist/Stepper/Stepper.js.map +1 -1
- package/package.json +1 -1
- package/src/Combobox/Combobox.stories.tsx +16 -5
- package/src/Combobox/Combobox.test.tsx +45 -7
- package/src/Combobox/Combobox.tsx +44 -0
- package/src/RadioGroup/RadioGroup.mdx +62 -0
- package/src/RadioGroup/RadioGroup.stories.tsx +164 -1
- package/src/RadioGroup/RadioGroup.test.tsx +179 -2
- package/src/RadioGroup/RadioGroup.tsx +107 -3
- package/src/RadioGroup/RadioGroupOption.tsx +122 -5
- package/src/Stepper/Stepper.test.tsx +17 -0
- package/src/Stepper/Stepper.tsx +21 -0
|
@@ -4,13 +4,40 @@ import {
|
|
|
4
4
|
useSinglePicker,
|
|
5
5
|
} from "@charcuterie/logic"
|
|
6
6
|
import type { ControlSize } from "@charcuterie/tokens"
|
|
7
|
-
import type { ReactNode } from "react"
|
|
7
|
+
import type { CSSProperties, ReactNode } from "react"
|
|
8
8
|
import { useCallback, useEffect, useRef } from "react"
|
|
9
9
|
|
|
10
10
|
import { toClassName } from "../toClassName.ts"
|
|
11
11
|
import { RadioGroupOption } from "./RadioGroupOption.tsx"
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Which shape the options are drawn in. The control is the same
|
|
15
|
+
* `radiogroup` either way — this is layout, and it is a prop rather
|
|
16
|
+
* than a second component because nothing about the semantics, the
|
|
17
|
+
* keyboard model or the element changes with it.
|
|
18
|
+
*
|
|
19
|
+
* - `row` — the default. Each option is a line of prose beside its
|
|
20
|
+
* radio, stacked down the page. The form-length list.
|
|
21
|
+
* - `tile` — each option is a bordered card carrying a name, an
|
|
22
|
+
* optional line of help and a selected surface, and the group is
|
|
23
|
+
* a grid that gains columns with the CONTAINER's width.
|
|
24
|
+
*/
|
|
25
|
+
export type RadioItemShape = "row" | "tile"
|
|
26
|
+
|
|
13
27
|
export type RadioItem = {
|
|
28
|
+
/**
|
|
29
|
+
* A line of help under the label. Drawn one step down the type
|
|
30
|
+
* ramp in `content-muted`, inside the option's accessible name —
|
|
31
|
+
* a hint a screen reader never reads is a hint half the audience
|
|
32
|
+
* does not have.
|
|
33
|
+
*/
|
|
34
|
+
hint?: ReactNode
|
|
35
|
+
/**
|
|
36
|
+
* Leading content, before the name. A `Badge`, an app's own icon,
|
|
37
|
+
* a count. `tile` only: a row already leads with its radio, and a
|
|
38
|
+
* second leading mark either side of it has nowhere to sit.
|
|
39
|
+
*/
|
|
40
|
+
icon?: ReactNode
|
|
14
41
|
/**
|
|
15
42
|
* A disabled option is simply **not registered** with the roving
|
|
16
43
|
* group, so the arrow keys skip it without any command in
|
|
@@ -24,6 +51,11 @@ export type RadioItem = {
|
|
|
24
51
|
|
|
25
52
|
export type RadioGroupProps = {
|
|
26
53
|
className?: string
|
|
54
|
+
/**
|
|
55
|
+
* Draws each option as a card in a grid instead of a row in a
|
|
56
|
+
* stack. Defaults to `row`.
|
|
57
|
+
*/
|
|
58
|
+
itemShape?: RadioItemShape
|
|
27
59
|
items: readonly RadioItem[]
|
|
28
60
|
/**
|
|
29
61
|
* Shows which option is chosen at full contrast but refuses to move
|
|
@@ -34,6 +66,13 @@ export type RadioGroupProps = {
|
|
|
34
66
|
isReadOnly?: boolean
|
|
35
67
|
/** The group's accessible name. Required. */
|
|
36
68
|
label: string
|
|
69
|
+
/**
|
|
70
|
+
* The narrowest a tile track may be, in CSS px. `tile` only.
|
|
71
|
+
*
|
|
72
|
+
* It is the grid's floor and not the tile's width: tracks below it
|
|
73
|
+
* are not created, and the ones that are share the row evenly.
|
|
74
|
+
*/
|
|
75
|
+
minTileInlineSize?: number
|
|
37
76
|
onChange?: (selectedValue: string | null) => void
|
|
38
77
|
/** **Initial** only. Charcuterie owns it from then on. */
|
|
39
78
|
selectedValue?: string
|
|
@@ -46,6 +85,47 @@ const GAP_CLASS: Record<ControlSize, string> = {
|
|
|
46
85
|
lg: "gap-2",
|
|
47
86
|
}
|
|
48
87
|
|
|
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
|
+
|
|
49
129
|
/**
|
|
50
130
|
* One choice out of many, each on its own row with a label the size
|
|
51
131
|
* of prose — the stacked sibling of `SegmentedControl`.
|
|
@@ -67,12 +147,24 @@ const GAP_CLASS: Record<ControlSize, string> = {
|
|
|
67
147
|
* choice from a named set is a radio group, and its arrow keys both
|
|
68
148
|
* move focus and check — APG's one activation mode, the same reason
|
|
69
149
|
* `SegmentedControl` has no `manual` prop.
|
|
150
|
+
*
|
|
151
|
+
* ### `itemShape="tile"` is the choice tile
|
|
152
|
+
*
|
|
153
|
+
* A bordered card carrying a name, a line of help and a selected
|
|
154
|
+
* surface, in a grid that gains columns with its container. It is a
|
|
155
|
+
* prop and not a third component because **only the box changes**:
|
|
156
|
+
* the element, the role, the roving tabindex, selection-follows-focus
|
|
157
|
+
* and `isReadOnly` are the ones already tested here. `SegmentedControl`
|
|
158
|
+
* earns its own file by being a different control — one connected
|
|
159
|
+
* strip, no radio affordance; a tile is this control with a border.
|
|
70
160
|
*/
|
|
71
161
|
export const RadioGroup = ({
|
|
72
162
|
className,
|
|
163
|
+
itemShape = "row",
|
|
73
164
|
items,
|
|
74
165
|
isReadOnly = false,
|
|
75
166
|
label,
|
|
167
|
+
minTileInlineSize = 200,
|
|
76
168
|
onChange,
|
|
77
169
|
selectedValue,
|
|
78
170
|
size = "md",
|
|
@@ -83,6 +175,8 @@ export const RadioGroup = ({
|
|
|
83
175
|
new Map<string, HTMLButtonElement>(),
|
|
84
176
|
)
|
|
85
177
|
|
|
178
|
+
const isTile = itemShape === "tile"
|
|
179
|
+
|
|
86
180
|
const initialValue =
|
|
87
181
|
selectedValue ??
|
|
88
182
|
items.find((one) => !one.isDisabled)?.value ??
|
|
@@ -152,8 +246,10 @@ export const RadioGroup = ({
|
|
|
152
246
|
aria-label={label}
|
|
153
247
|
aria-readonly={isReadOnly || undefined}
|
|
154
248
|
className={toClassName(
|
|
155
|
-
|
|
156
|
-
|
|
249
|
+
isTile
|
|
250
|
+
? toClassName("grid", TILE_COLUMNS_CLASS)
|
|
251
|
+
: "flex flex-col",
|
|
252
|
+
isTile ? TILE_GAP_CLASS[size] : GAP_CLASS[size],
|
|
157
253
|
className,
|
|
158
254
|
)}
|
|
159
255
|
onKeyDown={(keyEvent) => {
|
|
@@ -193,6 +289,13 @@ export const RadioGroup = ({
|
|
|
193
289
|
}
|
|
194
290
|
}}
|
|
195
291
|
ref={groupRef}
|
|
292
|
+
style={
|
|
293
|
+
isTile
|
|
294
|
+
? ({
|
|
295
|
+
"--charcuterie-tile-min-inline-size": `${minTileInlineSize}px`,
|
|
296
|
+
} as CSSProperties)
|
|
297
|
+
: undefined
|
|
298
|
+
}
|
|
196
299
|
role="radiogroup"
|
|
197
300
|
>
|
|
198
301
|
{items.map((item) => (
|
|
@@ -200,6 +303,7 @@ export const RadioGroup = ({
|
|
|
200
303
|
isChecked={checkedValue === item.value}
|
|
201
304
|
isReadOnly={isReadOnly}
|
|
202
305
|
item={item}
|
|
306
|
+
itemShape={itemShape}
|
|
203
307
|
key={item.value}
|
|
204
308
|
onChoose={chooseOption}
|
|
205
309
|
registerFocus={focus.register}
|
|
@@ -4,12 +4,16 @@ import { useEffect } from "react"
|
|
|
4
4
|
|
|
5
5
|
import { FOCUS_RING_CLASS } from "../intentStyles.ts"
|
|
6
6
|
import { toClassName } from "../toClassName.ts"
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
RadioItem,
|
|
9
|
+
RadioItemShape,
|
|
10
|
+
} from "./RadioGroup.tsx"
|
|
8
11
|
|
|
9
12
|
export type RadioGroupOptionProps = {
|
|
10
13
|
isChecked: boolean
|
|
11
14
|
isReadOnly: boolean
|
|
12
15
|
item: RadioItem
|
|
16
|
+
itemShape: RadioItemShape
|
|
13
17
|
onChoose: (value: string) => void
|
|
14
18
|
/** `RovingFocus.register` — membership of the arrow-key group. */
|
|
15
19
|
registerFocus: (value: string) => () => void
|
|
@@ -44,6 +48,36 @@ const TEXT_SIZE_CLASS: Record<ControlSize, string> = {
|
|
|
44
48
|
lg: "text-lg",
|
|
45
49
|
}
|
|
46
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
|
+
|
|
47
81
|
/**
|
|
48
82
|
* Its own file for the same reason `SegmentedOption` is: **both
|
|
49
83
|
* registrations are effects**, and an effect cannot run in a loop.
|
|
@@ -52,11 +86,22 @@ const TEXT_SIZE_CLASS: Record<ControlSize, string> = {
|
|
|
52
86
|
* with a background, so the checked dot can be centred by the grid
|
|
53
87
|
* and sized independently of the ring — a filled `bg-intent-accent`
|
|
54
88
|
* ring with no inner dot reads as a checkbox, not a radio.
|
|
89
|
+
*
|
|
90
|
+
* ### The tile shape is the same element with a border
|
|
91
|
+
*
|
|
92
|
+
* `itemShape="tile"` swaps `inline-flex` for `flex` — a grid item
|
|
93
|
+
* stretches to its track, and an inline box would sit at
|
|
94
|
+
* max-content inside it — adds the card's padding and border, and
|
|
95
|
+
* moves the ring to the top of a two-line label. The radio dot is
|
|
96
|
+
* still there and is still the thing a screen reader reads: a tile
|
|
97
|
+
* with its border taken away is this row, which is why the two are
|
|
98
|
+
* one component.
|
|
55
99
|
*/
|
|
56
100
|
export const RadioGroupOption = ({
|
|
57
101
|
isChecked,
|
|
58
102
|
isReadOnly,
|
|
59
103
|
item,
|
|
104
|
+
itemShape,
|
|
60
105
|
onChoose,
|
|
61
106
|
registerFocus,
|
|
62
107
|
registerSelection,
|
|
@@ -64,7 +109,15 @@ export const RadioGroupOption = ({
|
|
|
64
109
|
tabIndex,
|
|
65
110
|
trackElement,
|
|
66
111
|
}: RadioGroupOptionProps): ReactNode => {
|
|
67
|
-
const {
|
|
112
|
+
const {
|
|
113
|
+
hint,
|
|
114
|
+
icon,
|
|
115
|
+
isDisabled = false,
|
|
116
|
+
label,
|
|
117
|
+
value,
|
|
118
|
+
} = item
|
|
119
|
+
|
|
120
|
+
const isTile = itemShape === "tile"
|
|
68
121
|
|
|
69
122
|
useEffect(
|
|
70
123
|
() => registerSelection(value),
|
|
@@ -84,7 +137,21 @@ export const RadioGroupOption = ({
|
|
|
84
137
|
<button
|
|
85
138
|
aria-checked={isChecked}
|
|
86
139
|
className={toClassName(
|
|
87
|
-
"
|
|
140
|
+
"gap-2 text-content-secondary text-start transition-colors duration-(--duration-fast) ease-standard",
|
|
141
|
+
isTile
|
|
142
|
+
? toClassName(
|
|
143
|
+
"flex min-w-0 items-start",
|
|
144
|
+
TILE_BOX_CLASS,
|
|
145
|
+
TILE_PADDING_CLASS[size],
|
|
146
|
+
)
|
|
147
|
+
: toClassName(
|
|
148
|
+
"inline-flex rounded-sm",
|
|
149
|
+
// A hint puts a second line under the label, and a ring
|
|
150
|
+
// centred against both sits beside neither.
|
|
151
|
+
hint === undefined
|
|
152
|
+
? "items-center"
|
|
153
|
+
: "items-start",
|
|
154
|
+
),
|
|
88
155
|
TEXT_SIZE_CLASS[size],
|
|
89
156
|
// Disabled turns the whole option down with `opacity-60`, the
|
|
90
157
|
// same family treatment as `Checkbox` and `Switch`, rather than
|
|
@@ -95,7 +162,18 @@ export const RadioGroupOption = ({
|
|
|
95
162
|
? "cursor-not-allowed opacity-60"
|
|
96
163
|
: isReadOnly
|
|
97
164
|
? "cursor-default"
|
|
98
|
-
:
|
|
165
|
+
: isTile
|
|
166
|
+
? "cursor-pointer hover:border-border-strong"
|
|
167
|
+
: "cursor-pointer hover:text-content-primary",
|
|
168
|
+
// The selected tile carries the accent on its EDGE and lifts
|
|
169
|
+
// its surface. A tile that said "chosen" with a background
|
|
170
|
+
// alone would be the one state a monochrome ePaper build
|
|
171
|
+
// cannot show at all.
|
|
172
|
+
isTile && isChecked
|
|
173
|
+
? isReadOnly
|
|
174
|
+
? "border-intent-neutral-solid bg-surface-overlay"
|
|
175
|
+
: "border-intent-accent-solid bg-surface-overlay"
|
|
176
|
+
: "",
|
|
99
177
|
FOCUS_RING_CLASS,
|
|
100
178
|
)}
|
|
101
179
|
disabled={isDisabled}
|
|
@@ -142,7 +220,46 @@ export const RadioGroupOption = ({
|
|
|
142
220
|
/>
|
|
143
221
|
</span>
|
|
144
222
|
|
|
145
|
-
|
|
223
|
+
{hint === undefined && !isTile ? (
|
|
224
|
+
<span>{label}</span>
|
|
225
|
+
) : (
|
|
226
|
+
// `min-w-0` and `wrap-anywhere` together, because a grid
|
|
227
|
+
// item's automatic minimum is its min-content width: one
|
|
228
|
+
// unbroken token — a path, a URL, a title with no spaces —
|
|
229
|
+
// otherwise sets the track's floor and shoves the grid out
|
|
230
|
+
// of its container.
|
|
231
|
+
<span className="flex min-w-0 flex-col gap-0.5 wrap-anywhere">
|
|
232
|
+
{icon !== undefined && isTile ? (
|
|
233
|
+
// Decorative by construction: the name it sits beside is
|
|
234
|
+
// in the same button, so an announced icon is the label
|
|
235
|
+
// read twice.
|
|
236
|
+
<span aria-hidden className="flex">
|
|
237
|
+
{icon}
|
|
238
|
+
</span>
|
|
239
|
+
) : null}
|
|
240
|
+
|
|
241
|
+
<span
|
|
242
|
+
className={
|
|
243
|
+
isTile
|
|
244
|
+
? "font-semibold text-content-primary"
|
|
245
|
+
: undefined
|
|
246
|
+
}
|
|
247
|
+
>
|
|
248
|
+
{label}
|
|
249
|
+
</span>
|
|
250
|
+
|
|
251
|
+
{hint === undefined ? null : (
|
|
252
|
+
<span
|
|
253
|
+
className={toClassName(
|
|
254
|
+
"font-normal text-content-muted",
|
|
255
|
+
HINT_TEXT_CLASS[size],
|
|
256
|
+
)}
|
|
257
|
+
>
|
|
258
|
+
{hint}
|
|
259
|
+
</span>
|
|
260
|
+
)}
|
|
261
|
+
</span>
|
|
262
|
+
)}
|
|
146
263
|
</button>
|
|
147
264
|
)
|
|
148
265
|
}
|
|
@@ -82,6 +82,23 @@ test("the last step draws no connector", async () => {
|
|
|
82
82
|
await expect(connectors[2]).toBeNull()
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
test("each step carries its key into the DOM, so a re-order can animate", async () => {
|
|
86
|
+
const { canvas } = await mountStory(Default)
|
|
87
|
+
|
|
88
|
+
// React's `key` never reaches the markup. Without this attribute
|
|
89
|
+
// a caller who re-orders `steps` has no way to say which `<li>`
|
|
90
|
+
// is which, and `useFlipList` — which matches on exactly this
|
|
91
|
+
// attribute — would fall back to pairing items by POSITION,
|
|
92
|
+
// measure a delta of zero for every one, and animate nothing.
|
|
93
|
+
await expect(
|
|
94
|
+
canvas
|
|
95
|
+
.getAllByRole("listitem")
|
|
96
|
+
.map((item: HTMLElement) =>
|
|
97
|
+
item.getAttribute("data-flip-key"),
|
|
98
|
+
),
|
|
99
|
+
).toEqual(["rip", "tag", "file"])
|
|
100
|
+
})
|
|
101
|
+
|
|
85
102
|
test("advancing moves which step is current", async () => {
|
|
86
103
|
const { canvas } = await mountStory(Interactive)
|
|
87
104
|
|
package/src/Stepper/Stepper.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FLIP_KEY_ATTRIBUTE } from "@charcuterie/logic"
|
|
1
2
|
import type { ReactNode } from "react"
|
|
2
3
|
|
|
3
4
|
import { toClassName } from "../toClassName.ts"
|
|
@@ -138,7 +139,27 @@ export const Stepper = ({
|
|
|
138
139
|
const isLast = index === steps.length - 1
|
|
139
140
|
|
|
140
141
|
return (
|
|
142
|
+
/*
|
|
143
|
+
* `data-flip-key` carries `step.key` into the DOM so a
|
|
144
|
+
* re-ordered stepper can animate with `useFlipList`
|
|
145
|
+
* from `@charcuterie/logic`, which matches items on
|
|
146
|
+
* exactly this attribute.
|
|
147
|
+
*
|
|
148
|
+
* Emitted always, not behind a prop. React's `key` is
|
|
149
|
+
* invisible to the DOM, so without this a caller who
|
|
150
|
+
* re-orders `steps` has no way to say which `<li>` is
|
|
151
|
+
* which — and FLIP matched by POSITION pairs every item
|
|
152
|
+
* with whatever now sits where it used to be, measures
|
|
153
|
+
* a delta of zero, and animates nothing. Docket's phase
|
|
154
|
+
* list is the case: the owner reported *"when I move
|
|
155
|
+
* them up and down, there's no animation"*, and the
|
|
156
|
+
* step's identity never reached the markup.
|
|
157
|
+
*
|
|
158
|
+
* A data attribute on a list item costs nothing when
|
|
159
|
+
* nobody reads it, which is why it is not opt-in.
|
|
160
|
+
*/
|
|
141
161
|
<li
|
|
162
|
+
{...{ [FLIP_KEY_ATTRIBUTE]: step.key }}
|
|
142
163
|
className={toClassName(
|
|
143
164
|
"relative grid grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_minmax(0,1fr)] gap-x-3",
|
|
144
165
|
isHorizontal &&
|