@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.
Files changed (167) hide show
  1. package/LICENSE +22 -0
  2. package/NOTICE.md +12 -0
  3. package/README.md +104 -3
  4. package/dist/amount.d.ts +3 -0
  5. package/dist/calendar.d.ts +7 -0
  6. package/dist/components/action-chip.d.ts +9 -0
  7. package/dist/components/amount-field.d.ts +12 -0
  8. package/dist/components/avatar.d.ts +17 -0
  9. package/dist/components/back-button.d.ts +12 -0
  10. package/dist/components/back-icon.d.ts +3 -0
  11. package/dist/components/badge.d.ts +13 -0
  12. package/dist/components/banner.d.ts +14 -0
  13. package/dist/components/bottom-sheet-native.d.ts +11 -0
  14. package/dist/components/bottom-sheet-web.d.ts +18 -0
  15. package/dist/components/bottom-sheet.d.ts +3 -0
  16. package/dist/components/button-group.d.ts +26 -0
  17. package/dist/components/button.d.ts +48 -0
  18. package/dist/components/checkbox.d.ts +12 -0
  19. package/dist/components/color-picker.d.ts +21 -0
  20. package/dist/components/context-menu.d.ts +26 -0
  21. package/dist/components/date-picker.d.ts +64 -0
  22. package/dist/components/dialog-context.d.ts +2 -0
  23. package/dist/components/dialog.d.ts +20 -0
  24. package/dist/components/empty-state.d.ts +15 -0
  25. package/dist/components/expand-indicator.d.ts +27 -0
  26. package/dist/components/expressive-shape.d.ts +18 -0
  27. package/dist/components/fab-menu-legacy.d.ts +31 -0
  28. package/dist/components/fab-menu.d.ts +21 -0
  29. package/dist/components/filter-chip.d.ts +69 -0
  30. package/dist/components/floating-action-button.d.ts +15 -0
  31. package/dist/components/icon-button.d.ts +40 -0
  32. package/dist/components/icons.d.ts +18 -0
  33. package/dist/components/input.d.ts +9 -0
  34. package/dist/components/keyboard-inset-view.d.ts +15 -0
  35. package/dist/components/list-controls.d.ts +38 -0
  36. package/dist/components/loading-indicator.d.ts +9 -0
  37. package/dist/components/menu-sheet.d.ts +36 -0
  38. package/dist/components/panel-sheet.d.ts +8 -0
  39. package/dist/components/password-visibility.d.ts +5 -0
  40. package/dist/components/popover-layout.d.ts +25 -0
  41. package/dist/components/popover.d.ts +20 -0
  42. package/dist/components/pressable.d.ts +12 -0
  43. package/dist/components/radio-navigation.d.ts +11 -0
  44. package/dist/components/refresh-control.d.ts +16 -0
  45. package/dist/components/row-gap.d.ts +9 -0
  46. package/dist/components/search-field.d.ts +22 -0
  47. package/dist/components/select-field.d.ts +16 -0
  48. package/dist/components/settings-list.d.ts +98 -0
  49. package/dist/components/settings-row.d.ts +13 -0
  50. package/dist/components/sheet-frame.d.ts +27 -0
  51. package/dist/components/sheet-lifecycle.d.ts +36 -0
  52. package/dist/components/sheet-transition.d.ts +36 -0
  53. package/dist/components/slider.d.ts +21 -0
  54. package/dist/components/snackbar.d.ts +10 -0
  55. package/dist/components/split-button.d.ts +38 -0
  56. package/dist/components/squish.d.ts +20 -0
  57. package/dist/components/swipeable-row.d.ts +20 -0
  58. package/dist/components/switch.d.ts +8 -0
  59. package/dist/components/tab-bar.d.ts +20 -0
  60. package/dist/components/text-field.d.ts +31 -0
  61. package/dist/components/text.d.ts +9 -0
  62. package/dist/components/time-picker.d.ts +40 -0
  63. package/dist/components/toolbar.d.ts +77 -0
  64. package/dist/components/top-bar.d.ts +27 -0
  65. package/dist/components/undo-bar.d.ts +23 -0
  66. package/dist/components/wavy-progress.d.ts +21 -0
  67. package/dist/components/zoomable-image.d.ts +18 -0
  68. package/dist/expressive-shapes.d.ts +8 -0
  69. package/dist/index.d.ts +62 -0
  70. package/dist/keyboard.d.ts +2 -0
  71. package/dist/material.d.ts +11 -0
  72. package/dist/media.d.ts +11 -0
  73. package/dist/motion.d.ts +96 -0
  74. package/dist/platform.d.ts +3 -0
  75. package/dist/preset-tokens.d.ts +264 -0
  76. package/dist/provider.d.ts +59 -0
  77. package/dist/reduced-motion.d.ts +45 -0
  78. package/dist/selection.d.ts +2 -0
  79. package/dist/shape.d.ts +99 -0
  80. package/dist/sheet-physics.d.ts +37 -0
  81. package/dist/theme.d.ts +103 -0
  82. package/dist/tokens.d.ts +2 -0
  83. package/dist/use-keyboard-height.d.ts +10 -0
  84. package/dist/utils.d.ts +3 -0
  85. package/nativewind.cjs +68 -0
  86. package/package.json +63 -6
  87. package/src/amount.ts +53 -0
  88. package/src/calendar.ts +42 -0
  89. package/src/components/action-chip.tsx +64 -0
  90. package/src/components/amount-field.tsx +49 -0
  91. package/src/components/avatar.tsx +91 -0
  92. package/src/components/back-button.tsx +39 -0
  93. package/src/components/back-icon.tsx +25 -0
  94. package/src/components/badge.tsx +41 -0
  95. package/src/components/banner.tsx +63 -0
  96. package/src/components/bottom-sheet-native.tsx +52 -0
  97. package/src/components/bottom-sheet-web.tsx +193 -0
  98. package/src/components/bottom-sheet.tsx +5 -0
  99. package/src/components/button-group.tsx +326 -0
  100. package/src/components/button.tsx +337 -0
  101. package/src/components/checkbox.tsx +71 -0
  102. package/src/components/color-picker.tsx +115 -0
  103. package/src/components/context-menu.tsx +149 -0
  104. package/src/components/date-picker.tsx +466 -0
  105. package/src/components/dialog-context.ts +3 -0
  106. package/src/components/dialog.tsx +210 -0
  107. package/src/components/empty-state.tsx +49 -0
  108. package/src/components/expand-indicator.tsx +60 -0
  109. package/src/components/expressive-shape.tsx +37 -0
  110. package/src/components/fab-menu-legacy.tsx +181 -0
  111. package/src/components/fab-menu.tsx +90 -0
  112. package/src/components/filter-chip.tsx +136 -0
  113. package/src/components/floating-action-button.tsx +79 -0
  114. package/src/components/icon-button.tsx +127 -0
  115. package/src/components/icons.tsx +43 -0
  116. package/src/components/input.tsx +80 -0
  117. package/src/components/keyboard-inset-view.tsx +28 -0
  118. package/src/components/list-controls.tsx +134 -0
  119. package/src/components/loading-indicator.tsx +183 -0
  120. package/src/components/menu-sheet.tsx +133 -0
  121. package/src/components/panel-sheet.tsx +90 -0
  122. package/src/components/password-visibility.tsx +39 -0
  123. package/src/components/popover-layout.ts +47 -0
  124. package/src/components/popover.tsx +139 -0
  125. package/src/components/pressable.tsx +108 -0
  126. package/src/components/radio-navigation.ts +52 -0
  127. package/src/components/refresh-control.tsx +39 -0
  128. package/src/components/row-gap.tsx +13 -0
  129. package/src/components/search-field.tsx +92 -0
  130. package/src/components/select-field.tsx +105 -0
  131. package/src/components/settings-list.tsx +302 -0
  132. package/src/components/settings-row.tsx +61 -0
  133. package/src/components/sheet-frame.tsx +250 -0
  134. package/src/components/sheet-lifecycle.ts +233 -0
  135. package/src/components/sheet-transition.ts +105 -0
  136. package/src/components/slider.tsx +185 -0
  137. package/src/components/snackbar.tsx +97 -0
  138. package/src/components/split-button.tsx +212 -0
  139. package/src/components/squish.ts +41 -0
  140. package/src/components/swipeable-row.tsx +109 -0
  141. package/src/components/switch.tsx +77 -0
  142. package/src/components/tab-bar.tsx +195 -0
  143. package/src/components/text-field.tsx +264 -0
  144. package/src/components/text.tsx +58 -0
  145. package/src/components/time-picker.tsx +277 -0
  146. package/src/components/toolbar.tsx +406 -0
  147. package/src/components/top-bar.tsx +91 -0
  148. package/src/components/undo-bar.tsx +137 -0
  149. package/src/components/wavy-progress.tsx +150 -0
  150. package/src/components/zoomable-image.tsx +240 -0
  151. package/src/expressive-shapes.ts +25 -0
  152. package/src/index.ts +62 -0
  153. package/src/keyboard.ts +5 -0
  154. package/src/material.ts +16 -0
  155. package/src/media.ts +51 -0
  156. package/src/motion.ts +96 -0
  157. package/src/platform.ts +13 -0
  158. package/src/preset-tokens.ts +265 -0
  159. package/src/provider.tsx +201 -0
  160. package/src/reduced-motion.ts +50 -0
  161. package/src/selection.ts +11 -0
  162. package/src/shape.ts +217 -0
  163. package/src/sheet-physics.ts +73 -0
  164. package/src/theme.ts +180 -0
  165. package/src/tokens.ts +2 -0
  166. package/src/use-keyboard-height.ts +64 -0
  167. 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
+ }