@charcuterie/ui 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/Checkbox/Checkbox.d.ts +59 -0
  2. package/dist/Checkbox/Checkbox.d.ts.map +1 -0
  3. package/dist/Checkbox/Checkbox.js +61 -0
  4. package/dist/Checkbox/Checkbox.js.map +1 -0
  5. package/dist/Combobox/Combobox.d.ts.map +1 -1
  6. package/dist/Combobox/Combobox.js +1 -2
  7. package/dist/Combobox/Combobox.js.map +1 -1
  8. package/dist/Field/Field.d.ts.map +1 -1
  9. package/dist/Field/Field.js +12 -1
  10. package/dist/Field/Field.js.map +1 -1
  11. package/dist/Menu/Menu.d.ts +60 -2
  12. package/dist/Menu/Menu.d.ts.map +1 -1
  13. package/dist/Menu/Menu.js +58 -11
  14. package/dist/Menu/Menu.js.map +1 -1
  15. package/dist/RadioGroup/RadioGroup.d.ts +47 -0
  16. package/dist/RadioGroup/RadioGroup.d.ts.map +1 -0
  17. package/dist/RadioGroup/RadioGroup.js +114 -0
  18. package/dist/RadioGroup/RadioGroup.js.map +1 -0
  19. package/dist/RadioGroup/RadioGroupOption.d.ts +26 -0
  20. package/dist/RadioGroup/RadioGroupOption.d.ts.map +1 -0
  21. package/dist/RadioGroup/RadioGroupOption.js +52 -0
  22. package/dist/RadioGroup/RadioGroupOption.js.map +1 -0
  23. package/dist/Switch/Switch.d.ts +54 -0
  24. package/dist/Switch/Switch.d.ts.map +1 -0
  25. package/dist/Switch/Switch.js +94 -0
  26. package/dist/Switch/Switch.js.map +1 -0
  27. package/dist/index.d.ts +6 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +3 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/slotWiring.d.ts +9 -7
  32. package/dist/slotWiring.d.ts.map +1 -1
  33. package/dist/slotWiring.js +10 -70
  34. package/dist/slotWiring.js.map +1 -1
  35. package/package.json +3 -3
  36. package/src/Checkbox/Checkbox.mdx +58 -0
  37. package/src/Checkbox/Checkbox.stories.tsx +100 -0
  38. package/src/Checkbox/Checkbox.test.tsx +80 -0
  39. package/src/Checkbox/Checkbox.tsx +146 -0
  40. package/src/Combobox/Combobox.stories.tsx +64 -4
  41. package/src/Combobox/Combobox.tsx +13 -1
  42. package/src/Field/Field.stories.tsx +19 -0
  43. package/src/Field/Field.test.tsx +30 -2
  44. package/src/Field/Field.tsx +13 -1
  45. package/src/Menu/Menu.stories.tsx +80 -2
  46. package/src/Menu/Menu.test.tsx +102 -0
  47. package/src/Menu/Menu.tsx +156 -17
  48. package/src/RadioGroup/RadioGroup.mdx +58 -0
  49. package/src/RadioGroup/RadioGroup.stories.tsx +130 -0
  50. package/src/RadioGroup/RadioGroup.test.tsx +191 -0
  51. package/src/RadioGroup/RadioGroup.tsx +204 -0
  52. package/src/RadioGroup/RadioGroupOption.tsx +126 -0
  53. package/src/Switch/Switch.mdx +53 -0
  54. package/src/Switch/Switch.stories.tsx +94 -0
  55. package/src/Switch/Switch.test.tsx +86 -0
  56. package/src/Switch/Switch.tsx +162 -0
  57. package/src/index.ts +9 -0
  58. package/src/slotWiring.ts +15 -107
  59. package/src/sourceRules.test.ts +11 -1
  60. package/src/tailwindCandidates.test.ts +9 -7
@@ -0,0 +1,100 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
4
+ import {
5
+ StoryCell,
6
+ StoryGrid,
7
+ StorySection,
8
+ } from "../board.storyHelpers.tsx"
9
+ import { Checkbox } from "./Checkbox.tsx"
10
+
11
+ const meta = {
12
+ title: "Components/Checkbox",
13
+ component: Checkbox,
14
+ parameters: { layout: "padded" },
15
+ argTypes: { size: controlSizeArgType },
16
+ // The component's own defaults, restated — Storybook does not seed
17
+ // `args` from docgen, so an unstated default shows in the props
18
+ // table with nothing selected in its control.
19
+ args: { isChecked: false, isDisabled: false, size: "md" },
20
+ } satisfies Meta<typeof Checkbox>
21
+
22
+ export default meta
23
+
24
+ type Story = StoryObj<typeof meta>
25
+
26
+ export const Default: Story = {
27
+ args: { label: "Delete originals after import" },
28
+ }
29
+
30
+ export const AllVariants: Story = {
31
+ args: { label: "Delete originals after import" },
32
+ render: (controlProps) => (
33
+ <StorySection title="Three control heights, all reading the density axis rather than hardcoding a length.">
34
+ <StoryGrid columns={3}>
35
+ {(["sm", "md", "lg"] as const).map((size) => (
36
+ <StoryCell key={size} label={size}>
37
+ <Checkbox
38
+ {...controlProps}
39
+ isChecked
40
+ label={`Enabled at ${size}`}
41
+ size={size}
42
+ />
43
+ </StoryCell>
44
+ ))}
45
+ </StoryGrid>
46
+ </StorySection>
47
+ ),
48
+ }
49
+
50
+ /**
51
+ * `isChecked` seeds the first paint and nothing after — the `<input>`
52
+ * owns its state from then on, so a disabled-and-checked box is a
53
+ * saved preference the user cannot currently change, not a bug.
54
+ */
55
+ export const AllStates: Story = {
56
+ args: { label: "Delete originals after import" },
57
+ render: (controlProps) => (
58
+ <StorySection title="What varies is whether the box is checked and whether it can be changed.">
59
+ <StoryGrid columns={2}>
60
+ <StoryCell label="unchecked">
61
+ <Checkbox {...controlProps} label="Unchecked" />
62
+ </StoryCell>
63
+
64
+ <StoryCell label="checked">
65
+ <Checkbox
66
+ {...controlProps}
67
+ isChecked
68
+ label="Checked"
69
+ />
70
+ </StoryCell>
71
+
72
+ <StoryCell label="disabled, unchecked">
73
+ <Checkbox
74
+ {...controlProps}
75
+ isDisabled
76
+ label="Disabled"
77
+ />
78
+ </StoryCell>
79
+
80
+ <StoryCell label="disabled, checked">
81
+ <Checkbox
82
+ {...controlProps}
83
+ isChecked
84
+ isDisabled
85
+ label="Disabled and checked"
86
+ />
87
+ </StoryCell>
88
+ </StoryGrid>
89
+ </StorySection>
90
+ ),
91
+ }
92
+
93
+ /**
94
+ * The whole keyboard path is the browser's: Tab reaches the box,
95
+ * Space toggles it. A `<label>` wrapping the control is what makes a
96
+ * pointer press on the *text* toggle it too.
97
+ */
98
+ export const Interactive: Story = {
99
+ args: { label: "Delete originals after import" },
100
+ }
@@ -0,0 +1,80 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, waitFor } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import * as stories from "./Checkbox.stories.tsx"
9
+
10
+ const { AllStates, Default, Interactive } =
11
+ composeStories(stories)
12
+
13
+ test("it is a checkbox a screen reader can name", async () => {
14
+ const { canvas, canvasElement } =
15
+ await mountStory(Default)
16
+
17
+ const checkbox = expectAgentDrivable(canvas, {
18
+ name: "Delete originals after import",
19
+ role: "checkbox",
20
+ })
21
+
22
+ await expect(checkbox).not.toBeChecked()
23
+
24
+ await expectNoAxeViolations(canvasElement)
25
+ })
26
+
27
+ test("a press toggles it, and the label is part of the target", async () => {
28
+ const { canvas } = await mountStory(Interactive)
29
+
30
+ const checkbox = expectAgentDrivable(canvas, {
31
+ name: "Delete originals after import",
32
+ role: "checkbox",
33
+ })
34
+
35
+ // The label wraps the control, so a click on the text is a click
36
+ // on the box — which is the association `<label htmlFor>` gets
37
+ // wrong half the time in the fleet and this cannot.
38
+ await userEvent.click(checkbox)
39
+
40
+ await waitFor(() => {
41
+ expect(checkbox).toBeChecked()
42
+ })
43
+
44
+ await userEvent.click(checkbox)
45
+
46
+ await waitFor(() => {
47
+ expect(checkbox).not.toBeChecked()
48
+ })
49
+ })
50
+
51
+ test("a disabled box cannot be toggled", async () => {
52
+ const { canvas, canvasElement } =
53
+ await mountStory(AllStates)
54
+
55
+ const disabled = expectAgentDrivable(canvas, {
56
+ name: "Disabled",
57
+ role: "checkbox",
58
+ })
59
+
60
+ await expect(disabled).toBeDisabled()
61
+
62
+ await expect(disabled).not.toBeChecked()
63
+
64
+ await userEvent.click(disabled)
65
+
66
+ await expect(disabled).not.toBeChecked()
67
+
68
+ await expectNoAxeViolations(canvasElement)
69
+ })
70
+
71
+ test("isChecked decides the first render", async () => {
72
+ const { canvas } = await mountStory(AllStates)
73
+
74
+ await expect(
75
+ expectAgentDrivable(canvas, {
76
+ name: "Checked",
77
+ role: "checkbox",
78
+ }),
79
+ ).toBeChecked()
80
+ })
@@ -0,0 +1,146 @@
1
+ import type { ControlSize } from "@charcuterie/tokens"
2
+ import type { ChangeEvent, ReactNode } from "react"
3
+
4
+ import { FOCUS_RING_CLASS } from "../intentStyles.ts"
5
+ import { toClassName } from "../toClassName.ts"
6
+
7
+ export type CheckboxProps = {
8
+ className?: string
9
+ /**
10
+ * The caller's own id, when it has one — a stable target for an
11
+ * autofill hint or a deep link. Left to React's `useId` on the
12
+ * `<input>` otherwise, which is enough for the wrapping `<label>`.
13
+ */
14
+ id?: string
15
+ /**
16
+ * **Initial** only. The `<input>` is the store from then on — the
17
+ * DOM owns a checkbox's checkedness, and a controlled `checked`
18
+ * prop is the thing this library refuses to have
19
+ * (`SegmentedControl` says the same about `selectedValue`).
20
+ */
21
+ isChecked?: boolean
22
+ isDisabled?: boolean
23
+ /**
24
+ * The visible text beside the box, and the checkbox's accessible
25
+ * name — the `<label>` wraps the control, so there is no `for` to
26
+ * get wrong. A box with no label is a control a screen reader
27
+ * cannot announce, which is why this is required.
28
+ */
29
+ label: ReactNode
30
+ onChange?: (isChecked: boolean) => void
31
+ size?: ControlSize
32
+ }
33
+
34
+ const BOX_SIZE_CLASS: Record<ControlSize, string> = {
35
+ sm: "size-3.5",
36
+ md: "size-4",
37
+ lg: "size-5",
38
+ }
39
+
40
+ const CHECK_SIZE_CLASS: Record<ControlSize, string> = {
41
+ sm: "size-2.5",
42
+ md: "size-3",
43
+ lg: "size-3.5",
44
+ }
45
+
46
+ const TEXT_SIZE_CLASS: Record<ControlSize, string> = {
47
+ sm: "text-sm",
48
+ md: "text-md",
49
+ lg: "text-lg",
50
+ }
51
+
52
+ /**
53
+ * A single boolean, on or off, that owns nothing but itself.
54
+ *
55
+ * This is the primitive mux-magic's `BooleanField` hand-rolled in
56
+ * `bg-slate-700 accent-blue-500` because the library had no checkbox
57
+ * to reach for — palette colours with no light mode, the exact M6
58
+ * defect the token layer exists to delete. Here the box is
59
+ * `bg-surface-sunken` and the fill is `bg-intent-accent-solid`, so
60
+ * one control reads correctly in `daylight`, `midnight`, and on the
61
+ * kiosk with no per-app override.
62
+ *
63
+ * ### The `<label>` wraps the control
64
+ *
65
+ * A checkbox reads to the *left* of its text, which is why it is not
66
+ * a `Field` — `Field` stacks label above control in a `flex-col`,
67
+ * right for a text input and wrong for a boolean, and stacking every
68
+ * checkbox in a step card would double the card's height. Wrapping is
69
+ * also the one label association that needs no `for` at all, so there
70
+ * is no minted-then-dropped id to get wrong.
71
+ *
72
+ * ### The `<input>` is the store
73
+ *
74
+ * `defaultChecked`, not `checked`. The DOM owns a checkbox's state
75
+ * natively, so there is no second copy to keep in step and no
76
+ * controlled-prop round-trip — `onChange` reports the new state to a
77
+ * consumer that wants to persist it, and reading it back is the
78
+ * `<input>`'s job. `isChecked` seeds the first paint and nothing
79
+ * after.
80
+ */
81
+ export const Checkbox = ({
82
+ className,
83
+ id,
84
+ isChecked = false,
85
+ isDisabled = false,
86
+ label,
87
+ onChange,
88
+ size = "md",
89
+ }: CheckboxProps): ReactNode => (
90
+ <label
91
+ className={toClassName(
92
+ "inline-flex cursor-pointer items-center gap-2 text-content-secondary select-none",
93
+ TEXT_SIZE_CLASS[size],
94
+ isDisabled &&
95
+ "cursor-not-allowed text-content-disabled",
96
+ className,
97
+ )}
98
+ >
99
+ <span className="relative grid shrink-0 place-items-center">
100
+ <input
101
+ className={toClassName(
102
+ "peer cursor-pointer appearance-none rounded-sm border border-border-strong bg-surface-sunken transition-colors duration-(--duration-fast) ease-standard",
103
+ "checked:border-intent-accent-solid checked:bg-intent-accent-solid",
104
+ // Disabled mutes the border and the cursor, and — unlike the
105
+ // shared `DISABLED_CLASS` — does *not* touch the background.
106
+ // The generic bundle repaints the box `surface-sunken`, which
107
+ // erased both the checked accent fill (white tick on grey)
108
+ // and, in a light scheme, the unchecked box entirely: a
109
+ // `border-subtle` outline on `surface-sunken` on
110
+ // `surface-base` is three near-identical creams. A disabled
111
+ // box is still a box the user has to see.
112
+ "disabled:cursor-not-allowed disabled:border-border-default",
113
+ BOX_SIZE_CLASS[size],
114
+ FOCUS_RING_CLASS,
115
+ )}
116
+ defaultChecked={isChecked}
117
+ disabled={isDisabled}
118
+ id={id}
119
+ onChange={(
120
+ event: ChangeEvent<HTMLInputElement>,
121
+ ) => {
122
+ onChange?.(event.target.checked)
123
+ }}
124
+ type="checkbox"
125
+ />
126
+
127
+ <svg
128
+ aria-hidden="true"
129
+ className={toClassName(
130
+ "pointer-events-none invisible absolute text-intent-accent-on-solid peer-checked:visible",
131
+ CHECK_SIZE_CLASS[size],
132
+ )}
133
+ fill="none"
134
+ stroke="currentColor"
135
+ strokeLinecap="round"
136
+ strokeLinejoin="round"
137
+ strokeWidth={3}
138
+ viewBox="0 0 24 24"
139
+ >
140
+ <path d="M20 6 9 17l-5-5" />
141
+ </svg>
142
+ </span>
143
+
144
+ <span>{label}</span>
145
+ </label>
146
+ )
@@ -44,6 +44,7 @@ export default meta
44
44
  type Story = StoryObj<typeof meta>
45
45
 
46
46
  const ComboboxHarness = ({
47
+ className,
47
48
  emptyLabel,
48
49
  error,
49
50
  footer,
@@ -55,6 +56,7 @@ const ComboboxHarness = ({
55
56
  options,
56
57
  triggerLabel,
57
58
  }: {
59
+ className?: string
58
60
  emptyLabel?: string
59
61
  error?: ReactNode
60
62
  footer?: ReactNode
@@ -75,6 +77,7 @@ const ComboboxHarness = ({
75
77
  return (
76
78
  <>
77
79
  <Combobox
80
+ className={className}
78
81
  emptyLabel={emptyLabel}
79
82
  error={error}
80
83
  footer={footer}
@@ -85,11 +88,20 @@ const ComboboxHarness = ({
85
88
  isVisible={isVisible}
86
89
  onDismiss={hide}
87
90
  onSelect={(value) => {
88
- setChosen((previous) =>
89
- previous.includes(value)
91
+ // Mirror the control's own selection model so the "Chosen"
92
+ // line never disagrees with the ticks in the list: multi-select
93
+ // accumulates/toggles, single-select replaces (one value, one
94
+ // ✓). Toggling into an array in single-select made the line
95
+ // claim several were chosen while only the last carried a tick.
96
+ setChosen((previous) => {
97
+ if (!isMultiple) {
98
+ return [value]
99
+ }
100
+
101
+ return previous.includes(value)
90
102
  ? previous.filter((one) => one !== value)
91
- : [...previous, value],
92
- )
103
+ : [...previous, value]
104
+ })
93
105
  }}
94
106
  options={options}
95
107
  trigger={
@@ -388,3 +400,51 @@ export const DisabledFirstOption: Story = {
388
400
  />
389
401
  ),
390
402
  }
403
+
404
+ // 150 long folder names: over the ~100 auto-virtualize threshold, and in
405
+ // a narrow (`w-72`) panel each wraps to two lines — the combination that
406
+ // broke. The virtualizer estimates 36px/row, so a fixed-height row clipped
407
+ // the second line and stacked the next row on top of it (overlapping
408
+ // text). This is the regression guard for the measured-row fix.
409
+ const MANY_LONG: ListboxItem[] = Array.from(
410
+ { length: 150 },
411
+ (_unused, index) => {
412
+ const name = `Armored Trooper Votoms - Very Long Chapter Title Number ${index + 1} [anidb-${1000 + index}]`
413
+
414
+ return {
415
+ label: `📁 ${name}`,
416
+ textValue: name,
417
+ value: name,
418
+ }
419
+ },
420
+ )
421
+
422
+ /**
423
+ * The real-world break: **many** long option labels in a **narrow** panel,
424
+ * so the list both virtualizes (over ~100 options) and wraps each row to
425
+ * two lines. Rows are measured, not pinned to the 36px estimate, so they
426
+ * lay out below one another instead of overlapping.
427
+ *
428
+ * Multi-select as well, so it exercises the case together: every picked
429
+ * row keeps its own ✓ (a virtualized row that scrolls out and back is
430
+ * re-rendered from `selected`, not remembered by the DOM), and the chips
431
+ * above the trigger stay in sync with the ticks.
432
+ */
433
+ export const VirtualizedLongOptions: Story = {
434
+ args: {
435
+ isVisible: false,
436
+ onDismiss: () => {},
437
+ onSelect: () => {},
438
+ options: MANY_LONG,
439
+ trigger: <Button>Search folders</Button>,
440
+ },
441
+ render: () => (
442
+ <ComboboxHarness
443
+ className="w-72"
444
+ isInitiallyVisible
445
+ isMultiple
446
+ options={MANY_LONG}
447
+ triggerLabel="Search folders"
448
+ />
449
+ ),
450
+ }
@@ -679,9 +679,21 @@ export const Combobox = ({
679
679
  return (
680
680
  <div
681
681
  className="absolute start-0 top-0 w-full"
682
+ // Measured, not fixed-height. A long option
683
+ // label wraps to two lines (~56px), but the
684
+ // estimate is 36px — so pinning the row to
685
+ // `virtualRow.size` clipped it to one line's
686
+ // worth and stacked the next row on top of it,
687
+ // and the popup rendered as overlapping text.
688
+ // `measureElement` + `data-index` let the
689
+ // virtualizer read each row's real height and
690
+ // lay the rest out below it.
691
+ data-index={virtualRow.index}
682
692
  key={option.value}
693
+ ref={
694
+ rowVirtualizer.measureElement
695
+ }
683
696
  style={{
684
- height: `${virtualRow.size}px`,
685
697
  transform: `translateY(${virtualRow.start}px)`,
686
698
  }}
687
699
  >
@@ -36,6 +36,25 @@ export const Default: Story = {
36
36
  },
37
37
  }
38
38
 
39
+ /**
40
+ * The control carries its own `id` — `rename-pattern`, the kind of
41
+ * stable id a deep link or a server-rendered error summary points at.
42
+ * The `Field` adopts it rather than minting a `-control` id over the
43
+ * top, so the `<label htmlFor>` and every outside-in reference agree.
44
+ */
45
+ export const AdoptsChildId: Story = {
46
+ args: {
47
+ children: (
48
+ <input
49
+ className={TEXT_INPUT_CLASS}
50
+ id="rename-pattern"
51
+ type="text"
52
+ />
53
+ ),
54
+ label: "Rename pattern",
55
+ },
56
+ }
57
+
39
58
  /**
40
59
  * The control is a slot, not a hardcoded `<input>` — `Select` goes
41
60
  * in the same hole, and so does an app's own control. The `id`,
@@ -7,8 +7,36 @@ import { mountStory } from "../mountStory.testHelpers.ts"
7
7
  import { expectAgentDrivable } from "../testing/index.ts"
8
8
  import * as stories from "./Field.stories.tsx"
9
9
 
10
- const { AllStates, AllVariants, Default, Group, Nested } =
11
- composeStories(stories)
10
+ const {
11
+ AdoptsChildId,
12
+ AllStates,
13
+ AllVariants,
14
+ Default,
15
+ Group,
16
+ Nested,
17
+ } = composeStories(stories)
18
+
19
+ test("a control's own id is adopted, not overwritten", async () => {
20
+ const { canvas, canvasElement } =
21
+ await mountStory(AdoptsChildId)
22
+
23
+ // The child was written `<input id="rename-pattern" />`. The Field
24
+ // must keep that id — overwriting it with a minted `-control` id is
25
+ // what broke the outside-in references the id exists for — and the
26
+ // `<label htmlFor>` must agree, so the control is still findable by
27
+ // its label's text.
28
+ const control = expectAgentDrivable(canvas, {
29
+ name: "Rename pattern",
30
+ role: "textbox",
31
+ })
32
+
33
+ await expect(control).toHaveAttribute(
34
+ "id",
35
+ "rename-pattern",
36
+ )
37
+
38
+ await expectNoAxeViolations(canvasElement)
39
+ })
12
40
 
13
41
  test("the label names the control it points at", async () => {
14
42
  const { canvas, canvasElement } =
@@ -126,9 +126,21 @@ export const Field = ({
126
126
  * link, a server-rendered error summary linking to the control.
127
127
  * `useUniqueId` is still called: hooks are unconditional, and it
128
128
  * costs one counter read.
129
+ *
130
+ * Precedence is `Field` prop → **the child's own `id`** →
131
+ * generated. The child's is read because a control written as
132
+ * `<input id="rename-pattern" />` puts the id where the author can
133
+ * see it, not on the `Field`; overwriting it with a minted
134
+ * `-control` id used to break exactly the outside-in references the
135
+ * `Field` prop exists to keep — a deep link, an autofill hint, a
136
+ * consumer's own selector. The `Field` prop still wins when both
137
+ * are set: it is the outer, later declaration, and last-writer-wins
138
+ * is the slot's rule for a value.
129
139
  */
140
+ const childId = (children.props as { id?: string }).id
141
+
130
142
  const controlId =
131
- receivedSlotProps.id ?? `${baseId}-control`
143
+ receivedSlotProps.id ?? childId ?? `${baseId}-control`
132
144
 
133
145
  const descriptionId = `${baseId}-description`
134
146
 
@@ -14,7 +14,7 @@ import {
14
14
  UndoIcon,
15
15
  } from "../icons.storyHelpers.tsx"
16
16
  import { Tooltip } from "../Tooltip/Tooltip.tsx"
17
- import type { MenuItem } from "./Menu.tsx"
17
+ import type { MenuEntry, MenuItem } from "./Menu.tsx"
18
18
  import { Menu } from "./Menu.tsx"
19
19
 
20
20
  const noop = () => undefined
@@ -41,18 +41,50 @@ const BAY_ACTIONS: MenuItem[] = [
41
41
  },
42
42
  ]
43
43
 
44
+ const GROUPED_ACTIONS: MenuEntry[] = [
45
+ {
46
+ items: [
47
+ {
48
+ key: "retry",
49
+ label: "Retry title",
50
+ onSelect: noop,
51
+ },
52
+ { key: "skip", label: "Skip title", onSelect: noop },
53
+ ],
54
+ key: "disc",
55
+ label: "Disc",
56
+ type: "group",
57
+ },
58
+ { key: "sep", type: "separator" },
59
+ {
60
+ items: [
61
+ {
62
+ icon: <SettingsIcon />,
63
+ key: "eject",
64
+ label: "Eject disc",
65
+ onSelect: noop,
66
+ },
67
+ ],
68
+ key: "danger",
69
+ label: "Danger",
70
+ type: "group",
71
+ },
72
+ ]
73
+
44
74
  /**
45
75
  * A menu's visibility belongs to the app, exactly like `Popover`'s —
46
76
  * so the stories hold it in a `useVisibility`, which is what a
47
77
  * consumer writes too.
48
78
  */
49
79
  const MenuHarness = ({
80
+ emptyState,
50
81
  isInitiallyVisible = false,
51
82
  items = BAY_ACTIONS,
52
83
  triggerLabel,
53
84
  }: {
85
+ emptyState?: ReactNode
54
86
  isInitiallyVisible?: boolean
55
- items?: MenuItem[]
87
+ items?: MenuEntry[]
56
88
  triggerLabel: string
57
89
  }): ReactNode => {
58
90
  const { hide, isVisible, toggle } = useVisibility({
@@ -61,6 +93,7 @@ const MenuHarness = ({
61
93
 
62
94
  return (
63
95
  <Menu
96
+ emptyState={emptyState}
64
97
  isVisible={isVisible}
65
98
  items={items}
66
99
  onDismiss={hide}
@@ -226,3 +259,48 @@ export const Interactive: Story = {
226
259
  <MenuHarness triggerLabel="Open bay 4 menu" />
227
260
  ),
228
261
  }
262
+
263
+ /**
264
+ * `items` is a union — a `group` (`role="group"`, named by its
265
+ * label) and a `separator` (`role="separator"`) opt in by their
266
+ * `type`, and a bare item still works unchanged. Neither the
267
+ * separator nor the group headings register with the roving group,
268
+ * so arrow-down goes Retry → Skip → Eject, straight past both
269
+ * headings and the rule between them.
270
+ */
271
+ export const Grouped: Story = {
272
+ args: {
273
+ isVisible: false,
274
+ onDismiss: noop,
275
+ trigger: <Button appearance="outline">Actions</Button>,
276
+ },
277
+ render: () => (
278
+ <MenuHarness
279
+ isInitiallyVisible
280
+ items={GROUPED_ACTIONS}
281
+ triggerLabel="Bay 6"
282
+ />
283
+ ),
284
+ }
285
+
286
+ /**
287
+ * No actions to show. The `emptyState` renders as a **disabled**
288
+ * `menuitem` — a `role="menu"` must own one — so it is announced but
289
+ * takes no tab stop and no roving-focus membership, and a keyboard
290
+ * user can still Escape out of it.
291
+ */
292
+ export const Empty: Story = {
293
+ args: {
294
+ isVisible: false,
295
+ onDismiss: noop,
296
+ trigger: <Button appearance="outline">Actions</Button>,
297
+ },
298
+ render: () => (
299
+ <MenuHarness
300
+ emptyState="No actions available"
301
+ isInitiallyVisible
302
+ items={[]}
303
+ triggerLabel="Bay 7"
304
+ />
305
+ ),
306
+ }