@lotics/ui 21.3.0 → 22.1.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/AGENTS.md CHANGED
@@ -41,6 +41,13 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
41
41
  [composition.md §`Badge` is for STATUS only](./docs/composition.md).
42
42
  - **The kit's fonts/colors/icons ARE the design system** — never a custom font, icon set, or
43
43
  hand-picked palette shade; color is `solid`/`tint`/`ramp` with ONE accent per screen.
44
+ - **Hand-typed type is off-system — and it always lands too small.** Every run of language is
45
+ `<Text size= weight= color=>` on the fixed rungs; a screen reaching for `fontSize` /
46
+ `lineHeight` / `letterSpacing` has left the ladder, and the sizes it invents come out UNDER
47
+ `xs` (12) and `sm` (14) — which is what "looks small and cheap" literally is. `Text` is also
48
+ the only way to get the letter-spacing curve and Inter's `cv11 ss01 ss03` alternates, the
49
+ "designed" tells you cannot type by hand. **Grep the diff: `fontSize:` / `lineHeight:` /
50
+ `letterSpacing:` outside `src/` is a bug.** → [composition.md §Typography](./docs/composition.md).
44
51
  - **Pure primitives only** — no i18n, analytics, or domain types in `src/` (pass `labels`,
45
52
  callbacks); Lotics-coupled UI belongs in `@lotics/ui-internal`.
46
53
  - **A button never contains a control** — a row that presses open AND carries its own controls
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` (+ `RangeSlider`, `Counter`), `Chip`,
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
- - **`range_slider`** — `RangeSlider` + `rangeSummary`: min–max range control (filter facet).
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 `RangeSlider`, a `Counter`).
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 —
@@ -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 / `RangeSlider` / `Counter` / date range). Single-select
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": "21.3.0",
3
+ "version": "22.1.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
- "./range_slider": "./src/range_slider.tsx",
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
- * range use `RangeSlider`.
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;
@@ -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 (`RangeSlider`,
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
- * (`RangeSlider`, `Counter`, `OptionList` multi). With `footer` (+ controlled
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. RangeSlider) sets its
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
+ }
@@ -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
- });