@charcuterie/ui 1.1.0 → 2.0.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 (116) hide show
  1. package/dist/Combobox/Combobox.d.ts +54 -0
  2. package/dist/Combobox/Combobox.d.ts.map +1 -0
  3. package/dist/Combobox/Combobox.js +216 -0
  4. package/dist/Combobox/Combobox.js.map +1 -0
  5. package/dist/Combobox/ComboboxOption.d.ts +36 -0
  6. package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
  7. package/dist/Combobox/ComboboxOption.js +32 -0
  8. package/dist/Combobox/ComboboxOption.js.map +1 -0
  9. package/dist/Dialog/Dialog.d.ts +47 -0
  10. package/dist/Dialog/Dialog.d.ts.map +1 -0
  11. package/dist/Dialog/Dialog.js +52 -0
  12. package/dist/Dialog/Dialog.js.map +1 -0
  13. package/dist/Lightbox/Lightbox.d.ts +7 -7
  14. package/dist/Lightbox/Lightbox.js +9 -9
  15. package/dist/Lightbox/Lightbox.js.map +1 -1
  16. package/dist/Listbox/Listbox.d.ts +56 -0
  17. package/dist/Listbox/Listbox.d.ts.map +1 -0
  18. package/dist/Listbox/Listbox.js +151 -0
  19. package/dist/Listbox/Listbox.js.map +1 -0
  20. package/dist/Listbox/ListboxOption.d.ts +36 -0
  21. package/dist/Listbox/ListboxOption.d.ts.map +1 -0
  22. package/dist/Listbox/ListboxOption.js +42 -0
  23. package/dist/Listbox/ListboxOption.js.map +1 -0
  24. package/dist/Menu/Menu.d.ts.map +1 -1
  25. package/dist/Menu/Menu.js +47 -97
  26. package/dist/Menu/Menu.js.map +1 -1
  27. package/dist/Modal/Modal.d.ts +11 -59
  28. package/dist/Modal/Modal.d.ts.map +1 -1
  29. package/dist/Modal/Modal.js +28 -101
  30. package/dist/Modal/Modal.js.map +1 -1
  31. package/dist/Overlay/OverlayPanel.d.ts +50 -0
  32. package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
  33. package/dist/Overlay/OverlayPanel.js +72 -0
  34. package/dist/Overlay/OverlayPanel.js.map +1 -0
  35. package/dist/Overlay/OverlayStack.d.ts +47 -0
  36. package/dist/Overlay/OverlayStack.d.ts.map +1 -0
  37. package/dist/Overlay/OverlayStack.js +46 -0
  38. package/dist/Overlay/OverlayStack.js.map +1 -0
  39. package/dist/Overlay/SharedBackdrop.d.ts +24 -0
  40. package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
  41. package/dist/Overlay/SharedBackdrop.js +28 -0
  42. package/dist/Overlay/SharedBackdrop.js.map +1 -0
  43. package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
  44. package/dist/Overlay/lockScrollBehind.js.map +1 -0
  45. package/dist/Overlay/overlayPanelClass.d.ts +17 -0
  46. package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
  47. package/dist/Overlay/overlayPanelClass.js +17 -0
  48. package/dist/Overlay/overlayPanelClass.js.map +1 -0
  49. package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
  50. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
  51. package/dist/Overlay/useAnchoredOverlay.js +81 -0
  52. package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
  53. package/dist/Popover/Popover.d.ts +16 -24
  54. package/dist/Popover/Popover.d.ts.map +1 -1
  55. package/dist/Popover/Popover.js +32 -68
  56. package/dist/Popover/Popover.js.map +1 -1
  57. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  58. package/dist/Tooltip/Tooltip.js +25 -25
  59. package/dist/Tooltip/Tooltip.js.map +1 -1
  60. package/dist/index.d.ts +7 -1
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +3 -0
  63. package/dist/index.js.map +1 -1
  64. package/dist/mountStory.testHelpers.d.ts +1 -0
  65. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  66. package/dist/mountStory.testHelpers.js +21 -1
  67. package/dist/mountStory.testHelpers.js.map +1 -1
  68. package/package.json +3 -2
  69. package/src/Combobox/Combobox.mdx +71 -0
  70. package/src/Combobox/Combobox.stories.tsx +245 -0
  71. package/src/Combobox/Combobox.test.tsx +289 -0
  72. package/src/Combobox/Combobox.tsx +513 -0
  73. package/src/Combobox/ComboboxOption.tsx +88 -0
  74. package/src/Dialog/Dialog.mdx +87 -0
  75. package/src/Dialog/Dialog.stories.tsx +347 -0
  76. package/src/Dialog/Dialog.test.tsx +312 -0
  77. package/src/Dialog/Dialog.tsx +127 -0
  78. package/src/Field/Field.test.tsx +6 -4
  79. package/src/Lightbox/Lightbox.mdx +9 -8
  80. package/src/Lightbox/Lightbox.stories.tsx +5 -5
  81. package/src/Lightbox/Lightbox.test.tsx +25 -36
  82. package/src/Lightbox/Lightbox.tsx +10 -10
  83. package/src/Listbox/Listbox.mdx +68 -0
  84. package/src/Listbox/Listbox.stories.tsx +205 -0
  85. package/src/Listbox/Listbox.test.tsx +198 -0
  86. package/src/Listbox/Listbox.tsx +295 -0
  87. package/src/Listbox/ListboxOption.tsx +104 -0
  88. package/src/Menu/Menu.mdx +11 -13
  89. package/src/Menu/Menu.test.tsx +47 -38
  90. package/src/Menu/Menu.tsx +81 -142
  91. package/src/Modal/Modal.mdx +29 -82
  92. package/src/Modal/Modal.stories.tsx +55 -188
  93. package/src/Modal/Modal.test.tsx +44 -177
  94. package/src/Modal/Modal.tsx +54 -204
  95. package/src/Overlay/OverlayPanel.tsx +195 -0
  96. package/src/Overlay/OverlayStack.test.tsx +235 -0
  97. package/src/Overlay/OverlayStack.tsx +132 -0
  98. package/src/Overlay/SharedBackdrop.tsx +40 -0
  99. package/src/Overlay/overlayPanelClass.ts +17 -0
  100. package/src/Overlay/useAnchoredOverlay.ts +197 -0
  101. package/src/Popover/Popover.mdx +17 -20
  102. package/src/Popover/Popover.stories.tsx +33 -0
  103. package/src/Popover/Popover.test.tsx +68 -32
  104. package/src/Popover/Popover.tsx +56 -95
  105. package/src/Tooltip/Tooltip.mdx +12 -9
  106. package/src/Tooltip/Tooltip.test.tsx +17 -19
  107. package/src/Tooltip/Tooltip.tsx +35 -32
  108. package/src/index.ts +13 -4
  109. package/src/mountStory.testHelpers.ts +25 -1
  110. package/src/sourceRules.test.ts +12 -3
  111. package/src/storyControls.test.ts +25 -12
  112. package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
  113. package/dist/Modal/lockScrollBehind.js.map +0 -1
  114. /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
  115. /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
  116. /package/src/{Modal → Overlay}/lockScrollBehind.ts +0 -0
@@ -0,0 +1,127 @@
1
+ import { useUniqueId } from "@charcuterie/logic"
2
+ import type { ReactNode } from "react"
3
+
4
+ import { Button } from "../Button/Button.tsx"
5
+ import { Modal } from "../Modal/Modal.tsx"
6
+ import { toClassName } from "../toClassName.ts"
7
+
8
+ export type DialogSize = "full" | "lg" | "md" | "sm" | "xl"
9
+
10
+ export type DialogProps = {
11
+ children: ReactNode
12
+ className?: string
13
+ /** The footer row. Buttons, usually. */
14
+ footer?: ReactNode
15
+ heading: string
16
+ headingLevel?: 2 | 3 | 4
17
+ /**
18
+ * Escape and a backdrop press both close. Off for the one honest
19
+ * case — a destructive confirm the user has to answer.
20
+ */
21
+ isDismissable?: boolean
22
+ isVisible: boolean
23
+ /**
24
+ * Every route out lands here: the close button, Escape, a backdrop
25
+ * press. Wire it to a `useVisibility()`'s `hide`.
26
+ */
27
+ onClose: () => void
28
+ size?: DialogSize
29
+ }
30
+
31
+ /**
32
+ * `85dvh` rather than `85vh` — on a phone the two differ by the
33
+ * address bar, and a dialog whose footer is under it is a dialog
34
+ * with no buttons.
35
+ */
36
+ const SIZE_CLASS: Record<DialogSize, string> = {
37
+ sm: "max-h-[85dvh] w-full max-w-sm",
38
+ md: "max-h-[85dvh] w-full max-w-md",
39
+ lg: "max-h-[85dvh] w-full max-w-2xl",
40
+ // M5 added this, because the scale jumped from 42rem straight to
41
+ // the whole viewport and rip-deck's capture tail lands in the
42
+ // gap: a MakeMKV robot log is fixed-width `MSG:5072,0,1,"…"`
43
+ // lines that wrap badly at 42rem, and a full-screen dialog over a
44
+ // nine-bay dashboard hides the thing being diagnosed. 56rem is
45
+ // what that app's hand-rolled `<dialog>` used before the swap.
46
+ xl: "max-h-[85dvh] w-full max-w-4xl",
47
+ full: "h-[100dvh] max-h-[100dvh] w-full max-w-full rounded-none",
48
+ }
49
+
50
+ /**
51
+ * `Modal` plus the chrome — heading, a Close button, a scrolling body,
52
+ * an optional footer, and five sizes. This is what `Modal` *was*
53
+ * before the M8 rebuild renamed it: a dialog is one kind of modal, so
54
+ * it takes the specific name and hands the generic one back to the
55
+ * base layer.
56
+ *
57
+ * Everything modal — the portal, the scrim, Escape and outside-press
58
+ * dismiss, the focus trap, the scroll lock, the stacking — is `Modal`'s
59
+ * (via `OverlayPanel`), so this component is just the box's contents
60
+ * and the size class it wants that box to be. `SIZE_CLASS` is composed
61
+ * over `Modal`'s default surface, which is why `full` carries
62
+ * `rounded-none` to square off the base's `rounded-lg` at the viewport
63
+ * edge.
64
+ *
65
+ * ### The accessible name
66
+ *
67
+ * `heading` is required and is a string, not a `ReactNode`. It names
68
+ * both the visible header and — through `aria-labelledby` on the
69
+ * modal — the dialog itself. A dialog with no name is announced as
70
+ * "dialog" and nothing else, which is what every hand-rolled modal in
71
+ * the inventory does.
72
+ */
73
+ export const Dialog = ({
74
+ children,
75
+ className,
76
+ footer,
77
+ heading,
78
+ headingLevel = 2,
79
+ isDismissable = true,
80
+ isVisible,
81
+ onClose,
82
+ size = "md",
83
+ }: DialogProps): ReactNode => {
84
+ const headingId = useUniqueId()
85
+
86
+ const Heading = `h${headingLevel}` as const
87
+
88
+ return (
89
+ <Modal
90
+ aria-labelledby={headingId}
91
+ className={toClassName(SIZE_CLASS[size], className)}
92
+ isDismissable={isDismissable}
93
+ isVisible={isVisible}
94
+ onClose={onClose}
95
+ >
96
+ <header className="flex shrink-0 items-start justify-between gap-3 border-b border-border-subtle p-4">
97
+ <Heading
98
+ className="font-semibold text-lg leading-tight"
99
+ id={headingId}
100
+ >
101
+ {heading}
102
+ </Heading>
103
+
104
+ {isDismissable ? (
105
+ <Button
106
+ appearance="ghost"
107
+ intent="neutral"
108
+ onClick={onClose}
109
+ size="sm"
110
+ >
111
+ Close
112
+ </Button>
113
+ ) : null}
114
+ </header>
115
+
116
+ <div className="min-h-0 flex-1 overflow-auto p-4">
117
+ {children}
118
+ </div>
119
+
120
+ {footer ? (
121
+ <footer className="flex shrink-0 flex-wrap justify-end gap-2 border-t border-border-subtle p-4">
122
+ {footer}
123
+ </footer>
124
+ ) : null}
125
+ </Modal>
126
+ )
127
+ }
@@ -180,7 +180,7 @@ test("a slot forwards what an outer slot gave it", async () => {
180
180
  * ref and the attribute to have all three arrived.
181
181
  */
182
182
  test("a slot forwards what an outer slot gave it, in the other order", async () => {
183
- const { canvas } = await mountStory(Nested)
183
+ const { body, canvas } = await mountStory(Nested)
184
184
 
185
185
  const control = expectAgentDrivable(canvas, {
186
186
  name: "Archive path",
@@ -192,8 +192,10 @@ test("a slot forwards what an outer slot gave it, in the other order", async ()
192
192
  // only have been wired to the `<input>`.
193
193
  control.focus()
194
194
 
195
+ // The tip portals to the body now; the `aria-describedby` link still
196
+ // resolves across the boundary, asserted below.
195
197
  await waitFor(() => {
196
- expect(canvas.getByRole("tooltip")).toHaveTextContent(
198
+ expect(body.getByRole("tooltip")).toHaveTextContent(
197
199
  "Where finished rips are moved.",
198
200
  )
199
201
  })
@@ -224,7 +226,7 @@ test("a slot forwards what an outer slot gave it, in the other order", async ()
224
226
  * tip.
225
227
  */
226
228
  test("a tip is described alongside the field's own text, not instead of it", async () => {
227
- const { canvas } = await mountStory(Nested)
229
+ const { body, canvas } = await mountStory(Nested)
228
230
 
229
231
  const control = expectAgentDrivable(canvas, {
230
232
  name: "Rename pattern",
@@ -237,7 +239,7 @@ test("a tip is described alongside the field's own text, not instead of it", asy
237
239
  // tip is missing, and the reason this is testable without a
238
240
  // pointer at all.
239
241
  await waitFor(() => {
240
- expect(canvas.getByRole("tooltip")).toHaveTextContent(
242
+ expect(body.getByRole("tooltip")).toHaveTextContent(
241
243
  "A JavaScript regular expression.",
242
244
  )
243
245
  })
@@ -10,10 +10,11 @@ A thumbnail that opens its own full-size view. Three apps hand-roll this — a p
10
10
  a still, a scan the operator wants to read — and none get the focus trap or the scroll
11
11
  lock right, because the interesting part is not the overlay, it is the dialog underneath.
12
12
 
13
- So a `Lightbox` is a **skin over `Modal`**, not a second dialog. `Modal` owns
14
- `showModal()`, the top layer, Escape, the `::backdrop` scrim and `lockScrollBehind`;
15
- this adds the image-specific part — a `cursor-zoom-in` trigger and an `object-contain`
16
- image clamped to the viewport, so a 2:3 poster and a 16:9 still both land whole.
13
+ So a `Lightbox` is a **skin over `Dialog`**, not a second dialog. `Dialog` (through
14
+ `Modal`/`OverlayPanel`) owns the portal, the scrim, Escape, the focus trap and
15
+ `lockScrollBehind`; this adds the image-specific part — a `cursor-zoom-in` trigger and an
16
+ `object-contain` image clamped to the viewport, so a 2:3 poster and a 16:9 still both land
17
+ whole.
17
18
 
18
19
  <Canvas of={LightboxStories.Default} />
19
20
 
@@ -54,9 +55,9 @@ unread; the caller is the one truth.
54
55
  ## What it inherits, and does not re-test
55
56
 
56
57
  Escape, the focus trap, focus restore to the trigger, the scrim and the scroll lock are
57
- all `Modal`'s, asserted in `Modal`'s own tests. The lightbox's tests assert only the skin:
58
- that the thumbnail opens the enlarged image, that the caption rides along, and that the
59
- contract `Modal` guarantees is not quietly dropped by the wrapper — Escape → `onClose`,
60
- and focus back to the thumbnail.
58
+ all `Dialog`'s (via `Modal`), asserted in their own tests. The lightbox's tests assert only
59
+ the skin: that the thumbnail opens the enlarged image, that the caption rides along, and
60
+ that the contract `Dialog` guarantees is not quietly dropped by the wrapper — Escape →
61
+ `onClose`, and focus back to the thumbnail.
61
62
 
62
63
  <Canvas of={LightboxStories.Interactive} />
@@ -66,8 +66,8 @@ export const WithCaption: Story = {
66
66
 
67
67
  /**
68
68
  * Boards here are a trigger plus one open overlay, not a grid — a
69
- * lightbox is a `Modal` underneath, and two dialogs in the top layer
70
- * side by side is not a thing the platform can do.
69
+ * lightbox is a `Dialog` underneath, and a dialog covers the viewport,
70
+ * so two side by side is not the real arrangement.
71
71
  */
72
72
  export const AllStates: Story = {
73
73
  render: () => (
@@ -106,8 +106,8 @@ const ControlledDemo = (): ReactNode => {
106
106
  alt="THE OUTFIT poster"
107
107
  caption="Opened from a button, not the image"
108
108
  isOpen={isVisible}
109
- onOpenChange={(isOpen) => {
110
- if (!isOpen) {
109
+ onOpenChange={(isNextOpen) => {
110
+ if (!isNextOpen) {
111
111
  hide()
112
112
  }
113
113
  }}
@@ -122,7 +122,7 @@ export const Controlled: Story = {
122
122
  }
123
123
 
124
124
  /**
125
- * The keyboard contract, inherited from `Modal` and asserted here so
125
+ * The keyboard contract, inherited from `Dialog` and asserted here so
126
126
  * the skin cannot quietly drop it: the trigger opens, Escape routes
127
127
  * through `onClose`, and focus returns to the thumbnail button.
128
128
  */
@@ -10,23 +10,8 @@ import * as stories from "./Lightbox.stories.tsx"
10
10
  const { Controlled, Default, Interactive, WithCaption } =
11
11
  composeStories(stories)
12
12
 
13
- /**
14
- * The close request, dispatched where the browser would raise it.
15
- * `userEvent`'s Escape is untrusted and a native `<dialog>` runs no
16
- * default action for it, so — exactly as `Modal`'s tests do — the
17
- * `cancel` event is dispatched directly, and only the half
18
- * downstream of it (this component's `onClose`, the effect, the
19
- * focus restore) is ours to assert.
20
- */
21
- const requestClose = (dialog: HTMLElement) => {
22
- dialog.dispatchEvent(
23
- new Event("cancel", { cancelable: true }),
24
- )
25
- }
26
-
27
13
  test("the thumbnail opens the enlarged image", async () => {
28
- const { canvas, canvasElement } =
29
- await mountStory(Default)
14
+ const { body, canvas } = await mountStory(Default)
30
15
 
31
16
  await userEvent.click(
32
17
  expectAgentDrivable(canvas, {
@@ -35,16 +20,14 @@ test("the thumbnail opens the enlarged image", async () => {
35
20
  }),
36
21
  )
37
22
 
38
- const dialog = expectAgentDrivable(canvas, {
23
+ const dialog = expectAgentDrivable(body, {
39
24
  name: "THE OUTFIT poster",
40
25
  role: "dialog",
41
26
  })
42
27
 
43
- await expect(dialog).toHaveAttribute("open")
44
-
45
28
  // The enlarged image carries the name — a bare `<img>` in a dialog
46
29
  // otherwise announces as nothing.
47
- expectAgentDrivable(canvas, {
30
+ expectAgentDrivable(body, {
48
31
  name: "THE OUTFIT poster",
49
32
  role: "img",
50
33
  })
@@ -52,11 +35,11 @@ test("the thumbnail opens the enlarged image", async () => {
52
35
  // The open dialog audits itself: the empty-alt thumbnail, the
53
36
  // named trigger and the named enlarged image are the arrangement
54
37
  // most likely to trip `button-name` or a duplicate name.
55
- await expectNoAxeViolations(canvasElement)
38
+ await expectNoAxeViolations(dialog)
56
39
  })
57
40
 
58
41
  test("the caption rides along with the enlarged image", async () => {
59
- const { canvas } = await mountStory(WithCaption)
42
+ const { body, canvas } = await mountStory(WithCaption)
60
43
 
61
44
  await userEvent.click(
62
45
  canvas.getByRole("button", {
@@ -65,17 +48,19 @@ test("the caption rides along with the enlarged image", async () => {
65
48
  )
66
49
 
67
50
  await expect(
68
- canvas.getByText("2022 · Blu-ray · 1080p"),
51
+ body.getByText("2022 · Blu-ray · 1080p"),
69
52
  ).toBeInTheDocument()
70
53
  })
71
54
 
72
55
  /**
73
- * The keyboard contract, inherited from `Modal` and asserted here so
56
+ * The keyboard contract, inherited from `Dialog` and asserted here so
74
57
  * the skin cannot quietly drop it — Escape routes through `onClose`,
75
- * and focus returns to the trigger the platform restored it to.
58
+ * and focus returns to the trigger `FloatingFocusManager` restored it
59
+ * to. Escape is a real `userEvent` keypress now, since the overlay is
60
+ * no longer a native `<dialog>`.
76
61
  */
77
62
  test("closing restores focus to the thumbnail trigger", async () => {
78
- const { canvas } = await mountStory(Interactive)
63
+ const { body, canvas } = await mountStory(Interactive)
79
64
 
80
65
  const trigger = expectAgentDrivable(canvas, {
81
66
  name: "Enlarge THE OUTFIT poster",
@@ -84,19 +69,21 @@ test("closing restores focus to the thumbnail trigger", async () => {
84
69
 
85
70
  await userEvent.click(trigger)
86
71
 
87
- const dialog = expectAgentDrivable(canvas, {
72
+ const dialog = expectAgentDrivable(body, {
88
73
  name: "THE OUTFIT poster",
89
74
  role: "dialog",
90
75
  })
91
76
 
92
- await expect(dialog).toContainElement(
93
- document.activeElement as HTMLElement,
94
- )
77
+ await waitFor(() => {
78
+ expect(dialog.contains(document.activeElement)).toBe(
79
+ true,
80
+ )
81
+ })
95
82
 
96
- requestClose(dialog)
83
+ await userEvent.keyboard("{Escape}")
97
84
 
98
85
  await waitFor(() => {
99
- expect(canvas.queryByRole("dialog")).toBeNull()
86
+ expect(body.queryByRole("dialog")).toBeNull()
100
87
  })
101
88
 
102
89
  await expect(document.activeElement).toBe(trigger)
@@ -108,7 +95,7 @@ test("closing restores focus to the thumbnail trigger", async () => {
108
95
  * opens it, and closing it flows back through `onOpenChange`.
109
96
  */
110
97
  test("a controlled lightbox opens from an outside button", async () => {
111
- const { canvas } = await mountStory(Controlled)
98
+ const { body, canvas } = await mountStory(Controlled)
112
99
 
113
100
  // No built-in trigger: the only "Enlarge …" button a `thumbnail`
114
101
  // would have added is absent.
@@ -122,14 +109,16 @@ test("a controlled lightbox opens from an outside button", async () => {
122
109
  canvas.getByRole("button", { name: "View the poster" }),
123
110
  )
124
111
 
125
- const dialog = expectAgentDrivable(canvas, {
112
+ const dialog = expectAgentDrivable(body, {
126
113
  name: "THE OUTFIT poster",
127
114
  role: "dialog",
128
115
  })
129
116
 
130
- requestClose(dialog)
117
+ await userEvent.keyboard("{Escape}")
131
118
 
132
119
  await waitFor(() => {
133
- expect(canvas.queryByRole("dialog")).toBeNull()
120
+ expect(body.queryByRole("dialog")).toBeNull()
134
121
  })
122
+
123
+ await expect(dialog).not.toBeInTheDocument()
135
124
  })
@@ -1,7 +1,7 @@
1
1
  import { useVisibility } from "@charcuterie/logic"
2
2
  import type { ReactNode } from "react"
3
3
 
4
- import { Modal } from "../Modal/Modal.tsx"
4
+ import { Dialog } from "../Dialog/Dialog.tsx"
5
5
  import { toClassName } from "../toClassName.ts"
6
6
 
7
7
  export type LightboxProps = {
@@ -43,13 +43,13 @@ export type LightboxProps = {
43
43
  /**
44
44
  * A thumbnail that opens its own full-size view — the one image
45
45
  * interaction three apps hand-roll and none get the focus trap or
46
- * the scroll lock right. It is a **skin over `Modal`**, not a second
47
- * dialog: `Modal` owns `showModal()`, the top layer, Escape, the
48
- * `::backdrop` scrim and `lockScrollBehind`, and everything that
49
- * makes those correct in every browser the fleet runs. What this
50
- * adds is the image-specific part — a `cursor-zoom-in` trigger and
51
- * an `object-contain` image clamped to the viewport so a 2:3 poster
52
- * and a 16:9 still both land whole.
46
+ * the scroll lock right. It is a **skin over `Dialog`**, not a second
47
+ * dialog: `Dialog` (through `Modal`/`OverlayPanel`) owns the portal,
48
+ * the scrim, Escape, the focus trap and `lockScrollBehind`, and
49
+ * everything that makes those correct in every browser the fleet
50
+ * runs. What this adds is the image-specific part — a `cursor-zoom-in`
51
+ * trigger and an `object-contain` image clamped to the viewport so a
52
+ * 2:3 poster and a 16:9 still both land whole.
53
53
  *
54
54
  * ### Controlled and not, one component
55
55
  *
@@ -115,7 +115,7 @@ export const Lightbox = ({
115
115
  </button>
116
116
  )}
117
117
 
118
- <Modal
118
+ <Dialog
119
119
  heading={heading ?? alt}
120
120
  isVisible={isShown}
121
121
  onClose={requestClose}
@@ -137,7 +137,7 @@ export const Lightbox = ({
137
137
  </figcaption>
138
138
  ) : null}
139
139
  </figure>
140
- </Modal>
140
+ </Dialog>
141
141
  </>
142
142
  )
143
143
  }
@@ -0,0 +1,68 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as ListboxStories from "./Listbox.stories";
4
+
5
+ <Meta of={ListboxStories} />
6
+
7
+ # Listbox
8
+
9
+ The single-select **fancy** dropdown — the rich-option sibling of the native
10
+ [`Select`](?path=/docs/components-select--docs), not a replacement for it. `Select` keeps
11
+ type-ahead, Home/End, the mobile wheel picker, form submission, `:invalid` and autofill;
12
+ `Listbox` is for the cases `<option>` cannot render — a leading icon, two lines, a trailing
13
+ badge ([decision](../../../docs/decisions/2026-08-03-listbox-and-combobox-are-siblings-of-select.md)).
14
+
15
+ <Canvas of={ListboxStories.Default} />
16
+
17
+ ## Props
18
+
19
+ <Controls />
20
+
21
+ ## A listbox is not a menu
22
+
23
+ An `option` **is** something you are choosing; a `menuitem` **does** something. So a
24
+ `Listbox` carries `aria-selected`, it type-aheads (the APG requires it of a listbox and
25
+ `Menu` deliberately omits it), and a screen reader announces "listbox, selected, 2 of 4".
26
+ That is the distinction `Menu` documents from the other side.
27
+
28
+ ```tsx
29
+ const { hide, isVisible, toggle } = useVisibility()
30
+ const [value, setValue] = useState<string>()
31
+
32
+ <Listbox
33
+ isVisible={isVisible}
34
+ onDismiss={hide}
35
+ onSelect={setValue}
36
+ options={LANGUAGES}
37
+ selectedValue={value}
38
+ trigger={<Button onClick={toggle}>{label}</Button>}
39
+ />
40
+ ```
41
+
42
+ ## Two kinds of state, composed
43
+
44
+ `useRovingFocus` owns which option is tabbable — exactly one, so Tab enters and leaves the
45
+ group once and the arrow keys move within it — and `useSinglePicker` owns which option is
46
+ chosen. They are the same two kinds `Tabs` composes, and they never disturb each other:
47
+ arrowing moves focus without selecting, Enter selects the focused option. A disabled option
48
+ joins the choice but not the focus group, so the arrow keys and type-ahead skip it.
49
+
50
+ <Canvas of={ListboxStories.AllStates} />
51
+
52
+ ## Rich options, and `textValue`
53
+
54
+ The options take a `ReactNode` label — two lines, a flag, an intent-coloured confidence
55
+ badge. When the label is rich, `textValue` is required: it is the type-ahead target and the
56
+ name a screen reader reads, so a picture of a value is still a value you can find by typing.
57
+
58
+ <Canvas of={ListboxStories.AllVariants} />
59
+
60
+ ## Portalled, and a plain button opens it
61
+
62
+ The panel portals to `document.body`. The trigger stays a native **button** that gains
63
+ `aria-haspopup="listbox"`, `aria-expanded`, and `aria-controls` — the button-opens-listbox
64
+ pattern, not a `combobox` (that is `Combobox`, where focus stays in a text input). So there
65
+ is no `heading` prop; the button names the widget. The panel height is clamped to the
66
+ viewport by floating-ui's `size` middleware and scrolls within it.
67
+
68
+ <Canvas of={ListboxStories.Interactive} />
@@ -0,0 +1,205 @@
1
+ import { useVisibility } from "@charcuterie/logic"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import type { ReactNode } from "react"
4
+ import { useState } from "react"
5
+
6
+ import { placementArgType } from "../argTypes.storyHelpers.ts"
7
+ import { Badge } from "../Badge/Badge.tsx"
8
+ import { Button } from "../Button/Button.tsx"
9
+ import { StoryRow } from "../board.storyHelpers.tsx"
10
+ import type { ListboxItem } from "./Listbox.tsx"
11
+ import { Listbox } from "./Listbox.tsx"
12
+
13
+ const LANGUAGES: ListboxItem[] = [
14
+ { label: "English", value: "eng" },
15
+ { label: "Spanish", value: "spa" },
16
+ { label: "French", value: "fra" },
17
+ { isDisabled: true, label: "German", value: "deu" },
18
+ { label: "Japanese", value: "jpn" },
19
+ ]
20
+
21
+ /**
22
+ * The rich options a native `<option>` cannot render — a leading
23
+ * flag, a two-line detail, a trailing confidence badge — each with a
24
+ * `textValue` so type-ahead and the accessible name still work.
25
+ */
26
+ const TRACKS: ListboxItem[] = [
27
+ {
28
+ label: (
29
+ <span className="flex items-center gap-2">
30
+ <span aria-hidden="true">🇬🇧</span>
31
+ <span className="flex flex-col">
32
+ <span>English</span>
33
+ <span className="text-content-muted text-xs">
34
+ 5.1 · forced
35
+ </span>
36
+ </span>
37
+ </span>
38
+ ),
39
+ textValue: "English",
40
+ value: "eng",
41
+ },
42
+ {
43
+ label: (
44
+ <span className="flex items-center gap-2">
45
+ <span aria-hidden="true">🇯🇵</span>
46
+ <span className="flex flex-col">
47
+ <span>Japanese</span>
48
+ <span className="text-content-muted text-xs">
49
+ 2.0 · original
50
+ </span>
51
+ </span>
52
+ </span>
53
+ ),
54
+ textValue: "Japanese",
55
+ value: "jpn",
56
+ },
57
+ {
58
+ label: (
59
+ <span className="flex items-center gap-2">
60
+ <span aria-hidden="true">🇫🇷</span>
61
+ <span>French</span>
62
+ <Badge intent="warning" size="sm">
63
+ low confidence
64
+ </Badge>
65
+ </span>
66
+ ),
67
+ textValue: "French",
68
+ value: "fra",
69
+ },
70
+ ]
71
+
72
+ const meta = {
73
+ title: "Components/Listbox",
74
+ component: Listbox,
75
+ parameters: { layout: "padded" },
76
+ argTypes: { placement: placementArgType },
77
+ args: { placement: "bottom-start" },
78
+ } satisfies Meta<typeof Listbox>
79
+
80
+ export default meta
81
+
82
+ type Story = StoryObj<typeof meta>
83
+
84
+ const ListboxHarness = ({
85
+ initialSelected,
86
+ options,
87
+ triggerLabel,
88
+ }: {
89
+ initialSelected?: string
90
+ options: ListboxItem[]
91
+ triggerLabel: string
92
+ }): ReactNode => {
93
+ const { hide, isVisible, toggle } = useVisibility()
94
+
95
+ const [selected, setSelected] = useState<
96
+ string | undefined
97
+ >(initialSelected)
98
+
99
+ const current = options.find(
100
+ (option) => option.value === selected,
101
+ )
102
+
103
+ const label =
104
+ current === undefined
105
+ ? triggerLabel
106
+ : (current.textValue ??
107
+ (typeof current.label === "string"
108
+ ? current.label
109
+ : triggerLabel))
110
+
111
+ return (
112
+ <Listbox
113
+ isVisible={isVisible}
114
+ onDismiss={hide}
115
+ onSelect={setSelected}
116
+ options={options}
117
+ selectedValue={selected}
118
+ trigger={
119
+ <Button appearance="outline" onClick={toggle}>
120
+ {label}
121
+ </Button>
122
+ }
123
+ />
124
+ )
125
+ }
126
+
127
+ export const Default: Story = {
128
+ args: {
129
+ isVisible: false,
130
+ onDismiss: () => {},
131
+ onSelect: () => {},
132
+ options: LANGUAGES,
133
+ trigger: <Button>Choose a language</Button>,
134
+ },
135
+ render: () => (
136
+ <ListboxHarness
137
+ options={LANGUAGES}
138
+ triggerLabel="Choose a language"
139
+ />
140
+ ),
141
+ }
142
+
143
+ /**
144
+ * A disabled option stays in the list and stays announced — it joins
145
+ * the choice but not the focus group, so the arrow keys and type-ahead
146
+ * skip it. `Spanish` is seeded as the current value.
147
+ */
148
+ export const AllStates: Story = {
149
+ args: {
150
+ isVisible: false,
151
+ onDismiss: () => {},
152
+ onSelect: () => {},
153
+ options: LANGUAGES,
154
+ trigger: <Button>Choose a language</Button>,
155
+ },
156
+ render: () => (
157
+ <ListboxHarness
158
+ initialSelected="spa"
159
+ options={LANGUAGES}
160
+ triggerLabel="Choose a language"
161
+ />
162
+ ),
163
+ }
164
+
165
+ /**
166
+ * The thing a native `<select>` cannot do: two-line options, a leading
167
+ * flag, and a trailing intent-coloured badge.
168
+ */
169
+ export const AllVariants: Story = {
170
+ args: {
171
+ isVisible: false,
172
+ onDismiss: () => {},
173
+ onSelect: () => {},
174
+ options: TRACKS,
175
+ trigger: <Button>Choose a track</Button>,
176
+ },
177
+ render: () => (
178
+ <StoryRow>
179
+ <ListboxHarness
180
+ options={TRACKS}
181
+ triggerLabel="Choose a track"
182
+ />
183
+ </StoryRow>
184
+ ),
185
+ }
186
+
187
+ /**
188
+ * Open it and drive it from the keyboard: arrows move focus without
189
+ * selecting, type a letter to jump, Enter chooses, Escape closes.
190
+ */
191
+ export const Interactive: Story = {
192
+ args: {
193
+ isVisible: false,
194
+ onDismiss: () => {},
195
+ onSelect: () => {},
196
+ options: LANGUAGES,
197
+ trigger: <Button>Choose a language</Button>,
198
+ },
199
+ render: () => (
200
+ <ListboxHarness
201
+ options={LANGUAGES}
202
+ triggerLabel="Choose a language"
203
+ />
204
+ ),
205
+ }