@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.
- package/CHANGELOG.md +13 -0
- package/README.md +161 -0
- package/dist/Alert/Alert.d.ts +88 -0
- package/dist/Alert/Alert.d.ts.map +1 -0
- package/dist/Alert/Alert.js +67 -0
- package/dist/Alert/Alert.js.map +1 -0
- package/dist/Badge/Badge.d.ts +54 -0
- package/dist/Badge/Badge.d.ts.map +1 -0
- package/dist/Badge/Badge.js +54 -0
- package/dist/Badge/Badge.js.map +1 -0
- package/dist/Badge/useIsTextClipped.d.ts +22 -0
- package/dist/Badge/useIsTextClipped.d.ts.map +1 -0
- package/dist/Badge/useIsTextClipped.js +57 -0
- package/dist/Badge/useIsTextClipped.js.map +1 -0
- package/dist/Button/Button.d.ts +58 -0
- package/dist/Button/Button.d.ts.map +1 -0
- package/dist/Button/Button.js +37 -0
- package/dist/Button/Button.js.map +1 -0
- package/dist/Card/Card.d.ts +44 -0
- package/dist/Card/Card.d.ts.map +1 -0
- package/dist/Card/Card.js +59 -0
- package/dist/Card/Card.js.map +1 -0
- package/dist/EmptyState/EmptyState.d.ts +42 -0
- package/dist/EmptyState/EmptyState.d.ts.map +1 -0
- package/dist/EmptyState/EmptyState.js +37 -0
- package/dist/EmptyState/EmptyState.js.map +1 -0
- package/dist/IconButton/IconButton.d.ts +34 -0
- package/dist/IconButton/IconButton.d.ts.map +1 -0
- package/dist/IconButton/IconButton.js +23 -0
- package/dist/IconButton/IconButton.js.map +1 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts +37 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts.map +1 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.js +39 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.js.map +1 -0
- package/dist/MediaTile/MediaTile.d.ts +46 -0
- package/dist/MediaTile/MediaTile.d.ts.map +1 -0
- package/dist/MediaTile/MediaTile.js +79 -0
- package/dist/MediaTile/MediaTile.js.map +1 -0
- package/dist/MediaTile/mediaStatus.d.ts +22 -0
- package/dist/MediaTile/mediaStatus.d.ts.map +1 -0
- package/dist/MediaTile/mediaStatus.js +24 -0
- package/dist/MediaTile/mediaStatus.js.map +1 -0
- package/dist/Modal/Modal.d.ts +66 -0
- package/dist/Modal/Modal.d.ts.map +1 -0
- package/dist/Modal/Modal.js +111 -0
- package/dist/Modal/Modal.js.map +1 -0
- package/dist/Modal/lockScrollBehind.d.ts +22 -0
- package/dist/Modal/lockScrollBehind.d.ts.map +1 -0
- package/dist/Modal/lockScrollBehind.js +48 -0
- package/dist/Modal/lockScrollBehind.js.map +1 -0
- package/dist/Popover/Popover.d.ts +66 -0
- package/dist/Popover/Popover.d.ts.map +1 -0
- package/dist/Popover/Popover.js +88 -0
- package/dist/Popover/Popover.js.map +1 -0
- package/dist/ProgressBar/ProgressBar.d.ts +51 -0
- package/dist/ProgressBar/ProgressBar.d.ts.map +1 -0
- package/dist/ProgressBar/ProgressBar.js +50 -0
- package/dist/ProgressBar/ProgressBar.js.map +1 -0
- package/dist/ProgressBar/progressValue.d.ts +44 -0
- package/dist/ProgressBar/progressValue.d.ts.map +1 -0
- package/dist/ProgressBar/progressValue.js +55 -0
- package/dist/ProgressBar/progressValue.js.map +1 -0
- package/dist/SegmentedControl/SegmentedControl.d.ts +59 -0
- package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -0
- package/dist/SegmentedControl/SegmentedControl.js +129 -0
- package/dist/SegmentedControl/SegmentedControl.js.map +1 -0
- package/dist/SegmentedControl/SegmentedOption.d.ts +28 -0
- package/dist/SegmentedControl/SegmentedOption.d.ts.map +1 -0
- package/dist/SegmentedControl/SegmentedOption.js +40 -0
- package/dist/SegmentedControl/SegmentedOption.js.map +1 -0
- package/dist/Skeleton/Skeleton.d.ts +30 -0
- package/dist/Skeleton/Skeleton.d.ts.map +1 -0
- package/dist/Skeleton/Skeleton.js +43 -0
- package/dist/Skeleton/Skeleton.js.map +1 -0
- package/dist/Spinner/Spinner.d.ts +37 -0
- package/dist/Spinner/Spinner.d.ts.map +1 -0
- package/dist/Spinner/Spinner.js +33 -0
- package/dist/Spinner/Spinner.js.map +1 -0
- package/dist/Tabs/TabTrigger.d.ts +32 -0
- package/dist/Tabs/TabTrigger.d.ts.map +1 -0
- package/dist/Tabs/TabTrigger.js +62 -0
- package/dist/Tabs/TabTrigger.js.map +1 -0
- package/dist/Tabs/Tabs.d.ts +95 -0
- package/dist/Tabs/Tabs.d.ts.map +1 -0
- package/dist/Tabs/Tabs.js +195 -0
- package/dist/Tabs/Tabs.js.map +1 -0
- package/dist/VisuallyHidden/VisuallyHidden.d.ts +25 -0
- package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
- package/dist/VisuallyHidden/VisuallyHidden.js +23 -0
- package/dist/VisuallyHidden/VisuallyHidden.js.map +1 -0
- package/dist/controlStyles.d.ts +50 -0
- package/dist/controlStyles.d.ts.map +1 -0
- package/dist/controlStyles.js +60 -0
- package/dist/controlStyles.js.map +1 -0
- package/dist/expectNoAxeViolations.testHelpers.d.ts +19 -0
- package/dist/expectNoAxeViolations.testHelpers.d.ts.map +1 -0
- package/dist/expectNoAxeViolations.testHelpers.js +32 -0
- package/dist/expectNoAxeViolations.testHelpers.js.map +1 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/intentStyles.d.ts +76 -0
- package/dist/intentStyles.d.ts.map +1 -0
- package/dist/intentStyles.js +150 -0
- package/dist/intentStyles.js.map +1 -0
- package/dist/mountStory.testHelpers.d.ts +30 -0
- package/dist/mountStory.testHelpers.d.ts.map +1 -0
- package/dist/mountStory.testHelpers.js +19 -0
- package/dist/mountStory.testHelpers.js.map +1 -0
- package/dist/statusIntent.d.ts +33 -0
- package/dist/statusIntent.d.ts.map +1 -0
- package/dist/statusIntent.js +99 -0
- package/dist/statusIntent.js.map +1 -0
- package/dist/styles.css +130 -0
- package/dist/testing/expectAgentDrivable.d.ts +53 -0
- package/dist/testing/expectAgentDrivable.d.ts.map +1 -0
- package/dist/testing/expectAgentDrivable.js +163 -0
- package/dist/testing/expectAgentDrivable.js.map +1 -0
- package/dist/testing/index.d.ts +11 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +10 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/toClassName.d.ts +12 -0
- package/dist/toClassName.d.ts.map +1 -0
- package/dist/toClassName.js +12 -0
- package/dist/toClassName.js.map +1 -0
- package/dist/tokens.d.ts +16 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +16 -0
- package/dist/tokens.js.map +1 -0
- package/package.json +70 -0
- package/src/Alert/Alert.mdx +70 -0
- package/src/Alert/Alert.stories.tsx +218 -0
- package/src/Alert/Alert.test.tsx +112 -0
- package/src/Alert/Alert.tsx +166 -0
- package/src/Badge/Badge.mdx +43 -0
- package/src/Badge/Badge.stories.tsx +229 -0
- package/src/Badge/Badge.test.tsx +153 -0
- package/src/Badge/Badge.tsx +130 -0
- package/src/Badge/useIsTextClipped.ts +77 -0
- package/src/Button/Button.mdx +44 -0
- package/src/Button/Button.stories.tsx +175 -0
- package/src/Button/Button.test.tsx +114 -0
- package/src/Button/Button.tsx +125 -0
- package/src/Card/Card.mdx +53 -0
- package/src/Card/Card.stories.tsx +207 -0
- package/src/Card/Card.test.tsx +56 -0
- package/src/Card/Card.tsx +142 -0
- package/src/EmptyState/EmptyState.mdx +40 -0
- package/src/EmptyState/EmptyState.stories.tsx +183 -0
- package/src/EmptyState/EmptyState.test.tsx +41 -0
- package/src/EmptyState/EmptyState.tsx +114 -0
- package/src/IconButton/IconButton.mdx +34 -0
- package/src/IconButton/IconButton.stories.tsx +191 -0
- package/src/IconButton/IconButton.test.tsx +70 -0
- package/src/IconButton/IconButton.tsx +54 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.mdx +43 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.stories.tsx +199 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.test.tsx +63 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.tsx +116 -0
- package/src/MediaTile/MediaTile.mdx +51 -0
- package/src/MediaTile/MediaTile.stories.tsx +211 -0
- package/src/MediaTile/MediaTile.test.tsx +41 -0
- package/src/MediaTile/MediaTile.tsx +227 -0
- package/src/MediaTile/mediaStatus.ts +28 -0
- package/src/Modal/Modal.mdx +113 -0
- package/src/Modal/Modal.stories.tsx +272 -0
- package/src/Modal/Modal.test.tsx +248 -0
- package/src/Modal/Modal.tsx +228 -0
- package/src/Modal/lockScrollBehind.ts +59 -0
- package/src/Popover/Popover.mdx +102 -0
- package/src/Popover/Popover.stories.tsx +247 -0
- package/src/Popover/Popover.test.tsx +191 -0
- package/src/Popover/Popover.tsx +164 -0
- package/src/ProgressBar/ProgressBar.mdx +48 -0
- package/src/ProgressBar/ProgressBar.stories.tsx +232 -0
- package/src/ProgressBar/ProgressBar.test.tsx +74 -0
- package/src/ProgressBar/ProgressBar.tsx +172 -0
- package/src/ProgressBar/progressValue.test.ts +101 -0
- package/src/ProgressBar/progressValue.ts +87 -0
- package/src/SegmentedControl/SegmentedControl.mdx +71 -0
- package/src/SegmentedControl/SegmentedControl.stories.tsx +150 -0
- package/src/SegmentedControl/SegmentedControl.test.tsx +208 -0
- package/src/SegmentedControl/SegmentedControl.tsx +219 -0
- package/src/SegmentedControl/SegmentedOption.tsx +94 -0
- package/src/Skeleton/Skeleton.mdx +30 -0
- package/src/Skeleton/Skeleton.stories.tsx +130 -0
- package/src/Skeleton/Skeleton.test.tsx +47 -0
- package/src/Skeleton/Skeleton.tsx +106 -0
- package/src/Spinner/Spinner.mdx +36 -0
- package/src/Spinner/Spinner.stories.tsx +118 -0
- package/src/Spinner/Spinner.test.tsx +38 -0
- package/src/Spinner/Spinner.tsx +92 -0
- package/src/Tabs/TabTrigger.tsx +126 -0
- package/src/Tabs/Tabs.mdx +126 -0
- package/src/Tabs/Tabs.stories.tsx +187 -0
- package/src/Tabs/Tabs.test.tsx +283 -0
- package/src/Tabs/Tabs.tsx +333 -0
- package/src/VisuallyHidden/VisuallyHidden.mdx +29 -0
- package/src/VisuallyHidden/VisuallyHidden.stories.tsx +58 -0
- package/src/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
- package/src/VisuallyHidden/VisuallyHidden.tsx +43 -0
- package/src/argTypes.storyHelpers.ts +115 -0
- package/src/board.storyHelpers.tsx +150 -0
- package/src/controlStyles.ts +79 -0
- package/src/expectNoAxeViolations.testHelpers.ts +41 -0
- package/src/icons.storyHelpers.tsx +116 -0
- package/src/index.ts +132 -0
- package/src/intentStyles.ts +207 -0
- package/src/mdxReferences.test.ts +95 -0
- package/src/mountStory.testHelpers.ts +41 -0
- package/src/sourceRules.test.ts +303 -0
- package/src/statusIntent.test.ts +136 -0
- package/src/statusIntent.ts +137 -0
- package/src/storyControls.test.ts +128 -0
- package/src/styles.css +130 -0
- package/src/tailwindCandidates.test.ts +377 -0
- package/src/testing/expectAgentDrivable.test.tsx +71 -0
- package/src/testing/expectAgentDrivable.ts +273 -0
- package/src/testing/index.ts +17 -0
- package/src/toClassName.ts +13 -0
- 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
|
+
}
|