@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 +31 -0
- package/docs/catalog.md +12 -4
- 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/locale.tsx +6 -3
- package/src/progress_bar.tsx +14 -2
- 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
|
@@ -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
|
|
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` (+ `
|
|
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
|
-
- **`
|
|
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 `
|
|
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 —
|
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/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
|
-
|
|
293
|
-
|
|
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: "
|
|
444
|
+
removeMessage: "Các tệp đã chọn sẽ bị xóa.",
|
|
442
445
|
removeCancel: "Hủy",
|
|
443
446
|
removeConfirm: "Xóa",
|
|
444
447
|
},
|
package/src/progress_bar.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
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
|
-
});
|