@charcuterie/ui 1.0.1 → 2.0.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 (134) 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/Combobox/Combobox.d.ts +54 -0
  10. package/dist/Combobox/Combobox.d.ts.map +1 -0
  11. package/dist/Combobox/Combobox.js +216 -0
  12. package/dist/Combobox/Combobox.js.map +1 -0
  13. package/dist/Combobox/ComboboxOption.d.ts +36 -0
  14. package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
  15. package/dist/Combobox/ComboboxOption.js +32 -0
  16. package/dist/Combobox/ComboboxOption.js.map +1 -0
  17. package/dist/Dialog/Dialog.d.ts +47 -0
  18. package/dist/Dialog/Dialog.d.ts.map +1 -0
  19. package/dist/Dialog/Dialog.js +52 -0
  20. package/dist/Dialog/Dialog.js.map +1 -0
  21. package/dist/Lightbox/Lightbox.d.ts +57 -0
  22. package/dist/Lightbox/Lightbox.d.ts.map +1 -0
  23. package/dist/Lightbox/Lightbox.js +50 -0
  24. package/dist/Lightbox/Lightbox.js.map +1 -0
  25. package/dist/Listbox/Listbox.d.ts +56 -0
  26. package/dist/Listbox/Listbox.d.ts.map +1 -0
  27. package/dist/Listbox/Listbox.js +151 -0
  28. package/dist/Listbox/Listbox.js.map +1 -0
  29. package/dist/Listbox/ListboxOption.d.ts +36 -0
  30. package/dist/Listbox/ListboxOption.d.ts.map +1 -0
  31. package/dist/Listbox/ListboxOption.js +42 -0
  32. package/dist/Listbox/ListboxOption.js.map +1 -0
  33. package/dist/Menu/Menu.d.ts.map +1 -1
  34. package/dist/Menu/Menu.js +47 -97
  35. package/dist/Menu/Menu.js.map +1 -1
  36. package/dist/Modal/Modal.d.ts +11 -59
  37. package/dist/Modal/Modal.d.ts.map +1 -1
  38. package/dist/Modal/Modal.js +28 -101
  39. package/dist/Modal/Modal.js.map +1 -1
  40. package/dist/Overlay/OverlayPanel.d.ts +50 -0
  41. package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
  42. package/dist/Overlay/OverlayPanel.js +72 -0
  43. package/dist/Overlay/OverlayPanel.js.map +1 -0
  44. package/dist/Overlay/OverlayStack.d.ts +47 -0
  45. package/dist/Overlay/OverlayStack.d.ts.map +1 -0
  46. package/dist/Overlay/OverlayStack.js +46 -0
  47. package/dist/Overlay/OverlayStack.js.map +1 -0
  48. package/dist/Overlay/SharedBackdrop.d.ts +24 -0
  49. package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
  50. package/dist/Overlay/SharedBackdrop.js +28 -0
  51. package/dist/Overlay/SharedBackdrop.js.map +1 -0
  52. package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
  53. package/dist/Overlay/lockScrollBehind.js.map +1 -0
  54. package/dist/Overlay/overlayPanelClass.d.ts +17 -0
  55. package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
  56. package/dist/Overlay/overlayPanelClass.js +17 -0
  57. package/dist/Overlay/overlayPanelClass.js.map +1 -0
  58. package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
  59. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
  60. package/dist/Overlay/useAnchoredOverlay.js +81 -0
  61. package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
  62. package/dist/Popover/Popover.d.ts +16 -24
  63. package/dist/Popover/Popover.d.ts.map +1 -1
  64. package/dist/Popover/Popover.js +32 -68
  65. package/dist/Popover/Popover.js.map +1 -1
  66. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  67. package/dist/Tooltip/Tooltip.js +25 -25
  68. package/dist/Tooltip/Tooltip.js.map +1 -1
  69. package/dist/index.d.ts +13 -1
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +6 -0
  72. package/dist/index.js.map +1 -1
  73. package/dist/mountStory.testHelpers.d.ts +1 -0
  74. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  75. package/dist/mountStory.testHelpers.js +21 -1
  76. package/dist/mountStory.testHelpers.js.map +1 -1
  77. package/package.json +5 -4
  78. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.mdx +70 -0
  79. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.stories.tsx +118 -0
  80. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.test.tsx +53 -0
  81. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.tsx +90 -0
  82. package/src/ColorSchemeToggle/ColorSchemeToggle.mdx +48 -0
  83. package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +161 -0
  84. package/src/ColorSchemeToggle/ColorSchemeToggle.test.tsx +80 -0
  85. package/src/ColorSchemeToggle/ColorSchemeToggle.tsx +107 -0
  86. package/src/Combobox/Combobox.mdx +71 -0
  87. package/src/Combobox/Combobox.stories.tsx +245 -0
  88. package/src/Combobox/Combobox.test.tsx +289 -0
  89. package/src/Combobox/Combobox.tsx +513 -0
  90. package/src/Combobox/ComboboxOption.tsx +88 -0
  91. package/src/Dialog/Dialog.mdx +87 -0
  92. package/src/Dialog/Dialog.stories.tsx +347 -0
  93. package/src/Dialog/Dialog.test.tsx +312 -0
  94. package/src/Dialog/Dialog.tsx +127 -0
  95. package/src/Field/Field.test.tsx +6 -4
  96. package/src/Lightbox/Lightbox.mdx +63 -0
  97. package/src/Lightbox/Lightbox.stories.tsx +133 -0
  98. package/src/Lightbox/Lightbox.test.tsx +124 -0
  99. package/src/Lightbox/Lightbox.tsx +143 -0
  100. package/src/Listbox/Listbox.mdx +68 -0
  101. package/src/Listbox/Listbox.stories.tsx +205 -0
  102. package/src/Listbox/Listbox.test.tsx +198 -0
  103. package/src/Listbox/Listbox.tsx +295 -0
  104. package/src/Listbox/ListboxOption.tsx +104 -0
  105. package/src/Menu/Menu.mdx +11 -13
  106. package/src/Menu/Menu.test.tsx +47 -38
  107. package/src/Menu/Menu.tsx +81 -142
  108. package/src/Modal/Modal.mdx +29 -82
  109. package/src/Modal/Modal.stories.tsx +55 -188
  110. package/src/Modal/Modal.test.tsx +44 -177
  111. package/src/Modal/Modal.tsx +54 -204
  112. package/src/Overlay/OverlayPanel.tsx +195 -0
  113. package/src/Overlay/OverlayStack.test.tsx +235 -0
  114. package/src/Overlay/OverlayStack.tsx +132 -0
  115. package/src/Overlay/SharedBackdrop.tsx +40 -0
  116. package/src/Overlay/overlayPanelClass.ts +17 -0
  117. package/src/Overlay/useAnchoredOverlay.ts +197 -0
  118. package/src/Popover/Popover.mdx +17 -20
  119. package/src/Popover/Popover.stories.tsx +33 -0
  120. package/src/Popover/Popover.test.tsx +68 -32
  121. package/src/Popover/Popover.tsx +56 -95
  122. package/src/Tooltip/Tooltip.mdx +12 -9
  123. package/src/Tooltip/Tooltip.test.tsx +17 -19
  124. package/src/Tooltip/Tooltip.tsx +35 -32
  125. package/src/icons.storyHelpers.tsx +22 -0
  126. package/src/index.ts +22 -4
  127. package/src/mountStory.testHelpers.ts +25 -1
  128. package/src/sourceRules.test.ts +26 -1
  129. package/src/storyControls.test.ts +25 -12
  130. package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
  131. package/dist/Modal/lockScrollBehind.js.map +0 -1
  132. /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
  133. /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
  134. /package/src/{Modal → Overlay}/lockScrollBehind.ts +0 -0
@@ -45,15 +45,18 @@ instead of falling out of it.** Here `useRole` writes `aria-describedby` on the
45
45
  An `IconButton` under a tooltip still carries its own `aria-label`. `AllVariants` proves the
46
46
  button stays findable by the same query whether or not the tip is open.
47
47
 
48
- ## No `popover` and no focus manager
49
-
50
- Unlike `Popover`, which is in the top layer and traps nothing. Focus never enters a tooltip
51
- — moving it there is what makes one inescapable — so there is no `FloatingFocusManager` and
52
- no `popover="manual"`.
53
-
54
- The trade is real and worth stating: a tooltip is therefore **not** in the top layer and can
55
- be clipped by an `overflow: hidden` ancestor. `strategy: "fixed"` keeps that to ancestors
56
- which actually clip fixed descendants.
48
+ ## Portalled, and no focus manager
49
+
50
+ The tip renders in a `FloatingPortal` at `document.body` — the same reversal as the other
51
+ overlays
52
+ ([decision](../../../docs/decisions/2026-08-03-overlays-portal-to-the-body-not-the-top-layer.md)):
53
+ `strategy: "fixed"` alone still let an `overflow: hidden` ancestor clip it, and a portal
54
+ does not. `--layer-tooltip` keeps it above a modal.
55
+
56
+ There is no `FloatingFocusManager`, unlike `Popover`. Focus never enters a tooltip — moving
57
+ it there is what would make one inescapable — so the tip stays out of the focus manager and
58
+ the overlay stack. A scoped query for the tip now reaches for `body`; `useRole` writes
59
+ `aria-describedby` on the trigger, so the link survives the boundary.
57
60
 
58
61
  <Canvas of={TooltipStories.AllStates} />
59
62
 
@@ -11,7 +11,7 @@ const { AllVariants, Default, Interactive } =
11
11
  composeStories(stories)
12
12
 
13
13
  test("nothing is shown until something asks", async () => {
14
- const { canvas, canvasElement } =
14
+ const { body, canvas, canvasElement } =
15
15
  await mountStory(Default)
16
16
 
17
17
  expectAgentDrivable(canvas, {
@@ -19,9 +19,9 @@ test("nothing is shown until something asks", async () => {
19
19
  role: "button",
20
20
  })
21
21
 
22
- await expect(
23
- canvas.queryAllByRole("tooltip"),
24
- ).toHaveLength(0)
22
+ await expect(body.queryAllByRole("tooltip")).toHaveLength(
23
+ 0,
24
+ )
25
25
 
26
26
  await expectNoAxeViolations(canvasElement)
27
27
  })
@@ -34,8 +34,7 @@ test("nothing is shown until something asks", async () => {
34
34
  * floating node nobody is pointed at.
35
35
  */
36
36
  test("the trigger is described by the tip that opened", async () => {
37
- const { canvas, canvasElement } =
38
- await mountStory(Interactive)
37
+ const { body, canvas } = await mountStory(Interactive)
39
38
 
40
39
  const trigger = expectAgentDrivable(canvas, {
41
40
  name: "Keyboard tip",
@@ -52,8 +51,10 @@ test("the trigger is described by the tip that opened", async () => {
52
51
  expect(trigger).toHaveFocus()
53
52
  })
54
53
 
54
+ // Portalled now, so found through `body`; the `aria-describedby`
55
+ // link survives the boundary.
55
56
  const tip = await waitFor(() =>
56
- expectAgentDrivable(canvas, { role: "tooltip" }),
57
+ expectAgentDrivable(body, { role: "tooltip" }),
57
58
  )
58
59
 
59
60
  await expect(trigger).toHaveAttribute(
@@ -65,10 +66,7 @@ test("the trigger is described by the tip that opened", async () => {
65
66
  "Opened by focus, closed by Escape.",
66
67
  )
67
68
 
68
- // Audited in the *driven* state, not at mount. The a11y addon
69
- // runs axe once, when `run()` resolves — before anything has been
70
- // focused — so an open tip would never be seen by it.
71
- await expectNoAxeViolations(canvasElement)
69
+ await expectNoAxeViolations(tip)
72
70
  })
73
71
 
74
72
  /**
@@ -77,28 +75,28 @@ test("the trigger is described by the tip that opened", async () => {
77
75
  * failure on the one control whose explanation lives there.
78
76
  */
79
77
  test("focus opens it and blur closes it", async () => {
80
- const { canvas } = await mountStory(Interactive)
78
+ const { body } = await mountStory(Interactive)
81
79
 
82
80
  await userEvent.tab()
83
81
 
84
82
  await waitFor(() => {
85
- expectAgentDrivable(canvas, { role: "tooltip" })
83
+ expectAgentDrivable(body, { role: "tooltip" })
86
84
  })
87
85
 
88
86
  await userEvent.tab()
89
87
 
90
88
  await waitFor(() => {
91
- expect(canvas.queryAllByRole("tooltip")).toHaveLength(0)
89
+ expect(body.queryAllByRole("tooltip")).toHaveLength(0)
92
90
  })
93
91
  })
94
92
 
95
93
  test("Escape dismisses it", async () => {
96
- const { canvas } = await mountStory(Interactive)
94
+ const { body } = await mountStory(Interactive)
97
95
 
98
96
  await userEvent.tab()
99
97
 
100
98
  await waitFor(() => {
101
- expectAgentDrivable(canvas, { role: "tooltip" })
99
+ expectAgentDrivable(body, { role: "tooltip" })
102
100
  })
103
101
 
104
102
  // WCAG 1.4.13 requires content shown on hover or focus to be
@@ -106,7 +104,7 @@ test("Escape dismisses it", async () => {
106
104
  await userEvent.keyboard("{Escape}")
107
105
 
108
106
  await waitFor(() => {
109
- expect(canvas.queryAllByRole("tooltip")).toHaveLength(0)
107
+ expect(body.queryAllByRole("tooltip")).toHaveLength(0)
110
108
  })
111
109
  })
112
110
 
@@ -117,7 +115,7 @@ test("Escape dismisses it", async () => {
117
115
  * whether or not the tip is open.
118
116
  */
119
117
  test("the tip does not become the control's name", async () => {
120
- const { canvas } = await mountStory(AllVariants)
118
+ const { body, canvas } = await mountStory(AllVariants)
121
119
 
122
120
  const iconButton = expectAgentDrivable(canvas, {
123
121
  name: "Bay settings",
@@ -127,7 +125,7 @@ test("the tip does not become the control's name", async () => {
127
125
  await userEvent.hover(iconButton)
128
126
 
129
127
  await waitFor(() => {
130
- expectAgentDrivable(canvas, { role: "tooltip" })
128
+ expectAgentDrivable(body, { role: "tooltip" })
131
129
  })
132
130
 
133
131
  // Still "Bay settings", not "Opens the bay's rip settings."
@@ -5,6 +5,7 @@ import {
5
5
  import type { Placement } from "@floating-ui/react"
6
6
  import {
7
7
  autoUpdate,
8
+ FloatingPortal,
8
9
  flip,
9
10
  offset,
10
11
  shift,
@@ -155,38 +156,40 @@ export const Tooltip = ({
155
156
  {clonedTrigger}
156
157
 
157
158
  {isVisible ? (
158
- <div
159
- {...getFloatingProps()}
160
- className={toClassName(
161
- // `intent-neutral-solid` + its own `on-solid`, rather
162
- // than `surface-inverse` + `content-on-accent`. The
163
- // second pair looks like the obvious one and is not a
164
- // *pair* at all — nothing in the token set promises those
165
- // two contrast, and in `daylight` dark they land at
166
- // 2.9:1. The `solid`/`on-solid` roles exist precisely to
167
- // be a guaranteed couple, and the contrast gate checks
168
- // them as one.
169
- "inset-auto m-0 max-w-xs rounded-md bg-intent-neutral-solid px-2 py-1 text-intent-neutral-on-solid text-xs shadow-medium",
170
- className,
171
- )}
172
- // No `popover="manual"` and no `FloatingFocusManager`,
173
- // unlike `Popover`. Both exist to manage focus, and focus
174
- // never enters a tooltip — moving it here is what makes
175
- // one unescapable. The trade is that a tooltip is not in
176
- // the top layer, so it can be clipped by an
177
- // `overflow: hidden` ancestor; `strategy: "fixed"` keeps
178
- // that to ancestors that actually clip fixed descendants.
179
- ref={refs.setFloating}
180
- // Duplicated from `getFloatingProps()` deliberately, and
181
- // the two cannot differ — `useRole(context, { role:
182
- // "tooltip" })` above is where it comes from. Stated here
183
- // because a linter cannot see a role through a spread, and
184
- // neither can the next reader.
185
- role="tooltip"
186
- style={floatingStyles}
187
- >
188
- {label}
189
- </div>
159
+ <FloatingPortal>
160
+ <div
161
+ {...getFloatingProps()}
162
+ className={toClassName(
163
+ // `intent-neutral-solid` + its own `on-solid`, rather
164
+ // than `surface-inverse` + `content-on-accent`. The
165
+ // second pair looks like the obvious one and is not a
166
+ // *pair* at all — nothing in the token set promises those
167
+ // two contrast, and in `daylight` dark they land at
168
+ // 2.9:1. The `solid`/`on-solid` roles exist precisely to
169
+ // be a guaranteed couple, and the contrast gate checks
170
+ // them as one.
171
+ "z-[var(--layer-tooltip)] max-w-xs rounded-md bg-intent-neutral-solid px-2 py-1 text-intent-neutral-on-solid text-xs shadow-medium",
172
+ className,
173
+ )}
174
+ // Portalled to `document.body`, the same reversal as the
175
+ // other overlays: `strategy: "fixed"` alone still let an
176
+ // `overflow: hidden` ancestor clip the tip, and a portal
177
+ // does not. No `FloatingFocusManager`, unlike `Popover` —
178
+ // focus never enters a tooltip, and moving it here is what
179
+ // would make one unescapable. `--layer-tooltip` keeps it
180
+ // above a modal.
181
+ ref={refs.setFloating}
182
+ // Duplicated from `getFloatingProps()` deliberately, and
183
+ // the two cannot differ — `useRole(context, { role:
184
+ // "tooltip" })` above is where it comes from. Stated here
185
+ // because a linter cannot see a role through a spread, and
186
+ // neither can the next reader.
187
+ role="tooltip"
188
+ style={floatingStyles}
189
+ >
190
+ {label}
191
+ </div>
192
+ </FloatingPortal>
190
193
  ) : null}
191
194
  </>
192
195
  )
@@ -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,15 @@ 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"
62
+ export type { ComboboxProps } from "./Combobox/Combobox.tsx"
63
+ export { Combobox } from "./Combobox/Combobox.tsx"
55
64
  export type { BadgeSize } from "./controlStyles.ts"
56
65
  export {
57
66
  BADGE_SIZE_CLASS,
@@ -61,6 +70,11 @@ export {
61
70
  MIN_TOUCH_TARGET_CLASS,
62
71
  SPINNER_SIZE_CLASS,
63
72
  } from "./controlStyles.ts"
73
+ export type {
74
+ DialogProps,
75
+ DialogSize,
76
+ } from "./Dialog/Dialog.tsx"
77
+ export { Dialog } from "./Dialog/Dialog.tsx"
64
78
  export type {
65
79
  EmptyStateProps,
66
80
  EmptyStateSize,
@@ -83,6 +97,13 @@ export {
83
97
  INTENT_HOVER_CLASS,
84
98
  INTENT_SOLID_FILL_CLASS,
85
99
  } from "./intentStyles.ts"
100
+ export type { LightboxProps } from "./Lightbox/Lightbox.tsx"
101
+ export { Lightbox } from "./Lightbox/Lightbox.tsx"
102
+ export type {
103
+ ListboxItem,
104
+ ListboxProps,
105
+ } from "./Listbox/Listbox.tsx"
106
+ export { Listbox } from "./Listbox/Listbox.tsx"
86
107
  export type {
87
108
  LiveStatusIndicatorProps,
88
109
  LiveStatusIndicatorSize,
@@ -105,10 +126,7 @@ export type {
105
126
  MenuProps,
106
127
  } from "./Menu/Menu.tsx"
107
128
  export { Menu } from "./Menu/Menu.tsx"
108
- export type {
109
- ModalProps,
110
- ModalSize,
111
- } from "./Modal/Modal.tsx"
129
+ export type { ModalProps } from "./Modal/Modal.tsx"
112
130
  export { Modal } from "./Modal/Modal.tsx"
113
131
  export type { PopoverProps } from "./Popover/Popover.tsx"
114
132
  export { Popover } from "./Popover/Popover.tsx"
@@ -46,12 +46,26 @@ type MountableStory = {
46
46
  * The stale tree is removed rather than unmounted because portable
47
47
  * stories expose no unmount. Detaching is enough: it takes the node
48
48
  * out of the layout, which is the entire problem.
49
+ *
50
+ * ### Portalled panels leave the canvas
51
+ *
52
+ * Once the overlays portal to `document.body`, their panels are no
53
+ * longer inside `canvasElement`, and floating-ui's portal roots
54
+ * (`[data-floating-ui-portal]`) are body children a portable story
55
+ * never unmounts. A previous test's panel therefore lingers next to
56
+ * the live one, and `expectAgentDrivable(body, …)` fails with "2
57
+ * elements match" — the portal analogue of the fixed-position
58
+ * `ToastRegion` bug above. So the same cleanup that detaches stale
59
+ * canvases also removes stale portal roots, and `mountStory` now hands
60
+ * back `body` (scoped to `document.body`) for querying the panels the
61
+ * triggers still open from inside `canvas`.
49
62
  */
50
63
  const mountedCanvases: HTMLElement[] = []
51
64
 
52
65
  export const mountStory = async (
53
66
  story: MountableStory,
54
67
  ): Promise<{
68
+ body: Canvas
55
69
  canvas: Canvas
56
70
  canvasElement: HTMLElement
57
71
  }> => {
@@ -59,6 +73,12 @@ export const mountStory = async (
59
73
  previous.remove()
60
74
  }
61
75
 
76
+ for (const portalRoot of document.querySelectorAll(
77
+ "[data-floating-ui-portal]",
78
+ )) {
79
+ portalRoot.remove()
80
+ }
81
+
62
82
  const canvasElement = document.createElement("div")
63
83
 
64
84
  document.body.append(canvasElement)
@@ -67,5 +87,9 @@ export const mountStory = async (
67
87
 
68
88
  await story.run({ canvasElement })
69
89
 
70
- return { canvas: within(canvasElement), canvasElement }
90
+ return {
91
+ body: within(document.body),
92
+ canvas: within(canvasElement),
93
+ canvasElement,
94
+ }
71
95
  }
@@ -290,7 +290,17 @@ 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) — and `Lightbox`, the thumbnail-over-`Dialog` viewer —
297
+ // taking the count to 28.
298
+ //
299
+ // The M8 overlay rebuild renames the old chrome-bearing `Modal` to
300
+ // `Dialog` and reintroduces `Modal` as the base layer, so both are
301
+ // components: +1 → 29. The picker family adds `Listbox` (single-select)
302
+ // and `Combobox` (searchable/virtualized): +2 → 31.
303
+ expect(componentNames.length).toBe(31)
294
304
 
295
305
  for (const name of componentNames) {
296
306
  expect(barrel).toContain(`export { ${name} }`)
@@ -364,6 +374,13 @@ const ENTRY_POINT_RUNTIMES: Record<
364
374
  > = {
365
375
  logic: {
366
376
  ".": ["react"],
377
+ // The DOM defaults for `useColorScheme`. It reaches no bare
378
+ // specifier at all: `matchMedia`/`localStorage`/`document` are
379
+ // globals, not imports, and its only imports are type-only ones
380
+ // into `core` — which is why `ColorSchemeApplier` lives in the
381
+ // core rather than beside the hook, so this entry never pulls
382
+ // `react`.
383
+ "./browser": [],
367
384
  "./core": [],
368
385
  "./jotai": ["jotai"],
369
386
  "./preact": ["preact", "preact/hooks"],
@@ -376,8 +393,16 @@ const ENTRY_POINT_RUNTIMES: Record<
376
393
  ui: {
377
394
  ".": [
378
395
  "@charcuterie/logic",
396
+ // `ColorSchemeSwitcher` is the one component that reaches the
397
+ // browser, and it does so through this subpath so the coupling
398
+ // is named rather than smuggled in through the main entry.
399
+ "@charcuterie/logic/browser",
379
400
  "@charcuterie/tokens",
380
401
  "@floating-ui/react",
402
+ // `Combobox`'s virtualization — the second runtime dependency
403
+ // this package has ever taken. MIT, US-origin (Tanner Linsley),
404
+ // tree-shakeable, ~4 KB gz.
405
+ "@tanstack/react-virtual",
381
406
  "react",
382
407
  ],
383
408
  "./testing": [],
@@ -24,21 +24,32 @@ import { expect, test } from "vitest"
24
24
  const sourceDirectory = resolve(import.meta.dirname)
25
25
 
26
26
  const readComponent = async (name: string) => {
27
- const [component, story] = await Promise.all([
28
- readFile(
27
+ // A `<Name>/<Name>.tsx` is the component-folder signal. An
28
+ // uppercase folder without one — `Overlay/`, which holds the shared
29
+ // portal/backdrop/anchoring infrastructure and no storied component
30
+ // — is not a component and is skipped, so this rule stays about the
31
+ // things that have a Controls panel to get wrong. A real component
32
+ // missing only its *story* still throws below, which is the guard.
33
+ let component: string
34
+
35
+ try {
36
+ component = await readFile(
29
37
  join(sourceDirectory, name, `${name}.tsx`),
30
38
  "utf8",
31
- ),
32
- readFile(
33
- join(sourceDirectory, name, `${name}.stories.tsx`),
34
- "utf8",
35
- ),
36
- ])
39
+ )
40
+ } catch {
41
+ return null
42
+ }
43
+
44
+ const story = await readFile(
45
+ join(sourceDirectory, name, `${name}.stories.tsx`),
46
+ "utf8",
47
+ )
37
48
 
38
49
  return { component, name, story }
39
50
  }
40
51
 
41
- const componentNames = (
52
+ const componentDirectories = (
42
53
  await readdir(sourceDirectory, { withFileTypes: true })
43
54
  )
44
55
  .filter(
@@ -48,9 +59,11 @@ const componentNames = (
48
59
  .map((entry) => entry.name)
49
60
  .sort()
50
61
 
51
- const components = await Promise.all(
52
- componentNames.map(readComponent),
53
- )
62
+ const components = (
63
+ await Promise.all(componentDirectories.map(readComponent))
64
+ ).filter((one) => one !== null)
65
+
66
+ const componentNames = components.map((one) => one.name)
54
67
 
55
68
  /**
56
69
  * `import type { A, B } from "@scope/pkg"` — bare specifiers only.
@@ -1 +0,0 @@
1
- {"version":3,"file":"lockScrollBehind.d.ts","sourceRoot":"","sources":["../../src/Modal/lockScrollBehind.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAMH,eAAO,MAAM,gBAAgB,QAAO,CAAC,MAAM,IAAI,CAiC9C,CAAA"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"lockScrollBehind.js","sourceRoot":"","sources":["../../src/Modal/lockScrollBehind.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,IAAI,SAAS,GAAG,CAAC,CAAA;AAEjB,IAAI,gBAAgB,GAAG,EAAE,CAAA;AAEzB,MAAM,CAAC,MAAM,gBAAgB,GAAG,GAAiB,EAAE;IACjD,IAAI,OAAO,QAAQ,KAAK,WAAW,EAAE,CAAC;QACpC,OAAO,GAAG,EAAE,GAAE,CAAC,CAAA;IACjB,CAAC;IAED,MAAM,EAAE,eAAe,EAAE,GAAG,QAAQ,CAAA;IAEpC,IAAI,SAAS,KAAK,CAAC,EAAE,CAAC;QACpB,gBAAgB,GAAG,eAAe,CAAC,KAAK,CAAC,QAAQ,CAAA;QAEjD,eAAe,CAAC,KAAK,CAAC,QAAQ,GAAG,QAAQ,CAAA;IAC3C,CAAC;IAED,SAAS,IAAI,CAAC,CAAA;IAEd,IAAI,UAAU,GAAG,KAAK,CAAA;IAEtB,OAAO,GAAG,EAAE;QACV,6DAA6D;QAC7D,6DAA6D;QAC7D,qCAAqC;QACrC,IAAI,UAAU,EAAE,CAAC;YACf,OAAM;QACR,CAAC;QAED,UAAU,GAAG,IAAI,CAAA;QAEjB,SAAS,IAAI,CAAC,CAAA;QAEd,IAAI,SAAS,KAAK,CAAC,EAAE,CAAC;YACpB,eAAe,CAAC,KAAK,CAAC,QAAQ,GAAG,gBAAgB,CAAA;QACnD,CAAC;IACH,CAAC,CAAA;AACH,CAAC,CAAA"}
File without changes
File without changes
File without changes