@charcuterie/ui 3.8.0 → 3.9.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.
@@ -0,0 +1,256 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import { useState } from "react"
3
+
4
+ import {
5
+ controlSizeArgType,
6
+ intentArgType,
7
+ } from "../argTypes.storyHelpers.ts"
8
+ import {
9
+ ContainerBoard,
10
+ StoryCell,
11
+ StoryGrid,
12
+ StorySection,
13
+ } from "../board.storyHelpers.tsx"
14
+ import { Slider } from "./Slider.tsx"
15
+
16
+ /** mm:ss, the format a scrubber actually wants. */
17
+ const toClock = (seconds: number): string => {
18
+ const whole = Math.max(0, Math.round(seconds))
19
+
20
+ const mins = Math.floor(whole / 60)
21
+
22
+ const secs = whole % 60
23
+
24
+ return `${mins}:${String(secs).padStart(2, "0")}`
25
+ }
26
+
27
+ /**
28
+ * A controlled Slider that owns its own value, which is what every real
29
+ * call site is. The stories that only paint use the plain component.
30
+ */
31
+ const LiveSlider = ({
32
+ initialValue = 40,
33
+ ...sliderProps
34
+ }: {
35
+ initialValue?: number
36
+ } & Omit<
37
+ Parameters<typeof Slider>[0],
38
+ "onChange" | "value"
39
+ >): ReturnType<typeof Slider> => {
40
+ const [value, setValue] = useState(initialValue)
41
+
42
+ return (
43
+ <Slider
44
+ {...sliderProps}
45
+ onChange={setValue}
46
+ value={value}
47
+ />
48
+ )
49
+ }
50
+
51
+ const meta = {
52
+ title: "Components/Controls/Slider",
53
+ component: Slider,
54
+ parameters: { layout: "padded" },
55
+ argTypes: {
56
+ intent: intentArgType,
57
+ size: controlSizeArgType,
58
+ },
59
+ // The component's own defaults, restated — Storybook does not seed
60
+ // `args` from docgen, so an unstated default shows in the props table
61
+ // with nothing selected in its control.
62
+ args: {
63
+ intent: "accent",
64
+ isDisabled: false,
65
+ isLabelVisible: false,
66
+ isReadOnly: false,
67
+ isValueShown: false,
68
+ max: 100,
69
+ min: 0,
70
+ size: "md",
71
+ step: 1,
72
+ },
73
+ } satisfies Meta<typeof Slider>
74
+
75
+ export default meta
76
+
77
+ type Story = StoryObj<typeof meta>
78
+
79
+ export const Default: Story = {
80
+ args: { label: "Volume", value: 40 },
81
+ }
82
+
83
+ export const AllVariants: Story = {
84
+ args: { label: "Volume" },
85
+ render: (controlProps) => (
86
+ <StorySection title="Three heights off the density axis, and the six intents on the fill.">
87
+ <StoryGrid columns={3}>
88
+ {(["sm", "md", "lg"] as const).map((size) => (
89
+ <StoryCell
90
+ align="stretch"
91
+ key={size}
92
+ label={size}
93
+ >
94
+ <Slider
95
+ {...controlProps}
96
+ label={`Volume at ${size}`}
97
+ size={size}
98
+ value={55}
99
+ />
100
+ </StoryCell>
101
+ ))}
102
+ </StoryGrid>
103
+
104
+ <StoryGrid columns={3}>
105
+ {(
106
+ [
107
+ "accent",
108
+ "success",
109
+ "warning",
110
+ "danger",
111
+ "info",
112
+ "neutral",
113
+ ] as const
114
+ ).map((intent) => (
115
+ <StoryCell
116
+ align="stretch"
117
+ key={intent}
118
+ label={intent}
119
+ >
120
+ <Slider
121
+ {...controlProps}
122
+ intent={intent}
123
+ label={`Volume, ${intent}`}
124
+ value={55}
125
+ />
126
+ </StoryCell>
127
+ ))}
128
+ </StoryGrid>
129
+ </StorySection>
130
+ ),
131
+ }
132
+
133
+ /**
134
+ * `isReadOnly` is not `isDisabled`. A read-only slider keeps full
135
+ * contrast and stays focusable, so its value is still readable and
136
+ * still reachable by a screen reader — a live stream's position, where
137
+ * the number matters and seeking is not offered.
138
+ */
139
+ export const AllStates: Story = {
140
+ args: { label: "Volume" },
141
+ render: (controlProps) => (
142
+ <StorySection title="What varies is the value, and whether it can be changed.">
143
+ <StoryGrid columns={2}>
144
+ <StoryCell align="stretch" label="at the minimum">
145
+ <Slider
146
+ {...controlProps}
147
+ label="Empty"
148
+ value={0}
149
+ />
150
+ </StoryCell>
151
+
152
+ <StoryCell align="stretch" label="at the maximum">
153
+ <Slider
154
+ {...controlProps}
155
+ label="Full"
156
+ value={100}
157
+ />
158
+ </StoryCell>
159
+
160
+ <StoryCell align="stretch" label="disabled">
161
+ <Slider
162
+ {...controlProps}
163
+ isDisabled
164
+ label="Disabled"
165
+ value={35}
166
+ />
167
+ </StoryCell>
168
+
169
+ <StoryCell align="stretch" label="read-only">
170
+ <Slider
171
+ {...controlProps}
172
+ isReadOnly
173
+ label="Read-only"
174
+ value={35}
175
+ />
176
+ </StoryCell>
177
+
178
+ <StoryCell
179
+ align="stretch"
180
+ label="label and value shown"
181
+ >
182
+ <Slider
183
+ {...controlProps}
184
+ isLabelVisible
185
+ isValueShown
186
+ label="Position"
187
+ max={2_700}
188
+ value={1_274}
189
+ valueFormat={toClock}
190
+ />
191
+ </StoryCell>
192
+
193
+ <StoryCell align="stretch" label="a coarse step">
194
+ <Slider
195
+ {...controlProps}
196
+ isValueShown
197
+ label="Rating"
198
+ max={5}
199
+ step={1}
200
+ value={3}
201
+ />
202
+ </StoryCell>
203
+ </StoryGrid>
204
+ </StorySection>
205
+ ),
206
+ }
207
+
208
+ /**
209
+ * The track is the widget, so it fills whatever box it is given and the
210
+ * thumb stays on the value at every width. Nothing here is a media
211
+ * query — a slider in a 320px rail and one in a 960px panel are the
212
+ * same component.
213
+ */
214
+ export const Responsive: Story = {
215
+ args: { label: "Volume" },
216
+ render: (controlProps) => (
217
+ <ContainerBoard>
218
+ <Slider
219
+ {...controlProps}
220
+ isLabelVisible
221
+ isValueShown
222
+ label="Position"
223
+ max={2_700}
224
+ value={1_274}
225
+ valueFormat={toClock}
226
+ />
227
+ </ContainerBoard>
228
+ ),
229
+ }
230
+
231
+ /**
232
+ * The complete keyboard path. Tab reaches the track, then:
233
+ *
234
+ * - **Arrow Left / Down** and **Arrow Right / Up** move one `step`
235
+ * - **Page Down / Page Up** move one `largeStep`, ten steps by default
236
+ * - **Home** and **End** go to `min` and `max`
237
+ *
238
+ * A pointer press anywhere on the row jumps the thumb there and starts
239
+ * a drag, and the drag keeps reporting after the pointer leaves the bar
240
+ * vertically — the track takes pointer capture, so a thumb dragged off
241
+ * the bar does not stop dead at the boundary.
242
+ */
243
+ export const Interactive: Story = {
244
+ args: { label: "Volume" },
245
+ render: (controlProps) => (
246
+ <StorySection title="Drive it with the keyboard, or press anywhere on the row.">
247
+ <LiveSlider
248
+ {...controlProps}
249
+ initialValue={40}
250
+ isLabelVisible
251
+ isValueShown
252
+ label="Volume"
253
+ />
254
+ </StorySection>
255
+ ),
256
+ }
@@ -0,0 +1,241 @@
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 "./Slider.stories.tsx"
9
+
10
+ const { AllStates, Default, Interactive } =
11
+ composeStories(stories)
12
+
13
+ test("it is a slider a screen reader can name and read", async () => {
14
+ const { canvas, canvasElement } =
15
+ await mountStory(Default)
16
+
17
+ const slider = expectAgentDrivable(canvas, {
18
+ name: "Volume",
19
+ role: "slider",
20
+ })
21
+
22
+ await expect(slider).toHaveAttribute(
23
+ "aria-valuenow",
24
+ "40",
25
+ )
26
+
27
+ await expect(slider).toHaveAttribute("aria-valuemin", "0")
28
+
29
+ await expect(slider).toHaveAttribute(
30
+ "aria-valuemax",
31
+ "100",
32
+ )
33
+
34
+ await expectNoAxeViolations(canvasElement)
35
+ })
36
+
37
+ test("the arrow keys move it one step and Home/End reach both ends", async () => {
38
+ const { canvas } = await mountStory(Interactive)
39
+
40
+ const slider = expectAgentDrivable(canvas, {
41
+ name: "Volume",
42
+ role: "slider",
43
+ })
44
+
45
+ await userEvent.click(slider)
46
+
47
+ // The click landed somewhere on the track, so drive from a known
48
+ // point rather than from wherever the pointer put it.
49
+ await userEvent.keyboard("{Home}")
50
+
51
+ await waitFor(() => {
52
+ expect(slider).toHaveAttribute("aria-valuenow", "0")
53
+ })
54
+
55
+ await userEvent.keyboard("{ArrowRight}")
56
+
57
+ await waitFor(() => {
58
+ expect(slider).toHaveAttribute("aria-valuenow", "1")
59
+ })
60
+
61
+ await userEvent.keyboard("{ArrowUp}")
62
+
63
+ await waitFor(() => {
64
+ expect(slider).toHaveAttribute("aria-valuenow", "2")
65
+ })
66
+
67
+ await userEvent.keyboard("{ArrowLeft}")
68
+
69
+ await waitFor(() => {
70
+ expect(slider).toHaveAttribute("aria-valuenow", "1")
71
+ })
72
+
73
+ await userEvent.keyboard("{End}")
74
+
75
+ await waitFor(() => {
76
+ expect(slider).toHaveAttribute("aria-valuenow", "100")
77
+ })
78
+ })
79
+
80
+ /**
81
+ * The default `largeStep` is a tenth of the range, so PageDown from the
82
+ * top of a 0–100 slider lands on 90. The point of asserting the default
83
+ * rather than a passed-in one is that a caller should not have to know
84
+ * its own range to get a sensible page key.
85
+ */
86
+ test("the page keys move it ten steps by default", async () => {
87
+ const { canvas } = await mountStory(Interactive)
88
+
89
+ const slider = expectAgentDrivable(canvas, {
90
+ name: "Volume",
91
+ role: "slider",
92
+ })
93
+
94
+ await userEvent.click(slider)
95
+
96
+ await userEvent.keyboard("{End}")
97
+
98
+ await userEvent.keyboard("{PageDown}")
99
+
100
+ await waitFor(() => {
101
+ expect(slider).toHaveAttribute("aria-valuenow", "90")
102
+ })
103
+
104
+ await userEvent.keyboard("{PageUp}")
105
+
106
+ await waitFor(() => {
107
+ expect(slider).toHaveAttribute("aria-valuenow", "100")
108
+ })
109
+ })
110
+
111
+ test("it never leaves its own range", async () => {
112
+ const { canvas } = await mountStory(Interactive)
113
+
114
+ const slider = expectAgentDrivable(canvas, {
115
+ name: "Volume",
116
+ role: "slider",
117
+ })
118
+
119
+ await userEvent.click(slider)
120
+
121
+ await userEvent.keyboard("{Home}")
122
+
123
+ // Three presses past the floor. A slider that goes to -3 renders a
124
+ // thumb off the start of its track and reports a value AT cannot
125
+ // place on the range it just announced.
126
+ await userEvent.keyboard(
127
+ "{ArrowLeft}{ArrowLeft}{ArrowLeft}",
128
+ )
129
+
130
+ await expect(slider).toHaveAttribute("aria-valuenow", "0")
131
+
132
+ await userEvent.keyboard("{End}")
133
+
134
+ await userEvent.keyboard(
135
+ "{ArrowRight}{ArrowRight}{ArrowRight}",
136
+ )
137
+
138
+ await expect(slider).toHaveAttribute(
139
+ "aria-valuenow",
140
+ "100",
141
+ )
142
+ })
143
+
144
+ test("a disabled slider is out of the tab order and does not move", async () => {
145
+ const { canvas, canvasElement } =
146
+ await mountStory(AllStates)
147
+
148
+ const disabled = expectAgentDrivable(canvas, {
149
+ name: "Disabled",
150
+ role: "slider",
151
+ })
152
+
153
+ await expect(disabled).toHaveAttribute(
154
+ "aria-disabled",
155
+ "true",
156
+ )
157
+
158
+ await expect(disabled).toHaveAttribute("tabindex", "-1")
159
+
160
+ // Not `userEvent.click` — it refuses, because the disabled class sets
161
+ // `pointer-events: none` and the helper asserts that before acting.
162
+ // Its refusal IS the pointer half of this test, so the keyboard half
163
+ // focuses directly rather than working around the guarantee.
164
+ await expect(
165
+ getComputedStyle(disabled).pointerEvents,
166
+ ).toBe("none")
167
+
168
+ disabled.focus()
169
+
170
+ await userEvent.keyboard("{ArrowRight}")
171
+
172
+ await expect(disabled).toHaveAttribute(
173
+ "aria-valuenow",
174
+ "35",
175
+ )
176
+
177
+ await expectNoAxeViolations(canvasElement)
178
+ })
179
+
180
+ /**
181
+ * Read-only is the state that is easy to collapse into `disabled`, and
182
+ * they are different answers: this one is still focusable and still
183
+ * full contrast, because its value is worth reading even though it
184
+ * cannot be changed.
185
+ */
186
+ test("a read-only slider is announced, focusable, and does not move", async () => {
187
+ const { canvas, canvasElement } =
188
+ await mountStory(AllStates)
189
+
190
+ const readOnly = expectAgentDrivable(canvas, {
191
+ name: "Read-only",
192
+ role: "slider",
193
+ })
194
+
195
+ await expect(readOnly).toHaveAttribute(
196
+ "aria-readonly",
197
+ "true",
198
+ )
199
+
200
+ await expect(readOnly).toHaveAttribute("tabindex", "0")
201
+
202
+ await expect(readOnly).not.toHaveAttribute(
203
+ "aria-disabled",
204
+ )
205
+
206
+ await userEvent.click(readOnly)
207
+
208
+ await userEvent.keyboard("{ArrowRight}")
209
+
210
+ await expect(readOnly).toHaveAttribute(
211
+ "aria-valuenow",
212
+ "35",
213
+ )
214
+
215
+ await expectNoAxeViolations(canvasElement)
216
+ })
217
+
218
+ /**
219
+ * `aria-valuetext` is the whole reason `valueFormat` exists. Without it
220
+ * a scrubber announces "1274" — a number of seconds nobody has — and
221
+ * `aria-valuenow` alone is what AT would read.
222
+ */
223
+ test("valueFormat writes aria-valuetext and the shown value", async () => {
224
+ const { canvas } = await mountStory(AllStates)
225
+
226
+ const position = expectAgentDrivable(canvas, {
227
+ name: "Position",
228
+ role: "slider",
229
+ })
230
+
231
+ await expect(position).toHaveAttribute(
232
+ "aria-valuetext",
233
+ "21:14",
234
+ )
235
+
236
+ // The raw number is still there for anything that computes with it.
237
+ await expect(position).toHaveAttribute(
238
+ "aria-valuenow",
239
+ "1274",
240
+ )
241
+ })