@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.
- 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,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
|
+
}
|