@charcuterie/ui 4.5.0 → 4.6.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 (226) hide show
  1. package/README.md +7 -2
  2. package/dist/Card/Card.d.ts +5 -2
  3. package/dist/Card/Card.d.ts.map +1 -1
  4. package/dist/Card/Card.js +23 -3
  5. package/dist/Card/Card.js.map +1 -1
  6. package/dist/Overlay/OverlayPanel.d.ts.map +1 -1
  7. package/dist/Overlay/OverlayPanel.js +54 -2
  8. package/dist/Overlay/OverlayPanel.js.map +1 -1
  9. package/dist/Overlay/OverlayStack.d.ts +5 -0
  10. package/dist/Overlay/OverlayStack.d.ts.map +1 -1
  11. package/dist/Overlay/OverlayStack.js +2 -0
  12. package/dist/Overlay/OverlayStack.js.map +1 -1
  13. package/dist/Toast/ToastRegion.d.ts +61 -0
  14. package/dist/Toast/ToastRegion.d.ts.map +1 -1
  15. package/dist/Toast/ToastRegion.js +80 -3
  16. package/dist/Toast/ToastRegion.js.map +1 -1
  17. package/dist/Toolbar/useToolbarOverflow.d.ts.map +1 -1
  18. package/dist/Toolbar/useToolbarOverflow.js +39 -0
  19. package/dist/Toolbar/useToolbarOverflow.js.map +1 -1
  20. package/package.json +3 -2
  21. package/src/Accordion/Accordion.mdx +1 -1
  22. package/src/Accordion/Accordion.stories.tsx +3 -1
  23. package/src/Accordion/Accordion.test.tsx +4 -4
  24. package/src/ActionTiles/ActionTiles.mdx +1 -1
  25. package/src/ActionTiles/ActionTiles.stories.tsx +3 -1
  26. package/src/ActionTiles/ActionTiles.test.tsx +3 -3
  27. package/src/AdaptiveGrid/AdaptiveGrid.mdx +1 -1
  28. package/src/AdaptiveGrid/AdaptiveGrid.stories.tsx +3 -1
  29. package/src/AdaptiveGrid/AdaptiveGrid.test.tsx +5 -5
  30. package/src/Alert/Alert.mdx +1 -1
  31. package/src/Alert/Alert.stories.tsx +3 -1
  32. package/src/Alert/Alert.test.tsx +2 -2
  33. package/src/Avatar/Avatar.mdx +1 -1
  34. package/src/Avatar/Avatar.stories.tsx +4 -1
  35. package/src/Avatar/Avatar.test.tsx +2 -2
  36. package/src/Badge/Badge.mdx +1 -1
  37. package/src/Badge/Badge.stories.tsx +3 -1
  38. package/src/Badge/Badge.test.tsx +3 -3
  39. package/src/BadgeButton/BadgeButton.mdx +1 -1
  40. package/src/BadgeButton/BadgeButton.stories.tsx +3 -1
  41. package/src/BadgeButton/BadgeButton.test.tsx +2 -2
  42. package/src/Board/Board.mdx +1 -1
  43. package/src/Board/Board.stories.tsx +4 -1
  44. package/src/Board/Board.test.tsx +2 -2
  45. package/src/Button/Button.mdx +1 -1
  46. package/src/Button/Button.stories.tsx +3 -1
  47. package/src/Button/Button.test.tsx +2 -2
  48. package/src/ButtonLink/ButtonLink.mdx +1 -1
  49. package/src/ButtonLink/ButtonLink.stories.tsx +3 -1
  50. package/src/ButtonLink/ButtonLink.test.tsx +3 -3
  51. package/src/Card/Card.mdx +21 -1
  52. package/src/Card/Card.stories.tsx +91 -1
  53. package/src/Card/Card.test.tsx +60 -3
  54. package/src/Card/Card.tsx +24 -2
  55. package/src/Checkbox/Checkbox.mdx +1 -1
  56. package/src/Checkbox/Checkbox.stories.tsx +3 -1
  57. package/src/Checkbox/Checkbox.test.tsx +4 -4
  58. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.mdx +1 -1
  59. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.stories.tsx +3 -1
  60. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.test.tsx +5 -5
  61. package/src/ColorSchemeToggle/ColorSchemeToggle.mdx +1 -1
  62. package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +3 -1
  63. package/src/ColorSchemeToggle/ColorSchemeToggle.test.tsx +5 -5
  64. package/src/Combobox/Combobox.mdx +1 -1
  65. package/src/Combobox/Combobox.stories.tsx +3 -1
  66. package/src/Combobox/Combobox.test.tsx +2 -2
  67. package/src/CopyButton/CopyButton.mdx +1 -1
  68. package/src/CopyButton/CopyButton.stories.tsx +4 -1
  69. package/src/CopyButton/CopyButton.test.tsx +3 -3
  70. package/src/DataTable/DataTable.mdx +1 -1
  71. package/src/DataTable/DataTable.stories.tsx +3 -1
  72. package/src/DataTable/DataTable.test.tsx +3 -3
  73. package/src/DatePicker/DatePicker.mdx +1 -1
  74. package/src/DatePicker/DatePicker.stories.tsx +3 -1
  75. package/src/DatePicker/DatePicker.test.tsx +3 -3
  76. package/src/DatePicker/plainDate.test.ts +21 -3
  77. package/src/Dialog/Dialog.mdx +1 -1
  78. package/src/Dialog/Dialog.stories.tsx +3 -1
  79. package/src/Dialog/Dialog.test.tsx +2 -2
  80. package/src/DropRail/DropRail.mdx +1 -1
  81. package/src/DropRail/DropRail.stories.tsx +4 -1
  82. package/src/DropRail/DropRail.test.tsx +4 -4
  83. package/src/EmptyState/EmptyState.mdx +1 -1
  84. package/src/EmptyState/EmptyState.stories.tsx +3 -1
  85. package/src/EmptyState/EmptyState.test.tsx +2 -2
  86. package/src/Field/Field.mdx +1 -1
  87. package/src/Field/Field.stories.tsx +3 -1
  88. package/src/Field/Field.test.tsx +4 -4
  89. package/src/FileDropZone/FileDropZone.mdx +1 -1
  90. package/src/FileDropZone/FileDropZone.stories.tsx +3 -1
  91. package/src/FileDropZone/FileDropZone.test.tsx +3 -3
  92. package/src/Header/Header.mdx +1 -1
  93. package/src/Header/Header.stories.tsx +3 -1
  94. package/src/IconButton/IconButton.mdx +1 -1
  95. package/src/IconButton/IconButton.stories.tsx +3 -1
  96. package/src/IconButton/IconButton.test.tsx +2 -2
  97. package/src/Lightbox/Lightbox.mdx +1 -1
  98. package/src/Lightbox/Lightbox.stories.tsx +3 -1
  99. package/src/Lightbox/Lightbox.test.tsx +2 -2
  100. package/src/Listbox/Listbox.mdx +1 -1
  101. package/src/Listbox/Listbox.stories.tsx +3 -1
  102. package/src/Listbox/Listbox.test.tsx +5 -5
  103. package/src/LiveStatusIndicator/LiveStatusIndicator.mdx +1 -1
  104. package/src/LiveStatusIndicator/LiveStatusIndicator.stories.tsx +3 -1
  105. package/src/LiveStatusIndicator/LiveStatusIndicator.test.tsx +2 -2
  106. package/src/LogViewer/LogViewer.mdx +1 -1
  107. package/src/LogViewer/LogViewer.stories.tsx +3 -1
  108. package/src/LogViewer/LogViewer.test.tsx +2 -2
  109. package/src/Main/Main.mdx +1 -1
  110. package/src/Main/Main.stories.tsx +3 -1
  111. package/src/Main/Main.test.tsx +3 -3
  112. package/src/MarkdownEditor/MarkdownEditor.mdx +1 -1
  113. package/src/MarkdownEditor/MarkdownEditor.stories.tsx +3 -1
  114. package/src/MarkdownEditor/MarkdownEditor.test.tsx +4 -4
  115. package/src/MarkdownLine/MarkdownLine.mdx +1 -1
  116. package/src/MarkdownLine/MarkdownLine.stories.tsx +4 -1
  117. package/src/MarkdownLine/MarkdownLine.test.tsx +3 -3
  118. package/src/MediaTile/MediaTile.stories.tsx +3 -1
  119. package/src/MediaTile/MediaTile.test.tsx +2 -2
  120. package/src/Menu/Menu.mdx +1 -1
  121. package/src/Menu/Menu.stories.tsx +3 -1
  122. package/src/Modal/Modal.mdx +1 -1
  123. package/src/Modal/Modal.stories.tsx +3 -1
  124. package/src/Modal/Modal.test.tsx +3 -3
  125. package/src/Nav/Nav.mdx +1 -1
  126. package/src/Nav/Nav.stories.tsx +3 -1
  127. package/src/Nav/Nav.test.tsx +6 -6
  128. package/src/NavBar/NavBar.mdx +1 -1
  129. package/src/NavBar/NavBar.stories.tsx +3 -1
  130. package/src/NavBar/NavBar.test.tsx +3 -3
  131. package/src/Overlay/OverlayPanel.tsx +63 -2
  132. package/src/Overlay/OverlayStack.tsx +7 -1
  133. package/src/Picker/Picker.mdx +1 -1
  134. package/src/Picker/Picker.stories.tsx +3 -1
  135. package/src/Picker/Picker.test.tsx +2 -2
  136. package/src/Popover/Popover.mdx +1 -1
  137. package/src/Popover/Popover.stories.tsx +3 -1
  138. package/src/Popover/Popover.test.tsx +2 -2
  139. package/src/PortraitTiles/PortraitTiles.mdx +1 -1
  140. package/src/PortraitTiles/PortraitTiles.stories.tsx +3 -1
  141. package/src/PortraitTiles/PortraitTiles.test.tsx +5 -5
  142. package/src/ProgressBar/ProgressBar.mdx +1 -1
  143. package/src/ProgressBar/ProgressBar.stories.tsx +3 -1
  144. package/src/ProgressBar/ProgressBar.test.tsx +2 -2
  145. package/src/QueryBuilder/QueryBuilder.mdx +1 -1
  146. package/src/QueryBuilder/QueryBuilder.stories.tsx +3 -1
  147. package/src/QueryBuilder/QueryBuilder.test.tsx +2 -2
  148. package/src/RadioGroup/RadioGroup.mdx +1 -1
  149. package/src/RadioGroup/RadioGroup.stories.tsx +3 -1
  150. package/src/RadioGroup/RadioGroup.test.tsx +2 -2
  151. package/src/Rail/Rail.mdx +1 -1
  152. package/src/Rail/Rail.stories.tsx +3 -1
  153. package/src/Rail/Rail.test.tsx +2 -2
  154. package/src/RangeSlider/RangeSlider.mdx +1 -1
  155. package/src/RangeSlider/RangeSlider.stories.tsx +3 -1
  156. package/src/RangeSlider/RangeSlider.test.tsx +7 -3
  157. package/src/ReorderList/ReorderList.mdx +1 -1
  158. package/src/ReorderList/ReorderList.stories.tsx +3 -1
  159. package/src/ReorderList/ReorderList.test.tsx +7 -7
  160. package/src/RouterLink/UnstyledLink.stories.tsx +4 -1
  161. package/src/RouterLink/UnstyledLink.test.tsx +2 -2
  162. package/src/SearchInput/SearchInput.mdx +1 -1
  163. package/src/SearchInput/SearchInput.stories.tsx +3 -1
  164. package/src/SegmentedControl/SegmentedControl.mdx +1 -1
  165. package/src/SegmentedControl/SegmentedControl.stories.tsx +3 -1
  166. package/src/SegmentedControl/SegmentedControl.test.tsx +2 -2
  167. package/src/Select/Select.mdx +1 -1
  168. package/src/Select/Select.stories.tsx +3 -1
  169. package/src/Select/Select.test.tsx +4 -4
  170. package/src/Shell/Shell.mdx +1 -1
  171. package/src/Shell/Shell.stories.tsx +3 -1
  172. package/src/Shell/Shell.test.tsx +2 -2
  173. package/src/Skeleton/Skeleton.mdx +1 -1
  174. package/src/Skeleton/Skeleton.stories.tsx +3 -1
  175. package/src/Skeleton/Skeleton.test.tsx +2 -2
  176. package/src/Slider/Slider.mdx +1 -1
  177. package/src/Slider/Slider.stories.tsx +3 -1
  178. package/src/Slider/Slider.test.tsx +2 -2
  179. package/src/SortableTableHeader/SortableTableHeader.mdx +1 -1
  180. package/src/SortableTableHeader/SortableTableHeader.stories.tsx +3 -1
  181. package/src/SortableTableHeader/SortableTableHeader.test.tsx +3 -3
  182. package/src/Spinner/Spinner.mdx +1 -1
  183. package/src/Spinner/Spinner.stories.tsx +3 -1
  184. package/src/Spinner/Spinner.test.tsx +2 -2
  185. package/src/Stepper/Stepper.mdx +1 -1
  186. package/src/Stepper/Stepper.stories.tsx +4 -1
  187. package/src/Stepper/Stepper.test.tsx +7 -7
  188. package/src/Swatch/Swatch.mdx +1 -1
  189. package/src/Swatch/Swatch.stories.tsx +4 -1
  190. package/src/Swatch/Swatch.test.tsx +3 -3
  191. package/src/Switch/Switch.mdx +1 -1
  192. package/src/Switch/Switch.stories.tsx +3 -1
  193. package/src/Switch/Switch.test.tsx +2 -2
  194. package/src/Tabs/Tabs.mdx +1 -1
  195. package/src/Tabs/Tabs.stories.tsx +3 -1
  196. package/src/Tabs/Tabs.test.tsx +3 -3
  197. package/src/TextLink/TextLink.mdx +1 -1
  198. package/src/TextLink/TextLink.stories.tsx +3 -1
  199. package/src/TextLink/TextLink.test.tsx +4 -4
  200. package/src/TimecodeInput/TimecodeInput.mdx +1 -1
  201. package/src/TimecodeInput/TimecodeInput.stories.tsx +3 -1
  202. package/src/Toast/Toast.mdx +1 -1
  203. package/src/Toast/Toast.stories.tsx +157 -1
  204. package/src/Toast/Toast.test.tsx +2 -2
  205. package/src/Toast/ToastRegion.test.tsx +172 -0
  206. package/src/Toast/ToastRegion.tsx +120 -32
  207. package/src/Toolbar/Toolbar.mdx +1 -1
  208. package/src/Toolbar/Toolbar.stories.tsx +3 -1
  209. package/src/Toolbar/Toolbar.test.tsx +89 -5
  210. package/src/Toolbar/useToolbarOverflow.ts +43 -0
  211. package/src/Tooltip/Tooltip.mdx +1 -1
  212. package/src/Tooltip/Tooltip.stories.tsx +3 -1
  213. package/src/Tooltip/Tooltip.test.tsx +2 -2
  214. package/src/VirtualizedGrid/VirtualizedGrid.mdx +1 -1
  215. package/src/VirtualizedGrid/VirtualizedGrid.stories.tsx +4 -1
  216. package/src/VirtualizedGrid/VirtualizedGrid.test.tsx +5 -5
  217. package/src/VisuallyHidden/VisuallyHidden.mdx +1 -1
  218. package/src/VisuallyHidden/VisuallyHidden.stories.tsx +3 -1
  219. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +1 -1
  220. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +3 -1
  221. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.test.tsx +3 -3
  222. package/src/markdownEditorCodeMirror/MarkdownView.mdx +1 -1
  223. package/src/markdownEditorCodeMirror/MarkdownView.stories.tsx +4 -1
  224. package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +44 -20
  225. package/src/reactRouter/ReactRouterAdapter.stories.tsx +3 -1
  226. package/src/reactRouter/ReactRouterAdapter.test.tsx +2 -2
@@ -10,7 +10,7 @@ import * as stories from "./Tabs.stories.tsx"
10
10
  const {
11
11
  AllStates,
12
12
  AllVariants,
13
- Default,
13
+ Playground,
14
14
  Manual,
15
15
  Responsive,
16
16
  Routed,
@@ -20,7 +20,7 @@ const {
20
20
 
21
21
  test("selecting a tab wires the panel back to it", async () => {
22
22
  const { canvas, canvasElement } =
23
- await mountStory(Default)
23
+ await mountStory(Playground)
24
24
 
25
25
  const tablist = expectAgentDrivable(canvas, {
26
26
  name: "Bay 3",
@@ -256,7 +256,7 @@ test("manual activation moves focus without selecting", async () => {
256
256
  })
257
257
 
258
258
  test("automatic activation selects as focus moves, and wraps", async () => {
259
- const { canvas } = await mountStory(Default)
259
+ const { canvas } = await mountStory(Playground)
260
260
 
261
261
  const tablist = expectAgentDrivable(canvas, {
262
262
  name: "Bay 3",
@@ -18,7 +18,7 @@ header home link because there was nothing else to reach for. All of them are an
18
18
  here, so middle-click, ctrl-click, "open in new tab", "copy link address", and the browser's
19
19
  status bar work without anybody implementing them.
20
20
 
21
- <Canvas of={TextLinkStories.Default} />
21
+ <Canvas of={TextLinkStories.Playground} />
22
22
 
23
23
  ## Props
24
24
 
@@ -1,3 +1,4 @@
1
+ import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
1
2
  import type { Meta, StoryObj } from "@storybook/react"
2
3
  import type { ReactNode } from "react"
3
4
 
@@ -62,7 +63,8 @@ export default meta
62
63
 
63
64
  type Story = StoryObj<typeof meta>
64
65
 
65
- export const Default: Story = {
66
+ export const Playground: Story = {
67
+ parameters: playgroundParameters,
66
68
  args: { children: "the rip queue" },
67
69
  }
68
70
 
@@ -14,7 +14,7 @@ const {
14
14
  AllAppearances,
15
15
  AllStates,
16
16
  BackLink,
17
- Default,
17
+ Playground,
18
18
  External,
19
19
  Routed,
20
20
  } = composeStories(stories)
@@ -28,7 +28,7 @@ const {
28
28
  * it for free is the entire argument for rendering an anchor.
29
29
  */
30
30
  test("is drivable by role and name", async () => {
31
- const { canvas } = await mountStory(Default)
31
+ const { canvas } = await mountStory(Playground)
32
32
 
33
33
  const link = expectAgentDrivable(canvas, {
34
34
  name: "the rip queue",
@@ -39,7 +39,7 @@ test("is drivable by role and name", async () => {
39
39
  })
40
40
 
41
41
  test("Tab reaches it", async () => {
42
- const { canvas } = await mountStory(Default)
42
+ const { canvas } = await mountStory(Playground)
43
43
 
44
44
  const link = canvas.getByRole("link", {
45
45
  name: "the rip queue",
@@ -210,7 +210,7 @@ test("an injected router takes in-app paths and nothing else", async () => {
210
210
  })
211
211
 
212
212
  test("with no provider it is still a real anchor", async () => {
213
- const { canvas } = await mountStory(Default)
213
+ const { canvas } = await mountStory(Playground)
214
214
 
215
215
  const link = canvas.getByRole("link", {
216
216
  name: "the rip queue",
@@ -11,7 +11,7 @@ for a section of a file. The consumer that shaped it is **QueuePilot**, which pl
11
11
  chosen part of a video
12
12
  ([decision](../../../../docs/decisions/2026-09-01-a-timecode-is-milliseconds-and-a-section-is-a-mode.md)).
13
13
 
14
- <Canvas of={TimecodeInputStories.Default} />
14
+ <Canvas of={TimecodeInputStories.Playground} />
15
15
 
16
16
  ## Props
17
17
 
@@ -1,3 +1,4 @@
1
+ import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
1
2
  import type { Meta, StoryObj } from "@storybook/react"
2
3
  import type { ReactNode } from "react"
3
4
  import { useState } from "react"
@@ -113,7 +114,8 @@ const TimecodeHarness = ({
113
114
  * Milliseconds are optional both ways: `1:01:00` commits as
114
115
  * `01:01:00`, not as `01:01:00.000`.
115
116
  */
116
- export const Default: Story = {
117
+ export const Playground: Story = {
118
+ parameters: playgroundParameters,
117
119
  args: { label: "Start at" },
118
120
  render: () => <TimecodeHarness label="Start at" />,
119
121
  }
@@ -16,7 +16,7 @@ machine down and M2 never built it:
16
16
 
17
17
  > | Toast lifecycle | entering → visible → exiting → removed |
18
18
 
19
- <Canvas of={ToastStories.Default} />
19
+ <Canvas of={ToastStories.Playground} />
20
20
 
21
21
  ## Props
22
22
 
@@ -1,3 +1,4 @@
1
+ import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
1
2
  import type { Meta, StoryObj } from "@storybook/react"
2
3
  import type { ReactNode } from "react"
3
4
  import { useState } from "react"
@@ -8,6 +9,8 @@ import {
8
9
  StoryCell,
9
10
  StoryGrid,
10
11
  } from "../board.storyHelpers.tsx"
12
+ import { Modal } from "../Modal/Modal.tsx"
13
+ import { OverlayStackProvider } from "../Overlay/OverlayStack.tsx"
11
14
  import { Toast } from "./Toast.tsx"
12
15
  import type { ToastRecord } from "./ToastRegion.tsx"
13
16
  import { ToastRegion } from "./ToastRegion.tsx"
@@ -46,7 +49,8 @@ const ToastList = ({
46
49
  </ul>
47
50
  )
48
51
 
49
- export const Default: Story = {
52
+ export const Playground: Story = {
53
+ parameters: playgroundParameters,
50
54
  args: {
51
55
  children: "Blade Runner (1982) · 9 titles.",
52
56
  title: "Rip finished",
@@ -190,3 +194,155 @@ export const Interactive: Story = {
190
194
  )
191
195
  },
192
196
  }
197
+
198
+ /**
199
+ * Twelve at once, which is the case the bottom-anchored column
200
+ * cannot hold. Without a bound the oldest climb off the top of the
201
+ * page and take the stack with them; the list is capped, pinned to
202
+ * its bottom edge, and scrolls. The newest is always the one on
203
+ * screen.
204
+ */
205
+ export const ManyAtOnce: Story = {
206
+ args: { title: "Rip finished" },
207
+ parameters: { layout: "fullscreen" },
208
+ render: function ManyRegion() {
209
+ const [toasts, setToasts] = useState<ToastRecord[]>(
210
+ Array.from({ length: 12 }, (_, index) => ({
211
+ description: `Bay ${index + 1} finished.`,
212
+ duration: 0,
213
+ intent: "success" as const,
214
+ key: `rip-${index}`,
215
+ title: `Rip ${index + 1} finished`,
216
+ })),
217
+ )
218
+
219
+ return (
220
+ <div className="h-dvh">
221
+ <ToastRegion
222
+ onDismiss={(key) => {
223
+ setToasts((previous) =>
224
+ previous.filter((one) => one.key !== key),
225
+ )
226
+ }}
227
+ toasts={toasts}
228
+ />
229
+ </div>
230
+ )
231
+ },
232
+ }
233
+
234
+ /**
235
+ * The story the `z-50` bug needed and never had.
236
+ *
237
+ * Two stacked modals and a pinned toast carrying an Undo. Three
238
+ * things have to be true at once, and each one was false before the
239
+ * layering change:
240
+ *
241
+ * 1. The toast paints **above both modals**, which cascade at
242
+ * `--layer-modal + n`.
243
+ * 2. Pressing its Undo does **not** close the modal — a press on a
244
+ * toast is not an outside press.
245
+ * 3. The toast stays in the accessibility tree while the focus
246
+ * manager hides the rest of the page.
247
+ */
248
+ export const OverStackedModals: Story = {
249
+ args: { title: "Rip finished" },
250
+ parameters: { layout: "fullscreen" },
251
+ render: function OverModalsRegion() {
252
+ const [isOuterVisible, setIsOuterVisible] =
253
+ useState(true)
254
+
255
+ const [isInnerVisible, setIsInnerVisible] =
256
+ useState(true)
257
+
258
+ const [isUndone, setIsUndone] = useState(false)
259
+
260
+ const [toasts, setToasts] = useState<ToastRecord[]>([
261
+ {
262
+ description: "Undo",
263
+ duration: 0,
264
+ intent: "success",
265
+ key: "moved",
266
+ title: "Moved to Done",
267
+ },
268
+ ])
269
+
270
+ return (
271
+ <OverlayStackProvider>
272
+ <div className="h-dvh p-4">
273
+ {/* Page content beside the region, and load-bearing.
274
+ `markOthers` marks it, which is what makes
275
+ `OverlayPanel`'s `outsidePress` predicate decide the
276
+ press on Undo — a story holding nothing but the
277
+ region takes a different branch inside floating-ui
278
+ and passes whether the guard exists or not. */}
279
+ <h2 className="m-0 text-lg">Rip queue</h2>
280
+
281
+ <p className="m-0">
282
+ Four bays, three of them busy.
283
+ </p>
284
+
285
+ <Modal
286
+ aria-label="Rip settings"
287
+ isVisible={isOuterVisible}
288
+ onClose={() => {
289
+ setIsOuterVisible(false)
290
+ }}
291
+ >
292
+ <div className="flex flex-col gap-2 p-4">
293
+ <p className="m-0">The outer modal.</p>
294
+
295
+ <Button
296
+ onClick={() => {
297
+ setIsInnerVisible(true)
298
+ }}
299
+ >
300
+ Open the inner modal
301
+ </Button>
302
+ </div>
303
+ </Modal>
304
+
305
+ <Modal
306
+ aria-label="Confirm the rip"
307
+ isVisible={isOuterVisible && isInnerVisible}
308
+ onClose={() => {
309
+ setIsInnerVisible(false)
310
+ }}
311
+ >
312
+ <div className="flex flex-col gap-2 p-4">
313
+ <p className="m-0">
314
+ The inner modal, on top of the outer one.
315
+ </p>
316
+
317
+ <p className="m-0">
318
+ {isUndone ? "Undone" : "Not undone"}
319
+ </p>
320
+ </div>
321
+ </Modal>
322
+
323
+ <ToastRegion
324
+ onDismiss={(key) => {
325
+ setToasts((previous) =>
326
+ previous.filter((one) => one.key !== key),
327
+ )
328
+ }}
329
+ toasts={toasts.map((one) => ({
330
+ ...one,
331
+ description: (
332
+ <Button
333
+ onClick={() => {
334
+ setIsUndone(true)
335
+ }}
336
+ appearance="ghost"
337
+ size="sm"
338
+ >
339
+ Undo
340
+ </Button>
341
+ ),
342
+ }))}
343
+ />
344
+ </div>
345
+ </OverlayStackProvider>
346
+ )
347
+ },
348
+ }
@@ -8,7 +8,7 @@ import { expectAgentDrivable } from "../testing/index.ts"
8
8
  import * as stories from "./Toast.stories.tsx"
9
9
  import { toastTransitions } from "./toastLifecycle.ts"
10
10
 
11
- const { AllStates, AllVariants, Default, Interactive } =
11
+ const { AllStates, AllVariants, Playground, Interactive } =
12
12
  composeStories(stories)
13
13
 
14
14
  /**
@@ -180,7 +180,7 @@ test("a dismiss during the enter frame is not undone by it", async () => {
180
180
  })
181
181
 
182
182
  test("a pinned toast stays put", async () => {
183
- const { canvas } = await mountStory(Default)
183
+ const { canvas } = await mountStory(Playground)
184
184
 
185
185
  const dismiss = expectAgentDrivable(canvas, {
186
186
  name: "Dismiss Rip finished",
@@ -0,0 +1,172 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, waitFor } from "storybook/test"
3
+ import { test, expect as vitestExpect } from "vitest"
4
+
5
+ import { mountStory } from "../mountStory.testHelpers.ts"
6
+ import { expectAgentDrivable } from "../testing/index.ts"
7
+ import * as stories from "./Toast.stories.tsx"
8
+
9
+ const { ManyAtOnce, OverStackedModals } =
10
+ composeStories(stories)
11
+
12
+ const layerOf = (element: Element): number =>
13
+ Number.parseInt(
14
+ globalThis.getComputedStyle(element).zIndex,
15
+ 10,
16
+ )
17
+
18
+ /**
19
+ * The region used to paint at a hand-picked `z-50`, which is below
20
+ * every overlay token in the scale — so an Undo raised from inside a
21
+ * modal was drawn *behind* the modal and could not be pressed at
22
+ * all. These are the three things "the toast is on top" has to mean,
23
+ * and each one was false.
24
+ */
25
+ test("the toast paints above a cascade of modals", async () => {
26
+ const { body, canvasElement } = await mountStory(
27
+ OverStackedModals,
28
+ )
29
+
30
+ const [outer, inner] = [
31
+ ...document.querySelectorAll('[role="dialog"]'),
32
+ ].map((panel) => {
33
+ const container = panel.closest("[style*='z-index']")
34
+
35
+ if (!container) {
36
+ throw new Error("a panel rendered with no container")
37
+ }
38
+
39
+ return container
40
+ })
41
+
42
+ if (!outer || !inner) {
43
+ throw new Error("both modals should be open")
44
+ }
45
+
46
+ // Cascading, not one shared `--layer-modal`. Which of the two
47
+ // painted on top used to fall out of portal append order.
48
+ vitestExpect(layerOf(inner)).toBeGreaterThan(
49
+ layerOf(outer),
50
+ )
51
+
52
+ const region = expectAgentDrivable(body, {
53
+ name: "Notifications",
54
+ role: "status",
55
+ })
56
+
57
+ // Above the whole stack, not only its first floor.
58
+ vitestExpect(layerOf(region)).toBeGreaterThan(
59
+ layerOf(inner),
60
+ )
61
+
62
+ // And painted there, which the z-index alone does not prove: a
63
+ // region nested inside a panel's stacking context would report the
64
+ // same number and still be covered.
65
+ const undo = expectAgentDrivable(body, {
66
+ name: "Undo",
67
+ role: "button",
68
+ })
69
+
70
+ const box = undo.getBoundingClientRect()
71
+
72
+ const painted = document.elementFromPoint(
73
+ box.left + box.width / 2,
74
+ box.top + box.height / 2,
75
+ )
76
+
77
+ vitestExpect(undo.contains(painted)).toBe(true)
78
+
79
+ await expect(canvasElement).toBeTruthy()
80
+ })
81
+
82
+ test("pressing Undo does not dismiss the modal under it", async () => {
83
+ const { body } = await mountStory(OverStackedModals)
84
+
85
+ await userEvent.click(
86
+ expectAgentDrivable(body, {
87
+ name: "Undo",
88
+ role: "button",
89
+ }),
90
+ )
91
+
92
+ // The action ran...
93
+ await waitFor(() => {
94
+ vitestExpect(body.getByText("Undone")).toBeTruthy()
95
+ })
96
+
97
+ // ...and `useDismiss` did not read the press as an outside press.
98
+ // Closing a modal on the way to taking an action back is the
99
+ // opposite of what the button says.
100
+ vitestExpect(
101
+ document.querySelectorAll('[role="dialog"]'),
102
+ ).toHaveLength(2)
103
+ })
104
+
105
+ test("the toast stays in the accessibility tree behind a focus trap", async () => {
106
+ const { body } = await mountStory(OverStackedModals)
107
+
108
+ const region = expectAgentDrivable(body, {
109
+ name: "Notifications",
110
+ role: "status",
111
+ })
112
+
113
+ // `FloatingFocusManager`'s `modal` marks the body-level siblings
114
+ // of its portal `aria-hidden` — but `markOthers` concatenates
115
+ // `[aria-live],[role="status"],output` onto its keep-list, so a
116
+ // live region survives where it stands. That is load-bearing: it
117
+ // is why this region does not have to move inside the panel, and
118
+ // why it keeps `role="status"` rather than a bare `div`.
119
+ vitestExpect(region.closest("[aria-hidden='true']")).toBe(
120
+ null,
121
+ )
122
+ vitestExpect(region.closest("[inert]")).toBe(null)
123
+ })
124
+
125
+ test("a tall stack of toasts scrolls instead of climbing off the page", async () => {
126
+ const { body } = await mountStory(ManyAtOnce)
127
+
128
+ const region = expectAgentDrivable(body, {
129
+ name: "Notifications",
130
+ role: "status",
131
+ })
132
+
133
+ const list = region.querySelector("ul")
134
+
135
+ if (!list) {
136
+ throw new Error("the region should render a list")
137
+ }
138
+
139
+ // Bounded, so the column cannot grow past the viewport.
140
+ vitestExpect(list.scrollHeight).toBeGreaterThan(
141
+ list.clientHeight,
142
+ )
143
+ vitestExpect(
144
+ list.getBoundingClientRect().top,
145
+ ).toBeGreaterThanOrEqual(0)
146
+
147
+ // Taken to the far end on arrival: the newest toast is the one
148
+ // carrying the action, and it is the one at the bottom.
149
+ const newest = body.getByText("Rip 12 finished")
150
+
151
+ vitestExpect(
152
+ newest.getBoundingClientRect().bottom,
153
+ ).toBeLessThanOrEqual(
154
+ list.getBoundingClientRect().bottom + 1,
155
+ )
156
+
157
+ // And the oldest is reachable rather than clipped away. This is
158
+ // the assertion `justify-content: flex-end` fails: chromium does
159
+ // not make a flex container's start-edge overflow scrollable, so
160
+ // the toasts above the fold could not be reached at all.
161
+ list.scrollTop = 0
162
+
163
+ await waitFor(() => {
164
+ const oldest = body.getByText("Rip 1 finished")
165
+
166
+ vitestExpect(
167
+ oldest.getBoundingClientRect().top,
168
+ ).toBeGreaterThanOrEqual(
169
+ list.getBoundingClientRect().top - 1,
170
+ )
171
+ })
172
+ })
@@ -1,6 +1,8 @@
1
1
  import type { IntentName } from "@charcuterie/tokens"
2
2
  import type { ReactNode } from "react"
3
+ import { useEffect, useRef } from "react"
3
4
 
5
+ import { useOverlayStack } from "../Overlay/OverlayStack.tsx"
4
6
  import { toClassName } from "../toClassName.ts"
5
7
  import { Toast } from "./Toast.tsx"
6
8
 
@@ -52,47 +54,133 @@ export type ToastRegionProps = {
52
54
  * sync. The lifecycle *inside* one toast is state a store cannot
53
55
  * see, which is why `Toast` owns that and nothing more.
54
56
  *
57
+ * ### It is the top of the layer scale, and it moves to stay there
58
+ *
59
+ * ⚠️ The container was `z-50` — a hand-picked number, in the library
60
+ * whose whole point is that nobody picks one. It is
61
+ * `--layer-toast` now, the top of the scale, above `--layer-tooltip`.
62
+ * The owner's rule: *"I think toast should always exist on top of
63
+ * everything."*
64
+ *
65
+ * The open panels cascade — a modal over a modal paints at
66
+ * `--layer-modal + n` — so the depth is added here too, and the
67
+ * toast clears the whole stack rather than only its first floor.
68
+ * Both numbers come off `useOverlayStack`, so the ordering is
69
+ * arithmetic rather than portal-append order.
70
+ *
71
+ * ### Painting on top is only half of "on top"
72
+ *
73
+ * ⚠️ A press on a toast while a `Modal` is open is an **outside**
74
+ * press as far as floating-ui is concerned, and `useDismiss` would
75
+ * close the modal on the way to taking an action back. That is why
76
+ * the container carries `data-charcuterie-toast-region`:
77
+ * `OverlayPanel`'s `outsidePress` reads it and lets the press
78
+ * through. Nothing else in the fleet carries that attribute.
79
+ *
80
+ * ⚠️ Do **not** move this region inside the panel's portal to solve
81
+ * that. Two reasons, both measured. `FloatingFocusManager`'s `modal`
82
+ * already leaves it alone — `markOthers` concatenates
83
+ * `body.querySelectorAll('[aria-live],[role="status"],output')` onto
84
+ * its keep-list, and this region is a `role="status"`, so it is
85
+ * never marked `aria-hidden` where it stands. And the panel's
86
+ * container sets a z-index, so a region nested inside it would be
87
+ * trapped in that stacking context and lose to the *next* modal
88
+ * however high `--layer-toast` climbs.
89
+ *
55
90
  * ### `pointer-events-none` on the container, `auto` on each toast
56
91
  *
57
92
  * A fixed overlay across the bottom of the viewport swallows every
58
93
  * click in that band, including on the app behind it, and it does so
59
94
  * invisibly. The container passes clicks through and each toast
60
95
  * takes them back.
96
+ *
97
+ * The list keeps `pointer-events: none` and still scrolls: a wheel
98
+ * event over a toast chains to the nearest scrollable **ancestor**,
99
+ * which is the list, and hit-testing never has to reach it.
100
+ *
101
+ * ### Many at once, and the newest is the one you can see
102
+ *
103
+ * The column grows upward from the bottom of the viewport, so
104
+ * without a bound the oldest toasts climb off the top of the page —
105
+ * clipped by the viewport, and unreachable, because a fixed element
106
+ * is not something the page can scroll to. The list is capped at
107
+ * `min(60dvh, 32rem)` and scrolls `overscroll-contain`, so the page
108
+ * behind never moves because a notification arrived. The owner's
109
+ * rule: *"if you have multiple toasts, it should accomodate them
110
+ * somehow."*
111
+ *
112
+ * ⚠️ Bottom-pinned with `margin-block-start: auto` on the first
113
+ * toast, **not** `justify-content: flex-end`. Measured in chromium:
114
+ * with `justify-end` an overflowing list reports
115
+ * `scrollHeight === clientHeight` and the toasts above the fold
116
+ * cannot be scrolled to at all — content overflowing a flex
117
+ * container's *start* edge is not reachable overflow. An auto margin
118
+ * resolves to `0` the moment the free space runs out, so the same
119
+ * rule pins a short list and scrolls a long one.
120
+ *
121
+ * The scroll is taken to the bottom whenever the count changes,
122
+ * because the newest toast is the one with the action on it and it
123
+ * is the one at the far end.
61
124
  */
62
125
  export const ToastRegion = ({
63
126
  className,
64
127
  label = "Notifications",
65
128
  onDismiss,
66
129
  toasts,
67
- }: ToastRegionProps): ReactNode => (
68
- <div
69
- aria-label={label}
70
- // `role="status"` is `aria-live="polite"` plus
71
- // `aria-atomic="true"` by default — and atomic is wrong here,
72
- // because it re-reads the entire stack every time one arrives.
73
- // Spelling `aria-atomic="false"` next to it is what makes a
74
- // second toast announce only itself.
75
- aria-atomic="false"
76
- className={toClassName(
77
- "pointer-events-none fixed inset-x-0 bottom-0 z-50 flex flex-col items-center gap-2 p-4",
78
- className,
79
- )}
80
- role="status"
81
- >
82
- <ul className="pointer-events-none flex w-full max-w-sm list-none flex-col gap-2 p-0">
83
- {toasts.map((toast) => (
84
- <Toast
85
- duration={toast.duration}
86
- intent={toast.intent}
87
- key={toast.key}
88
- onRemove={() => {
89
- onDismiss(toast.key)
90
- }}
91
- title={toast.title}
92
- >
93
- {toast.description}
94
- </Toast>
95
- ))}
96
- </ul>
97
- </div>
98
- )
130
+ }: ToastRegionProps): ReactNode => {
131
+ const { depth } = useOverlayStack()
132
+
133
+ const listRef = useRef<HTMLUListElement>(null)
134
+
135
+ const _toastCount = toasts.length
136
+
137
+ useEffect(() => {
138
+ const list = listRef.current
139
+
140
+ if (!list) {
141
+ return
142
+ }
143
+
144
+ list.scrollTop = list.scrollHeight
145
+ }, [])
146
+
147
+ return (
148
+ <div
149
+ aria-label={label}
150
+ data-charcuterie-toast-region=""
151
+ // `role="status"` is `aria-live="polite"` plus
152
+ // `aria-atomic="true"` by default — and atomic is wrong here,
153
+ // because it re-reads the entire stack every time one arrives.
154
+ // Spelling `aria-atomic="false"` next to it is what makes a
155
+ // second toast announce only itself.
156
+ aria-atomic="false"
157
+ className={toClassName(
158
+ "pointer-events-none fixed inset-x-0 bottom-0 flex flex-col items-center gap-2 p-4",
159
+ className,
160
+ )}
161
+ role="status"
162
+ style={{
163
+ zIndex: `calc(var(--layer-toast) + ${depth})`,
164
+ }}
165
+ >
166
+ <ul
167
+ ref={listRef}
168
+ className="charcuterie-scrollbar pointer-events-none flex max-h-[min(60dvh,32rem)] w-full max-w-sm list-none flex-col gap-2 overflow-y-auto overscroll-contain p-0 [&>li]:shrink-0 [&>li:first-child]:mt-auto"
169
+ >
170
+ {toasts.map((toast) => (
171
+ <Toast
172
+ duration={toast.duration}
173
+ intent={toast.intent}
174
+ key={toast.key}
175
+ onRemove={() => {
176
+ onDismiss(toast.key)
177
+ }}
178
+ title={toast.title}
179
+ >
180
+ {toast.description}
181
+ </Toast>
182
+ ))}
183
+ </ul>
184
+ </div>
185
+ )
186
+ }
@@ -9,7 +9,7 @@ import * as ToolbarStories from "./Toolbar.stories";
9
9
  A row of controls that collapses into an overflow when it runs out of room — measured, not
10
10
  breakpointed, with **exactly one instance** of every control in the document.
11
11
 
12
- <Canvas of={ToolbarStories.Default} />
12
+ <Canvas of={ToolbarStories.Playground} />
13
13
 
14
14
  ## Props
15
15