@cdx-ui/primitives 0.0.1-beta.114 → 0.0.1-beta.116

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.
Files changed (85) hide show
  1. package/README.md +32 -30
  2. package/lib/commonjs/index.js +35 -0
  3. package/lib/commonjs/index.js.map +1 -1
  4. package/lib/commonjs/overlay/useAnchorPosition.js +60 -11
  5. package/lib/commonjs/overlay/useAnchorPosition.js.map +1 -1
  6. package/lib/commonjs/slider/SliderRail.js +131 -0
  7. package/lib/commonjs/slider/SliderRail.js.map +1 -0
  8. package/lib/commonjs/slider/SliderRoot.js +186 -0
  9. package/lib/commonjs/slider/SliderRoot.js.map +1 -0
  10. package/lib/commonjs/slider/SliderThumb.js +228 -0
  11. package/lib/commonjs/slider/SliderThumb.js.map +1 -0
  12. package/lib/commonjs/slider/context.js +11 -0
  13. package/lib/commonjs/slider/context.js.map +1 -0
  14. package/lib/commonjs/slider/index.js +65 -0
  15. package/lib/commonjs/slider/index.js.map +1 -0
  16. package/lib/commonjs/slider/types.js +6 -0
  17. package/lib/commonjs/slider/types.js.map +1 -0
  18. package/lib/commonjs/slider/useSliderPointer.js +116 -0
  19. package/lib/commonjs/slider/useSliderPointer.js.map +1 -0
  20. package/lib/commonjs/slider/utils.js +155 -0
  21. package/lib/commonjs/slider/utils.js.map +1 -0
  22. package/lib/commonjs/utils/assignRef.js +16 -0
  23. package/lib/commonjs/utils/assignRef.js.map +1 -0
  24. package/lib/commonjs/utils/createPortal.js +3 -1
  25. package/lib/commonjs/utils/createPortal.js.map +1 -1
  26. package/lib/module/index.js +4 -0
  27. package/lib/module/index.js.map +1 -1
  28. package/lib/module/overlay/useAnchorPosition.js +60 -11
  29. package/lib/module/overlay/useAnchorPosition.js.map +1 -1
  30. package/lib/module/slider/SliderRail.js +127 -0
  31. package/lib/module/slider/SliderRail.js.map +1 -0
  32. package/lib/module/slider/SliderRoot.js +182 -0
  33. package/lib/module/slider/SliderRoot.js.map +1 -0
  34. package/lib/module/slider/SliderThumb.js +224 -0
  35. package/lib/module/slider/SliderThumb.js.map +1 -0
  36. package/lib/module/slider/context.js +5 -0
  37. package/lib/module/slider/context.js.map +1 -0
  38. package/lib/module/slider/index.js +8 -0
  39. package/lib/module/slider/index.js.map +1 -0
  40. package/lib/module/slider/types.js +4 -0
  41. package/lib/module/slider/types.js.map +1 -0
  42. package/lib/module/slider/useSliderPointer.js +110 -0
  43. package/lib/module/slider/useSliderPointer.js.map +1 -0
  44. package/lib/module/slider/utils.js +138 -0
  45. package/lib/module/slider/utils.js.map +1 -0
  46. package/lib/module/utils/assignRef.js +12 -0
  47. package/lib/module/utils/assignRef.js.map +1 -0
  48. package/lib/module/utils/createPortal.js +3 -1
  49. package/lib/module/utils/createPortal.js.map +1 -1
  50. package/lib/typescript/index.d.ts +4 -0
  51. package/lib/typescript/index.d.ts.map +1 -1
  52. package/lib/typescript/overlay/useAnchorPosition.d.ts +7 -1
  53. package/lib/typescript/overlay/useAnchorPosition.d.ts.map +1 -1
  54. package/lib/typescript/slider/SliderRail.d.ts +6 -0
  55. package/lib/typescript/slider/SliderRail.d.ts.map +1 -0
  56. package/lib/typescript/slider/SliderRoot.d.ts +7 -0
  57. package/lib/typescript/slider/SliderRoot.d.ts.map +1 -0
  58. package/lib/typescript/slider/SliderThumb.d.ts +6 -0
  59. package/lib/typescript/slider/SliderThumb.d.ts.map +1 -0
  60. package/lib/typescript/slider/context.d.ts +37 -0
  61. package/lib/typescript/slider/context.d.ts.map +1 -0
  62. package/lib/typescript/slider/index.d.ts +8 -0
  63. package/lib/typescript/slider/index.d.ts.map +1 -0
  64. package/lib/typescript/slider/types.d.ts +37 -0
  65. package/lib/typescript/slider/types.d.ts.map +1 -0
  66. package/lib/typescript/slider/useSliderPointer.d.ts +23 -0
  67. package/lib/typescript/slider/useSliderPointer.d.ts.map +1 -0
  68. package/lib/typescript/slider/utils.d.ts +23 -0
  69. package/lib/typescript/slider/utils.d.ts.map +1 -0
  70. package/lib/typescript/utils/assignRef.d.ts +3 -0
  71. package/lib/typescript/utils/assignRef.d.ts.map +1 -0
  72. package/lib/typescript/utils/createPortal.d.ts.map +1 -1
  73. package/package.json +2 -2
  74. package/src/index.ts +4 -0
  75. package/src/overlay/useAnchorPosition.ts +79 -11
  76. package/src/slider/SliderRail.tsx +143 -0
  77. package/src/slider/SliderRoot.tsx +266 -0
  78. package/src/slider/SliderThumb.tsx +268 -0
  79. package/src/slider/context.tsx +38 -0
  80. package/src/slider/index.ts +19 -0
  81. package/src/slider/types.ts +44 -0
  82. package/src/slider/useSliderPointer.ts +157 -0
  83. package/src/slider/utils.ts +181 -0
  84. package/src/utils/assignRef.ts +11 -0
  85. package/src/utils/createPortal.ts +3 -1
@@ -0,0 +1,143 @@
1
+ import { forwardRef, useCallback, useRef } from 'react';
2
+ import {
3
+ Platform,
4
+ Pressable,
5
+ StyleSheet,
6
+ View,
7
+ type GestureResponderEvent,
8
+ type LayoutChangeEvent,
9
+ type PressableProps,
10
+ } from 'react-native';
11
+ import { Slot } from '../slot';
12
+ import { assignRef } from '../utils/assignRef';
13
+ import { dataAttributes } from '../utils/dataAttributes';
14
+ import { useSliderContext } from './context';
15
+ import type { InterfaceSliderRailProps } from './types';
16
+ import { getPointerCoords, getRatioFromPagePoint, useWebSliderDrag } from './useSliderPointer';
17
+
18
+ export interface SliderRailProps extends InterfaceSliderRailProps {}
19
+
20
+ export const SliderRail = forwardRef<View, SliderRailProps>(
21
+ ({ asChild, style, onLayout, children, ...props }, ref) => {
22
+ const {
23
+ orientation,
24
+ isDisabled,
25
+ isDragging,
26
+ trackLayout,
27
+ thumbSize,
28
+ setValueFromRatio,
29
+ setTrackLayout,
30
+ setIsDragging,
31
+ } = useSliderContext();
32
+
33
+ const railRef = useRef<View>(null);
34
+
35
+ const measureRail = useCallback(() => {
36
+ const node = railRef.current;
37
+ if (!node?.measureInWindow) {
38
+ return;
39
+ }
40
+ node.measureInWindow((x, y, width, height) => {
41
+ setTrackLayout({ x, y, width, height });
42
+ });
43
+ }, [setTrackLayout]);
44
+
45
+ const handleLayout = useCallback(
46
+ (event: LayoutChangeEvent) => {
47
+ measureRail();
48
+ onLayout?.(event);
49
+ },
50
+ [measureRail, onLayout],
51
+ );
52
+
53
+ const { startDrag } = useWebSliderDrag({
54
+ isDisabled,
55
+ setIsDragging,
56
+ setValueFromRatio,
57
+ trackLayout,
58
+ orientation,
59
+ thumbSize,
60
+ });
61
+
62
+ const updateFromEvent = useCallback(
63
+ (event: GestureResponderEvent) => {
64
+ if (isDisabled) {
65
+ return;
66
+ }
67
+ const { x, y } = getPointerCoords(event.nativeEvent);
68
+ setValueFromRatio(getRatioFromPagePoint(x, y, trackLayout, orientation, thumbSize), true);
69
+ },
70
+ [isDisabled, orientation, setValueFromRatio, thumbSize, trackLayout],
71
+ );
72
+
73
+ const handlePress = useCallback(
74
+ (event: GestureResponderEvent) => {
75
+ updateFromEvent(event);
76
+ },
77
+ [updateFromEvent],
78
+ );
79
+
80
+ const handlePointerDown = useCallback(
81
+ (event: GestureResponderEvent) => {
82
+ if (Platform.OS !== 'web' || isDisabled) {
83
+ return;
84
+ }
85
+ event.preventDefault();
86
+
87
+ const node = railRef.current as unknown as HTMLElement | null;
88
+ if (!node) return;
89
+ const rect = node.getBoundingClientRect();
90
+ const freshLayout = { x: rect.left, y: rect.top, width: rect.width, height: rect.height };
91
+ setTrackLayout(freshLayout);
92
+
93
+ const { x, y } = getPointerCoords(event.nativeEvent);
94
+ startDrag(x, y, undefined, freshLayout);
95
+ },
96
+ [isDisabled, setTrackLayout, startDrag],
97
+ );
98
+
99
+ const Comp = asChild ? Slot : View;
100
+
101
+ return (
102
+ <Pressable
103
+ accessibilityRole="none"
104
+ accessible={false}
105
+ disabled={isDisabled}
106
+ onPress={Platform.OS === 'web' ? undefined : handlePress}
107
+ onPointerDown={
108
+ Platform.OS === 'web'
109
+ ? (handlePointerDown as unknown as PressableProps['onPointerDown'])
110
+ : undefined
111
+ }
112
+ accessibilityElementsHidden
113
+ importantForAccessibility="no-hide-descendants"
114
+ aria-hidden
115
+ tabIndex={-1}
116
+ style={styles.railHitArea}
117
+ >
118
+ <Comp
119
+ ref={(node: View | null) => {
120
+ railRef.current = node;
121
+ assignRef(ref, node);
122
+ }}
123
+ {...props}
124
+ onLayout={handleLayout}
125
+ style={style}
126
+ {...dataAttributes({ slot: 'slider-rail', dragging: isDragging })}
127
+ >
128
+ {children}
129
+ </Comp>
130
+ </Pressable>
131
+ );
132
+ },
133
+ );
134
+
135
+ SliderRail.displayName = 'SliderRail';
136
+
137
+ const styles = StyleSheet.create({
138
+ railHitArea: {
139
+ ...StyleSheet.absoluteFillObject,
140
+ justifyContent: 'center',
141
+ alignItems: 'center',
142
+ },
143
+ });
@@ -0,0 +1,266 @@
1
+ import { forwardRef, useCallback, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ Platform,
4
+ View,
5
+ type NativeSyntheticEvent,
6
+ type TargetedEvent,
7
+ type ViewProps,
8
+ } from 'react-native';
9
+ import { composeEventHandlers, useControllableState } from '@cdx-ui/utils';
10
+ import { useFocus } from '@react-native-aria/focus';
11
+ import { Slot } from '../slot';
12
+ import { dataAttributes } from '../utils/dataAttributes';
13
+ import { SliderProvider, type SliderTrackLayout } from './context';
14
+ import type { InterfaceSliderProps } from './types';
15
+ import { clamp, formatSliderValue, snapToStep, valueToPercent } from './utils';
16
+
17
+ const STEP_KEYS = new Set(['ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowLeft', 'Home', 'End']);
18
+
19
+ export interface SliderRootProps extends InterfaceSliderProps {
20
+ readonly asChild?: boolean;
21
+ }
22
+
23
+ export const SliderRoot = forwardRef<View, SliderRootProps>(
24
+ (
25
+ {
26
+ asChild,
27
+ value: valueProp,
28
+ defaultValue,
29
+ onChange,
30
+ onChangeComplete,
31
+ min = 0,
32
+ max = 100,
33
+ step = 1,
34
+ isDisabled = false,
35
+ orientation = 'horizontal',
36
+ accessibilityLabel,
37
+ accessibilityValue: accessibilityValueProp,
38
+ accessibilityRole = 'adjustable',
39
+ role = 'slider',
40
+ children,
41
+ onLayout,
42
+ ...props
43
+ },
44
+ ref,
45
+ ) => {
46
+ const [trackLayout, setTrackLayoutState] = useState<SliderTrackLayout | null>(null);
47
+ const [thumbSize, setThumbSizeState] = useState(20);
48
+ const [isDragging, setIsDragging] = useState(false);
49
+ const [isHovered, setIsHovered] = useState(false);
50
+ const [isFocusedState, setIsFocusedState] = useState(false);
51
+ const thumbRef = useRef<View>(null);
52
+ const { focusProps, isFocused } = useFocus();
53
+
54
+ const resolvedDefault = defaultValue ?? min;
55
+ const [value, setValueState] = useControllableState<number>({
56
+ prop: valueProp,
57
+ defaultProp: resolvedDefault,
58
+ onChange,
59
+ });
60
+
61
+ const currentValue = snapToStep(value ?? resolvedDefault, min, max, step);
62
+ const percent = valueToPercent(currentValue, min, max);
63
+
64
+ const setValue = useCallback(
65
+ (next: number, complete = false) => {
66
+ if (isDisabled) {
67
+ return;
68
+ }
69
+ const snapped = snapToStep(next, min, max, step);
70
+ setValueState(snapped);
71
+ if (complete) {
72
+ onChangeComplete?.(snapped);
73
+ }
74
+ },
75
+ [isDisabled, max, min, onChangeComplete, setValueState, step],
76
+ );
77
+
78
+ const setValueFromRatio = useCallback(
79
+ (ratio: number, complete = false) => {
80
+ const next = min + clamp(ratio, 0, 1) * (max - min);
81
+ setValue(next, complete);
82
+ },
83
+ [max, min, setValue],
84
+ );
85
+
86
+ const setTrackLayout = useCallback((layout: SliderTrackLayout) => {
87
+ setTrackLayoutState((prev) => {
88
+ if (
89
+ prev !== null &&
90
+ prev.width === layout.width &&
91
+ prev.height === layout.height &&
92
+ prev.x === layout.x &&
93
+ prev.y === layout.y
94
+ ) {
95
+ return prev;
96
+ }
97
+ return layout;
98
+ });
99
+ }, []);
100
+
101
+ const setThumbSize = useCallback((size: number) => {
102
+ setThumbSizeState((prev) => (prev === size ? prev : size));
103
+ }, []);
104
+
105
+ const accessibilityValue = useMemo(() => {
106
+ const defaults = {
107
+ min,
108
+ max,
109
+ now: currentValue,
110
+ text: formatSliderValue(currentValue),
111
+ };
112
+
113
+ if (accessibilityValueProp === undefined) {
114
+ return defaults;
115
+ }
116
+
117
+ return { ...defaults, ...accessibilityValueProp };
118
+ }, [accessibilityValueProp, currentValue, max, min]);
119
+
120
+ const getValueLabel = useCallback(() => accessibilityValue.text, [accessibilityValue.text]);
121
+
122
+ const handleKeyDown = useCallback(
123
+ (event: NativeSyntheticEvent<TargetedEvent> & { nativeEvent: { key?: string } }) => {
124
+ if (isDisabled) {
125
+ return;
126
+ }
127
+ const key = event.nativeEvent.key;
128
+ if (!key || !STEP_KEYS.has(key)) {
129
+ return;
130
+ }
131
+ if (Platform.OS === 'web') {
132
+ (event as unknown as { preventDefault: () => void }).preventDefault();
133
+ }
134
+
135
+ let next = currentValue;
136
+ if (key === 'ArrowUp' || key === 'ArrowRight') {
137
+ next = currentValue + step;
138
+ } else if (key === 'ArrowDown' || key === 'ArrowLeft') {
139
+ next = currentValue - step;
140
+ } else if (key === 'Home') {
141
+ next = min;
142
+ } else if (key === 'End') {
143
+ next = max;
144
+ }
145
+
146
+ setValue(next, true);
147
+ },
148
+ [currentValue, isDisabled, max, min, setValue, step],
149
+ );
150
+
151
+ const contextValue = useMemo(
152
+ () => ({
153
+ value: currentValue,
154
+ min,
155
+ max,
156
+ step,
157
+ percent,
158
+ orientation,
159
+ isDisabled,
160
+ isDragging,
161
+ isHovered,
162
+ isFocused: isFocused || isFocusedState,
163
+ trackLayout,
164
+ thumbSize,
165
+ setValue,
166
+ setValueFromRatio,
167
+ setTrackLayout,
168
+ setThumbSize,
169
+ setIsDragging,
170
+ setIsHovered,
171
+ setIsFocused: setIsFocusedState,
172
+ getValueLabel,
173
+ thumbRef,
174
+ }),
175
+ [
176
+ currentValue,
177
+ getValueLabel,
178
+ isDisabled,
179
+ isDragging,
180
+ isFocused,
181
+ isFocusedState,
182
+ isHovered,
183
+ max,
184
+ min,
185
+ orientation,
186
+ percent,
187
+ setTrackLayout,
188
+ setThumbSize,
189
+ setValue,
190
+ setValueFromRatio,
191
+ step,
192
+ thumbSize,
193
+ trackLayout,
194
+ thumbRef,
195
+ ],
196
+ );
197
+
198
+ const dataAttrs = dataAttributes({
199
+ slot: 'slider',
200
+ orientation,
201
+ disabled: isDisabled,
202
+ dragging: isDragging,
203
+ hover: isHovered,
204
+ focus: isFocused,
205
+ value: String(currentValue),
206
+ min: String(min),
207
+ max: String(max),
208
+ });
209
+
210
+ const Comp = asChild ? Slot : View;
211
+
212
+ const handleLayout = composeEventHandlers(onLayout, () => undefined);
213
+
214
+ const webKeyProps =
215
+ Platform.OS === 'web'
216
+ ? {
217
+ tabIndex: isDisabled ? -1 : 0,
218
+ onKeyDown: composeEventHandlers(
219
+ (props as ViewProps & { onKeyDown?: (event: unknown) => void }).onKeyDown,
220
+ handleKeyDown as never,
221
+ ),
222
+ }
223
+ : {};
224
+
225
+ return (
226
+ <SliderProvider value={contextValue}>
227
+ <Comp
228
+ ref={ref}
229
+ {...(props as ViewProps)}
230
+ {...focusProps}
231
+ {...webKeyProps}
232
+ onLayout={handleLayout}
233
+ onFocus={composeEventHandlers(
234
+ composeEventHandlers(
235
+ props.onFocus as (...args: unknown[]) => unknown,
236
+ focusProps.onFocus,
237
+ ),
238
+ () => setIsFocusedState(true),
239
+ )}
240
+ onBlur={composeEventHandlers(
241
+ composeEventHandlers(
242
+ props.onBlur as (...args: unknown[]) => unknown,
243
+ focusProps.onBlur,
244
+ ),
245
+ () => setIsFocusedState(false),
246
+ )}
247
+ accessibilityRole={accessibilityRole}
248
+ accessibilityLabel={accessibilityLabel}
249
+ accessibilityValue={accessibilityValue}
250
+ accessibilityState={{ disabled: isDisabled }}
251
+ aria-valuemin={min}
252
+ aria-valuemax={max}
253
+ aria-valuenow={currentValue}
254
+ aria-orientation={orientation}
255
+ aria-disabled={isDisabled}
256
+ role={role}
257
+ {...dataAttrs}
258
+ >
259
+ {children}
260
+ </Comp>
261
+ </SliderProvider>
262
+ );
263
+ },
264
+ );
265
+
266
+ SliderRoot.displayName = 'SliderRoot';
@@ -0,0 +1,268 @@
1
+ import { forwardRef, useCallback, useMemo, useRef, type ComponentRef } from 'react';
2
+ import {
3
+ PanResponder,
4
+ Platform,
5
+ Pressable,
6
+ View,
7
+ type GestureResponderEvent,
8
+ type LayoutChangeEvent,
9
+ type PressableProps,
10
+ type ViewStyle,
11
+ } from 'react-native';
12
+ import { composeEventHandlers } from '@cdx-ui/utils';
13
+ import { useFocus, useFocusRing } from '@react-native-aria/focus';
14
+ import { useHover, usePress } from '@react-native-aria/interactions';
15
+ import { Slot } from '../slot';
16
+ import { assignRef } from '../utils/assignRef';
17
+ import { dataAttributes } from '../utils/dataAttributes';
18
+ import { useSliderContext } from './context';
19
+ import type { InterfaceSliderThumbProps } from './types';
20
+ import { getPointerCoords, useWebSliderDrag } from './useSliderPointer';
21
+ import { clamp, getThumbTravel, percentToThumbOffset } from './utils';
22
+
23
+ export interface SliderThumbProps extends InterfaceSliderThumbProps {}
24
+
25
+ export const SliderThumb = forwardRef<ComponentRef<typeof Pressable>, SliderThumbProps>(
26
+ (
27
+ {
28
+ asChild,
29
+ style,
30
+ onLayout,
31
+ onHoverIn,
32
+ onHoverOut,
33
+ onFocus,
34
+ onBlur,
35
+ onPressIn,
36
+ onPressOut,
37
+ children,
38
+ ...props
39
+ },
40
+ ref,
41
+ ) => {
42
+ const {
43
+ percent,
44
+ orientation,
45
+ isDisabled,
46
+ isDragging,
47
+ trackLayout,
48
+ thumbSize,
49
+ setValueFromRatio,
50
+ setIsDragging,
51
+ setIsHovered,
52
+ setIsFocused,
53
+ setThumbSize,
54
+ thumbRef,
55
+ } = useSliderContext();
56
+
57
+ const percentRef = useRef(percent);
58
+ percentRef.current = percent;
59
+
60
+ const dragStartRatio = useRef(0);
61
+ const lastRatioRef = useRef(percent / 100);
62
+
63
+ // The native PanResponder is created once (stable across renders) so an
64
+ // in-flight gesture is never handed a fresh internal `gestureState`
65
+ // mid-drag. In controlled mode `setValueFromRatio`/`trackLayout` change
66
+ // identity on every value update, so the handlers read the current values
67
+ // through refs instead of closing over them (which would force the
68
+ // `useMemo` to re-create the responder on every move and reset `dx`).
69
+ const orientationRef = useRef(orientation);
70
+ orientationRef.current = orientation;
71
+ const trackLayoutRef = useRef(trackLayout);
72
+ trackLayoutRef.current = trackLayout;
73
+ const thumbSizeRef = useRef(thumbSize);
74
+ thumbSizeRef.current = thumbSize;
75
+ const isDisabledRef = useRef(isDisabled);
76
+ isDisabledRef.current = isDisabled;
77
+ const setValueFromRatioRef = useRef(setValueFromRatio);
78
+ setValueFromRatioRef.current = setValueFromRatio;
79
+ const setIsDraggingRef = useRef(setIsDragging);
80
+ setIsDraggingRef.current = setIsDragging;
81
+
82
+ const { hoverProps, isHovered } = useHover();
83
+ const { focusProps, isFocused } = useFocus();
84
+ const { isFocusVisible, focusProps: focusRingProps } = useFocusRing() as {
85
+ isFocusVisible: boolean;
86
+ focusProps: typeof focusProps;
87
+ };
88
+ const { pressProps: pressableProps, isPressed } = usePress({ isDisabled }) as {
89
+ isPressed: boolean;
90
+ pressProps: Pick<PressableProps, 'onPressIn' | 'onPressOut'>;
91
+ };
92
+
93
+ const { startDrag } = useWebSliderDrag({
94
+ isDisabled,
95
+ setIsDragging,
96
+ setValueFromRatio,
97
+ trackLayout,
98
+ orientation,
99
+ thumbSize,
100
+ });
101
+
102
+ const panResponder = useMemo(
103
+ () =>
104
+ PanResponder.create({
105
+ onStartShouldSetPanResponder: () => !isDisabledRef.current,
106
+ onMoveShouldSetPanResponder: () => !isDisabledRef.current,
107
+ onPanResponderGrant: () => {
108
+ if (isDisabledRef.current) {
109
+ return;
110
+ }
111
+ setIsDraggingRef.current(true);
112
+ dragStartRatio.current = percentRef.current / 100;
113
+ lastRatioRef.current = dragStartRatio.current;
114
+ },
115
+ onPanResponderMove: (_event, gestureState) => {
116
+ const layout = trackLayoutRef.current;
117
+ if (!layout) {
118
+ return;
119
+ }
120
+ const isHorizontal = orientationRef.current === 'horizontal';
121
+ const trackSize = isHorizontal ? layout.width : layout.height;
122
+ const travel = getThumbTravel(trackSize, thumbSizeRef.current);
123
+ if (travel <= 0) {
124
+ return;
125
+ }
126
+ const delta = isHorizontal ? gestureState.dx : -gestureState.dy;
127
+ const nextRatio = clamp(dragStartRatio.current + delta / travel, 0, 1);
128
+ lastRatioRef.current = nextRatio;
129
+ setValueFromRatioRef.current(nextRatio);
130
+ },
131
+ onPanResponderRelease: () => {
132
+ setIsDraggingRef.current(false);
133
+ setValueFromRatioRef.current(lastRatioRef.current, true);
134
+ },
135
+ onPanResponderTerminate: () => {
136
+ setIsDraggingRef.current(false);
137
+ setValueFromRatioRef.current(lastRatioRef.current, true);
138
+ },
139
+ }),
140
+ [],
141
+ );
142
+
143
+ const thumbStyle = useMemo((): ViewStyle => {
144
+ const trackSize =
145
+ trackLayout == null
146
+ ? 0
147
+ : orientation === 'horizontal'
148
+ ? trackLayout.width
149
+ : trackLayout.height;
150
+ const offset = percentToThumbOffset(percent, trackSize, thumbSize);
151
+
152
+ if (orientation === 'vertical') {
153
+ return {
154
+ position: 'absolute',
155
+ left: '50%',
156
+ bottom: offset,
157
+ marginLeft: -thumbSize / 2,
158
+ zIndex: 2,
159
+ };
160
+ }
161
+
162
+ return {
163
+ position: 'absolute',
164
+ top: '50%',
165
+ left: offset,
166
+ transform: [{ translateY: -thumbSize / 2 }],
167
+ zIndex: 2,
168
+ };
169
+ }, [orientation, percent, thumbSize, trackLayout]);
170
+
171
+ const handleLayout = useCallback(
172
+ (event: LayoutChangeEvent) => {
173
+ const { width, height } = event.nativeEvent.layout;
174
+ const measured = orientation === 'horizontal' ? width : height;
175
+ if (measured > 0) {
176
+ setThumbSize(measured);
177
+ }
178
+ if (onLayout) {
179
+ onLayout(event);
180
+ }
181
+ },
182
+ [onLayout, orientation, setThumbSize],
183
+ );
184
+
185
+ const handlePointerDown = useCallback(
186
+ (event: GestureResponderEvent) => {
187
+ if (Platform.OS !== 'web' || isDisabled) {
188
+ return;
189
+ }
190
+ event.preventDefault();
191
+ event.stopPropagation();
192
+ const { x, y } = getPointerCoords(event.nativeEvent);
193
+ startDrag(x, y, percentRef.current / 100);
194
+ },
195
+ [isDisabled, startDrag],
196
+ );
197
+
198
+ const isWeb = Platform.OS === 'web';
199
+
200
+ // Native drag is driven by PanResponder. Its `panHandlers` set the same
201
+ // responder props (`onResponderGrant/Move/Release`, etc.) that RN's
202
+ // `Pressable`/`Pressability` also set internally — and `Pressable` spreads
203
+ // its own handlers last, silently clobbering ours. So on native the thumb
204
+ // must be a plain `View` that carries `panHandlers` directly. Web keeps
205
+ // `Pressable` for its pointer-event, hover, and focus-ring behavior.
206
+ const Comp = asChild ? Slot : isWeb ? Pressable : View;
207
+
208
+ const setThumbRef = useCallback(
209
+ (node: ComponentRef<typeof Pressable> | null) => {
210
+ thumbRef.current = node;
211
+ assignRef(ref, node);
212
+ },
213
+ [ref, thumbRef],
214
+ );
215
+
216
+ const webInteractionProps: PressableProps = {
217
+ onPointerDown: handlePointerDown as unknown as PressableProps['onPointerDown'],
218
+ onHoverIn: composeEventHandlers(
219
+ onHoverIn,
220
+ composeEventHandlers(hoverProps.onHoverIn, () => setIsHovered(true)),
221
+ ),
222
+ onHoverOut: composeEventHandlers(
223
+ onHoverOut,
224
+ composeEventHandlers(hoverProps.onHoverOut, () => setIsHovered(false)),
225
+ ),
226
+ onPressIn: composeEventHandlers(onPressIn, pressableProps.onPressIn),
227
+ onPressOut: composeEventHandlers(onPressOut, pressableProps.onPressOut),
228
+ disabled: isDisabled,
229
+ tabIndex: -1,
230
+ };
231
+
232
+ return (
233
+ <Comp
234
+ ref={setThumbRef}
235
+ {...(props as PressableProps)}
236
+ {...(isWeb ? webInteractionProps : panResponder.panHandlers)}
237
+ onLayout={handleLayout}
238
+ onFocus={composeEventHandlers(
239
+ composeEventHandlers(onFocus as (...args: unknown[]) => unknown, focusProps.onFocus),
240
+ composeEventHandlers(focusRingProps.onFocus, () => setIsFocused(true)),
241
+ )}
242
+ onBlur={composeEventHandlers(
243
+ composeEventHandlers(onBlur as (...args: unknown[]) => unknown, focusProps.onBlur),
244
+ composeEventHandlers(focusRingProps.onBlur, () => setIsFocused(false)),
245
+ )}
246
+ style={[thumbStyle, style]}
247
+ accessibilityRole="none"
248
+ importantForAccessibility="no"
249
+ {...dataAttributes({
250
+ slot: 'slider-thumb',
251
+ hover: isHovered,
252
+ focus: isFocused,
253
+ focusVisible: isFocusVisible,
254
+ // On native there is no `Pressable`, so `usePress` never fires; the
255
+ // drag state stands in for "active" so the thumb still scales while
256
+ // the user is dragging (see `data-[active=true]:scale-105`).
257
+ active: isPressed || isDragging,
258
+ dragging: isDragging,
259
+ disabled: isDisabled,
260
+ })}
261
+ >
262
+ {children}
263
+ </Comp>
264
+ );
265
+ },
266
+ );
267
+
268
+ SliderThumb.displayName = 'SliderThumb';
@@ -0,0 +1,38 @@
1
+ import type { RefObject } from 'react';
2
+ import type { View } from 'react-native';
3
+ import { createContext } from '@cdx-ui/utils';
4
+ import type { SliderOrientation } from './types';
5
+
6
+ export interface SliderTrackLayout {
7
+ readonly width: number;
8
+ readonly height: number;
9
+ readonly x: number;
10
+ readonly y: number;
11
+ }
12
+
13
+ export interface SliderContextValue {
14
+ readonly value: number;
15
+ readonly min: number;
16
+ readonly max: number;
17
+ readonly step: number;
18
+ readonly percent: number;
19
+ readonly orientation: SliderOrientation;
20
+ readonly isDisabled: boolean;
21
+ readonly isDragging: boolean;
22
+ readonly isHovered: boolean;
23
+ readonly isFocused: boolean;
24
+ readonly trackLayout: SliderTrackLayout | null;
25
+ readonly thumbSize: number;
26
+ readonly setValue: (value: number, complete?: boolean) => void;
27
+ readonly setValueFromRatio: (ratio: number, complete?: boolean) => void;
28
+ readonly setTrackLayout: (layout: SliderTrackLayout) => void;
29
+ readonly setThumbSize: (size: number) => void;
30
+ readonly setIsDragging: (dragging: boolean) => void;
31
+ readonly setIsHovered: (hovered: boolean) => void;
32
+ readonly setIsFocused: (focused: boolean) => void;
33
+ readonly getValueLabel: () => string;
34
+ readonly thumbRef: RefObject<View | null>;
35
+ }
36
+
37
+ export const [SliderProvider, useSliderContext] =
38
+ createContext<SliderContextValue>('SliderContext');