@charcuterie/ui 3.8.1 → 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.
- package/dist/Slider/Slider.d.ts +85 -0
- package/dist/Slider/Slider.d.ts.map +1 -0
- package/dist/Slider/Slider.js +193 -0
- package/dist/Slider/Slider.js.map +1 -0
- package/dist/Slider/sliderValue.d.ts +48 -0
- package/dist/Slider/sliderValue.d.ts.map +1 -0
- package/dist/Slider/sliderValue.js +98 -0
- package/dist/Slider/sliderValue.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/Slider/Slider.mdx +78 -0
- package/src/Slider/Slider.stories.tsx +256 -0
- package/src/Slider/Slider.test.tsx +241 -0
- package/src/Slider/Slider.tsx +412 -0
- package/src/Slider/sliderValue.test.ts +156 -0
- package/src/Slider/sliderValue.ts +149 -0
- package/src/index.ts +5 -0
- package/src/sourceRules.test.ts +7 -1
|
@@ -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
|
+
})
|