@charcuterie/ui 0.1.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 (223) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +161 -0
  3. package/dist/Alert/Alert.d.ts +88 -0
  4. package/dist/Alert/Alert.d.ts.map +1 -0
  5. package/dist/Alert/Alert.js +67 -0
  6. package/dist/Alert/Alert.js.map +1 -0
  7. package/dist/Badge/Badge.d.ts +54 -0
  8. package/dist/Badge/Badge.d.ts.map +1 -0
  9. package/dist/Badge/Badge.js +54 -0
  10. package/dist/Badge/Badge.js.map +1 -0
  11. package/dist/Badge/useIsTextClipped.d.ts +22 -0
  12. package/dist/Badge/useIsTextClipped.d.ts.map +1 -0
  13. package/dist/Badge/useIsTextClipped.js +57 -0
  14. package/dist/Badge/useIsTextClipped.js.map +1 -0
  15. package/dist/Button/Button.d.ts +58 -0
  16. package/dist/Button/Button.d.ts.map +1 -0
  17. package/dist/Button/Button.js +37 -0
  18. package/dist/Button/Button.js.map +1 -0
  19. package/dist/Card/Card.d.ts +44 -0
  20. package/dist/Card/Card.d.ts.map +1 -0
  21. package/dist/Card/Card.js +59 -0
  22. package/dist/Card/Card.js.map +1 -0
  23. package/dist/EmptyState/EmptyState.d.ts +42 -0
  24. package/dist/EmptyState/EmptyState.d.ts.map +1 -0
  25. package/dist/EmptyState/EmptyState.js +37 -0
  26. package/dist/EmptyState/EmptyState.js.map +1 -0
  27. package/dist/IconButton/IconButton.d.ts +34 -0
  28. package/dist/IconButton/IconButton.d.ts.map +1 -0
  29. package/dist/IconButton/IconButton.js +23 -0
  30. package/dist/IconButton/IconButton.js.map +1 -0
  31. package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts +37 -0
  32. package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts.map +1 -0
  33. package/dist/LiveStatusIndicator/LiveStatusIndicator.js +39 -0
  34. package/dist/LiveStatusIndicator/LiveStatusIndicator.js.map +1 -0
  35. package/dist/MediaTile/MediaTile.d.ts +46 -0
  36. package/dist/MediaTile/MediaTile.d.ts.map +1 -0
  37. package/dist/MediaTile/MediaTile.js +79 -0
  38. package/dist/MediaTile/MediaTile.js.map +1 -0
  39. package/dist/MediaTile/mediaStatus.d.ts +22 -0
  40. package/dist/MediaTile/mediaStatus.d.ts.map +1 -0
  41. package/dist/MediaTile/mediaStatus.js +24 -0
  42. package/dist/MediaTile/mediaStatus.js.map +1 -0
  43. package/dist/Modal/Modal.d.ts +66 -0
  44. package/dist/Modal/Modal.d.ts.map +1 -0
  45. package/dist/Modal/Modal.js +111 -0
  46. package/dist/Modal/Modal.js.map +1 -0
  47. package/dist/Modal/lockScrollBehind.d.ts +22 -0
  48. package/dist/Modal/lockScrollBehind.d.ts.map +1 -0
  49. package/dist/Modal/lockScrollBehind.js +48 -0
  50. package/dist/Modal/lockScrollBehind.js.map +1 -0
  51. package/dist/Popover/Popover.d.ts +66 -0
  52. package/dist/Popover/Popover.d.ts.map +1 -0
  53. package/dist/Popover/Popover.js +88 -0
  54. package/dist/Popover/Popover.js.map +1 -0
  55. package/dist/ProgressBar/ProgressBar.d.ts +51 -0
  56. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -0
  57. package/dist/ProgressBar/ProgressBar.js +50 -0
  58. package/dist/ProgressBar/ProgressBar.js.map +1 -0
  59. package/dist/ProgressBar/progressValue.d.ts +44 -0
  60. package/dist/ProgressBar/progressValue.d.ts.map +1 -0
  61. package/dist/ProgressBar/progressValue.js +55 -0
  62. package/dist/ProgressBar/progressValue.js.map +1 -0
  63. package/dist/SegmentedControl/SegmentedControl.d.ts +59 -0
  64. package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -0
  65. package/dist/SegmentedControl/SegmentedControl.js +129 -0
  66. package/dist/SegmentedControl/SegmentedControl.js.map +1 -0
  67. package/dist/SegmentedControl/SegmentedOption.d.ts +28 -0
  68. package/dist/SegmentedControl/SegmentedOption.d.ts.map +1 -0
  69. package/dist/SegmentedControl/SegmentedOption.js +40 -0
  70. package/dist/SegmentedControl/SegmentedOption.js.map +1 -0
  71. package/dist/Skeleton/Skeleton.d.ts +30 -0
  72. package/dist/Skeleton/Skeleton.d.ts.map +1 -0
  73. package/dist/Skeleton/Skeleton.js +43 -0
  74. package/dist/Skeleton/Skeleton.js.map +1 -0
  75. package/dist/Spinner/Spinner.d.ts +37 -0
  76. package/dist/Spinner/Spinner.d.ts.map +1 -0
  77. package/dist/Spinner/Spinner.js +33 -0
  78. package/dist/Spinner/Spinner.js.map +1 -0
  79. package/dist/Tabs/TabTrigger.d.ts +32 -0
  80. package/dist/Tabs/TabTrigger.d.ts.map +1 -0
  81. package/dist/Tabs/TabTrigger.js +62 -0
  82. package/dist/Tabs/TabTrigger.js.map +1 -0
  83. package/dist/Tabs/Tabs.d.ts +95 -0
  84. package/dist/Tabs/Tabs.d.ts.map +1 -0
  85. package/dist/Tabs/Tabs.js +195 -0
  86. package/dist/Tabs/Tabs.js.map +1 -0
  87. package/dist/VisuallyHidden/VisuallyHidden.d.ts +25 -0
  88. package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
  89. package/dist/VisuallyHidden/VisuallyHidden.js +23 -0
  90. package/dist/VisuallyHidden/VisuallyHidden.js.map +1 -0
  91. package/dist/controlStyles.d.ts +50 -0
  92. package/dist/controlStyles.d.ts.map +1 -0
  93. package/dist/controlStyles.js +60 -0
  94. package/dist/controlStyles.js.map +1 -0
  95. package/dist/expectNoAxeViolations.testHelpers.d.ts +19 -0
  96. package/dist/expectNoAxeViolations.testHelpers.d.ts.map +1 -0
  97. package/dist/expectNoAxeViolations.testHelpers.js +32 -0
  98. package/dist/expectNoAxeViolations.testHelpers.js.map +1 -0
  99. package/dist/index.d.ts +75 -0
  100. package/dist/index.d.ts.map +1 -0
  101. package/dist/index.js +55 -0
  102. package/dist/index.js.map +1 -0
  103. package/dist/intentStyles.d.ts +76 -0
  104. package/dist/intentStyles.d.ts.map +1 -0
  105. package/dist/intentStyles.js +150 -0
  106. package/dist/intentStyles.js.map +1 -0
  107. package/dist/mountStory.testHelpers.d.ts +30 -0
  108. package/dist/mountStory.testHelpers.d.ts.map +1 -0
  109. package/dist/mountStory.testHelpers.js +19 -0
  110. package/dist/mountStory.testHelpers.js.map +1 -0
  111. package/dist/statusIntent.d.ts +33 -0
  112. package/dist/statusIntent.d.ts.map +1 -0
  113. package/dist/statusIntent.js +99 -0
  114. package/dist/statusIntent.js.map +1 -0
  115. package/dist/styles.css +130 -0
  116. package/dist/testing/expectAgentDrivable.d.ts +53 -0
  117. package/dist/testing/expectAgentDrivable.d.ts.map +1 -0
  118. package/dist/testing/expectAgentDrivable.js +163 -0
  119. package/dist/testing/expectAgentDrivable.js.map +1 -0
  120. package/dist/testing/index.d.ts +11 -0
  121. package/dist/testing/index.d.ts.map +1 -0
  122. package/dist/testing/index.js +10 -0
  123. package/dist/testing/index.js.map +1 -0
  124. package/dist/toClassName.d.ts +12 -0
  125. package/dist/toClassName.d.ts.map +1 -0
  126. package/dist/toClassName.js +12 -0
  127. package/dist/toClassName.js.map +1 -0
  128. package/dist/tokens.d.ts +16 -0
  129. package/dist/tokens.d.ts.map +1 -0
  130. package/dist/tokens.js +16 -0
  131. package/dist/tokens.js.map +1 -0
  132. package/package.json +70 -0
  133. package/src/Alert/Alert.mdx +70 -0
  134. package/src/Alert/Alert.stories.tsx +218 -0
  135. package/src/Alert/Alert.test.tsx +112 -0
  136. package/src/Alert/Alert.tsx +166 -0
  137. package/src/Badge/Badge.mdx +43 -0
  138. package/src/Badge/Badge.stories.tsx +229 -0
  139. package/src/Badge/Badge.test.tsx +153 -0
  140. package/src/Badge/Badge.tsx +130 -0
  141. package/src/Badge/useIsTextClipped.ts +77 -0
  142. package/src/Button/Button.mdx +44 -0
  143. package/src/Button/Button.stories.tsx +175 -0
  144. package/src/Button/Button.test.tsx +114 -0
  145. package/src/Button/Button.tsx +125 -0
  146. package/src/Card/Card.mdx +53 -0
  147. package/src/Card/Card.stories.tsx +207 -0
  148. package/src/Card/Card.test.tsx +56 -0
  149. package/src/Card/Card.tsx +142 -0
  150. package/src/EmptyState/EmptyState.mdx +40 -0
  151. package/src/EmptyState/EmptyState.stories.tsx +183 -0
  152. package/src/EmptyState/EmptyState.test.tsx +41 -0
  153. package/src/EmptyState/EmptyState.tsx +114 -0
  154. package/src/IconButton/IconButton.mdx +34 -0
  155. package/src/IconButton/IconButton.stories.tsx +191 -0
  156. package/src/IconButton/IconButton.test.tsx +70 -0
  157. package/src/IconButton/IconButton.tsx +54 -0
  158. package/src/LiveStatusIndicator/LiveStatusIndicator.mdx +43 -0
  159. package/src/LiveStatusIndicator/LiveStatusIndicator.stories.tsx +199 -0
  160. package/src/LiveStatusIndicator/LiveStatusIndicator.test.tsx +63 -0
  161. package/src/LiveStatusIndicator/LiveStatusIndicator.tsx +116 -0
  162. package/src/MediaTile/MediaTile.mdx +51 -0
  163. package/src/MediaTile/MediaTile.stories.tsx +211 -0
  164. package/src/MediaTile/MediaTile.test.tsx +41 -0
  165. package/src/MediaTile/MediaTile.tsx +227 -0
  166. package/src/MediaTile/mediaStatus.ts +28 -0
  167. package/src/Modal/Modal.mdx +113 -0
  168. package/src/Modal/Modal.stories.tsx +272 -0
  169. package/src/Modal/Modal.test.tsx +248 -0
  170. package/src/Modal/Modal.tsx +228 -0
  171. package/src/Modal/lockScrollBehind.ts +59 -0
  172. package/src/Popover/Popover.mdx +102 -0
  173. package/src/Popover/Popover.stories.tsx +247 -0
  174. package/src/Popover/Popover.test.tsx +191 -0
  175. package/src/Popover/Popover.tsx +164 -0
  176. package/src/ProgressBar/ProgressBar.mdx +48 -0
  177. package/src/ProgressBar/ProgressBar.stories.tsx +232 -0
  178. package/src/ProgressBar/ProgressBar.test.tsx +74 -0
  179. package/src/ProgressBar/ProgressBar.tsx +172 -0
  180. package/src/ProgressBar/progressValue.test.ts +101 -0
  181. package/src/ProgressBar/progressValue.ts +87 -0
  182. package/src/SegmentedControl/SegmentedControl.mdx +71 -0
  183. package/src/SegmentedControl/SegmentedControl.stories.tsx +150 -0
  184. package/src/SegmentedControl/SegmentedControl.test.tsx +208 -0
  185. package/src/SegmentedControl/SegmentedControl.tsx +219 -0
  186. package/src/SegmentedControl/SegmentedOption.tsx +94 -0
  187. package/src/Skeleton/Skeleton.mdx +30 -0
  188. package/src/Skeleton/Skeleton.stories.tsx +130 -0
  189. package/src/Skeleton/Skeleton.test.tsx +47 -0
  190. package/src/Skeleton/Skeleton.tsx +106 -0
  191. package/src/Spinner/Spinner.mdx +36 -0
  192. package/src/Spinner/Spinner.stories.tsx +118 -0
  193. package/src/Spinner/Spinner.test.tsx +38 -0
  194. package/src/Spinner/Spinner.tsx +92 -0
  195. package/src/Tabs/TabTrigger.tsx +126 -0
  196. package/src/Tabs/Tabs.mdx +126 -0
  197. package/src/Tabs/Tabs.stories.tsx +187 -0
  198. package/src/Tabs/Tabs.test.tsx +283 -0
  199. package/src/Tabs/Tabs.tsx +333 -0
  200. package/src/VisuallyHidden/VisuallyHidden.mdx +29 -0
  201. package/src/VisuallyHidden/VisuallyHidden.stories.tsx +58 -0
  202. package/src/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
  203. package/src/VisuallyHidden/VisuallyHidden.tsx +43 -0
  204. package/src/argTypes.storyHelpers.ts +115 -0
  205. package/src/board.storyHelpers.tsx +150 -0
  206. package/src/controlStyles.ts +79 -0
  207. package/src/expectNoAxeViolations.testHelpers.ts +41 -0
  208. package/src/icons.storyHelpers.tsx +116 -0
  209. package/src/index.ts +132 -0
  210. package/src/intentStyles.ts +207 -0
  211. package/src/mdxReferences.test.ts +95 -0
  212. package/src/mountStory.testHelpers.ts +41 -0
  213. package/src/sourceRules.test.ts +303 -0
  214. package/src/statusIntent.test.ts +136 -0
  215. package/src/statusIntent.ts +137 -0
  216. package/src/storyControls.test.ts +128 -0
  217. package/src/styles.css +130 -0
  218. package/src/tailwindCandidates.test.ts +377 -0
  219. package/src/testing/expectAgentDrivable.test.tsx +71 -0
  220. package/src/testing/expectAgentDrivable.ts +273 -0
  221. package/src/testing/index.ts +17 -0
  222. package/src/toClassName.ts +13 -0
  223. package/src/tokens.ts +16 -0
@@ -0,0 +1,228 @@
1
+ import { useUniqueId } from "@charcuterie/logic"
2
+ import type {
3
+ ComponentPropsWithRef,
4
+ ReactNode,
5
+ } from "react"
6
+ import { useEffect, useRef } from "react"
7
+
8
+ import { Button } from "../Button/Button.tsx"
9
+ import { toClassName } from "../toClassName.ts"
10
+ import { lockScrollBehind } from "./lockScrollBehind.ts"
11
+
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
+ */
28
+ isDismissable?: boolean
29
+ isVisible: boolean
30
+ /**
31
+ * Every route out lands here: the close button, Escape, a
32
+ * backdrop press. Wire it to a `useVisibility()`'s `hide`.
33
+ */
34
+ onClose: () => void
35
+ size?: ModalSize
36
+ }
37
+
38
+ /**
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.
73
+ *
74
+ * ### Why it is not portalled
75
+ *
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.
84
+ *
85
+ * ### `open:flex`, not `flex`
86
+ *
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.
92
+ *
93
+ * ### The accessible name
94
+ *
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.
98
+ */
99
+ export const Modal = ({
100
+ children,
101
+ className,
102
+ footer,
103
+ heading,
104
+ headingLevel = 2,
105
+ isDismissable = true,
106
+ isVisible,
107
+ 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
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The one thing `showModal()` does not do.
3
+ *
4
+ * A native modal makes everything behind it `inert` — unclickable,
5
+ * untabbable, invisible to a screen reader — and then lets you
6
+ * scroll it anyway. On a phone that reads as the dialog sliding
7
+ * around; on a long page it loses the user's place entirely.
8
+ *
9
+ * Counted rather than boolean, because nested dialogs are legal
10
+ * (`showModal()` stacks in the top layer) and a naive
11
+ * lock/unlock pair has the inner one's cleanup unlock the page
12
+ * while the outer is still open. The original value is captured
13
+ * on the *first* lock, so restoring is a restore rather than a
14
+ * guess at what an app's own stylesheet wanted.
15
+ *
16
+ * Not a React hook: this is process-wide state, and two components
17
+ * sharing it through module scope is the honest expression of
18
+ * that. `Modal` calls it from an effect and returns the release as
19
+ * the cleanup.
20
+ */
21
+
22
+ let lockCount = 0
23
+
24
+ let previousOverflow = ""
25
+
26
+ export const lockScrollBehind = (): (() => void) => {
27
+ if (typeof document === "undefined") {
28
+ return () => {}
29
+ }
30
+
31
+ const { documentElement } = document
32
+
33
+ if (lockCount === 0) {
34
+ previousOverflow = documentElement.style.overflow
35
+
36
+ documentElement.style.overflow = "hidden"
37
+ }
38
+
39
+ lockCount += 1
40
+
41
+ let isReleased = false
42
+
43
+ return () => {
44
+ // Idempotent, because React 19's StrictMode runs an effect's
45
+ // cleanup twice and a double decrement would unlock the page
46
+ // under a dialog that is still open.
47
+ if (isReleased) {
48
+ return
49
+ }
50
+
51
+ isReleased = true
52
+
53
+ lockCount -= 1
54
+
55
+ if (lockCount === 0) {
56
+ documentElement.style.overflow = previousOverflow
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,102 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as PopoverStories from "./Popover.stories";
4
+
5
+ <Meta of={PopoverStories} />
6
+
7
+ # Popover
8
+
9
+ Replaces mux-magic's `@radix-ui/react-popover` re-export — a rewrite rather than a swap,
10
+ for one reason: **Radix owns `open`.** Wrapping it leaves `useVisibility` and Radix both
11
+ believing they hold the state, which is the conflict the whole state layer exists to
12
+ avoid.
13
+
14
+ <Canvas of={PopoverStories.Default} />
15
+
16
+ ## Props
17
+
18
+ <Controls />
19
+
20
+ ## floating-ui, and only for what it is good at
21
+
22
+ `@floating-ui/react` is the layer Radix, Base UI, and Ark UI are all *built on*, and it is
23
+ **controlled by construction**: you pass state in and it never stores any.
24
+
25
+ ```tsx
26
+ useFloating({
27
+ open: isVisible, // ← read-only. Charcuterie stays the owner.
28
+ onOpenChange: (isNext) => { if (!isNext) hide() },
29
+ middleware: [offset(8), flip(), shift({ padding: 8 })],
30
+ strategy: "fixed",
31
+ })
32
+ ```
33
+
34
+ It is used for the three things only it can do — collision handling, a real dismiss layer,
35
+ and focus management. The top layer, the positioning strategy, and the ARIA wiring come
36
+ from the platform and from `useRole`.
37
+
38
+ Passing `open:` *into* floating-ui does not violate the `is`/`has` rule; the adapter
39
+ boundary is exactly the carve-out that rule already has
40
+ ([decision](../../../docs/decisions/2026-07-29-is-has-rule-has-no-external-api-carve-out.md)).
41
+
42
+ ## `popover="manual"`, not a portal and not `auto`
43
+
44
+ `popover="manual"` puts the panel in the **top layer** — no `z-index` contest, and no
45
+ `overflow: hidden` ancestor clipping it. That is what a portal is normally reached for,
46
+ and unlike a portal the element **stays where it is in the DOM**:
47
+
48
+ > `canvas.getByRole("dialog")` finds this panel. It would not find a portalled one, and
49
+ > every story here would have to reach for `screen` and lose its scoping. Same argument as
50
+ > `Modal`, one layer down.
51
+
52
+ `manual` rather than `auto` is the state-ownership decision again. `auto` brings the UA's
53
+ own light-dismiss, which closes the element by itself and leaves `isVisible` true.
54
+ `useDismiss` does the same job through our callback, so Charcuterie stays the only thing
55
+ that decides.
56
+
57
+ ### Two UA defaults that must be undone
58
+
59
+ `[popover]` ships `position: fixed; inset: 0; margin: auto`. Left in place it centres the
60
+ panel in the viewport and fights every coordinate floating-ui computes — and it looks
61
+ deliberate, which is the worst kind of bug. `inset-auto m-0` puts it back, and
62
+ `strategy: "fixed"` keeps the numbers viewport-relative like the top layer itself.
63
+ `AllVariants` asserts the panel is positioned at all, for exactly this reason.
64
+
65
+ <Canvas of={PopoverStories.AllVariants} />
66
+
67
+ ## The name is required
68
+
69
+ `role="dialog"` takes **no accessible name from its content**, so `heading` is required and
70
+ becomes `aria-label`. An unnamed panel is announced as "dialog" and is unfindable by
71
+ `getByRole("dialog", { name })` — the same trap `Spinner` walked into in M3 with
72
+ `role="status"`
73
+ ([decision](../../../docs/decisions/2026-07-29-status-regions-carry-an-aria-label.md)).
74
+
75
+ `aria-expanded`, `aria-haspopup`, and `aria-controls` on the trigger, and `id` and `role`
76
+ on the panel, all come from `useRole` — one source for both ends of the link, because a
77
+ hand-written `aria-controls` pointing at an id that moved renders identically and
78
+ announces nothing.
79
+
80
+ <Canvas of={PopoverStories.AllStates} />
81
+
82
+ ## The trigger is cloned, not wrapped
83
+
84
+ `useClonedChild` injects the reference props and the ref into whatever button you already
85
+ have. An extra `<div>` around it is how a toolbar's layout quietly changes, and it is why
86
+ this is the pattern v1 chose and M2 ported.
87
+
88
+ ## Escape *is* pressable here
89
+
90
+ Unlike `Modal`'s. `useDismiss` listens for a keydown rather than relying on a UA default
91
+ action, and a synthetic keydown is a real keydown as far as a listener is concerned — so
92
+ the `Interactive` story presses it for real, and also dismisses by pressing the page
93
+ behind the panel.
94
+
95
+ <Canvas of={PopoverStories.Responsive} />
96
+
97
+ A popover is positioned against the **viewport**, so `Responsive` is three triggers pinned
98
+ to three edges rather than a `ContainerBoard`. The assertion is that the panel stays
99
+ inside the viewport — which is all `flip` and `shift` actually promise; asserting a
100
+ specific side would be asserting the collision did not happen.
101
+
102
+ <Canvas of={PopoverStories.Interactive} />
@@ -0,0 +1,247 @@
1
+ import { useVisibility } from "@charcuterie/logic"
2
+ import type { Placement } from "@floating-ui/react"
3
+ import type { Meta, StoryObj } from "@storybook/react"
4
+ import type { ReactNode } from "react"
5
+
6
+ import { placementArgType } from "../argTypes.storyHelpers.ts"
7
+ import { Badge } from "../Badge/Badge.tsx"
8
+ import { Button } from "../Button/Button.tsx"
9
+ import {
10
+ StoryCell,
11
+ StoryGrid,
12
+ StoryRow,
13
+ } from "../board.storyHelpers.tsx"
14
+ import { Popover } from "./Popover.tsx"
15
+
16
+ const meta = {
17
+ title: "Components/Popover",
18
+ component: Popover,
19
+ parameters: { layout: "padded" },
20
+ argTypes: { placement: placementArgType },
21
+ args: { placement: "bottom-start" },
22
+ } satisfies Meta<typeof Popover>
23
+
24
+ export default meta
25
+
26
+ type Story = StoryObj<typeof meta>
27
+
28
+ /**
29
+ * One `useVisibility` per popover, and the trigger is a real
30
+ * `Button` that the component clones rather than wraps.
31
+ */
32
+ const PopoverDemo = ({
33
+ children,
34
+ heading,
35
+ placement,
36
+ triggerLabel,
37
+ }: {
38
+ children?: ReactNode
39
+ heading: string
40
+ placement?: Placement
41
+ triggerLabel: string
42
+ }): ReactNode => {
43
+ const { hide, isVisible, toggle } = useVisibility()
44
+
45
+ return (
46
+ <Popover
47
+ heading={heading}
48
+ isVisible={isVisible}
49
+ onDismiss={hide}
50
+ placement={placement}
51
+ trigger={
52
+ <Button
53
+ appearance="soft"
54
+ onClick={toggle}
55
+ size="sm"
56
+ >
57
+ {triggerLabel}
58
+ </Button>
59
+ }
60
+ >
61
+ {children ?? (
62
+ <div className="flex flex-col gap-2">
63
+ <p className="text-content-secondary">
64
+ Filters apply to this bay only.
65
+ </p>
66
+
67
+ <Button appearance="outline" size="sm">
68
+ Clear filters
69
+ </Button>
70
+ </div>
71
+ )}
72
+ </Popover>
73
+ )
74
+ }
75
+
76
+ export const Default: Story = {
77
+ args: {
78
+ children: null,
79
+ heading: "Filters",
80
+ isVisible: false,
81
+ onDismiss: () => {},
82
+ trigger: <Button>Filters</Button>,
83
+ },
84
+ render: () => (
85
+ <PopoverDemo heading="Filters" triggerLabel="Filters" />
86
+ ),
87
+ }
88
+
89
+ const PLACEMENTS: Placement[] = [
90
+ "top",
91
+ "bottom-start",
92
+ "right",
93
+ "left-end",
94
+ ]
95
+
96
+ export const AllVariants: Story = {
97
+ args: {
98
+ children: null,
99
+ heading: "Filters",
100
+ isVisible: false,
101
+ onDismiss: () => {},
102
+ trigger: <Button>Filters</Button>,
103
+ },
104
+ render: () => (
105
+ <StoryGrid columns={4}>
106
+ {PLACEMENTS.map((placement) => (
107
+ <StoryCell key={placement} label={placement}>
108
+ <PopoverDemo
109
+ heading={`Filters — ${placement}`}
110
+ placement={placement}
111
+ triggerLabel={`Open ${placement}`}
112
+ >
113
+ <p className="text-content-secondary">
114
+ Requested {placement}. `flip` and `shift` will
115
+ overrule it rather than let the panel leave
116
+ the viewport.
117
+ </p>
118
+ </PopoverDemo>
119
+ </StoryCell>
120
+ ))}
121
+ </StoryGrid>
122
+ ),
123
+ }
124
+
125
+ export const AllStates: Story = {
126
+ args: {
127
+ children: null,
128
+ heading: "Filters",
129
+ isVisible: false,
130
+ onDismiss: () => {},
131
+ trigger: <Button>Filters</Button>,
132
+ },
133
+ render: () => (
134
+ <StoryRow>
135
+ <PopoverDemo
136
+ heading="Bay 3 filters"
137
+ triggerLabel="Interactive content"
138
+ />
139
+
140
+ <PopoverDemo
141
+ heading="Read error detail"
142
+ triggerLabel="Text only"
143
+ >
144
+ <p className="text-content-secondary">
145
+ MakeMKV reported a read error on title 4 at
146
+ 00:41:12. Eight of nine titles completed.
147
+ </p>
148
+ </PopoverDemo>
149
+
150
+ <PopoverDemo
151
+ heading="Bay 3 status"
152
+ triggerLabel="With a badge"
153
+ >
154
+ <div className="flex flex-col gap-2">
155
+ <Badge intent="warning" size="sm">
156
+ degraded
157
+ </Badge>
158
+
159
+ <p className="text-content-secondary">
160
+ One read retry in the last hour.
161
+ </p>
162
+ </div>
163
+ </PopoverDemo>
164
+ </StoryRow>
165
+ ),
166
+ }
167
+
168
+ /**
169
+ * The board a container query cannot help with, and for the
170
+ * opposite reason to `Modal`'s. A popover is positioned against the
171
+ * **viewport** — `flip` and `shift` measure collisions with it — so
172
+ * what varies is where the trigger sits on screen, not how wide its
173
+ * container is. Three triggers pinned to three edges is the honest
174
+ * instrument.
175
+ */
176
+ export const Responsive: Story = {
177
+ args: {
178
+ children: null,
179
+ heading: "Filters",
180
+ isVisible: false,
181
+ onDismiss: () => {},
182
+ trigger: <Button>Filters</Button>,
183
+ },
184
+ render: () => (
185
+ <div className="flex min-h-64 flex-col justify-between gap-4">
186
+ <div className="flex justify-between">
187
+ <PopoverDemo
188
+ heading="Filters — top start"
189
+ placement="top"
190
+ triggerLabel="Top edge, asks for top"
191
+ >
192
+ <p className="text-content-secondary">
193
+ Asked for `top`, has no room, so `flip` sends it
194
+ down.
195
+ </p>
196
+ </PopoverDemo>
197
+
198
+ <PopoverDemo
199
+ heading="Filters — top end"
200
+ placement="right"
201
+ triggerLabel="Right edge, asks for right"
202
+ >
203
+ <p className="text-content-secondary">
204
+ Asked for `right`, has no room, so `shift` and
205
+ `flip` bring it back inside.
206
+ </p>
207
+ </PopoverDemo>
208
+ </div>
209
+
210
+ <PopoverDemo
211
+ heading="Filters — bottom"
212
+ placement="bottom"
213
+ triggerLabel="Bottom, asks for bottom"
214
+ >
215
+ <p className="text-content-secondary">
216
+ Room below, so it gets what it asked for.
217
+ </p>
218
+ </PopoverDemo>
219
+ </div>
220
+ ),
221
+ }
222
+
223
+ /**
224
+ * The dismiss layer, driven. Outside press and Escape both route
225
+ * through `useDismiss` → `onDismiss` → `hide()`, so the panel is
226
+ * never closed by anything except the state saying so.
227
+ *
228
+ * Escape *is* pressable here, unlike `Modal`'s: `useDismiss`
229
+ * listens for a keydown rather than relying on a UA default
230
+ * action, and a synthetic keydown is a real keydown as far as a
231
+ * listener is concerned.
232
+ */
233
+ export const Interactive: Story = {
234
+ args: {
235
+ children: null,
236
+ heading: "Filters",
237
+ isVisible: false,
238
+ onDismiss: () => {},
239
+ trigger: <Button>Filters</Button>,
240
+ },
241
+ render: () => (
242
+ <PopoverDemo
243
+ heading="Bay 3 filters"
244
+ triggerLabel="Filters"
245
+ />
246
+ ),
247
+ }