@charcuterie/ui 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts +35 -0
  2. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts.map +1 -0
  3. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js +33 -0
  4. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js.map +1 -0
  5. package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts +54 -0
  6. package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts.map +1 -0
  7. package/dist/ColorSchemeToggle/ColorSchemeToggle.js +30 -0
  8. package/dist/ColorSchemeToggle/ColorSchemeToggle.js.map +1 -0
  9. package/dist/Combobox/Combobox.d.ts +54 -0
  10. package/dist/Combobox/Combobox.d.ts.map +1 -0
  11. package/dist/Combobox/Combobox.js +216 -0
  12. package/dist/Combobox/Combobox.js.map +1 -0
  13. package/dist/Combobox/ComboboxOption.d.ts +36 -0
  14. package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
  15. package/dist/Combobox/ComboboxOption.js +32 -0
  16. package/dist/Combobox/ComboboxOption.js.map +1 -0
  17. package/dist/Dialog/Dialog.d.ts +47 -0
  18. package/dist/Dialog/Dialog.d.ts.map +1 -0
  19. package/dist/Dialog/Dialog.js +52 -0
  20. package/dist/Dialog/Dialog.js.map +1 -0
  21. package/dist/Lightbox/Lightbox.d.ts +57 -0
  22. package/dist/Lightbox/Lightbox.d.ts.map +1 -0
  23. package/dist/Lightbox/Lightbox.js +50 -0
  24. package/dist/Lightbox/Lightbox.js.map +1 -0
  25. package/dist/Listbox/Listbox.d.ts +56 -0
  26. package/dist/Listbox/Listbox.d.ts.map +1 -0
  27. package/dist/Listbox/Listbox.js +151 -0
  28. package/dist/Listbox/Listbox.js.map +1 -0
  29. package/dist/Listbox/ListboxOption.d.ts +36 -0
  30. package/dist/Listbox/ListboxOption.d.ts.map +1 -0
  31. package/dist/Listbox/ListboxOption.js +42 -0
  32. package/dist/Listbox/ListboxOption.js.map +1 -0
  33. package/dist/Menu/Menu.d.ts.map +1 -1
  34. package/dist/Menu/Menu.js +47 -97
  35. package/dist/Menu/Menu.js.map +1 -1
  36. package/dist/Modal/Modal.d.ts +11 -59
  37. package/dist/Modal/Modal.d.ts.map +1 -1
  38. package/dist/Modal/Modal.js +28 -101
  39. package/dist/Modal/Modal.js.map +1 -1
  40. package/dist/Overlay/OverlayPanel.d.ts +50 -0
  41. package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
  42. package/dist/Overlay/OverlayPanel.js +72 -0
  43. package/dist/Overlay/OverlayPanel.js.map +1 -0
  44. package/dist/Overlay/OverlayStack.d.ts +47 -0
  45. package/dist/Overlay/OverlayStack.d.ts.map +1 -0
  46. package/dist/Overlay/OverlayStack.js +46 -0
  47. package/dist/Overlay/OverlayStack.js.map +1 -0
  48. package/dist/Overlay/SharedBackdrop.d.ts +24 -0
  49. package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
  50. package/dist/Overlay/SharedBackdrop.js +28 -0
  51. package/dist/Overlay/SharedBackdrop.js.map +1 -0
  52. package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
  53. package/dist/Overlay/lockScrollBehind.js.map +1 -0
  54. package/dist/Overlay/overlayPanelClass.d.ts +17 -0
  55. package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
  56. package/dist/Overlay/overlayPanelClass.js +17 -0
  57. package/dist/Overlay/overlayPanelClass.js.map +1 -0
  58. package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
  59. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
  60. package/dist/Overlay/useAnchoredOverlay.js +81 -0
  61. package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
  62. package/dist/Popover/Popover.d.ts +16 -24
  63. package/dist/Popover/Popover.d.ts.map +1 -1
  64. package/dist/Popover/Popover.js +32 -68
  65. package/dist/Popover/Popover.js.map +1 -1
  66. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  67. package/dist/Tooltip/Tooltip.js +25 -25
  68. package/dist/Tooltip/Tooltip.js.map +1 -1
  69. package/dist/index.d.ts +13 -1
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +6 -0
  72. package/dist/index.js.map +1 -1
  73. package/dist/mountStory.testHelpers.d.ts +1 -0
  74. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  75. package/dist/mountStory.testHelpers.js +21 -1
  76. package/dist/mountStory.testHelpers.js.map +1 -1
  77. package/package.json +5 -4
  78. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.mdx +70 -0
  79. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.stories.tsx +118 -0
  80. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.test.tsx +53 -0
  81. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.tsx +90 -0
  82. package/src/ColorSchemeToggle/ColorSchemeToggle.mdx +48 -0
  83. package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +161 -0
  84. package/src/ColorSchemeToggle/ColorSchemeToggle.test.tsx +80 -0
  85. package/src/ColorSchemeToggle/ColorSchemeToggle.tsx +107 -0
  86. package/src/Combobox/Combobox.mdx +71 -0
  87. package/src/Combobox/Combobox.stories.tsx +245 -0
  88. package/src/Combobox/Combobox.test.tsx +289 -0
  89. package/src/Combobox/Combobox.tsx +513 -0
  90. package/src/Combobox/ComboboxOption.tsx +88 -0
  91. package/src/Dialog/Dialog.mdx +87 -0
  92. package/src/Dialog/Dialog.stories.tsx +347 -0
  93. package/src/Dialog/Dialog.test.tsx +312 -0
  94. package/src/Dialog/Dialog.tsx +127 -0
  95. package/src/Field/Field.test.tsx +6 -4
  96. package/src/Lightbox/Lightbox.mdx +63 -0
  97. package/src/Lightbox/Lightbox.stories.tsx +133 -0
  98. package/src/Lightbox/Lightbox.test.tsx +124 -0
  99. package/src/Lightbox/Lightbox.tsx +143 -0
  100. package/src/Listbox/Listbox.mdx +68 -0
  101. package/src/Listbox/Listbox.stories.tsx +205 -0
  102. package/src/Listbox/Listbox.test.tsx +198 -0
  103. package/src/Listbox/Listbox.tsx +295 -0
  104. package/src/Listbox/ListboxOption.tsx +104 -0
  105. package/src/Menu/Menu.mdx +11 -13
  106. package/src/Menu/Menu.test.tsx +47 -38
  107. package/src/Menu/Menu.tsx +81 -142
  108. package/src/Modal/Modal.mdx +29 -82
  109. package/src/Modal/Modal.stories.tsx +55 -188
  110. package/src/Modal/Modal.test.tsx +44 -177
  111. package/src/Modal/Modal.tsx +54 -204
  112. package/src/Overlay/OverlayPanel.tsx +195 -0
  113. package/src/Overlay/OverlayStack.test.tsx +235 -0
  114. package/src/Overlay/OverlayStack.tsx +132 -0
  115. package/src/Overlay/SharedBackdrop.tsx +40 -0
  116. package/src/Overlay/overlayPanelClass.ts +17 -0
  117. package/src/Overlay/useAnchoredOverlay.ts +197 -0
  118. package/src/Popover/Popover.mdx +17 -20
  119. package/src/Popover/Popover.stories.tsx +33 -0
  120. package/src/Popover/Popover.test.tsx +68 -32
  121. package/src/Popover/Popover.tsx +56 -95
  122. package/src/Tooltip/Tooltip.mdx +12 -9
  123. package/src/Tooltip/Tooltip.test.tsx +17 -19
  124. package/src/Tooltip/Tooltip.tsx +35 -32
  125. package/src/icons.storyHelpers.tsx +22 -0
  126. package/src/index.ts +22 -4
  127. package/src/mountStory.testHelpers.ts +25 -1
  128. package/src/sourceRules.test.ts +26 -1
  129. package/src/storyControls.test.ts +25 -12
  130. package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
  131. package/dist/Modal/lockScrollBehind.js.map +0 -1
  132. /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
  133. /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
  134. /package/src/{Modal → Overlay}/lockScrollBehind.ts +0 -0
@@ -0,0 +1,195 @@
1
+ import {
2
+ useLatestRef,
3
+ useUniqueId,
4
+ } from "@charcuterie/logic"
5
+ import {
6
+ FloatingFocusManager,
7
+ FloatingPortal,
8
+ useDismiss,
9
+ useFloating,
10
+ useInteractions,
11
+ useRole,
12
+ } from "@floating-ui/react"
13
+ import type { ReactNode } from "react"
14
+ import { useCallback, useEffect } from "react"
15
+
16
+ import { lockScrollBehind } from "./lockScrollBehind.ts"
17
+ import { useOverlayStack } from "./OverlayStack.tsx"
18
+ import { SharedBackdrop } from "./SharedBackdrop.tsx"
19
+
20
+ /**
21
+ * The portalled, backdrop-backed, focus-trapping panel that `Modal`
22
+ * renders. No chrome — that is `Dialog`'s job — only the four things
23
+ * every backdrop overlay needs: it portals to `document.body`, a
24
+ * scrim stands behind it, focus is trapped inside it, and Escape or an
25
+ * outside press closes it.
26
+ *
27
+ * ### Portalled, and the top layer is gone on purpose
28
+ *
29
+ * This supersedes the `<dialog>.showModal()` top-layer approach. The
30
+ * top layer paints above everything but cannot escape an
31
+ * `overflow: hidden` ancestor's *clipping* in every case the fleet
32
+ * hit; a portal to `document.body` does. The objection the old
33
+ * decision raised — a portal moves the node out of a scoped query —
34
+ * is answered by `useRole` writing `aria-controls`/`aria-labelledby`
35
+ * so the trigger→panel link is followable across the boundary, and by
36
+ * the stories scoping panels to `within(document.body)` while the
37
+ * structural `expectAgentDrivable` needs no change.
38
+ *
39
+ * ### Only the top of the stack is live
40
+ *
41
+ * `useDismiss` (Escape + outside press) is enabled **only when this is
42
+ * the top** of the stack, so an outside press or Escape closes the
43
+ * innermost modal and nothing under it — the same rule the platform's
44
+ * nested `<dialog>` gave for free, without a hand-rolled document
45
+ * keydown racing the focus manager. Everything below the top is
46
+ * `inert`, which is what `expectHiddenFromAgents` asserts and what
47
+ * makes `FloatingFocusManager`'s trap unambiguous.
48
+ *
49
+ * ### One accessible name, required
50
+ *
51
+ * A `role="dialog"` takes no name from its content, so one of
52
+ * `aria-label` / `aria-labelledby` must be present. Missing both is a
53
+ * dev-time warning, not a throw — the lesson `Spinner`, `Popover`, and
54
+ * `Modal` each learned, applied without breaking a render.
55
+ */
56
+ export type OverlayPanelProps = {
57
+ "aria-label"?: string
58
+ "aria-labelledby"?: string
59
+ children: ReactNode
60
+ className?: string
61
+ /** Escape and an outside press both close. */
62
+ isDismissable?: boolean
63
+ isVisible: boolean
64
+ onClose: () => void
65
+ role?: "alertdialog" | "dialog"
66
+ }
67
+
68
+ export const OverlayPanel = ({
69
+ "aria-label": ariaLabel,
70
+ "aria-labelledby": ariaLabelledBy,
71
+ children,
72
+ className,
73
+ isDismissable = true,
74
+ isVisible,
75
+ onClose,
76
+ role = "dialog",
77
+ }: OverlayPanelProps): ReactNode => {
78
+ const stack = useOverlayStack()
79
+
80
+ const id = useUniqueId()
81
+
82
+ // A stable close, so registering it does not churn the stack as the
83
+ // caller's `onClose` closure changes identity between renders.
84
+ const onCloseRef = useLatestRef(onClose)
85
+
86
+ const stableOnClose = useCallback(() => {
87
+ onCloseRef.current()
88
+ }, [onCloseRef])
89
+
90
+ const isTop = !stack.isProvided || stack.topId === id
91
+
92
+ const { context, refs } = useFloating({
93
+ open: isVisible,
94
+ onOpenChange: (isNextVisible) => {
95
+ if (!isNextVisible) {
96
+ onClose()
97
+ }
98
+ },
99
+ })
100
+
101
+ const { getFloatingProps } = useInteractions([
102
+ useDismiss(context, {
103
+ escapeKey: isDismissable && isTop,
104
+ // The top's outside press is the backdrop click. A per-panel
105
+ // `outsidePress` on a *lower* modal would close it too, so it is
106
+ // scoped to the top — which is also why the scrim needs no click
107
+ // handler of its own.
108
+ outsidePress: isDismissable && isTop,
109
+ }),
110
+ useRole(context, { role }),
111
+ ])
112
+
113
+ const { register, unregister } = stack
114
+
115
+ useEffect(() => {
116
+ if (!isVisible) {
117
+ return
118
+ }
119
+
120
+ register({ id, onClose: stableOnClose })
121
+
122
+ return () => {
123
+ unregister(id)
124
+ }
125
+ }, [id, isVisible, register, stableOnClose, unregister])
126
+
127
+ // Ref-counted through `lockScrollBehind`'s module scope, so N
128
+ // stacked panels lock the page once and free it only when the last
129
+ // closes — with or without a provider.
130
+ useEffect(
131
+ () => (isVisible ? lockScrollBehind() : undefined),
132
+ [isVisible],
133
+ )
134
+
135
+ useEffect(() => {
136
+ if (
137
+ isVisible &&
138
+ ariaLabel === undefined &&
139
+ ariaLabelledBy === undefined
140
+ ) {
141
+ console.warn(
142
+ 'OverlayPanel: a modal needs an accessible name. Pass `aria-label` or `aria-labelledby` — a `role="dialog"` takes none from its content and is announced as just "dialog".',
143
+ )
144
+ }
145
+ }, [ariaLabel, ariaLabelledBy, isVisible])
146
+
147
+ if (!isVisible) {
148
+ return null
149
+ }
150
+
151
+ return (
152
+ <>
153
+ {/* A self-hosting `Modal` renders its own scrim; under a
154
+ provider the shared one already stands behind every panel. */}
155
+ {stack.isProvided ? null : (
156
+ <SharedBackdrop isVisible />
157
+ )}
158
+
159
+ <FloatingPortal>
160
+ <div className="fixed inset-0 z-[var(--layer-modal)] flex items-center justify-center">
161
+ <FloatingFocusManager
162
+ context={context}
163
+ // Only the top traps focus; a lower panel is `inert`
164
+ // anyway, and two live traps fight over one caret.
165
+ disabled={!isTop}
166
+ modal
167
+ >
168
+ <div
169
+ {...getFloatingProps()}
170
+ // Spread rather than written as literal attributes:
171
+ // both `dialog` and `alertdialog` take a name, but the
172
+ // role arrives as a variable and a static a11y linter
173
+ // cannot confirm that through it. `useRole` supplies the
174
+ // role itself; these are the accessible name.
175
+ {...{
176
+ "aria-label": ariaLabel,
177
+ "aria-labelledby": ariaLabelledBy,
178
+ }}
179
+ className={className}
180
+ // The fallback the plan calls for: `inert` on every panel
181
+ // but the top, so a lower modal is unreachable across the
182
+ // portal boundary even where `FloatingFocusManager`'s
183
+ // outside-hiding does not reach a sibling portal.
184
+ inert={isTop ? undefined : true}
185
+ ref={refs.setFloating}
186
+ role={role}
187
+ >
188
+ {children}
189
+ </div>
190
+ </FloatingFocusManager>
191
+ </div>
192
+ </FloatingPortal>
193
+ </>
194
+ )
195
+ }
@@ -0,0 +1,235 @@
1
+ import type { ReactNode } from "react"
2
+ import { act } from "react"
3
+ import { createRoot } from "react-dom/client"
4
+ import {
5
+ afterAll,
6
+ afterEach,
7
+ beforeAll,
8
+ expect,
9
+ test,
10
+ } from "vitest"
11
+
12
+ import { OverlayPanel } from "./OverlayPanel.tsx"
13
+ import type { OverlayStackValue } from "./OverlayStack.tsx"
14
+ import {
15
+ OverlayStackProvider,
16
+ useOverlayStack,
17
+ } from "./OverlayStack.tsx"
18
+
19
+ /**
20
+ * The stack's bookkeeping, driven directly rather than through a
21
+ * component's chrome — this is infrastructure with no story, so it
22
+ * mounts its own root and drives it with `act`, which flushes the
23
+ * passive effects the scroll lock and `requestCloseTop`'s live-top ref
24
+ * both ride on. The act environment is turned on for this file only;
25
+ * ui-dom isolates test files, so it never reaches the story-driven
26
+ * suites next door.
27
+ */
28
+ // Typed as a string-keyed record so the React flag's own
29
+ // SCREAMING_CASE name never has to be *declared* as a property (the
30
+ // boolean-naming rule wants `is`/`has`); it is only assigned.
31
+ const reactEnvironment = globalThis as unknown as Record<
32
+ string,
33
+ boolean
34
+ >
35
+
36
+ beforeAll(() => {
37
+ reactEnvironment.IS_REACT_ACT_ENVIRONMENT = true
38
+ })
39
+
40
+ afterAll(() => {
41
+ reactEnvironment.IS_REACT_ACT_ENVIRONMENT = false
42
+ })
43
+
44
+ const roots: {
45
+ container: HTMLElement
46
+ root: ReturnType<typeof createRoot>
47
+ }[] = []
48
+
49
+ const mount = async (ui: ReactNode) => {
50
+ const container = document.createElement("div")
51
+
52
+ document.body.append(container)
53
+
54
+ const root = createRoot(container)
55
+
56
+ await act(async () => {
57
+ root.render(ui)
58
+ })
59
+
60
+ roots.push({ container, root })
61
+
62
+ return root
63
+ }
64
+
65
+ afterEach(async () => {
66
+ for (const { container, root } of roots.splice(0)) {
67
+ await act(async () => {
68
+ root.unmount()
69
+ })
70
+
71
+ container.remove()
72
+ }
73
+ })
74
+
75
+ /**
76
+ * The one component this file declares: it reports the live stack up
77
+ * through `onStack` and renders two panels whose visibility the test
78
+ * drives. `OverlayStackProvider` is rendered by the tests around it,
79
+ * so this stays a single component (the `no-multi-comp` rule) while
80
+ * still being a real consumer of the context.
81
+ */
82
+ const Harness = ({
83
+ isAVisible = false,
84
+ isBVisible = false,
85
+ onStack,
86
+ }: {
87
+ isAVisible?: boolean
88
+ isBVisible?: boolean
89
+ onStack?: (stack: OverlayStackValue) => void
90
+ }): ReactNode => {
91
+ onStack?.(useOverlayStack())
92
+
93
+ return (
94
+ <>
95
+ <OverlayPanel
96
+ aria-label="A"
97
+ isVisible={isAVisible}
98
+ onClose={() => {}}
99
+ >
100
+ first
101
+ </OverlayPanel>
102
+
103
+ <OverlayPanel
104
+ aria-label="B"
105
+ isVisible={isBVisible}
106
+ onClose={() => {}}
107
+ >
108
+ second
109
+ </OverlayPanel>
110
+ </>
111
+ )
112
+ }
113
+
114
+ test("registers in order, closes the top, and toggles the scrim with depth", async () => {
115
+ const probe: { current: OverlayStackValue | null } = {
116
+ current: null,
117
+ }
118
+
119
+ await mount(
120
+ <OverlayStackProvider>
121
+ <Harness
122
+ onStack={(next) => {
123
+ probe.current = next
124
+ }}
125
+ />
126
+ </OverlayStackProvider>,
127
+ )
128
+
129
+ const stack = () => {
130
+ if (!probe.current) {
131
+ throw new Error("probe never rendered")
132
+ }
133
+
134
+ return probe.current
135
+ }
136
+
137
+ const scrim = () =>
138
+ document.body.querySelector(".bg-scrim")
139
+
140
+ expect(stack().depth).toBe(0)
141
+ expect(scrim()).toBeNull()
142
+
143
+ let closedTop = ""
144
+
145
+ await act(async () => {
146
+ stack().register({
147
+ id: "a",
148
+ onClose: () => {
149
+ closedTop = "a"
150
+ },
151
+ })
152
+ })
153
+
154
+ expect(stack().depth).toBe(1)
155
+ expect(stack().topId).toBe("a")
156
+ // The scrim renders as long as at least one modal is open.
157
+ expect(scrim()).not.toBeNull()
158
+
159
+ await act(async () => {
160
+ stack().register({
161
+ id: "b",
162
+ onClose: () => {
163
+ closedTop = "b"
164
+ },
165
+ })
166
+ })
167
+
168
+ expect(stack().depth).toBe(2)
169
+ // Last registered is the top.
170
+ expect(stack().topId).toBe("b")
171
+
172
+ // `requestCloseTop` targets the last, not the first.
173
+ await act(async () => {
174
+ stack().requestCloseTop()
175
+ })
176
+
177
+ expect(closedTop).toBe("b")
178
+
179
+ await act(async () => {
180
+ stack().unregister("b")
181
+ })
182
+
183
+ // The next one down becomes the top, and the scrim stays.
184
+ expect(stack().topId).toBe("a")
185
+ expect(scrim()).not.toBeNull()
186
+
187
+ await act(async () => {
188
+ stack().unregister("a")
189
+ })
190
+
191
+ // Empty, so the scrim goes.
192
+ expect(stack().depth).toBe(0)
193
+ expect(scrim()).toBeNull()
194
+ })
195
+
196
+ test("the scroll lock frees the page only when the last modal closes", async () => {
197
+ const original = document.documentElement.style.overflow
198
+
199
+ const root = await mount(
200
+ <OverlayStackProvider>
201
+ <Harness isAVisible isBVisible />
202
+ </OverlayStackProvider>,
203
+ )
204
+
205
+ // Two open, one lock.
206
+ expect(document.documentElement.style.overflow).toBe(
207
+ "hidden",
208
+ )
209
+
210
+ await act(async () => {
211
+ root.render(
212
+ <OverlayStackProvider>
213
+ <Harness isAVisible isBVisible={false} />
214
+ </OverlayStackProvider>,
215
+ )
216
+ })
217
+
218
+ // One still open — the ref count has not reached zero.
219
+ expect(document.documentElement.style.overflow).toBe(
220
+ "hidden",
221
+ )
222
+
223
+ await act(async () => {
224
+ root.render(
225
+ <OverlayStackProvider>
226
+ <Harness isAVisible={false} isBVisible={false} />
227
+ </OverlayStackProvider>,
228
+ )
229
+ })
230
+
231
+ // Empty, so the page is restored to exactly what it was.
232
+ expect(document.documentElement.style.overflow).toBe(
233
+ original,
234
+ )
235
+ })
@@ -0,0 +1,132 @@
1
+ import { useLatestRef } from "@charcuterie/logic"
2
+ import type { ReactNode } from "react"
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useMemo,
8
+ useState,
9
+ } from "react"
10
+
11
+ import { SharedBackdrop } from "./SharedBackdrop.tsx"
12
+
13
+ /**
14
+ * The ordered stack of open **backdrop** modals, and the one shared
15
+ * scrim that stands behind all of them.
16
+ *
17
+ * ### Why a stack at all
18
+ *
19
+ * A modal must know two things it cannot see for itself: whether it is
20
+ * the **top** of the stack (only the top traps focus and answers
21
+ * Escape / an outside press; everything under it is `inert`), and how
22
+ * **deep** the stack is (the scrim shows while at least one modal is
23
+ * open and hides when the last closes). An `OverlayPanel` registers on
24
+ * open and unregisters on close, and reads `topId`/`depth` back.
25
+ *
26
+ * ### The provider is optional
27
+ *
28
+ * `useOverlayStack()` has a self-hosting default — `isProvided:
29
+ * false`, a no-op register, `depth: 0` — so a lone `Modal` needs no
30
+ * setup: it sees it is unprovided and renders its own scrim, treating
31
+ * itself as the top. Only *stacking* needs `OverlayStackProvider` at
32
+ * the app root, where a single shared scrim and real top-of-stack
33
+ * bookkeeping replace N self-hosted scrims fighting for the same
34
+ * corner.
35
+ */
36
+ export type OverlayStackEntry = {
37
+ id: string
38
+ onClose: () => void
39
+ }
40
+
41
+ export type OverlayStackValue = {
42
+ depth: number
43
+ /**
44
+ * `false` in the context default. An `OverlayPanel` reads this to
45
+ * decide whether to render its own scrim (unprovided) or defer to
46
+ * the provider's shared one.
47
+ */
48
+ isProvided: boolean
49
+ register: (entry: OverlayStackEntry) => void
50
+ /** Closes the top entry — the imperative twin of an outside press. */
51
+ requestCloseTop: () => void
52
+ topId: null | string
53
+ unregister: (id: string) => void
54
+ }
55
+
56
+ const DEFAULT_STACK: OverlayStackValue = {
57
+ depth: 0,
58
+ isProvided: false,
59
+ register: () => {},
60
+ requestCloseTop: () => {},
61
+ topId: null,
62
+ unregister: () => {},
63
+ }
64
+
65
+ const OverlayStackContext =
66
+ createContext<OverlayStackValue>(DEFAULT_STACK)
67
+
68
+ export const useOverlayStack = (): OverlayStackValue =>
69
+ useContext(OverlayStackContext)
70
+
71
+ export const OverlayStackProvider = ({
72
+ children,
73
+ }: {
74
+ children: ReactNode
75
+ }): ReactNode => {
76
+ const [entries, setEntries] = useState<
77
+ OverlayStackEntry[]
78
+ >([])
79
+
80
+ // Read through a ref so `requestCloseTop` stays stable while still
81
+ // seeing the live top.
82
+ const entriesRef = useLatestRef(entries)
83
+
84
+ const register = useCallback(
85
+ (entry: OverlayStackEntry) => {
86
+ setEntries((previous) =>
87
+ // In place when the id is already present — an `onClose`
88
+ // update must not reorder the stack, or a re-render would
89
+ // shuffle which modal is "top". New ids append, which is what
90
+ // makes the last-registered one the top.
91
+ previous.some((one) => one.id === entry.id)
92
+ ? previous.map((one) =>
93
+ one.id === entry.id ? entry : one,
94
+ )
95
+ : [...previous, entry],
96
+ )
97
+ },
98
+ [],
99
+ )
100
+
101
+ const unregister = useCallback((id: string) => {
102
+ setEntries((previous) =>
103
+ previous.filter((one) => one.id !== id),
104
+ )
105
+ }, [])
106
+
107
+ const requestCloseTop = useCallback(() => {
108
+ entriesRef.current.at(-1)?.onClose()
109
+ }, [entriesRef])
110
+
111
+ const value = useMemo<OverlayStackValue>(
112
+ () => ({
113
+ depth: entries.length,
114
+ isProvided: true,
115
+ register,
116
+ requestCloseTop,
117
+ topId: entries.at(-1)?.id ?? null,
118
+ unregister,
119
+ }),
120
+ [entries, register, requestCloseTop, unregister],
121
+ )
122
+
123
+ return (
124
+ <OverlayStackContext.Provider value={value}>
125
+ {/* First, so it mounts its portal before the panels and paints
126
+ beneath them at an equal z-index. */}
127
+ <SharedBackdrop isVisible={entries.length > 0} />
128
+
129
+ {children}
130
+ </OverlayStackContext.Provider>
131
+ )
132
+ }
@@ -0,0 +1,40 @@
1
+ import { FloatingPortal } from "@floating-ui/react"
2
+ import type { ReactNode } from "react"
3
+
4
+ /**
5
+ * The scrim, and there is exactly one of it however many backdrop
6
+ * modals are stacked.
7
+ *
8
+ * Purely visual: `aria-hidden`, no handlers. Dismissal — a press
9
+ * anywhere off the top panel — is the top panel's own
10
+ * `useDismiss({ outsidePress })`, not a click on this node, which is
11
+ * what lets the whole package drop the `useKeyWithClickEvents`
12
+ * suppression the `<dialog>` backdrop needed. A press lands on the
13
+ * top panel's full-viewport centring layer (which sits above this),
14
+ * so this element never has to be the click target.
15
+ *
16
+ * Portalled to `document.body` on `--layer-overlay`, one step **below**
17
+ * the panels' `--layer-modal`, so it always paints behind every open
18
+ * modal and never intercepts a click meant for the dialog on top of it
19
+ * — a real bug a same-z-plus-DOM-order scheme has, because a
20
+ * later-mounted scrim then covers the panel. Stacking *between* panels
21
+ * still falls out of append order at the one `--layer-modal`.
22
+ */
23
+ export const SharedBackdrop = ({
24
+ isVisible,
25
+ }: {
26
+ isVisible: boolean
27
+ }): ReactNode => {
28
+ if (!isVisible) {
29
+ return null
30
+ }
31
+
32
+ return (
33
+ <FloatingPortal>
34
+ <div
35
+ aria-hidden="true"
36
+ className="fixed inset-0 z-[var(--layer-overlay)] bg-scrim"
37
+ />
38
+ </FloatingPortal>
39
+ )
40
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The surface an anchored overlay's panel is drawn on — the four
3
+ * tokens `Popover` and `Menu` wrote out byte-identically, and that
4
+ * `Listbox` and `Combobox` are about to write a third and fourth
5
+ * time.
6
+ *
7
+ * Only the *surface* lives here: the raised background, the hairline
8
+ * border, the corner radius, and the elevation. Everything a panel
9
+ * does with that surface — a popover's `max-w-xs p-3`, a menu's
10
+ * `min-w-48 flex flex-col`, a listbox's scroll clamp — is the panel's
11
+ * own and stays at the call site, composed after this with
12
+ * `toClassName`. Pulling the shared half out is what keeps a change
13
+ * to the overlay surface (a new elevation token, say) from having to
14
+ * be made in four places and kept in agreement by hand.
15
+ */
16
+ export const PANEL_SURFACE_CLASS =
17
+ "rounded-md border border-border-default bg-surface-overlay shadow-medium"