@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,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
|
+
}
|
|
@@ -104,6 +104,28 @@ export const ImageOffIcon = (): ReactNode => (
|
|
|
104
104
|
</svg>
|
|
105
105
|
)
|
|
106
106
|
|
|
107
|
+
export const SunIcon = (): ReactNode => (
|
|
108
|
+
<svg {...iconProps} aria-hidden="true">
|
|
109
|
+
<circle cx="12" cy="12" r="4" />
|
|
110
|
+
|
|
111
|
+
<path d="M12 2v2M12 20v2M4 12H2M22 12h-2M5.6 5.6 4.2 4.2M19.8 19.8l-1.4-1.4M18.4 5.6l1.4-1.4M4.2 19.8l1.4-1.4" />
|
|
112
|
+
</svg>
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
export const MoonIcon = (): ReactNode => (
|
|
116
|
+
<svg {...iconProps} aria-hidden="true">
|
|
117
|
+
<path d="M20 14.5A8 8 0 1 1 9.5 4a6.2 6.2 0 0 0 10.5 10.5z" />
|
|
118
|
+
</svg>
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
export const MonitorIcon = (): ReactNode => (
|
|
122
|
+
<svg {...iconProps} aria-hidden="true">
|
|
123
|
+
<rect height="12" rx="2" width="18" x="3" y="4.5" />
|
|
124
|
+
|
|
125
|
+
<path d="M8 20.5h8M12 16.5v4" />
|
|
126
|
+
</svg>
|
|
127
|
+
)
|
|
128
|
+
|
|
107
129
|
export const DotIcon = (): ReactNode => (
|
|
108
130
|
<svg
|
|
109
131
|
{...iconProps}
|
package/src/index.ts
CHANGED
|
@@ -52,6 +52,13 @@ export type {
|
|
|
52
52
|
CardSurface,
|
|
53
53
|
} from "./Card/Card.tsx"
|
|
54
54
|
export { Card } from "./Card/Card.tsx"
|
|
55
|
+
export type { ColorSchemeSwitcherProps } from "./ColorSchemeSwitcher/ColorSchemeSwitcher.tsx"
|
|
56
|
+
export { ColorSchemeSwitcher } from "./ColorSchemeSwitcher/ColorSchemeSwitcher.tsx"
|
|
57
|
+
export type {
|
|
58
|
+
ColorSchemeIcons,
|
|
59
|
+
ColorSchemeToggleProps,
|
|
60
|
+
} from "./ColorSchemeToggle/ColorSchemeToggle.tsx"
|
|
61
|
+
export { ColorSchemeToggle } from "./ColorSchemeToggle/ColorSchemeToggle.tsx"
|
|
55
62
|
export type { BadgeSize } from "./controlStyles.ts"
|
|
56
63
|
export {
|
|
57
64
|
BADGE_SIZE_CLASS,
|
|
@@ -83,6 +90,8 @@ export {
|
|
|
83
90
|
INTENT_HOVER_CLASS,
|
|
84
91
|
INTENT_SOLID_FILL_CLASS,
|
|
85
92
|
} from "./intentStyles.ts"
|
|
93
|
+
export type { LightboxProps } from "./Lightbox/Lightbox.tsx"
|
|
94
|
+
export { Lightbox } from "./Lightbox/Lightbox.tsx"
|
|
86
95
|
export type {
|
|
87
96
|
LiveStatusIndicatorProps,
|
|
88
97
|
LiveStatusIndicatorSize,
|
package/src/sourceRules.test.ts
CHANGED
|
@@ -290,7 +290,12 @@ test("the barrel is the only place components are re-exported", async () => {
|
|
|
290
290
|
// directory because a region with no toasts in it is not a thing
|
|
291
291
|
// anybody renders. It is therefore outside this count by the same
|
|
292
292
|
// rule, and the barrel assertion below does not reach it.
|
|
293
|
-
|
|
293
|
+
//
|
|
294
|
+
// 2026-08-03 adds the two colour-scheme controls — `ColorSchemeToggle`
|
|
295
|
+
// (Layer 2, presentational) and `ColorSchemeSwitcher` (Layer 3,
|
|
296
|
+
// connected) — plus `Lightbox` (landed the same day on its own
|
|
297
|
+
// branch), taking the count to 28.
|
|
298
|
+
expect(componentNames.length).toBe(28)
|
|
294
299
|
|
|
295
300
|
for (const name of componentNames) {
|
|
296
301
|
expect(barrel).toContain(`export { ${name} }`)
|
|
@@ -364,6 +369,13 @@ const ENTRY_POINT_RUNTIMES: Record<
|
|
|
364
369
|
> = {
|
|
365
370
|
logic: {
|
|
366
371
|
".": ["react"],
|
|
372
|
+
// The DOM defaults for `useColorScheme`. It reaches no bare
|
|
373
|
+
// specifier at all: `matchMedia`/`localStorage`/`document` are
|
|
374
|
+
// globals, not imports, and its only imports are type-only ones
|
|
375
|
+
// into `core` — which is why `ColorSchemeApplier` lives in the
|
|
376
|
+
// core rather than beside the hook, so this entry never pulls
|
|
377
|
+
// `react`.
|
|
378
|
+
"./browser": [],
|
|
367
379
|
"./core": [],
|
|
368
380
|
"./jotai": ["jotai"],
|
|
369
381
|
"./preact": ["preact", "preact/hooks"],
|
|
@@ -376,6 +388,10 @@ const ENTRY_POINT_RUNTIMES: Record<
|
|
|
376
388
|
ui: {
|
|
377
389
|
".": [
|
|
378
390
|
"@charcuterie/logic",
|
|
391
|
+
// `ColorSchemeSwitcher` is the one component that reaches the
|
|
392
|
+
// browser, and it does so through this subpath so the coupling
|
|
393
|
+
// is named rather than smuggled in through the main entry.
|
|
394
|
+
"@charcuterie/logic/browser",
|
|
379
395
|
"@charcuterie/tokens",
|
|
380
396
|
"@floating-ui/react",
|
|
381
397
|
"react",
|