@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,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
|
+
})
|