@charcuterie/ui 3.31.0 → 3.32.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/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/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/RadioGroup/RadioGroup.mdx +7 -0
- package/src/RadioGroup/RadioGroup.tsx +16 -42
- package/src/RadioGroup/RadioGroupOption.tsx +8 -38
- package/src/index.ts +5 -0
- package/src/sourceRules.test.ts +9 -1
- package/src/tileStyles.ts +118 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tile shape, shared by the two components that draw it.
|
|
3
|
+
*
|
|
4
|
+
* A tile is a bordered card carrying a name and a line of help, laid
|
|
5
|
+
* out in a grid that gains columns with its CONTAINER. `RadioGroup`
|
|
6
|
+
* draws one per option when `itemShape="tile"`; `ActionTiles` draws
|
|
7
|
+
* one per action. The two must be the **same box** — a page that puts
|
|
8
|
+
* "which activity" beside "what would you like to do" would otherwise
|
|
9
|
+
* show two cards with different padding, different corners and
|
|
10
|
+
* different gaps, which is the drift this file exists to make
|
|
11
|
+
* impossible.
|
|
12
|
+
*
|
|
13
|
+
* Same argument as `CONTROL_BASE_CLASS`, which `Button` and
|
|
14
|
+
* `ButtonLink` share for the same reason: "identical" spelled as two
|
|
15
|
+
* copies of one string is a promise that survives exactly one edit.
|
|
16
|
+
*
|
|
17
|
+
* These were `RadioGroup`'s private constants until `ActionTiles`
|
|
18
|
+
* shipped. Nothing about their values changed on the way out.
|
|
19
|
+
*/
|
|
20
|
+
import type { ControlSize } from "@charcuterie/tokens";
|
|
21
|
+
/**
|
|
22
|
+
* A tile is a card, so its padding is a card's rather than a
|
|
23
|
+
* control's — enough that the border reads as a box around content
|
|
24
|
+
* and not as an outline on a line of text.
|
|
25
|
+
*
|
|
26
|
+
* This is also the whole reason `ActionTiles` exists rather than a
|
|
27
|
+
* `Button` with a `className`: a `Button` is sized by
|
|
28
|
+
* `h-(--control-height-md)` and carries **no block padding at all**,
|
|
29
|
+
* so an app that reaches for `height: auto` to let one wrap gets a
|
|
30
|
+
* two-line card with `padding: 0` down its block axis and no error
|
|
31
|
+
* anywhere. QueuePilot's queue-type chooser shipped exactly that.
|
|
32
|
+
*/
|
|
33
|
+
export declare const TILE_PADDING_CLASS: Record<ControlSize, string>;
|
|
34
|
+
/**
|
|
35
|
+
* The box, and only the box. Every state layered over it is a
|
|
36
|
+
* **border and a surface**, never colour alone — the same reason
|
|
37
|
+
* `Stepper` refuses to say "done" with a hue.
|
|
38
|
+
*/
|
|
39
|
+
export declare const TILE_BOX_CLASS = "rounded-lg border border-border-subtle bg-surface-raised";
|
|
40
|
+
export declare const TILE_TEXT_SIZE_CLASS: Record<ControlSize, string>;
|
|
41
|
+
/**
|
|
42
|
+
* One step down from the tile's own text, and never below `xs`: a
|
|
43
|
+
* hint that keeps shrinking with the density axis is the 11px
|
|
44
|
+
* caption the 2026-08-10 ramp rebuild exists to have removed.
|
|
45
|
+
*/
|
|
46
|
+
export declare const TILE_HINT_TEXT_CLASS: Record<ControlSize, string>;
|
|
47
|
+
/**
|
|
48
|
+
* Wider than a list's row gaps: rows are separated by their leading,
|
|
49
|
+
* and tiles by nothing but the gap, so the same 6px that reads as a
|
|
50
|
+
* list reads as a seam between two cards.
|
|
51
|
+
*/
|
|
52
|
+
export declare const TILE_GAP_CLASS: Record<ControlSize, string>;
|
|
53
|
+
/**
|
|
54
|
+
* `auto-fill`, and deliberately not `auto-fit`.
|
|
55
|
+
*
|
|
56
|
+
* `auto-fit` collapses the empty tracks and lets the ones that
|
|
57
|
+
* remain share the whole row, so six tiles in a 2560px container
|
|
58
|
+
* become six 420px slabs — the full-width-row shape in a new
|
|
59
|
+
* costume. `auto-fill` keeps the empty tracks, so a tile stays a
|
|
60
|
+
* tile and the grid simply has room to spare.
|
|
61
|
+
*
|
|
62
|
+
* `min(…, 100%)` is what stops the floor overflowing a container
|
|
63
|
+
* narrower than one tile, which is the Narrow View and is otherwise
|
|
64
|
+
* a horizontal scrollbar on a phone.
|
|
65
|
+
*
|
|
66
|
+
* The floor arrives as a custom property rather than an interpolated
|
|
67
|
+
* class because Tailwind scans source *text* for complete class
|
|
68
|
+
* strings: `` `grid-cols-[…${n}px…]` `` generates nothing, paints
|
|
69
|
+
* nothing and reports nothing. One written-out literal reading
|
|
70
|
+
* `var(--charcuterie-tile-min-inline-size)` covers every width an
|
|
71
|
+
* app can ask for — the same reason `Card`'s accent edge goes
|
|
72
|
+
* through one property.
|
|
73
|
+
*
|
|
74
|
+
* This is **not** `useAdaptiveColumns`, and the difference is the
|
|
75
|
+
* question being asked. That hook buys a column with height, for an
|
|
76
|
+
* unbounded gallery that will scroll; a tile set is a bounded group
|
|
77
|
+
* inside a form section or a first step, where the only question is
|
|
78
|
+
* how many fit across the box it was given.
|
|
79
|
+
*/
|
|
80
|
+
export declare const TILE_COLUMNS_CLASS = "grid-cols-[repeat(auto-fill,minmax(min(var(--charcuterie-tile-min-inline-size),100%),1fr))]";
|
|
81
|
+
/** The property `TILE_COLUMNS_CLASS` reads its floor from. */
|
|
82
|
+
export declare const TILE_MIN_INLINE_SIZE_PROPERTY = "--charcuterie-tile-min-inline-size";
|
|
83
|
+
//# sourceMappingURL=tileStyles.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tileStyles.d.ts","sourceRoot":"","sources":["../src/tileStyles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAEtD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,kBAAkB,EAAE,MAAM,CACrC,WAAW,EACX,MAAM,CAKP,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,cAAc,6DACiC,CAAA;AAE5D,eAAO,MAAM,oBAAoB,EAAE,MAAM,CACvC,WAAW,EACX,MAAM,CAKP,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,EAAE,MAAM,CACvC,WAAW,EACX,MAAM,CAKP,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,cAAc,EAAE,MAAM,CAAC,WAAW,EAAE,MAAM,CAItD,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,kBAAkB,gGACgE,CAAA;AAE/F,8DAA8D;AAC9D,eAAO,MAAM,6BAA6B,uCACJ,CAAA"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tile shape, shared by the two components that draw it.
|
|
3
|
+
*
|
|
4
|
+
* A tile is a bordered card carrying a name and a line of help, laid
|
|
5
|
+
* out in a grid that gains columns with its CONTAINER. `RadioGroup`
|
|
6
|
+
* draws one per option when `itemShape="tile"`; `ActionTiles` draws
|
|
7
|
+
* one per action. The two must be the **same box** — a page that puts
|
|
8
|
+
* "which activity" beside "what would you like to do" would otherwise
|
|
9
|
+
* show two cards with different padding, different corners and
|
|
10
|
+
* different gaps, which is the drift this file exists to make
|
|
11
|
+
* impossible.
|
|
12
|
+
*
|
|
13
|
+
* Same argument as `CONTROL_BASE_CLASS`, which `Button` and
|
|
14
|
+
* `ButtonLink` share for the same reason: "identical" spelled as two
|
|
15
|
+
* copies of one string is a promise that survives exactly one edit.
|
|
16
|
+
*
|
|
17
|
+
* These were `RadioGroup`'s private constants until `ActionTiles`
|
|
18
|
+
* shipped. Nothing about their values changed on the way out.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* A tile is a card, so its padding is a card's rather than a
|
|
22
|
+
* control's — enough that the border reads as a box around content
|
|
23
|
+
* and not as an outline on a line of text.
|
|
24
|
+
*
|
|
25
|
+
* This is also the whole reason `ActionTiles` exists rather than a
|
|
26
|
+
* `Button` with a `className`: a `Button` is sized by
|
|
27
|
+
* `h-(--control-height-md)` and carries **no block padding at all**,
|
|
28
|
+
* so an app that reaches for `height: auto` to let one wrap gets a
|
|
29
|
+
* two-line card with `padding: 0` down its block axis and no error
|
|
30
|
+
* anywhere. QueuePilot's queue-type chooser shipped exactly that.
|
|
31
|
+
*/
|
|
32
|
+
export const TILE_PADDING_CLASS = {
|
|
33
|
+
sm: "px-3 py-2.5",
|
|
34
|
+
md: "px-3.5 py-3",
|
|
35
|
+
lg: "px-4 py-3.5",
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* The box, and only the box. Every state layered over it is a
|
|
39
|
+
* **border and a surface**, never colour alone — the same reason
|
|
40
|
+
* `Stepper` refuses to say "done" with a hue.
|
|
41
|
+
*/
|
|
42
|
+
export const TILE_BOX_CLASS = "rounded-lg border border-border-subtle bg-surface-raised";
|
|
43
|
+
export const TILE_TEXT_SIZE_CLASS = {
|
|
44
|
+
sm: "text-sm",
|
|
45
|
+
md: "text-md",
|
|
46
|
+
lg: "text-lg",
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* One step down from the tile's own text, and never below `xs`: a
|
|
50
|
+
* hint that keeps shrinking with the density axis is the 11px
|
|
51
|
+
* caption the 2026-08-10 ramp rebuild exists to have removed.
|
|
52
|
+
*/
|
|
53
|
+
export const TILE_HINT_TEXT_CLASS = {
|
|
54
|
+
sm: "text-xs",
|
|
55
|
+
md: "text-xs",
|
|
56
|
+
lg: "text-sm",
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Wider than a list's row gaps: rows are separated by their leading,
|
|
60
|
+
* and tiles by nothing but the gap, so the same 6px that reads as a
|
|
61
|
+
* list reads as a seam between two cards.
|
|
62
|
+
*/
|
|
63
|
+
export const TILE_GAP_CLASS = {
|
|
64
|
+
sm: "gap-1.5",
|
|
65
|
+
md: "gap-2",
|
|
66
|
+
lg: "gap-3",
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* `auto-fill`, and deliberately not `auto-fit`.
|
|
70
|
+
*
|
|
71
|
+
* `auto-fit` collapses the empty tracks and lets the ones that
|
|
72
|
+
* remain share the whole row, so six tiles in a 2560px container
|
|
73
|
+
* become six 420px slabs — the full-width-row shape in a new
|
|
74
|
+
* costume. `auto-fill` keeps the empty tracks, so a tile stays a
|
|
75
|
+
* tile and the grid simply has room to spare.
|
|
76
|
+
*
|
|
77
|
+
* `min(…, 100%)` is what stops the floor overflowing a container
|
|
78
|
+
* narrower than one tile, which is the Narrow View and is otherwise
|
|
79
|
+
* a horizontal scrollbar on a phone.
|
|
80
|
+
*
|
|
81
|
+
* The floor arrives as a custom property rather than an interpolated
|
|
82
|
+
* class because Tailwind scans source *text* for complete class
|
|
83
|
+
* strings: `` `grid-cols-[…${n}px…]` `` generates nothing, paints
|
|
84
|
+
* nothing and reports nothing. One written-out literal reading
|
|
85
|
+
* `var(--charcuterie-tile-min-inline-size)` covers every width an
|
|
86
|
+
* app can ask for — the same reason `Card`'s accent edge goes
|
|
87
|
+
* through one property.
|
|
88
|
+
*
|
|
89
|
+
* This is **not** `useAdaptiveColumns`, and the difference is the
|
|
90
|
+
* question being asked. That hook buys a column with height, for an
|
|
91
|
+
* unbounded gallery that will scroll; a tile set is a bounded group
|
|
92
|
+
* inside a form section or a first step, where the only question is
|
|
93
|
+
* how many fit across the box it was given.
|
|
94
|
+
*/
|
|
95
|
+
export const TILE_COLUMNS_CLASS = "grid-cols-[repeat(auto-fill,minmax(min(var(--charcuterie-tile-min-inline-size),100%),1fr))]";
|
|
96
|
+
/** The property `TILE_COLUMNS_CLASS` reads its floor from. */
|
|
97
|
+
export const TILE_MIN_INLINE_SIZE_PROPERTY = "--charcuterie-tile-min-inline-size";
|
|
98
|
+
//# sourceMappingURL=tileStyles.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tileStyles.js","sourceRoot":"","sources":["../src/tileStyles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAIH;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAG3B;IACF,EAAE,EAAE,aAAa;IACjB,EAAE,EAAE,aAAa;IACjB,EAAE,EAAE,aAAa;CAClB,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,cAAc,GACzB,0DAA0D,CAAA;AAE5D,MAAM,CAAC,MAAM,oBAAoB,GAG7B;IACF,EAAE,EAAE,SAAS;IACb,EAAE,EAAE,SAAS;IACb,EAAE,EAAE,SAAS;CACd,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAG7B;IACF,EAAE,EAAE,SAAS;IACb,EAAE,EAAE,SAAS;IACb,EAAE,EAAE,SAAS;CACd,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,cAAc,GAAgC;IACzD,EAAE,EAAE,SAAS;IACb,EAAE,EAAE,OAAO;IACX,EAAE,EAAE,OAAO;CACZ,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAC7B,6FAA6F,CAAA;AAE/F,8DAA8D;AAC9D,MAAM,CAAC,MAAM,6BAA6B,GACxC,oCAAoC,CAAA"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as ActionTilesStories from "./ActionTiles.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={ActionTilesStories} />
|
|
6
|
+
|
|
7
|
+
# ActionTiles
|
|
8
|
+
|
|
9
|
+
A set of **actions** drawn as tiles — a bordered card carrying a name and a line of help, in
|
|
10
|
+
a grid that gains columns with its container.
|
|
11
|
+
|
|
12
|
+
This is [`RadioGroup`](/docs/components-controls-radiogroup--docs)'s `itemShape="tile"` with
|
|
13
|
+
the radio taken away. The 2026-08-25 record that made the choice tile a `RadioGroup` shape
|
|
14
|
+
named this case under *"What this deliberately does not cover"*: mux-magic's "Pick a tool"
|
|
15
|
+
tiles are `<a href>` and points-market's are `<Link>`, and *"a link is not a radio, and
|
|
16
|
+
giving one `aria-checked` would be worse than the paint it replaced"*.
|
|
17
|
+
|
|
18
|
+
<Canvas of={ActionTilesStories.Default} />
|
|
19
|
+
|
|
20
|
+
## Props
|
|
21
|
+
|
|
22
|
+
<Controls />
|
|
23
|
+
|
|
24
|
+
## Which of the two you want
|
|
25
|
+
|
|
26
|
+
Ask what a press does.
|
|
27
|
+
|
|
28
|
+
- It **records a value** the user will then submit, or that filters what is below it —
|
|
29
|
+
`RadioGroup itemShape="tile"`. The set announces "3 of 6", the arrow keys move the choice,
|
|
30
|
+
and one tile stays selected.
|
|
31
|
+
- It **goes somewhere or starts something** — this. Nothing stays selected, because nothing
|
|
32
|
+
was chosen: the page moved on.
|
|
33
|
+
|
|
34
|
+
A first step that opens the next step is the second kind. QueuePilot's "Queue type" chooser
|
|
35
|
+
— *Picks* or *Rules*, and the editor for the one you press opens immediately — is the case
|
|
36
|
+
this component was built for; that same app's Tonight activity filter stays a `RadioGroup`,
|
|
37
|
+
because it holds a value the Go button below it reads.
|
|
38
|
+
|
|
39
|
+
<Canvas of={ActionTilesStories.BesideTheRadioTile} />
|
|
40
|
+
|
|
41
|
+
## Why this is not a `Button` with a `className`
|
|
42
|
+
|
|
43
|
+
A `Button` is sized by `h-(--control-height-md)` and carries **no block padding at all**. It
|
|
44
|
+
is a one-line control by contract, which is what keeps every control on a form row the same
|
|
45
|
+
height at every density.
|
|
46
|
+
|
|
47
|
+
So an app that wants a wrapping two-line card reaches for `height: auto` — and that deletes
|
|
48
|
+
the height and leaves `padding: 0` down the block axis. The title sits flush against the top
|
|
49
|
+
border and the description flush against the bottom one. Nothing reports it: the class really
|
|
50
|
+
is in the DOM, TypeScript never reads the CSS, and unstyled-but-present markup passes axe.
|
|
51
|
+
|
|
52
|
+
That shipped in QueuePilot's queue-type chooser and is what this component exists to have
|
|
53
|
+
made impossible. `ActionTiles.test.tsx` reads the padding off `getComputedStyle` in a real
|
|
54
|
+
browser for the same reason.
|
|
55
|
+
|
|
56
|
+
<Canvas of={ActionTilesStories.AllStates} />
|
|
57
|
+
|
|
58
|
+
## The box is shared, and asserted rather than trusted
|
|
59
|
+
|
|
60
|
+
`tileStyles.ts` holds the padding, the border, the radius, the surface, the type ramp, the
|
|
61
|
+
hint ramp, the gap and the grid; `RadioGroup` and `ActionTiles` both read it. Two tile sets
|
|
62
|
+
on one page have to be the same card, and "identical" spelled as two copies of one string is
|
|
63
|
+
a promise that survives exactly one edit — the same argument `CONTROL_BASE_CLASS` settles
|
|
64
|
+
between `Button` and `ButtonLink`.
|
|
65
|
+
|
|
66
|
+
The test compares **computed** styles of an action tile and a resting radio tile, not class
|
|
67
|
+
names. A class-name assertion passes while the paint is wrong.
|
|
68
|
+
|
|
69
|
+
## `href` makes the tile a real anchor
|
|
70
|
+
|
|
71
|
+
Middle-click, ctrl-click, "open in a new tab", "copy link address" and the status bar all
|
|
72
|
+
come from the element rather than from the paint — the standing
|
|
73
|
+
[buttons are actions, links are navigation](/docs/guides-routing--docs) rule. A same-origin
|
|
74
|
+
destination goes through the injected `RouterLink`; an external one or a fragment falls back
|
|
75
|
+
to a plain `<a>`, because pushing `https://…` or `#credits` onto the history stack navigates
|
|
76
|
+
the SPA to a route that does not exist.
|
|
77
|
+
|
|
78
|
+
A set may mix links and buttons. `isDisabled` on a link drops the `href` entirely rather than
|
|
79
|
+
shipping a focusable anchor that silently ignores clicks, and keeps `role="link"` plus
|
|
80
|
+
`aria-disabled` so it is *announced* as unavailable rather than vanishing from the tree.
|
|
81
|
+
|
|
82
|
+
<Canvas of={ActionTilesStories.Links} />
|
|
83
|
+
|
|
84
|
+
## `role="group"`, and no roving tabindex
|
|
85
|
+
|
|
86
|
+
These are ordinary buttons and anchors, so the platform already owns the keyboard: Tab
|
|
87
|
+
reaches **each** tile, Enter and Space press a button, Enter follows a link.
|
|
88
|
+
|
|
89
|
+
A radio group borrows one tab stop and moves its choice with the arrow keys because exactly
|
|
90
|
+
one of its options is true at a time. Nothing here is true, so imposing that model would only
|
|
91
|
+
cost a keyboard user the ability to Tab to the second tile.
|
|
92
|
+
|
|
93
|
+
The group takes a required `label`, which names the set — "Queue type", "Pick a tool" — the
|
|
94
|
+
way a `radiogroup` is named. A set of unnamed tiles is a pile of buttons.
|
|
95
|
+
|
|
96
|
+
## A tile may lead with an icon
|
|
97
|
+
|
|
98
|
+
`icon` is `aria-hidden` by construction, because the name it sits above is inside the same
|
|
99
|
+
tile — an announced icon is the label read twice. The library still ships no icons; apps
|
|
100
|
+
bring their own.
|
|
101
|
+
|
|
102
|
+
<Canvas of={ActionTilesStories.WithIcons} />
|
|
103
|
+
|
|
104
|
+
## The grid takes its columns from the container
|
|
105
|
+
|
|
106
|
+
`repeat(auto-fill, minmax(min(var(--charcuterie-tile-min-inline-size), 100%), 1fr))` — the
|
|
107
|
+
same constant `RadioGroup`'s tile shape uses, and each of its three parts is load-bearing:
|
|
108
|
+
|
|
109
|
+
- **`auto-fill`, not `auto-fit`.** `auto-fit` collapses the empty tracks and lets the
|
|
110
|
+
survivors share the whole row, so six tiles in a 2560px container become six 420px slabs —
|
|
111
|
+
the full-width-row shape in a new costume.
|
|
112
|
+
- **`min(…, 100%)`.** Without it the 200px floor overflows a container narrower than one
|
|
113
|
+
tile, which is a horizontal scrollbar in the Narrow View.
|
|
114
|
+
- **A custom property, not an interpolated class.** Tailwind scans source *text* for complete
|
|
115
|
+
class strings, so `` `grid-cols-[…${n}px…]` `` generates nothing, paints nothing and
|
|
116
|
+
reports nothing.
|
|
117
|
+
|
|
118
|
+
This is **not** `useAdaptiveColumns`. That hook buys a column with **height**, for an
|
|
119
|
+
unbounded gallery that is going to scroll; a tile set is a bounded group inside a form section
|
|
120
|
+
or a first step, where the only question is how many fit across the box it was given.
|
|
121
|
+
|
|
122
|
+
<Canvas of={ActionTilesStories.Responsive} />
|
|
123
|
+
|
|
124
|
+
## What this deliberately does not do
|
|
125
|
+
|
|
126
|
+
- **Hold a selection.** That is `RadioGroup itemShape="tile"`, and giving these tiles
|
|
127
|
+
`aria-checked` would be the mistake the 2026-08-25 record refused.
|
|
128
|
+
- **Toggle per tile.** A multi-select tile is a checkbox group and a different record.
|
|
129
|
+
- **Carry artwork.** That is [`MediaTile`](/docs/components-data-mediatile--docs).
|
|
130
|
+
|
|
131
|
+
<Canvas of={ActionTilesStories.Interactive} />
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
|
|
4
|
+
import {
|
|
5
|
+
ContainerBoard,
|
|
6
|
+
StoryCell,
|
|
7
|
+
StoryGrid,
|
|
8
|
+
StorySection,
|
|
9
|
+
} from "../board.storyHelpers.tsx"
|
|
10
|
+
import {
|
|
11
|
+
InboxIcon,
|
|
12
|
+
PlayIcon,
|
|
13
|
+
SearchIcon,
|
|
14
|
+
} from "../icons.storyHelpers.tsx"
|
|
15
|
+
import { RadioGroup } from "../RadioGroup/RadioGroup.tsx"
|
|
16
|
+
import type { ActionTileItem } from "./ActionTiles.tsx"
|
|
17
|
+
import { ActionTiles } from "./ActionTiles.tsx"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The shape this component was built for: a first step that opens
|
|
21
|
+
* the next step. Nothing stays selected, because nothing was chosen
|
|
22
|
+
* — the page moved on.
|
|
23
|
+
*/
|
|
24
|
+
const QUEUE_TYPE_ITEMS: ActionTileItem[] = [
|
|
25
|
+
{
|
|
26
|
+
hint: "Choose titles yourself, then arrange them in priority and random lanes.",
|
|
27
|
+
label: "Picks",
|
|
28
|
+
value: "picks",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
hint: "Set eligibility filters and let the app select matching titles.",
|
|
32
|
+
label: "Rules",
|
|
33
|
+
value: "rules",
|
|
34
|
+
},
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
/** A tile may lead with an icon. The library ships none. */
|
|
38
|
+
const START_ITEMS: ActionTileItem[] = [
|
|
39
|
+
{
|
|
40
|
+
hint: "Everything that arrived since the last run.",
|
|
41
|
+
icon: <InboxIcon />,
|
|
42
|
+
label: "New arrivals",
|
|
43
|
+
value: "new",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
hint: "Whatever the saved query matches today.",
|
|
47
|
+
icon: <SearchIcon />,
|
|
48
|
+
label: "Saved search",
|
|
49
|
+
value: "search",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
hint: "Pick up where the last session stopped.",
|
|
53
|
+
icon: <PlayIcon />,
|
|
54
|
+
label: "Continue",
|
|
55
|
+
value: "continue",
|
|
56
|
+
},
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A tile that carries an `href` is a real `<a href>`, so
|
|
61
|
+
* middle-click, ctrl-click and "copy link address" all work. The
|
|
62
|
+
* same set may mix links and buttons.
|
|
63
|
+
*/
|
|
64
|
+
const TOOL_ITEMS: ActionTileItem[] = [
|
|
65
|
+
{
|
|
66
|
+
hint: "Read a container's tracks and say what would change.",
|
|
67
|
+
href: "/tools/inspect",
|
|
68
|
+
label: "Inspect",
|
|
69
|
+
value: "inspect",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
hint: "Rewrite the container without touching a stream.",
|
|
73
|
+
href: "/tools/remux",
|
|
74
|
+
label: "Remux",
|
|
75
|
+
value: "remux",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
hint: "The reference, on the upstream site.",
|
|
79
|
+
href: "https://example.com/docs",
|
|
80
|
+
isExternal: true,
|
|
81
|
+
label: "Read the docs",
|
|
82
|
+
value: "docs",
|
|
83
|
+
},
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
const meta = {
|
|
87
|
+
title: "Components/Actions/ActionTiles",
|
|
88
|
+
component: ActionTiles,
|
|
89
|
+
parameters: { layout: "padded" },
|
|
90
|
+
argTypes: { size: controlSizeArgType },
|
|
91
|
+
// The component's own defaults, restated — Storybook does not seed
|
|
92
|
+
// `args` from docgen, so an unstated default shows in the props
|
|
93
|
+
// table with nothing selected in its control.
|
|
94
|
+
args: {
|
|
95
|
+
minTileInlineSize: 200,
|
|
96
|
+
size: "md",
|
|
97
|
+
},
|
|
98
|
+
} satisfies Meta<typeof ActionTiles>
|
|
99
|
+
|
|
100
|
+
export default meta
|
|
101
|
+
|
|
102
|
+
type Story = StoryObj<typeof meta>
|
|
103
|
+
|
|
104
|
+
export const Default: Story = {
|
|
105
|
+
args: { items: QUEUE_TYPE_ITEMS, label: "Queue type" },
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const AllVariants: Story = {
|
|
109
|
+
args: { items: QUEUE_TYPE_ITEMS, label: "Queue type" },
|
|
110
|
+
render: (controlProps) => (
|
|
111
|
+
<StorySection title="Three sizes, all reading the density axis rather than hardcoding a length — the same three a RadioGroup tile has, from the same tokens.">
|
|
112
|
+
<StoryGrid columns={3}>
|
|
113
|
+
{(["sm", "md", "lg"] as const).map((size) => (
|
|
114
|
+
<StoryCell
|
|
115
|
+
align="stretch"
|
|
116
|
+
key={size}
|
|
117
|
+
label={size}
|
|
118
|
+
>
|
|
119
|
+
<ActionTiles
|
|
120
|
+
{...controlProps}
|
|
121
|
+
label={`Queue type at ${size}`}
|
|
122
|
+
minTileInlineSize={160}
|
|
123
|
+
size={size}
|
|
124
|
+
/>
|
|
125
|
+
</StoryCell>
|
|
126
|
+
))}
|
|
127
|
+
</StoryGrid>
|
|
128
|
+
</StorySection>
|
|
129
|
+
),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A tile may lead with an icon. The library ships none — these are
|
|
134
|
+
* story-only SVGs, exactly what an app passes — and the icon is
|
|
135
|
+
* `aria-hidden` by construction, because the name it sits above is
|
|
136
|
+
* inside the same tile.
|
|
137
|
+
*/
|
|
138
|
+
export const WithIcons: Story = {
|
|
139
|
+
args: { items: START_ITEMS, label: "Where to start" },
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `href` makes the tile an anchor rather than a button, routed
|
|
144
|
+
* through the injected `RouterLink` when the destination is the
|
|
145
|
+
* router's. An `isExternal` tile falls back to a plain `<a
|
|
146
|
+
* target="_blank">` and says so to a screen reader.
|
|
147
|
+
*/
|
|
148
|
+
export const Links: Story = {
|
|
149
|
+
args: { items: TOOL_ITEMS, label: "Pick a tool" },
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const AllStates: Story = {
|
|
153
|
+
args: { items: QUEUE_TYPE_ITEMS, label: "Queue type" },
|
|
154
|
+
render: (controlProps) => (
|
|
155
|
+
<StorySection title="What varies is what a tile does and whether it can be pressed. Nothing here is 'selected' — that state belongs to RadioGroup itemShape='tile'.">
|
|
156
|
+
<StoryGrid columns={2}>
|
|
157
|
+
<StoryCell align="stretch" label="two actions">
|
|
158
|
+
<ActionTiles
|
|
159
|
+
{...controlProps}
|
|
160
|
+
label="Queue type"
|
|
161
|
+
/>
|
|
162
|
+
</StoryCell>
|
|
163
|
+
|
|
164
|
+
<StoryCell align="stretch" label="no hint">
|
|
165
|
+
<ActionTiles
|
|
166
|
+
{...controlProps}
|
|
167
|
+
items={[
|
|
168
|
+
{ label: "Picks", value: "picks" },
|
|
169
|
+
{ label: "Rules", value: "rules" },
|
|
170
|
+
]}
|
|
171
|
+
label="Queue type, names only"
|
|
172
|
+
/>
|
|
173
|
+
</StoryCell>
|
|
174
|
+
|
|
175
|
+
<StoryCell align="stretch" label="a disabled tile">
|
|
176
|
+
<ActionTiles
|
|
177
|
+
{...controlProps}
|
|
178
|
+
items={[
|
|
179
|
+
QUEUE_TYPE_ITEMS[0] as ActionTileItem,
|
|
180
|
+
{
|
|
181
|
+
hint: "No provider is configured to filter on.",
|
|
182
|
+
isDisabled: true,
|
|
183
|
+
label: "Rules",
|
|
184
|
+
value: "rules",
|
|
185
|
+
},
|
|
186
|
+
]}
|
|
187
|
+
label="Queue type with Rules unavailable"
|
|
188
|
+
/>
|
|
189
|
+
</StoryCell>
|
|
190
|
+
|
|
191
|
+
<StoryCell align="stretch" label="a disabled link">
|
|
192
|
+
<ActionTiles
|
|
193
|
+
{...controlProps}
|
|
194
|
+
items={[
|
|
195
|
+
{
|
|
196
|
+
hint: "Read a container's tracks and say what would change.",
|
|
197
|
+
href: "/tools/inspect",
|
|
198
|
+
label: "Inspect",
|
|
199
|
+
value: "inspect",
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
hint: "Nothing is loaded to remux yet.",
|
|
203
|
+
href: "/tools/remux",
|
|
204
|
+
isDisabled: true,
|
|
205
|
+
label: "Remux",
|
|
206
|
+
value: "remux",
|
|
207
|
+
},
|
|
208
|
+
]}
|
|
209
|
+
label="Pick a tool with Remux unavailable"
|
|
210
|
+
/>
|
|
211
|
+
</StoryCell>
|
|
212
|
+
|
|
213
|
+
<StoryCell align="stretch" label="links">
|
|
214
|
+
<ActionTiles
|
|
215
|
+
{...controlProps}
|
|
216
|
+
items={TOOL_ITEMS}
|
|
217
|
+
label="Pick a tool"
|
|
218
|
+
/>
|
|
219
|
+
</StoryCell>
|
|
220
|
+
|
|
221
|
+
<StoryCell align="stretch" label="icons">
|
|
222
|
+
<ActionTiles
|
|
223
|
+
{...controlProps}
|
|
224
|
+
items={START_ITEMS}
|
|
225
|
+
label="Where to start"
|
|
226
|
+
/>
|
|
227
|
+
</StoryCell>
|
|
228
|
+
</StoryGrid>
|
|
229
|
+
</StorySection>
|
|
230
|
+
),
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The two tile components, side by side, which is the comparison a
|
|
235
|
+
* reader choosing between them actually needs.
|
|
236
|
+
*
|
|
237
|
+
* The box is the **same box** — `tileStyles.ts` holds the padding,
|
|
238
|
+
* the border, the radius, the surface, the type ramp and the grid,
|
|
239
|
+
* and both components read it. What differs is everything the box is
|
|
240
|
+
* not: one announces "1 of 2" and keeps a tile selected, the other
|
|
241
|
+
* presses and is done.
|
|
242
|
+
*/
|
|
243
|
+
export const BesideTheRadioTile: Story = {
|
|
244
|
+
args: { items: QUEUE_TYPE_ITEMS, label: "Queue type" },
|
|
245
|
+
render: (controlProps) => (
|
|
246
|
+
<StorySection title="One box, two controls. Only the resting radio tile matches — a checked one wears the accent edge, which is the state ActionTiles has none of.">
|
|
247
|
+
<StoryGrid columns={2}>
|
|
248
|
+
<StoryCell
|
|
249
|
+
align="stretch"
|
|
250
|
+
label="ActionTiles — presses and is done"
|
|
251
|
+
>
|
|
252
|
+
<ActionTiles {...controlProps} />
|
|
253
|
+
</StoryCell>
|
|
254
|
+
|
|
255
|
+
<StoryCell
|
|
256
|
+
align="stretch"
|
|
257
|
+
label="RadioGroup itemShape='tile' — holds a value"
|
|
258
|
+
>
|
|
259
|
+
<RadioGroup
|
|
260
|
+
itemShape="tile"
|
|
261
|
+
items={QUEUE_TYPE_ITEMS.map(
|
|
262
|
+
({ hint, label, value }) => ({
|
|
263
|
+
hint,
|
|
264
|
+
label,
|
|
265
|
+
value,
|
|
266
|
+
}),
|
|
267
|
+
)}
|
|
268
|
+
label="Queue type, as radios"
|
|
269
|
+
size={controlProps.size}
|
|
270
|
+
/>
|
|
271
|
+
</StoryCell>
|
|
272
|
+
</StoryGrid>
|
|
273
|
+
</StorySection>
|
|
274
|
+
),
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Column count comes from the **container**, never the window — the
|
|
279
|
+
* standing rule for any list of cards, and the reason this board
|
|
280
|
+
* varies a wrapper's width rather than the viewport's.
|
|
281
|
+
*
|
|
282
|
+
* At the 200px default floor: one track at 15rem, one at 24rem, two
|
|
283
|
+
* at 34rem. `auto-fill` and deliberately not `auto-fit`, so the
|
|
284
|
+
* tiles keep their size in a container with room to spare instead of
|
|
285
|
+
* stretching to fill it. Same formula as the `RadioGroup` tile, from
|
|
286
|
+
* the same constant.
|
|
287
|
+
*/
|
|
288
|
+
export const Responsive: Story = {
|
|
289
|
+
args: { items: START_ITEMS, label: "Where to start" },
|
|
290
|
+
render: (controlProps) => (
|
|
291
|
+
<ContainerBoard>
|
|
292
|
+
{(width) => (
|
|
293
|
+
<ActionTiles
|
|
294
|
+
{...controlProps}
|
|
295
|
+
label={`Where to start at ${width}`}
|
|
296
|
+
/>
|
|
297
|
+
)}
|
|
298
|
+
</ContainerBoard>
|
|
299
|
+
),
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The complete keyboard path, and it is the platform's: Tab reaches
|
|
304
|
+
* **each** tile, Enter and Space press a button one, Enter follows a
|
|
305
|
+
* link one. There is no roving tabindex here — a radio group borrows
|
|
306
|
+
* one tab stop because exactly one of its options is true at a time,
|
|
307
|
+
* and nothing here is true.
|
|
308
|
+
*/
|
|
309
|
+
export const Interactive: Story = {
|
|
310
|
+
args: { items: QUEUE_TYPE_ITEMS, label: "Queue type" },
|
|
311
|
+
}
|