@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.
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts +35 -0
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts.map +1 -0
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js +33 -0
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js.map +1 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts +54 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts.map +1 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.js +30 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.js.map +1 -0
- package/dist/Combobox/Combobox.d.ts +54 -0
- package/dist/Combobox/Combobox.d.ts.map +1 -0
- package/dist/Combobox/Combobox.js +216 -0
- package/dist/Combobox/Combobox.js.map +1 -0
- package/dist/Combobox/ComboboxOption.d.ts +36 -0
- package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
- package/dist/Combobox/ComboboxOption.js +32 -0
- package/dist/Combobox/ComboboxOption.js.map +1 -0
- package/dist/Dialog/Dialog.d.ts +47 -0
- package/dist/Dialog/Dialog.d.ts.map +1 -0
- package/dist/Dialog/Dialog.js +52 -0
- package/dist/Dialog/Dialog.js.map +1 -0
- package/dist/Lightbox/Lightbox.d.ts +57 -0
- package/dist/Lightbox/Lightbox.d.ts.map +1 -0
- package/dist/Lightbox/Lightbox.js +50 -0
- package/dist/Lightbox/Lightbox.js.map +1 -0
- package/dist/Listbox/Listbox.d.ts +56 -0
- package/dist/Listbox/Listbox.d.ts.map +1 -0
- package/dist/Listbox/Listbox.js +151 -0
- package/dist/Listbox/Listbox.js.map +1 -0
- package/dist/Listbox/ListboxOption.d.ts +36 -0
- package/dist/Listbox/ListboxOption.d.ts.map +1 -0
- package/dist/Listbox/ListboxOption.js +42 -0
- package/dist/Listbox/ListboxOption.js.map +1 -0
- package/dist/Menu/Menu.d.ts.map +1 -1
- package/dist/Menu/Menu.js +47 -97
- package/dist/Menu/Menu.js.map +1 -1
- package/dist/Modal/Modal.d.ts +11 -59
- package/dist/Modal/Modal.d.ts.map +1 -1
- package/dist/Modal/Modal.js +28 -101
- package/dist/Modal/Modal.js.map +1 -1
- package/dist/Overlay/OverlayPanel.d.ts +50 -0
- package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
- package/dist/Overlay/OverlayPanel.js +72 -0
- package/dist/Overlay/OverlayPanel.js.map +1 -0
- package/dist/Overlay/OverlayStack.d.ts +47 -0
- package/dist/Overlay/OverlayStack.d.ts.map +1 -0
- package/dist/Overlay/OverlayStack.js +46 -0
- package/dist/Overlay/OverlayStack.js.map +1 -0
- package/dist/Overlay/SharedBackdrop.d.ts +24 -0
- package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
- package/dist/Overlay/SharedBackdrop.js +28 -0
- package/dist/Overlay/SharedBackdrop.js.map +1 -0
- package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
- package/dist/Overlay/lockScrollBehind.js.map +1 -0
- package/dist/Overlay/overlayPanelClass.d.ts +17 -0
- package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
- package/dist/Overlay/overlayPanelClass.js +17 -0
- package/dist/Overlay/overlayPanelClass.js.map +1 -0
- package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
- package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
- package/dist/Overlay/useAnchoredOverlay.js +81 -0
- package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
- package/dist/Popover/Popover.d.ts +16 -24
- package/dist/Popover/Popover.d.ts.map +1 -1
- package/dist/Popover/Popover.js +32 -68
- package/dist/Popover/Popover.js.map +1 -1
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +25 -25
- package/dist/Tooltip/Tooltip.js.map +1 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/mountStory.testHelpers.d.ts +1 -0
- package/dist/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +21 -1
- package/dist/mountStory.testHelpers.js.map +1 -1
- package/package.json +5 -4
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.mdx +70 -0
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.stories.tsx +118 -0
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.test.tsx +53 -0
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.tsx +90 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.mdx +48 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +161 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.test.tsx +80 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.tsx +107 -0
- package/src/Combobox/Combobox.mdx +71 -0
- package/src/Combobox/Combobox.stories.tsx +245 -0
- package/src/Combobox/Combobox.test.tsx +289 -0
- package/src/Combobox/Combobox.tsx +513 -0
- package/src/Combobox/ComboboxOption.tsx +88 -0
- package/src/Dialog/Dialog.mdx +87 -0
- package/src/Dialog/Dialog.stories.tsx +347 -0
- package/src/Dialog/Dialog.test.tsx +312 -0
- package/src/Dialog/Dialog.tsx +127 -0
- package/src/Field/Field.test.tsx +6 -4
- package/src/Lightbox/Lightbox.mdx +63 -0
- package/src/Lightbox/Lightbox.stories.tsx +133 -0
- package/src/Lightbox/Lightbox.test.tsx +124 -0
- package/src/Lightbox/Lightbox.tsx +143 -0
- package/src/Listbox/Listbox.mdx +68 -0
- package/src/Listbox/Listbox.stories.tsx +205 -0
- package/src/Listbox/Listbox.test.tsx +198 -0
- package/src/Listbox/Listbox.tsx +295 -0
- package/src/Listbox/ListboxOption.tsx +104 -0
- package/src/Menu/Menu.mdx +11 -13
- package/src/Menu/Menu.test.tsx +47 -38
- package/src/Menu/Menu.tsx +81 -142
- package/src/Modal/Modal.mdx +29 -82
- package/src/Modal/Modal.stories.tsx +55 -188
- package/src/Modal/Modal.test.tsx +44 -177
- package/src/Modal/Modal.tsx +54 -204
- package/src/Overlay/OverlayPanel.tsx +195 -0
- package/src/Overlay/OverlayStack.test.tsx +235 -0
- package/src/Overlay/OverlayStack.tsx +132 -0
- package/src/Overlay/SharedBackdrop.tsx +40 -0
- package/src/Overlay/overlayPanelClass.ts +17 -0
- package/src/Overlay/useAnchoredOverlay.ts +197 -0
- package/src/Popover/Popover.mdx +17 -20
- package/src/Popover/Popover.stories.tsx +33 -0
- package/src/Popover/Popover.test.tsx +68 -32
- package/src/Popover/Popover.tsx +56 -95
- package/src/Tooltip/Tooltip.mdx +12 -9
- package/src/Tooltip/Tooltip.test.tsx +17 -19
- package/src/Tooltip/Tooltip.tsx +35 -32
- package/src/icons.storyHelpers.tsx +22 -0
- package/src/index.ts +22 -4
- package/src/mountStory.testHelpers.ts +25 -1
- package/src/sourceRules.test.ts +26 -1
- package/src/storyControls.test.ts +25 -12
- package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
- package/dist/Modal/lockScrollBehind.js.map +0 -1
- /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
- /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
- /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
|
+
}
|
package/src/Field/Field.test.tsx
CHANGED
|
@@ -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(
|
|
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(
|
|
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} />
|