@charcuterie/ui 4.5.1 → 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.
@@ -1 +1 @@
1
- {"version":3,"file":"OverlayPanel.d.ts","sourceRoot":"","sources":["../../src/Overlay/OverlayPanel.tsx"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAOjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,QAAQ,EAAE,SAAS,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;;;;;OASG;IACH,YAAY,CAAC,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAA;IAC5C,8CAA8C;IAC9C,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,SAAS,EAAE,OAAO,CAAA;IAClB,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,IAAI,CAAC,EAAE,aAAa,GAAG,QAAQ,CAAA;CAChC,CAAA;AAED,eAAO,MAAM,YAAY,GAAI,6IAU1B,iBAAiB,KAAG,SA0HtB,CAAA"}
1
+ {"version":3,"file":"OverlayPanel.d.ts","sourceRoot":"","sources":["../../src/Overlay/OverlayPanel.tsx"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAOjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,QAAQ,EAAE,SAAS,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;;;;;OASG;IACH,YAAY,CAAC,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAA;IAC5C,8CAA8C;IAC9C,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,SAAS,EAAE,OAAO,CAAA;IAClB,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,IAAI,CAAC,EAAE,aAAa,GAAG,QAAQ,CAAA;CAChC,CAAA;AAED,eAAO,MAAM,YAAY,GAAI,6IAU1B,iBAAiB,KAAG,SAuLtB,CAAA"}
@@ -30,11 +30,46 @@ export const OverlayPanel = ({ "aria-label": ariaLabel, "aria-labelledby": ariaL
30
30
  // `outsidePress` on a *lower* modal would close it too, so it is
31
31
  // scoped to the top — which is also why the scrim needs no click
32
32
  // handler of its own.
33
- outsidePress: isDismissable && isTop,
33
+ outsidePress: (event) => {
34
+ if (!isDismissable || !isTop)
35
+ return false;
36
+ /*
37
+ * ⚠️ A press on a toast is NOT an outside press.
38
+ *
39
+ * A `ToastRegion` renders in the page, so floating-ui sees a
40
+ * press on its Undo as a press outside the floating element
41
+ * — and closing the modal on the way to taking an action
42
+ * back is the opposite of what the button says. The region
43
+ * marks itself with `data-charcuterie-toast-region`; nothing
44
+ * else in the fleet carries that attribute.
45
+ *
46
+ * ⚠️ Do not delete this on the evidence of a small story.
47
+ * floating-ui has a *second*, accidental guard that hides
48
+ * this one: `closeOnPressOutside` bails out early when the
49
+ * press target's body-level ancestor contains none of the
50
+ * `[data-floating-ui-inert]` markers `markOthers` left
51
+ * behind, on the theory that such an element was injected
52
+ * after the panel opened. A page that is nothing but a
53
+ * region takes that branch and never reaches this predicate
54
+ * at all. A page with content beside the region — every real
55
+ * app — does reach it. `Toast.stories.tsx`'s
56
+ * `OverStackedModals` carries a heading and a paragraph for
57
+ * exactly that reason, and `ToastRegion.test.tsx` fails
58
+ * without this predicate only because they are there.
59
+ */
60
+ const target = event.target;
61
+ return (target?.closest?.("[data-charcuterie-toast-region]") == null);
62
+ },
34
63
  }),
35
64
  useRole(context, { role }),
36
65
  ]);
37
66
  const { register, unregister } = stack;
67
+ /**
68
+ * Where this panel sits in the stack, and therefore how far above
69
+ * `--layer-modal` it paints. `0` when unprovided — a lone `Modal`
70
+ * is the only thing open.
71
+ */
72
+ const stackIndex = Math.max(stack.orderedIds.indexOf(id), 0);
38
73
  useEffect(() => {
39
74
  if (!isVisible) {
40
75
  return;
@@ -58,7 +93,24 @@ export const OverlayPanel = ({ "aria-label": ariaLabel, "aria-labelledby": ariaL
58
93
  if (!isVisible) {
59
94
  return null;
60
95
  }
61
- return (_jsxs(_Fragment, { children: [stack.isProvided ? null : (_jsx(SharedBackdrop, { isVisible: true })), _jsx(FloatingPortal, { children: _jsx("div", { className: "fixed inset-0 z-[var(--layer-modal)] flex items-center justify-center", children: _jsx(FloatingFocusManager, { context: context,
96
+ return (_jsxs(_Fragment, { children: [stack.isProvided ? null : (_jsx(SharedBackdrop, { isVisible: true })), _jsx(FloatingPortal, { children: _jsx("div", { className: "fixed inset-0 flex items-center justify-center",
97
+ /*
98
+ * ⚠️ Cascading, not one shared layer.
99
+ *
100
+ * Every panel used to render at the one `--layer-modal`, and
101
+ * which of two open modals painted on top fell out of the
102
+ * order their portals happened to be appended in. A modal
103
+ * opened from inside another is the case that made this
104
+ * visible. The index in the stack is the answer the stack
105
+ * already knows.
106
+ *
107
+ * The scale's gaps are 100, so a cascade this deep is
108
+ * arithmetic nobody has to think about — and the toast sits
109
+ * at `--layer-toast` plus the same depth, above all of it.
110
+ */
111
+ style: {
112
+ zIndex: `calc(var(--layer-modal) + ${stackIndex})`,
113
+ }, children: _jsx(FloatingFocusManager, { context: context,
62
114
  // `undefined` keeps the manager's own default (the first
63
115
  // tabbable), so nothing changes for a panel that does not
64
116
  // ask.
@@ -1 +1 @@
1
- {"version":3,"file":"OverlayPanel.js","sourceRoot":"","sources":["../../src/Overlay/OverlayPanel.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"OverlayPanel.js","sourceRoot":"","sources":["../../src/Overlay/OverlayPanel.tsx"],"names":[],"mappings":"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"}
@@ -28,6 +28,11 @@ export type OverlayStackEntry = {
28
28
  };
29
29
  export type OverlayStackValue = {
30
30
  depth: number;
31
+ /**
32
+ * The open ids, oldest first. A panel reads its own index out of
33
+ * this to cascade its z-index above the one below it.
34
+ */
35
+ orderedIds: string[];
31
36
  /**
32
37
  * `false` in the context default. An `OverlayPanel` reads this to
33
38
  * decide whether to render its own scrim (unprovided) or defer to
@@ -1 +1 @@
1
- {"version":3,"file":"OverlayStack.d.ts","sourceRoot":"","sources":["../../src/Overlay/OverlayStack.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAWtC;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb;;;;OAIG;IACH,UAAU,EAAE,OAAO,CAAA;IACnB,QAAQ,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAA;IAC5C,sEAAsE;IACtE,eAAe,EAAE,MAAM,IAAI,CAAA;IAC3B,KAAK,EAAE,IAAI,GAAG,MAAM,CAAA;IACpB,UAAU,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;CACjC,CAAA;AAcD,eAAO,MAAM,eAAe,QAAO,iBACF,CAAA;AAEjC,eAAO,MAAM,oBAAoB,GAAI,eAElC;IACD,QAAQ,EAAE,SAAS,CAAA;CACpB,KAAG,SAyDH,CAAA"}
1
+ {"version":3,"file":"OverlayStack.d.ts","sourceRoot":"","sources":["../../src/Overlay/OverlayStack.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAWtC;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb;;;OAGG;IACH,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB;;;;OAIG;IACH,UAAU,EAAE,OAAO,CAAA;IACnB,QAAQ,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAA;IAC5C,sEAAsE;IACtE,eAAe,EAAE,MAAM,IAAI,CAAA;IAC3B,KAAK,EAAE,IAAI,GAAG,MAAM,CAAA;IACpB,UAAU,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;CACjC,CAAA;AAeD,eAAO,MAAM,eAAe,QAAO,iBACF,CAAA;AAEjC,eAAO,MAAM,oBAAoB,GAAI,eAElC;IACD,QAAQ,EAAE,SAAS,CAAA;CACpB,KAAG,SAyDH,CAAA"}
@@ -5,6 +5,7 @@ import { SharedBackdrop } from "./SharedBackdrop.js";
5
5
  const DEFAULT_STACK = {
6
6
  depth: 0,
7
7
  isProvided: false,
8
+ orderedIds: [],
8
9
  register: () => { },
9
10
  requestCloseTop: () => { },
10
11
  topId: null,
@@ -36,6 +37,7 @@ export const OverlayStackProvider = ({ children, }) => {
36
37
  const value = useMemo(() => ({
37
38
  depth: entries.length,
38
39
  isProvided: true,
40
+ orderedIds: entries.map((one) => one.id),
39
41
  register,
40
42
  requestCloseTop,
41
43
  topId: entries.at(-1)?.id ?? null,
@@ -1 +1 @@
1
- {"version":3,"file":"OverlayStack.js","sourceRoot":"","sources":["../../src/Overlay/OverlayStack.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAEjD,OAAO,EACL,aAAa,EACb,WAAW,EACX,UAAU,EACV,OAAO,EACP,QAAQ,GACT,MAAM,OAAO,CAAA;AAEd,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAsB,CAAA;AA6CrD,MAAM,aAAa,GAAsB;IACvC,KAAK,EAAE,CAAC;IACR,UAAU,EAAE,KAAK;IACjB,QAAQ,EAAE,GAAG,EAAE,GAAE,CAAC;IAClB,eAAe,EAAE,GAAG,EAAE,GAAE,CAAC;IACzB,KAAK,EAAE,IAAI;IACX,UAAU,EAAE,GAAG,EAAE,GAAE,CAAC;CACrB,CAAA;AAED,MAAM,mBAAmB,GACvB,aAAa,CAAoB,aAAa,CAAC,CAAA;AAEjD,MAAM,CAAC,MAAM,eAAe,GAAG,GAAsB,EAAE,CACrD,UAAU,CAAC,mBAAmB,CAAC,CAAA;AAEjC,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,EACnC,QAAQ,GAGT,EAAa,EAAE;IACd,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,GAAG,QAAQ,CAEpC,EAAE,CAAC,CAAA;IAEL,mEAAmE;IACnE,uBAAuB;IACvB,MAAM,UAAU,GAAG,YAAY,CAAC,OAAO,CAAC,CAAA;IAExC,MAAM,QAAQ,GAAG,WAAW,CAC1B,CAAC,KAAwB,EAAE,EAAE;QAC3B,UAAU,CAAC,CAAC,QAAQ,EAAE,EAAE;QACtB,yDAAyD;QACzD,0DAA0D;QAC1D,8DAA8D;QAC9D,yCAAyC;QACzC,QAAQ,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,KAAK,CAAC,EAAE,CAAC;YACzC,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CACnB,GAAG,CAAC,EAAE,KAAK,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,CAClC;YACH,CAAC,CAAC,CAAC,GAAG,QAAQ,EAAE,KAAK,CAAC,CACzB,CAAA;IACH,CAAC,EACD,EAAE,CACH,CAAA;IAED,MAAM,UAAU,GAAG,WAAW,CAAC,CAAC,EAAU,EAAE,EAAE;QAC5C,UAAU,CAAC,CAAC,QAAQ,EAAE,EAAE,CACtB,QAAQ,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,EAAE,CAAC,CACxC,CAAA;IACH,CAAC,EAAE,EAAE,CAAC,CAAA;IAEN,MAAM,eAAe,GAAG,WAAW,CAAC,GAAG,EAAE;QACvC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAA;IACtC,CAAC,EAAE,CAAC,UAAU,CAAC,CAAC,CAAA;IAEhB,MAAM,KAAK,GAAG,OAAO,CACnB,GAAG,EAAE,CAAC,CAAC;QACL,KAAK,EAAE,OAAO,CAAC,MAAM;QACrB,UAAU,EAAE,IAAI;QAChB,QAAQ;QACR,eAAe;QACf,KAAK,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,IAAI,IAAI;QACjC,UAAU;KACX,CAAC,EACF,CAAC,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,UAAU,CAAC,CACjD,CAAA;IAED,OAAO,CACL,MAAC,mBAAmB,CAAC,QAAQ,IAAC,KAAK,EAAE,KAAK,aAGxC,KAAC,cAAc,IAAC,SAAS,EAAE,OAAO,CAAC,MAAM,GAAG,CAAC,GAAI,EAEhD,QAAQ,IACoB,CAChC,CAAA;AACH,CAAC,CAAA"}
1
+ {"version":3,"file":"OverlayStack.js","sourceRoot":"","sources":["../../src/Overlay/OverlayStack.tsx"],"names":[],"mappings":"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"}
@@ -46,12 +46,73 @@ export type ToastRegionProps = {
46
46
  * sync. The lifecycle *inside* one toast is state a store cannot
47
47
  * see, which is why `Toast` owns that and nothing more.
48
48
  *
49
+ * ### It is the top of the layer scale, and it moves to stay there
50
+ *
51
+ * ⚠️ The container was `z-50` — a hand-picked number, in the library
52
+ * whose whole point is that nobody picks one. It is
53
+ * `--layer-toast` now, the top of the scale, above `--layer-tooltip`.
54
+ * The owner's rule: *"I think toast should always exist on top of
55
+ * everything."*
56
+ *
57
+ * The open panels cascade — a modal over a modal paints at
58
+ * `--layer-modal + n` — so the depth is added here too, and the
59
+ * toast clears the whole stack rather than only its first floor.
60
+ * Both numbers come off `useOverlayStack`, so the ordering is
61
+ * arithmetic rather than portal-append order.
62
+ *
63
+ * ### Painting on top is only half of "on top"
64
+ *
65
+ * ⚠️ A press on a toast while a `Modal` is open is an **outside**
66
+ * press as far as floating-ui is concerned, and `useDismiss` would
67
+ * close the modal on the way to taking an action back. That is why
68
+ * the container carries `data-charcuterie-toast-region`:
69
+ * `OverlayPanel`'s `outsidePress` reads it and lets the press
70
+ * through. Nothing else in the fleet carries that attribute.
71
+ *
72
+ * ⚠️ Do **not** move this region inside the panel's portal to solve
73
+ * that. Two reasons, both measured. `FloatingFocusManager`'s `modal`
74
+ * already leaves it alone — `markOthers` concatenates
75
+ * `body.querySelectorAll('[aria-live],[role="status"],output')` onto
76
+ * its keep-list, and this region is a `role="status"`, so it is
77
+ * never marked `aria-hidden` where it stands. And the panel's
78
+ * container sets a z-index, so a region nested inside it would be
79
+ * trapped in that stacking context and lose to the *next* modal
80
+ * however high `--layer-toast` climbs.
81
+ *
49
82
  * ### `pointer-events-none` on the container, `auto` on each toast
50
83
  *
51
84
  * A fixed overlay across the bottom of the viewport swallows every
52
85
  * click in that band, including on the app behind it, and it does so
53
86
  * invisibly. The container passes clicks through and each toast
54
87
  * takes them back.
88
+ *
89
+ * The list keeps `pointer-events: none` and still scrolls: a wheel
90
+ * event over a toast chains to the nearest scrollable **ancestor**,
91
+ * which is the list, and hit-testing never has to reach it.
92
+ *
93
+ * ### Many at once, and the newest is the one you can see
94
+ *
95
+ * The column grows upward from the bottom of the viewport, so
96
+ * without a bound the oldest toasts climb off the top of the page —
97
+ * clipped by the viewport, and unreachable, because a fixed element
98
+ * is not something the page can scroll to. The list is capped at
99
+ * `min(60dvh, 32rem)` and scrolls `overscroll-contain`, so the page
100
+ * behind never moves because a notification arrived. The owner's
101
+ * rule: *"if you have multiple toasts, it should accomodate them
102
+ * somehow."*
103
+ *
104
+ * ⚠️ Bottom-pinned with `margin-block-start: auto` on the first
105
+ * toast, **not** `justify-content: flex-end`. Measured in chromium:
106
+ * with `justify-end` an overflowing list reports
107
+ * `scrollHeight === clientHeight` and the toasts above the fold
108
+ * cannot be scrolled to at all — content overflowing a flex
109
+ * container's *start* edge is not reachable overflow. An auto margin
110
+ * resolves to `0` the moment the free space runs out, so the same
111
+ * rule pins a short list and scrolls a long one.
112
+ *
113
+ * The scroll is taken to the bottom whenever the count changes,
114
+ * because the newest toast is the one with the action on it and it
115
+ * is the one at the far end.
55
116
  */
56
117
  export declare const ToastRegion: ({ className, label, onDismiss, toasts, }: ToastRegionProps) => ReactNode;
57
118
  //# sourceMappingURL=ToastRegion.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ToastRegion.d.ts","sourceRoot":"","sources":["../../src/Toast/ToastRegion.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AACrD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAKtC,MAAM,MAAM,WAAW,GAAG;IACxB,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB,wDAAwD;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,UAAU,CAAA;IACnB,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;IAChC,MAAM,EAAE,WAAW,EAAE,CAAA;CACtB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,eAAO,MAAM,WAAW,GAAI,0CAKzB,gBAAgB,KAAG,SA+BrB,CAAA"}
1
+ {"version":3,"file":"ToastRegion.d.ts","sourceRoot":"","sources":["../../src/Toast/ToastRegion.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AACrD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAOtC,MAAM,MAAM,WAAW,GAAG;IACxB,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB,wDAAwD;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,UAAU,CAAA;IACnB,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;IAChC,MAAM,EAAE,WAAW,EAAE,CAAA;CACtB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8FG;AACH,eAAO,MAAM,WAAW,GAAI,0CAKzB,gBAAgB,KAAG,SAwDrB,CAAA"}
@@ -1,4 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useRef } from "react";
3
+ import { useOverlayStack } from "../Overlay/OverlayStack.js";
2
4
  import { toClassName } from "../toClassName.js";
3
5
  import { Toast } from "./Toast.js";
4
6
  /**
@@ -28,14 +30,89 @@ import { Toast } from "./Toast.js";
28
30
  * sync. The lifecycle *inside* one toast is state a store cannot
29
31
  * see, which is why `Toast` owns that and nothing more.
30
32
  *
33
+ * ### It is the top of the layer scale, and it moves to stay there
34
+ *
35
+ * ⚠️ The container was `z-50` — a hand-picked number, in the library
36
+ * whose whole point is that nobody picks one. It is
37
+ * `--layer-toast` now, the top of the scale, above `--layer-tooltip`.
38
+ * The owner's rule: *"I think toast should always exist on top of
39
+ * everything."*
40
+ *
41
+ * The open panels cascade — a modal over a modal paints at
42
+ * `--layer-modal + n` — so the depth is added here too, and the
43
+ * toast clears the whole stack rather than only its first floor.
44
+ * Both numbers come off `useOverlayStack`, so the ordering is
45
+ * arithmetic rather than portal-append order.
46
+ *
47
+ * ### Painting on top is only half of "on top"
48
+ *
49
+ * ⚠️ A press on a toast while a `Modal` is open is an **outside**
50
+ * press as far as floating-ui is concerned, and `useDismiss` would
51
+ * close the modal on the way to taking an action back. That is why
52
+ * the container carries `data-charcuterie-toast-region`:
53
+ * `OverlayPanel`'s `outsidePress` reads it and lets the press
54
+ * through. Nothing else in the fleet carries that attribute.
55
+ *
56
+ * ⚠️ Do **not** move this region inside the panel's portal to solve
57
+ * that. Two reasons, both measured. `FloatingFocusManager`'s `modal`
58
+ * already leaves it alone — `markOthers` concatenates
59
+ * `body.querySelectorAll('[aria-live],[role="status"],output')` onto
60
+ * its keep-list, and this region is a `role="status"`, so it is
61
+ * never marked `aria-hidden` where it stands. And the panel's
62
+ * container sets a z-index, so a region nested inside it would be
63
+ * trapped in that stacking context and lose to the *next* modal
64
+ * however high `--layer-toast` climbs.
65
+ *
31
66
  * ### `pointer-events-none` on the container, `auto` on each toast
32
67
  *
33
68
  * A fixed overlay across the bottom of the viewport swallows every
34
69
  * click in that band, including on the app behind it, and it does so
35
70
  * invisibly. The container passes clicks through and each toast
36
71
  * takes them back.
72
+ *
73
+ * The list keeps `pointer-events: none` and still scrolls: a wheel
74
+ * event over a toast chains to the nearest scrollable **ancestor**,
75
+ * which is the list, and hit-testing never has to reach it.
76
+ *
77
+ * ### Many at once, and the newest is the one you can see
78
+ *
79
+ * The column grows upward from the bottom of the viewport, so
80
+ * without a bound the oldest toasts climb off the top of the page —
81
+ * clipped by the viewport, and unreachable, because a fixed element
82
+ * is not something the page can scroll to. The list is capped at
83
+ * `min(60dvh, 32rem)` and scrolls `overscroll-contain`, so the page
84
+ * behind never moves because a notification arrived. The owner's
85
+ * rule: *"if you have multiple toasts, it should accomodate them
86
+ * somehow."*
87
+ *
88
+ * ⚠️ Bottom-pinned with `margin-block-start: auto` on the first
89
+ * toast, **not** `justify-content: flex-end`. Measured in chromium:
90
+ * with `justify-end` an overflowing list reports
91
+ * `scrollHeight === clientHeight` and the toasts above the fold
92
+ * cannot be scrolled to at all — content overflowing a flex
93
+ * container's *start* edge is not reachable overflow. An auto margin
94
+ * resolves to `0` the moment the free space runs out, so the same
95
+ * rule pins a short list and scrolls a long one.
96
+ *
97
+ * The scroll is taken to the bottom whenever the count changes,
98
+ * because the newest toast is the one with the action on it and it
99
+ * is the one at the far end.
37
100
  */
38
- export const ToastRegion = ({ className, label = "Notifications", onDismiss, toasts, }) => (_jsx("div", { "aria-label": label, "aria-atomic": "false", className: toClassName("pointer-events-none fixed inset-x-0 bottom-0 z-50 flex flex-col items-center gap-2 p-4", className), role: "status", children: _jsx("ul", { className: "pointer-events-none flex w-full max-w-sm list-none flex-col gap-2 p-0", children: toasts.map((toast) => (_jsx(Toast, { duration: toast.duration, intent: toast.intent, onRemove: () => {
39
- onDismiss(toast.key);
40
- }, title: toast.title, children: toast.description }, toast.key))) }) }));
101
+ export const ToastRegion = ({ className, label = "Notifications", onDismiss, toasts, }) => {
102
+ const { depth } = useOverlayStack();
103
+ const listRef = useRef(null);
104
+ const _toastCount = toasts.length;
105
+ useEffect(() => {
106
+ const list = listRef.current;
107
+ if (!list) {
108
+ return;
109
+ }
110
+ list.scrollTop = list.scrollHeight;
111
+ }, []);
112
+ return (_jsx("div", { "aria-label": label, "data-charcuterie-toast-region": "", "aria-atomic": "false", className: toClassName("pointer-events-none fixed inset-x-0 bottom-0 flex flex-col items-center gap-2 p-4", className), role: "status", style: {
113
+ zIndex: `calc(var(--layer-toast) + ${depth})`,
114
+ }, children: _jsx("ul", { ref: listRef, 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", children: toasts.map((toast) => (_jsx(Toast, { duration: toast.duration, intent: toast.intent, onRemove: () => {
115
+ onDismiss(toast.key);
116
+ }, title: toast.title, children: toast.description }, toast.key))) }) }));
117
+ };
41
118
  //# sourceMappingURL=ToastRegion.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"ToastRegion.js","sourceRoot":"","sources":["../../src/Toast/ToastRegion.tsx"],"names":[],"mappings":";AAGA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAC/C,OAAO,EAAE,KAAK,EAAE,MAAM,YAAa,CAAA;AAuBnC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,EAC1B,SAAS,EACT,KAAK,GAAG,eAAe,EACvB,SAAS,EACT,MAAM,GACW,EAAa,EAAE,CAAC,CACjC,4BACc,KAAK,iBAML,OAAO,EACnB,SAAS,EAAE,WAAW,CACpB,wFAAwF,EACxF,SAAS,CACV,EACD,IAAI,EAAC,QAAQ,YAEb,aAAI,SAAS,EAAC,uEAAuE,YAClF,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CACrB,KAAC,KAAK,IACJ,QAAQ,EAAE,KAAK,CAAC,QAAQ,EACxB,MAAM,EAAE,KAAK,CAAC,MAAM,EAEpB,QAAQ,EAAE,GAAG,EAAE;gBACb,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;YACtB,CAAC,EACD,KAAK,EAAE,KAAK,CAAC,KAAK,YAEjB,KAAK,CAAC,WAAW,IANb,KAAK,CAAC,GAAG,CAOR,CACT,CAAC,GACC,GACD,CACP,CAAA"}
1
+ {"version":3,"file":"ToastRegion.js","sourceRoot":"","sources":["../../src/Toast/ToastRegion.tsx"],"names":[],"mappings":";AAEA,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,OAAO,CAAA;AAEzC,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA6B,CAAA;AAC7D,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAC/C,OAAO,EAAE,KAAK,EAAE,MAAM,YAAa,CAAA;AAuBnC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8FG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,EAC1B,SAAS,EACT,KAAK,GAAG,eAAe,EACvB,SAAS,EACT,MAAM,GACW,EAAa,EAAE;IAChC,MAAM,EAAE,KAAK,EAAE,GAAG,eAAe,EAAE,CAAA;IAEnC,MAAM,OAAO,GAAG,MAAM,CAAmB,IAAI,CAAC,CAAA;IAE9C,MAAM,WAAW,GAAG,MAAM,CAAC,MAAM,CAAA;IAEjC,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAA;QAE5B,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,OAAM;QACR,CAAC;QAED,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,YAAY,CAAA;IACpC,CAAC,EAAE,EAAE,CAAC,CAAA;IAEN,OAAO,CACL,4BACc,KAAK,mCACa,EAAE,iBAMpB,OAAO,EACnB,SAAS,EAAE,WAAW,CACpB,mFAAmF,EACnF,SAAS,CACV,EACD,IAAI,EAAC,QAAQ,EACb,KAAK,EAAE;YACL,MAAM,EAAE,6BAA6B,KAAK,GAAG;SAC9C,YAED,aACE,GAAG,EAAE,OAAO,EACZ,SAAS,EAAC,oMAAoM,YAE7M,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CACrB,KAAC,KAAK,IACJ,QAAQ,EAAE,KAAK,CAAC,QAAQ,EACxB,MAAM,EAAE,KAAK,CAAC,MAAM,EAEpB,QAAQ,EAAE,GAAG,EAAE;oBACb,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;gBACtB,CAAC,EACD,KAAK,EAAE,KAAK,CAAC,KAAK,YAEjB,KAAK,CAAC,WAAW,IANb,KAAK,CAAC,GAAG,CAOR,CACT,CAAC,GACC,GACD,CACP,CAAA;AACH,CAAC,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"useToolbarOverflow.d.ts","sourceRoot":"","sources":["../../src/Toolbar/useToolbarOverflow.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAUtC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,kBAAkB,GAAI,eAEhC;IACD,qCAAqC;IACrC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;CAC5B,KAAG;IACF,YAAY,EAAE,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAA;IAC9C,2DAA2D;IAC3D,SAAS,EAAE,CACT,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,WAAW,GAAG,IAAI,KACxB,IAAI,CAAA;IACT,mDAAmD;IACnD,YAAY,EAAE,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,KAAK,IAAI,CAAA;IACnD,YAAY,EAAE,MAAM,CAAA;CAwKrB,CAAA"}
1
+ {"version":3,"file":"useToolbarOverflow.d.ts","sourceRoot":"","sources":["../../src/Toolbar/useToolbarOverflow.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAUtC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,kBAAkB,GAAI,eAEhC;IACD,qCAAqC;IACrC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;CAC5B,KAAG;IACF,YAAY,EAAE,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAA;IAC9C,2DAA2D;IAC3D,SAAS,EAAE,CACT,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,WAAW,GAAG,IAAI,KACxB,IAAI,CAAA;IACT,mDAAmD;IACnD,YAAY,EAAE,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,KAAK,IAAI,CAAA;IACnD,YAAY,EAAE,MAAM,CAAA;CAmNrB,CAAA"}
@@ -103,6 +103,45 @@ export const useToolbarOverflow = ({ itemKeys, }) => {
103
103
  * answer, so a stable bar renders once and stops.
104
104
  */
105
105
  useLayoutEffect(measure);
106
+ /**
107
+ * Measure again once the webfont has actually arrived.
108
+ *
109
+ * Every width above is a **text** width, so it is only as correct
110
+ * as the font that was loaded when it was read. On a cold cache
111
+ * the first measurement happens in the fallback face, the items
112
+ * come out a few pixels off, and `chooseVisibleCount` keeps or
113
+ * drops one action it should not have — then never revisits it,
114
+ * because swapping a font changes no box the `ResizeObserver`
115
+ * watches and triggers no render.
116
+ *
117
+ * The user-visible symptom is a bar that settles one button wrong
118
+ * and stays there. It needs the two faces to disagree across a
119
+ * collapse boundary, so it is latent rather than observed: no
120
+ * story in the library crosses one today, which is why `vrt`
121
+ * reports nothing either way. That makes it a gap to close, not a
122
+ * regression to chase — the bar is supposed to measure what is on
123
+ * screen, and until now it could not.
124
+ *
125
+ * `document.fonts.ready` resolves immediately when the fonts are
126
+ * already in, so a warm load pays one microtask and
127
+ * `setVisibleCount` bails on the unchanged answer.
128
+ */
129
+ useLayoutEffect(() => {
130
+ // jsdom has no `document.fonts`, and neither does a very old
131
+ // browser. Nothing to wait for is not an error.
132
+ if (!document.fonts) {
133
+ return;
134
+ }
135
+ let isActive = true;
136
+ document.fonts.ready.then(() => {
137
+ if (isActive) {
138
+ measure();
139
+ }
140
+ });
141
+ return () => {
142
+ isActive = false;
143
+ };
144
+ }, [measure]);
106
145
  useLayoutEffect(() => {
107
146
  const container = containerRef.current;
108
147
  if (!container) {
@@ -1 +1 @@
1
- {"version":3,"file":"useToolbarOverflow.js","sourceRoot":"","sources":["../../src/Toolbar/useToolbarOverflow.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"useToolbarOverflow.js","sourceRoot":"","sources":["../../src/Toolbar/useToolbarOverflow.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcuterie/ui",
3
- "version": "4.5.1",
3
+ "version": "4.6.0",
4
4
  "description": "The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -54,7 +54,7 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@charcuterie/logic": "2.3.0",
57
- "@charcuterie/tokens": "1.9.0",
57
+ "@charcuterie/tokens": "1.10.0",
58
58
  "@floating-ui/react": "^0.27.20",
59
59
  "@tanstack/react-virtual": "^3.13.12",
60
60
  "tailwind-merge": "^3.6.0"
@@ -94,7 +94,7 @@
94
94
  }
95
95
  },
96
96
  "devDependencies": {
97
- "@charcuterie/storybook-config": "0.4.0",
97
+ "@charcuterie/storybook-config": "0.4.1",
98
98
  "@codemirror/commands": "^6",
99
99
  "@codemirror/lang-markdown": "^6",
100
100
  "@codemirror/language": "^6",
@@ -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: isDismissable && isTop,
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 className="fixed inset-0 z-[var(--layer-modal)] flex items-center justify-center">
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
- <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
+ }
@@ -284,3 +284,87 @@ test("the shell mounts exactly one toolbar", async () => {
284
284
  }),
285
285
  ).toHaveLength(1)
286
286
  })
287
+
288
+ /**
289
+ * The font race, which is latent rather than observed: it needs the
290
+ * two faces to disagree across a collapse boundary, and no story in
291
+ * the library crosses one today. The test builds that boundary on
292
+ * purpose, so the mechanism is checked rather than waited for.
293
+ *
294
+ * Every width the bar measures is a **text** width. On a cold cache
295
+ * the first measurement is taken in the fallback face, and when the
296
+ * real face swaps in the items change size — but the container does
297
+ * not, so the `ResizeObserver` stays quiet, and nothing re-renders,
298
+ * so the layout effect does not run either. The bar keeps an answer
299
+ * computed against a font that is no longer on screen.
300
+ *
301
+ * The stub below is that swap with the timing made explicit: the bar
302
+ * mounts while `document.fonts.ready` is still pending, the items
303
+ * are widened with no change to the container's box, and the
304
+ * promise is then resolved. A bar that only measures on mount and
305
+ * on resize sees neither event and stays wrong.
306
+ */
307
+ test("the bar re-measures once the webfont has loaded", async () => {
308
+ const realFonts = document.fonts
309
+
310
+ let markFontsReady = () => {}
311
+
312
+ const fontsReady = new Promise<FontFaceSet>((resolve) => {
313
+ markFontsReady = () => {
314
+ resolve(realFonts)
315
+ }
316
+ })
317
+
318
+ // Layered over the real set rather than replacing it, so anything
319
+ // else that reads `document.fonts` during the mount still works.
320
+ Object.defineProperty(document, "fonts", {
321
+ configurable: true,
322
+ value: Object.create(realFonts, {
323
+ ready: {
324
+ get: () => fontsReady,
325
+ },
326
+ }),
327
+ })
328
+
329
+ // Widens every action without touching the bar's own box — the
330
+ // `Frame` around `Playground` is a fixed `34rem`. That is exactly
331
+ // what a font swap does, and it is why the `ResizeObserver` is
332
+ // not a substitute for this effect.
333
+ const widenActions = document.createElement("style")
334
+
335
+ widenActions.textContent = `[role="toolbar"] button { padding-inline: 4rem; }`
336
+
337
+ try {
338
+ const { canvas } = await mountStory(Playground)
339
+
340
+ const toolbar = expectAgentDrivable(canvas, {
341
+ name: "Deck actions",
342
+ role: "toolbar",
343
+ })
344
+
345
+ // It fits in the fallback face.
346
+ await expect(
347
+ within(toolbar).queryByRole("button", {
348
+ name: "More actions",
349
+ }),
350
+ ).toBeNull()
351
+
352
+ document.head.append(widenActions)
353
+
354
+ markFontsReady()
355
+
356
+ await waitFor(() => {
357
+ expect(
358
+ within(toolbar).getByRole("button", {
359
+ name: "More actions",
360
+ }),
361
+ ).toBeInTheDocument()
362
+ })
363
+ } finally {
364
+ widenActions.remove()
365
+
366
+ // Back to the prototype getter — the stub was an own property
367
+ // shadowing it.
368
+ Reflect.deleteProperty(document, "fonts")
369
+ }
370
+ })
@@ -159,6 +159,49 @@ export const useToolbarOverflow = ({
159
159
  */
160
160
  useLayoutEffect(measure)
161
161
 
162
+ /**
163
+ * Measure again once the webfont has actually arrived.
164
+ *
165
+ * Every width above is a **text** width, so it is only as correct
166
+ * as the font that was loaded when it was read. On a cold cache
167
+ * the first measurement happens in the fallback face, the items
168
+ * come out a few pixels off, and `chooseVisibleCount` keeps or
169
+ * drops one action it should not have — then never revisits it,
170
+ * because swapping a font changes no box the `ResizeObserver`
171
+ * watches and triggers no render.
172
+ *
173
+ * The user-visible symptom is a bar that settles one button wrong
174
+ * and stays there. It needs the two faces to disagree across a
175
+ * collapse boundary, so it is latent rather than observed: no
176
+ * story in the library crosses one today, which is why `vrt`
177
+ * reports nothing either way. That makes it a gap to close, not a
178
+ * regression to chase — the bar is supposed to measure what is on
179
+ * screen, and until now it could not.
180
+ *
181
+ * `document.fonts.ready` resolves immediately when the fonts are
182
+ * already in, so a warm load pays one microtask and
183
+ * `setVisibleCount` bails on the unchanged answer.
184
+ */
185
+ useLayoutEffect(() => {
186
+ // jsdom has no `document.fonts`, and neither does a very old
187
+ // browser. Nothing to wait for is not an error.
188
+ if (!document.fonts) {
189
+ return
190
+ }
191
+
192
+ let isActive = true
193
+
194
+ document.fonts.ready.then(() => {
195
+ if (isActive) {
196
+ measure()
197
+ }
198
+ })
199
+
200
+ return () => {
201
+ isActive = false
202
+ }
203
+ }, [measure])
204
+
162
205
  useLayoutEffect(() => {
163
206
  const container = containerRef.current
164
207