@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.
- package/README.md +32 -30
- package/lib/commonjs/index.js +35 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/overlay/useAnchorPosition.js +60 -11
- package/lib/commonjs/overlay/useAnchorPosition.js.map +1 -1
- package/lib/commonjs/slider/SliderRail.js +131 -0
- package/lib/commonjs/slider/SliderRail.js.map +1 -0
- package/lib/commonjs/slider/SliderRoot.js +186 -0
- package/lib/commonjs/slider/SliderRoot.js.map +1 -0
- package/lib/commonjs/slider/SliderThumb.js +228 -0
- package/lib/commonjs/slider/SliderThumb.js.map +1 -0
- package/lib/commonjs/slider/context.js +11 -0
- package/lib/commonjs/slider/context.js.map +1 -0
- package/lib/commonjs/slider/index.js +65 -0
- package/lib/commonjs/slider/index.js.map +1 -0
- package/lib/commonjs/slider/types.js +6 -0
- package/lib/commonjs/slider/types.js.map +1 -0
- package/lib/commonjs/slider/useSliderPointer.js +116 -0
- package/lib/commonjs/slider/useSliderPointer.js.map +1 -0
- package/lib/commonjs/slider/utils.js +155 -0
- package/lib/commonjs/slider/utils.js.map +1 -0
- package/lib/commonjs/utils/assignRef.js +16 -0
- package/lib/commonjs/utils/assignRef.js.map +1 -0
- package/lib/commonjs/utils/createPortal.js +3 -1
- package/lib/commonjs/utils/createPortal.js.map +1 -1
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/overlay/useAnchorPosition.js +60 -11
- package/lib/module/overlay/useAnchorPosition.js.map +1 -1
- package/lib/module/slider/SliderRail.js +127 -0
- package/lib/module/slider/SliderRail.js.map +1 -0
- package/lib/module/slider/SliderRoot.js +182 -0
- package/lib/module/slider/SliderRoot.js.map +1 -0
- package/lib/module/slider/SliderThumb.js +224 -0
- package/lib/module/slider/SliderThumb.js.map +1 -0
- package/lib/module/slider/context.js +5 -0
- package/lib/module/slider/context.js.map +1 -0
- package/lib/module/slider/index.js +8 -0
- package/lib/module/slider/index.js.map +1 -0
- package/lib/module/slider/types.js +4 -0
- package/lib/module/slider/types.js.map +1 -0
- package/lib/module/slider/useSliderPointer.js +110 -0
- package/lib/module/slider/useSliderPointer.js.map +1 -0
- package/lib/module/slider/utils.js +138 -0
- package/lib/module/slider/utils.js.map +1 -0
- package/lib/module/utils/assignRef.js +12 -0
- package/lib/module/utils/assignRef.js.map +1 -0
- package/lib/module/utils/createPortal.js +3 -1
- package/lib/module/utils/createPortal.js.map +1 -1
- package/lib/typescript/index.d.ts +4 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/overlay/useAnchorPosition.d.ts +7 -1
- package/lib/typescript/overlay/useAnchorPosition.d.ts.map +1 -1
- package/lib/typescript/slider/SliderRail.d.ts +6 -0
- package/lib/typescript/slider/SliderRail.d.ts.map +1 -0
- package/lib/typescript/slider/SliderRoot.d.ts +7 -0
- package/lib/typescript/slider/SliderRoot.d.ts.map +1 -0
- package/lib/typescript/slider/SliderThumb.d.ts +6 -0
- package/lib/typescript/slider/SliderThumb.d.ts.map +1 -0
- package/lib/typescript/slider/context.d.ts +37 -0
- package/lib/typescript/slider/context.d.ts.map +1 -0
- package/lib/typescript/slider/index.d.ts +8 -0
- package/lib/typescript/slider/index.d.ts.map +1 -0
- package/lib/typescript/slider/types.d.ts +37 -0
- package/lib/typescript/slider/types.d.ts.map +1 -0
- package/lib/typescript/slider/useSliderPointer.d.ts +23 -0
- package/lib/typescript/slider/useSliderPointer.d.ts.map +1 -0
- package/lib/typescript/slider/utils.d.ts +23 -0
- package/lib/typescript/slider/utils.d.ts.map +1 -0
- package/lib/typescript/utils/assignRef.d.ts +3 -0
- package/lib/typescript/utils/assignRef.d.ts.map +1 -0
- package/lib/typescript/utils/createPortal.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +4 -0
- package/src/overlay/useAnchorPosition.ts +79 -11
- package/src/slider/SliderRail.tsx +143 -0
- package/src/slider/SliderRoot.tsx +266 -0
- package/src/slider/SliderThumb.tsx +268 -0
- package/src/slider/context.tsx +38 -0
- package/src/slider/index.ts +19 -0
- package/src/slider/types.ts +44 -0
- package/src/slider/useSliderPointer.ts +157 -0
- package/src/slider/utils.ts +181 -0
- package/src/utils/assignRef.ts +11 -0
- 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');
|