@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.
- 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/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/Select/Select.d.ts +10 -2
- package/dist/Select/Select.d.ts.map +1 -1
- package/dist/Select/Select.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/slotProps.d.ts +11 -0
- package/dist/slotProps.d.ts.map +1 -1
- package/dist/slotProps.js +16 -0
- package/dist/slotProps.js.map +1 -1
- package/dist/slotWiring.d.ts +32 -0
- package/dist/slotWiring.d.ts.map +1 -0
- package/dist/slotWiring.js +115 -0
- package/dist/slotWiring.js.map +1 -0
- package/package.json +3 -3
- 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/Lightbox/Lightbox.mdx +62 -0
- package/src/Lightbox/Lightbox.stories.tsx +133 -0
- package/src/Lightbox/Lightbox.test.tsx +135 -0
- package/src/Lightbox/Lightbox.tsx +143 -0
- package/src/Menu/Menu.mdx +23 -0
- package/src/Menu/Menu.stories.tsx +67 -0
- package/src/Menu/Menu.test.tsx +113 -2
- package/src/Popover/Popover.mdx +5 -0
- package/src/Select/Select.stories.tsx +10 -2
- package/src/Select/Select.tsx +10 -2
- package/src/icons.storyHelpers.tsx +22 -0
- package/src/index.ts +9 -0
- package/src/slotProps.ts +20 -0
- package/src/slotWiring.ts +173 -0
- 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
|