@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
@@ -7,8 +7,12 @@ import { mountStory } from "../mountStory.testHelpers.ts"
7
7
  import { expectAgentDrivable } from "../testing/index.ts"
8
8
  import * as stories from "./Menu.stories.tsx"
9
9
 
10
- const { AllStates, AllVariants, Interactive } =
11
- composeStories(stories)
10
+ const {
11
+ AllStates,
12
+ AllVariants,
13
+ Interactive,
14
+ SharedTrigger,
15
+ } = composeStories(stories)
12
16
 
13
17
  test("the menu is named, and its items are menuitems", async () => {
14
18
  const { canvas, canvasElement } =
@@ -200,3 +204,110 @@ test("the menu is not portalled out of its canvas", async () => {
200
204
 
201
205
  await expectNoAxeViolations(canvasElement)
202
206
  })
207
+
208
+ /**
209
+ * **One trigger, two slots** — the defect image-viewer reported, and
210
+ * the one shape `mergeSlotProps` did not cover when it shipped in
211
+ * 1.0.0.
212
+ *
213
+ * `Menu` and `Tooltip` both clone onto the button and both hand it a
214
+ * floating-ui `refs.setReference`. That is not an attribute, so
215
+ * last-write-wins destroyed one of them: the inner `Tooltip` wrote
216
+ * its own and the `Menu` was left with **no reference element at
217
+ * all**. Nothing threw, nothing was unnamed, axe was clean, and the
218
+ * panel rendered at `left: 0; top: 0` — a positioning bug that reads
219
+ * as CSS.
220
+ *
221
+ * So the assertion is geometric, and it is the only kind that can
222
+ * see this: the panel is where the trigger is. Nothing in `Menu`
223
+ * writes that number — floating-ui computes it from the reference
224
+ * element, and a dropped ref means no reference element.
225
+ */
226
+ const expectAnchoredTrigger = (trigger: HTMLElement) => {
227
+ const triggerRect = trigger.getBoundingClientRect()
228
+
229
+ // The precondition, stated rather than assumed. An unanchored
230
+ // panel lands at the viewport origin, so a trigger that also sits
231
+ // at the origin makes every assertion below pass for the wrong
232
+ // reason. The story's padding is what keeps them apart, and this
233
+ // is where its removal fails.
234
+ expect(triggerRect.left).toBeGreaterThan(32)
235
+
236
+ expect(triggerRect.top).toBeGreaterThan(32)
237
+
238
+ return triggerRect
239
+ }
240
+
241
+ test("a menu sharing its trigger with a tooltip is still anchored to it", async () => {
242
+ const { canvas, canvasElement } =
243
+ await mountStory(SharedTrigger)
244
+
245
+ const trigger = expectAgentDrivable(canvas, {
246
+ name: "Bay 5",
247
+ role: "button",
248
+ })
249
+
250
+ const triggerRect = expectAnchoredTrigger(trigger)
251
+
252
+ await userEvent.click(trigger)
253
+
254
+ const menu = await waitFor(() =>
255
+ expectAgentDrivable(canvas, { role: "menu" }),
256
+ )
257
+
258
+ // `bottom-start` with `offset(4)`: flush with the trigger's
259
+ // inline start, four pixels below it. Before the fix this read
260
+ // `left 0, top 0` — and it read that *forever*, so waiting for it
261
+ // costs a timeout rather than hiding a failure. The wait is for
262
+ // floating-ui's first position, which lands a frame after the
263
+ // panel does.
264
+ await waitFor(() => {
265
+ const menuRect = menu.getBoundingClientRect()
266
+
267
+ expect(Math.round(menuRect.left)).toBe(
268
+ Math.round(triggerRect.left),
269
+ )
270
+
271
+ expect(Math.round(menuRect.top)).toBe(
272
+ Math.round(triggerRect.bottom) + 4,
273
+ )
274
+ })
275
+
276
+ await expectNoAxeViolations(canvasElement)
277
+ })
278
+
279
+ /**
280
+ * The other ref, in the same composition — because a fix that
281
+ * reversed the priority instead of merging would leave *this* one
282
+ * pointing at nothing and the test above would still pass.
283
+ */
284
+ test("the tooltip sharing that trigger is anchored to it too", async () => {
285
+ const { canvas } = await mountStory(SharedTrigger)
286
+
287
+ const trigger = expectAgentDrivable(canvas, {
288
+ name: "Bay 5",
289
+ role: "button",
290
+ })
291
+
292
+ const triggerRect = expectAnchoredTrigger(trigger)
293
+
294
+ trigger.focus()
295
+
296
+ const tip = await waitFor(() =>
297
+ expectAgentDrivable(canvas, { role: "tooltip" }),
298
+ )
299
+
300
+ // The **main axis** only: `top` with `offset(6)`. The cross axis
301
+ // is not asserted because `shift({ padding: 8 })` legitimately
302
+ // moves it — a 229px tip over a 71px button cannot be centred
303
+ // this close to the edge — and an assertion that has to allow for
304
+ // that is one an unanchored tip at the viewport origin would also
305
+ // satisfy. This one it cannot: unanchored reads `bottom 24`.
306
+ await waitFor(() => {
307
+ const tipRect = tip.getBoundingClientRect()
308
+
309
+ expect(Math.round(tipRect.bottom)).toBe(
310
+ Math.round(triggerRect.top) - 6,
311
+ )
312
+ })
313
+ })
@@ -85,6 +85,11 @@ announces nothing.
85
85
  have. An extra `<div>` around it is how a toolbar's layout quietly changes, and it is why
86
86
  this is the pattern v1 chose and M2 ported.
87
87
 
88
+ It **composes** rather than overwrites: your button's own `ref` and its own `onClick` are
89
+ still called, with the popover's added. Until 1.0.1 they were replaced — silently, because a
90
+ ref that is never called and a handler that never runs both look exactly like nothing
91
+ happening.
92
+
88
93
  ## Escape *is* pressable here
89
94
 
90
95
  Unlike `Modal`'s. `useDismiss` listens for a keydown rather than relying on a UA default
@@ -9,11 +9,19 @@ import { Field } from "../Field/Field.tsx"
9
9
  import type { SelectItem } from "./Select.tsx"
10
10
  import { Select } from "./Select.tsx"
11
11
 
12
- const PROFILES: SelectItem[] = [
12
+ /**
13
+ * `as const satisfies`, which is a **typecheck** of the `readonly`
14
+ * widening as much as it is a story: a consumer's options table is
15
+ * usually a constant, and until 1.0.1 handing one to `options`
16
+ * failed with `TS4104` — image-viewer copied the array at every call
17
+ * site to get past it. If that widening is ever reverted, this line
18
+ * is where `yarn typecheck` says so.
19
+ */
20
+ const PROFILES = [
13
21
  { label: "Lossless remux", value: "lossless" },
14
22
  { label: "Compressed 1080p", value: "compressed" },
15
23
  { isDisabled: true, label: "Dolby Vision", value: "dv" },
16
- ]
24
+ ] as const satisfies readonly SelectItem[]
17
25
 
18
26
  const GROUPED: SelectItem[] = [
19
27
  {
@@ -23,7 +23,7 @@ export type SelectOption = {
23
23
 
24
24
  export type SelectOptionGroup = {
25
25
  label: string
26
- options: SelectOption[]
26
+ options: readonly SelectOption[]
27
27
  }
28
28
 
29
29
  export type SelectItem = SelectOption | SelectOptionGroup
@@ -56,7 +56,15 @@ export type SelectProps = Omit<
56
56
  */
57
57
  label?: string
58
58
  onChange?: (value: string) => void
59
- options: SelectItem[]
59
+ /**
60
+ * `readonly`, because a caller's list is usually a constant and
61
+ * TypeScript will not hand a `readonly` array to a mutable
62
+ * parameter — image-viewer hit `TS4104` on an `as const` options
63
+ * table and had to spread a copy at every call. This component
64
+ * only ever `.map`s over it, so demanding a mutable array was
65
+ * asking for a permission it never uses.
66
+ */
67
+ options: readonly SelectItem[]
60
68
  /**
61
69
  * A disabled first entry, not a real option. Reads as a prompt to
62
70
  * a sighted user and cannot be submitted, which is what a
@@ -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/slotProps.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { InputHTMLAttributes } from "react"
2
2
 
3
+ import { mergeSlotWiring } from "./slotWiring.ts"
4
+
3
5
  /**
4
6
  * The props a **cloning ancestor** injects into the control below it.
5
7
  *
@@ -114,6 +116,17 @@ export type SlotProps = Pick<
114
116
  * `isRequired || undefined`, so a plain spread would let an inner
115
117
  * `Field` with no error erase an outer one's `aria-invalid` — the
116
118
  * same silent drop, one level down.
119
+ *
120
+ * ### The wiring is not a value, and does not last-write-win
121
+ *
122
+ * `ref` and the `on*` handlers are merged rather than replaced, by
123
+ * `mergeSlotWiring`. This was the 1.0.0 hole: `mergeSlotProps`
124
+ * settled the five *attributes* and left the two props that are not
125
+ * attributes on last-write-wins, so a `Menu` and a `Tooltip` on one
126
+ * trigger each handed it a floating-ui `refs.setReference` and the
127
+ * inner one won. The outer panel had no anchor and rendered in the
128
+ * corner of the viewport — no error, no failing test, and it looks
129
+ * like a CSS bug.
117
130
  */
118
131
  export const mergeSlotProps = <
119
132
  OwnProps extends Record<string, unknown>,
@@ -139,5 +152,12 @@ export const mergeSlotProps = <
139
152
  ...definedOwnProps,
140
153
  "aria-describedby":
141
154
  describedBy === "" ? undefined : describedBy,
155
+ // Last, because it is the only merge that has to beat
156
+ // `definedOwnProps` — it is composing what that spread would
157
+ // otherwise have thrown away.
158
+ ...mergeSlotWiring(
159
+ receivedProps as Record<string, unknown>,
160
+ definedOwnProps,
161
+ ),
142
162
  }
143
163
  }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The two props a slot may not overwrite, and how it merges them.
3
+ *
4
+ * `mergeSlotProps` next door settles the five *attributes* two
5
+ * nested slots both write, and last-write-wins is right for four of
6
+ * them. It is not right for the wiring, because neither of these is
7
+ * a value:
8
+ *
9
+ * - **`ref`** is a subscription. `Menu` and `Tooltip` on one trigger
10
+ * both hand it a floating-ui `refs.setReference` — the anchor each
11
+ * of them positions its panel against — and the inner one used to
12
+ * replace the outer one's. Nothing threw. The outer panel simply
13
+ * had no reference element, so floating-ui left it at
14
+ * `left: 0; top: 0` in the corner of the viewport, which reads as
15
+ * a CSS bug and is not one.
16
+ * - **`on*`** is a listener, and listeners are plural.
17
+ *
18
+ * ### Why this is not imported from `@charcuterie/logic`
19
+ *
20
+ * `useClonedChild`'s `mergeClonedProps` applies the same rule one
21
+ * level down — between a slot and the element a caller wrote — and
22
+ * the two are near-identical. Sharing them means a new public export
23
+ * on `@charcuterie/logic`, which is a `minor`; this is a patch, and
24
+ * the duplication is thirty lines against a wrong version number.
25
+ * Queued in `docs/2026-08-02-reported-by-consumers-queued-for-1-1.md`.
26
+ */
27
+
28
+ const isEventHandlerName = (name: string) =>
29
+ /^on[A-Z]/.test(name)
30
+
31
+ type MergeableRef =
32
+ | ((node: unknown) => unknown)
33
+ | { current: unknown }
34
+
35
+ const isMergeableRef = (
36
+ value: unknown,
37
+ ): value is MergeableRef =>
38
+ typeof value === "function" ||
39
+ (typeof value === "object" &&
40
+ value !== null &&
41
+ "current" in value)
42
+
43
+ const setRef = (ref: MergeableRef, node: unknown) => {
44
+ if (typeof ref === "function") {
45
+ return ref(node)
46
+ }
47
+
48
+ ref.current = node
49
+
50
+ return undefined
51
+ }
52
+
53
+ /**
54
+ * Every pair of refs ever merged, so the same two always come back
55
+ * as the **same function** — which is load-bearing rather than
56
+ * frugal.
57
+ *
58
+ * React re-runs a callback ref whenever its identity changes between
59
+ * renders, detaching with the old one and attaching with the new.
60
+ * Both refs here are floating-ui `setReference`s, and calling one
61
+ * sets state; a merged ref rebuilt every render would call both
62
+ * twice per render, each call scheduling the render that rebuilds
63
+ * it. `mergeSlotProps` is a plain function called during render, so
64
+ * it cannot reach for `useMemo` — the memo has to be in the identity
65
+ * of the inputs, which is what a `WeakMap` of `WeakMap`s is.
66
+ */
67
+ const mergedRefs = new WeakMap<
68
+ MergeableRef,
69
+ WeakMap<MergeableRef, (node: unknown) => () => void>
70
+ >()
71
+
72
+ const mergeRefs = (
73
+ first: MergeableRef,
74
+ second: MergeableRef,
75
+ ) => {
76
+ const bySecond =
77
+ mergedRefs.get(first) ??
78
+ new WeakMap<
79
+ MergeableRef,
80
+ (node: unknown) => () => void
81
+ >()
82
+
83
+ mergedRefs.set(first, bySecond)
84
+
85
+ const cached = bySecond.get(second)
86
+
87
+ if (cached) {
88
+ return cached
89
+ }
90
+
91
+ const merged = (node: unknown) => {
92
+ const cleanups = [
93
+ setRef(first, node),
94
+ setRef(second, node),
95
+ ]
96
+
97
+ // Per ref: one that returned a cleanup has opted out of being
98
+ // called with `null`, and one that did not still expects it.
99
+ return () => {
100
+ for (const [index, cleanup] of cleanups.entries()) {
101
+ if (typeof cleanup === "function") {
102
+ cleanup()
103
+ } else {
104
+ setRef(index === 0 ? first : second, null)
105
+ }
106
+ }
107
+ }
108
+ }
109
+
110
+ bySecond.set(second, merged)
111
+
112
+ return merged
113
+ }
114
+
115
+ const chainHandlers =
116
+ (
117
+ first: (...args: unknown[]) => unknown,
118
+ second: (...args: unknown[]) => unknown,
119
+ ) =>
120
+ (...args: unknown[]) => {
121
+ // Outer first, the same order `aria-describedby` is joined in:
122
+ // the merge order is structural — whoever wrapped whom — and the
123
+ // author of the nesting is the one who chose it.
124
+ first(...args)
125
+
126
+ second(...args)
127
+ }
128
+
129
+ /**
130
+ * The keys of `ownProps` that must not simply replace the ones
131
+ * `receivedProps` arrived with. Spread **last**, over both.
132
+ */
133
+ export const mergeSlotWiring = (
134
+ receivedProps: Record<string, unknown>,
135
+ ownProps: Record<string, unknown>,
136
+ ): Record<string, unknown> =>
137
+ Object.fromEntries(
138
+ Object.entries(ownProps).flatMap(
139
+ ([name, ownValue]): [string, unknown][] => {
140
+ const receivedValue = receivedProps[name]
141
+
142
+ if (
143
+ name === "ref" &&
144
+ isMergeableRef(receivedValue) &&
145
+ isMergeableRef(ownValue)
146
+ ) {
147
+ return [
148
+ [name, mergeRefs(receivedValue, ownValue)],
149
+ ]
150
+ }
151
+
152
+ if (
153
+ isEventHandlerName(name) &&
154
+ typeof receivedValue === "function" &&
155
+ typeof ownValue === "function"
156
+ ) {
157
+ return [
158
+ [
159
+ name,
160
+ chainHandlers(
161
+ receivedValue as (
162
+ ...args: unknown[]
163
+ ) => unknown,
164
+ ownValue as (...args: unknown[]) => unknown,
165
+ ),
166
+ ],
167
+ ]
168
+ }
169
+
170
+ return []
171
+ },
172
+ ),
173
+ )
@@ -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",