@cdx-ui/primitives 0.3.5 → 0.3.7
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/lib/commonjs/index.js +12 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/progress-bar/ProgressBarIndicator.js +56 -3
- package/lib/commonjs/progress-bar/ProgressBarIndicator.js.map +1 -1
- package/lib/commonjs/select/SelectEmpty.js +2 -13
- package/lib/commonjs/select/SelectEmpty.js.map +1 -1
- package/lib/commonjs/slider/useSliderPointer.js +9 -4
- package/lib/commonjs/slider/useSliderPointer.js.map +1 -1
- package/lib/commonjs/stepper/StepperRoot.js +119 -0
- package/lib/commonjs/stepper/StepperRoot.js.map +1 -0
- package/lib/commonjs/stepper/StepperStep.js +78 -0
- package/lib/commonjs/stepper/StepperStep.js.map +1 -0
- package/lib/commonjs/stepper/StepperStepPressable.js +63 -0
- package/lib/commonjs/stepper/StepperStepPressable.js.map +1 -0
- package/lib/commonjs/stepper/StepperStepTrigger.js +44 -0
- package/lib/commonjs/stepper/StepperStepTrigger.js.map +1 -0
- package/lib/commonjs/stepper/StepperStepTrigger.web.js +71 -0
- package/lib/commonjs/stepper/StepperStepTrigger.web.js.map +1 -0
- package/lib/commonjs/stepper/context.js +21 -0
- package/lib/commonjs/stepper/context.js.map +1 -0
- package/lib/commonjs/stepper/index.js +46 -0
- package/lib/commonjs/stepper/index.js.map +1 -0
- package/lib/commonjs/stepper/types.js +6 -0
- package/lib/commonjs/stepper/types.js.map +1 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/progress-bar/ProgressBarIndicator.js +57 -5
- package/lib/module/progress-bar/ProgressBarIndicator.js.map +1 -1
- package/lib/module/select/SelectEmpty.js +2 -13
- package/lib/module/select/SelectEmpty.js.map +1 -1
- package/lib/module/slider/useSliderPointer.js +9 -4
- package/lib/module/slider/useSliderPointer.js.map +1 -1
- package/lib/module/stepper/StepperRoot.js +115 -0
- package/lib/module/stepper/StepperRoot.js.map +1 -0
- package/lib/module/stepper/StepperStep.js +74 -0
- package/lib/module/stepper/StepperStep.js.map +1 -0
- package/lib/module/stepper/StepperStepPressable.js +60 -0
- package/lib/module/stepper/StepperStepPressable.js.map +1 -0
- package/lib/module/stepper/StepperStepTrigger.js +40 -0
- package/lib/module/stepper/StepperStepTrigger.js.map +1 -0
- package/lib/module/stepper/StepperStepTrigger.web.js +67 -0
- package/lib/module/stepper/StepperStepTrigger.web.js.map +1 -0
- package/lib/module/stepper/context.js +11 -0
- package/lib/module/stepper/context.js.map +1 -0
- package/lib/module/stepper/index.js +7 -0
- package/lib/module/stepper/index.js.map +1 -0
- package/lib/module/stepper/types.js +4 -0
- package/lib/module/stepper/types.js.map +1 -0
- package/lib/typescript/index.d.ts +1 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/progress-bar/ProgressBarIndicator.d.ts.map +1 -1
- package/lib/typescript/select/SelectEmpty.d.ts.map +1 -1
- package/lib/typescript/slider/useSliderPointer.d.ts.map +1 -1
- package/lib/typescript/stepper/StepperRoot.d.ts +7 -0
- package/lib/typescript/stepper/StepperRoot.d.ts.map +1 -0
- package/lib/typescript/stepper/StepperStep.d.ts +7 -0
- package/lib/typescript/stepper/StepperStep.d.ts.map +1 -0
- package/lib/typescript/stepper/StepperStepPressable.d.ts +8 -0
- package/lib/typescript/stepper/StepperStepPressable.d.ts.map +1 -0
- package/lib/typescript/stepper/StepperStepTrigger.d.ts +9 -0
- package/lib/typescript/stepper/StepperStepTrigger.d.ts.map +1 -0
- package/lib/typescript/stepper/StepperStepTrigger.web.d.ts +9 -0
- package/lib/typescript/stepper/StepperStepTrigger.web.d.ts.map +1 -0
- package/lib/typescript/stepper/context.d.ts +16 -0
- package/lib/typescript/stepper/context.d.ts.map +1 -0
- package/lib/typescript/stepper/index.d.ts +6 -0
- package/lib/typescript/stepper/index.d.ts.map +1 -0
- package/lib/typescript/stepper/types.d.ts +96 -0
- package/lib/typescript/stepper/types.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/index.ts +1 -0
- package/src/progress-bar/ProgressBarIndicator.tsx +85 -6
- package/src/select/SelectEmpty.tsx +2 -22
- package/src/slider/useSliderPointer.ts +9 -4
- package/src/stepper/StepperRoot.tsx +148 -0
- package/src/stepper/StepperStep.tsx +101 -0
- package/src/stepper/StepperStepPressable.tsx +48 -0
- package/src/stepper/StepperStepTrigger.tsx +38 -0
- package/src/stepper/StepperStepTrigger.web.tsx +69 -0
- package/src/stepper/context.tsx +11 -0
- package/src/stepper/index.ts +16 -0
- package/src/stepper/types.ts +104 -0
|
@@ -1,20 +1,91 @@
|
|
|
1
|
-
import { forwardRef, useMemo } from 'react';
|
|
2
|
-
import {
|
|
1
|
+
import { forwardRef, useEffect, useMemo } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
I18nManager,
|
|
4
|
+
View,
|
|
5
|
+
type LayoutChangeEvent,
|
|
6
|
+
type ViewProps,
|
|
7
|
+
type ViewStyle,
|
|
8
|
+
} from 'react-native';
|
|
9
|
+
import Animated, {
|
|
10
|
+
Easing,
|
|
11
|
+
useAnimatedStyle,
|
|
12
|
+
useReducedMotion,
|
|
13
|
+
useSharedValue,
|
|
14
|
+
withRepeat,
|
|
15
|
+
withTiming,
|
|
16
|
+
} from 'react-native-reanimated';
|
|
17
|
+
import { composeEventHandlers } from '@cdx-ui/utils';
|
|
3
18
|
import { Slot } from '../slot';
|
|
4
19
|
import { dataAttributes } from '../utils/dataAttributes';
|
|
5
20
|
import { useProgressContext } from './context';
|
|
6
21
|
|
|
7
22
|
type PercentString = `${number}%`;
|
|
8
23
|
|
|
24
|
+
/**
|
|
25
|
+
* HeroUI indeterminate fill: a 2/5 segment translates from -100% to 350% of its
|
|
26
|
+
* own width over 1.5s (`progress-bar.css`). It enters off the start edge and
|
|
27
|
+
* exits off the end edge; the ease holds the slow parts of that travel off-screen.
|
|
28
|
+
*/
|
|
29
|
+
const INDETERMINATE_WIDTH = '40%';
|
|
30
|
+
const INDETERMINATE_DURATION_MS = 1500;
|
|
31
|
+
const INDETERMINATE_FROM = -1;
|
|
32
|
+
const INDETERMINATE_TO = 3.5;
|
|
33
|
+
|
|
9
34
|
export interface ProgressBarIndicatorProps extends ViewProps {
|
|
10
35
|
readonly asChild?: boolean;
|
|
11
36
|
}
|
|
12
37
|
|
|
13
38
|
export const ProgressBarIndicator = forwardRef<View, ProgressBarIndicatorProps>(
|
|
14
|
-
({ asChild, style, ...rest }, ref) => {
|
|
39
|
+
({ asChild, style, onLayout, ...rest }, ref) => {
|
|
15
40
|
const { percent, state, max, value } = useProgressContext();
|
|
41
|
+
const isIndeterminate = state === 'indeterminate';
|
|
42
|
+
const reducedMotion = useReducedMotion();
|
|
43
|
+
const travel = useSharedValue(0);
|
|
44
|
+
const segmentWidth = useSharedValue(0);
|
|
45
|
+
const direction = I18nManager.isRTL ? -1 : 1;
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!isIndeterminate) {
|
|
49
|
+
travel.set(0);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (reducedMotion) {
|
|
53
|
+
travel.set(0);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
travel.set(0);
|
|
57
|
+
travel.set(
|
|
58
|
+
withRepeat(
|
|
59
|
+
withTiming(1, {
|
|
60
|
+
duration: INDETERMINATE_DURATION_MS,
|
|
61
|
+
easing: Easing.bezier(0.65, 0, 0.35, 1),
|
|
62
|
+
}),
|
|
63
|
+
-1,
|
|
64
|
+
false,
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
}, [isIndeterminate, reducedMotion, travel]);
|
|
68
|
+
|
|
69
|
+
const handleLayout = (event: LayoutChangeEvent) => {
|
|
70
|
+
if (!isIndeterminate) return;
|
|
71
|
+
const next = event.nativeEvent.layout.width;
|
|
72
|
+
if (segmentWidth.get() !== next) {
|
|
73
|
+
segmentWidth.set(next);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const indeterminateStyle = useAnimatedStyle(() => {
|
|
78
|
+
const width = segmentWidth.get();
|
|
79
|
+
const span = INDETERMINATE_TO - INDETERMINATE_FROM;
|
|
80
|
+
const offset = reducedMotion ? 0 : width * (INDETERMINATE_FROM + travel.get() * span);
|
|
81
|
+
return {
|
|
82
|
+
width: INDETERMINATE_WIDTH,
|
|
83
|
+
height: '100%',
|
|
84
|
+
transform: [{ translateX: direction * offset }],
|
|
85
|
+
};
|
|
86
|
+
}, [segmentWidth, travel, direction, reducedMotion]);
|
|
16
87
|
|
|
17
|
-
const
|
|
88
|
+
const determinateStyle = useMemo((): ViewStyle => {
|
|
18
89
|
const pct = `${String(percent)}%` as PercentString;
|
|
19
90
|
return { width: pct, height: '100%' };
|
|
20
91
|
}, [percent]);
|
|
@@ -25,9 +96,17 @@ export const ProgressBarIndicator = forwardRef<View, ProgressBarIndicatorProps>(
|
|
|
25
96
|
max: String(max),
|
|
26
97
|
});
|
|
27
98
|
|
|
28
|
-
const Comp = asChild ? Slot : View;
|
|
99
|
+
const Comp = asChild ? Slot : Animated.View;
|
|
29
100
|
|
|
30
|
-
return
|
|
101
|
+
return (
|
|
102
|
+
<Comp
|
|
103
|
+
ref={ref}
|
|
104
|
+
{...rest}
|
|
105
|
+
{...dataAttrs}
|
|
106
|
+
onLayout={composeEventHandlers(onLayout, handleLayout)}
|
|
107
|
+
style={[isIndeterminate ? indeterminateStyle : determinateStyle, style]}
|
|
108
|
+
/>
|
|
109
|
+
);
|
|
31
110
|
},
|
|
32
111
|
);
|
|
33
112
|
|
|
@@ -1,12 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Children,
|
|
3
|
-
forwardRef,
|
|
4
|
-
isValidElement,
|
|
5
|
-
useLayoutEffect,
|
|
6
|
-
type ComponentRef,
|
|
7
|
-
type ReactNode,
|
|
8
|
-
} from 'react';
|
|
1
|
+
import { forwardRef, useLayoutEffect, type ComponentRef } from 'react';
|
|
9
2
|
import { View } from 'react-native';
|
|
3
|
+
import { getTextContent } from '@cdx-ui/utils';
|
|
10
4
|
import { Slot } from '../slot';
|
|
11
5
|
import { useSelectContext } from './context';
|
|
12
6
|
import type { ISelectEmptyProps } from './types';
|
|
@@ -16,20 +10,6 @@ export interface SelectEmptyProps extends ISelectEmptyProps {
|
|
|
16
10
|
className?: string;
|
|
17
11
|
}
|
|
18
12
|
|
|
19
|
-
function getTextContent(node: ReactNode): string {
|
|
20
|
-
return Children.toArray(node)
|
|
21
|
-
.map((child) => {
|
|
22
|
-
if (typeof child === 'string' || typeof child === 'number') {
|
|
23
|
-
return String(child);
|
|
24
|
-
}
|
|
25
|
-
if (isValidElement<{ children?: ReactNode }>(child)) {
|
|
26
|
-
return getTextContent(child.props.children);
|
|
27
|
-
}
|
|
28
|
-
return '';
|
|
29
|
-
})
|
|
30
|
-
.join('');
|
|
31
|
-
}
|
|
32
|
-
|
|
33
13
|
/**
|
|
34
14
|
* Renders only when no option matches. Its text is also what the search live region
|
|
35
15
|
* announces in that case.
|
|
@@ -63,6 +63,11 @@ export function useWebSliderDrag({
|
|
|
63
63
|
const lastRatioRef = useRef(0);
|
|
64
64
|
const dragContextRef = useRef<WebDragContext | null>(null);
|
|
65
65
|
const cleanupRef = useRef<(() => void) | null>(null);
|
|
66
|
+
// Window listeners outlive their render; stale setters would compare against the drag-start value.
|
|
67
|
+
const setValueFromRatioRef = useRef(setValueFromRatio);
|
|
68
|
+
const setIsDraggingRef = useRef(setIsDragging);
|
|
69
|
+
setValueFromRatioRef.current = setValueFromRatio;
|
|
70
|
+
setIsDraggingRef.current = setIsDragging;
|
|
66
71
|
|
|
67
72
|
useEffect(() => {
|
|
68
73
|
return () => {
|
|
@@ -77,9 +82,9 @@ export function useWebSliderDrag({
|
|
|
77
82
|
}
|
|
78
83
|
draggingRef.current = false;
|
|
79
84
|
dragContextRef.current = null;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}, [
|
|
85
|
+
setValueFromRatioRef.current(lastRatioRef.current, true);
|
|
86
|
+
setIsDraggingRef.current(false);
|
|
87
|
+
}, []);
|
|
83
88
|
|
|
84
89
|
const startDrag = useCallback(
|
|
85
90
|
(
|
|
@@ -132,7 +137,7 @@ export function useWebSliderDrag({
|
|
|
132
137
|
1,
|
|
133
138
|
);
|
|
134
139
|
lastRatioRef.current = nextRatio;
|
|
135
|
-
|
|
140
|
+
setValueFromRatioRef.current(nextRatio);
|
|
136
141
|
};
|
|
137
142
|
|
|
138
143
|
const onUp = () => {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cloneElement,
|
|
3
|
+
forwardRef,
|
|
4
|
+
isValidElement,
|
|
5
|
+
useCallback,
|
|
6
|
+
useMemo,
|
|
7
|
+
type ComponentRef,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { Platform, View } from 'react-native';
|
|
11
|
+
import { flattenChildren, useControllableState } from '@cdx-ui/utils';
|
|
12
|
+
import { Slot } from '../slot';
|
|
13
|
+
import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
|
|
14
|
+
import { StepperIndexProvider, StepperProvider } from './context';
|
|
15
|
+
import type { IStepperProps, StepperStepState } from './types';
|
|
16
|
+
|
|
17
|
+
export interface StepperRootProps extends IStepperProps {
|
|
18
|
+
readonly className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toAnnouncedPercent(progress: number): number {
|
|
22
|
+
return Math.min(99, Math.max(1, Math.round(progress)));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function defaultGetStepAccessibilityText({
|
|
26
|
+
isCurrent,
|
|
27
|
+
isComplete,
|
|
28
|
+
hasProgress,
|
|
29
|
+
progress,
|
|
30
|
+
}: StepperStepState): string {
|
|
31
|
+
const parts: string[] = [];
|
|
32
|
+
if (isCurrent) {
|
|
33
|
+
parts.push('current step');
|
|
34
|
+
}
|
|
35
|
+
if (isComplete) {
|
|
36
|
+
parts.push('completed');
|
|
37
|
+
} else if (hasProgress) {
|
|
38
|
+
parts.push(`${String(toAnnouncedPercent(progress))}% complete`);
|
|
39
|
+
} else if (!isCurrent) {
|
|
40
|
+
parts.push('incomplete');
|
|
41
|
+
}
|
|
42
|
+
return parts.join(', ');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Every direct element child is a step; fragments are unwrapped and `false`/`null` are
|
|
47
|
+
* dropped, so conditional steps renumber on the same render.
|
|
48
|
+
*/
|
|
49
|
+
function indexSteps(children: ReactNode): { steps: ReactNode[]; count: number } {
|
|
50
|
+
let count = 0;
|
|
51
|
+
const steps = flattenChildren(children).map((child) => {
|
|
52
|
+
if (!isValidElement(child)) {
|
|
53
|
+
return child;
|
|
54
|
+
}
|
|
55
|
+
const index = count;
|
|
56
|
+
count += 1;
|
|
57
|
+
return (
|
|
58
|
+
<StepperIndexProvider key={child.key} value={index}>
|
|
59
|
+
{child}
|
|
60
|
+
</StepperIndexProvider>
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
return { steps, count };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const StepperRoot = forwardRef<ComponentRef<typeof View>, StepperRootProps>(
|
|
67
|
+
(
|
|
68
|
+
{
|
|
69
|
+
asChild,
|
|
70
|
+
children,
|
|
71
|
+
currentStep: currentStepProp,
|
|
72
|
+
defaultStep = 0,
|
|
73
|
+
onStepChange,
|
|
74
|
+
isLinear = true,
|
|
75
|
+
orientation = 'vertical',
|
|
76
|
+
labelPlacement = 'end',
|
|
77
|
+
getStepAccessibilityText = defaultGetStepAccessibilityText,
|
|
78
|
+
accessibilityLabel = 'Progress',
|
|
79
|
+
role = 'list',
|
|
80
|
+
...props
|
|
81
|
+
},
|
|
82
|
+
ref,
|
|
83
|
+
) => {
|
|
84
|
+
const [currentStepValue, setCurrentStepValue] = useControllableState<number>({
|
|
85
|
+
prop: currentStepProp,
|
|
86
|
+
defaultProp: defaultStep,
|
|
87
|
+
onChange: onStepChange,
|
|
88
|
+
});
|
|
89
|
+
const currentStep = currentStepValue ?? defaultStep;
|
|
90
|
+
|
|
91
|
+
const setCurrentStep = useCallback(
|
|
92
|
+
(index: number) => {
|
|
93
|
+
setCurrentStepValue(index);
|
|
94
|
+
},
|
|
95
|
+
[setCurrentStepValue],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const asChildElement =
|
|
99
|
+
asChild && isValidElement<{ children?: ReactNode }>(children) ? children : null;
|
|
100
|
+
const { steps, count } = indexSteps(asChildElement ? asChildElement.props.children : children);
|
|
101
|
+
|
|
102
|
+
const contextValue = useMemo(
|
|
103
|
+
() => ({
|
|
104
|
+
currentStep,
|
|
105
|
+
count,
|
|
106
|
+
orientation,
|
|
107
|
+
labelPlacement,
|
|
108
|
+
isLinear,
|
|
109
|
+
hasStepChangeHandler: onStepChange !== undefined,
|
|
110
|
+
setCurrentStep,
|
|
111
|
+
getStepAccessibilityText,
|
|
112
|
+
}),
|
|
113
|
+
[
|
|
114
|
+
currentStep,
|
|
115
|
+
count,
|
|
116
|
+
orientation,
|
|
117
|
+
labelPlacement,
|
|
118
|
+
isLinear,
|
|
119
|
+
onStepChange,
|
|
120
|
+
setCurrentStep,
|
|
121
|
+
getStepAccessibilityText,
|
|
122
|
+
],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const a11yProps =
|
|
126
|
+
Platform.OS === 'web'
|
|
127
|
+
? ({ 'aria-label': accessibilityLabel } as const)
|
|
128
|
+
: ({ accessibilityLabel } as const);
|
|
129
|
+
|
|
130
|
+
const Comp = asChild ? Slot : View;
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<StepperProvider value={contextValue}>
|
|
134
|
+
<Comp
|
|
135
|
+
ref={ref}
|
|
136
|
+
{...props}
|
|
137
|
+
role={role}
|
|
138
|
+
{...a11yProps}
|
|
139
|
+
{...mergeDataAttributes(props, dataAttributes({ orientation, labelPlacement }))}
|
|
140
|
+
>
|
|
141
|
+
{asChildElement ? cloneElement(asChildElement, undefined, steps) : steps}
|
|
142
|
+
</Comp>
|
|
143
|
+
</StepperProvider>
|
|
144
|
+
);
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
StepperRoot.displayName = 'StepperRoot';
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { forwardRef, useMemo, type ComponentRef } from 'react';
|
|
2
|
+
import { Platform, View } from 'react-native';
|
|
3
|
+
import { Slot } from '../slot';
|
|
4
|
+
import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
|
|
5
|
+
import { StepperStepProvider, useStepperContext, useStepperIndex } from './context';
|
|
6
|
+
import type { IStepperStepProps, StepperStepState } from './types';
|
|
7
|
+
|
|
8
|
+
export interface StepperStepProps extends IStepperStepProps {
|
|
9
|
+
readonly className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function clampProgress(progress: number): number {
|
|
13
|
+
return Number.isFinite(progress) ? Math.min(100, Math.max(0, progress)) : 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const StepperStep = forwardRef<ComponentRef<typeof View>, StepperStepProps>(
|
|
17
|
+
(
|
|
18
|
+
{
|
|
19
|
+
asChild,
|
|
20
|
+
children,
|
|
21
|
+
isComplete: isCompleteProp,
|
|
22
|
+
progress: progressProp = 0,
|
|
23
|
+
isDisabled = false,
|
|
24
|
+
role = 'listitem',
|
|
25
|
+
...props
|
|
26
|
+
},
|
|
27
|
+
ref,
|
|
28
|
+
) => {
|
|
29
|
+
const index = useStepperIndex();
|
|
30
|
+
const { currentStep, count, orientation, labelPlacement, isLinear, hasStepChangeHandler } =
|
|
31
|
+
useStepperContext();
|
|
32
|
+
|
|
33
|
+
const isCurrent = index === currentStep;
|
|
34
|
+
const isComplete = isCompleteProp ?? index < currentStep;
|
|
35
|
+
const progress = clampProgress(progressProp);
|
|
36
|
+
const hasProgress = progress > 0 && !isComplete;
|
|
37
|
+
// The current step is never a button, so it adds no Tab stop that does nothing.
|
|
38
|
+
const isClickable =
|
|
39
|
+
hasStepChangeHandler && !isDisabled && !isCurrent && (isComplete || !isLinear);
|
|
40
|
+
|
|
41
|
+
const state = useMemo(
|
|
42
|
+
(): StepperStepState => ({
|
|
43
|
+
index,
|
|
44
|
+
count,
|
|
45
|
+
isCurrent,
|
|
46
|
+
isComplete,
|
|
47
|
+
progress,
|
|
48
|
+
hasProgress,
|
|
49
|
+
isLast: index === count - 1,
|
|
50
|
+
isClickable,
|
|
51
|
+
isDisabled,
|
|
52
|
+
orientation,
|
|
53
|
+
labelPlacement,
|
|
54
|
+
}),
|
|
55
|
+
[
|
|
56
|
+
index,
|
|
57
|
+
count,
|
|
58
|
+
isCurrent,
|
|
59
|
+
isComplete,
|
|
60
|
+
progress,
|
|
61
|
+
hasProgress,
|
|
62
|
+
isClickable,
|
|
63
|
+
isDisabled,
|
|
64
|
+
orientation,
|
|
65
|
+
labelPlacement,
|
|
66
|
+
],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const currentProps =
|
|
70
|
+
Platform.OS === 'web' && isCurrent ? ({ 'aria-current': 'step' } as const) : {};
|
|
71
|
+
|
|
72
|
+
const Comp = asChild ? Slot : View;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<StepperStepProvider value={state}>
|
|
76
|
+
<Comp
|
|
77
|
+
ref={ref}
|
|
78
|
+
{...props}
|
|
79
|
+
role={role}
|
|
80
|
+
{...currentProps}
|
|
81
|
+
{...mergeDataAttributes(
|
|
82
|
+
props,
|
|
83
|
+
dataAttributes({
|
|
84
|
+
orientation,
|
|
85
|
+
labelPlacement,
|
|
86
|
+
current: isCurrent,
|
|
87
|
+
complete: isComplete,
|
|
88
|
+
progress: hasProgress,
|
|
89
|
+
clickable: isClickable,
|
|
90
|
+
disabled: isDisabled,
|
|
91
|
+
}),
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
{children}
|
|
95
|
+
</Comp>
|
|
96
|
+
</StepperStepProvider>
|
|
97
|
+
);
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
StepperStep.displayName = 'StepperStep';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { forwardRef, type ComponentRef } from 'react';
|
|
2
|
+
import { Pressable, type GestureResponderEvent, type PressableProps } from 'react-native';
|
|
3
|
+
import { composeEventHandlers } from '@cdx-ui/utils';
|
|
4
|
+
import { useFocusRing } from '@react-native-aria/focus';
|
|
5
|
+
import { useHover, usePress } from '@react-native-aria/interactions';
|
|
6
|
+
import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
|
|
7
|
+
import { useStepperContext, useStepperStep } from './context';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Press target for a clickable step: a `button` that is reachable with Tab and activates
|
|
11
|
+
* with Enter or Space. Rendered by `StepperStepTrigger` only while the step is clickable,
|
|
12
|
+
* so display-only steps add no focus stop and no interaction state.
|
|
13
|
+
*/
|
|
14
|
+
export const StepperStepPressable = forwardRef<ComponentRef<typeof Pressable>, PressableProps>(
|
|
15
|
+
({ onPress, ...props }, ref) => {
|
|
16
|
+
const { index } = useStepperStep();
|
|
17
|
+
const { setCurrentStep } = useStepperContext();
|
|
18
|
+
const { isFocusVisible, focusProps } = useFocusRing() as {
|
|
19
|
+
isFocusVisible: boolean;
|
|
20
|
+
focusProps: Pick<PressableProps, 'onFocus' | 'onBlur'>;
|
|
21
|
+
};
|
|
22
|
+
const { pressProps, isPressed } = usePress({});
|
|
23
|
+
const { isHovered, hoverProps } = useHover();
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Pressable
|
|
27
|
+
ref={ref}
|
|
28
|
+
role="button"
|
|
29
|
+
{...props}
|
|
30
|
+
{...mergeDataAttributes(
|
|
31
|
+
props,
|
|
32
|
+
dataAttributes({ hover: isHovered, active: isPressed, focusVisible: isFocusVisible }),
|
|
33
|
+
)}
|
|
34
|
+
onPress={composeEventHandlers<GestureResponderEvent>(onPress, () => {
|
|
35
|
+
setCurrentStep(index);
|
|
36
|
+
})}
|
|
37
|
+
onPressIn={composeEventHandlers(props.onPressIn, pressProps.onPressIn)}
|
|
38
|
+
onPressOut={composeEventHandlers(props.onPressOut, pressProps.onPressOut)}
|
|
39
|
+
onHoverIn={composeEventHandlers(props.onHoverIn, hoverProps.onHoverIn)}
|
|
40
|
+
onHoverOut={composeEventHandlers(props.onHoverOut, hoverProps.onHoverOut)}
|
|
41
|
+
onFocus={composeEventHandlers(props.onFocus, focusProps.onFocus)}
|
|
42
|
+
onBlur={composeEventHandlers(props.onBlur, focusProps.onBlur)}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
StepperStepPressable.displayName = 'StepperStepPressable';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { forwardRef, type ComponentRef } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
|
|
4
|
+
import { useStepperContext, useStepperStep } from './context';
|
|
5
|
+
import { StepperStepPressable } from './StepperStepPressable';
|
|
6
|
+
import type { IStepperStepTriggerProps } from './types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Internal step region that carries the step's accessible name: the grouped visible
|
|
10
|
+
* content (indicator, title, description) followed by the state phrase. It becomes a
|
|
11
|
+
* `button` while the step is clickable. Not a public styled part; the styled
|
|
12
|
+
* `Stepper.Step` renders it around those slots.
|
|
13
|
+
*/
|
|
14
|
+
export const StepperStepTrigger = forwardRef<ComponentRef<typeof View>, IStepperStepTriggerProps>(
|
|
15
|
+
({ children, ...props }, ref) => {
|
|
16
|
+
const state = useStepperStep();
|
|
17
|
+
const { getStepAccessibilityText } = useStepperContext();
|
|
18
|
+
const phrase = getStepAccessibilityText(state);
|
|
19
|
+
const Comp = state.isClickable ? StepperStepPressable : View;
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Comp
|
|
23
|
+
ref={ref}
|
|
24
|
+
accessible
|
|
25
|
+
accessibilityValue={phrase ? { text: phrase } : undefined}
|
|
26
|
+
{...props}
|
|
27
|
+
{...mergeDataAttributes(
|
|
28
|
+
props,
|
|
29
|
+
dataAttributes({ clickable: state.isClickable, disabled: state.isDisabled }),
|
|
30
|
+
)}
|
|
31
|
+
>
|
|
32
|
+
{children}
|
|
33
|
+
</Comp>
|
|
34
|
+
);
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
StepperStepTrigger.displayName = 'StepperStepTrigger';
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { forwardRef, useLayoutEffect, useMemo, useRef, type ComponentRef } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
import { VisuallyHidden } from '@react-aria/visually-hidden';
|
|
4
|
+
import { composeEventHandlers, mergeRefs } from '@cdx-ui/utils';
|
|
5
|
+
import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
|
|
6
|
+
import { useStepperContext, useStepperStep } from './context';
|
|
7
|
+
import { StepperStepPressable } from './StepperStepPressable';
|
|
8
|
+
import type { IStepperStepTriggerProps } from './types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Internal step region that carries the step's accessible name. On web the state phrase
|
|
12
|
+
* is screen-reader-only text after the visible content, because browse-mode screen
|
|
13
|
+
* readers ignore `aria-label` on a list item. It becomes a `button` while the step is
|
|
14
|
+
* clickable. Not a public styled part.
|
|
15
|
+
*/
|
|
16
|
+
export const StepperStepTrigger = forwardRef<ComponentRef<typeof View>, IStepperStepTriggerProps>(
|
|
17
|
+
({ children, ...props }, ref) => {
|
|
18
|
+
const state = useStepperStep();
|
|
19
|
+
const { getStepAccessibilityText } = useStepperContext();
|
|
20
|
+
const phrase = getStepAccessibilityText(state);
|
|
21
|
+
const Comp = state.isClickable ? StepperStepPressable : View;
|
|
22
|
+
|
|
23
|
+
const nodeRef = useRef<ComponentRef<typeof View> | null>(null);
|
|
24
|
+
const mergedRef = useMemo(() => mergeRefs(ref, nodeRef), [ref]);
|
|
25
|
+
// Activating a step makes it current, which swaps the focused button for a plain region
|
|
26
|
+
// and would drop focus to the document. Focus moves to that region instead, outside the
|
|
27
|
+
// Tab order.
|
|
28
|
+
const restoreFocusRef = useRef(false);
|
|
29
|
+
|
|
30
|
+
useLayoutEffect(() => {
|
|
31
|
+
const node = nodeRef.current as unknown as HTMLElement | null;
|
|
32
|
+
if (!restoreFocusRef.current || state.isClickable || node === null) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
restoreFocusRef.current = false;
|
|
36
|
+
node.tabIndex = -1;
|
|
37
|
+
node.focus({ preventScroll: true });
|
|
38
|
+
}, [state.isClickable]);
|
|
39
|
+
|
|
40
|
+
const pressProps = state.isClickable
|
|
41
|
+
? {
|
|
42
|
+
onPress: () => {
|
|
43
|
+
restoreFocusRef.current =
|
|
44
|
+
document.activeElement === (nodeRef.current as unknown as HTMLElement | null);
|
|
45
|
+
},
|
|
46
|
+
onBlur: composeEventHandlers(props.onBlur, () => {
|
|
47
|
+
restoreFocusRef.current = false;
|
|
48
|
+
}),
|
|
49
|
+
}
|
|
50
|
+
: {};
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Comp
|
|
54
|
+
ref={mergedRef}
|
|
55
|
+
{...props}
|
|
56
|
+
{...pressProps}
|
|
57
|
+
{...mergeDataAttributes(
|
|
58
|
+
props,
|
|
59
|
+
dataAttributes({ clickable: state.isClickable, disabled: state.isDisabled }),
|
|
60
|
+
)}
|
|
61
|
+
>
|
|
62
|
+
{children}
|
|
63
|
+
{phrase ? <VisuallyHidden elementType="span">{`, ${phrase}`}</VisuallyHidden> : null}
|
|
64
|
+
</Comp>
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
StepperStepTrigger.displayName = 'StepperStepTrigger';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createContext } from '@cdx-ui/utils';
|
|
2
|
+
import type { StepperContextValue, StepperStepState } from './types';
|
|
3
|
+
|
|
4
|
+
export const [StepperProvider, useStepperContext] =
|
|
5
|
+
createContext<StepperContextValue>('StepperContext');
|
|
6
|
+
|
|
7
|
+
/** Position of a direct child of `StepperRoot`, supplied by the root while it renders. */
|
|
8
|
+
export const [StepperIndexProvider, useStepperIndex] = createContext<number>('StepperIndex');
|
|
9
|
+
|
|
10
|
+
/** Resolved state of the enclosing step. `useStepperStep` throws outside a `StepperStep`. */
|
|
11
|
+
export const [StepperStepProvider, useStepperStep] = createContext<StepperStepState>('StepperStep');
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
IStepperProps,
|
|
3
|
+
IStepperStepProps,
|
|
4
|
+
IStepperStepTriggerProps,
|
|
5
|
+
InterfaceStepperProps,
|
|
6
|
+
InterfaceStepperStepProps,
|
|
7
|
+
StepperContextValue,
|
|
8
|
+
StepperLabelPlacement,
|
|
9
|
+
StepperOrientation,
|
|
10
|
+
StepperStepState,
|
|
11
|
+
} from './types';
|
|
12
|
+
|
|
13
|
+
export { useStepperContext, useStepperIndex, useStepperStep } from './context';
|
|
14
|
+
export { StepperRoot, type StepperRootProps } from './StepperRoot';
|
|
15
|
+
export { StepperStep, type StepperStepProps } from './StepperStep';
|
|
16
|
+
export { StepperStepTrigger } from './StepperStepTrigger';
|