@charcuterie/ui 4.5.1 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/dist/LaneTimeline/LaneTimeline.d.ts +132 -0
- package/dist/LaneTimeline/LaneTimeline.d.ts.map +1 -0
- package/dist/LaneTimeline/LaneTimeline.js +165 -0
- package/dist/LaneTimeline/LaneTimeline.js.map +1 -0
- package/dist/LaneTimeline/LaneTimelineBar.d.ts +97 -0
- package/dist/LaneTimeline/LaneTimelineBar.d.ts.map +1 -0
- package/dist/LaneTimeline/LaneTimelineBar.js +69 -0
- package/dist/LaneTimeline/LaneTimelineBar.js.map +1 -0
- package/dist/LaneTimeline/LaneTimelineLane.d.ts +84 -0
- package/dist/LaneTimeline/LaneTimelineLane.d.ts.map +1 -0
- package/dist/LaneTimeline/LaneTimelineLane.js +109 -0
- package/dist/LaneTimeline/LaneTimelineLane.js.map +1 -0
- package/dist/LaneTimeline/laneTimelineGeometry.d.ts +268 -0
- package/dist/LaneTimeline/laneTimelineGeometry.d.ts.map +1 -0
- package/dist/LaneTimeline/laneTimelineGeometry.js +277 -0
- package/dist/LaneTimeline/laneTimelineGeometry.js.map +1 -0
- package/dist/LaneTimeline/useTrackMeasurements.d.ts +69 -0
- package/dist/LaneTimeline/useTrackMeasurements.d.ts.map +1 -0
- package/dist/LaneTimeline/useTrackMeasurements.js +134 -0
- package/dist/LaneTimeline/useTrackMeasurements.js.map +1 -0
- package/dist/Overlay/OverlayPanel.d.ts.map +1 -1
- package/dist/Overlay/OverlayPanel.js +54 -2
- package/dist/Overlay/OverlayPanel.js.map +1 -1
- package/dist/Overlay/OverlayStack.d.ts +5 -0
- package/dist/Overlay/OverlayStack.d.ts.map +1 -1
- package/dist/Overlay/OverlayStack.js +2 -0
- package/dist/Overlay/OverlayStack.js.map +1 -1
- package/dist/Toast/ToastRegion.d.ts +61 -0
- package/dist/Toast/ToastRegion.d.ts.map +1 -1
- package/dist/Toast/ToastRegion.js +80 -3
- package/dist/Toast/ToastRegion.js.map +1 -1
- package/dist/Toolbar/useToolbarOverflow.d.ts.map +1 -1
- package/dist/Toolbar/useToolbarOverflow.js +39 -0
- package/dist/Toolbar/useToolbarOverflow.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/LaneTimeline/LaneTimeline.mdx +191 -0
- package/src/LaneTimeline/LaneTimeline.stories.tsx +561 -0
- package/src/LaneTimeline/LaneTimeline.test.tsx +330 -0
- package/src/LaneTimeline/LaneTimeline.tsx +381 -0
- package/src/LaneTimeline/LaneTimelineBar.tsx +237 -0
- package/src/LaneTimeline/LaneTimelineLane.tsx +332 -0
- package/src/LaneTimeline/laneTimelineGeometry.test.ts +429 -0
- package/src/LaneTimeline/laneTimelineGeometry.ts +476 -0
- package/src/LaneTimeline/useTrackMeasurements.ts +165 -0
- package/src/Overlay/OverlayPanel.tsx +63 -2
- package/src/Overlay/OverlayStack.tsx +7 -1
- package/src/Toast/Toast.stories.tsx +154 -0
- package/src/Toast/ToastRegion.test.tsx +172 -0
- package/src/Toast/ToastRegion.tsx +120 -32
- package/src/Toolbar/Toolbar.test.tsx +84 -0
- package/src/Toolbar/useToolbarOverflow.ts +43 -0
- package/src/index.ts +13 -0
- package/src/sourceRules.test.ts +22 -1
|
@@ -117,13 +117,55 @@ export const OverlayPanel = ({
|
|
|
117
117
|
// `outsidePress` on a *lower* modal would close it too, so it is
|
|
118
118
|
// scoped to the top — which is also why the scrim needs no click
|
|
119
119
|
// handler of its own.
|
|
120
|
-
outsidePress:
|
|
120
|
+
outsidePress: (event) => {
|
|
121
|
+
if (!isDismissable || !isTop) return false
|
|
122
|
+
/*
|
|
123
|
+
* ⚠️ A press on a toast is NOT an outside press.
|
|
124
|
+
*
|
|
125
|
+
* A `ToastRegion` renders in the page, so floating-ui sees a
|
|
126
|
+
* press on its Undo as a press outside the floating element
|
|
127
|
+
* — and closing the modal on the way to taking an action
|
|
128
|
+
* back is the opposite of what the button says. The region
|
|
129
|
+
* marks itself with `data-charcuterie-toast-region`; nothing
|
|
130
|
+
* else in the fleet carries that attribute.
|
|
131
|
+
*
|
|
132
|
+
* ⚠️ Do not delete this on the evidence of a small story.
|
|
133
|
+
* floating-ui has a *second*, accidental guard that hides
|
|
134
|
+
* this one: `closeOnPressOutside` bails out early when the
|
|
135
|
+
* press target's body-level ancestor contains none of the
|
|
136
|
+
* `[data-floating-ui-inert]` markers `markOthers` left
|
|
137
|
+
* behind, on the theory that such an element was injected
|
|
138
|
+
* after the panel opened. A page that is nothing but a
|
|
139
|
+
* region takes that branch and never reaches this predicate
|
|
140
|
+
* at all. A page with content beside the region — every real
|
|
141
|
+
* app — does reach it. `Toast.stories.tsx`'s
|
|
142
|
+
* `OverStackedModals` carries a heading and a paragraph for
|
|
143
|
+
* exactly that reason, and `ToastRegion.test.tsx` fails
|
|
144
|
+
* without this predicate only because they are there.
|
|
145
|
+
*/
|
|
146
|
+
const target = event.target as Element | null
|
|
147
|
+
return (
|
|
148
|
+
target?.closest?.(
|
|
149
|
+
"[data-charcuterie-toast-region]",
|
|
150
|
+
) == null
|
|
151
|
+
)
|
|
152
|
+
},
|
|
121
153
|
}),
|
|
122
154
|
useRole(context, { role }),
|
|
123
155
|
])
|
|
124
156
|
|
|
125
157
|
const { register, unregister } = stack
|
|
126
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Where this panel sits in the stack, and therefore how far above
|
|
161
|
+
* `--layer-modal` it paints. `0` when unprovided — a lone `Modal`
|
|
162
|
+
* is the only thing open.
|
|
163
|
+
*/
|
|
164
|
+
const stackIndex = Math.max(
|
|
165
|
+
stack.orderedIds.indexOf(id),
|
|
166
|
+
0,
|
|
167
|
+
)
|
|
168
|
+
|
|
127
169
|
useEffect(() => {
|
|
128
170
|
if (!isVisible) {
|
|
129
171
|
return
|
|
@@ -169,7 +211,26 @@ export const OverlayPanel = ({
|
|
|
169
211
|
)}
|
|
170
212
|
|
|
171
213
|
<FloatingPortal>
|
|
172
|
-
<div
|
|
214
|
+
<div
|
|
215
|
+
className="fixed inset-0 flex items-center justify-center"
|
|
216
|
+
/*
|
|
217
|
+
* ⚠️ Cascading, not one shared layer.
|
|
218
|
+
*
|
|
219
|
+
* Every panel used to render at the one `--layer-modal`, and
|
|
220
|
+
* which of two open modals painted on top fell out of the
|
|
221
|
+
* order their portals happened to be appended in. A modal
|
|
222
|
+
* opened from inside another is the case that made this
|
|
223
|
+
* visible. The index in the stack is the answer the stack
|
|
224
|
+
* already knows.
|
|
225
|
+
*
|
|
226
|
+
* The scale's gaps are 100, so a cascade this deep is
|
|
227
|
+
* arithmetic nobody has to think about — and the toast sits
|
|
228
|
+
* at `--layer-toast` plus the same depth, above all of it.
|
|
229
|
+
*/
|
|
230
|
+
style={{
|
|
231
|
+
zIndex: `calc(var(--layer-modal) + ${stackIndex})`,
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
173
234
|
<FloatingFocusManager
|
|
174
235
|
context={context}
|
|
175
236
|
// `undefined` keeps the manager's own default (the first
|
|
@@ -40,6 +40,11 @@ export type OverlayStackEntry = {
|
|
|
40
40
|
|
|
41
41
|
export type OverlayStackValue = {
|
|
42
42
|
depth: number
|
|
43
|
+
/**
|
|
44
|
+
* The open ids, oldest first. A panel reads its own index out of
|
|
45
|
+
* this to cascade its z-index above the one below it.
|
|
46
|
+
*/
|
|
47
|
+
orderedIds: string[]
|
|
43
48
|
/**
|
|
44
49
|
* `false` in the context default. An `OverlayPanel` reads this to
|
|
45
50
|
* decide whether to render its own scrim (unprovided) or defer to
|
|
@@ -56,6 +61,7 @@ export type OverlayStackValue = {
|
|
|
56
61
|
const DEFAULT_STACK: OverlayStackValue = {
|
|
57
62
|
depth: 0,
|
|
58
63
|
isProvided: false,
|
|
64
|
+
orderedIds: [],
|
|
59
65
|
register: () => {},
|
|
60
66
|
requestCloseTop: () => {},
|
|
61
67
|
topId: null,
|
|
@@ -76,7 +82,6 @@ export const OverlayStackProvider = ({
|
|
|
76
82
|
const [entries, setEntries] = useState<
|
|
77
83
|
OverlayStackEntry[]
|
|
78
84
|
>([])
|
|
79
|
-
|
|
80
85
|
// Read through a ref so `requestCloseTop` stays stable while still
|
|
81
86
|
// seeing the live top.
|
|
82
87
|
const entriesRef = useLatestRef(entries)
|
|
@@ -112,6 +117,7 @@ export const OverlayStackProvider = ({
|
|
|
112
117
|
() => ({
|
|
113
118
|
depth: entries.length,
|
|
114
119
|
isProvided: true,
|
|
120
|
+
orderedIds: entries.map((one) => one.id),
|
|
115
121
|
register,
|
|
116
122
|
requestCloseTop,
|
|
117
123
|
topId: entries.at(-1)?.id ?? null,
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
StoryCell,
|
|
10
10
|
StoryGrid,
|
|
11
11
|
} from "../board.storyHelpers.tsx"
|
|
12
|
+
import { Modal } from "../Modal/Modal.tsx"
|
|
13
|
+
import { OverlayStackProvider } from "../Overlay/OverlayStack.tsx"
|
|
12
14
|
import { Toast } from "./Toast.tsx"
|
|
13
15
|
import type { ToastRecord } from "./ToastRegion.tsx"
|
|
14
16
|
import { ToastRegion } from "./ToastRegion.tsx"
|
|
@@ -192,3 +194,155 @@ export const Interactive: Story = {
|
|
|
192
194
|
)
|
|
193
195
|
},
|
|
194
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
|
+
}
|
|
@@ -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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
+
}
|