@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,337 @@
|
|
|
1
|
+
import type { PressableStateCallbackType } from 'react-native';
|
|
2
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Platform, View } from 'react-native';
|
|
5
|
+
import Animated, {
|
|
6
|
+
useAnimatedStyle,
|
|
7
|
+
useSharedValue,
|
|
8
|
+
withSpring,
|
|
9
|
+
} from 'react-native-reanimated';
|
|
10
|
+
|
|
11
|
+
import { Surface, type MaterialOptions } from '../provider.tsx';
|
|
12
|
+
import { usesPlatformMaterial } from '../material.ts';
|
|
13
|
+
import { DialogActionContext } from './dialog-context.ts';
|
|
14
|
+
import type { IconProps } from './icons.tsx';
|
|
15
|
+
import { AnimatedPressable, Pressable } from './pressable.tsx';
|
|
16
|
+
import { Text } from './text.tsx';
|
|
17
|
+
import { useIsolinePlatform, useHaptics } from '../provider.tsx';
|
|
18
|
+
import { spatial } from '../motion.ts';
|
|
19
|
+
import { corner } from '../shape.ts';
|
|
20
|
+
import {
|
|
21
|
+
useRipple,
|
|
22
|
+
useThemeColors,
|
|
23
|
+
type RippleColors,
|
|
24
|
+
type ThemeColors,
|
|
25
|
+
} from '../provider.tsx';
|
|
26
|
+
import { cn } from '../utils.ts';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Material 3 Expressive button.
|
|
30
|
+
*
|
|
31
|
+
* Colour still carries emphasis — Filled for the one action that matters,
|
|
32
|
+
* Tonal below it, Outlined and Text lower again — but Expressive added two
|
|
33
|
+
* dimensions on top of that:
|
|
34
|
+
*
|
|
35
|
+
* - **Size as hierarchy.** Five sizes from XS to XL, so a primary action can
|
|
36
|
+
* be made important by being *large*, not just by being coloured. The larger
|
|
37
|
+
* sizes are meant to be used; a screen where every button is medium is the
|
|
38
|
+
* thing this is trying to fix.
|
|
39
|
+
* - **Shape as feedback.** Every button morphs between round and square while
|
|
40
|
+
* pressed. The shape change is felt more than it is seen, and it survives on
|
|
41
|
+
* a filled button where a ripple over a saturated fill barely registers.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const buttonVariants = cva('flex-row items-center justify-center', {
|
|
45
|
+
variants: {
|
|
46
|
+
variant: {
|
|
47
|
+
default: 'bg-primary',
|
|
48
|
+
secondary: 'bg-secondaryContainer',
|
|
49
|
+
tonal: 'bg-primaryContainer',
|
|
50
|
+
tertiary: 'bg-tertiary',
|
|
51
|
+
outline: 'border border-outline bg-transparent',
|
|
52
|
+
ghost: 'bg-transparent',
|
|
53
|
+
destructive: 'bg-destructive',
|
|
54
|
+
},
|
|
55
|
+
size: {
|
|
56
|
+
xs: 'h-8 gap-1.5 px-3',
|
|
57
|
+
s: 'h-10 gap-2 px-4',
|
|
58
|
+
m: 'h-14 gap-2 px-6',
|
|
59
|
+
l: 'h-24 gap-3 px-12',
|
|
60
|
+
xl: 'h-[136px] gap-4 px-16',
|
|
61
|
+
icon: 'h-12 w-12 px-0',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
defaultVariants: { variant: 'default', size: 'm' },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const labelVariants = cva('font-semibold', {
|
|
68
|
+
variants: {
|
|
69
|
+
variant: {
|
|
70
|
+
default: 'text-primary-foreground',
|
|
71
|
+
secondary: 'text-onSecondaryContainer',
|
|
72
|
+
tonal: 'text-onPrimaryContainer',
|
|
73
|
+
tertiary: 'text-tertiary-foreground',
|
|
74
|
+
outline: 'text-foreground',
|
|
75
|
+
ghost: 'text-primary',
|
|
76
|
+
destructive: 'text-destructive-foreground',
|
|
77
|
+
},
|
|
78
|
+
size: {
|
|
79
|
+
xs: 'text-[13px]',
|
|
80
|
+
s: 'text-sm',
|
|
81
|
+
m: 'text-[15px]',
|
|
82
|
+
l: 'text-2xl',
|
|
83
|
+
xl: 'text-[32px]',
|
|
84
|
+
icon: 'text-[15px]',
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
defaultVariants: { variant: 'default', size: 'm' },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/** Ripple reads dark-on-light for filled/tonal fills, light-on-dark otherwise. */
|
|
91
|
+
function rippleByVariant(
|
|
92
|
+
ripple: RippleColors,
|
|
93
|
+
): Record<NonNullable<ButtonProps['variant']>, string> {
|
|
94
|
+
return {
|
|
95
|
+
default: ripple.onTonal,
|
|
96
|
+
secondary: ripple.onTonal,
|
|
97
|
+
tonal: ripple.onTonal,
|
|
98
|
+
tertiary: ripple.onTonal,
|
|
99
|
+
outline: ripple.onNeutral,
|
|
100
|
+
ghost: ripple.onNeutral,
|
|
101
|
+
destructive: ripple.onTonal,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The icon beside a label takes the label's color. */
|
|
106
|
+
function iconColor(colors: ThemeColors, variant: NonNullable<ButtonProps['variant']>): string {
|
|
107
|
+
const byVariant: Record<NonNullable<ButtonProps['variant']>, string> = {
|
|
108
|
+
default: colors.onPrimary,
|
|
109
|
+
secondary: colors.onSecondaryContainer,
|
|
110
|
+
tonal: colors.onPrimaryContainer,
|
|
111
|
+
tertiary: colors.onTertiary,
|
|
112
|
+
outline: colors.onSurface,
|
|
113
|
+
ghost: colors.primary,
|
|
114
|
+
destructive: colors.onError,
|
|
115
|
+
};
|
|
116
|
+
return byVariant[variant];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const ICON_SIZE: Record<NonNullable<ButtonProps['size']>, number> = {
|
|
120
|
+
xs: 16,
|
|
121
|
+
s: 18,
|
|
122
|
+
m: 20,
|
|
123
|
+
l: 32,
|
|
124
|
+
xl: 40,
|
|
125
|
+
icon: 20,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** Height and the M3 square-shape radius for each size, in dp. */
|
|
129
|
+
const METRICS: Record<NonNullable<ButtonProps['size']>, { height: number; square: number }> = {
|
|
130
|
+
xs: { height: 32, square: corner.medium },
|
|
131
|
+
s: { height: 40, square: corner.medium },
|
|
132
|
+
m: { height: 56, square: corner.large },
|
|
133
|
+
l: { height: 96, square: corner.extraLarge },
|
|
134
|
+
xl: { height: 136, square: corner.extraLarge },
|
|
135
|
+
icon: { height: 48, square: corner.medium },
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export type ButtonProps = Omit<React.ComponentProps<typeof Pressable>, 'children'> &
|
|
139
|
+
VariantProps<typeof buttonVariants> &
|
|
140
|
+
MaterialOptions & {
|
|
141
|
+
children?: React.ComponentProps<typeof Pressable>['children'];
|
|
142
|
+
pressableClassName?: string;
|
|
143
|
+
label?: string;
|
|
144
|
+
/** A leading symbol, drawn in the label's color. */
|
|
145
|
+
icon?: React.ComponentType<IconProps>;
|
|
146
|
+
/** Resting shape. Pressing morphs to the other one and back. */
|
|
147
|
+
shape?: 'round' | 'square';
|
|
148
|
+
/** Fires a selection tick on press. Off for destructive-adjacent actions. */
|
|
149
|
+
haptic?: boolean;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Pressable with a spring shape-and-scale morph on touch.
|
|
154
|
+
*
|
|
155
|
+
* The press feedback runs on the UI thread through Reanimated rather than
|
|
156
|
+
* through React state, so it stays at 60fps while the JS thread is busy
|
|
157
|
+
* ingesting a sync batch.
|
|
158
|
+
*/
|
|
159
|
+
export function Button({
|
|
160
|
+
className,
|
|
161
|
+
material = 'solid',
|
|
162
|
+
glassStyle,
|
|
163
|
+
tintColor,
|
|
164
|
+
fallbackColor,
|
|
165
|
+
interactive = true,
|
|
166
|
+
pressableClassName,
|
|
167
|
+
style,
|
|
168
|
+
variant,
|
|
169
|
+
size,
|
|
170
|
+
label,
|
|
171
|
+
icon: Icon,
|
|
172
|
+
shape = 'round',
|
|
173
|
+
haptic = true,
|
|
174
|
+
children,
|
|
175
|
+
onPressIn,
|
|
176
|
+
onPressOut,
|
|
177
|
+
onPress,
|
|
178
|
+
...props
|
|
179
|
+
}: ButtonProps) {
|
|
180
|
+
const web = Platform.OS === 'web';
|
|
181
|
+
const platform = useIsolinePlatform();
|
|
182
|
+
const materialActive = usesPlatformMaterial(platform, material);
|
|
183
|
+
const Container = (
|
|
184
|
+
materialActive ? Surface : web ? View : Animated.View
|
|
185
|
+
) as typeof Animated.View;
|
|
186
|
+
const Control = (
|
|
187
|
+
web || materialActive ? Pressable : AnimatedPressable
|
|
188
|
+
) as typeof AnimatedPressable;
|
|
189
|
+
// Reanimated only drives the animated styles it finds in a plain `style`
|
|
190
|
+
// value: inside a style callback they are dropped, and the control keeps
|
|
191
|
+
// square corners under its rounded clip (an Outlined border then shows only
|
|
192
|
+
// as broken top and bottom lines). So the animated control gets a resolved
|
|
193
|
+
// array, and a caller's style callback is fed the pressed state from here.
|
|
194
|
+
const animated = !web && !materialActive;
|
|
195
|
+
const tracksPressed = web || (animated && typeof style === 'function');
|
|
196
|
+
const [pressedNow, setPressedNow] = React.useState(false);
|
|
197
|
+
const ios = useIsolinePlatform() === 'ios';
|
|
198
|
+
const alertAction = React.useContext(DialogActionContext) && ios;
|
|
199
|
+
const { selectionHaptic } = useHaptics();
|
|
200
|
+
const ripple = useRipple();
|
|
201
|
+
const colors = useThemeColors();
|
|
202
|
+
const pressed = useSharedValue(0);
|
|
203
|
+
const resolvedVariant = variant ?? 'default';
|
|
204
|
+
const resolvedSize = size ?? 'm';
|
|
205
|
+
const metrics = METRICS[resolvedSize];
|
|
206
|
+
|
|
207
|
+
const rest = ios ? 12 : shape === 'round' ? metrics.height / 2 : metrics.square;
|
|
208
|
+
const active = ios ? rest : shape === 'round' ? metrics.square : metrics.height / 2;
|
|
209
|
+
|
|
210
|
+
// Radius is shared with the wrapper below so the ripple's own clip morphs
|
|
211
|
+
// in lockstep with the visible shape — a Pressable can only self-clip its
|
|
212
|
+
// ripple to whatever radius it has *right now*, and Android's ripple mask
|
|
213
|
+
// doesn't reliably keep up with per-frame Reanimated updates on its own.
|
|
214
|
+
const radiusStyle = useAnimatedStyle(() => ({
|
|
215
|
+
borderRadius: rest + (active - rest) * pressed.value,
|
|
216
|
+
}));
|
|
217
|
+
const scaleStyle = useAnimatedStyle(() => ({
|
|
218
|
+
transform: [{ scale: 1 - pressed.value * 0.03 }],
|
|
219
|
+
}));
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<Container
|
|
223
|
+
{...(materialActive
|
|
224
|
+
? { material, glassStyle, tintColor, fallbackColor, interactive }
|
|
225
|
+
: {})}
|
|
226
|
+
style={[
|
|
227
|
+
web || materialActive
|
|
228
|
+
? {
|
|
229
|
+
borderRadius: alertAction
|
|
230
|
+
? 0
|
|
231
|
+
: materialActive
|
|
232
|
+
? rest
|
|
233
|
+
: pressedNow
|
|
234
|
+
? active
|
|
235
|
+
: rest,
|
|
236
|
+
}
|
|
237
|
+
: radiusStyle,
|
|
238
|
+
alertAction && { flex: 1 },
|
|
239
|
+
]}
|
|
240
|
+
className={cn(!materialActive && 'overflow-hidden', className)}
|
|
241
|
+
>
|
|
242
|
+
<Control
|
|
243
|
+
className={cn(
|
|
244
|
+
buttonVariants({ variant, size }),
|
|
245
|
+
props.disabled && 'opacity-50',
|
|
246
|
+
pressableClassName,
|
|
247
|
+
)}
|
|
248
|
+
style={resolveStyle(animated, pressedNow, (state: PressableStateCallbackType) => [
|
|
249
|
+
web || materialActive
|
|
250
|
+
? {
|
|
251
|
+
borderRadius: pressedNow ? active : rest,
|
|
252
|
+
transform: [{ scale: pressedNow ? 0.97 : 1 }],
|
|
253
|
+
}
|
|
254
|
+
: radiusStyle,
|
|
255
|
+
animated && scaleStyle,
|
|
256
|
+
ios && {
|
|
257
|
+
minHeight: 44,
|
|
258
|
+
height: { xs: 44, s: 44, m: 50, l: 64, xl: 80, icon: 44 }[resolvedSize],
|
|
259
|
+
borderWidth: resolvedVariant === 'outline' ? 0 : undefined,
|
|
260
|
+
},
|
|
261
|
+
materialActive && { backgroundColor: 'transparent', borderWidth: 0 },
|
|
262
|
+
alertAction && { backgroundColor: 'transparent', borderWidth: 0, borderRadius: 0 },
|
|
263
|
+
typeof style === 'function' ? style(state) : style,
|
|
264
|
+
])}
|
|
265
|
+
android_ripple={{ color: rippleByVariant(ripple)[resolvedVariant] }}
|
|
266
|
+
onPressIn={(event) => {
|
|
267
|
+
if (tracksPressed) setPressedNow(true);
|
|
268
|
+
pressed.value = withSpring(1, spatial.fast);
|
|
269
|
+
onPressIn?.(event);
|
|
270
|
+
}}
|
|
271
|
+
onPressOut={(event) => {
|
|
272
|
+
if (tracksPressed) setPressedNow(false);
|
|
273
|
+
pressed.value = withSpring(0, spatial.default);
|
|
274
|
+
onPressOut?.(event);
|
|
275
|
+
}}
|
|
276
|
+
onPress={(event) => {
|
|
277
|
+
if (haptic) selectionHaptic();
|
|
278
|
+
onPress?.(event);
|
|
279
|
+
}}
|
|
280
|
+
accessibilityRole="button"
|
|
281
|
+
accessibilityState={{ disabled: props.disabled ?? false }}
|
|
282
|
+
{...props}
|
|
283
|
+
>
|
|
284
|
+
{(state: PressableStateCallbackType) => (
|
|
285
|
+
<>
|
|
286
|
+
{Icon ? (
|
|
287
|
+
<Icon
|
|
288
|
+
size={ICON_SIZE[resolvedSize]}
|
|
289
|
+
color={
|
|
290
|
+
alertAction || materialActive
|
|
291
|
+
? resolvedVariant === 'destructive'
|
|
292
|
+
? colors.error
|
|
293
|
+
: colors.primary
|
|
294
|
+
: iconColor(colors, resolvedVariant)
|
|
295
|
+
}
|
|
296
|
+
/>
|
|
297
|
+
) : null}
|
|
298
|
+
{label ? (
|
|
299
|
+
<Text
|
|
300
|
+
className={cn(labelVariants({ variant, size }))}
|
|
301
|
+
style={
|
|
302
|
+
alertAction || materialActive
|
|
303
|
+
? {
|
|
304
|
+
color:
|
|
305
|
+
resolvedVariant === 'destructive' ? colors.error : colors.primary,
|
|
306
|
+
}
|
|
307
|
+
: undefined
|
|
308
|
+
}
|
|
309
|
+
numberOfLines={1}
|
|
310
|
+
>
|
|
311
|
+
{label}
|
|
312
|
+
</Text>
|
|
313
|
+
) : typeof children === 'function' ? (
|
|
314
|
+
children(state)
|
|
315
|
+
) : (
|
|
316
|
+
children
|
|
317
|
+
)}
|
|
318
|
+
</>
|
|
319
|
+
)}
|
|
320
|
+
</Control>
|
|
321
|
+
</Container>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The control's style as Reanimated can read it: resolved now for the
|
|
327
|
+
* animated control, left as a callback where no animated style is involved.
|
|
328
|
+
*/
|
|
329
|
+
function resolveStyle<T>(
|
|
330
|
+
animated: boolean,
|
|
331
|
+
pressed: boolean,
|
|
332
|
+
style: (state: PressableStateCallbackType) => T,
|
|
333
|
+
): T | ((state: PressableStateCallbackType) => T) {
|
|
334
|
+
return animated ? style({ pressed }) : style;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export { buttonVariants, labelVariants };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { useThemeColors, useHaptics, useIsolinePlatform } from '../provider.tsx';
|
|
4
|
+
import { Pressable } from './pressable.tsx';
|
|
5
|
+
import { Check } from './icons.tsx';
|
|
6
|
+
import { Text } from './text.tsx';
|
|
7
|
+
|
|
8
|
+
export interface CheckboxProps {
|
|
9
|
+
checked: boolean | 'mixed';
|
|
10
|
+
onCheckedChange: (checked: boolean) => void;
|
|
11
|
+
accessibilityLabel: string;
|
|
12
|
+
label?: string;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
style?: StyleProp<ViewStyle>;
|
|
15
|
+
}
|
|
16
|
+
/** Controlled selection; storage and selection of a collection remain caller-owned. */
|
|
17
|
+
export function Checkbox({
|
|
18
|
+
checked,
|
|
19
|
+
onCheckedChange,
|
|
20
|
+
accessibilityLabel,
|
|
21
|
+
label,
|
|
22
|
+
disabled,
|
|
23
|
+
style,
|
|
24
|
+
}: CheckboxProps) {
|
|
25
|
+
const colors = useThemeColors();
|
|
26
|
+
const ios = useIsolinePlatform() === 'ios';
|
|
27
|
+
const { selectionHaptic } = useHaptics();
|
|
28
|
+
return (
|
|
29
|
+
<Pressable
|
|
30
|
+
accessibilityRole="checkbox"
|
|
31
|
+
accessibilityLabel={accessibilityLabel}
|
|
32
|
+
accessibilityState={{ checked, disabled }}
|
|
33
|
+
disabled={disabled}
|
|
34
|
+
onPress={() => {
|
|
35
|
+
selectionHaptic();
|
|
36
|
+
onCheckedChange(checked !== true);
|
|
37
|
+
}}
|
|
38
|
+
style={[
|
|
39
|
+
{
|
|
40
|
+
minHeight: 44,
|
|
41
|
+
minWidth: 44,
|
|
42
|
+
flexDirection: 'row',
|
|
43
|
+
alignItems: 'center',
|
|
44
|
+
gap: 10,
|
|
45
|
+
opacity: disabled ? 0.4 : 1,
|
|
46
|
+
},
|
|
47
|
+
style,
|
|
48
|
+
]}
|
|
49
|
+
>
|
|
50
|
+
<View
|
|
51
|
+
style={{
|
|
52
|
+
width: 24,
|
|
53
|
+
height: 24,
|
|
54
|
+
borderRadius: ios || checked ? 12 : 6,
|
|
55
|
+
borderWidth: 2,
|
|
56
|
+
borderColor: checked ? colors.primary : colors.outline,
|
|
57
|
+
backgroundColor: checked ? colors.primary : 'transparent',
|
|
58
|
+
alignItems: 'center',
|
|
59
|
+
justifyContent: 'center',
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{checked === 'mixed' ? (
|
|
63
|
+
<View style={{ width: 12, height: 2, backgroundColor: colors.onPrimary }} />
|
|
64
|
+
) : checked ? (
|
|
65
|
+
<Check size={18} color={colors.onPrimary} />
|
|
66
|
+
) : null}
|
|
67
|
+
</View>
|
|
68
|
+
{label ? <Text style={{ flexShrink: 1 }}>{label}</Text> : null}
|
|
69
|
+
</Pressable>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
import { Pressable } from './pressable.tsx';
|
|
4
|
+
import { Text } from './text.tsx';
|
|
5
|
+
import { Check } from './icons.tsx';
|
|
6
|
+
import { BottomSheet } from './bottom-sheet.tsx';
|
|
7
|
+
import { useThemeColors, type MaterialOptions } from '../provider.tsx';
|
|
8
|
+
import { useRadioNavigation } from './radio-navigation.ts';
|
|
9
|
+
|
|
10
|
+
export interface ColorSwatch {
|
|
11
|
+
value: string;
|
|
12
|
+
label: string;
|
|
13
|
+
color?: string;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface ColorSwatchPickerProps {
|
|
17
|
+
value: string | null;
|
|
18
|
+
onChange: (value: string) => void;
|
|
19
|
+
swatches: ColorSwatch[];
|
|
20
|
+
accessibilityLabel: string;
|
|
21
|
+
}
|
|
22
|
+
export function ColorSwatchPicker({
|
|
23
|
+
value,
|
|
24
|
+
onChange,
|
|
25
|
+
swatches,
|
|
26
|
+
accessibilityLabel,
|
|
27
|
+
}: ColorSwatchPickerProps) {
|
|
28
|
+
const colors = useThemeColors();
|
|
29
|
+
const navigation = useRadioNavigation(
|
|
30
|
+
swatches.map((swatch) => ({ id: swatch.value, disabled: swatch.disabled })),
|
|
31
|
+
value,
|
|
32
|
+
onChange,
|
|
33
|
+
);
|
|
34
|
+
return (
|
|
35
|
+
<View
|
|
36
|
+
accessibilityRole="radiogroup"
|
|
37
|
+
accessibilityLabel={accessibilityLabel}
|
|
38
|
+
style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 12 }}
|
|
39
|
+
>
|
|
40
|
+
{swatches.map((swatch) => (
|
|
41
|
+
<Pressable
|
|
42
|
+
{...navigation(swatch.value)}
|
|
43
|
+
key={swatch.value}
|
|
44
|
+
accessibilityRole="radio"
|
|
45
|
+
accessibilityLabel={swatch.label}
|
|
46
|
+
disabled={swatch.disabled}
|
|
47
|
+
accessibilityState={{ checked: value === swatch.value, disabled: swatch.disabled }}
|
|
48
|
+
onPress={() => onChange(swatch.value)}
|
|
49
|
+
style={{
|
|
50
|
+
width: 48,
|
|
51
|
+
height: 48,
|
|
52
|
+
padding: 4,
|
|
53
|
+
borderWidth: 2,
|
|
54
|
+
borderRadius: 24,
|
|
55
|
+
borderColor: value === swatch.value ? colors.primary : 'transparent',
|
|
56
|
+
opacity: swatch.disabled ? 0.4 : 1,
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
<View
|
|
60
|
+
style={{
|
|
61
|
+
flex: 1,
|
|
62
|
+
borderRadius: 20,
|
|
63
|
+
backgroundColor: swatch.color ?? swatch.value,
|
|
64
|
+
alignItems: 'center',
|
|
65
|
+
justifyContent: 'center',
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
{value === swatch.value ? (
|
|
69
|
+
<View style={{ borderRadius: 12, backgroundColor: colors.surface, padding: 2 }}>
|
|
70
|
+
<Check size={18} color={colors.onSurface} />
|
|
71
|
+
</View>
|
|
72
|
+
) : null}
|
|
73
|
+
</View>
|
|
74
|
+
</Pressable>
|
|
75
|
+
))}
|
|
76
|
+
</View>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
export interface ColorPickerSheetProps extends ColorSwatchPickerProps, MaterialOptions {
|
|
80
|
+
visible: boolean;
|
|
81
|
+
onClose: () => void;
|
|
82
|
+
title: string;
|
|
83
|
+
}
|
|
84
|
+
export function ColorPickerSheet({
|
|
85
|
+
visible,
|
|
86
|
+
onClose,
|
|
87
|
+
title,
|
|
88
|
+
material = 'solid',
|
|
89
|
+
glassStyle,
|
|
90
|
+
tintColor,
|
|
91
|
+
fallbackColor,
|
|
92
|
+
interactive,
|
|
93
|
+
...props
|
|
94
|
+
}: ColorPickerSheetProps) {
|
|
95
|
+
return (
|
|
96
|
+
<BottomSheet
|
|
97
|
+
visible={visible}
|
|
98
|
+
onClose={onClose}
|
|
99
|
+
material={material}
|
|
100
|
+
glassStyle={glassStyle}
|
|
101
|
+
tintColor={tintColor}
|
|
102
|
+
fallbackColor={fallbackColor}
|
|
103
|
+
interactive={interactive}
|
|
104
|
+
>
|
|
105
|
+
<Text variant="heading">{title}</Text>
|
|
106
|
+
<ColorSwatchPicker
|
|
107
|
+
{...props}
|
|
108
|
+
onChange={(value) => {
|
|
109
|
+
props.onChange(value);
|
|
110
|
+
onClose();
|
|
111
|
+
}}
|
|
112
|
+
/>
|
|
113
|
+
</BottomSheet>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Platform, View } from 'react-native';
|
|
3
|
+
import { useThemeColors, useHaptics } from '../provider.tsx';
|
|
4
|
+
import type { MaterialOptions } from '../material.ts';
|
|
5
|
+
import { Popover } from './popover.tsx';
|
|
6
|
+
import { Pressable } from './pressable.tsx';
|
|
7
|
+
import { Text } from './text.tsx';
|
|
8
|
+
import { Check, type IconProps } from './icons.tsx';
|
|
9
|
+
export interface ContextMenuItem {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
onPress: () => void;
|
|
13
|
+
icon?: React.ComponentType<IconProps>;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
destructive?: boolean;
|
|
16
|
+
selected?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface ContextMenuProps extends MaterialOptions {
|
|
19
|
+
children: React.ReactNode;
|
|
20
|
+
accessibilityLabel: string;
|
|
21
|
+
items: ContextMenuItem[];
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
onOpenChange?: (open: boolean) => void;
|
|
24
|
+
placement?: 'top' | 'bottom';
|
|
25
|
+
}
|
|
26
|
+
/** Shared anchored menu, not a UIKit context-menu controller or an application router. */
|
|
27
|
+
export function ContextMenu({
|
|
28
|
+
children,
|
|
29
|
+
accessibilityLabel,
|
|
30
|
+
items,
|
|
31
|
+
disabled,
|
|
32
|
+
onOpenChange,
|
|
33
|
+
placement,
|
|
34
|
+
...material
|
|
35
|
+
}: ContextMenuProps) {
|
|
36
|
+
const [open, setOpen] = React.useState(false);
|
|
37
|
+
const anchor = React.useRef<React.ComponentRef<typeof View>>(null);
|
|
38
|
+
const set = (next: boolean) => {
|
|
39
|
+
setOpen(next);
|
|
40
|
+
onOpenChange?.(next);
|
|
41
|
+
};
|
|
42
|
+
return (
|
|
43
|
+
<View ref={anchor} collapsable={false}>
|
|
44
|
+
<Pressable
|
|
45
|
+
accessibilityRole="button"
|
|
46
|
+
accessibilityLabel={accessibilityLabel}
|
|
47
|
+
accessibilityState={{ expanded: open, disabled }}
|
|
48
|
+
disabled={disabled}
|
|
49
|
+
onPress={() => set(true)}
|
|
50
|
+
onLongPress={() => set(true)}
|
|
51
|
+
style={{ minHeight: 44, justifyContent: 'center' }}
|
|
52
|
+
>
|
|
53
|
+
{children}
|
|
54
|
+
</Pressable>
|
|
55
|
+
<Popover
|
|
56
|
+
{...material}
|
|
57
|
+
visible={open}
|
|
58
|
+
onClose={() => set(false)}
|
|
59
|
+
anchorRef={anchor}
|
|
60
|
+
placement={placement}
|
|
61
|
+
accessibilityLabel={accessibilityLabel}
|
|
62
|
+
>
|
|
63
|
+
<ContextMenuRows items={items} onClose={() => set(false)} />
|
|
64
|
+
</Popover>
|
|
65
|
+
</View>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
export function ContextMenuRows({
|
|
69
|
+
items,
|
|
70
|
+
onClose,
|
|
71
|
+
}: {
|
|
72
|
+
items: ContextMenuItem[];
|
|
73
|
+
onClose: () => void;
|
|
74
|
+
}) {
|
|
75
|
+
const colors = useThemeColors();
|
|
76
|
+
const refs = React.useRef<Record<string, React.ComponentRef<typeof Pressable> | null>>({});
|
|
77
|
+
const initial =
|
|
78
|
+
items.find((item) => item.selected && !item.disabled) ??
|
|
79
|
+
items.find((item) => !item.disabled);
|
|
80
|
+
React.useEffect(() => {
|
|
81
|
+
if (Platform.OS === 'web' && initial) refs.current[initial.id]?.focus();
|
|
82
|
+
}, []);
|
|
83
|
+
const { selectionHaptic } = useHaptics();
|
|
84
|
+
return (
|
|
85
|
+
<View accessibilityRole="menu">
|
|
86
|
+
{items.map((item) => {
|
|
87
|
+
const Icon = item.icon;
|
|
88
|
+
const color = item.destructive ? colors.error : colors.onSurface;
|
|
89
|
+
return (
|
|
90
|
+
<Pressable
|
|
91
|
+
key={item.id}
|
|
92
|
+
ref={(node) => {
|
|
93
|
+
refs.current[item.id] = node;
|
|
94
|
+
}}
|
|
95
|
+
tabIndex={item.id === initial?.id ? 0 : -1}
|
|
96
|
+
{...(Platform.OS === 'web'
|
|
97
|
+
? {
|
|
98
|
+
onKeyDown: (
|
|
99
|
+
event: Parameters<
|
|
100
|
+
NonNullable<React.ComponentProps<typeof Pressable>['onKeyDown']>
|
|
101
|
+
>[0],
|
|
102
|
+
) => {
|
|
103
|
+
const key = event.nativeEvent.key;
|
|
104
|
+
if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(key)) return;
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
const enabled = items.filter((action) => !action.disabled);
|
|
107
|
+
if (!enabled.length) return;
|
|
108
|
+
const index = enabled.findIndex((action) => action.id === item.id);
|
|
109
|
+
const next =
|
|
110
|
+
key === 'Home'
|
|
111
|
+
? enabled[0]
|
|
112
|
+
: key === 'End'
|
|
113
|
+
? enabled[enabled.length - 1]
|
|
114
|
+
: enabled[
|
|
115
|
+
(index + (key === 'ArrowDown' ? 1 : -1) + enabled.length) %
|
|
116
|
+
enabled.length
|
|
117
|
+
];
|
|
118
|
+
refs.current[next.id]?.focus();
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
: {})}
|
|
122
|
+
disabled={item.disabled}
|
|
123
|
+
accessibilityRole={item.selected === undefined ? 'menuitem' : 'menuitemradio'}
|
|
124
|
+
accessibilityLabel={item.label}
|
|
125
|
+
accessibilityState={{ disabled: item.disabled, checked: item.selected }}
|
|
126
|
+
onPress={() => {
|
|
127
|
+
selectionHaptic();
|
|
128
|
+
onClose();
|
|
129
|
+
item.onPress();
|
|
130
|
+
}}
|
|
131
|
+
style={{
|
|
132
|
+
minHeight: 44,
|
|
133
|
+
padding: 10,
|
|
134
|
+
flexDirection: 'row',
|
|
135
|
+
alignItems: 'center',
|
|
136
|
+
gap: 10,
|
|
137
|
+
opacity: item.disabled ? 0.4 : 1,
|
|
138
|
+
borderRadius: 10,
|
|
139
|
+
}}
|
|
140
|
+
>
|
|
141
|
+
{Icon ? <Icon size={20} color={color} /> : null}
|
|
142
|
+
<Text style={{ flex: 1, color, fontSize: 16 }}>{item.label}</Text>
|
|
143
|
+
{item.selected ? <Check size={18} color={colors.primary} /> : null}
|
|
144
|
+
</Pressable>
|
|
145
|
+
);
|
|
146
|
+
})}
|
|
147
|
+
</View>
|
|
148
|
+
);
|
|
149
|
+
}
|