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