@isoline-ui/react-native 0.0.0-stage → 1.0.0

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,193 @@
1
+ import * as React from 'react';
2
+ import { KeyboardAvoidingView, Modal, Platform, View, type DimensionValue } from 'react-native';
3
+ import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
4
+ import Animated, { runOnJS, withSpring, withTiming } from 'react-native-reanimated';
5
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
6
+
7
+ import { Pressable } from './pressable.tsx';
8
+ import { useSheetTransition } from './sheet-transition.ts';
9
+ import { Text } from './text.tsx';
10
+ import {
11
+ Surface,
12
+ type MaterialOptions,
13
+ ThemeScope,
14
+ useThemeColors,
15
+ useIsolinePlatform,
16
+ useLabels,
17
+ } from '../provider.tsx';
18
+ import { spatial } from '../motion.ts';
19
+ import { elevation } from '../provider.tsx';
20
+
21
+ const CLOSE_SHARE = 0.3;
22
+ const CLOSE_VELOCITY = 900;
23
+ const LEAVE_MS = 160;
24
+
25
+ export interface BottomSheetProps extends MaterialOptions {
26
+ visible: boolean;
27
+ onClose: () => void;
28
+ /** Caps long lists; the default leaves space for the status bar. */
29
+ maxHeight?: DimensionValue;
30
+ /** Runs once the sheet is on screen, e.g. to focus an input. */
31
+ onShow?: () => void;
32
+ children: React.ReactNode;
33
+ }
34
+
35
+ /**
36
+ * One branded sheet on both platforms. The handle owns the dismiss gesture
37
+ * so it cannot steal scrolling, calendar swipes or drags inside the content.
38
+ * On iOS the panel also follows the keyboard and supports VoiceOver Escape.
39
+ */
40
+ export function BottomSheet({
41
+ visible,
42
+ onClose,
43
+ maxHeight = '90%',
44
+ onShow,
45
+ children,
46
+ material = 'solid',
47
+ glassStyle,
48
+ tintColor,
49
+ fallbackColor,
50
+ interactive = false,
51
+ }: BottomSheetProps) {
52
+ const ios = useIsolinePlatform() === 'ios';
53
+ const glass = ios && material !== 'solid';
54
+ const insets = useSafeAreaInsets();
55
+ const web = Platform.OS === 'web';
56
+ const Box = (web ? View : Animated.View) as typeof Animated.View;
57
+ const labels = useLabels();
58
+ const colors = useThemeColors();
59
+ const { scrimStyle, panelStyle, onPanelLayout, drag, height, reducedMotion } =
60
+ useSheetTransition(visible);
61
+
62
+ const pull = Gesture.Pan()
63
+ .activeOffsetY([-8, 8])
64
+ .onUpdate((event) => {
65
+ drag.set(Math.max(0, event.translationY));
66
+ })
67
+ .onEnd((event) => {
68
+ const closing =
69
+ event.translationY > height.get() * CLOSE_SHARE ||
70
+ (event.velocityY > CLOSE_VELOCITY && event.translationY > 0);
71
+ if (!closing) {
72
+ drag.set(reducedMotion ? 0 : withSpring(0, spatial.default));
73
+ return;
74
+ }
75
+ if (reducedMotion) {
76
+ runOnJS(onClose)();
77
+ return;
78
+ }
79
+ drag.set(
80
+ withTiming(height.get(), { duration: LEAVE_MS }, (finished) => {
81
+ if (finished) runOnJS(onClose)();
82
+ }),
83
+ );
84
+ })
85
+ .onFinalize((_event, success) => {
86
+ if (!success) drag.set(reducedMotion ? 0 : withSpring(0, spatial.default));
87
+ });
88
+
89
+ return (
90
+ <Modal
91
+ visible={visible}
92
+ transparent
93
+ presentationStyle="overFullScreen"
94
+ animationType="none"
95
+ onRequestClose={onClose}
96
+ onShow={onShow}
97
+ >
98
+ <ThemeScope style={{ flex: 1 }}>
99
+ <GestureHandlerRootView style={{ flex: 1 }}>
100
+ <KeyboardAvoidingView
101
+ style={{ flex: 1 }}
102
+ behavior="padding"
103
+ enabled={Platform.OS === 'ios'}
104
+ >
105
+ <Box
106
+ style={[
107
+ !web && scrimStyle,
108
+ { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', paddingTop: insets.top + 8 },
109
+ ]}
110
+ className="flex-1 bg-black/60"
111
+ >
112
+ <Pressable
113
+ testID="isoline-sheet-backdrop"
114
+ className="flex-1"
115
+ onPress={onClose}
116
+ accessible={false}
117
+ />
118
+
119
+ <Box
120
+ testID="isoline-sheet-panel"
121
+ onLayout={onPanelLayout}
122
+ accessibilityViewIsModal
123
+ onAccessibilityEscape={onClose}
124
+ className="rounded-t-[28px] bg-popover px-4"
125
+ style={[
126
+ !web && panelStyle,
127
+ {
128
+ ...(glass
129
+ ? { backgroundColor: 'transparent' }
130
+ : web
131
+ ? { backgroundColor: colors.surfaceContainerLow }
132
+ : {}),
133
+ paddingHorizontal: 16,
134
+ paddingBottom: insets.bottom + 16,
135
+ maxHeight,
136
+ borderTopLeftRadius: ios ? 20 : 28,
137
+ borderTopRightRadius: ios ? 20 : 28,
138
+ elevation: elevation.level3,
139
+ },
140
+ ]}
141
+ >
142
+ {glass ? (
143
+ <Surface
144
+ material={material}
145
+ glassStyle={glassStyle}
146
+ tintColor={tintColor}
147
+ fallbackColor={fallbackColor}
148
+ interactive={interactive}
149
+ pointerEvents="none"
150
+ style={{
151
+ position: 'absolute',
152
+ top: 0,
153
+ left: 0,
154
+ right: 0,
155
+ bottom: 0,
156
+ borderTopLeftRadius: ios ? 20 : 28,
157
+ borderTopRightRadius: ios ? 20 : 28,
158
+ }}
159
+ />
160
+ ) : null}
161
+ <GestureDetector gesture={pull}>
162
+ <Pressable
163
+ onPress={onClose}
164
+ className="-mx-4 min-h-11 items-center justify-center"
165
+ accessible
166
+ accessibilityRole="button"
167
+ accessibilityLabel={labels.close}
168
+ onAccessibilityTap={onClose}
169
+ accessibilityActions={[{ name: 'activate' }]}
170
+ onAccessibilityAction={({ nativeEvent }) => {
171
+ if (nativeEvent.actionName === 'activate') onClose();
172
+ }}
173
+ >
174
+ <View className="h-1 w-9 rounded-full bg-outlineVariant" />
175
+ </Pressable>
176
+ </GestureDetector>
177
+ <Pressable
178
+ onPress={onClose}
179
+ accessibilityRole="button"
180
+ accessibilityLabel={labels.close}
181
+ className="self-end px-3 py-2"
182
+ >
183
+ <Text style={{ color: colors.primary }}>{labels.close}</Text>
184
+ </Pressable>
185
+ {children}
186
+ </Box>
187
+ </Box>
188
+ </KeyboardAvoidingView>
189
+ </GestureHandlerRootView>
190
+ </ThemeScope>
191
+ </Modal>
192
+ );
193
+ }
@@ -0,0 +1,5 @@
1
+ import {Platform} from 'react-native';
2
+ import {BottomSheet as NativeSheet, type BottomSheetProps} from './bottom-sheet-native.tsx';
3
+ import {BottomSheet as WebSheet} from './bottom-sheet-web.tsx';
4
+ export type {BottomSheetProps};
5
+ export function BottomSheet(props: BottomSheetProps) { return Platform.OS === 'web' ? <WebSheet {...props}/> : <NativeSheet {...props}/>; }
@@ -0,0 +1,326 @@
1
+ import * as React from 'react';
2
+ import { Platform, View } from 'react-native';
3
+ import Animated, {
4
+ useAnimatedStyle,
5
+ useSharedValue,
6
+ withSpring,
7
+ type SharedValue,
8
+ } from 'react-native-reanimated';
9
+
10
+ import { Pressable, AnimatedPressable } from './pressable.tsx';
11
+ import { Text } from './text.tsx';
12
+ import {
13
+ Surface,
14
+ type MaterialOptions,
15
+ useIsolineTheme,
16
+ useIsolinePlatform,
17
+ useHaptics,
18
+ } from '../provider.tsx';
19
+ import { corner } from '../shape.ts';
20
+ import { spatial } from '../motion.ts';
21
+ import { useRipple, useThemeColors } from '../provider.tsx';
22
+ import { cn } from '../utils.ts';
23
+
24
+ /**
25
+ * Material 3 Expressive connected button group.
26
+ *
27
+ * A row of buttons that behave as one object. Two things make it Expressive
28
+ * rather than a row of chips:
29
+ *
30
+ * - **Shape as state.** The selected button's corners morph from the group's
31
+ * small inner radius to a full pill, so which one is active is legible from
32
+ * silhouette alone, before any colour is read.
33
+ * - **The squeeze.** Pressing a button widens it and compresses its immediate
34
+ * neighbours. The group has a fixed width, so the buttons behave like
35
+ * objects sharing a space rather than independent targets — which is what
36
+ * tells you they are one control.
37
+ *
38
+ * Both run on spatial springs, so a fast double-tap redirects the motion mid
39
+ * flight instead of queueing two animations.
40
+ */
41
+
42
+ /** How much a pressed button grows, and its neighbours give up, as flex. */
43
+ const PRESS_GROWTH = 0.18;
44
+ const NEIGHBOUR_SQUEEZE = 0.09;
45
+
46
+ export interface ButtonGroupItem<T extends string> {
47
+ id: T;
48
+ disabled?: boolean;
49
+ label: string;
50
+ icon?: React.ComponentType<{ size?: number; color?: string }>;
51
+ /** Optional trailing count, e.g. unread mail in that view. */
52
+ badge?: number;
53
+ }
54
+
55
+ export interface ButtonGroupProps<T extends string> extends MaterialOptions {
56
+ items: ButtonGroupItem<T>[];
57
+ value: T;
58
+ onChange: (value: T) => void;
59
+ className?: string;
60
+ /**
61
+ * Drop the label on unselected buttons, leaving icon + badge. Meant for
62
+ * groups where every item carries a badge, so the count alone still
63
+ * identifies it — the selected button keeps the only visible label.
64
+ */
65
+ compact?: boolean;
66
+ }
67
+
68
+ export function ButtonGroup<T extends string>({
69
+ items,
70
+ value,
71
+ onChange,
72
+ className,
73
+ compact,
74
+ material = 'solid',
75
+ glassStyle,
76
+ tintColor,
77
+ fallbackColor,
78
+ interactive = false,
79
+ }: ButtonGroupProps<T>) {
80
+ const ios = useIsolinePlatform() === 'ios';
81
+ const Group = material !== 'solid' ? Surface : View;
82
+ const colors = useThemeColors();
83
+ const { scheme } = useIsolineTheme();
84
+ const { selectionHaptic } = useHaptics();
85
+ // Which button is under a finger, as an index. -1 is "none", and lives on
86
+ // the UI thread so the squeeze never waits on a React render.
87
+ const pressed = useSharedValue(-1);
88
+
89
+ if (ios)
90
+ return (
91
+ <Group
92
+ {...(material !== 'solid'
93
+ ? { material, glassStyle, tintColor, fallbackColor, interactive }
94
+ : {})}
95
+ className={className}
96
+ style={{
97
+ flexDirection: 'row',
98
+ gap: 2,
99
+ padding: 3,
100
+ borderRadius: 12,
101
+ ...(material === 'solid' ? { backgroundColor: colors.surfaceContainerHigh } : {}),
102
+ }}
103
+ >
104
+ {items.map((item) => {
105
+ const selected = item.id === value;
106
+ const Icon = item.icon;
107
+ return (
108
+ <Pressable
109
+ key={item.id}
110
+ accessibilityRole="button"
111
+ accessibilityLabel={item.label}
112
+ disabled={item.disabled}
113
+ accessibilityState={{ selected, disabled: item.disabled }}
114
+ onPress={() => {
115
+ if (!selected) selectionHaptic();
116
+ onChange(item.id);
117
+ }}
118
+ style={{
119
+ opacity: item.disabled ? 0.4 : 1,
120
+ flex: 1,
121
+ minWidth: 0,
122
+ minHeight: 36,
123
+ paddingVertical: 8,
124
+ paddingHorizontal: 6,
125
+ borderRadius: 9,
126
+ flexDirection: 'row',
127
+ alignItems: 'center',
128
+ justifyContent: 'center',
129
+ gap: 4,
130
+ backgroundColor: selected
131
+ ? scheme === 'dark'
132
+ ? colors.surfaceContainerHighest
133
+ : colors.surfaceContainerLowest
134
+ : 'transparent',
135
+ boxShadow: selected ? '0px 1px 4px rgba(0,0,0,0.15)' : undefined,
136
+ }}
137
+ >
138
+ {Icon ? <Icon size={16} color={colors.onSurface} /> : null}
139
+ <Text
140
+ numberOfLines={1}
141
+ style={{
142
+ fontSize: 13,
143
+ lineHeight: 18,
144
+ fontWeight: '600',
145
+ color: colors.onSurface,
146
+ flexShrink: 1,
147
+ }}
148
+ >
149
+ {item.label}
150
+ </Text>
151
+ {item.badge !== undefined ? (
152
+ <Text style={{ fontSize: 12, color: colors.onSurface }}>{item.badge}</Text>
153
+ ) : null}
154
+ </Pressable>
155
+ );
156
+ })}
157
+ </Group>
158
+ );
159
+ return (
160
+ <View className={cn('flex-row gap-1', className)}>
161
+ {items.map((item, index) => (
162
+ <GroupButton
163
+ key={item.id}
164
+ item={item}
165
+ index={index}
166
+ count={items.length}
167
+ selected={item.id === value}
168
+ compact={compact ?? false}
169
+ pressed={pressed}
170
+ onPress={() => onChange(item.id)}
171
+ />
172
+ ))}
173
+ </View>
174
+ );
175
+ }
176
+
177
+ function GroupButton<T extends string>({
178
+ item,
179
+ index,
180
+ count,
181
+ selected,
182
+ compact,
183
+ pressed,
184
+ onPress,
185
+ }: {
186
+ item: ButtonGroupItem<T>;
187
+ index: number;
188
+ count: number;
189
+ selected: boolean;
190
+ compact: boolean;
191
+ pressed: SharedValue<number>;
192
+ onPress: () => void;
193
+ }) {
194
+ const { selectionHaptic } = useHaptics();
195
+ const web = Platform.OS === 'web';
196
+ const Container = (web ? View : Animated.View) as typeof Animated.View;
197
+ const Control = (web ? Pressable : AnimatedPressable) as typeof AnimatedPressable;
198
+ const colors = useThemeColors();
199
+ const ripple = useRipple();
200
+ const Icon = item.icon;
201
+ const first = index === 0;
202
+ const last = index === count - 1;
203
+
204
+ // The group's outer corners are round; the shared inner edges are not, so
205
+ // the row reads as one segmented object rather than as separate pills.
206
+ const outer = corner.extraLarge;
207
+ const inner = corner.small;
208
+
209
+ const shape = useSharedValue(selected ? 1 : 0);
210
+
211
+ React.useEffect(() => {
212
+ shape.value = withSpring(selected ? 1 : 0, spatial.default);
213
+ }, [selected, shape]);
214
+
215
+ // In compact mode, unselected buttons hug their icon+badge content instead
216
+ // of sharing the row evenly, so the freed width goes to the selected
217
+ // button's label rather than sitting empty inside its neighbours.
218
+ const flexStyle = useAnimatedStyle(() => {
219
+ if (compact) {
220
+ const grow = selected ? 1 : 0;
221
+ return { flexGrow: withSpring(grow, spatial.default), flexShrink: grow };
222
+ }
223
+
224
+ const distance = pressed.value < 0 ? 99 : Math.abs(pressed.value - index);
225
+
226
+ const flex = distance === 0 ? 1 + PRESS_GROWTH : distance === 1 ? 1 - NEIGHBOUR_SQUEEZE : 1;
227
+
228
+ return { flex: withSpring(flex, spatial.fast) };
229
+ });
230
+
231
+ // Selected morphs every corner to a pill; unselected keeps the group's
232
+ // segmented silhouette. Applied to the Pressable itself, not just the
233
+ // wrapping View, or the ripple shows hard corners through the morph.
234
+ const radiusStyle = useAnimatedStyle(() => {
235
+ const radius = (base: number) => base + (corner.extraExtraLarge - base) * shape.value;
236
+
237
+ return {
238
+ borderTopLeftRadius: withSpring(radius(first ? outer : inner), spatial.default),
239
+ borderBottomLeftRadius: withSpring(radius(first ? outer : inner), spatial.default),
240
+ borderTopRightRadius: withSpring(radius(last ? outer : inner), spatial.default),
241
+ borderBottomRightRadius: withSpring(radius(last ? outer : inner), spatial.default),
242
+ };
243
+ });
244
+
245
+ // The static style gives the animated flex above a value to spring from
246
+ // (and to render correctly before the first frame), matching what it
247
+ // settles into: an even share normally, or grow-to-fill vs. hug-content
248
+ // once compact mode gives each button a different role.
249
+ const staticFlexStyle = compact
250
+ ? { flexGrow: selected ? 1 : 0, flexShrink: selected ? 1 : 0 }
251
+ : { flex: 1 };
252
+
253
+ return (
254
+ <Container
255
+ style={[staticFlexStyle, !web && flexStyle, web ? { overflow: 'hidden' } : radiusStyle]}
256
+ className="overflow-hidden"
257
+ >
258
+ <Control
259
+ onPressIn={() => {
260
+ pressed.set(index);
261
+ }}
262
+ onPressOut={() => {
263
+ pressed.set(-1);
264
+ }}
265
+ onPress={() => {
266
+ if (!selected) selectionHaptic();
267
+ onPress();
268
+ }}
269
+ android_ripple={{ color: selected ? ripple.onTonal : ripple.onNeutral }}
270
+ accessibilityRole="button"
271
+ accessibilityLabel={item.label}
272
+ disabled={item.disabled}
273
+ accessibilityState={{ selected, disabled: item.disabled }}
274
+ className={cn(
275
+ 'h-12 flex-row items-center justify-center gap-1.5 px-3',
276
+ selected ? 'bg-primaryContainer' : 'bg-surfaceContainerHigh',
277
+ )}
278
+ style={
279
+ web
280
+ ? {
281
+ borderTopLeftRadius: selected ? corner.extraExtraLarge : first ? outer : inner,
282
+ borderBottomLeftRadius: selected
283
+ ? corner.extraExtraLarge
284
+ : first
285
+ ? outer
286
+ : inner,
287
+ borderTopRightRadius: selected ? corner.extraExtraLarge : last ? outer : inner,
288
+ borderBottomRightRadius: selected
289
+ ? corner.extraExtraLarge
290
+ : last
291
+ ? outer
292
+ : inner,
293
+ }
294
+ : radiusStyle
295
+ }
296
+ >
297
+ {Icon ? (
298
+ <Icon
299
+ size={18}
300
+ color={selected ? colors.onPrimaryContainer : colors.onSurfaceVariant}
301
+ />
302
+ ) : null}
303
+
304
+ {selected || !compact ? (
305
+ <Text
306
+ numberOfLines={1}
307
+ className={cn(
308
+ 'text-sm font-semibold',
309
+ selected ? 'text-onPrimaryContainer' : 'text-onSurfaceVariant',
310
+ )}
311
+ >
312
+ {item.label}
313
+ </Text>
314
+ ) : null}
315
+
316
+ {item.badge ? (
317
+ <View className="min-w-[20px] items-center justify-center rounded-full bg-primary px-1.5 py-0.5">
318
+ <Text className="text-xs font-bold tabular-nums text-primary-foreground">
319
+ {item.badge}
320
+ </Text>
321
+ </View>
322
+ ) : null}
323
+ </Control>
324
+ </Container>
325
+ );
326
+ }