@charcuterie/ui 1.0.1 → 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/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/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/icons.storyHelpers.tsx +22 -0
- package/src/index.ts +9 -0
- package/src/sourceRules.test.ts +17 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ColorSchemeApplier,
|
|
3
|
+
ColorSchemePersistence,
|
|
4
|
+
ColorSchemeResolver,
|
|
5
|
+
} from "@charcuterie/logic"
|
|
6
|
+
import { useColorScheme } from "@charcuterie/logic"
|
|
7
|
+
import {
|
|
8
|
+
dataSchemeApplier,
|
|
9
|
+
localStoragePersistence,
|
|
10
|
+
matchMediaResolver,
|
|
11
|
+
} from "@charcuterie/logic/browser"
|
|
12
|
+
import type { ReactNode } from "react"
|
|
13
|
+
import { useState } from "react"
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
ColorSchemeIcons,
|
|
17
|
+
ColorSchemeToggleProps,
|
|
18
|
+
} from "../ColorSchemeToggle/ColorSchemeToggle.tsx"
|
|
19
|
+
import { ColorSchemeToggle } from "../ColorSchemeToggle/ColorSchemeToggle.tsx"
|
|
20
|
+
|
|
21
|
+
export type ColorSchemeSwitcherProps = Pick<
|
|
22
|
+
ColorSchemeToggleProps,
|
|
23
|
+
| "appearance"
|
|
24
|
+
| "className"
|
|
25
|
+
| "isDisabled"
|
|
26
|
+
| "label"
|
|
27
|
+
| "order"
|
|
28
|
+
| "size"
|
|
29
|
+
> & {
|
|
30
|
+
/** Override where the resolved scheme is written. Default `<html>`. */
|
|
31
|
+
apply?: ColorSchemeApplier
|
|
32
|
+
icons: ColorSchemeIcons
|
|
33
|
+
/** Override persistence. Default `localStorage` under `storageKey`. */
|
|
34
|
+
persistence?: ColorSchemePersistence
|
|
35
|
+
/**
|
|
36
|
+
* Override the OS resolver. Default `matchMedia`. This is the seam
|
|
37
|
+
* the image-viewer passes Electron `nativeTheme` into, and a
|
|
38
|
+
* future React-Native app passes `Appearance` — the whole reason
|
|
39
|
+
* the browser dependency is isolated to this one layer.
|
|
40
|
+
*/
|
|
41
|
+
resolver?: ColorSchemeResolver
|
|
42
|
+
storageKey?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The connected colour-scheme switcher — Layer 3, and the only
|
|
47
|
+
* layer that touches the browser.
|
|
48
|
+
*
|
|
49
|
+
* It is a thin wrapper: it wires `useColorScheme` to the three
|
|
50
|
+
* browser defaults from `@charcuterie/logic/browser`
|
|
51
|
+
* (`matchMedia` + `localStorage` + `data-scheme` on `<html>`), and
|
|
52
|
+
* renders the presentational `ColorSchemeToggle`. Every seam is
|
|
53
|
+
* overridable, so a non-browser host swaps `resolver`/`persistence`/
|
|
54
|
+
* `apply` and the same component drives Electron or React-Native.
|
|
55
|
+
*
|
|
56
|
+
* The seams are built once (lazy `useState`) so the `matchMedia`
|
|
57
|
+
* query and the core survive re-renders and StrictMode's double
|
|
58
|
+
* mount.
|
|
59
|
+
*/
|
|
60
|
+
export const ColorSchemeSwitcher = ({
|
|
61
|
+
apply,
|
|
62
|
+
icons,
|
|
63
|
+
order,
|
|
64
|
+
persistence,
|
|
65
|
+
resolver,
|
|
66
|
+
storageKey,
|
|
67
|
+
...toggleProps
|
|
68
|
+
}: ColorSchemeSwitcherProps): ReactNode => {
|
|
69
|
+
const [seams] = useState(() => ({
|
|
70
|
+
apply: apply ?? dataSchemeApplier(),
|
|
71
|
+
persistence:
|
|
72
|
+
persistence ?? localStoragePersistence(storageKey),
|
|
73
|
+
resolver: resolver ?? matchMediaResolver(),
|
|
74
|
+
}))
|
|
75
|
+
|
|
76
|
+
const { cycle, mode } = useColorScheme({
|
|
77
|
+
...seams,
|
|
78
|
+
order,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<ColorSchemeToggle
|
|
83
|
+
{...toggleProps}
|
|
84
|
+
icons={icons}
|
|
85
|
+
mode={mode}
|
|
86
|
+
onCycle={cycle}
|
|
87
|
+
order={order}
|
|
88
|
+
/>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as ColorSchemeToggleStories from "./ColorSchemeToggle.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={ColorSchemeToggleStories} />
|
|
6
|
+
|
|
7
|
+
# ColorSchemeToggle
|
|
8
|
+
|
|
9
|
+
The cycling colour-scheme button — **light → dark → system → light**. Layer 2 of three:
|
|
10
|
+
presentational and **controlled**, with no `matchMedia`, `localStorage`, or `document` in
|
|
11
|
+
sight. It renders the current `mode` and reports the next one through `onCycle`; it stores
|
|
12
|
+
nothing.
|
|
13
|
+
|
|
14
|
+
That split is deliberate. The browser dependency lives one layer up in
|
|
15
|
+
[`ColorSchemeSwitcher`](?path=/docs/components-colorschemeswitcher--docs), so this component
|
|
16
|
+
runs unchanged in any runtime — a plain React app wires `onCycle` to `useState`, an Electron
|
|
17
|
+
or React-Native host wires it to `useColorScheme` with its own resolver.
|
|
18
|
+
|
|
19
|
+
<Canvas of={ColorSchemeToggleStories.Default} />
|
|
20
|
+
|
|
21
|
+
## Props
|
|
22
|
+
|
|
23
|
+
<Controls />
|
|
24
|
+
|
|
25
|
+
## `mode` vs scheme
|
|
26
|
+
|
|
27
|
+
`mode` is the three-way user preference — `"light" | "dark" | "system"`. It is **not** a
|
|
28
|
+
`data-scheme` value: `data-scheme` only ever holds `"light"` or `"dark"`. Resolving
|
|
29
|
+
`system` to a concrete scheme is the connected layer's job, not this one's.
|
|
30
|
+
|
|
31
|
+
## Icons are yours
|
|
32
|
+
|
|
33
|
+
The library ships **no icons**
|
|
34
|
+
([decision](?path=/docs/foundations-decisions--docs)) — `icons` is a `ReactNode` per mode,
|
|
35
|
+
and an app passes lucide `Sun` / `Moon` / `Monitor` (the fleet recommendation). The glyph is
|
|
36
|
+
`aria-hidden`; the button's name comes from `label`, which states the current mode so the
|
|
37
|
+
control is agent-drivable and a screen reader that lands on it hears "Colour scheme: system"
|
|
38
|
+
rather than a nameless button.
|
|
39
|
+
|
|
40
|
+
<Canvas of={ColorSchemeToggleStories.AllModes} />
|
|
41
|
+
|
|
42
|
+
## Cycling is the only form, for now
|
|
43
|
+
|
|
44
|
+
A segmented, all-three-visible `ColorSchemePicker` was considered and deferred (owner chose
|
|
45
|
+
cycling-only, 2026-08-03). `icons` and `order` are shaped so that form can be added later
|
|
46
|
+
without changing this API.
|
|
47
|
+
|
|
48
|
+
<Canvas of={ColorSchemeToggleStories.Sizes} />
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { ColorSchemeMode } from "@charcuterie/logic"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
|
|
5
|
+
import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
|
|
6
|
+
import {
|
|
7
|
+
ContainerBoard,
|
|
8
|
+
StoryCell,
|
|
9
|
+
StoryGrid,
|
|
10
|
+
StoryRow,
|
|
11
|
+
StorySection,
|
|
12
|
+
} from "../board.storyHelpers.tsx"
|
|
13
|
+
import {
|
|
14
|
+
MonitorIcon,
|
|
15
|
+
MoonIcon,
|
|
16
|
+
SunIcon,
|
|
17
|
+
} from "../icons.storyHelpers.tsx"
|
|
18
|
+
import type { ColorSchemeIcons } from "./ColorSchemeToggle.tsx"
|
|
19
|
+
import { ColorSchemeToggle } from "./ColorSchemeToggle.tsx"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The library ships **no icons** — these are the story-only
|
|
23
|
+
* hand-drawn SVGs an app would replace with lucide `Sun` / `Moon` /
|
|
24
|
+
* `Monitor`.
|
|
25
|
+
*/
|
|
26
|
+
const ICONS: ColorSchemeIcons = {
|
|
27
|
+
dark: <MoonIcon />,
|
|
28
|
+
light: <SunIcon />,
|
|
29
|
+
system: <MonitorIcon />,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const meta = {
|
|
33
|
+
title: "Components/ColorSchemeToggle",
|
|
34
|
+
component: ColorSchemeToggle,
|
|
35
|
+
parameters: { layout: "padded" },
|
|
36
|
+
argTypes: {
|
|
37
|
+
// `mode` is `ColorSchemeMode` from @charcuterie/logic — a bare
|
|
38
|
+
// specifier docgen cannot enumerate, so it gets an explicit
|
|
39
|
+
// radio rather than falling through to the object control.
|
|
40
|
+
mode: {
|
|
41
|
+
control: "radio",
|
|
42
|
+
options: ["light", "dark", "system"],
|
|
43
|
+
},
|
|
44
|
+
size: controlSizeArgType,
|
|
45
|
+
},
|
|
46
|
+
args: {
|
|
47
|
+
appearance: "ghost",
|
|
48
|
+
icons: ICONS,
|
|
49
|
+
isDisabled: false,
|
|
50
|
+
mode: "system",
|
|
51
|
+
onCycle: () => {},
|
|
52
|
+
size: "md",
|
|
53
|
+
},
|
|
54
|
+
} satisfies Meta<typeof ColorSchemeToggle>
|
|
55
|
+
|
|
56
|
+
export default meta
|
|
57
|
+
|
|
58
|
+
type Story = StoryObj<typeof meta>
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Controlled and self-contained: this story owns the mode in local
|
|
62
|
+
* state and advances it on each press, which is what a plain
|
|
63
|
+
* consumer wires up. `<ColorSchemeSwitcher>` wires the same
|
|
64
|
+
* `onCycle` to the `useColorScheme` hook instead.
|
|
65
|
+
*
|
|
66
|
+
* The accessible name states the current mode and the next one, so
|
|
67
|
+
* it changes as the button cycles — that is what the test asserts.
|
|
68
|
+
*/
|
|
69
|
+
export const Default: Story = {
|
|
70
|
+
render: (toggleProps) => {
|
|
71
|
+
const [mode, setMode] =
|
|
72
|
+
useState<ColorSchemeMode>("system")
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<ColorSchemeToggle
|
|
76
|
+
{...toggleProps}
|
|
77
|
+
mode={mode}
|
|
78
|
+
onCycle={setMode}
|
|
79
|
+
/>
|
|
80
|
+
)
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Each mode's icon, held fixed so the three glyphs are visible at
|
|
86
|
+
* once — light is the sun, dark the moon, system the monitor.
|
|
87
|
+
*/
|
|
88
|
+
export const AllModes: Story = {
|
|
89
|
+
render: (toggleProps) => (
|
|
90
|
+
<StorySection title="light → dark → system, the order a press cycles through.">
|
|
91
|
+
<StoryGrid columns={3}>
|
|
92
|
+
<StoryCell label="light">
|
|
93
|
+
<ColorSchemeToggle
|
|
94
|
+
{...toggleProps}
|
|
95
|
+
mode="light"
|
|
96
|
+
onCycle={() => {}}
|
|
97
|
+
/>
|
|
98
|
+
</StoryCell>
|
|
99
|
+
|
|
100
|
+
<StoryCell label="dark">
|
|
101
|
+
<ColorSchemeToggle
|
|
102
|
+
{...toggleProps}
|
|
103
|
+
mode="dark"
|
|
104
|
+
onCycle={() => {}}
|
|
105
|
+
/>
|
|
106
|
+
</StoryCell>
|
|
107
|
+
|
|
108
|
+
<StoryCell label="system">
|
|
109
|
+
<ColorSchemeToggle
|
|
110
|
+
{...toggleProps}
|
|
111
|
+
mode="system"
|
|
112
|
+
onCycle={() => {}}
|
|
113
|
+
/>
|
|
114
|
+
</StoryCell>
|
|
115
|
+
</StoryGrid>
|
|
116
|
+
</StorySection>
|
|
117
|
+
),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const Sizes: Story = {
|
|
121
|
+
render: (toggleProps) => (
|
|
122
|
+
<StorySection title="Square on the control height, so it lines up with the buttons beside it in a toolbar.">
|
|
123
|
+
<StoryRow>
|
|
124
|
+
<ColorSchemeToggle
|
|
125
|
+
{...toggleProps}
|
|
126
|
+
mode="light"
|
|
127
|
+
onCycle={() => {}}
|
|
128
|
+
size="sm"
|
|
129
|
+
/>
|
|
130
|
+
|
|
131
|
+
<ColorSchemeToggle
|
|
132
|
+
{...toggleProps}
|
|
133
|
+
mode="dark"
|
|
134
|
+
onCycle={() => {}}
|
|
135
|
+
size="md"
|
|
136
|
+
/>
|
|
137
|
+
|
|
138
|
+
<ColorSchemeToggle
|
|
139
|
+
{...toggleProps}
|
|
140
|
+
mode="system"
|
|
141
|
+
onCycle={() => {}}
|
|
142
|
+
size="lg"
|
|
143
|
+
/>
|
|
144
|
+
</StoryRow>
|
|
145
|
+
</StorySection>
|
|
146
|
+
),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const Responsive: Story = {
|
|
150
|
+
render: (toggleProps) => (
|
|
151
|
+
<ContainerBoard>
|
|
152
|
+
<StoryRow>
|
|
153
|
+
<ColorSchemeToggle
|
|
154
|
+
{...toggleProps}
|
|
155
|
+
mode="system"
|
|
156
|
+
onCycle={() => {}}
|
|
157
|
+
/>
|
|
158
|
+
</StoryRow>
|
|
159
|
+
</ContainerBoard>
|
|
160
|
+
),
|
|
161
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { userEvent } 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 "./ColorSchemeToggle.stories.tsx"
|
|
9
|
+
|
|
10
|
+
const { AllModes, Default } = composeStories(stories)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The name states the current mode — a screen reader that lands on
|
|
14
|
+
* the control hears "Colour scheme: system", not a nameless button,
|
|
15
|
+
* and an agent can find it.
|
|
16
|
+
*/
|
|
17
|
+
test("the toggle is named for its current mode", async () => {
|
|
18
|
+
const { canvas } = await mountStory(Default)
|
|
19
|
+
|
|
20
|
+
expectAgentDrivable(canvas, {
|
|
21
|
+
name: /colour scheme: system/i,
|
|
22
|
+
role: "button",
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("a press cycles the mode and updates the name", async () => {
|
|
27
|
+
const { canvas } = await mountStory(Default)
|
|
28
|
+
|
|
29
|
+
const button = expectAgentDrivable(canvas, {
|
|
30
|
+
name: /colour scheme: system/i,
|
|
31
|
+
role: "button",
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// system → light
|
|
35
|
+
await userEvent.click(button)
|
|
36
|
+
|
|
37
|
+
expectAgentDrivable(canvas, {
|
|
38
|
+
name: /colour scheme: light/i,
|
|
39
|
+
role: "button",
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// light → dark
|
|
43
|
+
await userEvent.click(button)
|
|
44
|
+
|
|
45
|
+
expectAgentDrivable(canvas, {
|
|
46
|
+
name: /colour scheme: dark/i,
|
|
47
|
+
role: "button",
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("the icon is hidden from assistive tech; the name carries it", async () => {
|
|
52
|
+
const { canvas, canvasElement } =
|
|
53
|
+
await mountStory(Default)
|
|
54
|
+
|
|
55
|
+
expectAgentDrivable(canvas, {
|
|
56
|
+
name: /colour scheme/i,
|
|
57
|
+
role: "button",
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
await expectNoAxeViolations(canvasElement)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("each mode renders its own control", async () => {
|
|
64
|
+
const { canvas } = await mountStory(AllModes)
|
|
65
|
+
|
|
66
|
+
expectAgentDrivable(canvas, {
|
|
67
|
+
name: /colour scheme: light/i,
|
|
68
|
+
role: "button",
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
expectAgentDrivable(canvas, {
|
|
72
|
+
name: /colour scheme: dark/i,
|
|
73
|
+
role: "button",
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
expectAgentDrivable(canvas, {
|
|
77
|
+
name: /colour scheme: system/i,
|
|
78
|
+
role: "button",
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -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
|
+
}
|