@charcuterie/ui 3.33.0 → 4.0.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/README.md +30 -0
- package/dist/Board/Board.d.ts.map +1 -1
- package/dist/Board/Board.js +2 -2
- package/dist/Board/Board.js.map +1 -1
- package/dist/Main/Main.d.ts +18 -19
- package/dist/Main/Main.d.ts.map +1 -1
- package/dist/Main/Main.js +21 -10
- package/dist/Main/Main.js.map +1 -1
- package/dist/Main/ScrollMemoryProvider.d.ts +38 -0
- package/dist/Main/ScrollMemoryProvider.d.ts.map +1 -0
- package/dist/Main/ScrollMemoryProvider.js +30 -0
- package/dist/Main/ScrollMemoryProvider.js.map +1 -0
- package/dist/Main/scrollMemory.d.ts +54 -28
- package/dist/Main/scrollMemory.d.ts.map +1 -1
- package/dist/Main/scrollMemory.js +81 -40
- package/dist/Main/scrollMemory.js.map +1 -1
- package/dist/Main/scrollMemoryContext.d.ts +18 -0
- package/dist/Main/scrollMemoryContext.d.ts.map +1 -0
- package/dist/Main/scrollMemoryContext.js +18 -0
- package/dist/Main/scrollMemoryContext.js.map +1 -0
- package/dist/Main/useScrollMemory.d.ts +20 -18
- package/dist/Main/useScrollMemory.d.ts.map +1 -1
- package/dist/Main/useScrollMemory.js +69 -42
- package/dist/Main/useScrollMemory.js.map +1 -1
- package/dist/RangeSlider/RangeSlider.d.ts +117 -0
- package/dist/RangeSlider/RangeSlider.d.ts.map +1 -0
- package/dist/RangeSlider/RangeSlider.js +242 -0
- package/dist/RangeSlider/RangeSlider.js.map +1 -0
- package/dist/RangeSlider/rangeSliderValue.d.ts +102 -0
- package/dist/RangeSlider/rangeSliderValue.d.ts.map +1 -0
- package/dist/RangeSlider/rangeSliderValue.js +116 -0
- package/dist/RangeSlider/rangeSliderValue.js.map +1 -0
- package/dist/SegmentedControl/SegmentedControl.d.ts +12 -1
- package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -1
- package/dist/SegmentedControl/SegmentedControl.js +3 -3
- package/dist/SegmentedControl/SegmentedControl.js.map +1 -1
- package/dist/SegmentedControl/SegmentedOption.d.ts +2 -1
- package/dist/SegmentedControl/SegmentedOption.d.ts.map +1 -1
- package/dist/SegmentedControl/SegmentedOption.js +7 -2
- package/dist/SegmentedControl/SegmentedOption.js.map +1 -1
- package/dist/Slider/Slider.d.ts +6 -1
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +8 -22
- package/dist/Slider/Slider.js.map +1 -1
- package/dist/TimecodeInput/TimecodeInput.d.ts +88 -0
- package/dist/TimecodeInput/TimecodeInput.d.ts.map +1 -0
- package/dist/TimecodeInput/TimecodeInput.js +258 -0
- package/dist/TimecodeInput/TimecodeInput.js.map +1 -0
- package/dist/TimecodeInput/timecode.d.ts +132 -0
- package/dist/TimecodeInput/timecode.d.ts.map +1 -0
- package/dist/TimecodeInput/timecode.js +246 -0
- package/dist/TimecodeInput/timecode.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/reactRouter/ReactRouterAdapter.d.ts +43 -0
- package/dist/reactRouter/ReactRouterAdapter.d.ts.map +1 -0
- package/dist/reactRouter/ReactRouterAdapter.js +50 -0
- package/dist/reactRouter/ReactRouterAdapter.js.map +1 -0
- package/dist/reactRouter/index.d.ts +14 -8
- package/dist/reactRouter/index.d.ts.map +1 -1
- package/dist/reactRouter/index.js +13 -8
- package/dist/reactRouter/index.js.map +1 -1
- package/dist/sliderStyles.d.ts +39 -0
- package/dist/sliderStyles.d.ts.map +1 -0
- package/dist/sliderStyles.js +50 -0
- package/dist/sliderStyles.js.map +1 -0
- package/package.json +1 -1
- package/src/Board/Board.test.tsx +8 -0
- package/src/Board/Board.tsx +7 -1
- package/src/Main/Main.mdx +35 -7
- package/src/Main/Main.stories.tsx +129 -64
- package/src/Main/Main.test.tsx +58 -0
- package/src/Main/Main.tsx +20 -20
- package/src/Main/ScrollMemoryProvider.tsx +48 -0
- package/src/Main/scrollMemory.test.ts +70 -16
- package/src/Main/scrollMemory.ts +98 -40
- package/src/Main/scrollMemoryContext.ts +21 -0
- package/src/Main/useScrollMemory.ts +83 -43
- package/src/RangeSlider/RangeSlider.mdx +85 -0
- package/src/RangeSlider/RangeSlider.stories.tsx +346 -0
- package/src/RangeSlider/RangeSlider.test.tsx +452 -0
- package/src/RangeSlider/RangeSlider.tsx +595 -0
- package/src/RangeSlider/rangeSliderValue.test.ts +217 -0
- package/src/RangeSlider/rangeSliderValue.ts +185 -0
- package/src/RouterLink/RouterLink.mdx +53 -16
- package/src/SegmentedControl/SegmentedControl.stories.tsx +50 -0
- package/src/SegmentedControl/SegmentedControl.test.tsx +36 -1
- package/src/SegmentedControl/SegmentedControl.tsx +14 -0
- package/src/SegmentedControl/SegmentedOption.tsx +7 -0
- package/src/Slider/Slider.tsx +23 -29
- package/src/TimecodeInput/TimecodeInput.mdx +155 -0
- package/src/TimecodeInput/TimecodeInput.stories.tsx +304 -0
- package/src/TimecodeInput/TimecodeInput.test.tsx +279 -0
- package/src/TimecodeInput/TimecodeInput.tsx +493 -0
- package/src/TimecodeInput/timecode.test.ts +236 -0
- package/src/TimecodeInput/timecode.ts +391 -0
- package/src/index.ts +21 -0
- package/src/reactRouter/ReactRouterAdapter.stories.tsx +120 -0
- package/src/reactRouter/ReactRouterAdapter.test.tsx +92 -0
- package/src/reactRouter/ReactRouterAdapter.tsx +66 -0
- package/src/reactRouter/index.ts +14 -8
- package/src/sliderStyles.ts +69 -0
- package/src/sourceRules.test.ts +29 -3
|
@@ -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
|
+
}
|
|
@@ -27,38 +27,68 @@ point — so an app that never imports this path never resolves it.
|
|
|
27
27
|
yarn add @charcuterie/ui react-router
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
**One component at the root**, and it must sit **inside** the router:
|
|
31
|
+
|
|
30
32
|
```tsx
|
|
31
33
|
// src/main.tsx
|
|
32
|
-
import {
|
|
33
|
-
import { ReactRouterLink } from "@charcuterie/ui/react-router"
|
|
34
|
+
import { ReactRouterAdapter } from "@charcuterie/ui/react-router"
|
|
34
35
|
import { StrictMode } from "react"
|
|
35
36
|
import { createRoot } from "react-dom/client"
|
|
36
|
-
import {
|
|
37
|
-
createBrowserRouter,
|
|
38
|
-
RouterProvider,
|
|
39
|
-
} from "react-router"
|
|
37
|
+
import { BrowserRouter } from "react-router"
|
|
40
38
|
|
|
41
|
-
import {
|
|
42
|
-
|
|
43
|
-
const router = createBrowserRouter(routes)
|
|
39
|
+
import { App } from "./App.tsx"
|
|
44
40
|
|
|
45
41
|
createRoot(document.getElementById("root")).render(
|
|
46
42
|
<StrictMode>
|
|
47
|
-
<
|
|
48
|
-
<
|
|
49
|
-
|
|
43
|
+
<BrowserRouter>
|
|
44
|
+
<ReactRouterAdapter>
|
|
45
|
+
<App />
|
|
46
|
+
</ReactRouterAdapter>
|
|
47
|
+
</BrowserRouter>
|
|
50
48
|
</StrictMode>,
|
|
51
49
|
)
|
|
52
50
|
```
|
|
53
51
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
Inside, because it reads `useLocation()`. Above the routes, because what it provides belongs
|
|
53
|
+
to the whole app — a link rendered by a route element and a link rendered by app-level
|
|
54
|
+
chrome both see it.
|
|
57
55
|
|
|
58
56
|
Already on `react-router-dom` v7, as bambuddy is? Nothing changes. `react-router-dom` v7 is
|
|
59
57
|
a re-export shim over `react-router`, and the adapter imports `Link` from the latter, so one
|
|
60
58
|
import covers both installs.
|
|
61
59
|
|
|
60
|
+
### Two seams, and why they arrive together
|
|
61
|
+
|
|
62
|
+
The adapter wires the **link seam** and the **scroll memory**, because a per-seam opt-in is
|
|
63
|
+
what the fleet has already failed at. `Main`'s scroll memory shipped as a `scrollKey` prop;
|
|
64
|
+
one app wired it, and the other three carried on losing the reader's place with the library
|
|
65
|
+
sitting right there supporting it. One name at the root means a seam added later arrives in
|
|
66
|
+
every app that already renders this, on its next release.
|
|
67
|
+
|
|
68
|
+
`RouterLinkProvider`, `ReactRouterLink` and `ScrollMemoryProvider` all stay exported for an
|
|
69
|
+
app that wants one half.
|
|
70
|
+
|
|
71
|
+
### On `createBrowserRouter`
|
|
72
|
+
|
|
73
|
+
`RouterProvider` takes no children, so the adapter goes in the root route's element:
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
const routes = [
|
|
77
|
+
{
|
|
78
|
+
element: (
|
|
79
|
+
<ReactRouterAdapter>
|
|
80
|
+
<Outlet />
|
|
81
|
+
</ReactRouterAdapter>
|
|
82
|
+
),
|
|
83
|
+
children: appRoutes,
|
|
84
|
+
},
|
|
85
|
+
]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
An app that wants only the link seam can keep `RouterLinkProvider` outside `RouterProvider`
|
|
89
|
+
instead, as it did before this adapter existed — that provider is plain React context and
|
|
90
|
+
needs no router of its own.
|
|
91
|
+
|
|
62
92
|
That is the whole integration. Every link in the app is now soft:
|
|
63
93
|
|
|
64
94
|
```tsx
|
|
@@ -166,6 +196,10 @@ asserts `getByRole("link", { name: … })` and its `href` with no router in the
|
|
|
166
196
|
With a provider, inject a stand-in that marks what it was handed — `TextLink.stories.tsx`'s
|
|
167
197
|
`Routed` story is exactly that, and `TextLink.test.tsx` reads it.
|
|
168
198
|
|
|
199
|
+
The scroll memory is the same: no provider, no memory. A test that wants it renders
|
|
200
|
+
`ScrollMemoryProvider` with a `{ key, path }` it controls, rather than a router — which is
|
|
201
|
+
what `Main.stories.tsx` does.
|
|
202
|
+
|
|
169
203
|
## API
|
|
170
204
|
|
|
171
205
|
| Export | From | What |
|
|
@@ -175,4 +209,7 @@ With a provider, inject a stand-in that marks what it was handed — `TextLink.s
|
|
|
175
209
|
| `RouterLinkComponent` / `RouterLinkProps` | `@charcuterie/ui` | The contract: `ComponentPropsWithRef<"a">` with `href` required. |
|
|
176
210
|
| `AnchorLink` | `@charcuterie/ui` | The plain `<a href>` default. |
|
|
177
211
|
| `getIsRoutedHref(href)` | `@charcuterie/ui` | The destination test above. |
|
|
178
|
-
| `
|
|
212
|
+
| `ScrollMemoryProvider` | `@charcuterie/ui` | The scroll seam. `entry` is `{ key, path }` for the current history entry. |
|
|
213
|
+
| `ScrollEntry` | `@charcuterie/ui` | That pair's type. |
|
|
214
|
+
| `ReactRouterAdapter` | `@charcuterie/ui/react-router` | Both seams, wired. The one thing an app renders. Optional peer. |
|
|
215
|
+
| `ReactRouterLink` | `@charcuterie/ui/react-router` | The link adapter alone. Optional peer. |
|
|
@@ -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
|