@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,161 @@
|
|
|
1
|
+
import { type ComponentProps, type ReactElement, useCallback } from "react";
|
|
2
|
+
import type { AccessibilityActionEvent, LayoutChangeEvent, ViewProps } from "react-native";
|
|
3
|
+
import Animated, { useAnimatedStyle, useDerivedValue, withSpring } from "react-native-reanimated";
|
|
4
|
+
import { useSliderPart } from "./slider.context";
|
|
5
|
+
import {
|
|
6
|
+
formatSliderValue,
|
|
7
|
+
progressOf,
|
|
8
|
+
SLIDER_THUMB_ANIMATION,
|
|
9
|
+
SLIDER_THUMB_SPRING,
|
|
10
|
+
sliderVariants,
|
|
11
|
+
} from "./slider.variants";
|
|
12
|
+
|
|
13
|
+
/** What one press of an assistive increment moves the value by, when `step` is continuous. */
|
|
14
|
+
const CONTINUOUS_ACCESSIBILITY_STEPS = 10;
|
|
15
|
+
|
|
16
|
+
export type SliderThumbProps = Omit<ViewProps, "children" | "style"> & {
|
|
17
|
+
/** Which value this thumb drives. `0` unless the slider holds a range. */
|
|
18
|
+
index?: number;
|
|
19
|
+
className?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Passed to the knob inside the capsule.
|
|
22
|
+
*
|
|
23
|
+
* A prop rather than a `classNames` map, which is the shape `Radio.Indicator`
|
|
24
|
+
* already uses for its dot: one escape hatch, named after the thing it reaches,
|
|
25
|
+
* and it carries every `View` prop rather than only a class.
|
|
26
|
+
*/
|
|
27
|
+
knobProps?: Omit<ComponentProps<typeof Animated.View>, "children" | "className" | "style"> & { className?: string };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The grab handle.
|
|
32
|
+
*
|
|
33
|
+
* **It holds no gesture of its own**, which is the design rather than an
|
|
34
|
+
* omission. One `Gesture.Pan()` on the track drives every thumb: a press 40pt
|
|
35
|
+
* along an empty groove should still lift the thumb it is about to move, and a
|
|
36
|
+
* per-thumb gesture could not know that. It would also nest a descendant handler
|
|
37
|
+
* inside the track's, leaving two recognisers to negotiate for one drag.
|
|
38
|
+
*
|
|
39
|
+
* **One animated style, one node.** The position and the grabbed scale are two
|
|
40
|
+
* entries in a single `transform`, because two `useAnimatedStyle` calls on one
|
|
41
|
+
* view fight for the same prop and the later one silently wins — the rule
|
|
42
|
+
* `Radio.Indicator` states, and the reason `Pressable` cannot be the thing that
|
|
43
|
+
* moves here.
|
|
44
|
+
*
|
|
45
|
+
* The scale is a `useDerivedValue` rather than a branch inside the position
|
|
46
|
+
* style: it depends only on which thumb is being dragged, so deriving it
|
|
47
|
+
* separately means the spring is evaluated when the grab changes rather than on
|
|
48
|
+
* every frame of the drag.
|
|
49
|
+
*
|
|
50
|
+
* **It is invisible until the track has been measured.** Both layouts arrive a
|
|
51
|
+
* commit or two after mount, and a thumb drawn before then sits at a garbage
|
|
52
|
+
* offset for a frame — which reads as a flicker on every mount.
|
|
53
|
+
*
|
|
54
|
+
* **The accessibility surface lives here**, and it is the whole of the control
|
|
55
|
+
* for anyone not using a finger. `adjustable` plus a value is what VoiceOver and
|
|
56
|
+
* TalkBack read; the increment and decrement actions are what their swipe
|
|
57
|
+
* gestures call. Without them a slider with no gesture on its thumb would have no
|
|
58
|
+
* assistive path to its value at all.
|
|
59
|
+
*/
|
|
60
|
+
export function SliderThumb({ index = 0, className, knobProps, ...props }: SliderThumbProps): ReactElement {
|
|
61
|
+
const {
|
|
62
|
+
positions,
|
|
63
|
+
trackSize,
|
|
64
|
+
thumbSize,
|
|
65
|
+
activeIndex,
|
|
66
|
+
values,
|
|
67
|
+
minValue,
|
|
68
|
+
maxValue,
|
|
69
|
+
step,
|
|
70
|
+
formatOptions,
|
|
71
|
+
orientation,
|
|
72
|
+
color,
|
|
73
|
+
size,
|
|
74
|
+
isInvalid,
|
|
75
|
+
isDisabled,
|
|
76
|
+
updateValue,
|
|
77
|
+
} = useSliderPart("Slider.Thumb");
|
|
78
|
+
const isVertical = orientation === "vertical";
|
|
79
|
+
const slots = sliderVariants({ color, isDisabled, isInvalid, orientation, size });
|
|
80
|
+
|
|
81
|
+
const handleLayout = useCallback(
|
|
82
|
+
(event: LayoutChangeEvent) => {
|
|
83
|
+
// Every thumb writes the same number — they are one size — so the last
|
|
84
|
+
// one to lay out wins with the value all of them hold.
|
|
85
|
+
thumbSize.value = isVertical ? event.nativeEvent.layout.height : event.nativeEvent.layout.width;
|
|
86
|
+
},
|
|
87
|
+
[isVertical, thumbSize]
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// The capsule carries the position and the knob carries the squeeze, on two
|
|
91
|
+
// different nodes. Two animated styles on *one* node fight for the same prop
|
|
92
|
+
// and the later one silently wins — the rule `Radio.Indicator` states — and
|
|
93
|
+
// scaling the capsule would push it past the track it sits flush inside.
|
|
94
|
+
const thumbStyle = useAnimatedStyle(() => {
|
|
95
|
+
const measured = trackSize.value;
|
|
96
|
+
const travel = measured - thumbSize.value;
|
|
97
|
+
const progress = progressOf(positions.value[index] ?? minValue, minValue, maxValue);
|
|
98
|
+
const offset = travel <= 0 ? 0 : progress * travel;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
opacity: measured > 0 ? 1 : 0,
|
|
102
|
+
// A vertical slider counts up from the bottom, so the handle is anchored
|
|
103
|
+
// there and travels the other way. This sign and `valueFromOffset`'s
|
|
104
|
+
// inversion are the only two places the axis turns around.
|
|
105
|
+
transform: isVertical ? [{ translateY: -offset }] : [{ translateX: offset }],
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Derived rather than branched inside the style above: it depends only on which
|
|
110
|
+
// thumb is being dragged, so the spring is evaluated when the grab changes
|
|
111
|
+
// rather than on every frame of the drag.
|
|
112
|
+
const scale = useDerivedValue(() =>
|
|
113
|
+
withSpring(
|
|
114
|
+
activeIndex.value === index ? SLIDER_THUMB_ANIMATION.grabbedScale : SLIDER_THUMB_ANIMATION.restScale,
|
|
115
|
+
SLIDER_THUMB_SPRING
|
|
116
|
+
)
|
|
117
|
+
);
|
|
118
|
+
const knobStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
|
119
|
+
|
|
120
|
+
const value = values[index] ?? minValue;
|
|
121
|
+
const nudge = step > 0 ? step : (maxValue - minValue) / CONTINUOUS_ACCESSIBILITY_STEPS;
|
|
122
|
+
|
|
123
|
+
const handleAccessibilityAction = useCallback(
|
|
124
|
+
(event: AccessibilityActionEvent) => {
|
|
125
|
+
const direction = event.nativeEvent.actionName === "increment" ? 1 : -1;
|
|
126
|
+
updateValue(index, value + direction * nudge);
|
|
127
|
+
},
|
|
128
|
+
[index, nudge, updateValue, value]
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<Animated.View
|
|
133
|
+
accessibilityActions={ACCESSIBILITY_ACTIONS}
|
|
134
|
+
accessibilityRole="adjustable"
|
|
135
|
+
accessibilityState={{ disabled: isDisabled }}
|
|
136
|
+
accessibilityValue={{
|
|
137
|
+
max: maxValue,
|
|
138
|
+
min: minValue,
|
|
139
|
+
now: value,
|
|
140
|
+
text: formatSliderValue([value], formatOptions),
|
|
141
|
+
}}
|
|
142
|
+
accessible
|
|
143
|
+
className={slots.thumb({ className })}
|
|
144
|
+
onAccessibilityAction={handleAccessibilityAction}
|
|
145
|
+
onLayout={handleLayout}
|
|
146
|
+
style={thumbStyle}
|
|
147
|
+
{...props}
|
|
148
|
+
>
|
|
149
|
+
<Animated.View {...knobProps} className={slots.knob({ className: knobProps?.className })} style={knobStyle} />
|
|
150
|
+
</Animated.View>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
SliderThumb.displayName = "DelacourUI.Slider.Thumb";
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The two actions an assistive swipe on an `adjustable` maps to.
|
|
157
|
+
*
|
|
158
|
+
* Module scope on purpose: a fresh array each render is a new prop every commit,
|
|
159
|
+
* and React Native sends the whole accessibility config across on any change.
|
|
160
|
+
*/
|
|
161
|
+
const ACCESSIBILITY_ACTIONS = [{ name: "increment" }, { name: "decrement" }] as const;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { type ReactElement, useCallback, useMemo } from "react";
|
|
2
|
+
import type { LayoutChangeEvent, ViewProps } from "react-native";
|
|
3
|
+
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
4
|
+
import Animated, { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
|
|
5
|
+
import { scheduleOnRN } from "react-native-worklets";
|
|
6
|
+
import { playHaptic } from "../pressable";
|
|
7
|
+
import { useSliderPart } from "./slider.context";
|
|
8
|
+
import type { SliderRenderChildren } from "./slider.types";
|
|
9
|
+
import {
|
|
10
|
+
clampThumb,
|
|
11
|
+
nearestThumbIndex,
|
|
12
|
+
shouldTickHaptic,
|
|
13
|
+
sliderVariants,
|
|
14
|
+
snapToStep,
|
|
15
|
+
valueFromOffset,
|
|
16
|
+
} from "./slider.variants";
|
|
17
|
+
|
|
18
|
+
export type SliderTrackProps = Omit<ViewProps, "children" | "style"> & {
|
|
19
|
+
/**
|
|
20
|
+
* The fill and the thumbs. A function is called with the slider's settled
|
|
21
|
+
* state, which is how a range maps over its own values.
|
|
22
|
+
*/
|
|
23
|
+
children?: SliderRenderChildren;
|
|
24
|
+
className?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The groove the thumbs run along, and the surface the drag is claimed on.
|
|
29
|
+
*
|
|
30
|
+
* This is where the whole gesture lives. One `Gesture.Pan()` drives every thumb:
|
|
31
|
+
* touching down grabs the nearest one and moves it to the finger, and from there
|
|
32
|
+
* the drag tracks it. One rule rather than a tap mode and a drag mode — the
|
|
33
|
+
* alternative, dragging the handle only, is what iOS's own slider does and it is
|
|
34
|
+
* a poor trade on a wide track, where the thumb is usually nowhere near where you
|
|
35
|
+
* are pointing.
|
|
36
|
+
*
|
|
37
|
+
* **The value is written in `onBegin`, not only in `onUpdate`.** A pan activates
|
|
38
|
+
* on the first *movement*, so a stationary tap never reaches `onStart` or
|
|
39
|
+
* `onUpdate` at all — a slider that only computed there would tick, lift its
|
|
40
|
+
* thumb and then not move it. `onFinalize` is likewise where the drag is reported
|
|
41
|
+
* as finished, because it is the one callback that fires on every path, including
|
|
42
|
+
* the one where the pan never activated.
|
|
43
|
+
*
|
|
44
|
+
* **`minDistance(0)` is what wins the touch from a scroll view.**
|
|
45
|
+
* `Screen.ScrollArea` renders React Native's own `ScrollView`, not Gesture
|
|
46
|
+
* Handler's, so there is no handler to negotiate with — the two race, and a pan
|
|
47
|
+
* that activates on the first move beats a scroll view's ten-point slop on both
|
|
48
|
+
* platforms. It is also why there is no `activeOffsetX` here: waiting for the
|
|
49
|
+
* axis to declare itself would hand the scroll the first move and put a dead zone
|
|
50
|
+
* at the start of every drag. `blocksExternalGesture` is not the answer either —
|
|
51
|
+
* it resolves a ref to a handler tag, a plain `ScrollView` has none, and the call
|
|
52
|
+
* is dropped without an error.
|
|
53
|
+
*
|
|
54
|
+
* **`shouldCancelWhenOutside(false)`** where `Pressable`'s tap sets it `true`:
|
|
55
|
+
* dragging a thumb to the far end routinely leaves the track's bounds, and the
|
|
56
|
+
* value must keep tracking rather than the gesture giving up.
|
|
57
|
+
*
|
|
58
|
+
* The mirror back to React is deliberately thin. `positions` is written on the UI
|
|
59
|
+
* thread at the display's refresh rate; only a *changed* set of snapped values is
|
|
60
|
+
* scheduled across, so a whole-track drag at the default step is a few dozen
|
|
61
|
+
* commits rather than one per frame.
|
|
62
|
+
*/
|
|
63
|
+
export function SliderTrack({ children, className, ...props }: SliderTrackProps): ReactElement {
|
|
64
|
+
const {
|
|
65
|
+
positions,
|
|
66
|
+
trackSize,
|
|
67
|
+
thumbSize,
|
|
68
|
+
activeIndex,
|
|
69
|
+
minValue,
|
|
70
|
+
maxValue,
|
|
71
|
+
step,
|
|
72
|
+
haptic,
|
|
73
|
+
orientation,
|
|
74
|
+
color,
|
|
75
|
+
size,
|
|
76
|
+
isInvalid,
|
|
77
|
+
isDisabled,
|
|
78
|
+
commitValues,
|
|
79
|
+
commitEnd,
|
|
80
|
+
setDragging,
|
|
81
|
+
renderProps,
|
|
82
|
+
} = useSliderPart("Slider.Track");
|
|
83
|
+
const isVertical = orientation === "vertical";
|
|
84
|
+
|
|
85
|
+
// What the last haptic reported, and where the finger was when it did. Both
|
|
86
|
+
// feed `shouldTickHaptic`, which is what keeps a flick across a fine step scale
|
|
87
|
+
// from firing a hundred times.
|
|
88
|
+
const lastTick = useSharedValue(Number.NaN);
|
|
89
|
+
const lastTickPosition = useSharedValue(0);
|
|
90
|
+
|
|
91
|
+
const handleLayout = useCallback(
|
|
92
|
+
(event: LayoutChangeEvent) => {
|
|
93
|
+
trackSize.value = isVertical ? event.nativeEvent.layout.height : event.nativeEvent.layout.width;
|
|
94
|
+
},
|
|
95
|
+
[isVertical, trackSize]
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const gesture = useMemo(() => {
|
|
99
|
+
// Declared here rather than at module scope, and called only from the two
|
|
100
|
+
// callbacks below. A worklet defined beside its callers in one scope is
|
|
101
|
+
// captured by ordinary closure; a module-scope one binds at import time, in
|
|
102
|
+
// source order, which is how a helper ends up `undefined` on the UI thread.
|
|
103
|
+
const applyTouch = (along: number, forIndex: number, isGrab: boolean) => {
|
|
104
|
+
"worklet";
|
|
105
|
+
const thumb = thumbSize.value;
|
|
106
|
+
const travel = trackSize.value - thumb;
|
|
107
|
+
// The touch is read in the thumb's own frame — its centre, not its left
|
|
108
|
+
// edge — so the handle stays inside the groove at both ends.
|
|
109
|
+
const position = along - thumb / 2;
|
|
110
|
+
const raw = valueFromOffset({ position, travel, minValue, maxValue, isVertical });
|
|
111
|
+
const snapped = snapToStep(raw, step, minValue, maxValue);
|
|
112
|
+
const current = positions.value;
|
|
113
|
+
const next = clampThumb(snapped, current, forIndex, minValue, maxValue);
|
|
114
|
+
|
|
115
|
+
if (next !== current[forIndex]) {
|
|
116
|
+
const updated = [...current];
|
|
117
|
+
updated[forIndex] = next;
|
|
118
|
+
positions.value = updated;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The grab itself always confirms, the way a press does. After that the
|
|
122
|
+
// ticks are the step crossings, rate-limited by distance travelled.
|
|
123
|
+
if (isGrab) {
|
|
124
|
+
lastTick.value = next;
|
|
125
|
+
lastTickPosition.value = position;
|
|
126
|
+
if (haptic !== false) playHaptic(haptic);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (
|
|
131
|
+
haptic !== false &&
|
|
132
|
+
shouldTickHaptic({
|
|
133
|
+
lastPosition: lastTickPosition.value,
|
|
134
|
+
lastSnapped: lastTick.value,
|
|
135
|
+
maxValue,
|
|
136
|
+
minValue,
|
|
137
|
+
position,
|
|
138
|
+
snapped: next,
|
|
139
|
+
step,
|
|
140
|
+
})
|
|
141
|
+
) {
|
|
142
|
+
playHaptic(haptic);
|
|
143
|
+
lastTick.value = next;
|
|
144
|
+
lastTickPosition.value = position;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return Gesture.Pan()
|
|
149
|
+
.enabled(!isDisabled)
|
|
150
|
+
.minDistance(0)
|
|
151
|
+
.shouldCancelWhenOutside(false)
|
|
152
|
+
.onBegin((event) => {
|
|
153
|
+
"worklet";
|
|
154
|
+
const along = isVertical ? event.y : event.x;
|
|
155
|
+
const thumb = thumbSize.value;
|
|
156
|
+
const raw = valueFromOffset({
|
|
157
|
+
isVertical,
|
|
158
|
+
maxValue,
|
|
159
|
+
minValue,
|
|
160
|
+
position: along - thumb / 2,
|
|
161
|
+
travel: trackSize.value - thumb,
|
|
162
|
+
});
|
|
163
|
+
const index = nearestThumbIndex(positions.value, raw);
|
|
164
|
+
activeIndex.value = index;
|
|
165
|
+
scheduleOnRN(setDragging, true);
|
|
166
|
+
applyTouch(along, index, true);
|
|
167
|
+
})
|
|
168
|
+
.onUpdate((event) => {
|
|
169
|
+
"worklet";
|
|
170
|
+
const index = activeIndex.value;
|
|
171
|
+
if (index < 0) return;
|
|
172
|
+
applyTouch(isVertical ? event.y : event.x, index, false);
|
|
173
|
+
})
|
|
174
|
+
.onFinalize(() => {
|
|
175
|
+
"worklet";
|
|
176
|
+
activeIndex.value = -1;
|
|
177
|
+
// Order matters: the root must have stopped treating this as a live
|
|
178
|
+
// drag before `commitEnd` asks it to re-sync. `scheduleOnRN` keeps
|
|
179
|
+
// them in the order they were queued.
|
|
180
|
+
scheduleOnRN(setDragging, false);
|
|
181
|
+
scheduleOnRN(commitEnd, [...positions.value]);
|
|
182
|
+
});
|
|
183
|
+
}, [
|
|
184
|
+
activeIndex,
|
|
185
|
+
commitEnd,
|
|
186
|
+
haptic,
|
|
187
|
+
isDisabled,
|
|
188
|
+
isVertical,
|
|
189
|
+
lastTick,
|
|
190
|
+
lastTickPosition,
|
|
191
|
+
maxValue,
|
|
192
|
+
minValue,
|
|
193
|
+
positions,
|
|
194
|
+
setDragging,
|
|
195
|
+
step,
|
|
196
|
+
thumbSize,
|
|
197
|
+
trackSize,
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
// The one hop from the UI thread back to React, and it is gated element-wise:
|
|
201
|
+
// `useAnimatedReaction` re-runs on every write, and `positions` is a fresh
|
|
202
|
+
// array each time, so identity says nothing about whether the value moved.
|
|
203
|
+
useAnimatedReaction(
|
|
204
|
+
() => positions.value,
|
|
205
|
+
(current, previous) => {
|
|
206
|
+
if (previous !== null && current.length === previous.length) {
|
|
207
|
+
let changed = false;
|
|
208
|
+
for (let index = 0; index < current.length; index++) {
|
|
209
|
+
if (current[index] !== previous[index]) {
|
|
210
|
+
changed = true;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (!changed) return;
|
|
215
|
+
}
|
|
216
|
+
scheduleOnRN(commitValues, [...current]);
|
|
217
|
+
},
|
|
218
|
+
[commitValues]
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const slots = sliderVariants({ color, isDisabled, isInvalid, orientation, size });
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<GestureDetector gesture={gesture}>
|
|
225
|
+
<Animated.View className={slots.touchArea()} {...props}>
|
|
226
|
+
<Animated.View className={slots.track({ className })} onLayout={handleLayout}>
|
|
227
|
+
{typeof children === "function" ? children(renderProps) : children}
|
|
228
|
+
</Animated.View>
|
|
229
|
+
</Animated.View>
|
|
230
|
+
</GestureDetector>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
SliderTrack.displayName = "DelacourUI.Slider.Track";
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createContext, type ReactElement, type ReactNode, use } from "react";
|
|
2
|
+
import type { SharedValue } from "react-native-reanimated";
|
|
3
|
+
import type { HapticFeedback } from "../pressable";
|
|
4
|
+
import type { SliderRenderProps } from "./slider.types";
|
|
5
|
+
import type { SliderColor, SliderOrientation, SliderSize } from "./slider.variants";
|
|
6
|
+
|
|
7
|
+
export type SliderContextValue = {
|
|
8
|
+
/**
|
|
9
|
+
* Every thumb's value, snapped, on the **JS thread**.
|
|
10
|
+
*
|
|
11
|
+
* What `Slider.Output` and the accessibility props read. The live position a
|
|
12
|
+
* finger is dragging lives in {@link SliderContextValue.positions} instead —
|
|
13
|
+
* these two agree at rest and lag by a mirror hop during a drag, which is the
|
|
14
|
+
* whole reason the drag never waits on React.
|
|
15
|
+
*/
|
|
16
|
+
values: number[];
|
|
17
|
+
minValue: number;
|
|
18
|
+
maxValue: number;
|
|
19
|
+
/** The increment the values snap to. `0` is continuous. */
|
|
20
|
+
step: number;
|
|
21
|
+
orientation: SliderOrientation;
|
|
22
|
+
color: SliderColor;
|
|
23
|
+
size: SliderSize;
|
|
24
|
+
isDisabled: boolean;
|
|
25
|
+
isInvalid: boolean;
|
|
26
|
+
/** Passed to `Intl.NumberFormat` by `Slider.Output`. */
|
|
27
|
+
formatOptions?: Intl.NumberFormatOptions;
|
|
28
|
+
/** Played as the value crosses a step. `false` silences it. */
|
|
29
|
+
haptic: false | HapticFeedback;
|
|
30
|
+
/**
|
|
31
|
+
* Every thumb's live value, on the **UI thread**.
|
|
32
|
+
*
|
|
33
|
+
* One shared value holding the whole array rather than one per thumb, because
|
|
34
|
+
* a thumb count is data and hooks cannot be called in a loop. Reassign it —
|
|
35
|
+
* `positions.value = next` — and never write an element in place: an array
|
|
36
|
+
* element has no setter behind it, so `positions.value[0] = x` updates nothing
|
|
37
|
+
* and fails silently.
|
|
38
|
+
*/
|
|
39
|
+
positions: SharedValue<number[]>;
|
|
40
|
+
/** The track's own length, from its `onLayout`. `0` until it has been measured. */
|
|
41
|
+
trackSize: SharedValue<number>;
|
|
42
|
+
/** A thumb's diameter, from its `onLayout`. `0` until it has been measured. */
|
|
43
|
+
thumbSize: SharedValue<number>;
|
|
44
|
+
/** Which thumb is being dragged, or `-1` when none is. */
|
|
45
|
+
activeIndex: SharedValue<number>;
|
|
46
|
+
/**
|
|
47
|
+
* Writes one thumb's value from the **JS thread**, snapping and clamping it
|
|
48
|
+
* first.
|
|
49
|
+
*
|
|
50
|
+
* The one path into the value that does not go through the pan — an
|
|
51
|
+
* accessibility increment has no gesture behind it.
|
|
52
|
+
*/
|
|
53
|
+
updateValue: (index: number, next: number) => void;
|
|
54
|
+
/**
|
|
55
|
+
* Tells the root a drag has started or finished.
|
|
56
|
+
*
|
|
57
|
+
* The root stops syncing the shared value from React state while this is true.
|
|
58
|
+
* Without it the mirror hop back from the UI thread would round-trip through a
|
|
59
|
+
* render and land on the thumb a frame late, dragging it backwards on every
|
|
60
|
+
* commit — a jitter that only shows up on a fast drag.
|
|
61
|
+
*/
|
|
62
|
+
setDragging: (dragging: boolean) => void;
|
|
63
|
+
/** Reports a drag's new values. Called from the pan's mirror, never in render. */
|
|
64
|
+
commitValues: (next: number[]) => void;
|
|
65
|
+
/** Reports the values a drag settled on, once, on release. */
|
|
66
|
+
commitEnd: (next: number[]) => void;
|
|
67
|
+
/** The settled state a render function is handed. */
|
|
68
|
+
renderProps: SliderRenderProps;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const SliderContext = createContext<SliderContextValue | null>(null);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Supplies one slider's settled state to its own parts.
|
|
75
|
+
*
|
|
76
|
+
* Lives in its own module, importing nothing but React and types, so a part can
|
|
77
|
+
* read it without importing `./slider`. That import would close a cycle, and Metro
|
|
78
|
+
* serves a partially initialised module for a cycle — leaving the context
|
|
79
|
+
* `undefined` at import time and red-boxing the app on a cold start.
|
|
80
|
+
*/
|
|
81
|
+
export function SliderProvider({ value, children }: { value: SliderContextValue; children: ReactNode }): ReactElement {
|
|
82
|
+
return <SliderContext value={value}>{children}</SliderContext>;
|
|
83
|
+
}
|
|
84
|
+
SliderProvider.displayName = "DelacourUI.Slider.Provider";
|
|
85
|
+
|
|
86
|
+
/** The enclosing slider's state, or null outside a `<Slider>`. */
|
|
87
|
+
export function useSliderContext(): SliderContextValue | null {
|
|
88
|
+
return use(SliderContext);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Reads the enclosing slider's settled state.
|
|
93
|
+
*
|
|
94
|
+
* For a custom readout or a custom thumb that has to match the slider it sits in.
|
|
95
|
+
* Throws outside one — use {@link useSliderContext} where the slider is optional.
|
|
96
|
+
*/
|
|
97
|
+
export function useSlider(): SliderContextValue {
|
|
98
|
+
const context = useSliderContext();
|
|
99
|
+
if (!context) {
|
|
100
|
+
throw new Error("useSlider must be called inside a <Slider>.");
|
|
101
|
+
}
|
|
102
|
+
return context;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The enclosing slider's state, for a compound part that cannot work without one.
|
|
107
|
+
*
|
|
108
|
+
* Internal: deliberately not re-exported from `index.ts`. A caller outside the
|
|
109
|
+
* library wants {@link useSlider}, whose error message names the hook rather than
|
|
110
|
+
* a part.
|
|
111
|
+
*/
|
|
112
|
+
export function useSliderPart(component: string): SliderContextValue {
|
|
113
|
+
const context = useSliderContext();
|
|
114
|
+
if (!context) {
|
|
115
|
+
throw new Error(`${component} must be rendered inside a <Slider>.`);
|
|
116
|
+
}
|
|
117
|
+
return context;
|
|
118
|
+
}
|