@lotics/ui 21.2.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 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
@@ -159,7 +159,9 @@ plain sm tabular count beside it — the cell/heading/peek-trigger meter; a capt
159
159
  above a tiny bar reads misaligned. **The track clamps at 100%, the caption does not** — over
160
160
  its max it reads `2,100 / 2,000 · 105%`, because a meter that says "100%" when you are over
161
161
  tells the reader they are exactly at the limit. Numbers format in the reader's locale, so
162
- never hand-format the value you pass in) / `StackedProgressBar` / `StepProgress`, `Breakdown` (a
162
+ never hand-format the value you pass in when display precision differs from the true value
163
+ (whole credits off a fractional balance), `formatValue` reshapes the caption text and leaves
164
+ the fill and the percentage exact) / `StackedProgressBar` / `StepProgress`, `Breakdown` (a
163
165
  stacked bar + ranked share rows, pressable to drill; `maxRows` folds the long tail behind a
164
166
  "Show N more" toggle — `labels` to localize — so several facet cards align to one height in
165
167
  a row), `Funnel` (a CONVERSION funnel — ordered stages as bars that NARROW; the step
@@ -209,7 +211,7 @@ no assembly — reach for `PressDoor` only outside them.
209
211
 
210
212
  ### Filters & view controls
211
213
 
212
- `SearchInput`, `ChipGroup`, `FilterChip` (+ `RangeSlider`, `Counter`), `Chip`,
214
+ `SearchInput`, `ChipGroup`, `FilterChip` (+ `Slider range`, `Counter`), `Chip`,
213
215
  `ColumnFilter` (the typed per-column filter pill + `columnFilterToConditions`).
214
216
 
215
217
  ### Overlays
@@ -576,7 +578,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
576
578
  - **`checkbox_input`** — `CheckboxInput`: checkbox + label as one labelled control.
577
579
  - **`switch`** — `Switch`: the bare toggle (a cell/toolbar boolean).
578
580
  - **`counter`** — `Counter`: increment/decrement numeric stepper (filter facet).
579
- - **`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.
580
588
 
581
589
  ### Form & text inputs
582
590
 
@@ -778,7 +786,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
778
786
  `isColumnFilterActive` + `columnFilterSummary`: the typed per-column filter pill; for a
779
787
  register filtering on several columns.
780
788
  - **`filter_chip`** — `FilterChip` + `selectSummary`: the toolbar filter pill hosting a
781
- facet (options, a `RangeSlider`, a `Counter`).
789
+ facet (options, a `Slider range`, a `Counter`).
782
790
  - **`summary_line`** — `SummaryLine`: the light inline summary of a register/list's FILTERED
783
791
  view, sits below the toolbar; NOT the boxed dashboard `kpi_strip` band.
784
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.2.0",
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
- "./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/locale.tsx CHANGED
@@ -289,8 +289,11 @@ export const en: LoticsLocale = {
289
289
  downloadAll: "Download all",
290
290
  downloadSelected: (n: number) => `Download (${n})`,
291
291
  delete: "Delete",
292
- removeTitle: "Remove attachment?",
293
- removeMessage: "This removes the file.",
292
+ // `FilesEditorRemove` removes the whole SELECTION, so the confirm is phrased for a set —
293
+ // singular copy under-reports what the button is about to do. And the kit does not know what
294
+ // the files hang off, so it says what it can see: the files, and that they are selected.
295
+ removeTitle: "Remove files?",
296
+ removeMessage: "The selected files will be removed.",
294
297
  removeCancel: "Cancel",
295
298
  removeConfirm: "Remove",
296
299
  },
@@ -438,7 +441,7 @@ export const vi: LoticsLocale = {
438
441
  downloadSelected: (n: number) => `Tải ${n} tệp`,
439
442
  delete: "Xóa",
440
443
  removeTitle: "Xóa tệp?",
441
- removeMessage: "Tệp sẽ bị gỡ khỏi bản ghi.",
444
+ removeMessage: "Các tệp đã chọn sẽ bị xóa.",
442
445
  removeCancel: "Hủy",
443
446
  removeConfirm: "Xóa",
444
447
  },
@@ -16,6 +16,16 @@ export interface ProgressBarProps {
16
16
  * "1,250 / 2,500 · 50%" (separators follow the reader's locale). Reports the
17
17
  * TRUE ratio — over `max` it reads "105%" while the track stays clamped. */
18
18
  format?: ProgressBarFormat;
19
+ /**
20
+ * How `value` and `max` render inside the caption. Defaults to the reader's locale grouping.
21
+ *
22
+ * The escape hatch for a quantity whose natural precision is not its DISPLAY precision — a
23
+ * fractional credit balance metered to whole credits, a byte count shown as GB. Still the bar's
24
+ * job rather than the caller's: pass the true `value`/`max` so the track and the percentage stay
25
+ * exact, and let this reshape only the text. Pre-rounding the numbers you pass in moves the fill
26
+ * and the percentage too, which is how a 99.6%-full meter starts claiming it is exactly full.
27
+ */
28
+ formatValue?: (n: number) => string;
19
29
  color?: string;
20
30
  completeColor?: string;
21
31
  /** COMPACT: one row — the track (flex) with a plain sm tabular count beside
@@ -37,6 +47,7 @@ export function ProgressBar(props: ProgressBarProps) {
37
47
  max,
38
48
  title,
39
49
  format = "percentage",
50
+ formatValue,
40
51
  color = colors.blue["500"],
41
52
  completeColor = colors.green["500"],
42
53
  compact = false,
@@ -55,10 +66,11 @@ export function ProgressBar(props: ProgressBarProps) {
55
66
  const ratio = max > 0 ? Math.max(0, (value / max) * 100) : 0;
56
67
  const percentage = Math.min(100, ratio);
57
68
  const isComplete = ratio >= 100;
69
+ const num = formatValue ?? ((n: number) => n.toLocaleString(localeTag));
58
70
 
59
71
  if (compact) {
60
72
  const label =
61
- format === "percentage" ? `${Math.round(ratio)}%` : `${value.toLocaleString(localeTag)}/${max.toLocaleString(localeTag)}`;
73
+ format === "percentage" ? `${Math.round(ratio)}%` : `${num(value)}/${num(max)}`;
62
74
  return (
63
75
  <View style={styles.compactRow}>
64
76
  <View style={[styles.track, styles.compactTrack]}>
@@ -75,7 +87,7 @@ export function ProgressBar(props: ProgressBarProps) {
75
87
 
76
88
  const caption =
77
89
  format === "fraction"
78
- ? `${value.toLocaleString(localeTag)} / ${max.toLocaleString(localeTag)} · ${Math.round(ratio)}%`
90
+ ? `${num(value)} / ${num(max)} · ${Math.round(ratio)}%`
79
91
  : format === "percentage"
80
92
  ? `${Math.round(ratio)}%`
81
93
  : null;
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
- });