@charcuterie/ui 3.32.0 → 3.34.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 (76) hide show
  1. package/README.md +30 -0
  2. package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts +16 -0
  3. package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts.map +1 -1
  4. package/dist/AdaptiveGrid/useAdaptiveColumns.js +1 -0
  5. package/dist/AdaptiveGrid/useAdaptiveColumns.js.map +1 -1
  6. package/dist/Board/Board.d.ts.map +1 -1
  7. package/dist/Board/Board.js +2 -2
  8. package/dist/Board/Board.js.map +1 -1
  9. package/dist/RangeSlider/RangeSlider.d.ts +117 -0
  10. package/dist/RangeSlider/RangeSlider.d.ts.map +1 -0
  11. package/dist/RangeSlider/RangeSlider.js +242 -0
  12. package/dist/RangeSlider/RangeSlider.js.map +1 -0
  13. package/dist/RangeSlider/rangeSliderValue.d.ts +102 -0
  14. package/dist/RangeSlider/rangeSliderValue.d.ts.map +1 -0
  15. package/dist/RangeSlider/rangeSliderValue.js +116 -0
  16. package/dist/RangeSlider/rangeSliderValue.js.map +1 -0
  17. package/dist/SegmentedControl/SegmentedControl.d.ts +12 -1
  18. package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -1
  19. package/dist/SegmentedControl/SegmentedControl.js +3 -3
  20. package/dist/SegmentedControl/SegmentedControl.js.map +1 -1
  21. package/dist/SegmentedControl/SegmentedOption.d.ts +2 -1
  22. package/dist/SegmentedControl/SegmentedOption.d.ts.map +1 -1
  23. package/dist/SegmentedControl/SegmentedOption.js +7 -2
  24. package/dist/SegmentedControl/SegmentedOption.js.map +1 -1
  25. package/dist/Slider/Slider.d.ts +6 -1
  26. package/dist/Slider/Slider.d.ts.map +1 -1
  27. package/dist/Slider/Slider.js +8 -22
  28. package/dist/Slider/Slider.js.map +1 -1
  29. package/dist/TimecodeInput/TimecodeInput.d.ts +88 -0
  30. package/dist/TimecodeInput/TimecodeInput.d.ts.map +1 -0
  31. package/dist/TimecodeInput/TimecodeInput.js +258 -0
  32. package/dist/TimecodeInput/TimecodeInput.js.map +1 -0
  33. package/dist/TimecodeInput/timecode.d.ts +132 -0
  34. package/dist/TimecodeInput/timecode.d.ts.map +1 -0
  35. package/dist/TimecodeInput/timecode.js +246 -0
  36. package/dist/TimecodeInput/timecode.js.map +1 -0
  37. package/dist/VirtualizedGrid/VirtualizedGrid.d.ts +35 -0
  38. package/dist/VirtualizedGrid/VirtualizedGrid.d.ts.map +1 -1
  39. package/dist/VirtualizedGrid/VirtualizedGrid.js +37 -1
  40. package/dist/VirtualizedGrid/VirtualizedGrid.js.map +1 -1
  41. package/dist/index.d.ts +7 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +3 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/sliderStyles.d.ts +39 -0
  46. package/dist/sliderStyles.d.ts.map +1 -0
  47. package/dist/sliderStyles.js +50 -0
  48. package/dist/sliderStyles.js.map +1 -0
  49. package/package.json +1 -1
  50. package/src/AdaptiveGrid/useAdaptiveColumns.ts +17 -0
  51. package/src/Board/Board.test.tsx +8 -0
  52. package/src/Board/Board.tsx +7 -1
  53. package/src/RangeSlider/RangeSlider.mdx +85 -0
  54. package/src/RangeSlider/RangeSlider.stories.tsx +346 -0
  55. package/src/RangeSlider/RangeSlider.test.tsx +452 -0
  56. package/src/RangeSlider/RangeSlider.tsx +595 -0
  57. package/src/RangeSlider/rangeSliderValue.test.ts +217 -0
  58. package/src/RangeSlider/rangeSliderValue.ts +185 -0
  59. package/src/SegmentedControl/SegmentedControl.stories.tsx +50 -0
  60. package/src/SegmentedControl/SegmentedControl.test.tsx +36 -1
  61. package/src/SegmentedControl/SegmentedControl.tsx +14 -0
  62. package/src/SegmentedControl/SegmentedOption.tsx +7 -0
  63. package/src/Slider/Slider.tsx +23 -29
  64. package/src/TimecodeInput/TimecodeInput.mdx +155 -0
  65. package/src/TimecodeInput/TimecodeInput.stories.tsx +304 -0
  66. package/src/TimecodeInput/TimecodeInput.test.tsx +279 -0
  67. package/src/TimecodeInput/TimecodeInput.tsx +493 -0
  68. package/src/TimecodeInput/timecode.test.ts +236 -0
  69. package/src/TimecodeInput/timecode.ts +391 -0
  70. package/src/VirtualizedGrid/VirtualizedGrid.mdx +21 -0
  71. package/src/VirtualizedGrid/VirtualizedGrid.stories.tsx +64 -0
  72. package/src/VirtualizedGrid/VirtualizedGrid.test.tsx +122 -3
  73. package/src/VirtualizedGrid/VirtualizedGrid.tsx +37 -1
  74. package/src/index.ts +18 -0
  75. package/src/sliderStyles.ts +69 -0
  76. package/src/sourceRules.test.ts +25 -1
@@ -0,0 +1,217 @@
1
+ import { describe, expect, test } from "vitest"
2
+
3
+ import {
4
+ getNearerThumb,
5
+ getThumbBounds,
6
+ moveThumb,
7
+ snapRange,
8
+ } from "./rangeSliderValue.ts"
9
+
10
+ const bounds = { max: 100, min: 0, step: 1 }
11
+
12
+ describe("snapRange", () => {
13
+ test("it snaps both ends onto the step grid", () => {
14
+ expect(
15
+ snapRange(
16
+ { end: 78, start: 22 },
17
+ { max: 100, min: 0, step: 5 },
18
+ ),
19
+ ).toEqual({ end: 80, start: 20 })
20
+ })
21
+
22
+ /**
23
+ * `value` seeds rather than controls, so a backwards pair is a state
24
+ * an app can genuinely be in for a frame — a typed start committing
25
+ * before its end catches up. A negative-width fill paints nothing,
26
+ * so the pair is put in order here instead.
27
+ */
28
+ test("a backwards pair comes back in order", () => {
29
+ expect(
30
+ snapRange({ end: 20, start: 80 }, bounds),
31
+ ).toEqual({ end: 80, start: 20 })
32
+ })
33
+
34
+ test("neither end escapes the range, and a NaN does not reach the style attribute", () => {
35
+ expect(
36
+ snapRange({ end: 400, start: -40 }, bounds),
37
+ ).toEqual({ end: 100, start: 0 })
38
+
39
+ expect(
40
+ snapRange(
41
+ { end: Number.NaN, start: Number.NaN },
42
+ bounds,
43
+ ),
44
+ ).toEqual({ end: 0, start: 0 })
45
+ })
46
+ })
47
+
48
+ describe("moveThumb", () => {
49
+ test("it moves the thumb it is given and leaves the other alone", () => {
50
+ expect(
51
+ moveThumb({
52
+ bounds,
53
+ next: 30,
54
+ range: { end: 80, start: 20 },
55
+ thumb: "start",
56
+ }),
57
+ ).toEqual({ end: 80, start: 30 })
58
+
59
+ expect(
60
+ moveThumb({
61
+ bounds,
62
+ next: 60,
63
+ range: { end: 80, start: 20 },
64
+ thumb: "end",
65
+ }),
66
+ ).toEqual({ end: 60, start: 20 })
67
+ })
68
+
69
+ /**
70
+ * The crossing rule, and the whole reason this module exists. A
71
+ * thumb pushed past its partner stops **on** it: the range collapses
72
+ * to zero width and the two handles keep their identities.
73
+ */
74
+ test("a thumb stops on the other one rather than crossing it", () => {
75
+ expect(
76
+ moveThumb({
77
+ bounds,
78
+ next: 95,
79
+ range: { end: 80, start: 20 },
80
+ thumb: "start",
81
+ }),
82
+ ).toEqual({ end: 80, start: 80 })
83
+
84
+ expect(
85
+ moveThumb({
86
+ bounds,
87
+ next: 5,
88
+ range: { end: 80, start: 20 },
89
+ thumb: "end",
90
+ }),
91
+ ).toEqual({ end: 20, start: 20 })
92
+ })
93
+
94
+ test("a collapsed range opens again from either side", () => {
95
+ const collapsed = { end: 40, start: 40 }
96
+
97
+ expect(
98
+ moveThumb({
99
+ bounds,
100
+ next: 25,
101
+ range: collapsed,
102
+ thumb: "start",
103
+ }),
104
+ ).toEqual({ end: 40, start: 25 })
105
+
106
+ expect(
107
+ moveThumb({
108
+ bounds,
109
+ next: 55,
110
+ range: collapsed,
111
+ thumb: "end",
112
+ }),
113
+ ).toEqual({ end: 55, start: 40 })
114
+ })
115
+
116
+ /**
117
+ * The snap happens on the global grid and the clamp happens after
118
+ * it. Snapping against the other thumb as an anchor would put the
119
+ * two handles on different ladders — 7–19 by 4 is the range that
120
+ * shows it, since neither end of it is on a zero-anchored grid.
121
+ */
122
+ test("both thumbs stay on one grid, anchored on min", () => {
123
+ const coarse = { max: 19, min: 7, step: 4 }
124
+
125
+ expect(
126
+ moveThumb({
127
+ bounds: coarse,
128
+ next: 10,
129
+ range: { end: 19, start: 7 },
130
+ thumb: "start",
131
+ }),
132
+ ).toEqual({ end: 19, start: 11 })
133
+
134
+ expect(
135
+ moveThumb({
136
+ bounds: coarse,
137
+ next: 14,
138
+ range: { end: 19, start: 11 },
139
+ thumb: "end",
140
+ }),
141
+ ).toEqual({ end: 15, start: 11 })
142
+ })
143
+
144
+ test("a float step does not accumulate drift", () => {
145
+ expect(
146
+ moveThumb({
147
+ bounds: { max: 1, min: 0, step: 0.1 },
148
+ next: 0.30000000000000004,
149
+ range: { end: 0.8, start: 0.2 },
150
+ thumb: "start",
151
+ }),
152
+ ).toEqual({ end: 0.8, start: 0.3 })
153
+ })
154
+ })
155
+
156
+ describe("getNearerThumb", () => {
157
+ test("the nearer end picks up the press", () => {
158
+ const range = { end: 80, start: 20 }
159
+
160
+ expect(getNearerThumb({ at: 25, range })).toBe("start")
161
+
162
+ expect(getNearerThumb({ at: 75, range })).toBe("end")
163
+ })
164
+
165
+ /**
166
+ * Two thumbs on the same value is the state the clamping rule makes
167
+ * reachable, so the tie rule is what stops a collapsed range being
168
+ * stuck. The press's side decides.
169
+ */
170
+ test("a collapsed pair is opened by the side the press landed on", () => {
171
+ const collapsed = { end: 40, start: 40 }
172
+
173
+ expect(
174
+ getNearerThumb({ at: 10, range: collapsed }),
175
+ ).toBe("start")
176
+
177
+ expect(
178
+ getNearerThumb({ at: 90, range: collapsed }),
179
+ ).toBe("end")
180
+ })
181
+
182
+ test("a press outside the span picks the end it is outside of", () => {
183
+ const range = { end: 80, start: 20 }
184
+
185
+ expect(getNearerThumb({ at: 0, range })).toBe("start")
186
+
187
+ expect(getNearerThumb({ at: 100, range })).toBe("end")
188
+ })
189
+ })
190
+
191
+ describe("getThumbBounds", () => {
192
+ /**
193
+ * How the clamping rule reaches a screen reader: a slider's only
194
+ * vocabulary for "this handle stops here" is `aria-valuemax`.
195
+ */
196
+ test("each thumb reports the other as its own bound", () => {
197
+ const range = { end: 80, start: 20 }
198
+
199
+ expect(
200
+ getThumbBounds({ bounds, range, thumb: "start" }),
201
+ ).toEqual({ max: 80, min: 0 })
202
+
203
+ expect(
204
+ getThumbBounds({ bounds, range, thumb: "end" }),
205
+ ).toEqual({ max: 100, min: 20 })
206
+ })
207
+
208
+ test("a reversed range still reports two ordered bounds", () => {
209
+ expect(
210
+ getThumbBounds({
211
+ bounds: { max: 0, min: 100, step: 1 },
212
+ range: { end: 20, start: 80 },
213
+ thumb: "start",
214
+ }),
215
+ ).toEqual({ max: 80, min: 0 })
216
+ })
217
+ })
@@ -0,0 +1,185 @@
1
+ /**
2
+ * The arithmetic behind `RangeSlider`: everything the second thumb
3
+ * adds to `Slider`'s, and nothing `Slider` already answers.
4
+ *
5
+ * The single-value functions are **called**, never re-derived —
6
+ * `snapToStep` and `clampToRange` come from `../Slider/sliderValue.ts`
7
+ * — so a range slider snaps on exactly the grid a plain slider snaps
8
+ * on. Anchoring on `min` rather than on zero, the re-clamp after a
9
+ * snap, and the float-step rounding are all already right there, and
10
+ * a second copy of them is a second set of edge cases to keep in
11
+ * agreement.
12
+ *
13
+ * Every function here is total. A caller may hand over a reversed
14
+ * range, a `NaN`, or a start past its end, and the answer is still a
15
+ * pair of numbers inside `[min, max]` with `start <= end`. A range
16
+ * that renders `inline-size: NaN%` paints nothing and the DOM says
17
+ * nothing about it.
18
+ */
19
+
20
+ import {
21
+ clampToRange,
22
+ snapToStep,
23
+ } from "../Slider/sliderValue.ts"
24
+
25
+ /** Which end of the range a movement is about. */
26
+ export type RangeSliderThumb = "end" | "start"
27
+
28
+ export type RangeSliderValue = {
29
+ end: number
30
+ start: number
31
+ }
32
+
33
+ export type RangeSliderBounds = {
34
+ max: number
35
+ min: number
36
+ step: number
37
+ }
38
+
39
+ /**
40
+ * Both ends snapped to the step grid, clamped into the range, and put
41
+ * in order.
42
+ *
43
+ * The ordering is not a nicety. `value` **seeds** rather than
44
+ * controls, exactly as `Slider`'s does, so the pair arrives from an
45
+ * app's state and an app can hold `{ end: 20, start: 80 }` for a
46
+ * frame — a text field committing a typed start before the end
47
+ * catches up is the ordinary way to reach it. A backwards pair paints
48
+ * a fill of negative width, which is invisible rather than wrong-
49
+ * looking, so it is corrected here instead.
50
+ */
51
+ export const snapRange = (
52
+ range: RangeSliderValue,
53
+ { max, min, step }: RangeSliderBounds,
54
+ ): RangeSliderValue => {
55
+ const first = snapToStep(range.start, min, max, step)
56
+
57
+ const second = snapToStep(range.end, min, max, step)
58
+
59
+ return {
60
+ end: Math.max(first, second),
61
+ start: Math.min(first, second),
62
+ }
63
+ }
64
+
65
+ /**
66
+ * The range with one thumb moved to `next`, clamped at the other one.
67
+ *
68
+ * **The thumbs clamp; they do not swap.** A thumb pushed past its
69
+ * partner stops on it, keeps its own identity, and the range collapses
70
+ * to zero width rather than turning inside out. Swapping is the other
71
+ * defensible answer and is the wrong one for the first consumer: the
72
+ * thumbs are a clip's start and its end, a QueuePilot user drags them
73
+ * against a `TimecodeInput` showing which is which, and a drag that
74
+ * silently renamed the handle under the pointer would move a number
75
+ * the user is reading somewhere else. Keyboard makes it plainer still
76
+ * — swapping means one ArrowRight moves the *other* thumb, and the
77
+ * focused element is no longer the value that changed.
78
+ *
79
+ * The snap happens on the **global** grid before the clamp, never on
80
+ * a grid anchored at the other thumb: the partner is already on the
81
+ * grid, so clamping to it lands on the grid too, while snapping
82
+ * against a moving anchor would put the two thumbs on different
83
+ * ladders.
84
+ */
85
+ export const moveThumb = ({
86
+ bounds,
87
+ next,
88
+ range,
89
+ thumb,
90
+ }: {
91
+ bounds: RangeSliderBounds
92
+ next: number
93
+ range: RangeSliderValue
94
+ thumb: RangeSliderThumb
95
+ }): RangeSliderValue => {
96
+ const ordered = snapRange(range, bounds)
97
+
98
+ const snapped = snapToStep(
99
+ next,
100
+ bounds.min,
101
+ bounds.max,
102
+ bounds.step,
103
+ )
104
+
105
+ if (thumb === "start") {
106
+ return {
107
+ end: ordered.end,
108
+ start: clampToRange(
109
+ snapped,
110
+ Math.min(bounds.min, bounds.max),
111
+ ordered.end,
112
+ ),
113
+ }
114
+ }
115
+
116
+ return {
117
+ end: clampToRange(
118
+ snapped,
119
+ ordered.start,
120
+ Math.max(bounds.min, bounds.max),
121
+ ),
122
+ start: ordered.start,
123
+ }
124
+ }
125
+
126
+ /**
127
+ * The thumb a press at `at` should pick up.
128
+ *
129
+ * The nearer one, and the tie is the interesting half: two thumbs
130
+ * sitting on the same value are the state the clamping rule makes
131
+ * reachable, and a rule that always picked the same one would leave a
132
+ * collapsed range stuck against whichever end it collapsed on. So a
133
+ * tie is broken by **which side of them the press landed on** —
134
+ * below picks the start, on or above picks the end — which is what
135
+ * lets a collapsed pair be pulled open in either direction.
136
+ */
137
+ export const getNearerThumb = ({
138
+ at,
139
+ range,
140
+ }: {
141
+ at: number
142
+ range: RangeSliderValue
143
+ }): RangeSliderThumb => {
144
+ const toStart = Math.abs(at - range.start)
145
+
146
+ const toEnd = Math.abs(at - range.end)
147
+
148
+ if (toStart < toEnd) return "start"
149
+
150
+ if (toEnd < toStart) return "end"
151
+
152
+ return at < range.start ? "start" : "end"
153
+ }
154
+
155
+ /**
156
+ * What one thumb may report as its own `aria-valuemin` /
157
+ * `aria-valuemax`.
158
+ *
159
+ * The other thumb is the bound, which is how the clamping rule
160
+ * reaches assistive technology at all: a screen reader reading
161
+ * "12 of 0 to 30" on the start thumb is being told, in the only
162
+ * vocabulary a slider has, that 30 is where this handle stops. The
163
+ * APG's multi-thumb pattern asks for exactly this.
164
+ */
165
+ export const getThumbBounds = ({
166
+ bounds,
167
+ range,
168
+ thumb,
169
+ }: {
170
+ bounds: RangeSliderBounds
171
+ range: RangeSliderValue
172
+ thumb: RangeSliderThumb
173
+ }): { max: number; min: number } => {
174
+ const ordered = snapRange(range, bounds)
175
+
176
+ return thumb === "start"
177
+ ? {
178
+ max: ordered.end,
179
+ min: Math.min(bounds.min, bounds.max),
180
+ }
181
+ : {
182
+ max: Math.max(bounds.min, bounds.max),
183
+ min: ordered.start,
184
+ }
185
+ }
@@ -139,6 +139,56 @@ export const Responsive: Story = {
139
139
  ),
140
140
  }
141
141
 
142
+ /**
143
+ * A board's lane selector is a route selector, not a compact
144
+ * preference. It reaches the board's edges and each route gets the
145
+ * same hit area. At a cramped width, two targets share the first
146
+ * row and the final target takes the full second row, so no label
147
+ * wraps or clips.
148
+ */
149
+ export const FullWidth: Story = {
150
+ args: {
151
+ isFullWidth: true,
152
+ items: [
153
+ {
154
+ label: (
155
+ <span className="block whitespace-nowrap">
156
+ Todo
157
+ </span>
158
+ ),
159
+ value: "todo",
160
+ },
161
+ {
162
+ label: (
163
+ <span className="block whitespace-nowrap">
164
+ In Progress
165
+ </span>
166
+ ),
167
+ value: "in-progress",
168
+ },
169
+ {
170
+ label: (
171
+ <span className="block whitespace-nowrap">
172
+ Needs Review
173
+ </span>
174
+ ),
175
+ value: "needs-review",
176
+ },
177
+ ],
178
+ label: "Board lanes",
179
+ },
180
+ render: (controlProps) => (
181
+ <ContainerBoard>
182
+ {(width) => (
183
+ <SegmentedControl
184
+ {...controlProps}
185
+ label={`Board lanes at ${width}`}
186
+ />
187
+ )}
188
+ </ContainerBoard>
189
+ ),
190
+ }
191
+
142
192
  /**
143
193
  * The complete keyboard path. Tab enters the group **once** — the
144
194
  * roving-tabindex rule — then the arrow keys move and check
@@ -7,7 +7,7 @@ import { mountStory } from "../mountStory.testHelpers.ts"
7
7
  import { expectAgentDrivable } from "../testing/index.ts"
8
8
  import * as stories from "./SegmentedControl.stories.tsx"
9
9
 
10
- const { AllStates, Default, Interactive } =
10
+ const { AllStates, Default, FullWidth, Interactive } =
11
11
  composeStories(stories)
12
12
 
13
13
  /**
@@ -206,3 +206,38 @@ test("selectedValue decides the first render and nothing after", async () => {
206
206
  ).toHaveTextContent("3")
207
207
  })
208
208
  })
209
+
210
+ test("full-width mode fills its container without wrapping labels", async () => {
211
+ const { canvas } = await mountStory(FullWidth)
212
+
213
+ for (const group of canvas.getAllByRole("radiogroup", {
214
+ name: /Board lanes at/,
215
+ })) {
216
+ const options = getOptions(group)
217
+ const widths = options.map(
218
+ (option) => option.getBoundingClientRect().width,
219
+ )
220
+
221
+ if (
222
+ group.getAttribute("aria-label")?.includes("15rem")
223
+ ) {
224
+ // Two equal targets fit on the first row. The third fills the
225
+ // second row, so every label stays intact and there is no gap.
226
+ expect(widths[0]).toBeCloseTo(widths[1] ?? 0, 0)
227
+ expect(widths[2]).toBeGreaterThan(widths[0] ?? 0)
228
+ expect(
229
+ group.getBoundingClientRect().width -
230
+ (widths[2] ?? 0),
231
+ ).toBeLessThanOrEqual(8)
232
+ } else {
233
+ expect(
234
+ group.getBoundingClientRect().width,
235
+ ).toBeGreaterThan(
236
+ widths.reduce((sum, width) => sum + width, 0),
237
+ )
238
+ expect(
239
+ Math.max(...widths) - Math.min(...widths),
240
+ ).toBeLessThan(1)
241
+ }
242
+ }
243
+ })
@@ -24,6 +24,17 @@ export type SegmentedItem = {
24
24
 
25
25
  export type SegmentedControlProps = {
26
26
  className?: string
27
+ /**
28
+ * Fill the available inline space, with every option claiming an
29
+ * equal share. A board's Narrow View selector needs this: its
30
+ * options name the only lane on screen, so a content-width strip
31
+ * leaves the rest of the board looking disconnected.
32
+ *
33
+ * The default stays content-width. Most segmented controls choose
34
+ * a compact view or density, where stretching three short words
35
+ * across a whole toolbar would weaken their grouping.
36
+ */
37
+ isFullWidth?: boolean
27
38
  items: readonly SegmentedItem[]
28
39
  /** The group's accessible name. Required. */
29
40
  label: string
@@ -75,6 +86,7 @@ const GAP_CLASS: Record<ControlSize, string> = {
75
86
  */
76
87
  export const SegmentedControl = ({
77
88
  className,
89
+ isFullWidth = false,
78
90
  items,
79
91
  label,
80
92
  onChange,
@@ -152,6 +164,7 @@ export const SegmentedControl = ({
152
164
  aria-label={label}
153
165
  className={toClassName(
154
166
  "inline-flex items-center rounded-md border border-border-subtle bg-surface-sunken p-0.5",
167
+ isFullWidth && "w-full flex-wrap",
155
168
  GAP_CLASS[size],
156
169
  className,
157
170
  )}
@@ -194,6 +207,7 @@ export const SegmentedControl = ({
194
207
  {items.map((item) => (
195
208
  <SegmentedOption
196
209
  isChecked={checkedValue === item.value}
210
+ isFullWidth={isFullWidth}
197
211
  item={item}
198
212
  key={item.value}
199
213
  onChoose={chooseOption}
@@ -9,6 +9,7 @@ import type { SegmentedItem } from "./SegmentedControl.tsx"
9
9
 
10
10
  export type SegmentedOptionProps = {
11
11
  isChecked: boolean
12
+ isFullWidth: boolean
12
13
  item: SegmentedItem
13
14
  onChoose: (value: string) => void
14
15
  /** `RovingFocus.register` — membership of the arrow-key group. */
@@ -36,6 +37,7 @@ export type SegmentedOptionProps = {
36
37
  */
37
38
  export const SegmentedOption = ({
38
39
  isChecked,
40
+ isFullWidth,
39
41
  item,
40
42
  onChoose,
41
43
  registerFocus,
@@ -65,6 +67,11 @@ export const SegmentedOption = ({
65
67
  aria-checked={isChecked}
66
68
  className={toClassName(
67
69
  "cursor-pointer rounded-sm border border-transparent font-medium tabular-nums transition-colors duration-(--duration-fast) ease-standard",
70
+ // In a very narrow board, three full labels cannot share
71
+ // one row. The minimum lets two fit, then the final option
72
+ // takes a full second row instead of wrapping its label or
73
+ // leaving unused space beside it.
74
+ isFullWidth && "min-w-28 flex-1 basis-28",
68
75
  CONTROL_SIZE_CLASS[size],
69
76
  // Two entries rather than one interpolated string:
70
77
  // `tailwindCandidates.test.ts` rejects a template literal in
@@ -12,6 +12,15 @@ import {
12
12
  FOCUS_RING_CLASS,
13
13
  INTENT_SOLID_FILL_CLASS,
14
14
  } from "../intentStyles.ts"
15
+ import type { SliderSize } from "../sliderStyles.ts"
16
+ import {
17
+ SLIDER_ROW_CLASS,
18
+ SLIDER_THUMB_CLASS,
19
+ SLIDER_THUMB_OFFSET,
20
+ SLIDER_THUMB_SIZE_CLASS,
21
+ SLIDER_TRACK_CLASS,
22
+ SLIDER_TRACK_SIZE_CLASS,
23
+ } from "../sliderStyles.ts"
15
24
  import { toClassName } from "../toClassName.ts"
16
25
  import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
17
26
  import {
@@ -21,7 +30,11 @@ import {
21
30
  toPercent,
22
31
  } from "./sliderValue.ts"
23
32
 
24
- export type SliderSize = "lg" | "md" | "sm"
33
+ /**
34
+ * Re-exported rather than declared here: the three sizes are the bar's,
35
+ * and `RangeSlider` paints the same bar from `../sliderStyles.ts`.
36
+ */
37
+ export type { SliderSize }
25
38
 
26
39
  export type SliderProps = Omit<
27
40
  ComponentPropsWithRef<"div">,
@@ -73,25 +86,6 @@ export type SliderProps = Omit<
73
86
  valueFormat?: (value: number) => string
74
87
  }
75
88
 
76
- const TRACK_SIZE_CLASS: Record<SliderSize, string> = {
77
- sm: "h-1",
78
- md: "h-2",
79
- lg: "h-3",
80
- }
81
-
82
- const THUMB_SIZE_CLASS: Record<SliderSize, string> = {
83
- sm: "size-3",
84
- md: "size-4",
85
- lg: "size-5",
86
- }
87
-
88
- /** Half of each `THUMB_SIZE_CLASS`, as the inline-start pull-back. */
89
- const THUMB_OFFSET: Record<SliderSize, string> = {
90
- sm: "-0.375rem",
91
- md: "-0.5rem",
92
- lg: "-0.625rem",
93
- }
94
-
95
89
  /**
96
90
  * A single-value range control: the thing `<input type="range">` is,
97
91
  * without the OS painting it.
@@ -341,10 +335,10 @@ export const Slider = ({
341
335
  aria-valuenow={shown}
342
336
  aria-valuetext={valueText}
343
337
  className={toClassName(
344
- "relative flex w-full touch-none items-center rounded-full",
345
- // The hit area is the row, which is taller than the bar: a
346
- // 2px track is not a pointer target, and a finger is 44px.
347
- "min-h-(--control-height-sm) cursor-pointer",
338
+ // The bar, shared with `RangeSlider` through
339
+ // `../sliderStyles.ts` — including the hit area being the
340
+ // row rather than the 2px track.
341
+ SLIDER_ROW_CLASS,
348
342
  FOCUS_RING_CLASS,
349
343
  // NOT `ARIA_DISABLED_CLASS`. That constant paints a
350
344
  // box-shaped control — `bg-surface-sunken` plus a border —
@@ -373,8 +367,8 @@ export const Slider = ({
373
367
  >
374
368
  <span
375
369
  className={toClassName(
376
- "w-full overflow-hidden rounded-full bg-surface-sunken",
377
- TRACK_SIZE_CLASS[size],
370
+ SLIDER_TRACK_CLASS,
371
+ SLIDER_TRACK_SIZE_CLASS[size],
378
372
  )}
379
373
  >
380
374
  <span
@@ -389,8 +383,8 @@ export const Slider = ({
389
383
  <span
390
384
  aria-hidden="true"
391
385
  className={toClassName(
392
- "absolute rounded-full border-2 border-surface-raised shadow-sm",
393
- THUMB_SIZE_CLASS[size],
386
+ SLIDER_THUMB_CLASS,
387
+ SLIDER_THUMB_SIZE_CLASS[size],
394
388
  INTENT_SOLID_FILL_CLASS[intent],
395
389
  )}
396
390
  // Pulled back by half its own width so it sits ON the
@@ -403,7 +397,7 @@ export const Slider = ({
403
397
  // logical `inset-inline-start` looks like it already solved.
404
398
  style={{
405
399
  insetInlineStart: `${percent}%`,
406
- marginInlineStart: THUMB_OFFSET[size],
400
+ marginInlineStart: SLIDER_THUMB_OFFSET[size],
407
401
  }}
408
402
  />
409
403
  </div>