@isoline-ui/react-native 0.0.0-stage → 0.1.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/LICENSE +22 -0
  2. package/NOTICE.md +12 -0
  3. package/README.md +104 -3
  4. package/dist/amount.d.ts +3 -0
  5. package/dist/calendar.d.ts +7 -0
  6. package/dist/components/action-chip.d.ts +9 -0
  7. package/dist/components/amount-field.d.ts +12 -0
  8. package/dist/components/avatar.d.ts +17 -0
  9. package/dist/components/back-button.d.ts +12 -0
  10. package/dist/components/back-icon.d.ts +3 -0
  11. package/dist/components/badge.d.ts +13 -0
  12. package/dist/components/banner.d.ts +14 -0
  13. package/dist/components/bottom-sheet-native.d.ts +11 -0
  14. package/dist/components/bottom-sheet-web.d.ts +18 -0
  15. package/dist/components/bottom-sheet.d.ts +3 -0
  16. package/dist/components/button-group.d.ts +26 -0
  17. package/dist/components/button.d.ts +48 -0
  18. package/dist/components/checkbox.d.ts +12 -0
  19. package/dist/components/color-picker.d.ts +21 -0
  20. package/dist/components/context-menu.d.ts +26 -0
  21. package/dist/components/date-picker.d.ts +64 -0
  22. package/dist/components/dialog-context.d.ts +2 -0
  23. package/dist/components/dialog.d.ts +20 -0
  24. package/dist/components/empty-state.d.ts +15 -0
  25. package/dist/components/expand-indicator.d.ts +27 -0
  26. package/dist/components/expressive-shape.d.ts +18 -0
  27. package/dist/components/fab-menu-legacy.d.ts +31 -0
  28. package/dist/components/fab-menu.d.ts +21 -0
  29. package/dist/components/filter-chip.d.ts +69 -0
  30. package/dist/components/floating-action-button.d.ts +15 -0
  31. package/dist/components/icon-button.d.ts +40 -0
  32. package/dist/components/icons.d.ts +18 -0
  33. package/dist/components/input.d.ts +9 -0
  34. package/dist/components/keyboard-inset-view.d.ts +15 -0
  35. package/dist/components/list-controls.d.ts +38 -0
  36. package/dist/components/loading-indicator.d.ts +9 -0
  37. package/dist/components/menu-sheet.d.ts +36 -0
  38. package/dist/components/panel-sheet.d.ts +8 -0
  39. package/dist/components/password-visibility.d.ts +5 -0
  40. package/dist/components/popover-layout.d.ts +25 -0
  41. package/dist/components/popover.d.ts +20 -0
  42. package/dist/components/pressable.d.ts +12 -0
  43. package/dist/components/radio-navigation.d.ts +11 -0
  44. package/dist/components/refresh-control.d.ts +16 -0
  45. package/dist/components/row-gap.d.ts +9 -0
  46. package/dist/components/search-field.d.ts +22 -0
  47. package/dist/components/select-field.d.ts +16 -0
  48. package/dist/components/settings-list.d.ts +98 -0
  49. package/dist/components/settings-row.d.ts +13 -0
  50. package/dist/components/sheet-frame.d.ts +27 -0
  51. package/dist/components/sheet-lifecycle.d.ts +36 -0
  52. package/dist/components/sheet-transition.d.ts +36 -0
  53. package/dist/components/slider.d.ts +21 -0
  54. package/dist/components/snackbar.d.ts +10 -0
  55. package/dist/components/split-button.d.ts +38 -0
  56. package/dist/components/squish.d.ts +20 -0
  57. package/dist/components/swipeable-row.d.ts +20 -0
  58. package/dist/components/switch.d.ts +8 -0
  59. package/dist/components/tab-bar.d.ts +20 -0
  60. package/dist/components/text-field.d.ts +31 -0
  61. package/dist/components/text.d.ts +9 -0
  62. package/dist/components/time-picker.d.ts +40 -0
  63. package/dist/components/toolbar.d.ts +77 -0
  64. package/dist/components/top-bar.d.ts +27 -0
  65. package/dist/components/undo-bar.d.ts +23 -0
  66. package/dist/components/wavy-progress.d.ts +21 -0
  67. package/dist/components/zoomable-image.d.ts +18 -0
  68. package/dist/expressive-shapes.d.ts +8 -0
  69. package/dist/index.d.ts +62 -0
  70. package/dist/keyboard.d.ts +2 -0
  71. package/dist/material.d.ts +11 -0
  72. package/dist/media.d.ts +11 -0
  73. package/dist/motion.d.ts +96 -0
  74. package/dist/platform.d.ts +3 -0
  75. package/dist/preset-tokens.d.ts +264 -0
  76. package/dist/provider.d.ts +59 -0
  77. package/dist/reduced-motion.d.ts +45 -0
  78. package/dist/selection.d.ts +2 -0
  79. package/dist/shape.d.ts +99 -0
  80. package/dist/sheet-physics.d.ts +37 -0
  81. package/dist/theme.d.ts +103 -0
  82. package/dist/tokens.d.ts +2 -0
  83. package/dist/use-keyboard-height.d.ts +10 -0
  84. package/dist/utils.d.ts +3 -0
  85. package/nativewind.cjs +68 -0
  86. package/package.json +63 -6
  87. package/src/amount.ts +53 -0
  88. package/src/calendar.ts +42 -0
  89. package/src/components/action-chip.tsx +64 -0
  90. package/src/components/amount-field.tsx +49 -0
  91. package/src/components/avatar.tsx +91 -0
  92. package/src/components/back-button.tsx +39 -0
  93. package/src/components/back-icon.tsx +25 -0
  94. package/src/components/badge.tsx +41 -0
  95. package/src/components/banner.tsx +63 -0
  96. package/src/components/bottom-sheet-native.tsx +52 -0
  97. package/src/components/bottom-sheet-web.tsx +193 -0
  98. package/src/components/bottom-sheet.tsx +5 -0
  99. package/src/components/button-group.tsx +326 -0
  100. package/src/components/button.tsx +337 -0
  101. package/src/components/checkbox.tsx +71 -0
  102. package/src/components/color-picker.tsx +115 -0
  103. package/src/components/context-menu.tsx +149 -0
  104. package/src/components/date-picker.tsx +466 -0
  105. package/src/components/dialog-context.ts +3 -0
  106. package/src/components/dialog.tsx +210 -0
  107. package/src/components/empty-state.tsx +49 -0
  108. package/src/components/expand-indicator.tsx +60 -0
  109. package/src/components/expressive-shape.tsx +37 -0
  110. package/src/components/fab-menu-legacy.tsx +181 -0
  111. package/src/components/fab-menu.tsx +90 -0
  112. package/src/components/filter-chip.tsx +136 -0
  113. package/src/components/floating-action-button.tsx +79 -0
  114. package/src/components/icon-button.tsx +127 -0
  115. package/src/components/icons.tsx +43 -0
  116. package/src/components/input.tsx +80 -0
  117. package/src/components/keyboard-inset-view.tsx +28 -0
  118. package/src/components/list-controls.tsx +134 -0
  119. package/src/components/loading-indicator.tsx +183 -0
  120. package/src/components/menu-sheet.tsx +133 -0
  121. package/src/components/panel-sheet.tsx +90 -0
  122. package/src/components/password-visibility.tsx +39 -0
  123. package/src/components/popover-layout.ts +47 -0
  124. package/src/components/popover.tsx +139 -0
  125. package/src/components/pressable.tsx +108 -0
  126. package/src/components/radio-navigation.ts +52 -0
  127. package/src/components/refresh-control.tsx +39 -0
  128. package/src/components/row-gap.tsx +13 -0
  129. package/src/components/search-field.tsx +92 -0
  130. package/src/components/select-field.tsx +105 -0
  131. package/src/components/settings-list.tsx +302 -0
  132. package/src/components/settings-row.tsx +61 -0
  133. package/src/components/sheet-frame.tsx +250 -0
  134. package/src/components/sheet-lifecycle.ts +233 -0
  135. package/src/components/sheet-transition.ts +105 -0
  136. package/src/components/slider.tsx +185 -0
  137. package/src/components/snackbar.tsx +97 -0
  138. package/src/components/split-button.tsx +212 -0
  139. package/src/components/squish.ts +41 -0
  140. package/src/components/swipeable-row.tsx +109 -0
  141. package/src/components/switch.tsx +77 -0
  142. package/src/components/tab-bar.tsx +195 -0
  143. package/src/components/text-field.tsx +264 -0
  144. package/src/components/text.tsx +58 -0
  145. package/src/components/time-picker.tsx +277 -0
  146. package/src/components/toolbar.tsx +406 -0
  147. package/src/components/top-bar.tsx +91 -0
  148. package/src/components/undo-bar.tsx +137 -0
  149. package/src/components/wavy-progress.tsx +150 -0
  150. package/src/components/zoomable-image.tsx +240 -0
  151. package/src/expressive-shapes.ts +25 -0
  152. package/src/index.ts +62 -0
  153. package/src/keyboard.ts +5 -0
  154. package/src/material.ts +16 -0
  155. package/src/media.ts +51 -0
  156. package/src/motion.ts +96 -0
  157. package/src/platform.ts +13 -0
  158. package/src/preset-tokens.ts +265 -0
  159. package/src/provider.tsx +201 -0
  160. package/src/reduced-motion.ts +50 -0
  161. package/src/selection.ts +11 -0
  162. package/src/shape.ts +217 -0
  163. package/src/sheet-physics.ts +73 -0
  164. package/src/theme.ts +180 -0
  165. package/src/tokens.ts +2 -0
  166. package/src/use-keyboard-height.ts +64 -0
  167. package/src/utils.ts +7 -0
@@ -0,0 +1,49 @@
1
+ import type * as React from 'react';
2
+ import { View } from 'react-native';
3
+ import Animated, { FadeIn } from 'react-native-reanimated';
4
+
5
+ import { ExpressiveShape, type ExpressiveShapeName } from './expressive-shape.tsx';
6
+ import type { IconProps } from './icons.tsx';
7
+ import { Text } from './text.tsx';
8
+ import { useThemeColors } from '../provider.tsx';
9
+
10
+ /**
11
+ * An empty list's one thing to look at: a large Expressive shape with the
12
+ * symbol of what would be here, a line saying so, and a line saying how to
13
+ * change it.
14
+ */
15
+ export function EmptyState({
16
+ icon: Icon,
17
+ shape,
18
+ title,
19
+ body,
20
+ tone = 'primary',
21
+ }: {
22
+ icon: React.ComponentType<IconProps>;
23
+ shape: ExpressiveShapeName;
24
+ title: string;
25
+ body?: string;
26
+ tone?: 'primary' | 'tertiary';
27
+ }) {
28
+ const colors = useThemeColors();
29
+ const fill = tone === 'primary' ? colors.primaryContainer : colors.tertiaryContainer;
30
+ const ink = tone === 'primary' ? colors.onPrimaryContainer : colors.onTertiaryContainer;
31
+
32
+ return (
33
+ <Animated.View entering={FadeIn.duration(220)} className="items-center gap-5 px-8 py-12">
34
+ <ExpressiveShape shape={shape} size={132} color={fill}>
35
+ <Icon size={56} color={ink} />
36
+ </ExpressiveShape>
37
+ <View className="items-center gap-2">
38
+ <Text variant="heading" className="text-center">
39
+ {title}
40
+ </Text>
41
+ {body ? (
42
+ <Text variant="muted" className="text-center leading-5">
43
+ {body}
44
+ </Text>
45
+ ) : null}
46
+ </View>
47
+ </Animated.View>
48
+ );
49
+ }
@@ -0,0 +1,60 @@
1
+ import * as React from 'react';
2
+ import Animated, {
3
+ useAnimatedStyle,
4
+ useSharedValue,
5
+ withSpring,
6
+ } from 'react-native-reanimated';
7
+
8
+ import { ChevronDown } from './icons.tsx';
9
+ import { spatial } from '../motion.ts';
10
+ import { useThemeColors } from '../provider.tsx';
11
+
12
+ /** One size for every disclosure chevron, so no two sections look differently foldable. */
13
+ export const EXPAND_INDICATOR_SIZE = 20;
14
+
15
+ export interface ExpandIndicatorProps {
16
+ expanded: boolean;
17
+ /** Defaults to `onSurfaceVariant`; a chip in its selected tone passes its own. */
18
+ color?: string;
19
+ }
20
+
21
+ /**
22
+ * The chevron on anything that opens in place — a message card, its details,
23
+ * the Cc/Bcc fields.
24
+ *
25
+ * Every one of those used to draw its own `ChevronDown` at whatever size
26
+ * suited the spot: 16 on a card, 12 on the details line beneath it, so two
27
+ * disclosures a finger's width apart looked like different kinds of thing.
28
+ * This is the one they share.
29
+ *
30
+ * It turns on the fast spatial spring rather than flipping, and overshoots a
31
+ * little on the way: the Expressive cue that the section is a thing being
32
+ * opened, not a picture being swapped. Reanimated drops the spring for
33
+ * "Remove animations" on its own, and the chevron still ends up pointing the
34
+ * right way — the state is carried by where it ends, not by the movement.
35
+ *
36
+ * Decorative to a screen reader: whatever it sits in owns the tap and says
37
+ * `expanded` itself.
38
+ */
39
+ export function ExpandIndicator({ expanded, color }: ExpandIndicatorProps) {
40
+ const colors = useThemeColors();
41
+ const turn = useSharedValue(expanded ? 1 : 0);
42
+
43
+ React.useEffect(() => {
44
+ turn.value = withSpring(expanded ? 1 : 0, spatial.fast);
45
+ }, [expanded, turn]);
46
+
47
+ const style = useAnimatedStyle(() => ({
48
+ transform: [{ rotate: `${turn.value * 180}deg` }],
49
+ }));
50
+
51
+ return (
52
+ <Animated.View
53
+ style={style}
54
+ accessibilityElementsHidden
55
+ importantForAccessibility="no-hide-descendants"
56
+ >
57
+ <ChevronDown size={EXPAND_INDICATOR_SIZE} color={color ?? colors.onSurfaceVariant} />
58
+ </Animated.View>
59
+ );
60
+ }
@@ -0,0 +1,37 @@
1
+ import * as React from 'react';
2
+ import { View } from 'react-native';
3
+ import Svg, { Path } from 'react-native-svg';
4
+
5
+ /**
6
+ * A still member of the Expressive shape library, with something inside it.
7
+ *
8
+ * Expressive leans on a small set of characterful shapes — the 4-leaf clover,
9
+ * the scalloped cookie — to mark the one thing on a screen worth looking at
10
+ * first: an empty state's symbol, a step in a flow. Drawn from the same radial
11
+ * maths the loading indicator morphs (`~/lib/shape`), so the app's shapes are
12
+ * one family.
13
+ */
14
+
15
+ export { type ExpressiveShapeName } from '../expressive-shapes.ts';
16
+ import { EXPRESSIVE_SHAPES as PATHS, type ExpressiveShapeName } from '../expressive-shapes.ts';
17
+
18
+ export function ExpressiveShape({
19
+ shape,
20
+ size,
21
+ color,
22
+ children,
23
+ }: {
24
+ shape: ExpressiveShapeName;
25
+ size: number;
26
+ color: string;
27
+ children?: React.ReactNode;
28
+ }) {
29
+ return (
30
+ <View style={{ width: size, height: size }} className="items-center justify-center">
31
+ <Svg width={size} height={size} viewBox="0 0 100 100" style={{ position: 'absolute' }}>
32
+ <Path d={PATHS[shape]} fill={color} />
33
+ </Svg>
34
+ {children}
35
+ </View>
36
+ );
37
+ }
@@ -0,0 +1,181 @@
1
+ import * as React from 'react';
2
+ import { BackHandler, View } from 'react-native';
3
+ import Animated, {
4
+ FadeIn,
5
+ FadeInDown,
6
+ FadeOut,
7
+ FadeOutDown,
8
+ useAnimatedStyle,
9
+ useSharedValue,
10
+ withSpring,
11
+ } from 'react-native-reanimated';
12
+
13
+ import { Plus, type IconProps } from './icons.tsx';
14
+ import { Pressable } from './pressable.tsx';
15
+ import { Text } from './text.tsx';
16
+ import { useHaptics, useLabels } from '../provider.tsx';
17
+ import { spatial, staggerFor } from '../motion.ts';
18
+ import { useReducedMotion } from '../reduced-motion.ts';
19
+ import { elevation, useRipple, useThemeColors } from '../provider.tsx';
20
+
21
+ export interface FabMenuAction {
22
+ disabled?: boolean;
23
+ destructive?: boolean;
24
+ id: string;
25
+ label: string;
26
+ icon: React.ComponentType<IconProps>;
27
+ run: () => void;
28
+ }
29
+ export interface FabMenuProps {
30
+ bottom: number;
31
+ label: string;
32
+ actions: FabMenuAction[];
33
+ }
34
+
35
+ /**
36
+ * The button's closed size: the height of the floating navigation bar it
37
+ * sits beside, so the two read as one row — and round, as the bar is.
38
+ */
39
+ export const FAB_MENU_SIZE = 64;
40
+ const CLOSE_SIZE = 56;
41
+
42
+ /**
43
+ * The capture button, as an Expressive FAB menu.
44
+ *
45
+ * Closed, it is the one tinted circle beside the navigation bar (or, in a
46
+ * collection, in the corner). Open, it shrinks into a smaller close button
47
+ * while the ways in — link or text, file, scan, camera — unfold above it one after
48
+ * another, each a full-width pill with its name on it. Everything else dims;
49
+ * nothing about capturing asks where a thing goes. Only ways in: a list is
50
+ * written, not captured, and is started from Collections or a collection's menu.
51
+ */
52
+ export function LegacyFabMenu({ bottom, label, actions }: FabMenuProps) {
53
+ const labels = useLabels();
54
+ const { selectionHaptic, impactHaptic } = useHaptics();
55
+ const colors = useThemeColors();
56
+ const ripple = useRipple();
57
+ const reducedMotion = useReducedMotion();
58
+ const [open, setOpen] = React.useState(false);
59
+ const progress = useSharedValue(0);
60
+
61
+ React.useEffect(() => {
62
+ // The calmer spatial spring: the button settles with a hint of give
63
+ // rather than wobbling through its size and rotation.
64
+ progress.value = withSpring(open ? 1 : 0, spatial.default);
65
+ if (!open) return;
66
+ const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
67
+ setOpen(false);
68
+ return true;
69
+ });
70
+ return () => subscription.remove();
71
+ }, [open, progress]);
72
+
73
+ const fabStyle = useAnimatedStyle(() => {
74
+ const size = FAB_MENU_SIZE + (CLOSE_SIZE - FAB_MENU_SIZE) * progress.value;
75
+ return {
76
+ width: size,
77
+ height: size,
78
+ // Round, like the pill beside it, at every size it passes through.
79
+ borderRadius: size / 2,
80
+ backgroundColor: progress.value > 0.5 ? colors.primary : colors.primaryContainer,
81
+ };
82
+ });
83
+ const iconStyle = useAnimatedStyle(() => ({
84
+ // An eighth of a turn: the plus becomes a cross without spinning.
85
+ transform: [{ rotate: `${progress.value * 45}deg` }],
86
+ }));
87
+
88
+ const choose = (run: () => void) => {
89
+ selectionHaptic();
90
+ setOpen(false);
91
+ run();
92
+ };
93
+
94
+ return (
95
+ <>
96
+ {open ? (
97
+ <Animated.View
98
+ entering={FadeIn.duration(150)}
99
+ exiting={FadeOut.duration(120)}
100
+ className="absolute inset-0 bg-black/40"
101
+ >
102
+ <Pressable
103
+ className="flex-1"
104
+ onPress={() => setOpen(false)}
105
+ accessibilityLabel={labels.close}
106
+ />
107
+ </Animated.View>
108
+ ) : null}
109
+
110
+ <View
111
+ pointerEvents="box-none"
112
+ className="absolute right-4 items-end gap-3"
113
+ style={{ bottom }}
114
+ >
115
+ {open
116
+ ? actions.map((action, index) => {
117
+ const Icon = action.icon;
118
+ // Nearest the button first, so the menu unfolds upwards.
119
+ const order = actions.length - 1 - index;
120
+ return (
121
+ <Animated.View
122
+ key={action.id}
123
+ entering={FadeInDown.springify()
124
+ .stiffness(spatial.default.stiffness)
125
+ .damping(spatial.default.damping)
126
+ .delay(order * staggerFor(reducedMotion))}
127
+ exiting={FadeOutDown.duration(100)}
128
+ className="overflow-hidden rounded-full"
129
+ style={{ elevation: elevation.level2 }}
130
+ >
131
+ <Pressable
132
+ disabled={action.disabled}
133
+ style={{ opacity: action.disabled ? 0.4 : 1 }}
134
+ onPress={() => choose(action.run)}
135
+ android_ripple={{ color: ripple.onTonal }}
136
+ accessibilityRole="button"
137
+ className="h-14 flex-row items-center gap-3 rounded-full bg-primaryContainer pl-5 pr-6"
138
+ >
139
+ <Icon
140
+ size={24}
141
+ color={action.destructive ? colors.error : colors.onPrimaryContainer}
142
+ />
143
+ <Text
144
+ className="text-base font-semibold text-onPrimaryContainer"
145
+ style={action.destructive ? { color: colors.error } : undefined}
146
+ >
147
+ {action.label}
148
+ </Text>
149
+ </Pressable>
150
+ </Animated.View>
151
+ );
152
+ })
153
+ : null}
154
+
155
+ <Animated.View
156
+ style={[fabStyle, { elevation: elevation.level3 }]}
157
+ className="overflow-hidden"
158
+ >
159
+ <Pressable
160
+ onPress={() => {
161
+ impactHaptic('light');
162
+ setOpen((value) => !value);
163
+ }}
164
+ android_ripple={{ color: ripple.onTonal }}
165
+ accessibilityRole="button"
166
+ accessibilityLabel={open ? labels.close : label}
167
+ accessibilityState={{ expanded: open }}
168
+ className="flex-1 items-center justify-center"
169
+ >
170
+ <Animated.View style={iconStyle}>
171
+ <Plus
172
+ size={open ? 24 : 28}
173
+ color={open ? colors.onPrimary : colors.onPrimaryContainer}
174
+ />
175
+ </Animated.View>
176
+ </Pressable>
177
+ </Animated.View>
178
+ </View>
179
+ </>
180
+ );
181
+ }
@@ -0,0 +1,90 @@
1
+ import * as React from 'react';
2
+ import { Platform, View } from 'react-native';
3
+ import { LegacyFabMenu } from './fab-menu-legacy.tsx';
4
+ import { Plus, type IconProps } from './icons.tsx';
5
+ import { FloatingActionButton } from './floating-action-button.tsx';
6
+ import { Popover } from './popover.tsx';
7
+ import { ContextMenuRows } from './context-menu.tsx';
8
+ import {
9
+ useHaptics,
10
+ useLabels,
11
+ useIsolinePlatform,
12
+ type MaterialOptions,
13
+ } from '../provider.tsx';
14
+ export interface FabMenuAction {
15
+ id: string;
16
+ label: string;
17
+ icon: React.ComponentType<IconProps>;
18
+ run: () => void;
19
+ disabled?: boolean;
20
+ destructive?: boolean;
21
+ }
22
+ export interface FabMenuProps extends MaterialOptions {
23
+ /** Material of the anchored menu, independent of the floating trigger. */
24
+ menuMaterial?: MaterialOptions['material'];
25
+ bottom: number;
26
+ label: string;
27
+ actions: FabMenuAction[];
28
+ }
29
+ export const FAB_MENU_SIZE = 64;
30
+ /** Floating primary action with a safe, anchored overflow surface on native and web. */
31
+ export function FabMenu({
32
+ bottom,
33
+ label,
34
+ actions,
35
+ material = 'solid',
36
+ menuMaterial = 'auto',
37
+ glassStyle,
38
+ tintColor,
39
+ fallbackColor,
40
+ interactive = true,
41
+ }: FabMenuProps) {
42
+ const [open, setOpen] = React.useState(false);
43
+ const anchor = React.useRef<React.ComponentRef<typeof View>>(null);
44
+ const labels = useLabels();
45
+ const { impactHaptic } = useHaptics();
46
+ const ios = useIsolinePlatform() === 'ios';
47
+ if (Platform.OS !== 'web' && (!ios || material === 'solid'))
48
+ return <LegacyFabMenu bottom={bottom} label={label} actions={actions} />;
49
+ return (
50
+ <>
51
+ <View
52
+ ref={anchor}
53
+ collapsable={false}
54
+ style={{ position: 'absolute', right: 16, bottom }}
55
+ >
56
+ <FloatingActionButton
57
+ icon={Plus}
58
+ accessibilityLabel={open ? labels.close : label}
59
+ size={FAB_MENU_SIZE}
60
+ material={material}
61
+ glassStyle={glassStyle}
62
+ tintColor={tintColor}
63
+ fallbackColor={fallbackColor}
64
+ interactive={interactive}
65
+ onPress={() => {
66
+ impactHaptic('light');
67
+ setOpen((value) => !value);
68
+ }}
69
+ />
70
+ </View>
71
+ <Popover
72
+ visible={open}
73
+ onClose={() => setOpen(false)}
74
+ anchorRef={anchor}
75
+ placement="top"
76
+ accessibilityLabel={label}
77
+ material={menuMaterial}
78
+ glassStyle={glassStyle}
79
+ tintColor={tintColor}
80
+ fallbackColor={fallbackColor}
81
+ interactive={interactive}
82
+ >
83
+ <ContextMenuRows
84
+ items={actions.map(({ run, ...action }) => ({ ...action, onPress: run }))}
85
+ onClose={() => setOpen(false)}
86
+ />
87
+ </Popover>
88
+ </>
89
+ );
90
+ }
@@ -0,0 +1,136 @@
1
+ import * as React from 'react';
2
+ import { Platform, View } from 'react-native';
3
+ import Animated from 'react-native-reanimated';
4
+
5
+ import { type IconProps } from './icons.tsx';
6
+ import { Pressable } from './pressable.tsx';
7
+ import { useSquish } from './squish.ts';
8
+ import { Text } from './text.tsx';
9
+ import { corner } from '../shape.ts';
10
+ import { useIsolinePlatform, useRipple, useThemeColors } from '../provider.tsx';
11
+ import { cn } from '../utils.ts';
12
+
13
+ /**
14
+ * Material 3 filter chip — the app's one selectable pill.
15
+ *
16
+ * Unselected it reads as an offer, selected it fills with the primary
17
+ * container. Both states are drawn from the same set of style properties;
18
+ * only the colour values change. That is load-bearing on Android, and it is
19
+ * why an unselected chip paints an opaque fill in its own surface's tone
20
+ * rather than simply leaving the background unset:
21
+ *
22
+ * React Native builds a view's background out of layers — fill, border,
23
+ * ripple — held in a single drawable. Adding a *layer* that wasn't there
24
+ * before (a fill appearing on select, a border appearing on deselect)
25
+ * replaces that drawable with a freshly constructed one, and a fresh drawable
26
+ * starts with empty bounds. A view with `overflow: hidden` clips its children
27
+ * to those bounds, so for as long as they stay empty the chip paints its fill
28
+ * and nothing else: the icon and the label are laid out at full size but
29
+ * never drawn. That is the bug this shape avoids — and `overflow: hidden` is
30
+ * not optional here, since a contained ripple has to be clipped to the pill
31
+ * (see `pressable.tsx`).
32
+ *
33
+ * So: a fill and a border in *both* states, and selection expressed as
34
+ * colour. `surface` is what an unselected chip fills with, which has to match
35
+ * whatever the chip is sitting on for "off" to read as unfilled.
36
+ *
37
+ * The same reasoning rules out swapping the leading icon for a checkmark on
38
+ * select: everything about the chip's structure stays put across a toggle,
39
+ * and only colour moves. Selection stays legible without colour from the
40
+ * filled silhouette, the same way it does in `button-group.tsx` — the one
41
+ * selectable control in the app that never lost its label, and the shape this
42
+ * chip is now built to match.
43
+ *
44
+ * The radius is a real dp value rather than `rounded-full`: Tailwind's full
45
+ * radius is 9999, and `button-group.tsx` likewise morphs between numbers from
46
+ * the corner scale.
47
+ */
48
+
49
+ /** The tones a chip can sit on, as the fill an unselected chip paints. */
50
+ const SURFACE_FILL = {
51
+ background: 'bg-background',
52
+ surfaceContainerHigh: 'bg-surfaceContainerHigh',
53
+ /** Bottom sheets and dialogs. */
54
+ popover: 'bg-popover',
55
+ } as const;
56
+
57
+ export interface FilterChipProps {
58
+ label: string;
59
+ selected: boolean;
60
+ onPress: () => void;
61
+ /**
62
+ * A second, deliberate action on the same chip — removing a saved search,
63
+ * say. Optional: a chip that only filters has nothing to hide behind a hold.
64
+ */
65
+ onLongPress?: () => void;
66
+ /** Optional leading icon. Drawn in both states, or in neither. */
67
+ icon?: React.ComponentType<IconProps>;
68
+ /**
69
+ * Optional trailing icon — a chevron on a chip that opens a choice rather
70
+ * than toggling. Drawn in both states, like `icon`.
71
+ */
72
+ trailingIcon?: React.ComponentType<IconProps>;
73
+ /**
74
+ * The tone the chip is drawn on. An unselected chip fills with it rather
75
+ * than staying transparent, so its fill is a colour change away from the
76
+ * selected one rather than a new drawable layer.
77
+ */
78
+ surface?: keyof typeof SURFACE_FILL;
79
+ }
80
+
81
+ export function FilterChip({
82
+ label,
83
+ selected,
84
+ onPress,
85
+ onLongPress,
86
+ icon: Icon,
87
+ trailingIcon: Trailing,
88
+ surface = 'background',
89
+ }: FilterChipProps) {
90
+ const ios = useIsolinePlatform() === 'ios';
91
+ const web = Platform.OS === 'web';
92
+ const Container = (web ? View : Animated.View) as typeof Animated.View;
93
+ const colors = useThemeColors();
94
+ const ripple = useRipple();
95
+
96
+ const foreground = selected ? colors.onPrimaryContainer : colors.onSurfaceVariant;
97
+ const squish = useSquish();
98
+
99
+ // Fill and clip on a plain wrapper, with the same radius on the Pressable
100
+ // inside it, so the contained ripple is clipped to the pill rather than to
101
+ // its bounding box.
102
+ return (
103
+ <Container
104
+ className="shrink-0 overflow-hidden"
105
+ style={[{ borderRadius: ios ? 10 : corner.large }, !web && squish.style]}
106
+ >
107
+ <Pressable
108
+ {...squish.handlers}
109
+ onPress={onPress}
110
+ onLongPress={onLongPress}
111
+ // A 32dp chip is under the 48dp touch target on its own.
112
+ hitSlop={{ top: 8, bottom: 8 }}
113
+ android_ripple={{ color: selected ? ripple.onTonal : ripple.onNeutral }}
114
+ accessibilityRole="button"
115
+ accessibilityState={{ selected }}
116
+ className={cn(
117
+ 'min-h-8 flex-row items-center gap-1.5 border px-3 py-1',
118
+ selected
119
+ ? 'border-primaryContainer bg-primaryContainer'
120
+ : cn('border-outline', SURFACE_FILL[surface]),
121
+ )}
122
+ style={{ borderRadius: ios ? 10 : corner.large }}
123
+ >
124
+ {Icon ? <Icon size={16} color={foreground} /> : null}
125
+
126
+ {/* Coloured inline from the same value the icon uses, rather than
127
+ through a class, so the two cannot diverge and the label keeps a
128
+ colour even if the class does not resolve. */}
129
+ <Text variant="labelLarge" numberOfLines={1} style={{ color: foreground }}>
130
+ {label}
131
+ </Text>
132
+ {Trailing ? <Trailing size={16} color={foreground} /> : null}
133
+ </Pressable>
134
+ </Container>
135
+ );
136
+ }
@@ -0,0 +1,79 @@
1
+ import * as React from 'react';
2
+ import { View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import {
4
+ Surface,
5
+ useIsolinePlatform,
6
+ useThemeColors,
7
+ useRipple,
8
+ type MaterialOptions,
9
+ } from '../provider.tsx';
10
+ import { Pressable } from './pressable.tsx';
11
+ import { Text } from './text.tsx';
12
+ import type { IconProps } from './icons.tsx';
13
+ export interface FloatingActionButtonProps extends MaterialOptions {
14
+ icon: React.ComponentType<IconProps>;
15
+ accessibilityLabel: string;
16
+ onPress: () => void;
17
+ label?: string;
18
+ disabled?: boolean;
19
+ size?: 56 | 64;
20
+ style?: StyleProp<ViewStyle>;
21
+ }
22
+ /** A reusable floating action; screen placement and navigation remain consumer-owned. */
23
+ export function FloatingActionButton({
24
+ icon: Icon,
25
+ accessibilityLabel,
26
+ onPress,
27
+ label,
28
+ disabled,
29
+ size = 64,
30
+ style,
31
+ material = 'solid',
32
+ glassStyle,
33
+ tintColor,
34
+ fallbackColor,
35
+ interactive = true,
36
+ }: FloatingActionButtonProps) {
37
+ const colors = useThemeColors();
38
+ const ripple = useRipple();
39
+ const glass = useIsolinePlatform() === 'ios' && material !== 'solid';
40
+ const Container = glass ? Surface : View;
41
+ const color = glass ? colors.primary : colors.onPrimaryContainer;
42
+ return (
43
+ <Container
44
+ {...(glass ? { material, glassStyle, tintColor, fallbackColor, interactive } : {})}
45
+ style={[
46
+ {
47
+ borderRadius: size / 2,
48
+ ...(!glass ? { backgroundColor: colors.primaryContainer } : {}),
49
+ minWidth: size,
50
+ height: size,
51
+ },
52
+ style,
53
+ ]}
54
+ >
55
+ <Pressable
56
+ disabled={disabled}
57
+ accessibilityRole="button"
58
+ accessibilityLabel={accessibilityLabel}
59
+ accessibilityState={{ disabled }}
60
+ onPress={onPress}
61
+ android_ripple={{ color: ripple.onTonal }}
62
+ style={{
63
+ minWidth: size,
64
+ height: size,
65
+ paddingHorizontal: label ? 20 : 0,
66
+ flexDirection: 'row',
67
+ alignItems: 'center',
68
+ justifyContent: 'center',
69
+ gap: 10,
70
+ borderRadius: size / 2,
71
+ opacity: disabled ? 0.4 : 1,
72
+ }}
73
+ >
74
+ <Icon size={26} color={color} />
75
+ {label ? <Text style={{ color, fontWeight: '600' }}>{label}</Text> : null}
76
+ </Pressable>
77
+ </Container>
78
+ );
79
+ }