@charcuterie/ui 1.1.0 → 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 (116) hide show
  1. package/dist/Combobox/Combobox.d.ts +54 -0
  2. package/dist/Combobox/Combobox.d.ts.map +1 -0
  3. package/dist/Combobox/Combobox.js +216 -0
  4. package/dist/Combobox/Combobox.js.map +1 -0
  5. package/dist/Combobox/ComboboxOption.d.ts +36 -0
  6. package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
  7. package/dist/Combobox/ComboboxOption.js +32 -0
  8. package/dist/Combobox/ComboboxOption.js.map +1 -0
  9. package/dist/Dialog/Dialog.d.ts +47 -0
  10. package/dist/Dialog/Dialog.d.ts.map +1 -0
  11. package/dist/Dialog/Dialog.js +52 -0
  12. package/dist/Dialog/Dialog.js.map +1 -0
  13. package/dist/Lightbox/Lightbox.d.ts +7 -7
  14. package/dist/Lightbox/Lightbox.js +9 -9
  15. package/dist/Lightbox/Lightbox.js.map +1 -1
  16. package/dist/Listbox/Listbox.d.ts +56 -0
  17. package/dist/Listbox/Listbox.d.ts.map +1 -0
  18. package/dist/Listbox/Listbox.js +151 -0
  19. package/dist/Listbox/Listbox.js.map +1 -0
  20. package/dist/Listbox/ListboxOption.d.ts +36 -0
  21. package/dist/Listbox/ListboxOption.d.ts.map +1 -0
  22. package/dist/Listbox/ListboxOption.js +42 -0
  23. package/dist/Listbox/ListboxOption.js.map +1 -0
  24. package/dist/Menu/Menu.d.ts.map +1 -1
  25. package/dist/Menu/Menu.js +47 -97
  26. package/dist/Menu/Menu.js.map +1 -1
  27. package/dist/Modal/Modal.d.ts +11 -59
  28. package/dist/Modal/Modal.d.ts.map +1 -1
  29. package/dist/Modal/Modal.js +28 -101
  30. package/dist/Modal/Modal.js.map +1 -1
  31. package/dist/Overlay/OverlayPanel.d.ts +50 -0
  32. package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
  33. package/dist/Overlay/OverlayPanel.js +72 -0
  34. package/dist/Overlay/OverlayPanel.js.map +1 -0
  35. package/dist/Overlay/OverlayStack.d.ts +47 -0
  36. package/dist/Overlay/OverlayStack.d.ts.map +1 -0
  37. package/dist/Overlay/OverlayStack.js +46 -0
  38. package/dist/Overlay/OverlayStack.js.map +1 -0
  39. package/dist/Overlay/SharedBackdrop.d.ts +24 -0
  40. package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
  41. package/dist/Overlay/SharedBackdrop.js +28 -0
  42. package/dist/Overlay/SharedBackdrop.js.map +1 -0
  43. package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
  44. package/dist/Overlay/lockScrollBehind.js.map +1 -0
  45. package/dist/Overlay/overlayPanelClass.d.ts +17 -0
  46. package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
  47. package/dist/Overlay/overlayPanelClass.js +17 -0
  48. package/dist/Overlay/overlayPanelClass.js.map +1 -0
  49. package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
  50. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
  51. package/dist/Overlay/useAnchoredOverlay.js +81 -0
  52. package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
  53. package/dist/Popover/Popover.d.ts +16 -24
  54. package/dist/Popover/Popover.d.ts.map +1 -1
  55. package/dist/Popover/Popover.js +32 -68
  56. package/dist/Popover/Popover.js.map +1 -1
  57. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  58. package/dist/Tooltip/Tooltip.js +25 -25
  59. package/dist/Tooltip/Tooltip.js.map +1 -1
  60. package/dist/index.d.ts +7 -1
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +3 -0
  63. package/dist/index.js.map +1 -1
  64. package/dist/mountStory.testHelpers.d.ts +1 -0
  65. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  66. package/dist/mountStory.testHelpers.js +21 -1
  67. package/dist/mountStory.testHelpers.js.map +1 -1
  68. package/package.json +3 -2
  69. package/src/Combobox/Combobox.mdx +71 -0
  70. package/src/Combobox/Combobox.stories.tsx +245 -0
  71. package/src/Combobox/Combobox.test.tsx +289 -0
  72. package/src/Combobox/Combobox.tsx +513 -0
  73. package/src/Combobox/ComboboxOption.tsx +88 -0
  74. package/src/Dialog/Dialog.mdx +87 -0
  75. package/src/Dialog/Dialog.stories.tsx +347 -0
  76. package/src/Dialog/Dialog.test.tsx +312 -0
  77. package/src/Dialog/Dialog.tsx +127 -0
  78. package/src/Field/Field.test.tsx +6 -4
  79. package/src/Lightbox/Lightbox.mdx +9 -8
  80. package/src/Lightbox/Lightbox.stories.tsx +5 -5
  81. package/src/Lightbox/Lightbox.test.tsx +25 -36
  82. package/src/Lightbox/Lightbox.tsx +10 -10
  83. package/src/Listbox/Listbox.mdx +68 -0
  84. package/src/Listbox/Listbox.stories.tsx +205 -0
  85. package/src/Listbox/Listbox.test.tsx +198 -0
  86. package/src/Listbox/Listbox.tsx +295 -0
  87. package/src/Listbox/ListboxOption.tsx +104 -0
  88. package/src/Menu/Menu.mdx +11 -13
  89. package/src/Menu/Menu.test.tsx +47 -38
  90. package/src/Menu/Menu.tsx +81 -142
  91. package/src/Modal/Modal.mdx +29 -82
  92. package/src/Modal/Modal.stories.tsx +55 -188
  93. package/src/Modal/Modal.test.tsx +44 -177
  94. package/src/Modal/Modal.tsx +54 -204
  95. package/src/Overlay/OverlayPanel.tsx +195 -0
  96. package/src/Overlay/OverlayStack.test.tsx +235 -0
  97. package/src/Overlay/OverlayStack.tsx +132 -0
  98. package/src/Overlay/SharedBackdrop.tsx +40 -0
  99. package/src/Overlay/overlayPanelClass.ts +17 -0
  100. package/src/Overlay/useAnchoredOverlay.ts +197 -0
  101. package/src/Popover/Popover.mdx +17 -20
  102. package/src/Popover/Popover.stories.tsx +33 -0
  103. package/src/Popover/Popover.test.tsx +68 -32
  104. package/src/Popover/Popover.tsx +56 -95
  105. package/src/Tooltip/Tooltip.mdx +12 -9
  106. package/src/Tooltip/Tooltip.test.tsx +17 -19
  107. package/src/Tooltip/Tooltip.tsx +35 -32
  108. package/src/index.ts +13 -4
  109. package/src/mountStory.testHelpers.ts +25 -1
  110. package/src/sourceRules.test.ts +12 -3
  111. package/src/storyControls.test.ts +25 -12
  112. package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
  113. package/dist/Modal/lockScrollBehind.js.map +0 -1
  114. /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
  115. /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
  116. /package/src/{Modal → Overlay}/lockScrollBehind.ts +0 -0
@@ -1,228 +1,78 @@
1
- import { useUniqueId } from "@charcuterie/logic"
2
- import type {
3
- ComponentPropsWithRef,
4
- ReactNode,
5
- } from "react"
6
- import { useEffect, useRef } from "react"
1
+ import type { ReactNode } from "react"
7
2
 
8
- import { Button } from "../Button/Button.tsx"
3
+ import { OverlayPanel } from "../Overlay/OverlayPanel.tsx"
9
4
  import { toClassName } from "../toClassName.ts"
10
- import { lockScrollBehind } from "./lockScrollBehind.ts"
11
5
 
12
- export type ModalSize = "full" | "lg" | "md" | "sm" | "xl"
13
-
14
- export type ModalProps = Omit<
15
- ComponentPropsWithRef<"dialog">,
16
- "onClose" | "open"
17
- > & {
18
- /** The footer row. Buttons, usually. */
19
- footer?: ReactNode
20
- heading: string
21
- headingLevel?: 2 | 3 | 4
22
- /**
23
- * Escape and a backdrop press both close. Off for the one
24
- * honest case — a destructive confirm the user has to answer —
25
- * and off means Escape is *cancelled* rather than ignored, so
26
- * the browser cannot close a dialog the app still owns.
27
- */
6
+ export type ModalProps = {
7
+ "aria-label"?: string
8
+ "aria-labelledby"?: string
9
+ children: ReactNode
10
+ className?: string
11
+ /** Escape and a backdrop press both close. */
28
12
  isDismissable?: boolean
29
13
  isVisible: boolean
30
14
  /**
31
- * Every route out lands here: the close button, Escape, a
32
- * backdrop press. Wire it to a `useVisibility()`'s `hide`.
15
+ * Every route out lands here: Escape, a backdrop press. Wire it to a
16
+ * `useVisibility()`'s `hide`.
33
17
  */
34
18
  onClose: () => void
35
- size?: ModalSize
19
+ role?: "alertdialog" | "dialog"
36
20
  }
37
21
 
38
22
  /**
39
- * `85dvh` rather than `85vh` — on a phone the two differ by the
40
- * address bar, and a dialog whose footer is under it is a dialog
41
- * with no buttons.
42
- */
43
- const SIZE_CLASS: Record<ModalSize, string> = {
44
- sm: "max-h-[85dvh] w-full max-w-sm",
45
- md: "max-h-[85dvh] w-full max-w-md",
46
- lg: "max-h-[85dvh] w-full max-w-2xl",
47
- // M5 added this, because the scale jumped from 42rem straight to
48
- // the whole viewport and rip-deck's capture tail lands in the
49
- // gap: a MakeMKV robot log is fixed-width `MSG:5072,0,1,"…"`
50
- // lines that wrap badly at 42rem, and a full-screen dialog over a
51
- // nine-bay dashboard hides the thing being diagnosed. 56rem is
52
- // what that app's hand-rolled `<dialog>` used before the swap.
53
- xl: "max-h-[85dvh] w-full max-w-4xl",
54
- full: "h-[100dvh] max-h-[100dvh] w-full max-w-full rounded-none",
55
- }
56
-
57
- /**
58
- * Four repos, and **plex-channels and ripdeck independently
59
- * converged on native `<dialog>`** — which is the design here
60
- * rather than a shortcut. `showModal()` gives a focus trap,
61
- * Escape, `::backdrop`, `inert` on everything behind it, and the
62
- * **top layer**, from the platform, correctly, in every browser
63
- * the fleet runs.
64
- *
65
- * What is not free — and the reason a component exists at all —
66
- * is that a native `<dialog>` is a *self-owning* control: call
67
- * `showModal()` and it decides it is open. That is the same
68
- * conflict Radix has, and it gets the same answer as everywhere
69
- * else in this library: **`isVisible` is the truth, and an effect
70
- * makes the element agree with it.** Nothing here reads
71
- * `dialogElement.open` as state; it is read only to avoid calling
72
- * a method that would throw.
23
+ * The base layer. Backdrop, dismiss, and portal — and no chrome.
73
24
  *
74
- * ### Why it is not portalled
25
+ * This is the generic modal the library never had while `Modal` was
26
+ * secretly a *dialog*. A dialog is one kind of modal; so are a
27
+ * confirmation, a media lightbox, and anything else that wants a
28
+ * scrim, an Escape, and a click-outside. `Modal` is that foundation,
29
+ * and `Dialog` is `Modal` plus the heading/Close/footer/size chrome
30
+ * the old component shipped.
75
31
  *
76
- * The top layer is a *paint* concept: a `<dialog>` renders above
77
- * everything while staying exactly where it is in the DOM. So it
78
- * needs no portal — and that matters well past tidiness, because a
79
- * portal moves the node to `document.body`, out of whatever
80
- * element a test or an agent scoped its queries to.
81
- * `canvas.getByRole("dialog")` finds this one. It would not find a
82
- * portalled one, and the story would have to reach for `screen`
83
- * and lose its scoping.
32
+ * It renders an `OverlayPanel`: portalled to `document.body` (escaping
33
+ * `overflow: hidden` the top layer could not), focus-trapped, stacked
34
+ * by portal append order, and dismissible only when it is the top of
35
+ * the stack. All `Modal` adds is a sensible surface — a bordered,
36
+ * rounded, elevated box clamped to `85dvh` — that a caller composes
37
+ * over with `className`. `Dialog` replaces the width and layout; a
38
+ * bare `Modal` sizes to its content.
84
39
  *
85
- * ### `open:flex`, not `flex`
40
+ * ### One accessible name, required
86
41
  *
87
- * A closed `<dialog>` is hidden by a UA rule that any
88
- * `display: flex` of ours would beat — so the modal would render
89
- * permanently, in the page flow, with no error. The layout is
90
- * therefore behind Tailwind's `open:` variant, which is the
91
- * `[open]` attribute the browser manages.
42
+ * A `role="dialog"` takes no name from its content, so pass exactly
43
+ * one of `aria-label` / `aria-labelledby`. Missing both is a dev-time
44
+ * warning from `OverlayPanel`, not a throw — the lesson `Spinner`,
45
+ * `Popover`, and the old `Modal` each learned.
92
46
  *
93
- * ### The accessible name
47
+ * ### Stacking is zero-config, until it is not
94
48
  *
95
- * `heading` is required and is a string, not a `ReactNode`. A
96
- * dialog with no name is announced as "dialog" and nothing else,
97
- * which is what every hand-rolled modal in the inventory does.
49
+ * A lone `Modal` renders its own scrim with no setup. Only when modals
50
+ * **stack** — one opened from inside another — does the app need an
51
+ * `OverlayStackProvider` at its root, so the N of them share one scrim
52
+ * and dismiss top-first.
98
53
  */
54
+ const MODAL_BOX_CLASS =
55
+ "flex max-h-[85dvh] flex-col overflow-hidden rounded-lg border border-border-default bg-surface-overlay text-content-primary shadow-high"
56
+
99
57
  export const Modal = ({
58
+ "aria-label": ariaLabel,
59
+ "aria-labelledby": ariaLabelledBy,
100
60
  children,
101
61
  className,
102
- footer,
103
- heading,
104
- headingLevel = 2,
105
62
  isDismissable = true,
106
63
  isVisible,
107
64
  onClose,
108
- size = "md",
109
- ...dialogProps
110
- }: ModalProps): ReactNode => {
111
- const dialogRef = useRef<HTMLDialogElement>(null)
112
-
113
- const headingId = useUniqueId()
114
-
115
- const Heading = `h${headingLevel}` as const
116
-
117
- useEffect(() => {
118
- const dialogElement = dialogRef.current
119
-
120
- if (!dialogElement) {
121
- return
122
- }
123
-
124
- // Guarded both ways. `showModal()` on an already-open dialog
125
- // throws `InvalidStateError`, and `close()` on a closed one
126
- // fires a spurious `close` event — either turns a re-render
127
- // into a bug.
128
- if (isVisible && !dialogElement.open) {
129
- dialogElement.showModal()
130
- }
131
-
132
- if (!isVisible && dialogElement.open) {
133
- dialogElement.close()
134
- }
135
- }, [isVisible])
136
-
137
- useEffect(
138
- () => (isVisible ? lockScrollBehind() : undefined),
139
- [isVisible],
140
- )
141
-
142
- return (
143
- /*
144
- * The one suppression in this package, and the rule really is
145
- * wrong here rather than inconvenient.
146
- *
147
- * `useKeyWithClickEvents` looks for a click handler on
148
- * something a keyboard user cannot reach. This one is on the
149
- * **backdrop** — which is not an element, has no keyboard
150
- * equivalent to give it, and whose keyboard counterpart is the
151
- * `onCancel` directly above: Escape, from the platform, which
152
- * is the entire reason this is built on `<dialog>`. Biome
153
- * cannot see `onCancel` as a keyboard route.
154
- *
155
- * Pairing the click with an `onKeyDown` to satisfy the rule
156
- * would duplicate a close request the browser already routes
157
- * correctly — including deciding which of two stacked dialogs
158
- * it belongs to — which is a worse component in exchange for a
159
- * green linter.
160
- */
161
- // biome-ignore lint/a11y/useKeyWithClickEvents: Escape is handled by `onCancel`, which this rule cannot see.
162
- <dialog
163
- {...dialogProps}
164
- aria-labelledby={headingId}
165
- className={toClassName(
166
- // `m-auto` and `p-0` undo the UA stylesheet, which centres
167
- // by margin and adds padding of its own.
168
- "m-auto flex-col overflow-hidden rounded-lg border border-border-default bg-surface-overlay p-0 text-content-primary shadow-high backdrop:bg-scrim open:flex",
169
- SIZE_CLASS[size],
170
- className,
171
- )}
172
- onCancel={(cancelEvent) => {
173
- // Escape. Cancelled rather than ignored when the dialog is
174
- // not dismissable: letting the browser close it would
175
- // leave `isVisible` true and the element shut, which is
176
- // precisely the desync this component exists to prevent.
177
- cancelEvent.preventDefault()
178
-
179
- if (isDismissable) {
180
- onClose()
181
- }
182
- }}
183
- onClick={(clickEvent) => {
184
- // The backdrop is not an element — a press on it targets
185
- // the `<dialog>` itself, while a press on the content
186
- // targets something inside. Comparing target to
187
- // currentTarget is the whole test.
188
- if (
189
- isDismissable &&
190
- clickEvent.target === clickEvent.currentTarget
191
- ) {
192
- onClose()
193
- }
194
- }}
195
- ref={dialogRef}
196
- >
197
- <header className="flex shrink-0 items-start justify-between gap-3 border-b border-border-subtle p-4">
198
- <Heading
199
- className="font-semibold text-lg leading-tight"
200
- id={headingId}
201
- >
202
- {heading}
203
- </Heading>
204
-
205
- {isDismissable ? (
206
- <Button
207
- appearance="ghost"
208
- intent="neutral"
209
- onClick={onClose}
210
- size="sm"
211
- >
212
- Close
213
- </Button>
214
- ) : null}
215
- </header>
216
-
217
- <div className="min-h-0 flex-1 overflow-auto p-4">
218
- {children}
219
- </div>
220
-
221
- {footer ? (
222
- <footer className="flex shrink-0 flex-wrap justify-end gap-2 border-t border-border-subtle p-4">
223
- {footer}
224
- </footer>
225
- ) : null}
226
- </dialog>
227
- )
228
- }
65
+ role = "dialog",
66
+ }: ModalProps): ReactNode => (
67
+ <OverlayPanel
68
+ aria-label={ariaLabel}
69
+ aria-labelledby={ariaLabelledBy}
70
+ className={toClassName(MODAL_BOX_CLASS, className)}
71
+ isDismissable={isDismissable}
72
+ isVisible={isVisible}
73
+ onClose={onClose}
74
+ role={role}
75
+ >
76
+ {children}
77
+ </OverlayPanel>
78
+ )
@@ -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
+ }