@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,113 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as ModalStories from "./Modal.stories";
4
+
5
+ <Meta of={ModalStories} />
6
+
7
+ # Modal
8
+
9
+ Four repos, and **plex-channels and ripdeck arrived at native `<dialog>` independently**
10
+ before this library existed. That is the design, not a shortcut: `showModal()` gives a
11
+ focus trap, Escape, `::backdrop`, `inert` on everything behind it, and the top layer —
12
+ from the platform, correctly, in every browser the fleet runs on.
13
+
14
+ <Canvas of={ModalStories.Default} />
15
+
16
+ ## Props
17
+
18
+ <Controls />
19
+
20
+ ## The one thing a component has to add
21
+
22
+ A native `<dialog>` is a **self-owning** control: call `showModal()` and it decides it is
23
+ open. That is the same conflict Radix has, and it gets the same answer as everywhere else
24
+ here — `isVisible` is the truth, and an effect makes the element agree with it. Nothing
25
+ reads `dialogElement.open` as state; it is read only to avoid calling a method that would
26
+ throw.
27
+
28
+ ```tsx
29
+ const { hide, isVisible, show } = useVisibility()
30
+
31
+ <Button onClick={show}>Stop the rip</Button>
32
+
33
+ <Modal heading="Stop the rip?" isVisible={isVisible} onClose={hide}>
34
+ …
35
+ </Modal>
36
+ ```
37
+
38
+ `onClose` is every route out — the close button, Escape, a backdrop press — so there is
39
+ one place to wire and no way to end up with the element shut and the state still open.
40
+
41
+ ## No portal, and that is load-bearing
42
+
43
+ The top layer is a **paint** concept. A `<dialog>` renders above everything while staying
44
+ exactly where it is in the DOM, so it needs no portal — and that matters well past
45
+ tidiness:
46
+
47
+ > A portal moves the node to `document.body`, **out of whatever element a test or an agent
48
+ > scoped its queries to.** `canvas.getByRole("dialog")` finds this modal. It would not find
49
+ > a portalled one, and every story here would have to reach for `screen` and give up its
50
+ > scoping.
51
+
52
+ The same reasoning is why `Popover` uses `popover="manual"` rather than a portal.
53
+
54
+ ## `open:flex`, not `flex`
55
+
56
+ A closed `<dialog>` is hidden by a UA rule, and any `display: flex` of ours beats it — so
57
+ a modal laid out with a plain `flex` renders permanently, in the page flow, with no error
58
+ anywhere. The layout is therefore behind Tailwind's `open:` variant, keyed on the `[open]`
59
+ attribute the browser manages.
60
+
61
+ <Canvas of={ModalStories.AllVariants} />
62
+
63
+ ## What a story cannot press
64
+
65
+ `play`'s `userEvent` is `@testing-library/user-event`, which dispatches **untrusted**
66
+ events, and a browser runs a default action only for trusted input. So a synthetic Escape
67
+ does not make a native `<dialog>` fire `cancel` — it does nothing at all, and a story
68
+ asserting "Escape did not close the non-dismissable dialog" would pass for the wrong
69
+ reason forever.
70
+
71
+ The stories dispatch the close request where the browser would raise it instead, which
72
+ splits the contract honestly: keystroke → `cancel` is the platform's and is not ours to
73
+ test; everything downstream of `cancel` is ours and is asserted. `AllStates` pairs it with
74
+ a differential — the dialog that refuses the close request is then closed by its own
75
+ button — because "still open" is also true of a dialog nothing can close at all.
76
+
77
+ <Canvas of={ModalStories.AllStates} />
78
+
79
+ ## `isDismissable={false}` cancels, it does not ignore
80
+
81
+ Escape is `preventDefault`ed rather than dropped. Letting the browser close a dialog the
82
+ app said was not dismissable would leave `isVisible` true and the element shut — the exact
83
+ desync this component exists to prevent.
84
+
85
+ ## Scroll behind it is locked, and the count is why
86
+
87
+ `showModal()` makes the page `inert` and then lets you scroll it anyway. The lock is a
88
+ counted module-level one rather than a hook, because nested dialogs are legal and a naive
89
+ lock/unlock pair has the inner dialog's cleanup unlock the page while the outer is still
90
+ open. The release is idempotent, because StrictMode runs an effect's cleanup twice.
91
+
92
+ ## `dvh`, and the scrim
93
+
94
+ Every size clamps at `85dvh`; `full` takes the lot. `dvh` rather than `vh` because on a
95
+ phone the two differ by the address bar, and a dialog whose footer is under it is a dialog
96
+ with no buttons.
97
+
98
+ `::backdrop` reads `--color-scrim`, which is a **variant** token: `legible` dims at 0.84
99
+ and `daylight` at 0.66, because how hard a direction separates a dialog from the page is
100
+ exactly the sort of thing a visual direction decides. ePaper's is `transparent` — a panel
101
+ with no opacity cannot dim, so it says so instead of faking it. The `Responsive` story
102
+ asserts the computed `::backdrop` colour is really there, since a `var()` that failed to
103
+ inherit would just look like a design choice.
104
+
105
+ <Canvas of={ModalStories.Responsive} />
106
+
107
+ ## A modal is not a container-query citizen
108
+
109
+ It gets no `ContainerBoard`. Its size is measured against the viewport by definition — it
110
+ is the thing covering the viewport — so boarding it at three container widths would be
111
+ measuring something with no bearing on it.
112
+
113
+ <Canvas of={ModalStories.Interactive} />
@@ -0,0 +1,272 @@
1
+ import { useVisibility } from "@charcuterie/logic"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import type { ReactNode } from "react"
4
+
5
+ import { toStoryChoice } from "../argTypes.storyHelpers.ts"
6
+ import { Button } from "../Button/Button.tsx"
7
+ import { StoryRow } from "../board.storyHelpers.tsx"
8
+ import type { ModalSize } from "./Modal.tsx"
9
+ import { Modal } from "./Modal.tsx"
10
+
11
+ const meta = {
12
+ title: "Components/Modal",
13
+ component: Modal,
14
+ parameters: { layout: "padded" },
15
+ argTypes: {
16
+ size: toStoryChoice([
17
+ "sm",
18
+ "md",
19
+ "lg",
20
+ "xl",
21
+ "full",
22
+ ] as const),
23
+ },
24
+ args: {
25
+ headingLevel: 2,
26
+ isDismissable: true,
27
+ size: "md",
28
+ },
29
+ } satisfies Meta<typeof Modal>
30
+
31
+ export default meta
32
+
33
+ type Story = StoryObj<typeof meta>
34
+
35
+ /**
36
+ * Every board here is triggers-plus-one-open-dialog rather than a
37
+ * grid, and that is the component rather than the stories being
38
+ * awkward: `showModal()` puts a dialog in the **top layer**, so two
39
+ * of them side by side is not a thing the platform can do. What the
40
+ * boards show instead is the real arrangement — a page, a scrim,
41
+ * and one dialog over both.
42
+ *
43
+ * `footer` is a function of `hide` because a dialog's own buttons
44
+ * are the normal way out of it, and they need the same `hide` the
45
+ * close button gets. One `useVisibility` per demo, no second source
46
+ * of truth.
47
+ */
48
+ const ModalDemo = ({
49
+ buttonLabel,
50
+ children,
51
+ footer,
52
+ heading,
53
+ isDismissable = true,
54
+ size,
55
+ }: {
56
+ buttonLabel: string
57
+ children?: ReactNode
58
+ footer?: (hide: () => void) => ReactNode
59
+ heading: string
60
+ isDismissable?: boolean
61
+ size?: ModalSize
62
+ }): ReactNode => {
63
+ const { hide, isVisible, show } = useVisibility()
64
+
65
+ return (
66
+ <>
67
+ <Button appearance="soft" onClick={show} size="sm">
68
+ {buttonLabel}
69
+ </Button>
70
+
71
+ <Modal
72
+ footer={footer?.(hide)}
73
+ heading={heading}
74
+ isDismissable={isDismissable}
75
+ isVisible={isVisible}
76
+ onClose={hide}
77
+ size={size}
78
+ >
79
+ {children ?? (
80
+ <p className="text-content-secondary text-sm">
81
+ MakeMKV reported a read error on title 4. The
82
+ rip can continue with the remaining eight
83
+ titles, or stop here and keep what has
84
+ completed.
85
+ </p>
86
+ )}
87
+ </Modal>
88
+ </>
89
+ )
90
+ }
91
+
92
+ export const Default: Story = {
93
+ args: {
94
+ heading: "Bay 3",
95
+ isVisible: false,
96
+ onClose: () => {},
97
+ },
98
+ render: () => (
99
+ <ModalDemo
100
+ buttonLabel="Show the read error"
101
+ heading="Read error on title 4"
102
+ />
103
+ ),
104
+ }
105
+
106
+ const SIZES: ModalSize[] = ["sm", "md", "lg", "xl", "full"]
107
+
108
+ export const AllVariants: Story = {
109
+ args: {
110
+ heading: "Bay 3",
111
+ isVisible: false,
112
+ onClose: () => {},
113
+ },
114
+ render: () => (
115
+ <StoryRow>
116
+ {SIZES.map((size) => (
117
+ <ModalDemo
118
+ buttonLabel={`Open ${size}`}
119
+ heading={`Read error — ${size}`}
120
+ key={size}
121
+ size={size}
122
+ />
123
+ ))}
124
+ </StoryRow>
125
+ ),
126
+ }
127
+
128
+ export const AllStates: Story = {
129
+ args: {
130
+ heading: "Bay 3",
131
+ isVisible: false,
132
+ onClose: () => {},
133
+ },
134
+ render: () => (
135
+ <StoryRow>
136
+ <ModalDemo
137
+ buttonLabel="With a footer"
138
+ footer={(hide) => (
139
+ <>
140
+ <Button
141
+ appearance="soft"
142
+ intent="neutral"
143
+ onClick={hide}
144
+ size="sm"
145
+ >
146
+ Keep going
147
+ </Button>
148
+
149
+ <Button intent="danger" size="sm">
150
+ Stop the rip
151
+ </Button>
152
+ </>
153
+ )}
154
+ heading="Stop the rip?"
155
+ />
156
+
157
+ <ModalDemo
158
+ buttonLabel="Not dismissable"
159
+ footer={(hide) => (
160
+ <>
161
+ <Button
162
+ appearance="soft"
163
+ intent="neutral"
164
+ onClick={hide}
165
+ size="sm"
166
+ >
167
+ Keep the titles
168
+ </Button>
169
+
170
+ <Button intent="danger" size="sm">
171
+ Erase and re-rip
172
+ </Button>
173
+ </>
174
+ )}
175
+ heading="This erases eight completed titles"
176
+ isDismissable={false}
177
+ />
178
+
179
+ <ModalDemo
180
+ buttonLabel="Long body"
181
+ heading="MakeMKV log"
182
+ >
183
+ <div className="flex flex-col gap-2">
184
+ {Array.from(
185
+ { length: 24 },
186
+ (_, index) =>
187
+ `14:0${index % 10}:22 title ${
188
+ index + 1
189
+ } AACS handshake complete, reading…`,
190
+ ).map((line) => (
191
+ <p
192
+ className="font-mono text-content-secondary text-xs"
193
+ key={line}
194
+ >
195
+ {line}
196
+ </p>
197
+ ))}
198
+ </div>
199
+ </ModalDemo>
200
+ </StoryRow>
201
+ ),
202
+ }
203
+
204
+ /**
205
+ * A modal is the one surface in this library that is **not** a
206
+ * container-query citizen, so it gets no `ContainerBoard`. Its size
207
+ * is measured against the viewport by definition — it is the thing
208
+ * covering the viewport — and boarding it at three container widths
209
+ * would be measuring something that has no bearing on it.
210
+ *
211
+ * What does vary is the clamp: every size caps at `85dvh`, and
212
+ * `full` takes the lot. `dvh` rather than `vh` because on a phone
213
+ * the two differ by the address bar, and a dialog whose footer is
214
+ * underneath it is a dialog with no buttons.
215
+ */
216
+ export const Responsive: Story = {
217
+ args: {
218
+ heading: "Bay 3",
219
+ isVisible: false,
220
+ onClose: () => {},
221
+ },
222
+ render: () => (
223
+ <ModalDemo
224
+ buttonLabel="Open full-bleed"
225
+ heading="MakeMKV log — full"
226
+ size="full"
227
+ >
228
+ <p className="text-content-secondary text-sm">
229
+ `full` is the kiosk and phone case: the dialog is
230
+ the screen, so there is nothing behind it worth
231
+ showing.
232
+ </p>
233
+ </ModalDemo>
234
+ ),
235
+ }
236
+
237
+ /**
238
+ * The keyboard contract, driven. `showModal()` supplies the focus
239
+ * trap and the focus restore; what this asserts is that the state
240
+ * layer never disagrees with the element — Escape routes through
241
+ * `onCancel` → `hide()` → the effect → `close()`, rather than the
242
+ * browser closing the dialog behind the app's back.
243
+ */
244
+ export const Interactive: Story = {
245
+ args: {
246
+ heading: "Bay 3",
247
+ isVisible: false,
248
+ onClose: () => {},
249
+ },
250
+ render: () => (
251
+ <ModalDemo
252
+ buttonLabel="Stop the rip"
253
+ footer={(hide) => (
254
+ <>
255
+ <Button
256
+ appearance="soft"
257
+ intent="neutral"
258
+ onClick={hide}
259
+ size="sm"
260
+ >
261
+ Keep going
262
+ </Button>
263
+
264
+ <Button intent="danger" size="sm">
265
+ Stop it now
266
+ </Button>
267
+ </>
268
+ )}
269
+ heading="Stop the rip?"
270
+ />
271
+ ),
272
+ }
@@ -0,0 +1,248 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, waitFor } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import * as stories from "./Modal.stories.tsx"
9
+
10
+ const {
11
+ AllStates,
12
+ AllVariants,
13
+ Default,
14
+ Interactive,
15
+ Responsive,
16
+ } = composeStories(stories)
17
+
18
+ /**
19
+ * Escape, as far as a test can press it — and the limit is worth
20
+ * stating rather than working around quietly.
21
+ *
22
+ * `userEvent` is `@testing-library/user-event`, which dispatches
23
+ * **untrusted** events. The browser runs a default action only for
24
+ * trusted input, so a synthetic Escape keydown does not make a
25
+ * native `<dialog>` fire `cancel` — it does nothing at all. A test
26
+ * asserting "Escape did not close the non-dismissable dialog"
27
+ * against a synthetic keypress therefore passes for the wrong
28
+ * reason, forever, which is worse than not testing it.
29
+ *
30
+ * So the close request is dispatched where the browser would raise
31
+ * it. That splits the contract honestly: the keystroke → `cancel`
32
+ * half is the platform's and is not ours to test, and everything
33
+ * downstream of `cancel` — `preventDefault`, `hide()`, the effect,
34
+ * `close()`, focus restore — is ours and is asserted here.
35
+ */
36
+ const requestClose = (dialog: HTMLElement) => {
37
+ dialog.dispatchEvent(
38
+ new Event("cancel", { cancelable: true }),
39
+ )
40
+ }
41
+
42
+ test("the dialog is reachable from a scoped query", async () => {
43
+ const { canvas, canvasElement } =
44
+ await mountStory(Default)
45
+
46
+ await userEvent.click(
47
+ expectAgentDrivable(canvas, {
48
+ name: "Show the read error",
49
+ role: "button",
50
+ }),
51
+ )
52
+
53
+ // Scoped to `canvas`, and that is the point of not portalling:
54
+ // the dialog paints in the top layer while staying inside the
55
+ // story root, so an agent's scoped query still reaches it.
56
+ const dialog = expectAgentDrivable(canvas, {
57
+ name: "Read error on title 4",
58
+ role: "dialog",
59
+ })
60
+
61
+ await expect(dialog).toHaveAttribute("open")
62
+
63
+ // The addon's own axe pass fired before any of this was clicked,
64
+ // so the *open* dialog audits itself.
65
+ await expectNoAxeViolations(canvasElement)
66
+ })
67
+
68
+ test("size is a real width, not a class that happens to be there", async () => {
69
+ const { canvas } = await mountStory(AllVariants)
70
+
71
+ await userEvent.click(
72
+ expectAgentDrivable(canvas, {
73
+ name: "Open lg",
74
+ role: "button",
75
+ }),
76
+ )
77
+
78
+ const dialog = expectAgentDrivable(canvas, {
79
+ name: "Read error — lg",
80
+ role: "dialog",
81
+ })
82
+
83
+ await expect(
84
+ dialog.getBoundingClientRect().width,
85
+ ).toBeGreaterThan(400)
86
+ })
87
+
88
+ test("a non-dismissable dialog cancels the close rather than ignoring it", async () => {
89
+ const { canvas } = await mountStory(AllStates)
90
+
91
+ await userEvent.click(
92
+ expectAgentDrivable(canvas, {
93
+ name: "Not dismissable",
94
+ role: "button",
95
+ }),
96
+ )
97
+
98
+ const dialog = expectAgentDrivable(canvas, {
99
+ name: "This erases eight completed titles",
100
+ role: "dialog",
101
+ })
102
+
103
+ // There is no Close button, and the close request is *cancelled*
104
+ // rather than ignored — letting the browser close it would leave
105
+ // `isVisible` true and the element shut, which is the desync the
106
+ // component exists to prevent.
107
+ await expect(
108
+ canvas.queryByRole("button", { name: "Close" }),
109
+ ).toBeNull()
110
+
111
+ requestClose(dialog)
112
+
113
+ // `find*` is async, which is what gives React's update from that
114
+ // dispatch a chance to flush. If the request *had* got through,
115
+ // this query would fail rather than the assertion below passing by
116
+ // being too quick.
117
+ const keepButton = await canvas.findByRole("button", {
118
+ name: "Keep the titles",
119
+ })
120
+
121
+ await expect(dialog).toHaveAttribute("open")
122
+
123
+ // The differential half. Without it, "still open" would also be
124
+ // satisfied by a dialog nothing can close at all — a green
125
+ // assertion about a component that does not work.
126
+ await userEvent.click(keepButton)
127
+
128
+ await waitFor(() => {
129
+ expect(canvas.queryByRole("dialog")).toBeNull()
130
+ })
131
+ })
132
+
133
+ test("a long body scrolls inside the clamp", async () => {
134
+ const { canvas, canvasElement } =
135
+ await mountStory(AllStates)
136
+
137
+ await userEvent.click(
138
+ canvas.getByRole("button", { name: "Long body" }),
139
+ )
140
+
141
+ const log = expectAgentDrivable(canvas, {
142
+ name: "MakeMKV log",
143
+ role: "dialog",
144
+ })
145
+
146
+ // The body scrolls, the header and footer do not, and the dialog
147
+ // stops at `85dvh` rather than running off the screen.
148
+ await expect(
149
+ log.getBoundingClientRect().height,
150
+ ).toBeLessThan(globalThis.innerHeight)
151
+
152
+ await expect(
153
+ canvas.getByText(/title 24/),
154
+ ).toBeInTheDocument()
155
+
156
+ // The case that would trip `scrollable-region-focusable` if the
157
+ // body scrolled with nothing inside it to focus.
158
+ await expectNoAxeViolations(canvasElement)
159
+ })
160
+
161
+ test("the scrim is the token, not Chromium's default backdrop", async () => {
162
+ const { canvas } = await mountStory(Responsive)
163
+
164
+ await userEvent.click(
165
+ expectAgentDrivable(canvas, {
166
+ name: "Open full-bleed",
167
+ role: "button",
168
+ }),
169
+ )
170
+
171
+ const dialog = expectAgentDrivable(canvas, {
172
+ name: "MakeMKV log — full",
173
+ role: "dialog",
174
+ })
175
+
176
+ await expect(
177
+ dialog.getBoundingClientRect().height,
178
+ ).toBeGreaterThan(globalThis.innerHeight * 0.95)
179
+
180
+ // `::backdrop` inheriting custom properties from its originating
181
+ // element is the half that could silently not work.
182
+ //
183
+ // Compared against the token's *resolved* value rather than
184
+ // against "not transparent", which M4 learned the hard way:
185
+ // Chromium's own `::backdrop` is `rgba(0, 0, 0, 0.1)`, so the
186
+ // loose version of this assertion stayed green against a token
187
+ // build where `bg-scrim` generated no CSS at all.
188
+ //
189
+ // The probe is how the two become comparable — the same `var()`
190
+ // put through `background-color` comes back in the identical
191
+ // serialisation the backdrop uses.
192
+ const probe = document.createElement("div")
193
+
194
+ probe.style.backgroundColor = "var(--color-scrim)"
195
+
196
+ document.body.append(probe)
197
+
198
+ const expectedScrim =
199
+ globalThis.getComputedStyle(probe).backgroundColor
200
+
201
+ probe.remove()
202
+
203
+ await expect(expectedScrim).not.toBe("rgba(0, 0, 0, 0)")
204
+
205
+ await expect(
206
+ globalThis.getComputedStyle(dialog, "::backdrop")
207
+ .backgroundColor,
208
+ ).toBe(expectedScrim)
209
+ })
210
+
211
+ /**
212
+ * `showModal()` supplies the focus trap and the focus restore; what
213
+ * this asserts is that the state layer never disagrees with the
214
+ * element — Escape routes through `onCancel` → `hide()` → the
215
+ * effect → `close()`, rather than the browser closing the dialog
216
+ * behind the app's back.
217
+ */
218
+ test("focus is trapped inside, and restored to the trigger on close", async () => {
219
+ const { canvas } = await mountStory(Interactive)
220
+
221
+ const trigger = expectAgentDrivable(canvas, {
222
+ name: "Stop the rip",
223
+ role: "button",
224
+ })
225
+
226
+ await userEvent.click(trigger)
227
+
228
+ const dialog = expectAgentDrivable(canvas, {
229
+ name: "Stop the rip?",
230
+ role: "dialog",
231
+ })
232
+
233
+ // The trap, from the platform: focus is inside the dialog, and the
234
+ // trigger behind it is `inert` and unreachable.
235
+ await expect(dialog).toContainElement(
236
+ document.activeElement as HTMLElement,
237
+ )
238
+
239
+ requestClose(dialog)
240
+
241
+ await waitFor(() => {
242
+ expect(canvas.queryByRole("dialog")).toBeNull()
243
+ })
244
+
245
+ // Focus restored to what opened it — also the platform's, and the
246
+ // thing hand-rolled modals in the fleet lose.
247
+ await expect(document.activeElement).toBe(trigger)
248
+ })