@charcuterie/ui 1.0.1 → 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 (134) hide show
  1. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts +35 -0
  2. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts.map +1 -0
  3. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js +33 -0
  4. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js.map +1 -0
  5. package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts +54 -0
  6. package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts.map +1 -0
  7. package/dist/ColorSchemeToggle/ColorSchemeToggle.js +30 -0
  8. package/dist/ColorSchemeToggle/ColorSchemeToggle.js.map +1 -0
  9. package/dist/Combobox/Combobox.d.ts +54 -0
  10. package/dist/Combobox/Combobox.d.ts.map +1 -0
  11. package/dist/Combobox/Combobox.js +216 -0
  12. package/dist/Combobox/Combobox.js.map +1 -0
  13. package/dist/Combobox/ComboboxOption.d.ts +36 -0
  14. package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
  15. package/dist/Combobox/ComboboxOption.js +32 -0
  16. package/dist/Combobox/ComboboxOption.js.map +1 -0
  17. package/dist/Dialog/Dialog.d.ts +47 -0
  18. package/dist/Dialog/Dialog.d.ts.map +1 -0
  19. package/dist/Dialog/Dialog.js +52 -0
  20. package/dist/Dialog/Dialog.js.map +1 -0
  21. package/dist/Lightbox/Lightbox.d.ts +57 -0
  22. package/dist/Lightbox/Lightbox.d.ts.map +1 -0
  23. package/dist/Lightbox/Lightbox.js +50 -0
  24. package/dist/Lightbox/Lightbox.js.map +1 -0
  25. package/dist/Listbox/Listbox.d.ts +56 -0
  26. package/dist/Listbox/Listbox.d.ts.map +1 -0
  27. package/dist/Listbox/Listbox.js +151 -0
  28. package/dist/Listbox/Listbox.js.map +1 -0
  29. package/dist/Listbox/ListboxOption.d.ts +36 -0
  30. package/dist/Listbox/ListboxOption.d.ts.map +1 -0
  31. package/dist/Listbox/ListboxOption.js +42 -0
  32. package/dist/Listbox/ListboxOption.js.map +1 -0
  33. package/dist/Menu/Menu.d.ts.map +1 -1
  34. package/dist/Menu/Menu.js +47 -97
  35. package/dist/Menu/Menu.js.map +1 -1
  36. package/dist/Modal/Modal.d.ts +11 -59
  37. package/dist/Modal/Modal.d.ts.map +1 -1
  38. package/dist/Modal/Modal.js +28 -101
  39. package/dist/Modal/Modal.js.map +1 -1
  40. package/dist/Overlay/OverlayPanel.d.ts +50 -0
  41. package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
  42. package/dist/Overlay/OverlayPanel.js +72 -0
  43. package/dist/Overlay/OverlayPanel.js.map +1 -0
  44. package/dist/Overlay/OverlayStack.d.ts +47 -0
  45. package/dist/Overlay/OverlayStack.d.ts.map +1 -0
  46. package/dist/Overlay/OverlayStack.js +46 -0
  47. package/dist/Overlay/OverlayStack.js.map +1 -0
  48. package/dist/Overlay/SharedBackdrop.d.ts +24 -0
  49. package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
  50. package/dist/Overlay/SharedBackdrop.js +28 -0
  51. package/dist/Overlay/SharedBackdrop.js.map +1 -0
  52. package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
  53. package/dist/Overlay/lockScrollBehind.js.map +1 -0
  54. package/dist/Overlay/overlayPanelClass.d.ts +17 -0
  55. package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
  56. package/dist/Overlay/overlayPanelClass.js +17 -0
  57. package/dist/Overlay/overlayPanelClass.js.map +1 -0
  58. package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
  59. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
  60. package/dist/Overlay/useAnchoredOverlay.js +81 -0
  61. package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
  62. package/dist/Popover/Popover.d.ts +16 -24
  63. package/dist/Popover/Popover.d.ts.map +1 -1
  64. package/dist/Popover/Popover.js +32 -68
  65. package/dist/Popover/Popover.js.map +1 -1
  66. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  67. package/dist/Tooltip/Tooltip.js +25 -25
  68. package/dist/Tooltip/Tooltip.js.map +1 -1
  69. package/dist/index.d.ts +13 -1
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +6 -0
  72. package/dist/index.js.map +1 -1
  73. package/dist/mountStory.testHelpers.d.ts +1 -0
  74. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  75. package/dist/mountStory.testHelpers.js +21 -1
  76. package/dist/mountStory.testHelpers.js.map +1 -1
  77. package/package.json +5 -4
  78. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.mdx +70 -0
  79. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.stories.tsx +118 -0
  80. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.test.tsx +53 -0
  81. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.tsx +90 -0
  82. package/src/ColorSchemeToggle/ColorSchemeToggle.mdx +48 -0
  83. package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +161 -0
  84. package/src/ColorSchemeToggle/ColorSchemeToggle.test.tsx +80 -0
  85. package/src/ColorSchemeToggle/ColorSchemeToggle.tsx +107 -0
  86. package/src/Combobox/Combobox.mdx +71 -0
  87. package/src/Combobox/Combobox.stories.tsx +245 -0
  88. package/src/Combobox/Combobox.test.tsx +289 -0
  89. package/src/Combobox/Combobox.tsx +513 -0
  90. package/src/Combobox/ComboboxOption.tsx +88 -0
  91. package/src/Dialog/Dialog.mdx +87 -0
  92. package/src/Dialog/Dialog.stories.tsx +347 -0
  93. package/src/Dialog/Dialog.test.tsx +312 -0
  94. package/src/Dialog/Dialog.tsx +127 -0
  95. package/src/Field/Field.test.tsx +6 -4
  96. package/src/Lightbox/Lightbox.mdx +63 -0
  97. package/src/Lightbox/Lightbox.stories.tsx +133 -0
  98. package/src/Lightbox/Lightbox.test.tsx +124 -0
  99. package/src/Lightbox/Lightbox.tsx +143 -0
  100. package/src/Listbox/Listbox.mdx +68 -0
  101. package/src/Listbox/Listbox.stories.tsx +205 -0
  102. package/src/Listbox/Listbox.test.tsx +198 -0
  103. package/src/Listbox/Listbox.tsx +295 -0
  104. package/src/Listbox/ListboxOption.tsx +104 -0
  105. package/src/Menu/Menu.mdx +11 -13
  106. package/src/Menu/Menu.test.tsx +47 -38
  107. package/src/Menu/Menu.tsx +81 -142
  108. package/src/Modal/Modal.mdx +29 -82
  109. package/src/Modal/Modal.stories.tsx +55 -188
  110. package/src/Modal/Modal.test.tsx +44 -177
  111. package/src/Modal/Modal.tsx +54 -204
  112. package/src/Overlay/OverlayPanel.tsx +195 -0
  113. package/src/Overlay/OverlayStack.test.tsx +235 -0
  114. package/src/Overlay/OverlayStack.tsx +132 -0
  115. package/src/Overlay/SharedBackdrop.tsx +40 -0
  116. package/src/Overlay/overlayPanelClass.ts +17 -0
  117. package/src/Overlay/useAnchoredOverlay.ts +197 -0
  118. package/src/Popover/Popover.mdx +17 -20
  119. package/src/Popover/Popover.stories.tsx +33 -0
  120. package/src/Popover/Popover.test.tsx +68 -32
  121. package/src/Popover/Popover.tsx +56 -95
  122. package/src/Tooltip/Tooltip.mdx +12 -9
  123. package/src/Tooltip/Tooltip.test.tsx +17 -19
  124. package/src/Tooltip/Tooltip.tsx +35 -32
  125. package/src/icons.storyHelpers.tsx +22 -0
  126. package/src/index.ts +22 -4
  127. package/src/mountStory.testHelpers.ts +25 -1
  128. package/src/sourceRules.test.ts +26 -1
  129. package/src/storyControls.test.ts +25 -12
  130. package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
  131. package/dist/Modal/lockScrollBehind.js.map +0 -1
  132. /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
  133. /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
  134. /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
  })
@@ -0,0 +1,63 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as LightboxStories from "./Lightbox.stories";
4
+
5
+ <Meta of={LightboxStories} />
6
+
7
+ # Lightbox
8
+
9
+ A thumbnail that opens its own full-size view. Three apps hand-roll this — a poster,
10
+ a still, a scan the operator wants to read — and none get the focus trap or the scroll
11
+ lock right, because the interesting part is not the overlay, it is the dialog underneath.
12
+
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.
18
+
19
+ <Canvas of={LightboxStories.Default} />
20
+
21
+ ## Props
22
+
23
+ <Controls />
24
+
25
+ ## The thumbnail is the trigger, and it carries the name
26
+
27
+ Pass the small image as `thumbnail` and the `Lightbox` wraps it in the button that opens
28
+ the overlay. That button carries the accessible name — `Enlarge {alt}` — so the
29
+ `thumbnail` inside it goes `alt=""`, exactly as `MediaTile` names its link and silences
30
+ the image within it. A button around an empty-alt image is otherwise `button-name` with
31
+ nothing to announce.
32
+
33
+ ```tsx
34
+ <Lightbox
35
+ alt="THE OUTFIT poster"
36
+ src={fullSizePoster}
37
+ thumbnail={<img alt="" className="h-40 w-28 object-cover" src={thumbSrc} />}
38
+ />
39
+ ```
40
+
41
+ `alt` also names the dialog, through `heading`'s default. Pass `heading` when the two
42
+ should differ — a filename overlay whose thumbnail alt is already the title.
43
+
44
+ <Canvas of={LightboxStories.WithCaption} />
45
+
46
+ ## Controlled, when something else opens it
47
+
48
+ Omit `thumbnail` and pass `isOpen` / `onOpenChange`, and the `Lightbox` renders only the
49
+ overlay — a button elsewhere on the page owns the open state through its own
50
+ `useVisibility`. The internal store still runs (hooks cannot be conditional) but goes
51
+ unread; the caller is the one truth.
52
+
53
+ <Canvas of={LightboxStories.Controlled} />
54
+
55
+ ## What it inherits, and does not re-test
56
+
57
+ Escape, the focus trap, focus restore to the trigger, the scrim and the scroll lock are
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.
62
+
63
+ <Canvas of={LightboxStories.Interactive} />
@@ -0,0 +1,133 @@
1
+ import { useVisibility } from "@charcuterie/logic"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import type { ReactNode } from "react"
4
+
5
+ import { Button } from "../Button/Button.tsx"
6
+ import { StoryRow } from "../board.storyHelpers.tsx"
7
+ import { Lightbox } from "./Lightbox.tsx"
8
+
9
+ /**
10
+ * An inline SVG rather than a fixture file or a placeholder service:
11
+ * it decodes synchronously and needs no network, so the enlarged
12
+ * image is deterministic in CI — the same reason `MediaTile`'s
13
+ * stories draw their posters this way.
14
+ */
15
+ const toPosterSrc = (label: string) =>
16
+ `data:image/svg+xml;utf8,${encodeURIComponent(
17
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300"><rect width="200" height="300" fill="#3E38C4"/><text x="100" y="150" fill="#FFFFFF" font-family="sans-serif" font-size="18" text-anchor="middle">${label}</text></svg>`,
18
+ )}`
19
+
20
+ const POSTER = toPosterSrc("THE OUTFIT")
21
+
22
+ /**
23
+ * A small poster thumbnail, exactly as a card would render it —
24
+ * `object-cover` at a fixed poster trim. It is the child the
25
+ * `Lightbox` trigger wraps.
26
+ */
27
+ const PosterThumb = (): ReactNode => (
28
+ <img
29
+ alt=""
30
+ className="h-40 w-28 rounded-md object-cover ring-1 ring-border-subtle"
31
+ src={POSTER}
32
+ />
33
+ )
34
+
35
+ const meta = {
36
+ title: "Components/Lightbox",
37
+ component: Lightbox,
38
+ parameters: { layout: "padded" },
39
+ args: {
40
+ alt: "THE OUTFIT poster",
41
+ src: POSTER,
42
+ },
43
+ } satisfies Meta<typeof Lightbox>
44
+
45
+ export default meta
46
+
47
+ type Story = StoryObj<typeof meta>
48
+
49
+ /**
50
+ * The uncontrolled default: the thumbnail *is* the trigger, and the
51
+ * component owns its open state. This is what a rip card passes —
52
+ * one `Lightbox` with the poster as its `thumbnail`.
53
+ */
54
+ export const Default: Story = {
55
+ args: {
56
+ thumbnail: <PosterThumb />,
57
+ },
58
+ }
59
+
60
+ export const WithCaption: Story = {
61
+ args: {
62
+ caption: "2022 · Blu-ray · 1080p",
63
+ thumbnail: <PosterThumb />,
64
+ },
65
+ }
66
+
67
+ /**
68
+ * Boards here are a trigger plus one open overlay, not a grid — a
69
+ * lightbox is a `Dialog` underneath, and a dialog covers the viewport,
70
+ * so two side by side is not the real arrangement.
71
+ */
72
+ export const AllStates: Story = {
73
+ render: () => (
74
+ <StoryRow>
75
+ <Lightbox
76
+ alt="THE OUTFIT poster"
77
+ src={POSTER}
78
+ thumbnail={<PosterThumb />}
79
+ />
80
+
81
+ <Lightbox
82
+ alt="THE OUTFIT poster"
83
+ caption="2022 · Blu-ray · 1080p"
84
+ src={POSTER}
85
+ thumbnail={<PosterThumb />}
86
+ />
87
+ </StoryRow>
88
+ ),
89
+ }
90
+
91
+ /**
92
+ * The controlled case: some other control opens the lightbox, and
93
+ * this renders only the overlay. `isOpen` present makes the internal
94
+ * store go unread — the caller's `useVisibility` is the one truth.
95
+ */
96
+ const ControlledDemo = (): ReactNode => {
97
+ const { hide, isVisible, show } = useVisibility()
98
+
99
+ return (
100
+ <>
101
+ <Button appearance="soft" onClick={show} size="sm">
102
+ View the poster
103
+ </Button>
104
+
105
+ <Lightbox
106
+ alt="THE OUTFIT poster"
107
+ caption="Opened from a button, not the image"
108
+ isOpen={isVisible}
109
+ onOpenChange={(isNextOpen) => {
110
+ if (!isNextOpen) {
111
+ hide()
112
+ }
113
+ }}
114
+ src={POSTER}
115
+ />
116
+ </>
117
+ )
118
+ }
119
+
120
+ export const Controlled: Story = {
121
+ render: () => <ControlledDemo />,
122
+ }
123
+
124
+ /**
125
+ * The keyboard contract, inherited from `Dialog` and asserted here so
126
+ * the skin cannot quietly drop it: the trigger opens, Escape routes
127
+ * through `onClose`, and focus returns to the thumbnail button.
128
+ */
129
+ export const Interactive: Story = {
130
+ args: {
131
+ thumbnail: <PosterThumb />,
132
+ },
133
+ }
@@ -0,0 +1,124 @@
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 "./Lightbox.stories.tsx"
9
+
10
+ const { Controlled, Default, Interactive, WithCaption } =
11
+ composeStories(stories)
12
+
13
+ test("the thumbnail opens the enlarged image", async () => {
14
+ const { body, canvas } = await mountStory(Default)
15
+
16
+ await userEvent.click(
17
+ expectAgentDrivable(canvas, {
18
+ name: "Enlarge THE OUTFIT poster",
19
+ role: "button",
20
+ }),
21
+ )
22
+
23
+ const dialog = expectAgentDrivable(body, {
24
+ name: "THE OUTFIT poster",
25
+ role: "dialog",
26
+ })
27
+
28
+ // The enlarged image carries the name — a bare `<img>` in a dialog
29
+ // otherwise announces as nothing.
30
+ expectAgentDrivable(body, {
31
+ name: "THE OUTFIT poster",
32
+ role: "img",
33
+ })
34
+
35
+ // The open dialog audits itself: the empty-alt thumbnail, the
36
+ // named trigger and the named enlarged image are the arrangement
37
+ // most likely to trip `button-name` or a duplicate name.
38
+ await expectNoAxeViolations(dialog)
39
+ })
40
+
41
+ test("the caption rides along with the enlarged image", async () => {
42
+ const { body, canvas } = await mountStory(WithCaption)
43
+
44
+ await userEvent.click(
45
+ canvas.getByRole("button", {
46
+ name: "Enlarge THE OUTFIT poster",
47
+ }),
48
+ )
49
+
50
+ await expect(
51
+ body.getByText("2022 · Blu-ray · 1080p"),
52
+ ).toBeInTheDocument()
53
+ })
54
+
55
+ /**
56
+ * The keyboard contract, inherited from `Dialog` and asserted here so
57
+ * the skin cannot quietly drop it — Escape routes through `onClose`,
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>`.
61
+ */
62
+ test("closing restores focus to the thumbnail trigger", async () => {
63
+ const { body, canvas } = await mountStory(Interactive)
64
+
65
+ const trigger = expectAgentDrivable(canvas, {
66
+ name: "Enlarge THE OUTFIT poster",
67
+ role: "button",
68
+ })
69
+
70
+ await userEvent.click(trigger)
71
+
72
+ const dialog = expectAgentDrivable(body, {
73
+ name: "THE OUTFIT poster",
74
+ role: "dialog",
75
+ })
76
+
77
+ await waitFor(() => {
78
+ expect(dialog.contains(document.activeElement)).toBe(
79
+ true,
80
+ )
81
+ })
82
+
83
+ await userEvent.keyboard("{Escape}")
84
+
85
+ await waitFor(() => {
86
+ expect(body.queryByRole("dialog")).toBeNull()
87
+ })
88
+
89
+ await expect(document.activeElement).toBe(trigger)
90
+ })
91
+
92
+ /**
93
+ * Controlled: the caller's `useVisibility` is the one truth, and the
94
+ * `Lightbox` renders no trigger of its own — a button elsewhere
95
+ * opens it, and closing it flows back through `onOpenChange`.
96
+ */
97
+ test("a controlled lightbox opens from an outside button", async () => {
98
+ const { body, canvas } = await mountStory(Controlled)
99
+
100
+ // No built-in trigger: the only "Enlarge …" button a `thumbnail`
101
+ // would have added is absent.
102
+ await expect(
103
+ canvas.queryByRole("button", {
104
+ name: /^Enlarge/,
105
+ }),
106
+ ).toBeNull()
107
+
108
+ await userEvent.click(
109
+ canvas.getByRole("button", { name: "View the poster" }),
110
+ )
111
+
112
+ const dialog = expectAgentDrivable(body, {
113
+ name: "THE OUTFIT poster",
114
+ role: "dialog",
115
+ })
116
+
117
+ await userEvent.keyboard("{Escape}")
118
+
119
+ await waitFor(() => {
120
+ expect(body.queryByRole("dialog")).toBeNull()
121
+ })
122
+
123
+ await expect(dialog).not.toBeInTheDocument()
124
+ })
@@ -0,0 +1,143 @@
1
+ import { useVisibility } from "@charcuterie/logic"
2
+ import type { ReactNode } from "react"
3
+
4
+ import { Dialog } from "../Dialog/Dialog.tsx"
5
+ import { toClassName } from "../toClassName.ts"
6
+
7
+ export type LightboxProps = {
8
+ /**
9
+ * Required, and the whole reason this is not a bare `<img>` in a
10
+ * dialog. It names both the enlarged image and — via `heading`'s
11
+ * default below — the dialog itself, and a poster grid of unnamed
12
+ * images is unnavigable rather than merely imperfect. Same rule
13
+ * `MediaTile` makes.
14
+ */
15
+ alt: string
16
+ /** Shown under the enlarged image. A year, a source, a filename. */
17
+ caption?: ReactNode
18
+ /** Applied to the built-in trigger button, not the dialog. */
19
+ className?: string
20
+ /**
21
+ * The dialog's accessible name and visible heading. Defaults to
22
+ * `alt`, which is right when the thumbnail's alt is already the
23
+ * title ("THE OUTFIT poster"); pass it when the two should differ.
24
+ */
25
+ heading?: string
26
+ /**
27
+ * **Controlled** open state. Omit for the uncontrolled default,
28
+ * where the trigger owns it through `useVisibility`. Present means
29
+ * the caller owns it and the built-in trigger becomes a plain
30
+ * button that only *reports* intent through `onOpenChange`.
31
+ */
32
+ isOpen?: boolean
33
+ onOpenChange?: (isOpen: boolean) => void
34
+ src: string
35
+ /**
36
+ * The clickable thumbnail — a smaller `<img>`, usually. Omitted in
37
+ * the controlled, trigger-elsewhere case, where something else in
38
+ * the page opens the lightbox and this renders only the overlay.
39
+ */
40
+ thumbnail?: ReactNode
41
+ }
42
+
43
+ /**
44
+ * A thumbnail that opens its own full-size view — the one image
45
+ * interaction three apps hand-roll and none get the focus trap or
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
+ *
54
+ * ### Controlled and not, one component
55
+ *
56
+ * `useVisibility` runs unconditionally — hooks cannot be conditional
57
+ * — but is *consulted* only when `isOpen` is absent. A caller that
58
+ * passes `isOpen` owns the truth; the internal store is then an
59
+ * unread two-field object, which is cheaper than splitting this into
60
+ * two components that share a Modal body.
61
+ */
62
+ export const Lightbox = ({
63
+ alt,
64
+ caption,
65
+ className,
66
+ heading,
67
+ isOpen,
68
+ onOpenChange,
69
+ src,
70
+ thumbnail,
71
+ }: LightboxProps): ReactNode => {
72
+ const isControlled = isOpen !== undefined
73
+
74
+ const { hide, isVisible, show } = useVisibility()
75
+
76
+ const isShown = isControlled ? isOpen : isVisible
77
+
78
+ const requestOpen = () => {
79
+ if (!isControlled) {
80
+ show()
81
+ }
82
+
83
+ onOpenChange?.(true)
84
+ }
85
+
86
+ const requestClose = () => {
87
+ if (!isControlled) {
88
+ hide()
89
+ }
90
+
91
+ onOpenChange?.(false)
92
+ }
93
+
94
+ return (
95
+ <>
96
+ {thumbnail === undefined ? null : (
97
+ <button
98
+ // The trigger carries the name; the `thumbnail` inside it
99
+ // goes `alt=""`, exactly as `MediaTile` names its link and
100
+ // silences the image within it. A button around an
101
+ // empty-alt image is otherwise `button-name` with nothing
102
+ // to announce.
103
+ aria-label={`Enlarge ${heading ?? alt}`}
104
+ className={toClassName(
105
+ // `cursor-zoom-in` is the affordance a bare `<img>`
106
+ // never had, and the focus ring is the fleet's standard
107
+ // — the same one `MediaTile`'s link wears.
108
+ "group block cursor-zoom-in rounded-md outline-offset-2 focus-visible:outline-solid focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-focus-ring",
109
+ className,
110
+ )}
111
+ onClick={requestOpen}
112
+ type="button"
113
+ >
114
+ {thumbnail}
115
+ </button>
116
+ )}
117
+
118
+ <Dialog
119
+ heading={heading ?? alt}
120
+ isVisible={isShown}
121
+ onClose={requestClose}
122
+ size="xl"
123
+ >
124
+ <figure className="flex flex-col items-center gap-3">
125
+ <img
126
+ alt={alt}
127
+ // `object-contain`, not `cover`: the enlarged view must
128
+ // show the whole image, and the clamp is `dvh` for the
129
+ // same address-bar reason `Modal`'s is.
130
+ className="max-h-[75dvh] w-auto max-w-full rounded-md object-contain"
131
+ src={src}
132
+ />
133
+
134
+ {caption ? (
135
+ <figcaption className="text-center text-content-muted text-sm">
136
+ {caption}
137
+ </figcaption>
138
+ ) : null}
139
+ </figure>
140
+ </Dialog>
141
+ </>
142
+ )
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} />