@delacour/react-native-ui 0.1.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -0
- package/package.json +125 -0
- package/src/components/accordion/AGENTS.md +192 -0
- package/src/components/accordion/accordion-content.tsx +125 -0
- package/src/components/accordion/accordion-description.tsx +17 -0
- package/src/components/accordion/accordion-indicator.tsx +104 -0
- package/src/components/accordion/accordion-item.tsx +78 -0
- package/src/components/accordion/accordion-title.tsx +18 -0
- package/src/components/accordion/accordion-trigger.tsx +148 -0
- package/src/components/accordion/accordion.context.tsx +153 -0
- package/src/components/accordion/accordion.tsx +253 -0
- package/src/components/accordion/accordion.types.ts +11 -0
- package/src/components/accordion/accordion.variants.test.ts +435 -0
- package/src/components/accordion/accordion.variants.ts +358 -0
- package/src/components/accordion/index.ts +45 -0
- package/src/components/badge/AGENTS.md +83 -0
- package/src/components/badge/badge-close-button.tsx +48 -0
- package/src/components/badge/badge-end-content.tsx +15 -0
- package/src/components/badge/badge-label.tsx +24 -0
- package/src/components/badge/badge-start-content.tsx +16 -0
- package/src/components/badge/badge.context.tsx +64 -0
- package/src/components/badge/badge.tsx +192 -0
- package/src/components/badge/badge.types.ts +10 -0
- package/src/components/badge/badge.variants.test.ts +269 -0
- package/src/components/badge/badge.variants.ts +238 -0
- package/src/components/badge/index.ts +17 -0
- package/src/components/bottom-sheet/AGENTS.md +232 -0
- package/src/components/bottom-sheet/bottom-sheet-background.tsx +35 -0
- package/src/components/bottom-sheet/bottom-sheet-close.tsx +60 -0
- package/src/components/bottom-sheet/bottom-sheet-container.tsx +245 -0
- package/src/components/bottom-sheet/bottom-sheet-content.tsx +66 -0
- package/src/components/bottom-sheet/bottom-sheet-description.tsx +29 -0
- package/src/components/bottom-sheet/bottom-sheet-footer.tsx +148 -0
- package/src/components/bottom-sheet/bottom-sheet-handle.tsx +47 -0
- package/src/components/bottom-sheet/bottom-sheet-overlay.tsx +180 -0
- package/src/components/bottom-sheet/bottom-sheet-portal.tsx +72 -0
- package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +95 -0
- package/src/components/bottom-sheet/bottom-sheet-title.tsx +24 -0
- package/src/components/bottom-sheet/bottom-sheet-trigger.tsx +73 -0
- package/src/components/bottom-sheet/bottom-sheet.context.tsx +135 -0
- package/src/components/bottom-sheet/bottom-sheet.tsx +125 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +276 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.ts +199 -0
- package/src/components/bottom-sheet/index.ts +40 -0
- package/src/components/bottom-sheet/use-bottom-sheet-input.ts +125 -0
- package/src/components/button/AGENTS.md +217 -0
- package/src/components/button/button-end-content.tsx +15 -0
- package/src/components/button/button-group-separator.tsx +37 -0
- package/src/components/button/button-group-text.tsx +101 -0
- package/src/components/button/button-group.tsx +158 -0
- package/src/components/button/button-label.tsx +24 -0
- package/src/components/button/button-start-content.tsx +15 -0
- package/src/components/button/button.context.tsx +187 -0
- package/src/components/button/button.tsx +273 -0
- package/src/components/button/button.types.ts +10 -0
- package/src/components/button/button.variants.test.ts +811 -0
- package/src/components/button/button.variants.ts +480 -0
- package/src/components/button/index.ts +50 -0
- package/src/components/chart/AGENTS.md +210 -0
- package/src/components/chart/chart-area.tsx +93 -0
- package/src/components/chart/chart-bar.tsx +103 -0
- package/src/components/chart/chart-bars.tsx +75 -0
- package/src/components/chart/chart-candlestick.tsx +60 -0
- package/src/components/chart/chart-grid.tsx +33 -0
- package/src/components/chart/chart-legend.tsx +41 -0
- package/src/components/chart/chart-line.tsx +50 -0
- package/src/components/chart/chart-scatter.tsx +35 -0
- package/src/components/chart/chart-tooltip-dot.tsx +70 -0
- package/src/components/chart/chart-tooltip-x.tsx +121 -0
- package/src/components/chart/chart-tooltip-y.tsx +55 -0
- package/src/components/chart/chart-tooltip.tsx +133 -0
- package/src/components/chart/chart-x-axis.tsx +27 -0
- package/src/components/chart/chart-y-axis.tsx +21 -0
- package/src/components/chart/chart.context.tsx +127 -0
- package/src/components/chart/chart.tsx +448 -0
- package/src/components/chart/chart.types.ts +108 -0
- package/src/components/chart/chart.variants.test.ts +893 -0
- package/src/components/chart/chart.variants.ts +726 -0
- package/src/components/chart/index.ts +103 -0
- package/src/components/chart/pie-chart-center.tsx +40 -0
- package/src/components/chart/pie-chart-label.tsx +56 -0
- package/src/components/chart/pie-chart-slice.tsx +45 -0
- package/src/components/chart/pie-chart-tooltip.tsx +85 -0
- package/src/components/chart/pie-chart.context.tsx +77 -0
- package/src/components/chart/pie-chart.tsx +247 -0
- package/src/components/chart/use-chart-font.ts +25 -0
- package/src/components/chart/use-chart-palette.ts +38 -0
- package/src/components/checkbox/AGENTS.md +185 -0
- package/src/components/checkbox/checkbox-box.tsx +152 -0
- package/src/components/checkbox/checkbox-group.tsx +107 -0
- package/src/components/checkbox/checkbox-label.tsx +34 -0
- package/src/components/checkbox/checkbox.context.tsx +136 -0
- package/src/components/checkbox/checkbox.tsx +258 -0
- package/src/components/checkbox/checkbox.types.ts +14 -0
- package/src/components/checkbox/checkbox.variants.test.ts +634 -0
- package/src/components/checkbox/checkbox.variants.ts +484 -0
- package/src/components/checkbox/index.ts +42 -0
- package/src/components/field/AGENTS.md +100 -0
- package/src/components/field/field-content.tsx +24 -0
- package/src/components/field/field-description.tsx +29 -0
- package/src/components/field/field-error.tsx +61 -0
- package/src/components/field/field-group.tsx +22 -0
- package/src/components/field/field-label.tsx +33 -0
- package/src/components/field/field-legend.tsx +34 -0
- package/src/components/field/field-separator.tsx +81 -0
- package/src/components/field/field-set.tsx +22 -0
- package/src/components/field/field.context.tsx +78 -0
- package/src/components/field/field.tsx +144 -0
- package/src/components/field/field.types.ts +15 -0
- package/src/components/field/field.variants.test.ts +216 -0
- package/src/components/field/field.variants.ts +136 -0
- package/src/components/field/index.ts +21 -0
- package/src/components/icon/AGENTS.md +104 -0
- package/src/components/icon/icon.context.tsx +29 -0
- package/src/components/icon/icon.tsx +92 -0
- package/src/components/icon/icon.variants.test.ts +111 -0
- package/src/components/icon/icon.variants.ts +79 -0
- package/src/components/icon/index.ts +11 -0
- package/src/components/input/AGENTS.md +126 -0
- package/src/components/input/index.ts +27 -0
- package/src/components/input/input-group-decorator.tsx +84 -0
- package/src/components/input/input-group-prefix.tsx +16 -0
- package/src/components/input/input-group-suffix.tsx +15 -0
- package/src/components/input/input-group.tsx +140 -0
- package/src/components/input/input.context.tsx +87 -0
- package/src/components/input/input.tsx +184 -0
- package/src/components/input/input.types.ts +10 -0
- package/src/components/input/input.variants.test.ts +369 -0
- package/src/components/input/input.variants.ts +329 -0
- package/src/components/list-group/AGENTS.md +67 -0
- package/src/components/list-group/index.ts +20 -0
- package/src/components/list-group/list-group-item-content.tsx +10 -0
- package/src/components/list-group/list-group-item-description.tsx +12 -0
- package/src/components/list-group/list-group-item-prefix.tsx +30 -0
- package/src/components/list-group/list-group-item-suffix.tsx +55 -0
- package/src/components/list-group/list-group-item-title.tsx +17 -0
- package/src/components/list-group/list-group-item.tsx +87 -0
- package/src/components/list-group/list-group.context.tsx +65 -0
- package/src/components/list-group/list-group.tsx +116 -0
- package/src/components/list-group/list-group.types.ts +14 -0
- package/src/components/list-group/list-group.variants.test.ts +223 -0
- package/src/components/list-group/list-group.variants.ts +107 -0
- package/src/components/pressable/AGENTS.md +64 -0
- package/src/components/pressable/index.ts +9 -0
- package/src/components/pressable/pressable.tsx +256 -0
- package/src/components/pressable/pressable.variants.test.ts +99 -0
- package/src/components/pressable/pressable.variants.ts +63 -0
- package/src/components/provider/AGENTS.md +90 -0
- package/src/components/provider/index.ts +1 -0
- package/src/components/provider/provider.tsx +105 -0
- package/src/components/radio/AGENTS.md +247 -0
- package/src/components/radio/index.ts +35 -0
- package/src/components/radio/radio-group.tsx +126 -0
- package/src/components/radio/radio-indicator.tsx +91 -0
- package/src/components/radio/radio-label.tsx +39 -0
- package/src/components/radio/radio.context.tsx +132 -0
- package/src/components/radio/radio.tsx +215 -0
- package/src/components/radio/radio.variants.test.ts +581 -0
- package/src/components/radio/radio.variants.ts +272 -0
- package/src/components/screen/AGENTS.md +287 -0
- package/src/components/screen/index.ts +85 -0
- package/src/components/screen/screen-chat-list.tsx +485 -0
- package/src/components/screen/screen-content.tsx +69 -0
- package/src/components/screen/screen-debug.ts +43 -0
- package/src/components/screen/screen-error.tsx +61 -0
- package/src/components/screen/screen-flat-list.tsx +88 -0
- package/src/components/screen/screen-footer-background.tsx +60 -0
- package/src/components/screen/screen-footer.tsx +170 -0
- package/src/components/screen/screen-header.tsx +36 -0
- package/src/components/screen/screen-legend-list.tsx +105 -0
- package/src/components/screen/screen-list-component.tsx +23 -0
- package/src/components/screen/screen-loading.tsx +50 -0
- package/src/components/screen/screen-navbar-back-button.tsx +79 -0
- package/src/components/screen/screen-navbar-background.tsx +45 -0
- package/src/components/screen/screen-navbar-subtitle.tsx +26 -0
- package/src/components/screen/screen-navbar-title.tsx +31 -0
- package/src/components/screen/screen-navbar.tsx +154 -0
- package/src/components/screen/screen-root.tsx +38 -0
- package/src/components/screen/screen-scroll-area.tsx +116 -0
- package/src/components/screen/screen-scroll-shadow.tsx +186 -0
- package/src/components/screen/screen-section-list.tsx +81 -0
- package/src/components/screen/screen-view.tsx +59 -0
- package/src/components/screen/screen.context.tsx +167 -0
- package/src/components/screen/screen.tsx +114 -0
- package/src/components/screen/screen.types.ts +66 -0
- package/src/components/screen/screen.variants.test.ts +602 -0
- package/src/components/screen/screen.variants.ts +475 -0
- package/src/components/screen/use-screen-scroll-insets.ts +218 -0
- package/src/components/separator/AGENTS.md +30 -0
- package/src/components/separator/index.ts +8 -0
- package/src/components/separator/separator.tsx +85 -0
- package/src/components/slider/AGENTS.md +274 -0
- package/src/components/slider/index.ts +48 -0
- package/src/components/slider/slider-fill.tsx +68 -0
- package/src/components/slider/slider-output.tsx +55 -0
- package/src/components/slider/slider-thumb.tsx +161 -0
- package/src/components/slider/slider-track.tsx +233 -0
- package/src/components/slider/slider.context.tsx +118 -0
- package/src/components/slider/slider.tsx +321 -0
- package/src/components/slider/slider.types.ts +26 -0
- package/src/components/slider/slider.variants.test.ts +804 -0
- package/src/components/slider/slider.variants.ts +607 -0
- package/src/components/spinner/AGENTS.md +73 -0
- package/src/components/spinner/index.ts +17 -0
- package/src/components/spinner/spinner-arc.tsx +90 -0
- package/src/components/spinner/spinner-content.tsx +63 -0
- package/src/components/spinner/spinner.context.tsx +52 -0
- package/src/components/spinner/spinner.tsx +128 -0
- package/src/components/spinner/spinner.variants.test.ts +273 -0
- package/src/components/spinner/spinner.variants.ts +187 -0
- package/src/components/switch/AGENTS.md +213 -0
- package/src/components/switch/index.ts +42 -0
- package/src/components/switch/switch-content.tsx +111 -0
- package/src/components/switch/switch-end-content.tsx +18 -0
- package/src/components/switch/switch-start-content.tsx +20 -0
- package/src/components/switch/switch-thumb.tsx +102 -0
- package/src/components/switch/switch.context.tsx +79 -0
- package/src/components/switch/switch.tsx +423 -0
- package/src/components/switch/switch.types.ts +14 -0
- package/src/components/switch/switch.variants.test.ts +570 -0
- package/src/components/switch/switch.variants.ts +511 -0
- package/src/components/tabs/AGENTS.md +286 -0
- package/src/components/tabs/index.ts +69 -0
- package/src/components/tabs/tabs-content.tsx +65 -0
- package/src/components/tabs/tabs-indicator.tsx +97 -0
- package/src/components/tabs/tabs-label.tsx +62 -0
- package/src/components/tabs/tabs-list.tsx +139 -0
- package/src/components/tabs/tabs-pager.tsx +59 -0
- package/src/components/tabs/tabs-scroll-view.tsx +171 -0
- package/src/components/tabs/tabs-separator.tsx +73 -0
- package/src/components/tabs/tabs-trigger.tsx +210 -0
- package/src/components/tabs/tabs.context.tsx +294 -0
- package/src/components/tabs/tabs.tsx +435 -0
- package/src/components/tabs/tabs.types.ts +13 -0
- package/src/components/tabs/tabs.variants.test.ts +1020 -0
- package/src/components/tabs/tabs.variants.ts +672 -0
- package/src/components/text/AGENTS.md +95 -0
- package/src/components/text/index.ts +25 -0
- package/src/components/text/text.context.tsx +60 -0
- package/src/components/text/text.tsx +251 -0
- package/src/components/text/text.variants.test.ts +422 -0
- package/src/components/text/text.variants.ts +282 -0
- package/src/display-name.test.ts +145 -0
- package/src/docs.test.ts +98 -0
- package/src/expo/navigation-theme.tsx +58 -0
- package/src/hooks/use-controllable-state.ts +45 -0
- package/src/hooks/use-keyboard-state-sync.tsx +147 -0
- package/src/hooks/use-navigation-theme.ts +78 -0
- package/src/hooks/use-theme-color.ts +44 -0
- package/src/icons/central.ts +1 -0
- package/src/lib/cn.test.ts +136 -0
- package/src/lib/cn.ts +29 -0
- package/src/lib/color.test.ts +80 -0
- package/src/lib/color.ts +79 -0
- package/src/lib/compose-refs.test.ts +64 -0
- package/src/lib/compose-refs.ts +37 -0
- package/src/lib/keyboard-animation.test.ts +33 -0
- package/src/lib/keyboard-animation.ts +28 -0
- package/src/lib/merge-props.test.ts +82 -0
- package/src/lib/merge-props.ts +47 -0
- package/src/lib/navigation-theme.test.ts +49 -0
- package/src/lib/navigation-theme.ts +53 -0
- package/src/lib/slot.tsx +45 -0
- package/src/lib/tv.ts +20 -0
- package/src/styles/base.css +2 -0
- package/src/styles/geometry.test.ts +117 -0
- package/src/styles/index.css +3 -0
- package/src/styles/theme-tokens.test.ts +252 -0
- package/src/styles/theme.css +447 -0
- package/src/styles/tokens.css +132 -0
- package/src/styles/tokens.test.ts +181 -0
- package/src/styles/tokens.ts +86 -0
- package/src/uniwind-env.d.ts +1 -0
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import type { VariantProps } from "tailwind-variants";
|
|
2
|
+
import { tv } from "../../lib/tv";
|
|
3
|
+
import type { TextSize } from "../text/text.variants";
|
|
4
|
+
|
|
5
|
+
/** What a filled track means. Badge's and Checkbox's set, reusing tokens the theme already has. */
|
|
6
|
+
export const SLIDER_COLORS = ["default", "primary", "success", "warning", "destructive", "info"] as const;
|
|
7
|
+
|
|
8
|
+
export const SLIDER_SIZES = ["sm", "md", "lg"] as const;
|
|
9
|
+
|
|
10
|
+
/** Which way the track runs. A `vertical` slider counts up from the bottom. */
|
|
11
|
+
export const SLIDER_ORIENTATIONS = ["horizontal", "vertical"] as const;
|
|
12
|
+
|
|
13
|
+
export type SliderColor = (typeof SLIDER_COLORS)[number];
|
|
14
|
+
export type SliderSize = (typeof SLIDER_SIZES)[number];
|
|
15
|
+
export type SliderOrientation = (typeof SLIDER_ORIENTATIONS)[number];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The axes a slider falls back to when nothing names one.
|
|
19
|
+
*
|
|
20
|
+
* Named once because two places read them — `defaultVariants` below and
|
|
21
|
+
* {@link resolveSliderAxes}, which runs before `tv` is ever called. A test pins
|
|
22
|
+
* the pair, since a drift between them is a slider that renders at one size and
|
|
23
|
+
* reports another.
|
|
24
|
+
*/
|
|
25
|
+
export const SLIDER_DEFAULT_COLOR: SliderColor = "default";
|
|
26
|
+
export const SLIDER_DEFAULT_SIZE: SliderSize = "md";
|
|
27
|
+
export const SLIDER_DEFAULT_ORIENTATION: SliderOrientation = "horizontal";
|
|
28
|
+
|
|
29
|
+
/** The range a slider covers when the caller names none. */
|
|
30
|
+
export const SLIDER_MIN_VALUE = 0;
|
|
31
|
+
export const SLIDER_MAX_VALUE = 100;
|
|
32
|
+
|
|
33
|
+
/** The increment a slider snaps to when the caller names none. `0` is continuous. */
|
|
34
|
+
export const SLIDER_STEP = 1;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The spring the grabbed thumb grows on.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately `Pressable`'s own `PRESS_SPRING`: a thumb responding to a touch is
|
|
40
|
+
* the same event as a button responding to one, and two springs a frame apart
|
|
41
|
+
* would read as two things happening.
|
|
42
|
+
*/
|
|
43
|
+
export const SLIDER_THUMB_SPRING = { damping: 18, mass: 0.4, stiffness: 320 } as const;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How far the grabbed handle's knob travels on the scale axis.
|
|
47
|
+
*
|
|
48
|
+
* It **shrinks**, the way every other pressable in this library does, and it is
|
|
49
|
+
* the *knob* that moves rather than the capsule around it. Scaling the capsule
|
|
50
|
+
* would push it past the track it sits flush inside — a bulge on both edges every
|
|
51
|
+
* time a finger lands. The knob is a descendant with padding to spare, so the
|
|
52
|
+
* squeeze happens entirely within the handle's own footprint and the capsule
|
|
53
|
+
* stays exactly where the value says it is.
|
|
54
|
+
*/
|
|
55
|
+
export const SLIDER_THUMB_ANIMATION = { restScale: 1, grabbedScale: 0.9 } as const;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The `Text` size step each slider size hands its output.
|
|
59
|
+
*
|
|
60
|
+
* `Slider.Output` renders `Text.Label` and names this step rather than restating a
|
|
61
|
+
* type scale in the `output` slot. `Text`'s own size axis is built to beat its
|
|
62
|
+
* preset, so the weight and the colour stay in exactly one place — the preset —
|
|
63
|
+
* while the readout still tracks the slider's size. A test asserts every value
|
|
64
|
+
* here is a size `Text` actually has.
|
|
65
|
+
*/
|
|
66
|
+
export const SLIDER_OUTPUT_TEXT_SIZE: Record<SliderSize, TextSize> = { sm: "sm", md: "md", lg: "lg" };
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* How far a drag must travel between two haptic ticks, in points.
|
|
70
|
+
*
|
|
71
|
+
* A pan updates at the display's refresh rate, so "tick whenever the snapped
|
|
72
|
+
* value changed" is not a rate limit on its own: 0–100 in whole steps across a
|
|
73
|
+
* 300pt track is a step every three points, and a flick crosses a hundred of them
|
|
74
|
+
* in a fifth of a second. That is not a hundred ticks, it is a buzz — and a
|
|
75
|
+
* hundred synchronous calls into the haptic engine to produce it.
|
|
76
|
+
*
|
|
77
|
+
* Gating on distance rather than on a clock keeps the rule pure, so `bun test`
|
|
78
|
+
* reaches it, and makes it degrade the right way: a coarse step scale ticks on
|
|
79
|
+
* every stop, and a fine one thins out to a cadence a hand can actually feel.
|
|
80
|
+
*/
|
|
81
|
+
export const SLIDER_HAPTIC_MIN_TRAVEL = 6;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* What sits between the two ends of a range in `Slider.Output`.
|
|
85
|
+
*
|
|
86
|
+
* An en dash, not a hyphen. A hyphen beside a negative number reads as arithmetic
|
|
87
|
+
* — `-20 - -5` — and a range is exactly the case where that happens.
|
|
88
|
+
*/
|
|
89
|
+
export const SLIDER_RANGE_SEPARATOR = " – ";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Styling for every part of a slider.
|
|
93
|
+
*
|
|
94
|
+
* One slotted `tv()` rather than a call per part, because `slider-track.tsx`,
|
|
95
|
+
* `slider-fill.tsx` and `slider-thumb.tsx` cannot import the root without closing
|
|
96
|
+
* a cycle (AGENTS.md rule 3) yet all three read the same `color`, `size` and
|
|
97
|
+
* `orientation`.
|
|
98
|
+
*
|
|
99
|
+
* **The colour paints the fill, the capsule and the knob — never the groove.** An
|
|
100
|
+
* empty track is the same chrome at every colour, the way an unticked checkbox is
|
|
101
|
+
* `border-input bg-card` however it is coloured, and a test asserts that. The
|
|
102
|
+
* capsule takes the fill's *own* colour so the two meet with no seam and the
|
|
103
|
+
* handle reads as the leading end of the fill rather than as something sitting on
|
|
104
|
+
* top of it; the knob is that colour's `-foreground`, which is what the token
|
|
105
|
+
* means (rule 11) and what keeps it legible on all six. A single pale knob would
|
|
106
|
+
* be unreadable on `warning`, and a test pins the pair rather than trusting two
|
|
107
|
+
* maps to stay in step.
|
|
108
|
+
*
|
|
109
|
+
* **`default` and `primary` name different tokens that this theme tunes to the
|
|
110
|
+
* same value.** `foreground` is the page's ink and `primary` is the brand's
|
|
111
|
+
* action colour; they are `#262626` in both roles today, which is the situation
|
|
112
|
+
* `Badge` already documents for its neutral end. Modelling them as one token
|
|
113
|
+
* would be the drift, not the duplication: an app that re-themes `primary` to
|
|
114
|
+
* blue wants `color="primary"` blue and `color="default"` still ink.
|
|
115
|
+
*
|
|
116
|
+
* **The handle is two nodes: a capsule and a knob.** The capsule carries the
|
|
117
|
+
* colour, the size and the position; the knob is the pale bar inside it and the
|
|
118
|
+
* only thing that moves when a finger lands. Two nodes rather than one because a
|
|
119
|
+
* single view cannot be both the surface and the thing inset within it, and
|
|
120
|
+
* because two animated styles on one node fight for the same prop — see
|
|
121
|
+
* {@link SLIDER_THUMB_ANIMATION}.
|
|
122
|
+
*
|
|
123
|
+
* **Neither takes a shadow.** Nothing else in this package draws one, and React
|
|
124
|
+
* Native's shadow props diverge between platforms in a way a flat fill does not.
|
|
125
|
+
* A test pins that absence across the whole matrix. The capsule needs no border
|
|
126
|
+
* either: it is a solid block of the fill's colour, so its edge is already the
|
|
127
|
+
* boundary between the fill and the groove.
|
|
128
|
+
*
|
|
129
|
+
* **The capsule is flush across the track and longer along it**, and the flush
|
|
130
|
+
* half is load-bearing rather than decorative. It is what lets {@link fillExtent}
|
|
131
|
+
* land exactly on both extremes — one capsule's length of fill at the minimum, the
|
|
132
|
+
* track's full length at the maximum — with no inset to leave stray colour at one
|
|
133
|
+
* end and empty groove at the other. So the cross axis takes the track's own step
|
|
134
|
+
* and a test asserts the two classes name the same one, while the long axis is two
|
|
135
|
+
* steps up from it: a handle you can tell apart from the groove it runs in.
|
|
136
|
+
*
|
|
137
|
+
* Both are plain spacing steps rather than tokens: these are numbers read in one
|
|
138
|
+
* component, the trade `Radio` already makes for the dot inside its ring.
|
|
139
|
+
* `Checkbox` reads `--spacing-icon-*` for its square and should keep doing so — a
|
|
140
|
+
* glyph in a box is a mark on the icon scale, where a slider's handle is the body
|
|
141
|
+
* of the control itself.
|
|
142
|
+
*
|
|
143
|
+
* Because the two axes differ and only the orientation knows which is which, the
|
|
144
|
+
* capsule's size lives in the same six compound cells as the track's thickness
|
|
145
|
+
* rather than in a `size`-only variant.
|
|
146
|
+
*
|
|
147
|
+
* **The groove is not the touch target on its own.** A `sm` track is sixteen
|
|
148
|
+
* points, so the drag is claimed on a transparent `touchArea` whose padding brings
|
|
149
|
+
* it up to 44. The thickness and that padding live in the *same* compound cell
|
|
150
|
+
* because they are one number — they sum to 44 at every size, and a test asserts
|
|
151
|
+
* the sum rather than the parts. Split across two variants, a retune of the
|
|
152
|
+
* thickness silently shrinks the target.
|
|
153
|
+
*
|
|
154
|
+
* That padding is on the **cross axis only**. The two boxes therefore share an
|
|
155
|
+
* origin along the axis the value is measured on, which is what lets the pan read
|
|
156
|
+
* its offset straight off the touch without correcting for a gutter it cannot see.
|
|
157
|
+
* Pad the main axis and every value is wrong by the padding, silently, and
|
|
158
|
+
* visibly only at the ends.
|
|
159
|
+
*
|
|
160
|
+
* **The track still centres the thumb, and now has nothing to centre.** An
|
|
161
|
+
* absolutely-positioned child with no cross-axis inset is placed at the static
|
|
162
|
+
* position the parent's `items-center` decides — which did the work when the thumb
|
|
163
|
+
* overhung a hairline groove, and is a no-op now that the two are the same size.
|
|
164
|
+
* It stays because the moment those sizes are allowed to differ it is load-bearing
|
|
165
|
+
* again, and because it is why the track is `flex-row` when horizontal:
|
|
166
|
+
* `items-center` centres on the *cross* axis, and a column track would centre the
|
|
167
|
+
* wrong one.
|
|
168
|
+
*
|
|
169
|
+
* **The fade lands on the root**, which is a plain `View` here rather than a
|
|
170
|
+
* `Pressable` — so unlike `Radio`, an `opacity-50` class on it is not overwritten
|
|
171
|
+
* by an animated style writing `opacity` every frame.
|
|
172
|
+
*
|
|
173
|
+
* No slot worn by a `View` carries `text-*` or `font-*`: `Slider.Output` renders
|
|
174
|
+
* a `Text` preset and names a step — see {@link SLIDER_OUTPUT_TEXT_SIZE}.
|
|
175
|
+
*
|
|
176
|
+
* Free of React Native imports so it stays unit-testable — `bun test` cannot parse
|
|
177
|
+
* React Native's Flow-typed source. See AGENTS.md.
|
|
178
|
+
*/
|
|
179
|
+
export const sliderVariants = tv({
|
|
180
|
+
slots: {
|
|
181
|
+
/** The column holding the readout and the track. */
|
|
182
|
+
root: "",
|
|
183
|
+
/** Handed to a `Text.Label`. Layout only, and today there is none. */
|
|
184
|
+
output: "",
|
|
185
|
+
/** The transparent box the drag is claimed on. Padded on the cross axis only. */
|
|
186
|
+
touchArea: "items-center justify-center",
|
|
187
|
+
/** The groove. Positions the fill and the thumbs inside it. */
|
|
188
|
+
track: "relative items-center rounded-full bg-secondary",
|
|
189
|
+
/** The painted part of the groove. Its extent is an animated style, never a class. */
|
|
190
|
+
fill: "absolute rounded-full",
|
|
191
|
+
/** The handle's capsule. Painted in the fill's own colour, so the two join seamlessly. */
|
|
192
|
+
thumb: "absolute justify-center rounded-full p-0.5",
|
|
193
|
+
/** The pale bar inside the capsule, and the only part that moves under a finger. */
|
|
194
|
+
knob: "flex-1 rounded-full",
|
|
195
|
+
},
|
|
196
|
+
variants: {
|
|
197
|
+
orientation: {
|
|
198
|
+
horizontal: {
|
|
199
|
+
root: "w-full gap-2",
|
|
200
|
+
touchArea: "w-full flex-row",
|
|
201
|
+
track: "w-full flex-row",
|
|
202
|
+
fill: "bottom-0 top-0",
|
|
203
|
+
thumb: "left-0",
|
|
204
|
+
},
|
|
205
|
+
vertical: {
|
|
206
|
+
root: "h-full items-center gap-2",
|
|
207
|
+
touchArea: "h-full flex-col",
|
|
208
|
+
track: "h-full justify-end",
|
|
209
|
+
fill: "left-0 right-0",
|
|
210
|
+
thumb: "bottom-0",
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
// Empty because a capsule's two axes differ and only the orientation knows
|
|
214
|
+
// which is which — the six cells below carry both. Declared anyway so `size`
|
|
215
|
+
// stays a variant `tv` can type and default.
|
|
216
|
+
size: { sm: {}, md: {}, lg: {} },
|
|
217
|
+
// The empty branches are load-bearing typing, not placeholders. `tv` derives
|
|
218
|
+
// the prop type from the declared keys, so a map with only `true` types the
|
|
219
|
+
// prop as `true` rather than `boolean`.
|
|
220
|
+
color: { default: {}, primary: {}, success: {}, warning: {}, destructive: {}, info: {} },
|
|
221
|
+
isInvalid: { true: {}, false: {} },
|
|
222
|
+
isDisabled: { true: { root: "opacity-50" }, false: {} },
|
|
223
|
+
},
|
|
224
|
+
compoundVariants: [
|
|
225
|
+
// The groove's thickness is the one measurement that has to know the
|
|
226
|
+
// orientation, because it is the *cross* axis of whichever way the track
|
|
227
|
+
// runs. Six cells rather than a second size scale, so the two orientations
|
|
228
|
+
// cannot drift to different weights.
|
|
229
|
+
//
|
|
230
|
+
// The touch padding rides in the same cell, because the two are one number:
|
|
231
|
+
// they sum to 44pt at every size, and splitting them across two variants is
|
|
232
|
+
// how a retune of the thickness silently shrinks the target.
|
|
233
|
+
{ orientation: "horizontal", size: "sm", class: { track: "h-4", touchArea: "py-3.5", thumb: "h-4 w-6" } },
|
|
234
|
+
{ orientation: "horizontal", size: "md", class: { track: "h-5", touchArea: "py-3", thumb: "h-5 w-7" } },
|
|
235
|
+
{ orientation: "horizontal", size: "lg", class: { track: "h-6", touchArea: "py-2.5", thumb: "h-6 w-8" } },
|
|
236
|
+
{ orientation: "vertical", size: "sm", class: { track: "w-4", touchArea: "px-3.5", thumb: "w-4 h-6" } },
|
|
237
|
+
{ orientation: "vertical", size: "md", class: { track: "w-5", touchArea: "px-3", thumb: "w-5 h-7" } },
|
|
238
|
+
{ orientation: "vertical", size: "lg", class: { track: "w-6", touchArea: "px-2.5", thumb: "w-6 h-8" } },
|
|
239
|
+
// `color` is the only axis painting these three, so its six cells could be a
|
|
240
|
+
// plain variant. They are compounds so that `isInvalid` below, emitted after
|
|
241
|
+
// them, can beat every one — the ordering `Radio` leans on for its ring.
|
|
242
|
+
//
|
|
243
|
+
// The capsule takes the fill's own colour rather than a chrome of its own, so
|
|
244
|
+
// the two meet with no seam and the handle reads as the leading end of the
|
|
245
|
+
// fill rather than as something sitting on top of it. The knob is that
|
|
246
|
+
// colour's `-foreground`, which is what the token means (rule 11) and what
|
|
247
|
+
// keeps it legible on all six.
|
|
248
|
+
{ color: "default", class: { fill: "bg-foreground", thumb: "bg-foreground", knob: "bg-background" } },
|
|
249
|
+
{ color: "primary", class: { fill: "bg-primary", thumb: "bg-primary", knob: "bg-primary-foreground" } },
|
|
250
|
+
{ color: "success", class: { fill: "bg-success", thumb: "bg-success", knob: "bg-success-foreground" } },
|
|
251
|
+
{ color: "warning", class: { fill: "bg-warning", thumb: "bg-warning", knob: "bg-warning-foreground" } },
|
|
252
|
+
{
|
|
253
|
+
color: "destructive",
|
|
254
|
+
class: { fill: "bg-destructive", thumb: "bg-destructive", knob: "bg-destructive-foreground" },
|
|
255
|
+
},
|
|
256
|
+
{ color: "info", class: { fill: "bg-info", thumb: "bg-info", knob: "bg-info-foreground" } },
|
|
257
|
+
// Invalid outranks the colour, the way it does on a checkbox's border. A
|
|
258
|
+
// slider that stayed green while its value was rejected would drop its only
|
|
259
|
+
// signal exactly while the value is being corrected.
|
|
260
|
+
{ isInvalid: true, class: { fill: "bg-destructive", thumb: "bg-destructive", knob: "bg-destructive-foreground" } },
|
|
261
|
+
],
|
|
262
|
+
defaultVariants: {
|
|
263
|
+
color: SLIDER_DEFAULT_COLOR,
|
|
264
|
+
size: SLIDER_DEFAULT_SIZE,
|
|
265
|
+
orientation: SLIDER_DEFAULT_ORIENTATION,
|
|
266
|
+
isInvalid: false,
|
|
267
|
+
isDisabled: false,
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The caller's value, as the array every part reads.
|
|
273
|
+
*
|
|
274
|
+
* Always a copy: the array is handed to a shared value and written on the UI
|
|
275
|
+
* thread, and aliasing the caller's own would mutate state they still hold.
|
|
276
|
+
*/
|
|
277
|
+
export function toValueArray(value: number | number[]): number[] {
|
|
278
|
+
"worklet";
|
|
279
|
+
return Array.isArray(value) ? [...value] : [value];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The array, back in the shape the caller passed in.
|
|
284
|
+
*
|
|
285
|
+
* A slider given a number reports a number; one given an array reports an array.
|
|
286
|
+
* `isRange` is locked on first render rather than sniffed per call — see
|
|
287
|
+
* `slider.tsx` — so the shape a caller receives never changes under them.
|
|
288
|
+
*/
|
|
289
|
+
export function fromValueArray(values: readonly number[], isRange: boolean): number | number[] {
|
|
290
|
+
if (isRange) return [...values];
|
|
291
|
+
return values[0] ?? 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Where a value sits on the track, as 0–1.
|
|
296
|
+
*
|
|
297
|
+
* Clamped rather than extrapolated, and `0` for a degenerate range — a slider
|
|
298
|
+
* whose minimum equals its maximum has one position, and dividing by the
|
|
299
|
+
* difference would put `NaN` into a shared value and freeze the thumb for good.
|
|
300
|
+
*/
|
|
301
|
+
export function progressOf(value: number, minValue: number, maxValue: number): number {
|
|
302
|
+
"worklet";
|
|
303
|
+
const span = maxValue - minValue;
|
|
304
|
+
if (span <= 0) return 0;
|
|
305
|
+
const ratio = (value - minValue) / span;
|
|
306
|
+
return ratio < 0 ? 0 : ratio > 1 ? 1 : ratio;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The nearest reachable value, measured in steps **from the minimum**.
|
|
311
|
+
*
|
|
312
|
+
* Not from zero: a slider from 5 to 100 in tens reaches 5, 15, 25 — the stops a
|
|
313
|
+
* caller can actually land on — rather than 10, 20, 30 with an unreachable 5 at
|
|
314
|
+
* the end.
|
|
315
|
+
*
|
|
316
|
+
* **Both ends are always stops, even when the step does not divide the range.**
|
|
317
|
+
* 0–100 by 7 reaches 0, 7, 14 … 98 and then 100, because a slider whose maximum
|
|
318
|
+
* cannot be reached by dragging all the way to the end is a slider that lies about
|
|
319
|
+
* its own range — the caller wrote `maxValue`, and the last two points of travel
|
|
320
|
+
* are not where they get told it was unreachable. A tie goes to the regular stop,
|
|
321
|
+
* so the extra one only ever appears at the very end of the drag.
|
|
322
|
+
*
|
|
323
|
+
* `step <= 0` means continuous and passes the clamped value through.
|
|
324
|
+
*
|
|
325
|
+
* The final rounding kills binary floating-point noise: ten steps of `0.1` land on
|
|
326
|
+
* `0.30000000000000004` without it, which a formatted readout shows in full.
|
|
327
|
+
*/
|
|
328
|
+
export function snapToStep(value: number, step: number, minValue: number, maxValue: number): number {
|
|
329
|
+
"worklet";
|
|
330
|
+
const clamped = value < minValue ? minValue : value > maxValue ? maxValue : value;
|
|
331
|
+
if (step <= 0) return clamped;
|
|
332
|
+
|
|
333
|
+
const snapped = minValue + Math.round((clamped - minValue) / step) * step;
|
|
334
|
+
const bounded = snapped < minValue ? minValue : snapped > maxValue ? maxValue : snapped;
|
|
335
|
+
const rounded = Math.round(bounded * 1e10) / 1e10;
|
|
336
|
+
|
|
337
|
+
return Math.abs(clamped - maxValue) < Math.abs(clamped - rounded) ? maxValue : rounded;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* One thumb's value, held inside the range and inside its own neighbours.
|
|
342
|
+
*
|
|
343
|
+
* The outer thumbs are bounded by the range; an inner one by the thumbs either
|
|
344
|
+
* side of it. This is the whole of what makes a range a range — without it a drag
|
|
345
|
+
* past a neighbour reorders the values and the fill inverts.
|
|
346
|
+
*/
|
|
347
|
+
export function clampThumb(
|
|
348
|
+
value: number,
|
|
349
|
+
values: readonly number[],
|
|
350
|
+
index: number,
|
|
351
|
+
minValue: number,
|
|
352
|
+
maxValue: number
|
|
353
|
+
): number {
|
|
354
|
+
"worklet";
|
|
355
|
+
const lower = index > 0 ? (values[index - 1] ?? minValue) : minValue;
|
|
356
|
+
const upper = index < values.length - 1 ? (values[index + 1] ?? maxValue) : maxValue;
|
|
357
|
+
const floor = lower < minValue ? minValue : lower;
|
|
358
|
+
const ceiling = upper > maxValue ? maxValue : upper;
|
|
359
|
+
return value < floor ? floor : value > ceiling ? ceiling : value;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The value a touch at `position` points at.
|
|
364
|
+
*
|
|
365
|
+
* `position` is the touch's offset along the track with the thumb's own half-width
|
|
366
|
+
* already taken off, and `travel` is what the thumb's leading edge can cover —
|
|
367
|
+
* `trackSize - thumbSize`. Working in the thumb's own frame is what keeps the
|
|
368
|
+
* handle inside the groove at both ends instead of overhanging them.
|
|
369
|
+
*
|
|
370
|
+
* **A vertical track is inverted here**, and in exactly one other place — the sign
|
|
371
|
+
* of the thumb's translate. A touch offset is measured from the top and a vertical
|
|
372
|
+
* slider counts up from the bottom, so one of the two has to turn around, and one
|
|
373
|
+
* place deciding it is what keeps them agreeing.
|
|
374
|
+
*
|
|
375
|
+
* `travel <= 0` returns the minimum rather than dividing: the track reports its
|
|
376
|
+
* size on layout, so every frame before the first one has nothing to divide by.
|
|
377
|
+
*/
|
|
378
|
+
export function valueFromOffset({
|
|
379
|
+
position,
|
|
380
|
+
travel,
|
|
381
|
+
minValue,
|
|
382
|
+
maxValue,
|
|
383
|
+
isVertical,
|
|
384
|
+
}: {
|
|
385
|
+
position: number;
|
|
386
|
+
travel: number;
|
|
387
|
+
minValue: number;
|
|
388
|
+
maxValue: number;
|
|
389
|
+
isVertical: boolean;
|
|
390
|
+
}): number {
|
|
391
|
+
"worklet";
|
|
392
|
+
if (travel <= 0) return minValue;
|
|
393
|
+
|
|
394
|
+
const raw = position / travel;
|
|
395
|
+
const oriented = isVertical ? 1 - raw : raw;
|
|
396
|
+
const ratio = oriented < 0 ? 0 : oriented > 1 ? 1 : oriented;
|
|
397
|
+
return minValue + ratio * (maxValue - minValue);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Which thumb a touch grabs.
|
|
402
|
+
*
|
|
403
|
+
* A tie resolves to the lower index, and it has to resolve to *something* fixed:
|
|
404
|
+
* pressing the exact midpoint between two thumbs is a real gesture, and which one
|
|
405
|
+
* moves must not depend on iteration order changing under a refactor.
|
|
406
|
+
*/
|
|
407
|
+
export function nearestThumbIndex(values: readonly number[], target: number): number {
|
|
408
|
+
"worklet";
|
|
409
|
+
let nearest = 0;
|
|
410
|
+
let shortest = Number.POSITIVE_INFINITY;
|
|
411
|
+
|
|
412
|
+
for (let index = 0; index < values.length; index++) {
|
|
413
|
+
const distance = Math.abs((values[index] ?? 0) - target);
|
|
414
|
+
if (distance < shortest) {
|
|
415
|
+
shortest = distance;
|
|
416
|
+
nearest = index;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return nearest;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* The stretch of track the fill covers, as two 0–1 positions.
|
|
425
|
+
*
|
|
426
|
+
* A lone thumb fills from the minimum, because that is what a single value means:
|
|
427
|
+
* how far along. A range fills *between* its own thumbs, because the ends are what
|
|
428
|
+
* the caller excluded.
|
|
429
|
+
*
|
|
430
|
+
* The outermost thumbs are taken by value rather than by index, so a caller who
|
|
431
|
+
* hands over a descending array still gets a fill with a positive extent instead
|
|
432
|
+
* of one drawn backwards. The drag itself keeps them in order — see
|
|
433
|
+
* {@link clampThumb} — but the first render happens before any drag.
|
|
434
|
+
*/
|
|
435
|
+
export function fillBounds(
|
|
436
|
+
values: readonly number[],
|
|
437
|
+
minValue: number,
|
|
438
|
+
maxValue: number
|
|
439
|
+
): { start: number; end: number } {
|
|
440
|
+
"worklet";
|
|
441
|
+
const span = maxValue - minValue;
|
|
442
|
+
if (values.length === 0 || span <= 0) return { start: 0, end: 0 };
|
|
443
|
+
|
|
444
|
+
let lowest = values[0] ?? minValue;
|
|
445
|
+
let highest = lowest;
|
|
446
|
+
for (const value of values) {
|
|
447
|
+
if (value < lowest) lowest = value;
|
|
448
|
+
if (value > highest) highest = value;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// The progress maths is written out rather than calling `progressOf`. A
|
|
452
|
+
// module-scope worklet calling another one binds its closure once, at module
|
|
453
|
+
// init, in source order — so the pair works only while the callee happens to be
|
|
454
|
+
// declared first, and a tidy-up that reorders the file crashes the UI thread
|
|
455
|
+
// with `undefined is not a function`. Every exported worklet here stays flat,
|
|
456
|
+
// which is the conclusion `screen.variants.ts` reached the hard way.
|
|
457
|
+
const start = values.length > 1 ? Math.min(Math.max((lowest - minValue) / span, 0), 1) : 0;
|
|
458
|
+
const end = Math.min(Math.max((highest - minValue) / span, 0), 1);
|
|
459
|
+
|
|
460
|
+
return { start, end: Math.max(start, end) };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The fill's box in points: where it starts along the track, and how long it is.
|
|
465
|
+
*
|
|
466
|
+
* Takes {@link fillBounds}' two 0–1 positions into the track's own frame. It is a
|
|
467
|
+
* function rather than four lines inside `slider-fill.tsx` because the property it
|
|
468
|
+
* encodes is not self-evident and is the whole reason the thumb is drawn the size
|
|
469
|
+
* it is — a `+ thumbSize` at the far end, which lands exactly on both extremes:
|
|
470
|
+
*
|
|
471
|
+
* - at the **minimum** the extent is one thumb, so the handle covers the fill
|
|
472
|
+
* completely and a slider at rest shows a plain track;
|
|
473
|
+
* - at the **maximum** the extent is `travel + thumbSize`, the track's full
|
|
474
|
+
* length, with no sliver of empty groove past the handle;
|
|
475
|
+
* - a **collapsed range** is one thumb wide rather than zero, so the fill does not
|
|
476
|
+
* blink out from under two thumbs dragged together.
|
|
477
|
+
*
|
|
478
|
+
* All three hold only because the thumb's diameter equals the track's thickness.
|
|
479
|
+
* Inset the thumb inside the track and every one of them is off by the inset — a
|
|
480
|
+
* few points of stray colour at one end and of empty groove at the other, at every
|
|
481
|
+
* size. This is the arithmetic that pays for that proportion, so it is the
|
|
482
|
+
* arithmetic `bun test` has to be able to reach.
|
|
483
|
+
*
|
|
484
|
+
* It replaced a `+ thumbSize / 2`, which stopped the fill at the thumb's *centre*.
|
|
485
|
+
* That was correct but invisible while a large disc overhung a hairline groove; it
|
|
486
|
+
* would now leave the last half-thumb of the bar unfilled, in plain view.
|
|
487
|
+
*
|
|
488
|
+
* `travel <= 0` draws nothing: a measured `0` means *not measured yet*, and a bar
|
|
489
|
+
* sized off it would flash at a garbage length on the frame before layout lands.
|
|
490
|
+
*/
|
|
491
|
+
export function fillExtent({
|
|
492
|
+
start,
|
|
493
|
+
end,
|
|
494
|
+
travel,
|
|
495
|
+
thumbSize,
|
|
496
|
+
isRange,
|
|
497
|
+
}: {
|
|
498
|
+
start: number;
|
|
499
|
+
end: number;
|
|
500
|
+
travel: number;
|
|
501
|
+
thumbSize: number;
|
|
502
|
+
isRange: boolean;
|
|
503
|
+
}): { offset: number; extent: number } {
|
|
504
|
+
"worklet";
|
|
505
|
+
if (travel <= 0) return { offset: 0, extent: 0 };
|
|
506
|
+
|
|
507
|
+
const from = isRange ? Math.min(Math.max(start, 0), 1) : 0;
|
|
508
|
+
const to = Math.min(Math.max(end, 0), 1);
|
|
509
|
+
const offset = from * travel;
|
|
510
|
+
const extent = Math.max(to * travel + thumbSize - offset, thumbSize);
|
|
511
|
+
|
|
512
|
+
return { offset, extent };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* The values as one readable string.
|
|
517
|
+
*
|
|
518
|
+
* JS-thread only — `Intl` is not available to a worklet, which is the reason
|
|
519
|
+
* `Slider.Output` reads React state rather than the shared value the thumb reads.
|
|
520
|
+
*/
|
|
521
|
+
export function formatSliderValue(values: readonly number[], formatOptions?: Intl.NumberFormatOptions): string {
|
|
522
|
+
if (values.length === 0) return "";
|
|
523
|
+
const format = new Intl.NumberFormat(undefined, formatOptions);
|
|
524
|
+
return values.map((value) => format.format(value)).join(SLIDER_RANGE_SEPARATOR);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Whether crossing into `snapped` should tick.
|
|
529
|
+
*
|
|
530
|
+
* Four rules, in order:
|
|
531
|
+
*
|
|
532
|
+
* - a **continuous** slider never ticks. There is no stop to land on, so a tick
|
|
533
|
+
* would be reporting the refresh rate rather than the value.
|
|
534
|
+
* - a value that did not change does not tick.
|
|
535
|
+
* - either **end of the range** always ticks, however fast the drag arrived. It
|
|
536
|
+
* is the one moment a slider has something to say that the screen does not
|
|
537
|
+
* already show — the thumb has stopped and the finger has not.
|
|
538
|
+
* - otherwise the drag must have travelled {@link SLIDER_HAPTIC_MIN_TRAVEL}
|
|
539
|
+
* points since the last tick.
|
|
540
|
+
*
|
|
541
|
+
* Pure, so the whole ladder is reachable from `bun test`, and flat, so it is safe
|
|
542
|
+
* to call from the pan's worklet. See AGENTS.md.
|
|
543
|
+
*/
|
|
544
|
+
export function shouldTickHaptic({
|
|
545
|
+
step,
|
|
546
|
+
snapped,
|
|
547
|
+
lastSnapped,
|
|
548
|
+
position,
|
|
549
|
+
lastPosition,
|
|
550
|
+
minValue,
|
|
551
|
+
maxValue,
|
|
552
|
+
}: {
|
|
553
|
+
step: number;
|
|
554
|
+
snapped: number;
|
|
555
|
+
lastSnapped: number;
|
|
556
|
+
position: number;
|
|
557
|
+
lastPosition: number;
|
|
558
|
+
minValue: number;
|
|
559
|
+
maxValue: number;
|
|
560
|
+
}): boolean {
|
|
561
|
+
"worklet";
|
|
562
|
+
if (step <= 0) return false;
|
|
563
|
+
if (snapped === lastSnapped) return false;
|
|
564
|
+
if (snapped === minValue || snapped === maxValue) return true;
|
|
565
|
+
return Math.abs(position - lastPosition) >= SLIDER_HAPTIC_MIN_TRAVEL;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** What a slider was given at its own call site. */
|
|
569
|
+
export type SliderOwnAxes = {
|
|
570
|
+
color?: SliderColor;
|
|
571
|
+
size?: SliderSize;
|
|
572
|
+
orientation?: SliderOrientation;
|
|
573
|
+
isDisabled?: boolean;
|
|
574
|
+
isInvalid?: boolean;
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
/** What an enclosing `Field` publishes, or null outside one. */
|
|
578
|
+
export type SliderFieldAxes = { isDisabled?: boolean; isInvalid?: boolean };
|
|
579
|
+
|
|
580
|
+
/** Every axis settled, ready to hand to {@link sliderVariants} and to context. */
|
|
581
|
+
export type SliderAxes = Required<SliderOwnAxes>;
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Settles a slider's axes from the two places they can come from.
|
|
585
|
+
*
|
|
586
|
+
* There is no `Slider.Group`, so the ladder is two rungs rather than three: the
|
|
587
|
+
* slider's own props, then an enclosing `Field`. A slider inside `<Field isDisabled>`
|
|
588
|
+
* dims with nothing said at the call site, and `isDisabled={false}` opts that one
|
|
589
|
+
* out — `??` throughout and never `||`, so an explicit `false` is a value rather
|
|
590
|
+
* than an absence.
|
|
591
|
+
*
|
|
592
|
+
* A `Field` reaches the two *state* axes only. It carries no colour, size or
|
|
593
|
+
* orientation, and a test pins that it cannot acquire one by accident.
|
|
594
|
+
*
|
|
595
|
+
* Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
|
|
596
|
+
*/
|
|
597
|
+
export function resolveSliderAxes({ own, field }: { own?: SliderOwnAxes; field?: SliderFieldAxes | null }): SliderAxes {
|
|
598
|
+
return {
|
|
599
|
+
color: own?.color ?? SLIDER_DEFAULT_COLOR,
|
|
600
|
+
size: own?.size ?? SLIDER_DEFAULT_SIZE,
|
|
601
|
+
orientation: own?.orientation ?? SLIDER_DEFAULT_ORIENTATION,
|
|
602
|
+
isDisabled: own?.isDisabled ?? field?.isDisabled ?? false,
|
|
603
|
+
isInvalid: own?.isInvalid ?? field?.isInvalid ?? false,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export type SliderVariantProps = VariantProps<typeof sliderVariants>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Spinner
|
|
2
|
+
|
|
3
|
+
An animated loading indicator. Compound root plus `Spinner.Content`, the part
|
|
4
|
+
that rotates.
|
|
5
|
+
|
|
6
|
+
`import { Spinner } from "@delacour/react-native-ui/spinner";`
|
|
7
|
+
|
|
8
|
+
## Files
|
|
9
|
+
|
|
10
|
+
| File | What it holds |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| `index.ts` | → `@delacour/react-native-ui/spinner` |
|
|
13
|
+
| `spinner.tsx` | Root + the `Object.assign` compound surface |
|
|
14
|
+
| `spinner-content.tsx` | `Spinner.Content`, the rotating layer |
|
|
15
|
+
| `spinner-arc.tsx` | The default arc glyph |
|
|
16
|
+
| `spinner.context.tsx` | `SpinnerContext`, `useSpinner()`, `useSpinnerContext()` |
|
|
17
|
+
| `spinner.variants.ts` | Pure `tv()` slots + resolvers, no RN imports |
|
|
18
|
+
| `spinner.variants.test.ts` | |
|
|
19
|
+
|
|
20
|
+
## Design
|
|
21
|
+
|
|
22
|
+
- **Sizes**: the icon scale, shared outright — `SPINNER_SIZES` *is*
|
|
23
|
+
`ICON_SIZES`, `xs`…`2xl` (14/16/18/20/24/32pt) — or an explicit number. A
|
|
24
|
+
spinner stands in for an icon, so the two must agree on what `md` means. **The root is the only sized box** — `Spinner.Content` is
|
|
25
|
+
`size-full` and the arc carries no width or height at all, because
|
|
26
|
+
react-native-svg resolves both to `'100%'` when neither is set. That
|
|
27
|
+
`size-full` is load-bearing: content-size the middle layer and the percentage
|
|
28
|
+
resolves against an indefinite parent, collapsing the glyph to zero.
|
|
29
|
+
**Colours**: `default`, `success`, `warning`, `destructive`, plus any token the
|
|
30
|
+
theme emits (`primary-foreground`, `muted-foreground`) or a literal
|
|
31
|
+
(`#EC4899`). A Tailwind palette name like `emerald-500` only resolves if some
|
|
32
|
+
utility class already pulled that variable into the build — otherwise the
|
|
33
|
+
token is unresolved and nothing is drawn. Prefer the semantic tokens.
|
|
34
|
+
- **Size and colour are inherited, not passed** — through exactly one path, the
|
|
35
|
+
nearest `IconDefaultsProvider`, falling back to `md` on `foreground`. A
|
|
36
|
+
[`Button`](../button/AGENTS.md) already wraps the spinner it composes in with
|
|
37
|
+
its own icon class and variant foreground, so the spinner does **not** read
|
|
38
|
+
the button's context: a second path would recompute the same two values and
|
|
39
|
+
could drift from them. An explicit `size` or `color` always wins — the
|
|
40
|
+
precedence [`Icon`](../icon/AGENTS.md) follows.
|
|
41
|
+
- **Any child is the glyph**, wrapped in a `Spinner.Content` automatically so it
|
|
42
|
+
still rotates — a custom icon or asset needs nothing but to be passed in.
|
|
43
|
+
`Spinner.Content` is the rotating layer itself, so write it out by hand only
|
|
44
|
+
when that layer needs styling.
|
|
45
|
+
- **`speed` belongs to the root**, not to `Spinner.Content`. It rides the
|
|
46
|
+
spinner's context alongside the resolved size and colour, so every part of the
|
|
47
|
+
spinner turns at one rate and a caller never reaches past the root to set it:
|
|
48
|
+
`<Spinner speed={0.7}>` — 1 is one full turn per 900ms.
|
|
49
|
+
- **The default glyph is drawn from SVG primitives**, not a Central Icon — the
|
|
50
|
+
set has no loader glyph. Rule 5 governs *icons*; primitives are fine.
|
|
51
|
+
- **The arc's caps are `butt`, and its round head is a separate `Circle`.** The
|
|
52
|
+
two half-rings share the endpoint at the bottom of the ring, where both sit at
|
|
53
|
+
`SPINNER_ARC_JOINT_OPACITY`. Round caps there stack, and two semi-transparent
|
|
54
|
+
discs composite to roughly 0.8 alpha — a bright dot straddling the joint,
|
|
55
|
+
opposite the head, at every size. Butt caps abut instead, and the one end that
|
|
56
|
+
wants rounding gets a disc of its own, drawn last so it sits on top; the tail
|
|
57
|
+
terminates fully transparent at that same point, so its flat end is invisible.
|
|
58
|
+
Do not put `strokeLinecap="round"` back.
|
|
59
|
+
- **The gradient stops are angle-compensated, and run in user space.** A linear
|
|
60
|
+
gradient fades along its axis and that axis is `y`, but a point at angle θ
|
|
61
|
+
clockwise from the top sits at `y = 12 - 10·cos θ` — so two stops alone make
|
|
62
|
+
the fade stall near 3 and 9 o'clock and race through 12 and 6, and the ring
|
|
63
|
+
reads as a bright chunk beside a flat grey quadrant rather than as an even
|
|
64
|
+
comet. `spinnerArcStops` places each stop at the offset the arc actually
|
|
65
|
+
occupies at that angle while stepping the opacity evenly, which inverts the
|
|
66
|
+
skew; it is pure, so `bun test` pins the whole ladder.
|
|
67
|
+
`gradientUnits="userSpaceOnUse"` then keeps the endpoints exact, where an
|
|
68
|
+
object bounding box would leave open whether a given renderer includes the
|
|
69
|
+
stroke — which shifts both ends off 0 and 1 and clips the head and the tail.
|
|
70
|
+
- **The rotation sets `ReduceMotion.Never` deliberately.** Under the default
|
|
71
|
+
`System` policy `withTiming` completes instantly while the OS reduce-motion
|
|
72
|
+
setting is on, so `withRepeat(-1)` would spin a zero-length animation forever.
|
|
73
|
+
A status indicator is not decorative motion.
|