@charcuterie/ui 3.8.1 → 3.10.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 (40) hide show
  1. package/README.md +10 -0
  2. package/dist/Badge/Badge.d.ts +2 -37
  3. package/dist/Badge/Badge.d.ts.map +1 -1
  4. package/dist/Badge/Badge.js +15 -30
  5. package/dist/Badge/Badge.js.map +1 -1
  6. package/dist/Badge/useBadgeShape.d.ts +57 -0
  7. package/dist/Badge/useBadgeShape.d.ts.map +1 -0
  8. package/dist/Badge/useBadgeShape.js +35 -0
  9. package/dist/Badge/useBadgeShape.js.map +1 -0
  10. package/dist/BadgeButton/BadgeButton.d.ts +59 -0
  11. package/dist/BadgeButton/BadgeButton.d.ts.map +1 -0
  12. package/dist/BadgeButton/BadgeButton.js +64 -0
  13. package/dist/BadgeButton/BadgeButton.js.map +1 -0
  14. package/dist/Slider/Slider.d.ts +85 -0
  15. package/dist/Slider/Slider.d.ts.map +1 -0
  16. package/dist/Slider/Slider.js +193 -0
  17. package/dist/Slider/Slider.js.map +1 -0
  18. package/dist/Slider/sliderValue.d.ts +48 -0
  19. package/dist/Slider/sliderValue.d.ts.map +1 -0
  20. package/dist/Slider/sliderValue.js +98 -0
  21. package/dist/Slider/sliderValue.js.map +1 -0
  22. package/dist/index.d.ts +5 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2 -0
  25. package/dist/index.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/Badge/Badge.tsx +30 -113
  28. package/src/Badge/useBadgeShape.tsx +142 -0
  29. package/src/BadgeButton/BadgeButton.mdx +67 -0
  30. package/src/BadgeButton/BadgeButton.stories.tsx +199 -0
  31. package/src/BadgeButton/BadgeButton.test.tsx +164 -0
  32. package/src/BadgeButton/BadgeButton.tsx +129 -0
  33. package/src/Slider/Slider.mdx +78 -0
  34. package/src/Slider/Slider.stories.tsx +256 -0
  35. package/src/Slider/Slider.test.tsx +241 -0
  36. package/src/Slider/Slider.tsx +412 -0
  37. package/src/Slider/sliderValue.test.ts +156 -0
  38. package/src/Slider/sliderValue.ts +149 -0
  39. package/src/index.ts +8 -0
  40. package/src/sourceRules.test.ts +14 -1
@@ -0,0 +1,78 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as SliderStories from "./Slider.stories";
4
+
5
+ <Meta of={SliderStories} />
6
+
7
+ # Slider
8
+
9
+ A single-value range control: what `<input type="range">` is, without the OS painting it.
10
+
11
+ The fleet had no way to ask for a value along a range, so QueuePilot's Now-playing bar
12
+ could not have a scrubber and the choice was a native range input or a hand-rolled one in
13
+ a single app. `ProgressBar` looks like the answer and is not — it is **output**, it takes
14
+ no input, and `role="progressbar"` tells assistive technology exactly that.
15
+
16
+ <Canvas of={SliderStories.Default} />
17
+
18
+ ## Props
19
+
20
+ <Controls />
21
+
22
+ ## Why a `div` and not `<input type="range">`
23
+
24
+ The same reason [`Select` is deprecated](?path=/docs/components-controls-select--docs).
25
+
26
+ A range input's thumb and track are UA pseudo-elements — `::-webkit-slider-thumb`,
27
+ `::-moz-range-track` — and they are not one surface: the two engines disagree about which
28
+ of them the filled portion even belongs to, so a token-styled range is two stylesheets that
29
+ drift apart. Windows paints its own regardless.
30
+
31
+ `role="slider"` on a focusable `div` is the APG's own pattern, gets the same AT support, and
32
+ every pixel of it is ours.
33
+
34
+ ## The role, the tab stop and the pointer target are all the track
35
+
36
+ Putting them on the thumb is what a hand-rolled slider usually does, because the thumb is
37
+ the part that looks draggable. It gives the widget a 16px box: a press on the bar does
38
+ nothing, and a Playwright bounding box is the handle rather than the control.
39
+
40
+ The hit area is a row taller than the bar, because a 2px track is not a pointer target.
41
+
42
+ ## Drag does not commit
43
+
44
+ `onChange` fires on every movement — each arrow key, each pointer sample. `onChangeEnd`
45
+ fires once, on release or key up.
46
+
47
+ This split exists because the expensive consumer of a slider is almost never the paint. A
48
+ seek scrubber issuing its network call from `onChange` would send one request per pointer
49
+ sample and arrive at the right offset **last**, behind a queue of stale ones.
50
+
51
+ While a drag is in flight the component paints from its own local value rather than from
52
+ the `value` prop. Without that, a controlled slider whose owner only commits on
53
+ `onChangeEnd` would render the thumb pinned at the old position under the finger — the drag
54
+ would look broken while behaving correctly.
55
+
56
+ <Canvas of={SliderStories.Interactive} />
57
+
58
+ ## Read-only is not disabled
59
+
60
+ A read-only slider keeps full contrast and stays focusable, so its value is still readable
61
+ and still reachable by a screen reader. A live stream's position is this: the number
62
+ matters and seeking is not offered.
63
+
64
+ <Canvas of={SliderStories.AllStates} />
65
+
66
+ ## The arithmetic is a tested pure function
67
+
68
+ `sliderValue.ts`, in the node project. Steps anchor on `min` rather than on zero — a 7–19
69
+ range stepping by 4 anchored on zero can reach 8, 12 and 16, and neither end of its own
70
+ range is one of them. A snap never escapes the range, a float step does not accumulate
71
+ drift into `aria-valuenow`, and a `NaN` cannot reach the style attribute, where it would
72
+ render `inline-size: NaN%` and paint nothing with no error.
73
+
74
+ `fromFraction` takes an `isRtl` flag because `getBoundingClientRect()` is physical and knows
75
+ nothing about writing direction — without it a right-to-left slider runs backwards under the
76
+ pointer while its keyboard path stays correct.
77
+
78
+ <Canvas of={SliderStories.AllVariants} />
@@ -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
+ })