@charcuterie/ui 1.0.0 → 1.1.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 (51) 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/Lightbox/Lightbox.d.ts +57 -0
  10. package/dist/Lightbox/Lightbox.d.ts.map +1 -0
  11. package/dist/Lightbox/Lightbox.js +50 -0
  12. package/dist/Lightbox/Lightbox.js.map +1 -0
  13. package/dist/Select/Select.d.ts +10 -2
  14. package/dist/Select/Select.d.ts.map +1 -1
  15. package/dist/Select/Select.js.map +1 -1
  16. package/dist/index.d.ts +6 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +3 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/slotProps.d.ts +11 -0
  21. package/dist/slotProps.d.ts.map +1 -1
  22. package/dist/slotProps.js +16 -0
  23. package/dist/slotProps.js.map +1 -1
  24. package/dist/slotWiring.d.ts +32 -0
  25. package/dist/slotWiring.d.ts.map +1 -0
  26. package/dist/slotWiring.js +115 -0
  27. package/dist/slotWiring.js.map +1 -0
  28. package/package.json +3 -3
  29. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.mdx +70 -0
  30. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.stories.tsx +118 -0
  31. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.test.tsx +53 -0
  32. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.tsx +90 -0
  33. package/src/ColorSchemeToggle/ColorSchemeToggle.mdx +48 -0
  34. package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +161 -0
  35. package/src/ColorSchemeToggle/ColorSchemeToggle.test.tsx +80 -0
  36. package/src/ColorSchemeToggle/ColorSchemeToggle.tsx +107 -0
  37. package/src/Lightbox/Lightbox.mdx +62 -0
  38. package/src/Lightbox/Lightbox.stories.tsx +133 -0
  39. package/src/Lightbox/Lightbox.test.tsx +135 -0
  40. package/src/Lightbox/Lightbox.tsx +143 -0
  41. package/src/Menu/Menu.mdx +23 -0
  42. package/src/Menu/Menu.stories.tsx +67 -0
  43. package/src/Menu/Menu.test.tsx +113 -2
  44. package/src/Popover/Popover.mdx +5 -0
  45. package/src/Select/Select.stories.tsx +10 -2
  46. package/src/Select/Select.tsx +10 -2
  47. package/src/icons.storyHelpers.tsx +22 -0
  48. package/src/index.ts +9 -0
  49. package/src/slotProps.ts +20 -0
  50. package/src/slotWiring.ts +173 -0
  51. package/src/sourceRules.test.ts +17 -1
@@ -0,0 +1,107 @@
1
+ import type { ColorSchemeMode } from "@charcuterie/logic"
2
+ import {
3
+ DEFAULT_COLOR_SCHEME_ORDER,
4
+ nextColorSchemeMode,
5
+ } from "@charcuterie/logic"
6
+ import type { ControlSize } from "@charcuterie/tokens"
7
+ import type { ReactNode } from "react"
8
+
9
+ import { IconButton } from "../IconButton/IconButton.tsx"
10
+ import type { IntentAppearance } from "../intentStyles.ts"
11
+
12
+ /**
13
+ * One `ReactNode` per mode. The library ships **no icons** — lucide
14
+ * (ISC) is the fleet recommendation — so an app passes its own
15
+ * `Sun` / `Moon` / `Monitor`, exactly as `IconButton` takes its
16
+ * glyph as `children`
17
+ * ([decision](../../../../docs/decisions/2026-07-29-ship-no-icons-and-no-symbol-glyphs.md)).
18
+ */
19
+ export type ColorSchemeIcons = Record<
20
+ ColorSchemeMode,
21
+ ReactNode
22
+ >
23
+
24
+ export type ColorSchemeToggleProps = {
25
+ appearance?: IntentAppearance
26
+ className?: string
27
+ icons: ColorSchemeIcons
28
+ isDisabled?: boolean
29
+ /**
30
+ * Builds the accessible name from the current and next mode.
31
+ * Default announces the current mode and where a press goes —
32
+ * "Colour scheme: dark. Activate to switch to system." — so the
33
+ * name updates as the control cycles and
34
+ * `getByRole("button", { name: /colour scheme/i })` resolves.
35
+ */
36
+ label?: (
37
+ mode: ColorSchemeMode,
38
+ nextMode: ColorSchemeMode,
39
+ ) => string
40
+ mode: ColorSchemeMode
41
+ /**
42
+ * Called with the next mode on activation. **Controlled**: this
43
+ * component stores nothing — it renders `mode` and reports intent,
44
+ * which is what keeps it free of `matchMedia`/`document` and
45
+ * usable in any runtime. `<ColorSchemeSwitcher>` wires this to the
46
+ * `useColorScheme` hook; a plain consumer wires it to its own
47
+ * state.
48
+ */
49
+ onCycle: (nextMode: ColorSchemeMode) => void
50
+ order?: readonly ColorSchemeMode[]
51
+ size?: ControlSize
52
+ }
53
+
54
+ const MODE_LABEL: Record<ColorSchemeMode, string> = {
55
+ dark: "Dark",
56
+ light: "Light",
57
+ system: "System",
58
+ }
59
+
60
+ const defaultLabel = (
61
+ mode: ColorSchemeMode,
62
+ nextMode: ColorSchemeMode,
63
+ ) =>
64
+ `Colour scheme: ${MODE_LABEL[mode]}. Activate to switch to ${MODE_LABEL[nextMode]}.`
65
+
66
+ /**
67
+ * The cycling colour-scheme button — light → dark → system → light.
68
+ *
69
+ * Layer 2 of the three: presentational and controlled, with no
70
+ * browser coupling. It is an `IconButton`, so it inherits the one
71
+ * rule that makes the fleet agent-drivable — a glyph is not a name,
72
+ * and this control's name states the current mode rather than
73
+ * leaving a screen reader to read a sun path as nothing.
74
+ *
75
+ * A *segmented* all-three-visible form was considered and deferred
76
+ * (owner chose cycling-only, 2026-08-03); `icons` and `order` are
77
+ * shaped so a `ColorSchemePicker` could join later without changing
78
+ * this API.
79
+ */
80
+ export const ColorSchemeToggle = ({
81
+ appearance = "ghost",
82
+ className,
83
+ icons,
84
+ isDisabled = false,
85
+ label = defaultLabel,
86
+ mode,
87
+ onCycle,
88
+ order = DEFAULT_COLOR_SCHEME_ORDER,
89
+ size = "md",
90
+ }: ColorSchemeToggleProps): ReactNode => {
91
+ const nextMode = nextColorSchemeMode(mode, order)
92
+
93
+ return (
94
+ <IconButton
95
+ appearance={appearance}
96
+ className={className}
97
+ isDisabled={isDisabled}
98
+ label={label(mode, nextMode)}
99
+ onClick={() => {
100
+ onCycle(nextMode)
101
+ }}
102
+ size={size}
103
+ >
104
+ {icons[mode]}
105
+ </IconButton>
106
+ )
107
+ }
@@ -0,0 +1,62 @@
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 `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.
17
+
18
+ <Canvas of={LightboxStories.Default} />
19
+
20
+ ## Props
21
+
22
+ <Controls />
23
+
24
+ ## The thumbnail is the trigger, and it carries the name
25
+
26
+ Pass the small image as `thumbnail` and the `Lightbox` wraps it in the button that opens
27
+ the overlay. That button carries the accessible name — `Enlarge {alt}` — so the
28
+ `thumbnail` inside it goes `alt=""`, exactly as `MediaTile` names its link and silences
29
+ the image within it. A button around an empty-alt image is otherwise `button-name` with
30
+ nothing to announce.
31
+
32
+ ```tsx
33
+ <Lightbox
34
+ alt="THE OUTFIT poster"
35
+ src={fullSizePoster}
36
+ thumbnail={<img alt="" className="h-40 w-28 object-cover" src={thumbSrc} />}
37
+ />
38
+ ```
39
+
40
+ `alt` also names the dialog, through `heading`'s default. Pass `heading` when the two
41
+ should differ — a filename overlay whose thumbnail alt is already the title.
42
+
43
+ <Canvas of={LightboxStories.WithCaption} />
44
+
45
+ ## Controlled, when something else opens it
46
+
47
+ Omit `thumbnail` and pass `isOpen` / `onOpenChange`, and the `Lightbox` renders only the
48
+ overlay — a button elsewhere on the page owns the open state through its own
49
+ `useVisibility`. The internal store still runs (hooks cannot be conditional) but goes
50
+ unread; the caller is the one truth.
51
+
52
+ <Canvas of={LightboxStories.Controlled} />
53
+
54
+ ## What it inherits, and does not re-test
55
+
56
+ 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.
61
+
62
+ <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 `Modal` underneath, and two dialogs in the top layer
70
+ * side by side is not a thing the platform can do.
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={(isOpen) => {
110
+ if (!isOpen) {
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 `Modal` 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,135 @@
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
+ /**
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
+ test("the thumbnail opens the enlarged image", async () => {
28
+ const { canvas, canvasElement } =
29
+ await mountStory(Default)
30
+
31
+ await userEvent.click(
32
+ expectAgentDrivable(canvas, {
33
+ name: "Enlarge THE OUTFIT poster",
34
+ role: "button",
35
+ }),
36
+ )
37
+
38
+ const dialog = expectAgentDrivable(canvas, {
39
+ name: "THE OUTFIT poster",
40
+ role: "dialog",
41
+ })
42
+
43
+ await expect(dialog).toHaveAttribute("open")
44
+
45
+ // The enlarged image carries the name — a bare `<img>` in a dialog
46
+ // otherwise announces as nothing.
47
+ expectAgentDrivable(canvas, {
48
+ name: "THE OUTFIT poster",
49
+ role: "img",
50
+ })
51
+
52
+ // The open dialog audits itself: the empty-alt thumbnail, the
53
+ // named trigger and the named enlarged image are the arrangement
54
+ // most likely to trip `button-name` or a duplicate name.
55
+ await expectNoAxeViolations(canvasElement)
56
+ })
57
+
58
+ test("the caption rides along with the enlarged image", async () => {
59
+ const { canvas } = await mountStory(WithCaption)
60
+
61
+ await userEvent.click(
62
+ canvas.getByRole("button", {
63
+ name: "Enlarge THE OUTFIT poster",
64
+ }),
65
+ )
66
+
67
+ await expect(
68
+ canvas.getByText("2022 · Blu-ray · 1080p"),
69
+ ).toBeInTheDocument()
70
+ })
71
+
72
+ /**
73
+ * The keyboard contract, inherited from `Modal` and asserted here so
74
+ * the skin cannot quietly drop it — Escape routes through `onClose`,
75
+ * and focus returns to the trigger the platform restored it to.
76
+ */
77
+ test("closing restores focus to the thumbnail trigger", async () => {
78
+ const { canvas } = await mountStory(Interactive)
79
+
80
+ const trigger = expectAgentDrivable(canvas, {
81
+ name: "Enlarge THE OUTFIT poster",
82
+ role: "button",
83
+ })
84
+
85
+ await userEvent.click(trigger)
86
+
87
+ const dialog = expectAgentDrivable(canvas, {
88
+ name: "THE OUTFIT poster",
89
+ role: "dialog",
90
+ })
91
+
92
+ await expect(dialog).toContainElement(
93
+ document.activeElement as HTMLElement,
94
+ )
95
+
96
+ requestClose(dialog)
97
+
98
+ await waitFor(() => {
99
+ expect(canvas.queryByRole("dialog")).toBeNull()
100
+ })
101
+
102
+ await expect(document.activeElement).toBe(trigger)
103
+ })
104
+
105
+ /**
106
+ * Controlled: the caller's `useVisibility` is the one truth, and the
107
+ * `Lightbox` renders no trigger of its own — a button elsewhere
108
+ * opens it, and closing it flows back through `onOpenChange`.
109
+ */
110
+ test("a controlled lightbox opens from an outside button", async () => {
111
+ const { canvas } = await mountStory(Controlled)
112
+
113
+ // No built-in trigger: the only "Enlarge …" button a `thumbnail`
114
+ // would have added is absent.
115
+ await expect(
116
+ canvas.queryByRole("button", {
117
+ name: /^Enlarge/,
118
+ }),
119
+ ).toBeNull()
120
+
121
+ await userEvent.click(
122
+ canvas.getByRole("button", { name: "View the poster" }),
123
+ )
124
+
125
+ const dialog = expectAgentDrivable(canvas, {
126
+ name: "THE OUTFIT poster",
127
+ role: "dialog",
128
+ })
129
+
130
+ requestClose(dialog)
131
+
132
+ await waitFor(() => {
133
+ expect(canvas.queryByRole("dialog")).toBeNull()
134
+ })
135
+ })
@@ -0,0 +1,143 @@
1
+ import { useVisibility } from "@charcuterie/logic"
2
+ import type { ReactNode } from "react"
3
+
4
+ import { Modal } from "../Modal/Modal.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 `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.
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
+ <Modal
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
+ </Modal>
141
+ </>
142
+ )
143
+ }
package/src/Menu/Menu.mdx CHANGED
@@ -101,3 +101,26 @@ a keystroke buffer and a timer in a component that has neither, for a menu you c
101
101
  three arrow presses.
102
102
 
103
103
  <Canvas of={MenuStories.Interactive} />
104
+
105
+ ## One trigger, two slots
106
+
107
+ A `Menu` and a `Tooltip` on the same button — the obvious thing to write, and until 1.0.1
108
+ it did not work.
109
+
110
+ Both components clone onto the trigger and both hand it a floating-ui
111
+ `refs.setReference` — the element each of them positions its panel against. Every merge in
112
+ the library was last-write-wins, so the inner `Tooltip` wrote its ref and the `Menu`'s was
113
+ gone. A menu with no reference element is not an error: floating-ui simply leaves the panel
114
+ at `left: 0; top: 0`, in the corner of the viewport, which reads as a CSS bug.
115
+
116
+ A `ref` is a subscription and an `on*` is a listener, and neither survives being replaced.
117
+ So both are **merged** now — between two slots by `mergeSlotWiring`, and between a slot and
118
+ the element you wrote by `useClonedChild`, which is what makes
119
+ `<Menu trigger={<Button ref={buttonRef} onClick={toggle} />} />` keep your ref *and* your
120
+ handler.
121
+
122
+ `SharedTrigger` is padded so the trigger sits away from the viewport origin: at the corner,
123
+ an anchored panel and an unanchored one land in the same place, and the test asserts that
124
+ padding is still there before it asserts anything else.
125
+
126
+ <Canvas of={MenuStories.SharedTrigger} />
@@ -13,6 +13,7 @@ import {
13
13
  SettingsIcon,
14
14
  UndoIcon,
15
15
  } from "../icons.storyHelpers.tsx"
16
+ import { Tooltip } from "../Tooltip/Tooltip.tsx"
16
17
  import type { MenuItem } from "./Menu.tsx"
17
18
  import { Menu } from "./Menu.tsx"
18
19
 
@@ -72,6 +73,40 @@ const MenuHarness = ({
72
73
  )
73
74
  }
74
75
 
76
+ /**
77
+ * The same harness, with a `Tooltip` between the `Menu` and the
78
+ * button — two components cloning onto **one** trigger, which is what
79
+ * image-viewer wanted and could not have.
80
+ */
81
+ const TooltipTriggerHarness = ({
82
+ isInitiallyVisible = false,
83
+ tip,
84
+ triggerLabel,
85
+ }: {
86
+ isInitiallyVisible?: boolean
87
+ tip: string
88
+ triggerLabel: string
89
+ }): ReactNode => {
90
+ const { hide, isVisible, toggle } = useVisibility({
91
+ isVisible: isInitiallyVisible,
92
+ })
93
+
94
+ return (
95
+ <Menu
96
+ isVisible={isVisible}
97
+ items={BAY_ACTIONS}
98
+ onDismiss={hide}
99
+ trigger={
100
+ <Tooltip label={tip}>
101
+ <Button appearance="outline" onClick={toggle}>
102
+ {triggerLabel}
103
+ </Button>
104
+ </Tooltip>
105
+ }
106
+ />
107
+ )
108
+ }
109
+
75
110
  const meta = {
76
111
  title: "Components/Menu",
77
112
  component: Menu,
@@ -143,6 +178,38 @@ export const AllStates: Story = {
143
178
  ),
144
179
  }
145
180
 
181
+ /**
182
+ * **One trigger, two slots.** `Menu` and `Tooltip` both clone onto
183
+ * the button and both hand it a `ref` — floating-ui's
184
+ * `refs.setReference`, which is the anchor each of them positions
185
+ * against. Until 1.0.1 the second clone's ref simply replaced the
186
+ * first's, so the panel it belonged to had no anchor and rendered in
187
+ * the top-left corner of the viewport.
188
+ *
189
+ * Hover the button and open the menu: the tip and the panel are both
190
+ * attached to it.
191
+ */
192
+ export const SharedTrigger: Story = {
193
+ args: {
194
+ isVisible: false,
195
+ onDismiss: noop,
196
+ trigger: <Button appearance="outline">Actions</Button>,
197
+ },
198
+ render: () => (
199
+ // Padded, and the padding is load-bearing rather than pretty: an
200
+ // anchor that has been dropped leaves floating-ui at
201
+ // `left: 0; top: 0`, so a trigger sitting in the corner of the
202
+ // viewport is a trigger where "anchored" and "not anchored" look
203
+ // identical. `Menu.test.tsx` asserts the padding is still here.
204
+ <div className="p-16">
205
+ <TooltipTriggerHarness
206
+ tip="Everything that can be done to this bay."
207
+ triggerLabel="Bay 5"
208
+ />
209
+ </div>
210
+ ),
211
+ }
212
+
146
213
  /**
147
214
  * Open it and arrow down. Focus lands on the first item **on
148
215
  * opening** — a menu that leaves the caret on the trigger is one a