@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.
Files changed (32) 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/index.d.ts +6 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -0
  16. package/dist/index.js.map +1 -1
  17. package/package.json +3 -3
  18. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.mdx +70 -0
  19. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.stories.tsx +118 -0
  20. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.test.tsx +53 -0
  21. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.tsx +90 -0
  22. package/src/ColorSchemeToggle/ColorSchemeToggle.mdx +48 -0
  23. package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +161 -0
  24. package/src/ColorSchemeToggle/ColorSchemeToggle.test.tsx +80 -0
  25. package/src/ColorSchemeToggle/ColorSchemeToggle.tsx +107 -0
  26. package/src/Lightbox/Lightbox.mdx +62 -0
  27. package/src/Lightbox/Lightbox.stories.tsx +133 -0
  28. package/src/Lightbox/Lightbox.test.tsx +135 -0
  29. package/src/Lightbox/Lightbox.tsx +143 -0
  30. package/src/icons.storyHelpers.tsx +22 -0
  31. package/src/index.ts +9 -0
  32. 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,
@@ -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
- expect(componentNames.length).toBe(25)
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",