@lotics/ui 21.3.0 → 22.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/MIGRATION.md +31 -0
- package/docs/catalog.md +9 -3
- package/docs/composition.md +1 -1
- package/package.json +3 -2
- package/src/counter.tsx +1 -1
- package/src/filter_chip.tsx +3 -3
- package/src/slider.tsx +302 -0
- package/src/slider_math.ts +63 -0
- package/src/range_slider.tsx +0 -213
package/MIGRATION.md
CHANGED
|
@@ -4,6 +4,37 @@ Breaking changes, newest first — normally per major, plus the rare minor that
|
|
|
4
4
|
anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
|
|
5
5
|
this file exists only to move an app from one release to the next.
|
|
6
6
|
|
|
7
|
+
## 22.0.0 — `RangeSlider` is `Slider`, and single-value is a mode on it
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { RangeSlider, rangeSummary } from "@lotics/ui/range_slider"; // BEFORE
|
|
11
|
+
import { Slider, rangeSummary } from "@lotics/ui/slider"; // AFTER
|
|
12
|
+
|
|
13
|
+
<RangeSlider value={[lo, hi]} onValueChange={setBand} … /> // BEFORE
|
|
14
|
+
<Slider range value={[lo, hi]} onValueChange={setBand} … /> // AFTER
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`range` is the only addition to a range caller: every other prop, the drag, the keyboard
|
|
18
|
+
contract and the rendered a11y anatomy are unchanged, and `rangeSummary` keeps its name and
|
|
19
|
+
signature.
|
|
20
|
+
|
|
21
|
+
The kit had no single-value slider at all — `RangeSlider`'s own doc sent a single free value to
|
|
22
|
+
`NumberInput`, which is a typed field, not a drag. Apps that wanted one hand-rolled it. Adding a
|
|
23
|
+
second `Slider` component beside `RangeSlider` would have been the parallel family the kit
|
|
24
|
+
forbids, since single-vs-multi is a MODE: same geometry, same responder, same ARIA. So the
|
|
25
|
+
component is named for what it is and the second thumb became the axis.
|
|
26
|
+
|
|
27
|
+
New in the same change: `labels={false}` (suppress the built-in value line when the page prints
|
|
28
|
+
the figure itself), `Home`/`End` jump to the bounds, and `@lotics/ui/slider_math` — the
|
|
29
|
+
react-native-free geometry (`snapToStep`, `valueFraction`, `thumbOffset`, `thumbCenter`,
|
|
30
|
+
`valueAt`), unit-tested and exported for a caller measuring its own track.
|
|
31
|
+
|
|
32
|
+
A thumb also announces its POSITION now. `RangeSlider` passed `accessibilityValue={{min, max,
|
|
33
|
+
now}}`, which neither react-native-web 0.21 nor React Native 0.86 forwards — every thumb reached
|
|
34
|
+
the DOM as a bare `role="slider"` with no value, so a screen reader read "slider" and stopped.
|
|
35
|
+
`Slider` passes `aria-valuemin` / `aria-valuemax` / `aria-valuenow`, which are first-class on
|
|
36
|
+
`View` in both targets. A test asserting the old (absent) attributes was asserting a bug.
|
|
37
|
+
|
|
7
38
|
## 21.2.0 — `Stepper` is a list, not a progressbar
|
|
8
39
|
|
|
9
40
|
No API changed, but the RENDERED role did, so a test that queried it breaks:
|
package/docs/catalog.md
CHANGED
|
@@ -211,7 +211,7 @@ no assembly — reach for `PressDoor` only outside them.
|
|
|
211
211
|
|
|
212
212
|
### Filters & view controls
|
|
213
213
|
|
|
214
|
-
`SearchInput`, `ChipGroup`, `FilterChip` (+ `
|
|
214
|
+
`SearchInput`, `ChipGroup`, `FilterChip` (+ `Slider range`, `Counter`), `Chip`,
|
|
215
215
|
`ColumnFilter` (the typed per-column filter pill + `columnFilterToConditions`).
|
|
216
216
|
|
|
217
217
|
### Overlays
|
|
@@ -578,7 +578,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
578
578
|
- **`checkbox_input`** — `CheckboxInput`: checkbox + label as one labelled control.
|
|
579
579
|
- **`switch`** — `Switch`: the bare toggle (a cell/toolbar boolean).
|
|
580
580
|
- **`counter`** — `Counter`: increment/decrement numeric stepper (filter facet).
|
|
581
|
-
- **`
|
|
581
|
+
- **`slider`** — `Slider` + `rangeSummary`: drag a continuous number along a track. ONE thumb
|
|
582
|
+
by default (a volume, a threshold); `range` turns on the second and the value becomes
|
|
583
|
+
`[low, high]` (a price band, a filter facet). `labels={false}` when the caller prints the
|
|
584
|
+
figure itself.
|
|
585
|
+
- **`slider_math`** — `snapToStep` / `valueFraction` / `thumbOffset` / `thumbCenter` /
|
|
586
|
+
`valueAt`: `Slider`'s react-native-free geometry, exported so a caller measuring its own
|
|
587
|
+
track lands on the same grid.
|
|
582
588
|
|
|
583
589
|
### Form & text inputs
|
|
584
590
|
|
|
@@ -780,7 +786,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
780
786
|
`isColumnFilterActive` + `columnFilterSummary`: the typed per-column filter pill; for a
|
|
781
787
|
register filtering on several columns.
|
|
782
788
|
- **`filter_chip`** — `FilterChip` + `selectSummary`: the toolbar filter pill hosting a
|
|
783
|
-
facet (options, a `
|
|
789
|
+
facet (options, a `Slider range`, a `Counter`).
|
|
784
790
|
- **`summary_line`** — `SummaryLine`: the light inline summary of a register/list's FILTERED
|
|
785
791
|
view, sits below the toolbar; NOT the boxed dashboard `kpi_strip` band.
|
|
786
792
|
- **`use_selection`** — `useSelection`: always-on multi-select state for a register/list —
|
package/docs/composition.md
CHANGED
|
@@ -364,7 +364,7 @@ All view controls are 40px tall (`CONTROL_HEIGHT`), `sm` labels, in ONE wrapping
|
|
|
364
364
|
counts in the label). Filters ONE list to a SUBSET — including by process stage / lifecycle
|
|
365
365
|
state.
|
|
366
366
|
- **`FilterChip`** is THE secondary-dimension filter — a compact chip that opens a composed
|
|
367
|
-
popover editor (`OptionList` multi / `
|
|
367
|
+
popover editor (`OptionList` multi / `Slider range` / `Counter` / date range). Single-select
|
|
368
368
|
closes on pick via the `{({ close }) => …}` render prop.
|
|
369
369
|
- **`ColumnFilter`** is the TYPED column filter — give it a `FilterableColumn`
|
|
370
370
|
(`type: "text" | "number" | "select"`) + a controlled `ColumnFilterValue` and it renders the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -172,7 +172,8 @@
|
|
|
172
172
|
"./form_switch": "./src/form_switch.tsx",
|
|
173
173
|
"./chip": "./src/chip.tsx",
|
|
174
174
|
"./filter_chip": "./src/filter_chip.tsx",
|
|
175
|
-
"./
|
|
175
|
+
"./slider": "./src/slider.tsx",
|
|
176
|
+
"./slider_math": "./src/slider_math.ts",
|
|
176
177
|
"./counter": "./src/counter.tsx",
|
|
177
178
|
"./link": "./src/link.tsx",
|
|
178
179
|
"./text_link": "./src/text_link.tsx",
|
package/src/counter.tsx
CHANGED
|
@@ -20,7 +20,7 @@ export interface CounterProps {
|
|
|
20
20
|
* a min-nights filter, an order line quantity. The Airbnb-style counter: two
|
|
21
21
|
* bordered round buttons flanking the value, a hover wash on each, dimmed at
|
|
22
22
|
* their bounds. For free numeric entry use `NumberInput`; for a wide continuous
|
|
23
|
-
*
|
|
23
|
+
* quantity use `Slider`.
|
|
24
24
|
*/
|
|
25
25
|
export function Counter(props: CounterProps) {
|
|
26
26
|
const { value, onValueChange, min = 0, max = Infinity, step = 1, accessibilityLabel, format } = props;
|
package/src/filter_chip.tsx
CHANGED
|
@@ -23,7 +23,7 @@ export interface FilterChipProps {
|
|
|
23
23
|
onClear?: () => void;
|
|
24
24
|
/** Label for the clear affordances (pass a translated string). */
|
|
25
25
|
clearLabel?: string;
|
|
26
|
-
/** The editor revealed on press — a premium primitive (`
|
|
26
|
+
/** The editor revealed on press — a premium primitive (`Slider range`,
|
|
27
27
|
* `Counter`, `OptionList` multi) or any composed control. `FilterChip` owns
|
|
28
28
|
* the pill + popover chrome; the consumer owns the value and applies it.
|
|
29
29
|
* Pass a render function to receive `close` — the one editor that closes on
|
|
@@ -64,7 +64,7 @@ export function selectSummary(
|
|
|
64
64
|
* × to clear. `FilterChip` bakes the consistent chrome — the preview pill, a
|
|
65
65
|
* padded popover body for the composed editor, and a Clear footer when active —
|
|
66
66
|
* so every filter looks and behaves the same. Drop a premium primitive inside
|
|
67
|
-
* (`
|
|
67
|
+
* (`Slider range`, `Counter`, `OptionList` multi). With `footer` (+ controlled
|
|
68
68
|
* `open`/`onOpenChange`) the same shell wraps a non-filter VALUE pill — a
|
|
69
69
|
* setting gate with Cancel/Save — keeping it on the one pill surface. The table
|
|
70
70
|
* toolbar's `ColumnFilter` is this pill plus its query-condition mapping.
|
|
@@ -119,7 +119,7 @@ export function FilterChip(props: FilterChipProps) {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
const styles = StyleSheet.create({
|
|
122
|
-
// The popover hugs its content — a wide control (e.g.
|
|
122
|
+
// The popover hugs its content — a wide control (e.g. Slider) sets its
|
|
123
123
|
// OWN fixed width; the shell never forces one. No extra padding: the editor and
|
|
124
124
|
// the Clear footer then share the popover's own 8px inset, so a multi-select's
|
|
125
125
|
// options, its select-all, and the Clear all line up on one left edge.
|
package/src/slider.tsx
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
StyleSheet,
|
|
4
|
+
View,
|
|
5
|
+
type GestureResponderEvent,
|
|
6
|
+
type LayoutChangeEvent,
|
|
7
|
+
type ViewStyle,
|
|
8
|
+
} from "react-native";
|
|
9
|
+
import { Text } from "./text";
|
|
10
|
+
import { colors } from "./colors";
|
|
11
|
+
import { FOCUS_RING } from "./control_surface";
|
|
12
|
+
import { useFocusRing } from "./use_focus_ring";
|
|
13
|
+
import { snapToStep, thumbCenter, thumbOffset, valueAt, type SliderScale } from "./slider_math";
|
|
14
|
+
|
|
15
|
+
const THUMB = 20;
|
|
16
|
+
|
|
17
|
+
export interface SliderBaseProps {
|
|
18
|
+
min: number;
|
|
19
|
+
max: number;
|
|
20
|
+
step?: number;
|
|
21
|
+
/** Render the value labels (money, units). Default: the bare number. */
|
|
22
|
+
format?: (value: number) => string;
|
|
23
|
+
/** Print the selected value(s) above the track. Turn OFF when the caller
|
|
24
|
+
* renders the figure itself — a page that shows the volume at display size
|
|
25
|
+
* under the control does not want it a second time in muted 14px. */
|
|
26
|
+
labels?: boolean;
|
|
27
|
+
/** Filled-track / thumb accent. Default the neutral ink. */
|
|
28
|
+
color?: string;
|
|
29
|
+
/** Base name. In `range` mode the thumbs announce "{label} minimum" /
|
|
30
|
+
* "{label} maximum". */
|
|
31
|
+
accessibilityLabel: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type SliderProps = SliderBaseProps &
|
|
35
|
+
(
|
|
36
|
+
| { range?: false; value: number; onValueChange: (value: number) => void }
|
|
37
|
+
| {
|
|
38
|
+
range: true;
|
|
39
|
+
value: [number, number];
|
|
40
|
+
onValueChange: (value: [number, number]) => void;
|
|
41
|
+
}
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
type Thumb = "low" | "high";
|
|
45
|
+
|
|
46
|
+
function SliderThumb(props: {
|
|
47
|
+
label: string;
|
|
48
|
+
min: number;
|
|
49
|
+
max: number;
|
|
50
|
+
now: number;
|
|
51
|
+
left: number;
|
|
52
|
+
color: string;
|
|
53
|
+
active: boolean;
|
|
54
|
+
keyProps: object;
|
|
55
|
+
}) {
|
|
56
|
+
const { focusVisible, focusProps } = useFocusRing();
|
|
57
|
+
return (
|
|
58
|
+
<View
|
|
59
|
+
accessibilityRole="adjustable"
|
|
60
|
+
accessibilityLabel={props.label}
|
|
61
|
+
// The `aria-*` props, NOT `accessibilityValue={{min,max,now}}`: neither
|
|
62
|
+
// react-native-web 0.21 nor RN 0.86 forwards the object form, so the
|
|
63
|
+
// thumb reached the DOM as a bare `role="slider"` with no position — a
|
|
64
|
+
// screen reader announced "slider" and nothing else. These three are
|
|
65
|
+
// first-class on View in both targets.
|
|
66
|
+
aria-valuemin={props.min}
|
|
67
|
+
aria-valuemax={props.max}
|
|
68
|
+
aria-valuenow={props.now}
|
|
69
|
+
focusable
|
|
70
|
+
{...focusProps}
|
|
71
|
+
style={[
|
|
72
|
+
styles.thumb,
|
|
73
|
+
{ left: props.left, borderColor: props.color },
|
|
74
|
+
props.active && styles.thumbActive,
|
|
75
|
+
focusVisible && { boxShadow: FOCUS_RING },
|
|
76
|
+
]}
|
|
77
|
+
{...props.keyProps}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A short human summary of a range selection for a `FilterChip` preview —
|
|
84
|
+
* "5tr–10tr", or "≥ 5tr" / "≤ 10tr" when one end sits at its bound, and
|
|
85
|
+
* `undefined` at the full range (no active filter). Mirrors `columnFilterSummary`.
|
|
86
|
+
*/
|
|
87
|
+
export function rangeSummary(
|
|
88
|
+
value: [number, number],
|
|
89
|
+
format: (n: number) => string = String,
|
|
90
|
+
bounds?: [number, number],
|
|
91
|
+
): string | undefined {
|
|
92
|
+
const [low, high] = value;
|
|
93
|
+
if (!bounds) return `${format(low)}–${format(high)}`;
|
|
94
|
+
const atMin = low <= bounds[0];
|
|
95
|
+
const atMax = high >= bounds[1];
|
|
96
|
+
if (atMin && atMax) return undefined;
|
|
97
|
+
if (atMin) return `≤ ${format(high)}`;
|
|
98
|
+
if (atMax) return `≥ ${format(low)}`;
|
|
99
|
+
return `${format(low)}–${format(high)}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Drag a continuous number along a track — a volume, a price, a weight, a
|
|
104
|
+
* threshold. ONE thumb by default; `range` turns on the second and the value
|
|
105
|
+
* becomes `[low, high]` (a price band, a filter facet). Single and range are
|
|
106
|
+
* the same control in two modes, not two components: the geometry, the drag,
|
|
107
|
+
* the keyboard contract and the a11y anatomy are identical.
|
|
108
|
+
*
|
|
109
|
+
* Drag works on web and native (the RN Responder system), and every thumb is a
|
|
110
|
+
* keyboard-operable ARIA slider — arrows step it, and in `range` mode each
|
|
111
|
+
* thumb is clamped against the other. For a small discrete count use `Counter`;
|
|
112
|
+
* for a number typed exactly use `NumberInput`.
|
|
113
|
+
*
|
|
114
|
+
* <Slider min={20} max={600} step={10} value={lots}
|
|
115
|
+
* onValueChange={setLots} accessibilityLabel="Lots a month" />
|
|
116
|
+
*
|
|
117
|
+
* <Slider range min={0} max={50_000_000} step={500_000} value={band}
|
|
118
|
+
* onValueChange={setBand} format={toMillions} accessibilityLabel="Price" />
|
|
119
|
+
*/
|
|
120
|
+
export function Slider(props: SliderProps) {
|
|
121
|
+
const {
|
|
122
|
+
min,
|
|
123
|
+
max,
|
|
124
|
+
step = 1,
|
|
125
|
+
format,
|
|
126
|
+
labels = true,
|
|
127
|
+
color = colors.zinc[900],
|
|
128
|
+
accessibilityLabel,
|
|
129
|
+
} = props;
|
|
130
|
+
const [width, setWidth] = useState(0);
|
|
131
|
+
const [dragging, setDragging] = useState<Thumb | null>(null);
|
|
132
|
+
const active = useRef<Thumb | null>(null);
|
|
133
|
+
|
|
134
|
+
// One internal model: a low and a high. In single mode the fill runs from the
|
|
135
|
+
// track's start, so "low" is the floor rather than a second thumb.
|
|
136
|
+
const range = props.range === true;
|
|
137
|
+
const [low, high] = range ? props.value : [min, props.value];
|
|
138
|
+
|
|
139
|
+
const fmt = (n: number) => (format ? format(n) : String(n));
|
|
140
|
+
const scale: SliderScale = { min, max, step, width, thumb: THUMB };
|
|
141
|
+
|
|
142
|
+
const emit = (thumb: Thumb, v: number) => {
|
|
143
|
+
if (!range) {
|
|
144
|
+
props.onValueChange(v);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// A thumb never crosses the other — low stays ≤ high.
|
|
148
|
+
if (thumb === "low") props.onValueChange([Math.min(v, high), high]);
|
|
149
|
+
else props.onValueChange([low, Math.max(v, low)]);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const closerThumb = (locationX: number): Thumb => {
|
|
153
|
+
if (!range) return "high";
|
|
154
|
+
const v = valueAt(locationX, scale);
|
|
155
|
+
return Math.abs(v - low) <= Math.abs(v - high) ? "low" : "high";
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const onLayout = (e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width);
|
|
159
|
+
|
|
160
|
+
// The track is the responder; grant picks the nearer thumb. We refuse
|
|
161
|
+
// termination so a parent ScrollView can't steal the drag on a vertical wobble.
|
|
162
|
+
const responder = {
|
|
163
|
+
onStartShouldSetResponder: () => true,
|
|
164
|
+
onMoveShouldSetResponder: () => true,
|
|
165
|
+
onResponderTerminationRequest: () => false,
|
|
166
|
+
onResponderGrant: (e: GestureResponderEvent) => {
|
|
167
|
+
const t = closerThumb(e.nativeEvent.locationX);
|
|
168
|
+
active.current = t;
|
|
169
|
+
setDragging(t);
|
|
170
|
+
emit(t, valueAt(e.nativeEvent.locationX, scale));
|
|
171
|
+
},
|
|
172
|
+
onResponderMove: (e: GestureResponderEvent) => {
|
|
173
|
+
if (active.current) emit(active.current, valueAt(e.nativeEvent.locationX, scale));
|
|
174
|
+
},
|
|
175
|
+
onResponderRelease: () => {
|
|
176
|
+
active.current = null;
|
|
177
|
+
setDragging(null);
|
|
178
|
+
},
|
|
179
|
+
onResponderTerminate: () => {
|
|
180
|
+
active.current = null;
|
|
181
|
+
setDragging(null);
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Web keyboard handler, forwarded by react-native-web (cast like the mouse
|
|
186
|
+
// handlers elsewhere — RN's View types don't declare it).
|
|
187
|
+
const keyProps = (thumb: Thumb) =>
|
|
188
|
+
({
|
|
189
|
+
onKeyDown: (e: { key: string; preventDefault: () => void }) => {
|
|
190
|
+
const now = thumb === "low" ? low : high;
|
|
191
|
+
const next =
|
|
192
|
+
e.key === "ArrowRight" || e.key === "ArrowUp"
|
|
193
|
+
? now + step
|
|
194
|
+
: e.key === "ArrowLeft" || e.key === "ArrowDown"
|
|
195
|
+
? now - step
|
|
196
|
+
: e.key === "Home"
|
|
197
|
+
? min
|
|
198
|
+
: e.key === "End"
|
|
199
|
+
? max
|
|
200
|
+
: null;
|
|
201
|
+
if (next === null) return;
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
emit(thumb, snapToStep(next, scale));
|
|
204
|
+
},
|
|
205
|
+
}) as object;
|
|
206
|
+
|
|
207
|
+
const thumbs: Thumb[] = range ? ["low", "high"] : ["high"];
|
|
208
|
+
// One expression for both modes: in single mode `low` IS `min`, whose centre
|
|
209
|
+
// is exactly where the track background starts.
|
|
210
|
+
const fillFrom = thumbCenter(low, scale);
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<View style={styles.container}>
|
|
214
|
+
{labels ? (
|
|
215
|
+
<View style={styles.labels}>
|
|
216
|
+
{thumbs.map((thumb) => (
|
|
217
|
+
<Text key={thumb} size="sm" color="muted" tabular>
|
|
218
|
+
{fmt(thumb === "low" ? low : high)}
|
|
219
|
+
</Text>
|
|
220
|
+
))}
|
|
221
|
+
</View>
|
|
222
|
+
) : null}
|
|
223
|
+
<View style={styles.trackArea} onLayout={onLayout} {...responder}>
|
|
224
|
+
<View style={styles.trackBg} />
|
|
225
|
+
<View
|
|
226
|
+
style={[
|
|
227
|
+
styles.trackFill,
|
|
228
|
+
{
|
|
229
|
+
left: fillFrom,
|
|
230
|
+
width: Math.max(0, thumbCenter(high, scale) - fillFrom),
|
|
231
|
+
backgroundColor: color,
|
|
232
|
+
},
|
|
233
|
+
]}
|
|
234
|
+
/>
|
|
235
|
+
{thumbs.map((thumb) => {
|
|
236
|
+
const v = thumb === "low" ? low : high;
|
|
237
|
+
return (
|
|
238
|
+
<SliderThumb
|
|
239
|
+
key={thumb}
|
|
240
|
+
label={
|
|
241
|
+
range
|
|
242
|
+
? `${accessibilityLabel} ${thumb === "low" ? "minimum" : "maximum"}`
|
|
243
|
+
: accessibilityLabel
|
|
244
|
+
}
|
|
245
|
+
min={min}
|
|
246
|
+
max={max}
|
|
247
|
+
now={v}
|
|
248
|
+
left={thumbOffset(v, scale)}
|
|
249
|
+
color={color}
|
|
250
|
+
active={dragging === thumb}
|
|
251
|
+
keyProps={keyProps(thumb)}
|
|
252
|
+
/>
|
|
253
|
+
);
|
|
254
|
+
})}
|
|
255
|
+
</View>
|
|
256
|
+
</View>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const styles = StyleSheet.create({
|
|
261
|
+
container: { gap: 10, minWidth: 240 },
|
|
262
|
+
labels: { flexDirection: "row", justifyContent: "space-between" },
|
|
263
|
+
trackArea: {
|
|
264
|
+
height: 28,
|
|
265
|
+
justifyContent: "center",
|
|
266
|
+
...({ cursor: "pointer", touchAction: "none" } as ViewStyle),
|
|
267
|
+
},
|
|
268
|
+
trackBg: {
|
|
269
|
+
position: "absolute",
|
|
270
|
+
left: THUMB / 2,
|
|
271
|
+
right: THUMB / 2,
|
|
272
|
+
top: 11,
|
|
273
|
+
height: 6,
|
|
274
|
+
borderRadius: 999,
|
|
275
|
+
backgroundColor: colors.zinc[200],
|
|
276
|
+
},
|
|
277
|
+
trackFill: {
|
|
278
|
+
position: "absolute",
|
|
279
|
+
top: 11,
|
|
280
|
+
height: 6,
|
|
281
|
+
borderRadius: 999,
|
|
282
|
+
},
|
|
283
|
+
thumb: {
|
|
284
|
+
position: "absolute",
|
|
285
|
+
top: 4,
|
|
286
|
+
width: THUMB,
|
|
287
|
+
height: THUMB,
|
|
288
|
+
borderRadius: 999,
|
|
289
|
+
backgroundColor: colors.white,
|
|
290
|
+
borderWidth: 2,
|
|
291
|
+
...({
|
|
292
|
+
cursor: "pointer",
|
|
293
|
+
boxShadow: "0 1px 3px rgba(38,38,38,0.18)",
|
|
294
|
+
} as ViewStyle),
|
|
295
|
+
},
|
|
296
|
+
thumbActive: {
|
|
297
|
+
// a soft halo while dragging — no size change, so the thumb never shifts
|
|
298
|
+
...({
|
|
299
|
+
boxShadow: "0 1px 3px rgba(38,38,38,0.18), 0 0 0 6px rgba(38,38,38,0.10)",
|
|
300
|
+
} as ViewStyle),
|
|
301
|
+
},
|
|
302
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Slider`'s geometry, with no react-native import so it can be unit-tested.
|
|
3
|
+
*
|
|
4
|
+
* The one idea worth isolating: a thumb is a BOX, not a point. Browsers and
|
|
5
|
+
* every hand-rolled slider inset the travel by half a thumb at each end so the
|
|
6
|
+
* thumb never overhangs the control — which means the pointer→value map and the
|
|
7
|
+
* value→offset map must use the SAME inset, or the fill and the thumb disagree
|
|
8
|
+
* by up to a radius at the extremes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface SliderScale {
|
|
12
|
+
min: number;
|
|
13
|
+
max: number;
|
|
14
|
+
/** Grid the value snaps to. `0` or less means "do not snap". */
|
|
15
|
+
step: number;
|
|
16
|
+
/** Measured width of the track, in px. */
|
|
17
|
+
width: number;
|
|
18
|
+
/** Thumb diameter, in px. */
|
|
19
|
+
thumb: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type Bounds = Pick<SliderScale, "min" | "max" | "step">;
|
|
23
|
+
|
|
24
|
+
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
|
|
25
|
+
|
|
26
|
+
/** `max - min`, never zero — a degenerate range would divide by it. */
|
|
27
|
+
const span = (b: Bounds) => b.max - b.min || 1;
|
|
28
|
+
|
|
29
|
+
/** The travel a thumb's LEFT EDGE has, in px: the track less one whole thumb. */
|
|
30
|
+
const usable = (s: SliderScale) => Math.max(0, s.width - s.thumb);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Round to the step grid, then clamp into the bounds — in that order, so a
|
|
34
|
+
* grid that does not divide the range evenly can still reach both ends.
|
|
35
|
+
*/
|
|
36
|
+
export function snapToStep(raw: number, b: Bounds): number {
|
|
37
|
+
if (!Number.isFinite(raw)) return b.min;
|
|
38
|
+
if (b.step <= 0) return clamp(raw, b.min, b.max);
|
|
39
|
+
return clamp(Math.round((raw - b.min) / b.step) * b.step + b.min, b.min, b.max);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Where a value sits along the track, 0 at `min` and 1 at `max`. */
|
|
43
|
+
export function valueFraction(value: number, b: Bounds): number {
|
|
44
|
+
return clamp((value - b.min) / span(b), 0, 1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Left offset of a thumb's BOX for a value — never past either edge. */
|
|
48
|
+
export function thumbOffset(value: number, s: SliderScale): number {
|
|
49
|
+
return valueFraction(value, s) * usable(s);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Centre of a thumb for a value — where the filled track has to stop. */
|
|
53
|
+
export function thumbCenter(value: number, s: SliderScale): number {
|
|
54
|
+
return thumbOffset(value, s) + s.thumb / 2;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The snapped value under a pointer sitting `locationX` px from the track's
|
|
58
|
+
* left edge. Inverts {@link thumbOffset}, inset included. */
|
|
59
|
+
export function valueAt(locationX: number, s: SliderScale): number {
|
|
60
|
+
const travel = usable(s);
|
|
61
|
+
const fraction = travel === 0 ? 0 : clamp((locationX - s.thumb / 2) / travel, 0, 1);
|
|
62
|
+
return snapToStep(s.min + fraction * span(s), s);
|
|
63
|
+
}
|
package/src/range_slider.tsx
DELETED
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
import { useRef, useState } from "react";
|
|
2
|
-
import { StyleSheet, View, type GestureResponderEvent, type LayoutChangeEvent, type ViewStyle } from "react-native";
|
|
3
|
-
import { Text } from "./text";
|
|
4
|
-
import { colors } from "./colors";
|
|
5
|
-
import { FOCUS_RING } from "./control_surface";
|
|
6
|
-
import { useFocusRing } from "./use_focus_ring";
|
|
7
|
-
|
|
8
|
-
const THUMB = 20;
|
|
9
|
-
const R = THUMB / 2;
|
|
10
|
-
|
|
11
|
-
export interface RangeSliderProps {
|
|
12
|
-
min: number;
|
|
13
|
-
max: number;
|
|
14
|
-
step?: number;
|
|
15
|
-
/** The controlled [low, high] selection. */
|
|
16
|
-
value: [number, number];
|
|
17
|
-
onValueChange: (value: [number, number]) => void;
|
|
18
|
-
/** Render the edge labels (money, units). Default: the bare number. */
|
|
19
|
-
format?: (value: number) => string;
|
|
20
|
-
/** Filled-track / thumb accent. Default the neutral ink. */
|
|
21
|
-
color?: string;
|
|
22
|
-
/** Base name — the thumbs announce "{label} minimum" / "{label} maximum". */
|
|
23
|
-
accessibilityLabel: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
type Thumb = "low" | "high";
|
|
27
|
-
|
|
28
|
-
function SliderThumb(props: {
|
|
29
|
-
label: string;
|
|
30
|
-
min: number;
|
|
31
|
-
max: number;
|
|
32
|
-
now: number;
|
|
33
|
-
left: number;
|
|
34
|
-
color: string;
|
|
35
|
-
active: boolean;
|
|
36
|
-
keyProps: object;
|
|
37
|
-
}) {
|
|
38
|
-
const { focusVisible, focusProps } = useFocusRing();
|
|
39
|
-
return (
|
|
40
|
-
<View
|
|
41
|
-
accessibilityRole="adjustable"
|
|
42
|
-
accessibilityLabel={props.label}
|
|
43
|
-
accessibilityValue={{ min: props.min, max: props.max, now: props.now }}
|
|
44
|
-
focusable
|
|
45
|
-
{...focusProps}
|
|
46
|
-
style={[styles.thumb, { left: props.left, borderColor: props.color }, props.active && styles.thumbActive, focusVisible && { boxShadow: FOCUS_RING }]}
|
|
47
|
-
{...props.keyProps}
|
|
48
|
-
/>
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* A short human summary of a range selection for a `FilterChip` preview —
|
|
54
|
-
* "5tr–10tr", or "≥ 5tr" / "≤ 10tr" when one end sits at its bound, and
|
|
55
|
-
* `undefined` at the full range (no active filter). Mirrors `columnFilterSummary`.
|
|
56
|
-
*/
|
|
57
|
-
export function rangeSummary(
|
|
58
|
-
value: [number, number],
|
|
59
|
-
format: (n: number) => string = String,
|
|
60
|
-
bounds?: [number, number],
|
|
61
|
-
): string | undefined {
|
|
62
|
-
const [low, high] = value;
|
|
63
|
-
if (!bounds) return `${format(low)}–${format(high)}`;
|
|
64
|
-
const atMin = low <= bounds[0];
|
|
65
|
-
const atMax = high >= bounds[1];
|
|
66
|
-
if (atMin && atMax) return undefined;
|
|
67
|
-
if (atMin) return `≤ ${format(high)}`;
|
|
68
|
-
if (atMax) return `≥ ${format(low)}`;
|
|
69
|
-
return `${format(low)}–${format(high)}`;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* A dual-thumb range slider — drag either end to bound a continuous number
|
|
74
|
-
* (a price range, an amount, a weight). Drag works on web and native (the RN
|
|
75
|
-
* Responder system), and each thumb is a keyboard-operable ARIA slider
|
|
76
|
-
* (arrow keys step it, clamped against the other). For a small discrete count
|
|
77
|
-
* use `Counter`; for a single free value use `NumberInput`.
|
|
78
|
-
*/
|
|
79
|
-
export function RangeSlider(props: RangeSliderProps) {
|
|
80
|
-
const { min, max, step = 1, value, onValueChange, format, color = colors.zinc[900], accessibilityLabel } = props;
|
|
81
|
-
const [low, high] = value;
|
|
82
|
-
const [width, setWidth] = useState(0);
|
|
83
|
-
const [dragging, setDragging] = useState<Thumb | null>(null);
|
|
84
|
-
const active = useRef<Thumb | null>(null);
|
|
85
|
-
|
|
86
|
-
const fmt = (n: number) => (format ? format(n) : String(n));
|
|
87
|
-
const span = max - min || 1;
|
|
88
|
-
const usable = Math.max(0, width - THUMB); // the track inset by a thumb radius at each end
|
|
89
|
-
const pct = (v: number) => (v - min) / span;
|
|
90
|
-
const x = (v: number) => pct(v) * usable; // thumb box left, in px — never crosses the edge
|
|
91
|
-
|
|
92
|
-
const snap = (raw: number) => {
|
|
93
|
-
const stepped = Math.round((raw - min) / step) * step + min;
|
|
94
|
-
return Math.min(max, Math.max(min, stepped));
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
// A thumb never crosses the other — low stays ≤ high.
|
|
98
|
-
const setThumb = (thumb: Thumb, v: number) => {
|
|
99
|
-
if (thumb === "low") onValueChange([Math.min(v, high), high]);
|
|
100
|
-
else onValueChange([low, Math.max(v, low)]);
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
// locationX is relative to the track; the usable range maps [R, width−R] → [0,1].
|
|
104
|
-
const fromX = (thumb: Thumb, locationX: number) =>
|
|
105
|
-
setThumb(thumb, snap(min + Math.min(1, Math.max(0, (locationX - R) / (usable || 1))) * span));
|
|
106
|
-
|
|
107
|
-
const closerThumb = (locationX: number): Thumb => {
|
|
108
|
-
const f = Math.min(1, Math.max(0, (locationX - R) / (usable || 1)));
|
|
109
|
-
const v = min + f * span;
|
|
110
|
-
return Math.abs(v - low) <= Math.abs(v - high) ? "low" : "high";
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
const onLayout = (e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width);
|
|
114
|
-
|
|
115
|
-
// The track is the responder; grant picks the nearer thumb. We refuse
|
|
116
|
-
// termination so a parent ScrollView can't steal the drag on a vertical wobble.
|
|
117
|
-
const responder = {
|
|
118
|
-
onStartShouldSetResponder: () => true,
|
|
119
|
-
onMoveShouldSetResponder: () => true,
|
|
120
|
-
onResponderTerminationRequest: () => false,
|
|
121
|
-
onResponderGrant: (e: GestureResponderEvent) => {
|
|
122
|
-
const t = closerThumb(e.nativeEvent.locationX);
|
|
123
|
-
active.current = t;
|
|
124
|
-
setDragging(t);
|
|
125
|
-
fromX(t, e.nativeEvent.locationX);
|
|
126
|
-
},
|
|
127
|
-
onResponderMove: (e: GestureResponderEvent) => {
|
|
128
|
-
if (active.current) fromX(active.current, e.nativeEvent.locationX);
|
|
129
|
-
},
|
|
130
|
-
onResponderRelease: () => { active.current = null; setDragging(null); },
|
|
131
|
-
onResponderTerminate: () => { active.current = null; setDragging(null); },
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
// Web keyboard handler, forwarded by react-native-web (cast like the mouse
|
|
135
|
-
// handlers elsewhere — RN's View types don't declare it).
|
|
136
|
-
const keyProps = (thumb: Thumb) =>
|
|
137
|
-
({
|
|
138
|
-
onKeyDown: (e: { key: string; preventDefault: () => void }) => {
|
|
139
|
-
const d = e.key === "ArrowRight" || e.key === "ArrowUp" ? step : e.key === "ArrowLeft" || e.key === "ArrowDown" ? -step : 0;
|
|
140
|
-
if (d === 0) return;
|
|
141
|
-
e.preventDefault();
|
|
142
|
-
setThumb(thumb, snap((thumb === "low" ? low : high) + d));
|
|
143
|
-
},
|
|
144
|
-
}) as object;
|
|
145
|
-
|
|
146
|
-
return (
|
|
147
|
-
<View style={styles.container}>
|
|
148
|
-
<View style={styles.labels}>
|
|
149
|
-
<Text size="sm" color="muted" tabular>{fmt(low)}</Text>
|
|
150
|
-
<Text size="sm" color="muted" tabular>{fmt(high)}</Text>
|
|
151
|
-
</View>
|
|
152
|
-
<View style={styles.trackArea} onLayout={onLayout} {...responder}>
|
|
153
|
-
<View style={styles.trackBg} />
|
|
154
|
-
<View style={[styles.trackFill, { left: x(low) + R, width: Math.max(0, x(high) - x(low)), backgroundColor: color }]} />
|
|
155
|
-
{(["low", "high"] as const).map((thumb) => {
|
|
156
|
-
const v = thumb === "low" ? low : high;
|
|
157
|
-
return (
|
|
158
|
-
<SliderThumb
|
|
159
|
-
key={thumb}
|
|
160
|
-
label={`${accessibilityLabel} ${thumb === "low" ? "minimum" : "maximum"}`}
|
|
161
|
-
min={min}
|
|
162
|
-
max={max}
|
|
163
|
-
now={v}
|
|
164
|
-
left={x(v)}
|
|
165
|
-
color={color}
|
|
166
|
-
active={dragging === thumb}
|
|
167
|
-
keyProps={keyProps(thumb)}
|
|
168
|
-
/>
|
|
169
|
-
);
|
|
170
|
-
})}
|
|
171
|
-
</View>
|
|
172
|
-
</View>
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const styles = StyleSheet.create({
|
|
177
|
-
container: { gap: 10, minWidth: 240 },
|
|
178
|
-
labels: { flexDirection: "row", justifyContent: "space-between" },
|
|
179
|
-
trackArea: {
|
|
180
|
-
height: 28,
|
|
181
|
-
justifyContent: "center",
|
|
182
|
-
...({ cursor: "pointer", touchAction: "none" } as ViewStyle),
|
|
183
|
-
},
|
|
184
|
-
trackBg: {
|
|
185
|
-
position: "absolute",
|
|
186
|
-
left: R,
|
|
187
|
-
right: R,
|
|
188
|
-
top: 11,
|
|
189
|
-
height: 6,
|
|
190
|
-
borderRadius: 999,
|
|
191
|
-
backgroundColor: colors.zinc[200],
|
|
192
|
-
},
|
|
193
|
-
trackFill: {
|
|
194
|
-
position: "absolute",
|
|
195
|
-
top: 11,
|
|
196
|
-
height: 6,
|
|
197
|
-
borderRadius: 999,
|
|
198
|
-
},
|
|
199
|
-
thumb: {
|
|
200
|
-
position: "absolute",
|
|
201
|
-
top: 4,
|
|
202
|
-
width: THUMB,
|
|
203
|
-
height: THUMB,
|
|
204
|
-
borderRadius: 999,
|
|
205
|
-
backgroundColor: colors.white,
|
|
206
|
-
borderWidth: 2,
|
|
207
|
-
...({ cursor: "pointer", boxShadow: "0 1px 3px rgba(38,38,38,0.18)" } as ViewStyle),
|
|
208
|
-
},
|
|
209
|
-
thumbActive: {
|
|
210
|
-
// a soft halo while dragging — no size change, so the thumb never shifts
|
|
211
|
-
...({ boxShadow: "0 1px 3px rgba(38,38,38,0.18), 0 0 0 6px rgba(38,38,38,0.10)" } as ViewStyle),
|
|
212
|
-
},
|
|
213
|
-
});
|