@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,191 @@
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 "./Popover.stories.tsx"
9
+
10
+ const {
11
+ AllStates,
12
+ AllVariants,
13
+ Default,
14
+ Interactive,
15
+ Responsive,
16
+ } = composeStories(stories)
17
+
18
+ test("the trigger and the panel agree about each other", async () => {
19
+ const { canvas, canvasElement } =
20
+ await mountStory(Default)
21
+
22
+ const trigger = expectAgentDrivable(canvas, {
23
+ name: "Filters",
24
+ role: "button",
25
+ })
26
+
27
+ await expect(trigger).toHaveAttribute(
28
+ "aria-expanded",
29
+ "false",
30
+ )
31
+
32
+ await userEvent.click(trigger)
33
+
34
+ // Found through `canvas`, not `screen` — the panel is in the top
35
+ // layer and still in this story's DOM, which a portal would have
36
+ // cost.
37
+ const panel = expectAgentDrivable(canvas, {
38
+ name: "Filters",
39
+ role: "dialog",
40
+ })
41
+
42
+ // `aria-controls` really points at the panel. An id that has
43
+ // drifted renders identically and announces nothing, which is why
44
+ // `useRole` owns both ends of it.
45
+ await expect(trigger).toHaveAttribute(
46
+ "aria-controls",
47
+ panel.id,
48
+ )
49
+
50
+ // `manual`, not `auto`: the UA's light-dismiss closes the element
51
+ // by itself and leaves `isVisible` true.
52
+ await expect(panel).toHaveAttribute("popover", "manual")
53
+
54
+ await expectNoAxeViolations(canvasElement)
55
+ })
56
+
57
+ test("the panel is positioned rather than centred by the UA", async () => {
58
+ const { canvas } = await mountStory(AllVariants)
59
+
60
+ await userEvent.click(
61
+ expectAgentDrivable(canvas, {
62
+ name: "Open top",
63
+ role: "button",
64
+ }),
65
+ )
66
+
67
+ const panel = expectAgentDrivable(canvas, {
68
+ name: "Filters — top",
69
+ role: "dialog",
70
+ })
71
+
72
+ // The thing that silently fails if the UA's `inset: 0; margin:
73
+ // auto` is left in place: the panel would sit dead centre of the
74
+ // viewport looking deliberate.
75
+ const { left, top } = panel.getBoundingClientRect()
76
+
77
+ await expect(left).toBeGreaterThan(0)
78
+
79
+ await expect(top).toBeGreaterThan(0)
80
+ })
81
+
82
+ test("a panel with nothing tabbable inside is still reachable", async () => {
83
+ const { canvas } = await mountStory(AllStates)
84
+
85
+ await userEvent.click(
86
+ expectAgentDrivable(canvas, {
87
+ name: "Text only",
88
+ role: "button",
89
+ }),
90
+ )
91
+
92
+ const panel = expectAgentDrivable(canvas, {
93
+ name: "Read error detail",
94
+ role: "dialog",
95
+ })
96
+
97
+ // Otherwise a keyboard user is told a dialog opened and then
98
+ // cannot get to it. `FloatingFocusManager` puts the panel itself
99
+ // in the tab order — `tabindex="0"`, not `-1`, so Tab reaches it
100
+ // and not only the programmatic focus does.
101
+ await expect(panel).toHaveAttribute("tabindex", "0")
102
+
103
+ await waitFor(() => {
104
+ expect(panel.contains(document.activeElement)).toBe(
105
+ true,
106
+ )
107
+ })
108
+ })
109
+
110
+ test("flip and shift keep the panel inside the viewport", async () => {
111
+ const { canvas } = await mountStory(Responsive)
112
+
113
+ await userEvent.click(
114
+ expectAgentDrivable(canvas, {
115
+ name: "Top edge, asks for top",
116
+ role: "button",
117
+ }),
118
+ )
119
+
120
+ const panel = expectAgentDrivable(canvas, {
121
+ name: "Filters — top start",
122
+ role: "dialog",
123
+ })
124
+
125
+ // Inside the viewport is the only thing `flip` and `shift`
126
+ // actually promise — asserting a specific side would be asserting
127
+ // the collision *did not* happen.
128
+ const rect = panel.getBoundingClientRect()
129
+
130
+ await expect(rect.top).toBeGreaterThanOrEqual(0)
131
+
132
+ await expect(rect.bottom).toBeLessThanOrEqual(
133
+ globalThis.innerHeight,
134
+ )
135
+
136
+ await expect(rect.left).toBeGreaterThanOrEqual(0)
137
+
138
+ await expect(rect.right).toBeLessThanOrEqual(
139
+ globalThis.innerWidth,
140
+ )
141
+ })
142
+
143
+ /**
144
+ * The dismiss layer, driven. Outside press and Escape both route
145
+ * through `useDismiss` → `onDismiss` → `hide()`, so the panel is
146
+ * never closed by anything except the state saying so.
147
+ *
148
+ * Escape *is* pressable here, unlike `Modal`'s: `useDismiss` listens
149
+ * for a keydown rather than relying on a UA default action, and a
150
+ * synthetic keydown is a real keydown as far as a listener is
151
+ * concerned.
152
+ */
153
+ test("Escape and an outside press both dismiss through the state", async () => {
154
+ const { canvas } = await mountStory(Interactive)
155
+
156
+ const trigger = expectAgentDrivable(canvas, {
157
+ name: "Filters",
158
+ role: "button",
159
+ })
160
+
161
+ await userEvent.click(trigger)
162
+
163
+ expectAgentDrivable(canvas, {
164
+ name: "Bay 3 filters",
165
+ role: "dialog",
166
+ })
167
+
168
+ await userEvent.keyboard("{Escape}")
169
+
170
+ await waitFor(() => {
171
+ expect(canvas.queryByRole("dialog")).toBeNull()
172
+ })
173
+
174
+ await expect(trigger).toHaveAttribute(
175
+ "aria-expanded",
176
+ "false",
177
+ )
178
+
179
+ // Reopened, then dismissed by pressing the page behind it.
180
+ await userEvent.click(trigger)
181
+
182
+ await waitFor(() => {
183
+ expect(canvas.getByRole("dialog")).toBeInTheDocument()
184
+ })
185
+
186
+ await userEvent.click(document.body)
187
+
188
+ await waitFor(() => {
189
+ expect(canvas.queryByRole("dialog")).toBeNull()
190
+ })
191
+ })
@@ -0,0 +1,164 @@
1
+ import { useClonedChild } from "@charcuterie/logic"
2
+ import type { Placement } from "@floating-ui/react"
3
+ import {
4
+ autoUpdate,
5
+ FloatingFocusManager,
6
+ flip,
7
+ offset,
8
+ shift,
9
+ useDismiss,
10
+ useFloating,
11
+ useInteractions,
12
+ useRole,
13
+ } from "@floating-ui/react"
14
+ import type { ReactElement, ReactNode } from "react"
15
+
16
+ import { toClassName } from "../toClassName.ts"
17
+
18
+ export type PopoverProps = {
19
+ children: ReactNode
20
+ className?: string
21
+ /**
22
+ * Required, and it becomes the panel's `aria-label`. A
23
+ * `role="dialog"` takes **no accessible name from its content**,
24
+ * so an unnamed one is announced as "dialog" and is unfindable
25
+ * by `getByRole("dialog", { name })` — the same trap `Spinner`
26
+ * walked into in M3 with `role="status"`.
27
+ */
28
+ heading: string
29
+ isVisible: boolean
30
+ /**
31
+ * Outside press and Escape both land here, through floating-ui's
32
+ * `useDismiss`. Wire it to a `useVisibility()`'s `hide`.
33
+ */
34
+ onDismiss: () => void
35
+ placement?: Placement
36
+ /**
37
+ * The control the panel hangs off. **Cloned, not wrapped** — an
38
+ * extra `<div>` around a button is how a toolbar's layout
39
+ * quietly changes.
40
+ */
41
+ trigger: ReactElement
42
+ }
43
+
44
+ /**
45
+ * Replaces mux-magic's `@radix-ui/react-popover` re-export, and it
46
+ * is a rewrite rather than a swap for one reason: Radix owns
47
+ * `open`, so wrapping it leaves `useVisibility` and Radix both
48
+ * believing they hold it. `@floating-ui/react` is the layer Radix,
49
+ * Base UI, and Ark UI are all *built on*, and it is controlled by
50
+ * construction — you pass state in and it never stores any.
51
+ *
52
+ * What floating-ui is used for is what only it can do: collision
53
+ * handling (`flip`, `shift`), a real dismiss layer, and focus
54
+ * management. Positioning strategy, the top layer, and the ARIA
55
+ * wiring come from the platform and from `useRole`.
56
+ *
57
+ * ### The top layer, from the platform — not a portal
58
+ *
59
+ * `popover="manual"` puts the panel in the top layer, which is
60
+ * what a portal is normally reached for: no `z-index` contest, and
61
+ * no `overflow: hidden` ancestor clipping it. Unlike a portal it
62
+ * **stays where it is in the DOM**, so `canvas.getByRole("dialog")`
63
+ * still finds it and a story keeps its scoping. Same argument as
64
+ * `Modal`, one layer down.
65
+ *
66
+ * `manual`, not `auto`, and that is the state-ownership decision
67
+ * again: `auto` brings the UA's own light-dismiss, which closes the
68
+ * element by itself and leaves `isVisible` true. `useDismiss` does
69
+ * the same job through our callback, so Charcuterie stays the only
70
+ * thing that decides.
71
+ *
72
+ * ### Two UA defaults that have to be undone
73
+ *
74
+ * `[popover]` ships `position: fixed; inset: 0; margin: auto`,
75
+ * which centres the panel in the viewport and fights every
76
+ * coordinate floating-ui computes — hence `inset-auto m-0`, and
77
+ * `strategy: "fixed"` so the numbers are viewport-relative like the
78
+ * top layer itself. Getting either wrong reads as a positioning
79
+ * bug rather than as a UA default.
80
+ */
81
+ export const Popover = ({
82
+ children,
83
+ className,
84
+ heading,
85
+ isVisible,
86
+ onDismiss,
87
+ placement = "bottom-start",
88
+ trigger,
89
+ }: PopoverProps): ReactNode => {
90
+ const { context, floatingStyles, refs } = useFloating({
91
+ // Read-only. Charcuterie remains the sole owner; floating-ui
92
+ // is told what is true and never decides it.
93
+ open: isVisible,
94
+ onOpenChange: (isNextVisible) => {
95
+ if (!isNextVisible) {
96
+ onDismiss()
97
+ }
98
+ },
99
+ placement,
100
+ middleware: [offset(8), flip(), shift({ padding: 8 })],
101
+ strategy: "fixed",
102
+ whileElementsMounted: autoUpdate,
103
+ })
104
+
105
+ // `useRole` writes `aria-expanded`, `aria-haspopup`, and
106
+ // `aria-controls` on the trigger and `id` + `role="dialog"` on
107
+ // the panel, from one source. Hand-writing them alongside is how
108
+ // an `aria-controls` ends up pointing at an id that moved.
109
+ const { getFloatingProps, getReferenceProps } =
110
+ useInteractions([
111
+ useDismiss(context),
112
+ useRole(context, { role: "dialog" }),
113
+ ])
114
+
115
+ const clonedTrigger = useClonedChild(trigger, {
116
+ ...getReferenceProps(),
117
+ ref: refs.setReference,
118
+ })
119
+
120
+ return (
121
+ <>
122
+ {clonedTrigger}
123
+
124
+ {isVisible ? (
125
+ <FloatingFocusManager
126
+ context={context}
127
+ modal={false}
128
+ >
129
+ <div
130
+ {...getFloatingProps()}
131
+ aria-label={heading}
132
+ className={toClassName(
133
+ "inset-auto m-0 max-w-xs rounded-md border border-border-default bg-surface-overlay p-3 text-content-primary text-sm shadow-medium",
134
+ className,
135
+ )}
136
+ popover="manual"
137
+ // Duplicated from `getFloatingProps()` on purpose, and
138
+ // the two cannot differ — `useRole(context, { role:
139
+ // "dialog" })` above is where it comes from. Stated
140
+ // here because a linter cannot see a role through a
141
+ // spread, and neither can the next reader.
142
+ role="dialog"
143
+ ref={(node) => {
144
+ refs.setFloating(node)
145
+
146
+ // In a ref callback rather than an effect, because a
147
+ // `[popover]` is `display: none` until it is shown —
148
+ // and `FloatingFocusManager` is a *child*, so its
149
+ // effect runs first and would be focusing into a
150
+ // hidden element. Refs are set during commit, before
151
+ // any of that.
152
+ if (node && !node.matches(":popover-open")) {
153
+ node.showPopover()
154
+ }
155
+ }}
156
+ style={floatingStyles}
157
+ >
158
+ {children}
159
+ </div>
160
+ </FloatingFocusManager>
161
+ ) : null}
162
+ </>
163
+ )
164
+ }
@@ -0,0 +1,48 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as ProgressBarStories from "./ProgressBar.stories";
4
+
5
+ <Meta of={ProgressBarStories} />
6
+
7
+ # ProgressBar
8
+
9
+ Five repos hand-roll one and **only rip-deck's has `role="progressbar"`**. The other four
10
+ are a `<div>` with a percentage width: invisible to assistive technology and to
11
+ `getByRole("progressbar")`.
12
+
13
+ This is the union — rip-deck's ARIA and its indeterminate sweep, plus threshold colours
14
+ nobody has yet.
15
+
16
+ <Canvas of={ProgressBarStories.Default} />
17
+
18
+ ## Props
19
+
20
+ <Controls />
21
+
22
+ ## Three corrections to rip-deck's version
23
+
24
+ 1. **The role goes on the track, not the fill.** rip-deck puts `role="progressbar"` on the
25
+ filled element, so a 4% rip is a progressbar 4px wide as far as AT and a Playwright
26
+ bounding box are concerned.
27
+ 2. **The name comes from a real label**, wired with `aria-labelledby` — visible or
28
+ `VisuallyHidden` — rather than a hardcoded `aria-label` that cannot be translated.
29
+ 3. **`aria-valuenow` is omitted while indeterminate**, which is what says "unknown" rather
30
+ than "zero".
31
+
32
+ ## Indeterminate means "no measurable progress yet"
33
+
34
+ Not zero, and not unknown-therefore-full. rip-deck's case is the AACS/BD+ preamble: ~25 s
35
+ of a real Blu-ray emitting nothing, where a full bar reads as a finished rip and an empty
36
+ one as a wedged drive. The sweep animates `inset-inline-start`, so it runs the other way in
37
+ RTL for free, and its reduced-motion fallback hatches the whole track rather than parking a
38
+ bar at one end.
39
+
40
+ <Canvas of={ProgressBarStories.Indeterminate} />
41
+
42
+ ## Values arrive from a log parser
43
+
44
+ `-1`, `101`, `NaN`, and `0 of 0` are all normal in this fleet, so clamping is a tested pure
45
+ function (`progressValue.ts`) and `aria-valuenow` is clamped in the value's own units —
46
+ never disagreeing with what is drawn.
47
+
48
+ <Canvas of={ProgressBarStories.AllStates} />
@@ -0,0 +1,232 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import { useState } from "react"
3
+
4
+ import { intentArgType } from "../argTypes.storyHelpers.ts"
5
+ import { Button } from "../Button/Button.tsx"
6
+ import {
7
+ ContainerBoard,
8
+ StoryCell,
9
+ StoryGrid,
10
+ StoryRow,
11
+ StorySection,
12
+ } from "../board.storyHelpers.tsx"
13
+ import { ProgressBar } from "./ProgressBar.tsx"
14
+ import type { ProgressThreshold } from "./progressValue.ts"
15
+
16
+ /** Green when finished, amber past 90% — the rip-deck case. */
17
+ const DISC_THRESHOLDS: ProgressThreshold[] = [
18
+ { from: 90, intent: "warning" },
19
+ { from: 100, intent: "success" },
20
+ ]
21
+
22
+ const meta = {
23
+ title: "Components/ProgressBar",
24
+ component: ProgressBar,
25
+ parameters: { layout: "padded" },
26
+ argTypes: { intent: intentArgType },
27
+ args: {
28
+ intent: "accent",
29
+ isIndeterminate: false,
30
+ isLabelVisible: false,
31
+ isValueShown: false,
32
+ max: 100,
33
+ size: "md",
34
+ },
35
+ } satisfies Meta<typeof ProgressBar>
36
+
37
+ export default meta
38
+
39
+ type Story = StoryObj<typeof meta>
40
+
41
+ /**
42
+ * The name comes from a real `aria-labelledby` even though the label
43
+ * is not printed, and the value sits on the *track* — so an agent's
44
+ * bounding box is the widget, not the filled 38%.
45
+ */
46
+ export const Default: Story = {
47
+ args: { label: "Ripping title 4 of 9", value: 38 },
48
+ }
49
+
50
+ export const AllVariants: Story = {
51
+ args: { label: "Ripping", value: 62 },
52
+ render: (progressProps) => (
53
+ <StorySection title="Three track sizes, six intents, and the threshold colours nobody in the fleet has yet.">
54
+ <StoryGrid columns={3}>
55
+ <StoryCell align="stretch" label="sm">
56
+ <ProgressBar {...progressProps} size="sm" />
57
+ </StoryCell>
58
+
59
+ <StoryCell align="stretch" label="md">
60
+ <ProgressBar {...progressProps} size="md" />
61
+ </StoryCell>
62
+
63
+ <StoryCell align="stretch" label="lg">
64
+ <ProgressBar {...progressProps} size="lg" />
65
+ </StoryCell>
66
+
67
+ <StoryCell align="stretch" label="intent=success">
68
+ <ProgressBar
69
+ {...progressProps}
70
+ intent="success"
71
+ />
72
+ </StoryCell>
73
+
74
+ <StoryCell
75
+ align="stretch"
76
+ label="thresholds · 94% → warning"
77
+ >
78
+ <ProgressBar
79
+ {...progressProps}
80
+ thresholds={DISC_THRESHOLDS}
81
+ value={94}
82
+ />
83
+ </StoryCell>
84
+
85
+ <StoryCell
86
+ align="stretch"
87
+ label="thresholds · 100% → success"
88
+ >
89
+ <ProgressBar
90
+ {...progressProps}
91
+ thresholds={DISC_THRESHOLDS}
92
+ value={100}
93
+ />
94
+ </StoryCell>
95
+ </StoryGrid>
96
+ </StorySection>
97
+ ),
98
+ }
99
+
100
+ export const AllStates: Story = {
101
+ args: { label: "Ripping title 4 of 9" },
102
+ render: (progressProps) => (
103
+ <StoryGrid columns={2}>
104
+ <StoryCell
105
+ align="stretch"
106
+ label="0% — empty, not unknown"
107
+ >
108
+ <ProgressBar {...progressProps} value={0} />
109
+ </StoryCell>
110
+
111
+ <StoryCell align="stretch" label="mid">
112
+ <ProgressBar {...progressProps} value={47} />
113
+ </StoryCell>
114
+
115
+ <StoryCell align="stretch" label="complete">
116
+ <ProgressBar
117
+ {...progressProps}
118
+ thresholds={DISC_THRESHOLDS}
119
+ value={100}
120
+ />
121
+ </StoryCell>
122
+
123
+ <StoryCell
124
+ align="stretch"
125
+ label="indeterminate — no valuenow at all"
126
+ >
127
+ <ProgressBar {...progressProps} isIndeterminate />
128
+ </StoryCell>
129
+
130
+ <StoryCell
131
+ align="stretch"
132
+ label="label + value visible"
133
+ >
134
+ <ProgressBar
135
+ {...progressProps}
136
+ isLabelVisible
137
+ isValueShown
138
+ value={47}
139
+ />
140
+ </StoryCell>
141
+
142
+ <StoryCell
143
+ align="stretch"
144
+ label="out of range (value=-5, max=0)"
145
+ >
146
+ <ProgressBar
147
+ {...progressProps}
148
+ max={0}
149
+ value={-5}
150
+ />
151
+ </StoryCell>
152
+ </StoryGrid>
153
+ ),
154
+ }
155
+
156
+ /**
157
+ * "Unknown", not "zero" — so no `aria-valuenow` at all.
158
+ *
159
+ * rip-deck's AACS/BD+ preamble is ~25 s of a real Blu-ray emitting
160
+ * nothing: a full bar reads as a finished rip, an empty one as a
161
+ * wedged drive.
162
+ */
163
+ export const Indeterminate: Story = {
164
+ args: {
165
+ isIndeterminate: true,
166
+ isLabelVisible: true,
167
+ label: "Working — no measurable progress yet",
168
+ },
169
+ }
170
+
171
+ export const Responsive: Story = {
172
+ args: {
173
+ isLabelVisible: true,
174
+ isValueShown: true,
175
+ label: "Ripping title 4 of 9 — Blade Runner (1982)",
176
+ value: 47,
177
+ },
178
+ render: (progressProps) => (
179
+ <ContainerBoard>
180
+ <ProgressBar {...progressProps} />
181
+ </ContainerBoard>
182
+ ),
183
+ }
184
+
185
+ const SteppedProgressBar = () => {
186
+ const [percent, setPercent] = useState(0)
187
+
188
+ return (
189
+ <div className="flex flex-col gap-3">
190
+ <ProgressBar
191
+ isLabelVisible
192
+ isValueShown
193
+ label="Ripping title 4 of 9"
194
+ thresholds={DISC_THRESHOLDS}
195
+ value={percent}
196
+ />
197
+
198
+ <StoryRow>
199
+ <Button
200
+ onClick={() => {
201
+ setPercent((current) =>
202
+ Math.min(100, current + 25),
203
+ )
204
+ }}
205
+ size="sm"
206
+ >
207
+ Advance 25%
208
+ </Button>
209
+
210
+ <Button
211
+ appearance="ghost"
212
+ onClick={() => {
213
+ setPercent(0)
214
+ }}
215
+ size="sm"
216
+ >
217
+ Reset
218
+ </Button>
219
+ </StoryRow>
220
+ </div>
221
+ )
222
+ }
223
+
224
+ /**
225
+ * A progressbar is not focusable and has no keyboard path — the
226
+ * contract it owes an agent is that `aria-valuenow` tracks what is
227
+ * drawn. Advance it to 100% and the threshold flips the fill green.
228
+ */
229
+ export const Interactive: Story = {
230
+ args: { label: "Ripping title 4 of 9" },
231
+ render: () => <SteppedProgressBar />,
232
+ }
@@ -0,0 +1,74 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { mountStory } from "../mountStory.testHelpers.ts"
6
+ import { expectAgentDrivable } from "../testing/index.ts"
7
+ import * as stories from "./ProgressBar.stories.tsx"
8
+
9
+ const { Default, Indeterminate, Interactive } =
10
+ composeStories(stories)
11
+
12
+ test("the name is real and the value sits on the track", async () => {
13
+ const { canvas } = await mountStory(Default)
14
+
15
+ const bar = expectAgentDrivable(canvas, {
16
+ name: "Ripping title 4 of 9",
17
+ role: "progressbar",
18
+ })
19
+
20
+ // The label is not printed, so the name has to come from a real
21
+ // `aria-labelledby`. The value is on the *track* so an agent's
22
+ // bounding box is the widget, not the filled 38%.
23
+ await expect(bar).toHaveAttribute("aria-valuenow", "38")
24
+ await expect(bar).toHaveAttribute("aria-valuemax", "100")
25
+ })
26
+
27
+ test("indeterminate means no value at all, not zero", async () => {
28
+ const { canvas } = await mountStory(Indeterminate)
29
+
30
+ const bar = expectAgentDrivable(canvas, {
31
+ name: "Working — no measurable progress yet",
32
+ role: "progressbar",
33
+ })
34
+
35
+ // rip-deck's AACS/BD+ preamble is ~25 s of a real Blu-ray emitting
36
+ // nothing: a full bar reads as a finished rip, an empty one as a
37
+ // wedged drive. Absent is the only honest answer.
38
+ await expect(bar).not.toHaveAttribute("aria-valuenow")
39
+ await expect(bar).toHaveAttribute("aria-busy", "true")
40
+ })
41
+
42
+ /**
43
+ * A progressbar is not focusable and has no keyboard path — the
44
+ * contract it owes an agent is that `aria-valuenow` tracks what is
45
+ * drawn.
46
+ */
47
+ test("aria-valuenow tracks the fill to completion", async () => {
48
+ const { canvas } = await mountStory(Interactive)
49
+
50
+ const bar = canvas.getByRole("progressbar")
51
+
52
+ await expect(bar).toHaveAttribute("aria-valuenow", "0")
53
+
54
+ const advance = canvas.getByRole("button", {
55
+ name: "Advance 25%",
56
+ })
57
+
58
+ await userEvent.click(advance)
59
+ await userEvent.click(advance)
60
+
61
+ await expect(bar).toHaveAttribute("aria-valuenow", "50")
62
+ await expect(canvas.getByText("50%")).toBeVisible()
63
+
64
+ await userEvent.click(advance)
65
+ await userEvent.click(advance)
66
+
67
+ await expect(bar).toHaveAttribute("aria-valuenow", "100")
68
+
69
+ // The 100% threshold turns the fill green, which is the
70
+ // generalisation of rip-deck's `FILL_CLASS` keyed by rip state.
71
+ await expect(
72
+ bar.querySelector(".bg-intent-success-solid"),
73
+ ).toBeInTheDocument()
74
+ })