@isoline-ui/react-native 0.0.0-stage → 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/LICENSE +22 -0
- package/NOTICE.md +12 -0
- package/README.md +104 -3
- package/dist/amount.d.ts +3 -0
- package/dist/calendar.d.ts +7 -0
- package/dist/components/action-chip.d.ts +9 -0
- package/dist/components/amount-field.d.ts +12 -0
- package/dist/components/avatar.d.ts +17 -0
- package/dist/components/back-button.d.ts +12 -0
- package/dist/components/back-icon.d.ts +3 -0
- package/dist/components/badge.d.ts +13 -0
- package/dist/components/banner.d.ts +14 -0
- package/dist/components/bottom-sheet-native.d.ts +11 -0
- package/dist/components/bottom-sheet-web.d.ts +18 -0
- package/dist/components/bottom-sheet.d.ts +3 -0
- package/dist/components/button-group.d.ts +26 -0
- package/dist/components/button.d.ts +48 -0
- package/dist/components/checkbox.d.ts +12 -0
- package/dist/components/color-picker.d.ts +21 -0
- package/dist/components/context-menu.d.ts +26 -0
- package/dist/components/date-picker.d.ts +64 -0
- package/dist/components/dialog-context.d.ts +2 -0
- package/dist/components/dialog.d.ts +20 -0
- package/dist/components/empty-state.d.ts +15 -0
- package/dist/components/expand-indicator.d.ts +27 -0
- package/dist/components/expressive-shape.d.ts +18 -0
- package/dist/components/fab-menu-legacy.d.ts +31 -0
- package/dist/components/fab-menu.d.ts +21 -0
- package/dist/components/filter-chip.d.ts +69 -0
- package/dist/components/floating-action-button.d.ts +15 -0
- package/dist/components/icon-button.d.ts +40 -0
- package/dist/components/icons.d.ts +18 -0
- package/dist/components/input.d.ts +9 -0
- package/dist/components/keyboard-inset-view.d.ts +15 -0
- package/dist/components/list-controls.d.ts +38 -0
- package/dist/components/loading-indicator.d.ts +9 -0
- package/dist/components/menu-sheet.d.ts +36 -0
- package/dist/components/panel-sheet.d.ts +8 -0
- package/dist/components/password-visibility.d.ts +5 -0
- package/dist/components/popover-layout.d.ts +25 -0
- package/dist/components/popover.d.ts +20 -0
- package/dist/components/pressable.d.ts +12 -0
- package/dist/components/radio-navigation.d.ts +11 -0
- package/dist/components/refresh-control.d.ts +16 -0
- package/dist/components/row-gap.d.ts +9 -0
- package/dist/components/search-field.d.ts +22 -0
- package/dist/components/select-field.d.ts +16 -0
- package/dist/components/settings-list.d.ts +98 -0
- package/dist/components/settings-row.d.ts +13 -0
- package/dist/components/sheet-frame.d.ts +27 -0
- package/dist/components/sheet-lifecycle.d.ts +36 -0
- package/dist/components/sheet-transition.d.ts +36 -0
- package/dist/components/slider.d.ts +21 -0
- package/dist/components/snackbar.d.ts +10 -0
- package/dist/components/split-button.d.ts +38 -0
- package/dist/components/squish.d.ts +20 -0
- package/dist/components/swipeable-row.d.ts +20 -0
- package/dist/components/switch.d.ts +8 -0
- package/dist/components/tab-bar.d.ts +20 -0
- package/dist/components/text-field.d.ts +31 -0
- package/dist/components/text.d.ts +9 -0
- package/dist/components/time-picker.d.ts +40 -0
- package/dist/components/toolbar.d.ts +77 -0
- package/dist/components/top-bar.d.ts +27 -0
- package/dist/components/undo-bar.d.ts +23 -0
- package/dist/components/wavy-progress.d.ts +21 -0
- package/dist/components/zoomable-image.d.ts +18 -0
- package/dist/expressive-shapes.d.ts +8 -0
- package/dist/index.d.ts +62 -0
- package/dist/keyboard.d.ts +2 -0
- package/dist/material.d.ts +11 -0
- package/dist/media.d.ts +11 -0
- package/dist/motion.d.ts +96 -0
- package/dist/platform.d.ts +3 -0
- package/dist/preset-tokens.d.ts +264 -0
- package/dist/provider.d.ts +59 -0
- package/dist/reduced-motion.d.ts +45 -0
- package/dist/selection.d.ts +2 -0
- package/dist/shape.d.ts +99 -0
- package/dist/sheet-physics.d.ts +37 -0
- package/dist/theme.d.ts +103 -0
- package/dist/tokens.d.ts +2 -0
- package/dist/use-keyboard-height.d.ts +10 -0
- package/dist/utils.d.ts +3 -0
- package/nativewind.cjs +68 -0
- package/package.json +63 -6
- package/src/amount.ts +53 -0
- package/src/calendar.ts +42 -0
- package/src/components/action-chip.tsx +64 -0
- package/src/components/amount-field.tsx +49 -0
- package/src/components/avatar.tsx +91 -0
- package/src/components/back-button.tsx +39 -0
- package/src/components/back-icon.tsx +25 -0
- package/src/components/badge.tsx +41 -0
- package/src/components/banner.tsx +63 -0
- package/src/components/bottom-sheet-native.tsx +52 -0
- package/src/components/bottom-sheet-web.tsx +193 -0
- package/src/components/bottom-sheet.tsx +5 -0
- package/src/components/button-group.tsx +326 -0
- package/src/components/button.tsx +337 -0
- package/src/components/checkbox.tsx +71 -0
- package/src/components/color-picker.tsx +115 -0
- package/src/components/context-menu.tsx +149 -0
- package/src/components/date-picker.tsx +466 -0
- package/src/components/dialog-context.ts +3 -0
- package/src/components/dialog.tsx +210 -0
- package/src/components/empty-state.tsx +49 -0
- package/src/components/expand-indicator.tsx +60 -0
- package/src/components/expressive-shape.tsx +37 -0
- package/src/components/fab-menu-legacy.tsx +181 -0
- package/src/components/fab-menu.tsx +90 -0
- package/src/components/filter-chip.tsx +136 -0
- package/src/components/floating-action-button.tsx +79 -0
- package/src/components/icon-button.tsx +127 -0
- package/src/components/icons.tsx +43 -0
- package/src/components/input.tsx +80 -0
- package/src/components/keyboard-inset-view.tsx +28 -0
- package/src/components/list-controls.tsx +134 -0
- package/src/components/loading-indicator.tsx +183 -0
- package/src/components/menu-sheet.tsx +133 -0
- package/src/components/panel-sheet.tsx +90 -0
- package/src/components/password-visibility.tsx +39 -0
- package/src/components/popover-layout.ts +47 -0
- package/src/components/popover.tsx +139 -0
- package/src/components/pressable.tsx +108 -0
- package/src/components/radio-navigation.ts +52 -0
- package/src/components/refresh-control.tsx +39 -0
- package/src/components/row-gap.tsx +13 -0
- package/src/components/search-field.tsx +92 -0
- package/src/components/select-field.tsx +105 -0
- package/src/components/settings-list.tsx +302 -0
- package/src/components/settings-row.tsx +61 -0
- package/src/components/sheet-frame.tsx +250 -0
- package/src/components/sheet-lifecycle.ts +233 -0
- package/src/components/sheet-transition.ts +105 -0
- package/src/components/slider.tsx +185 -0
- package/src/components/snackbar.tsx +97 -0
- package/src/components/split-button.tsx +212 -0
- package/src/components/squish.ts +41 -0
- package/src/components/swipeable-row.tsx +109 -0
- package/src/components/switch.tsx +77 -0
- package/src/components/tab-bar.tsx +195 -0
- package/src/components/text-field.tsx +264 -0
- package/src/components/text.tsx +58 -0
- package/src/components/time-picker.tsx +277 -0
- package/src/components/toolbar.tsx +406 -0
- package/src/components/top-bar.tsx +91 -0
- package/src/components/undo-bar.tsx +137 -0
- package/src/components/wavy-progress.tsx +150 -0
- package/src/components/zoomable-image.tsx +240 -0
- package/src/expressive-shapes.ts +25 -0
- package/src/index.ts +62 -0
- package/src/keyboard.ts +5 -0
- package/src/material.ts +16 -0
- package/src/media.ts +51 -0
- package/src/motion.ts +96 -0
- package/src/platform.ts +13 -0
- package/src/preset-tokens.ts +265 -0
- package/src/provider.tsx +201 -0
- package/src/reduced-motion.ts +50 -0
- package/src/selection.ts +11 -0
- package/src/shape.ts +217 -0
- package/src/sheet-physics.ts +73 -0
- package/src/theme.ts +180 -0
- package/src/tokens.ts +2 -0
- package/src/use-keyboard-height.ts +64 -0
- package/src/utils.ts +7 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Platform, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
4
|
+
import { useThemeColors, useIsolinePlatform, useHaptics } from '../provider.tsx';
|
|
5
|
+
import { Pressable } from './pressable.tsx';
|
|
6
|
+
import { Text } from './text.tsx';
|
|
7
|
+
import { sliderValue } from '../selection.ts';
|
|
8
|
+
|
|
9
|
+
export interface SliderProps {
|
|
10
|
+
value: number;
|
|
11
|
+
onValueChange: (value: number) => void;
|
|
12
|
+
onSlidingComplete?: (value: number) => void;
|
|
13
|
+
minimumValue?: number;
|
|
14
|
+
maximumValue?: number;
|
|
15
|
+
step?: number;
|
|
16
|
+
hapticStep?: number;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
accessibilityLabel: string;
|
|
19
|
+
valueLabel?: string;
|
|
20
|
+
leadingLabel?: string;
|
|
21
|
+
trailingLabel?: string;
|
|
22
|
+
style?: StyleProp<ViewStyle>;
|
|
23
|
+
activeColor?: string;
|
|
24
|
+
trackColor?: string;
|
|
25
|
+
thumbColor?: string;
|
|
26
|
+
}
|
|
27
|
+
export function Slider({
|
|
28
|
+
value,
|
|
29
|
+
onValueChange,
|
|
30
|
+
onSlidingComplete,
|
|
31
|
+
minimumValue = 0,
|
|
32
|
+
maximumValue = 1,
|
|
33
|
+
step = 0,
|
|
34
|
+
hapticStep,
|
|
35
|
+
disabled,
|
|
36
|
+
accessibilityLabel,
|
|
37
|
+
valueLabel,
|
|
38
|
+
leadingLabel,
|
|
39
|
+
trailingLabel,
|
|
40
|
+
style,
|
|
41
|
+
activeColor,
|
|
42
|
+
trackColor,
|
|
43
|
+
thumbColor,
|
|
44
|
+
}: SliderProps) {
|
|
45
|
+
const colors = useThemeColors();
|
|
46
|
+
const { selectionHaptic } = useHaptics();
|
|
47
|
+
const lastHaptic = React.useRef<number | null>(null);
|
|
48
|
+
const ios = useIsolinePlatform() === 'ios';
|
|
49
|
+
const thumbWidth = ios ? 24 : 6;
|
|
50
|
+
const trackHeight = ios ? 4 : 12;
|
|
51
|
+
const [width, setWidth] = React.useState(1);
|
|
52
|
+
const current = sliderValue(value, minimumValue, maximumValue, step);
|
|
53
|
+
const fraction =
|
|
54
|
+
maximumValue > minimumValue ? (current - minimumValue) / (maximumValue - minimumValue) : 0;
|
|
55
|
+
const fromX = (x: number) =>
|
|
56
|
+
sliderValue(
|
|
57
|
+
minimumValue + Math.max(0, Math.min(1, x / width)) * (maximumValue - minimumValue),
|
|
58
|
+
minimumValue,
|
|
59
|
+
maximumValue,
|
|
60
|
+
step,
|
|
61
|
+
);
|
|
62
|
+
const change = (next: number, finish = false) => {
|
|
63
|
+
if (disabled) return;
|
|
64
|
+
const result = sliderValue(next, minimumValue, maximumValue, step);
|
|
65
|
+
if (hapticStep && hapticStep > 0) {
|
|
66
|
+
const tick = Math.round((result - minimumValue) / hapticStep);
|
|
67
|
+
if (tick !== lastHaptic.current) {
|
|
68
|
+
lastHaptic.current = tick;
|
|
69
|
+
selectionHaptic();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
onValueChange(result);
|
|
73
|
+
if (finish) onSlidingComplete?.(result);
|
|
74
|
+
};
|
|
75
|
+
const delta = step > 0 ? step : (maximumValue - minimumValue) / 100;
|
|
76
|
+
const gesture = Gesture.Pan()
|
|
77
|
+
.enabled(!disabled)
|
|
78
|
+
.minDistance(0)
|
|
79
|
+
.runOnJS(true)
|
|
80
|
+
.onBegin((event) => change(fromX(event.x)))
|
|
81
|
+
.onUpdate((event) => change(fromX(event.x)))
|
|
82
|
+
.onEnd((event) => change(fromX(event.x), true));
|
|
83
|
+
return (
|
|
84
|
+
<View style={[{ gap: 4, opacity: disabled ? 0.4 : 1 }, style]}>
|
|
85
|
+
{leadingLabel || trailingLabel ? (
|
|
86
|
+
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
|
87
|
+
<Text variant="caption">{leadingLabel}</Text>
|
|
88
|
+
<Text variant="caption">{trailingLabel}</Text>
|
|
89
|
+
</View>
|
|
90
|
+
) : null}
|
|
91
|
+
<GestureDetector gesture={gesture}>
|
|
92
|
+
<Pressable
|
|
93
|
+
disabled={disabled}
|
|
94
|
+
accessibilityRole="adjustable"
|
|
95
|
+
accessibilityLabel={accessibilityLabel}
|
|
96
|
+
accessibilityState={{ disabled }}
|
|
97
|
+
accessibilityValue={{
|
|
98
|
+
min: minimumValue,
|
|
99
|
+
max: maximumValue,
|
|
100
|
+
now: current,
|
|
101
|
+
text: valueLabel,
|
|
102
|
+
}}
|
|
103
|
+
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
|
|
104
|
+
onAccessibilityAction={(event) =>
|
|
105
|
+
change(
|
|
106
|
+
current + (event.nativeEvent.actionName === 'increment' ? delta : -delta),
|
|
107
|
+
true,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
{...(Platform.OS === 'web'
|
|
111
|
+
? {
|
|
112
|
+
role: 'slider',
|
|
113
|
+
'aria-valuemin': minimumValue,
|
|
114
|
+
'aria-valuemax': maximumValue,
|
|
115
|
+
'aria-valuenow': current,
|
|
116
|
+
'aria-valuetext': valueLabel,
|
|
117
|
+
onKeyDown: (
|
|
118
|
+
event: Parameters<
|
|
119
|
+
NonNullable<React.ComponentProps<typeof Pressable>['onKeyDown']>
|
|
120
|
+
>[0],
|
|
121
|
+
) => {
|
|
122
|
+
const key = event.nativeEvent.key;
|
|
123
|
+
if (
|
|
124
|
+
![
|
|
125
|
+
'ArrowLeft',
|
|
126
|
+
'ArrowRight',
|
|
127
|
+
'ArrowDown',
|
|
128
|
+
'ArrowUp',
|
|
129
|
+
'Home',
|
|
130
|
+
'End',
|
|
131
|
+
'PageUp',
|
|
132
|
+
'PageDown',
|
|
133
|
+
].includes(key)
|
|
134
|
+
)
|
|
135
|
+
return;
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
change(
|
|
138
|
+
key === 'Home'
|
|
139
|
+
? minimumValue
|
|
140
|
+
: key === 'End'
|
|
141
|
+
? maximumValue
|
|
142
|
+
: current +
|
|
143
|
+
(['ArrowUp', 'ArrowRight', 'PageUp'].includes(key) ? 1 : -1) *
|
|
144
|
+
delta *
|
|
145
|
+
(key.startsWith('Page') ? 10 : 1),
|
|
146
|
+
true,
|
|
147
|
+
);
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
: {})}
|
|
151
|
+
onLayout={(event) => setWidth(Math.max(1, event.nativeEvent.layout.width))}
|
|
152
|
+
style={{ height: 44, minWidth: 80, justifyContent: 'center' }}
|
|
153
|
+
>
|
|
154
|
+
<View
|
|
155
|
+
style={{
|
|
156
|
+
height: trackHeight,
|
|
157
|
+
borderRadius: 6,
|
|
158
|
+
backgroundColor: trackColor ?? colors.surfaceContainerHighest,
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
<View
|
|
162
|
+
style={{
|
|
163
|
+
height: trackHeight,
|
|
164
|
+
width: `${fraction * 100}%`,
|
|
165
|
+
backgroundColor: activeColor ?? colors.primary,
|
|
166
|
+
borderRadius: 6,
|
|
167
|
+
}}
|
|
168
|
+
/>
|
|
169
|
+
</View>
|
|
170
|
+
<View
|
|
171
|
+
pointerEvents="none"
|
|
172
|
+
style={{
|
|
173
|
+
position: 'absolute',
|
|
174
|
+
left: fraction * Math.max(0, width - thumbWidth),
|
|
175
|
+
width: thumbWidth,
|
|
176
|
+
height: ios ? 24 : 36,
|
|
177
|
+
borderRadius: ios ? 12 : 3,
|
|
178
|
+
backgroundColor: thumbColor ?? activeColor ?? colors.primary,
|
|
179
|
+
}}
|
|
180
|
+
/>
|
|
181
|
+
</Pressable>
|
|
182
|
+
</GestureDetector>
|
|
183
|
+
</View>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Platform, View } from 'react-native';
|
|
3
|
+
import Animated, { FadeInDown, FadeOutDown } from 'react-native-reanimated';
|
|
4
|
+
import { Text } from './text.tsx';
|
|
5
|
+
import { Pressable } from './pressable.tsx';
|
|
6
|
+
import {
|
|
7
|
+
Surface,
|
|
8
|
+
useIsolinePlatform,
|
|
9
|
+
useThemeColors,
|
|
10
|
+
elevation,
|
|
11
|
+
type MaterialOptions,
|
|
12
|
+
} from '../provider.tsx';
|
|
13
|
+
export interface SnackbarProps extends MaterialOptions {
|
|
14
|
+
message: string;
|
|
15
|
+
bottom?: number;
|
|
16
|
+
actionLabel?: string;
|
|
17
|
+
onAction?: () => void;
|
|
18
|
+
}
|
|
19
|
+
/** Visibility, duration and undo state belong to the caller. Solid is the reading-safe default. */
|
|
20
|
+
export function Snackbar({
|
|
21
|
+
message,
|
|
22
|
+
bottom = 12,
|
|
23
|
+
actionLabel,
|
|
24
|
+
onAction,
|
|
25
|
+
material = 'solid',
|
|
26
|
+
glassStyle,
|
|
27
|
+
tintColor,
|
|
28
|
+
fallbackColor,
|
|
29
|
+
interactive = false,
|
|
30
|
+
}: SnackbarProps) {
|
|
31
|
+
const colors = useThemeColors();
|
|
32
|
+
const glass = useIsolinePlatform() === 'ios' && material !== 'solid';
|
|
33
|
+
const web = Platform.OS === 'web';
|
|
34
|
+
const Box = (glass ? Surface : web ? View : Animated.View) as typeof Animated.View;
|
|
35
|
+
return (
|
|
36
|
+
<View
|
|
37
|
+
pointerEvents="box-none"
|
|
38
|
+
style={{ position: 'absolute', left: 0, right: 0, paddingHorizontal: 16, bottom }}
|
|
39
|
+
>
|
|
40
|
+
<Box
|
|
41
|
+
{...(glass
|
|
42
|
+
? { material, glassStyle, tintColor, fallbackColor, interactive }
|
|
43
|
+
: !web
|
|
44
|
+
? {
|
|
45
|
+
entering: FadeInDown.springify().damping(20),
|
|
46
|
+
exiting: FadeOutDown.duration(120),
|
|
47
|
+
}
|
|
48
|
+
: {})}
|
|
49
|
+
accessibilityLiveRegion="polite"
|
|
50
|
+
style={{
|
|
51
|
+
minHeight: 48,
|
|
52
|
+
flexDirection: 'row',
|
|
53
|
+
alignItems: 'center',
|
|
54
|
+
gap: 8,
|
|
55
|
+
borderRadius: 16,
|
|
56
|
+
paddingVertical: 6,
|
|
57
|
+
paddingLeft: 16,
|
|
58
|
+
paddingRight: 6,
|
|
59
|
+
elevation: elevation.level3,
|
|
60
|
+
...(!glass ? { backgroundColor: colors.inverseSurface } : {}),
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
<Text
|
|
64
|
+
style={{
|
|
65
|
+
flex: 1,
|
|
66
|
+
fontSize: 14,
|
|
67
|
+
color: glass ? colors.onSurface : colors.inverseOnSurface,
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
{message}
|
|
71
|
+
</Text>
|
|
72
|
+
{onAction && actionLabel ? (
|
|
73
|
+
<Pressable
|
|
74
|
+
onPress={onAction}
|
|
75
|
+
accessibilityRole="button"
|
|
76
|
+
style={{
|
|
77
|
+
minHeight: 44,
|
|
78
|
+
justifyContent: 'center',
|
|
79
|
+
borderRadius: 22,
|
|
80
|
+
paddingHorizontal: 12,
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
<Text
|
|
84
|
+
style={{
|
|
85
|
+
fontSize: 14,
|
|
86
|
+
fontWeight: '700',
|
|
87
|
+
color: glass ? colors.primary : colors.inversePrimary,
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
{actionLabel}
|
|
91
|
+
</Text>
|
|
92
|
+
</Pressable>
|
|
93
|
+
) : null}
|
|
94
|
+
</Box>
|
|
95
|
+
</View>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Platform, View } from 'react-native';
|
|
3
|
+
import Animated, {
|
|
4
|
+
useAnimatedStyle,
|
|
5
|
+
useSharedValue,
|
|
6
|
+
withSpring,
|
|
7
|
+
} from 'react-native-reanimated';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
Surface,
|
|
11
|
+
SurfaceGroup,
|
|
12
|
+
useIsolinePlatform,
|
|
13
|
+
type MaterialOptions,
|
|
14
|
+
} from '../provider.tsx';
|
|
15
|
+
import { Popover } from './popover.tsx';
|
|
16
|
+
import { ContextMenuRows } from './context-menu.tsx';
|
|
17
|
+
import { ChevronUp } from './icons.tsx';
|
|
18
|
+
import { AnimatedPressable, Pressable } from './pressable.tsx';
|
|
19
|
+
import { Text } from './text.tsx';
|
|
20
|
+
import { useHaptics } from '../provider.tsx';
|
|
21
|
+
import { spatial } from '../motion.ts';
|
|
22
|
+
import { corner } from '../shape.ts';
|
|
23
|
+
import { elevation, useRipple, useThemeColors } from '../provider.tsx';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Material 3 Expressive split button.
|
|
27
|
+
*
|
|
28
|
+
* One control holding a primary action and the variations on it: tapping the
|
|
29
|
+
* leading half does the obvious thing, tapping the trailing half offers the
|
|
30
|
+
* alternatives. It exists so the common case stays one tap while the rarer
|
|
31
|
+
* ones stop needing a separate button each.
|
|
32
|
+
*
|
|
33
|
+
* The trailing half is where the Expressive part lives — on activation its
|
|
34
|
+
* chevron spins and its outer corners morph from a pill to a small radius, so
|
|
35
|
+
* the button visibly *becomes* the open menu's anchor rather than just
|
|
36
|
+
* toggling a highlight.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
export interface SplitButtonAction {
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
destructive?: boolean;
|
|
42
|
+
id: string;
|
|
43
|
+
label: string;
|
|
44
|
+
icon: React.ComponentType<{ size?: number; color?: string }>;
|
|
45
|
+
onPress: () => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SplitButtonProps extends MaterialOptions {
|
|
49
|
+
menuMaterial?: MaterialOptions['material'];
|
|
50
|
+
label: string;
|
|
51
|
+
icon: React.ComponentType<{ size?: number; color?: string }>;
|
|
52
|
+
onPress: () => void;
|
|
53
|
+
actions: SplitButtonAction[];
|
|
54
|
+
optionsLabel?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The two halves sit a hair apart, which is what reads as "split". */
|
|
58
|
+
const HAIRLINE = 2;
|
|
59
|
+
const HEIGHT = 56;
|
|
60
|
+
|
|
61
|
+
export function SplitButton({
|
|
62
|
+
label,
|
|
63
|
+
icon: Icon,
|
|
64
|
+
onPress,
|
|
65
|
+
actions,
|
|
66
|
+
optionsLabel,
|
|
67
|
+
material = 'solid',
|
|
68
|
+
menuMaterial = 'auto',
|
|
69
|
+
glassStyle,
|
|
70
|
+
tintColor,
|
|
71
|
+
fallbackColor,
|
|
72
|
+
interactive = true,
|
|
73
|
+
}: SplitButtonProps) {
|
|
74
|
+
const glass = useIsolinePlatform() === 'ios' && material !== 'solid';
|
|
75
|
+
const web = Platform.OS === 'web';
|
|
76
|
+
const Piece = glass ? Surface : View;
|
|
77
|
+
const Trailing = (glass ? Surface : web ? View : Animated.View) as typeof Animated.View;
|
|
78
|
+
const Control = (web ? Pressable : AnimatedPressable) as typeof AnimatedPressable;
|
|
79
|
+
const colors = useThemeColors();
|
|
80
|
+
const { selectionHaptic } = useHaptics();
|
|
81
|
+
const ripple = useRipple();
|
|
82
|
+
const [open, setOpen] = React.useState(false);
|
|
83
|
+
// Where to pin the menu, measured off the trailing half as it opens.
|
|
84
|
+
const trailingRef = React.useRef<React.ElementRef<typeof View>>(null);
|
|
85
|
+
const progress = useSharedValue(0);
|
|
86
|
+
|
|
87
|
+
const close = React.useCallback(() => {
|
|
88
|
+
setOpen(false);
|
|
89
|
+
progress.value = withSpring(0, spatial.default);
|
|
90
|
+
}, [progress]);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The menu lives in a modal rather than in an absolutely positioned sibling,
|
|
94
|
+
* because on Android a view drawn outside its parent's bounds never receives
|
|
95
|
+
* the touch — the menu would appear above the button and then ignore taps.
|
|
96
|
+
* Measuring the trailing half first is what keeps it looking anchored to it.
|
|
97
|
+
*/
|
|
98
|
+
const openMenu = React.useCallback(() => {
|
|
99
|
+
setOpen(true);
|
|
100
|
+
selectionHaptic();
|
|
101
|
+
progress.value = withSpring(1, spatial.default);
|
|
102
|
+
}, [selectionHaptic, progress]);
|
|
103
|
+
|
|
104
|
+
const trailingStyle = useAnimatedStyle(() => {
|
|
105
|
+
const pill = HEIGHT / 2;
|
|
106
|
+
const radius = pill + (corner.medium - pill) * progress.value;
|
|
107
|
+
|
|
108
|
+
return { borderTopRightRadius: radius, borderBottomRightRadius: radius };
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const chevronStyle = useAnimatedStyle(() => ({
|
|
112
|
+
transform: [{ rotate: `${progress.value * 180}deg` }],
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<View>
|
|
117
|
+
<Popover
|
|
118
|
+
visible={open}
|
|
119
|
+
onClose={close}
|
|
120
|
+
anchorRef={trailingRef}
|
|
121
|
+
placement="top"
|
|
122
|
+
accessibilityLabel={optionsLabel ?? label}
|
|
123
|
+
material={menuMaterial}
|
|
124
|
+
glassStyle={glassStyle}
|
|
125
|
+
tintColor={tintColor}
|
|
126
|
+
fallbackColor={fallbackColor}
|
|
127
|
+
interactive={interactive}
|
|
128
|
+
>
|
|
129
|
+
<ContextMenuRows items={actions} onClose={close} />
|
|
130
|
+
</Popover>
|
|
131
|
+
|
|
132
|
+
<SurfaceGroup spacing={12} style={{ flexDirection: 'row', gap: HAIRLINE }}>
|
|
133
|
+
{/* The ripple's own corner radius has to come from clipping a wrapper,
|
|
134
|
+
not from the Pressable's own borderRadius: Android's ripple mask
|
|
135
|
+
can only express one radius for all four corners, and this button
|
|
136
|
+
mixes a full pill on the left with a small radius on the right. */}
|
|
137
|
+
<Piece
|
|
138
|
+
{...(glass ? { material, glassStyle, tintColor, fallbackColor, interactive } : {})}
|
|
139
|
+
style={{
|
|
140
|
+
elevation: elevation.level2,
|
|
141
|
+
borderTopLeftRadius: 28,
|
|
142
|
+
borderBottomLeftRadius: 28,
|
|
143
|
+
borderTopRightRadius: 8,
|
|
144
|
+
borderBottomRightRadius: 8,
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
<Pressable
|
|
148
|
+
onPress={onPress}
|
|
149
|
+
android_ripple={{ color: ripple.onTonal }}
|
|
150
|
+
accessibilityRole="button"
|
|
151
|
+
className="flex-row items-center gap-2 rounded-l-full rounded-r-lg bg-primaryContainer pl-6 pr-5"
|
|
152
|
+
style={{ height: HEIGHT, ...(glass ? { backgroundColor: 'transparent' } : {}) }}
|
|
153
|
+
>
|
|
154
|
+
<Icon size={20} color={glass ? colors.primary : colors.onPrimaryContainer} />
|
|
155
|
+
<Text
|
|
156
|
+
className="text-base font-semibold text-onPrimaryContainer"
|
|
157
|
+
style={glass ? { color: colors.primary } : undefined}
|
|
158
|
+
>
|
|
159
|
+
{label}
|
|
160
|
+
</Text>
|
|
161
|
+
</Pressable>
|
|
162
|
+
</Piece>
|
|
163
|
+
|
|
164
|
+
<View ref={trailingRef} collapsable={false}>
|
|
165
|
+
<Trailing
|
|
166
|
+
{...(glass ? { material, glassStyle, tintColor, fallbackColor, interactive } : {})}
|
|
167
|
+
style={[
|
|
168
|
+
!web && !glass && trailingStyle,
|
|
169
|
+
{
|
|
170
|
+
elevation: elevation.level2,
|
|
171
|
+
borderTopRightRadius: open ? 12 : 28,
|
|
172
|
+
borderBottomRightRadius: open ? 12 : 28,
|
|
173
|
+
borderTopLeftRadius: 8,
|
|
174
|
+
borderBottomLeftRadius: 8,
|
|
175
|
+
},
|
|
176
|
+
]}
|
|
177
|
+
className="overflow-hidden rounded-l-lg"
|
|
178
|
+
>
|
|
179
|
+
{/* The ripple has to carry the same (animated) corner radius as
|
|
180
|
+
this wrapper, or it shows hard corners while the pill morphs
|
|
181
|
+
to a menu anchor — `overflow-hidden` above clips the fill but
|
|
182
|
+
not the ripple's own outline. */}
|
|
183
|
+
<Control
|
|
184
|
+
onPress={() => (open ? close() : openMenu())}
|
|
185
|
+
android_ripple={{ color: ripple.onTonal }}
|
|
186
|
+
accessibilityRole="button"
|
|
187
|
+
accessibilityLabel={optionsLabel ?? label}
|
|
188
|
+
accessibilityState={{ expanded: open }}
|
|
189
|
+
className="items-center justify-center rounded-l-lg bg-primaryContainer px-4"
|
|
190
|
+
style={[
|
|
191
|
+
!web && !glass && trailingStyle,
|
|
192
|
+
{
|
|
193
|
+
height: HEIGHT,
|
|
194
|
+
borderTopRightRadius: open ? 12 : 28,
|
|
195
|
+
borderBottomRightRadius: open ? 12 : 28,
|
|
196
|
+
...(glass ? { backgroundColor: 'transparent' } : {}),
|
|
197
|
+
},
|
|
198
|
+
]}
|
|
199
|
+
>
|
|
200
|
+
<Animated.View style={chevronStyle}>
|
|
201
|
+
<ChevronUp
|
|
202
|
+
size={20}
|
|
203
|
+
color={glass ? colors.primary : colors.onPrimaryContainer}
|
|
204
|
+
/>
|
|
205
|
+
</Animated.View>
|
|
206
|
+
</Control>
|
|
207
|
+
</Trailing>
|
|
208
|
+
</View>
|
|
209
|
+
</SurfaceGroup>
|
|
210
|
+
</View>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnimatedStyle,
|
|
3
|
+
useAnimatedStyle,
|
|
4
|
+
useSharedValue,
|
|
5
|
+
withSpring,
|
|
6
|
+
} from 'react-native-reanimated';
|
|
7
|
+
|
|
8
|
+
import { spatial } from '../motion.ts';
|
|
9
|
+
|
|
10
|
+
/** How far a card gives under the finger: enough to feel, not enough to look like a jump. */
|
|
11
|
+
const PRESSED_SCALE = 0.97;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A card or tile giving a little under the finger and springing back on
|
|
15
|
+
* release, as Material 3 Expressive's components do — on top of the ripple,
|
|
16
|
+
* which only colours. Spread `handlers` on the Pressable and put `style` on
|
|
17
|
+
* the animated view around it. Under "Remove animations" the spring is
|
|
18
|
+
* Reanimated's, so the system's setting already stills it.
|
|
19
|
+
*/
|
|
20
|
+
export interface SquishFeedback {
|
|
21
|
+
style: AnimatedStyle<{ transform: { scale: number }[] }>;
|
|
22
|
+
handlers: { onPressIn: () => void; onPressOut: () => void };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useSquish(): SquishFeedback {
|
|
26
|
+
const pressed = useSharedValue(0);
|
|
27
|
+
const style = useAnimatedStyle(() => ({
|
|
28
|
+
transform: [{ scale: withSpring(pressed.value === 1 ? PRESSED_SCALE : 1, spatial.fast) }],
|
|
29
|
+
}));
|
|
30
|
+
return {
|
|
31
|
+
style,
|
|
32
|
+
handlers: {
|
|
33
|
+
onPressIn: () => {
|
|
34
|
+
pressed.value = 1;
|
|
35
|
+
},
|
|
36
|
+
onPressOut: () => {
|
|
37
|
+
pressed.value = 0;
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Platform, View } from 'react-native';
|
|
3
|
+
import ReanimatedSwipeable, {
|
|
4
|
+
type SwipeableMethods,
|
|
5
|
+
} from 'react-native-gesture-handler/ReanimatedSwipeable';
|
|
6
|
+
import { Button } from './button.tsx';
|
|
7
|
+
|
|
8
|
+
export interface SwipeAction {
|
|
9
|
+
id: string;
|
|
10
|
+
label: string;
|
|
11
|
+
onPress: () => void;
|
|
12
|
+
destructive?: boolean;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface SwipeableRowProps {
|
|
16
|
+
children: React.ReactNode;
|
|
17
|
+
accessibilityLabel: string;
|
|
18
|
+
actionsLabel: string;
|
|
19
|
+
startActions?: SwipeAction[];
|
|
20
|
+
endActions?: SwipeAction[];
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
showActionsButton?: boolean;
|
|
23
|
+
onSwipeStart?: () => void;
|
|
24
|
+
onSwipeEnd?: () => void;
|
|
25
|
+
}
|
|
26
|
+
export function SwipeableRow({
|
|
27
|
+
children,
|
|
28
|
+
accessibilityLabel,
|
|
29
|
+
actionsLabel,
|
|
30
|
+
startActions = [],
|
|
31
|
+
endActions = [],
|
|
32
|
+
disabled,
|
|
33
|
+
showActionsButton = true,
|
|
34
|
+
onSwipeStart,
|
|
35
|
+
onSwipeEnd,
|
|
36
|
+
}: SwipeableRowProps) {
|
|
37
|
+
const ref = React.useRef<SwipeableMethods>(null);
|
|
38
|
+
const [open, setOpen] = React.useState(false);
|
|
39
|
+
const actions = [...startActions, ...endActions];
|
|
40
|
+
const run = (action: SwipeAction) => {
|
|
41
|
+
if (disabled || action.disabled) return;
|
|
42
|
+
ref.current?.close();
|
|
43
|
+
setOpen(false);
|
|
44
|
+
action.onPress();
|
|
45
|
+
};
|
|
46
|
+
const rows = (items: SwipeAction[]) => (
|
|
47
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, padding: 4 }}>
|
|
48
|
+
{items.map((action) => (
|
|
49
|
+
<Button
|
|
50
|
+
key={action.id}
|
|
51
|
+
label={action.label}
|
|
52
|
+
size="s"
|
|
53
|
+
variant={action.destructive ? 'destructive' : 'tonal'}
|
|
54
|
+
disabled={disabled || action.disabled}
|
|
55
|
+
onPress={() => run(action)}
|
|
56
|
+
/>
|
|
57
|
+
))}
|
|
58
|
+
</View>
|
|
59
|
+
);
|
|
60
|
+
const content = (
|
|
61
|
+
<View>
|
|
62
|
+
{children}
|
|
63
|
+
{showActionsButton ? (
|
|
64
|
+
<Button
|
|
65
|
+
label={actionsLabel}
|
|
66
|
+
variant="ghost"
|
|
67
|
+
size="s"
|
|
68
|
+
disabled={disabled || !actions.length}
|
|
69
|
+
onPress={() => setOpen((value) => !value)}
|
|
70
|
+
/>
|
|
71
|
+
) : null}
|
|
72
|
+
{open ? rows(actions) : null}
|
|
73
|
+
</View>
|
|
74
|
+
);
|
|
75
|
+
return (
|
|
76
|
+
<View
|
|
77
|
+
accessibilityLabel={accessibilityLabel}
|
|
78
|
+
accessibilityActions={actions
|
|
79
|
+
.filter((action) => !action.disabled)
|
|
80
|
+
.map((action) => ({ name: action.id, label: action.label }))}
|
|
81
|
+
onAccessibilityAction={(event) => {
|
|
82
|
+
const action = actions.find((action) => action.id === event.nativeEvent.actionName);
|
|
83
|
+
if (action) run(action);
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
{Platform.OS === 'web' ? (
|
|
87
|
+
content
|
|
88
|
+
) : (
|
|
89
|
+
<ReanimatedSwipeable
|
|
90
|
+
ref={ref}
|
|
91
|
+
enabled={!disabled}
|
|
92
|
+
overshootLeft={false}
|
|
93
|
+
overshootRight={false}
|
|
94
|
+
renderLeftActions={startActions.length ? () => rows(startActions) : undefined}
|
|
95
|
+
renderRightActions={endActions.length ? () => rows(endActions) : undefined}
|
|
96
|
+
onSwipeableWillOpen={(direction) => {
|
|
97
|
+
const action = direction === 'right' ? onSwipeStart : onSwipeEnd;
|
|
98
|
+
if (action) {
|
|
99
|
+
action();
|
|
100
|
+
ref.current?.close();
|
|
101
|
+
}
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
{content}
|
|
105
|
+
</ReanimatedSwipeable>
|
|
106
|
+
)}
|
|
107
|
+
</View>
|
|
108
|
+
);
|
|
109
|
+
}
|