@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,277 @@
1
+ import * as React from 'react';
2
+ import { ScrollView, View } from 'react-native';
3
+ import { BottomSheet } from './bottom-sheet.tsx';
4
+ import { Text } from './text.tsx';
5
+ import { Button } from './button.tsx';
6
+ import { IconButton } from './icon-button.tsx';
7
+ import { X } from './icons.tsx';
8
+ import { Pressable } from './pressable.tsx';
9
+ import { useThemeColors, useHaptics, type MaterialOptions } from '../provider.tsx';
10
+ import { useRadioNavigation } from './radio-navigation.ts';
11
+ import { validTime } from '../calendar.ts';
12
+
13
+ export interface TimeOption {
14
+ value: string;
15
+ label: string;
16
+ disabled?: boolean;
17
+ }
18
+ export interface TimeChipsProps {
19
+ value: string | null;
20
+ onChange: (time: string) => void;
21
+ options: TimeOption[];
22
+ disabled?: boolean;
23
+ }
24
+ export function TimeChips({ value, onChange, options, disabled }: TimeChipsProps) {
25
+ return (
26
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
27
+ {options.map((option) => (
28
+ <Button
29
+ key={option.value}
30
+ label={option.label}
31
+ size="s"
32
+ variant={value === option.value ? 'default' : 'tonal'}
33
+ accessibilityState={{
34
+ selected: value === option.value,
35
+ disabled: disabled || option.disabled,
36
+ }}
37
+ disabled={disabled || option.disabled || !validTime(option.value)}
38
+ onPress={() => onChange(option.value)}
39
+ />
40
+ ))}
41
+ </View>
42
+ );
43
+ }
44
+ export interface TimeGridProps {
45
+ value: string | null;
46
+ onChange: (time: string) => void;
47
+ hourLabel: string;
48
+ minuteLabel: string;
49
+ minuteStep?: number;
50
+ disabled?: boolean;
51
+ }
52
+ function TimeRadioRow({
53
+ label,
54
+ values,
55
+ selected,
56
+ choose,
57
+ disabled,
58
+ }: {
59
+ label: string;
60
+ values: number[];
61
+ selected: number | null;
62
+ choose: (value: number) => void;
63
+ disabled?: boolean;
64
+ }) {
65
+ const colors = useThemeColors();
66
+ const { selectionHaptic } = useHaptics();
67
+ const pick = (number: number) => {
68
+ if (number !== selected) selectionHaptic();
69
+ choose(number);
70
+ };
71
+ const navigation = useRadioNavigation(
72
+ values.map((value) => ({ id: String(value), disabled })),
73
+ selected === null ? null : String(selected),
74
+ (id) => pick(Number(id)),
75
+ );
76
+ return (
77
+ <View accessibilityRole="radiogroup" accessibilityLabel={label} style={{ gap: 4 }}>
78
+ <Text variant="caption">{label}</Text>
79
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 4 }}>
80
+ {values.map((number) => (
81
+ <Pressable
82
+ key={number}
83
+ {...navigation(String(number))}
84
+ accessibilityRole="radio"
85
+ accessibilityLabel={`${label} ${String(number).padStart(2, '0')}`}
86
+ accessibilityState={{
87
+ checked: number === selected,
88
+ disabled,
89
+ }}
90
+ disabled={disabled}
91
+ onPress={() => pick(number)}
92
+ style={{
93
+ minWidth: 44,
94
+ minHeight: 44,
95
+ borderRadius: 22,
96
+ alignItems: 'center',
97
+ justifyContent: 'center',
98
+ backgroundColor:
99
+ selected === number ? colors.primary : colors.surfaceContainerHigh,
100
+ opacity: disabled ? 0.4 : 1,
101
+ }}
102
+ >
103
+ <Text style={{ color: selected === number ? colors.onPrimary : colors.onSurface }}>
104
+ {String(number).padStart(2, '0')}
105
+ </Text>
106
+ </Pressable>
107
+ ))}
108
+ </View>
109
+ </View>
110
+ );
111
+ }
112
+ export function TimeGrid({
113
+ value,
114
+ onChange,
115
+ hourLabel,
116
+ minuteLabel,
117
+ minuteStep = 15,
118
+ disabled,
119
+ }: TimeGridProps) {
120
+ const [hour, minute] = validTime(value ?? '') ? value!.split(':').map(Number) : [9, 0];
121
+ const step =
122
+ Number.isInteger(minuteStep) && minuteStep > 0 && minuteStep <= 60 ? minuteStep : 15;
123
+ const format = (h: number, m: number) =>
124
+ `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
125
+ const minutes = Array.from(
126
+ new Set([...Array.from({ length: Math.ceil(60 / step) }, (_, i) => i * step), minute]),
127
+ ).sort((a, b) => a - b);
128
+ return (
129
+ <View style={{ gap: 12 }}>
130
+ <TimeRadioRow
131
+ label={hourLabel}
132
+ values={Array.from({ length: 24 }, (_, i) => i)}
133
+ selected={validTime(value ?? '') ? hour : null}
134
+ choose={(h) => onChange(format(h, minute))}
135
+ disabled={disabled}
136
+ />
137
+ <TimeRadioRow
138
+ label={minuteLabel}
139
+ values={minutes}
140
+ selected={validTime(value ?? '') ? minute : null}
141
+ choose={(m) => onChange(format(hour, m))}
142
+ disabled={disabled}
143
+ />
144
+ </View>
145
+ );
146
+ }
147
+ export interface TimePickerSheetProps extends TimeGridProps, MaterialOptions {
148
+ visible: boolean;
149
+ onClose: () => void;
150
+ title: string;
151
+ confirmLabel: string;
152
+ clearLabel?: string;
153
+ onClear?: () => void;
154
+ presets?: TimeOption[];
155
+ onConfirm?: (time: string) => void;
156
+ }
157
+ export function TimePickerSheet({
158
+ visible,
159
+ onClose,
160
+ title,
161
+ confirmLabel,
162
+ clearLabel,
163
+ onClear,
164
+ presets = [],
165
+ onConfirm,
166
+ material = 'solid',
167
+ glassStyle,
168
+ tintColor,
169
+ fallbackColor,
170
+ interactive,
171
+ ...grid
172
+ }: TimePickerSheetProps) {
173
+ return (
174
+ <BottomSheet
175
+ visible={visible}
176
+ onClose={onClose}
177
+ material={material}
178
+ glassStyle={glassStyle}
179
+ tintColor={tintColor}
180
+ fallbackColor={fallbackColor}
181
+ interactive={interactive}
182
+ >
183
+ <ScrollView
184
+ keyboardShouldPersistTaps="handled"
185
+ style={{ flexGrow: 0 }}
186
+ contentContainerStyle={{ gap: 12 }}
187
+ >
188
+ <Text variant="heading">{title}</Text>
189
+ <TimeChips
190
+ value={grid.value}
191
+ onChange={grid.onChange}
192
+ options={presets}
193
+ disabled={grid.disabled}
194
+ />
195
+ <TimeGrid {...grid} />
196
+ <View style={{ flexDirection: 'row', gap: 8 }}>
197
+ {onClear && clearLabel ? (
198
+ <Button
199
+ label={clearLabel}
200
+ disabled={grid.disabled}
201
+ size="s"
202
+ variant="ghost"
203
+ onPress={() => {
204
+ onClear();
205
+ onClose();
206
+ }}
207
+ />
208
+ ) : null}
209
+ <Button
210
+ label={confirmLabel}
211
+ size="s"
212
+ disabled={grid.disabled || !validTime(grid.value ?? '')}
213
+ onPress={() => {
214
+ if (grid.value && validTime(grid.value)) {
215
+ onConfirm?.(grid.value);
216
+ onClose();
217
+ }
218
+ }}
219
+ />
220
+ </View>
221
+ </ScrollView>
222
+ </BottomSheet>
223
+ );
224
+ }
225
+ export interface TimeFieldProps extends Omit<
226
+ TimePickerSheetProps,
227
+ 'visible' | 'onClose' | 'title' | 'onChange' | 'onClear'
228
+ > {
229
+ label: string;
230
+ emptyLabel: string;
231
+ onChange: (time: string | null) => void;
232
+ }
233
+ export function TimeField({ label, emptyLabel, onChange, ...props }: TimeFieldProps) {
234
+ const [open, setOpen] = React.useState(false);
235
+ const [draft, setDraft] = React.useState(props.value);
236
+ React.useEffect(() => {
237
+ if (props.disabled) setOpen(false);
238
+ }, [props.disabled]);
239
+ return (
240
+ <View style={{ gap: 4 }}>
241
+ <Text variant="caption">{label}</Text>
242
+ <View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
243
+ <View style={{ flex: 1 }}>
244
+ <Button
245
+ label={props.value ?? emptyLabel}
246
+ accessibilityLabel={label}
247
+ variant="tonal"
248
+ size="s"
249
+ disabled={props.disabled}
250
+ onPress={() => {
251
+ setDraft(props.value);
252
+ setOpen(true);
253
+ }}
254
+ />
255
+ </View>
256
+ {props.value && props.clearLabel ? (
257
+ <IconButton
258
+ icon={X}
259
+ accessibilityLabel={props.clearLabel}
260
+ disabled={props.disabled}
261
+ onPress={() => onChange(null)}
262
+ />
263
+ ) : null}
264
+ </View>
265
+ <TimePickerSheet
266
+ {...props}
267
+ value={draft}
268
+ title={label}
269
+ visible={open}
270
+ onClose={() => setOpen(false)}
271
+ onChange={setDraft}
272
+ onConfirm={onChange}
273
+ onClear={() => onChange(null)}
274
+ />
275
+ </View>
276
+ );
277
+ }
@@ -0,0 +1,406 @@
1
+ import * as React from 'react';
2
+ import {
3
+ View,
4
+ type ViewProps,
5
+ type ViewStyle,
6
+ type NativeScrollEvent,
7
+ type NativeSyntheticEvent,
8
+ } from 'react-native';
9
+ import Animated, {
10
+ type AnimatedStyle,
11
+ useAnimatedStyle,
12
+ useSharedValue,
13
+ withSpring,
14
+ } from 'react-native-reanimated';
15
+
16
+ import type { IconProps } from './icons.tsx';
17
+ import { Pressable } from './pressable.tsx';
18
+ import { Text } from './text.tsx';
19
+ import { Platform } from 'react-native';
20
+ import { Surface, type MaterialOptions, useIsolinePlatform, useHaptics } from '../provider.tsx';
21
+ import { useReducedMotion } from '../reduced-motion.ts';
22
+ import { spatial } from '../motion.ts';
23
+ import { elevation, useRipple, useThemeColors } from '../provider.tsx';
24
+ import { cn } from '../utils.ts';
25
+
26
+ /**
27
+ * Material 3 Expressive floating toolbar.
28
+ *
29
+ * Expressive replaced the bottom app bar — a full-width slab pinned to the
30
+ * edge of the screen — with a pill that floats above the content. It costs
31
+ * less width than it looks like it does, it can sit beside a FAB, and because
32
+ * content scrolls visibly underneath it, it reads as a layer over the screen
33
+ * rather than a border around it.
34
+ *
35
+ * A toolbar can also carry the screen's primary action itself, as a filled
36
+ * item among the plain ones (`prominent`). That is the Expressive alternative
37
+ * to a separate FAB: one control in one place, with the important action
38
+ * wearing the container colour and a label, and its neighbours reduced to
39
+ * icons. It is worth it when the primary action has obvious company — a
40
+ * screen with nothing to sit beside it is still a FAB's job.
41
+ *
42
+ * Each item springs down under the finger; there is no ripple-only press state
43
+ * here, because at this size a ripple alone is easy to miss.
44
+ */
45
+
46
+ /** Height of the pill, and of the reach target it has to keep. */
47
+ const TOOLBAR_HEIGHT = 64;
48
+
49
+ /**
50
+ * For a toolbar that has to share the screen with the keyboard instead of
51
+ * owning the bottom of it: the pill loses its padding and the buttons draw
52
+ * smaller, with the reach they give up handed back as `hitSlop` so the
53
+ * target stays 48dp even though only 40dp of it is painted.
54
+ */
55
+ const COMPACT_TOOLBAR_HEIGHT = 40;
56
+ /** The smallest target a control is allowed to have, painted or not. */
57
+ const MINIMUM_TARGET = 48;
58
+ const COMPACT_HIT_SLOP = (MINIMUM_TARGET - COMPACT_TOOLBAR_HEIGHT) / 2;
59
+
60
+ export interface ToolbarItem {
61
+ id: string;
62
+ disabled?: boolean;
63
+ icon: React.ComponentType<IconProps>;
64
+ label: string;
65
+ onPress: () => void;
66
+ /** Overrides the icon colour, for a state like "starred". */
67
+ color?: string;
68
+ /** Filled icons read as "on" for toggles. */
69
+ fill?: string;
70
+ /**
71
+ * Marks the item as the one currently in charge — a tool whose own panel is
72
+ * open, rather than a value that is set. It takes the container tone and
73
+ * squares its corners off, which is how M3 separates a selected toggle from
74
+ * its neighbours without adding a second row of chrome.
75
+ */
76
+ active?: boolean;
77
+ /**
78
+ * Colours the rule the icon already draws — the bar under the A, under the
79
+ * pen — with the colour the item would apply. Only symbols that carry one
80
+ * have anything to colour.
81
+ */
82
+ rule?: string;
83
+ /**
84
+ * Renders the item as the toolbar's primary action: a filled pill that
85
+ * shows its label instead of relying on the icon alone. At most one.
86
+ */
87
+ prominent?: boolean;
88
+ /** A count to overlay on the icon. Zero and below draw nothing. */
89
+ badge?: number;
90
+ }
91
+
92
+ export interface ToolbarProps extends MaterialOptions {
93
+ items: ToolbarItem[];
94
+ children?: React.ReactNode;
95
+ material?: 'auto' | 'glass' | 'solid';
96
+ className?: string;
97
+ /** Motion from `useToolbarAutoHide`, when the toolbar scrolls away. */
98
+ style?: React.ComponentProps<typeof Animated.View>['style'];
99
+ /** Slims the pill down around the same targets — see `COMPACT_TOOLBAR_HEIGHT`. */
100
+ compact?: boolean;
101
+ }
102
+
103
+ export function Toolbar(props: ToolbarProps | ToolbarContainerProps) {
104
+ const colors = useThemeColors();
105
+ const ios = useIsolinePlatform() === 'ios';
106
+ if (!('items' in props)) return <ContainerToolbar {...props} />;
107
+ const {
108
+ items,
109
+ className,
110
+ style,
111
+ compact,
112
+ material = 'auto',
113
+ glassStyle,
114
+ tintColor,
115
+ fallbackColor,
116
+ interactive = false,
117
+ } = props;
118
+ const Root = (Platform.OS === 'web' ? View : Animated.View) as typeof Animated.View;
119
+ return (
120
+ <Root
121
+ className={className}
122
+ style={[
123
+ { height: compact ? COMPACT_TOOLBAR_HEIGHT : TOOLBAR_HEIGHT, flexDirection: 'row' },
124
+ style,
125
+ ]}
126
+ accessibilityRole="toolbar"
127
+ >
128
+ <Surface
129
+ material={ios ? material : 'solid'}
130
+ glassStyle={glassStyle}
131
+ tintColor={tintColor}
132
+ fallbackColor={fallbackColor ?? colors.surfaceContainerHigh}
133
+ interactive={interactive}
134
+ style={{
135
+ height: '100%',
136
+ flexDirection: 'row',
137
+ alignItems: 'center',
138
+ gap: 4,
139
+ paddingHorizontal: compact ? 12 : 8,
140
+ borderRadius: compact ? 20 : 32,
141
+ }}
142
+ >
143
+ {items.map((item) =>
144
+ item.prominent ? (
145
+ <ProminentButton key={item.id} item={item} />
146
+ ) : (
147
+ <ToolbarButton key={item.id} item={item} compact={compact} />
148
+ ),
149
+ )}
150
+ </Surface>
151
+ </Root>
152
+ );
153
+ }
154
+
155
+ /** Shared press physics: the item is pushed down, then springs back. */
156
+ function usePressScale() {
157
+ const scale = useSharedValue(1);
158
+
159
+ const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
160
+
161
+ const handlers = {
162
+ onPressIn: () => {
163
+ scale.value = withSpring(0.86, spatial.fast);
164
+ },
165
+ onPressOut: () => {
166
+ scale.value = withSpring(1, spatial.default);
167
+ },
168
+ };
169
+
170
+ return { style, handlers };
171
+ }
172
+
173
+ function ToolbarButton({ item, compact }: { item: ToolbarItem; compact?: boolean }) {
174
+ const { selectionHaptic } = useHaptics();
175
+ const colors = useThemeColors();
176
+ const ripple = useRipple();
177
+ const { style, handlers } = usePressScale();
178
+ const Box = (Platform.OS === 'web' ? View : Animated.View) as typeof Animated.View;
179
+ const Icon = item.icon;
180
+ const iconColor =
181
+ item.color ?? (item.active ? colors.onSecondaryContainer : colors.onSurfaceVariant);
182
+
183
+ return (
184
+ // An active item is a filled shape, so its ripple has to be clipped to
185
+ // that shape rather than spreading past it the way a borderless one over
186
+ // a bare icon is meant to.
187
+ <Box
188
+ style={Platform.OS === 'web' ? undefined : style}
189
+ className={cn(item.active && 'overflow-hidden rounded-xl')}
190
+ >
191
+ <Pressable
192
+ disabled={item.disabled}
193
+ style={{ opacity: item.disabled ? 0.4 : 1 }}
194
+ {...handlers}
195
+ onPress={() => {
196
+ selectionHaptic();
197
+ item.onPress();
198
+ }}
199
+ android_ripple={
200
+ item.active
201
+ ? { color: ripple.onTonal }
202
+ : { color: ripple.onNeutral, borderless: true, radius: compact ? 20 : 26 }
203
+ }
204
+ hitSlop={compact ? COMPACT_HIT_SLOP : undefined}
205
+ // A compact toolbar sits beside a focused field and acts on it, so
206
+ // its buttons must not take the focus away from what they format.
207
+ // (Input focus only; a screen reader still reaches them.)
208
+ focusable={!compact}
209
+ accessibilityRole="button"
210
+ accessibilityState={{ selected: item.active, disabled: item.disabled }}
211
+ accessibilityLabel={item.label}
212
+ className={cn(
213
+ 'items-center justify-center',
214
+ compact ? 'h-10 w-10' : 'h-12 w-12',
215
+ item.active ? 'rounded-xl bg-secondaryContainer' : 'rounded-full',
216
+ )}
217
+ >
218
+ <Icon
219
+ size={compact ? 20 : 22}
220
+ color={iconColor}
221
+ {...(item.fill ? { fill: item.fill } : null)}
222
+ {...(item.rule ? { ruleColor: item.rule } : null)}
223
+ />
224
+ <Badge count={item.badge} />
225
+ </Pressable>
226
+ </Box>
227
+ );
228
+ }
229
+
230
+ /**
231
+ * The toolbar's primary action.
232
+ *
233
+ * Filled in the container tone the FAB used, so the action that used to sit
234
+ * in the corner keeps its colour and its weight when it moves inline. The
235
+ * label stays visible: this is the one item whose target the user aims for
236
+ * without having to read an icon first.
237
+ */
238
+ function ProminentButton({ item }: { item: ToolbarItem }) {
239
+ const { impactHaptic } = useHaptics();
240
+ const colors = useThemeColors();
241
+ const ripple = useRipple();
242
+ const { style, handlers } = usePressScale();
243
+ const Box = (Platform.OS === 'web' ? View : Animated.View) as typeof Animated.View;
244
+ const Icon = item.icon;
245
+
246
+ return (
247
+ <Box
248
+ style={Platform.OS === 'web' ? undefined : style}
249
+ className="overflow-hidden rounded-full"
250
+ >
251
+ <Pressable
252
+ disabled={item.disabled}
253
+ style={{ opacity: item.disabled ? 0.4 : 1 }}
254
+ {...handlers}
255
+ onPress={() => {
256
+ impactHaptic('light');
257
+ item.onPress();
258
+ }}
259
+ android_ripple={{ color: ripple.onTonal }}
260
+ accessibilityRole="button"
261
+ accessibilityLabel={item.label}
262
+ className="h-12 flex-row items-center gap-2 rounded-full bg-primaryContainer pl-4 pr-5"
263
+ >
264
+ <Icon size={20} color={colors.onPrimaryContainer} />
265
+ <Text className="text-[15px] font-semibold text-onPrimaryContainer">{item.label}</Text>
266
+ </Pressable>
267
+ </Box>
268
+ );
269
+ }
270
+
271
+ function Badge({ count }: { count?: number }) {
272
+ if (!count || count <= 0) return null;
273
+
274
+ return (
275
+ <View className="absolute right-1 top-1.5 min-w-[18px] items-center rounded-full bg-primary px-1">
276
+ <Text className="text-[11px] font-bold tabular-nums text-primary-foreground">
277
+ {count > 99 ? '99+' : count}
278
+ </Text>
279
+ </View>
280
+ );
281
+ }
282
+
283
+ /**
284
+ * Scrolls the toolbar away downwards, and brings it back on the way up.
285
+ *
286
+ * The point of a floating toolbar is that it is a layer over the content
287
+ * rather than a permanent border around it — so it gets out of the way while
288
+ * the user is reading, and returns the moment they reverse, without having to
289
+ * reach the top of the list first.
290
+ *
291
+ * The scroll handler is a plain JS one on purpose: it only writes shared
292
+ * values, so it never re-renders the screen, and it works on `FlashList`
293
+ * without wrapping the list in `Animated.createAnimatedComponent`.
294
+ *
295
+ * @param travel How far down to send it — far enough to clear the screen edge,
296
+ * so a hidden toolbar cannot be tapped.
297
+ */
298
+ export interface ToolbarAutoHide {
299
+ onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
300
+ style: AnimatedStyle<ViewStyle>;
301
+ }
302
+ export function useToolbarAutoHide(travel: number): ToolbarAutoHide {
303
+ const hidden = useSharedValue(0);
304
+ const [webHidden, setWebHidden] = React.useState(false);
305
+ const reducedMotion = useReducedMotion();
306
+ // Where the last decision was made, not the last frame: comparing against
307
+ // the previous frame turns any jitter into a flicker.
308
+ const anchor = useSharedValue(0);
309
+
310
+ const onScroll = React.useCallback(
311
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
312
+ const y = event.nativeEvent.contentOffset.y;
313
+ const delta = y - anchor.value;
314
+
315
+ // A drag has to mean it. Below this, the toolbar holds its state.
316
+ if (Math.abs(delta) < 16) return;
317
+ anchor.value = y;
318
+
319
+ // The first rows are still "the top of the inbox": nothing hides there,
320
+ // however the list is being dragged.
321
+ const away = delta > 0 && y > TOOLBAR_HEIGHT;
322
+ hidden.value = away ? 1 : 0;
323
+ if (Platform.OS === 'web') setWebHidden(away);
324
+ },
325
+ [anchor, hidden],
326
+ );
327
+
328
+ const style = useAnimatedStyle(() => {
329
+ const away = hidden.value;
330
+
331
+ return {
332
+ transform: [{ translateY: withSpring(away * travel, spatial.default) }],
333
+ // Translate the material off-screen; opacity would flatten native glass.
334
+ };
335
+ });
336
+
337
+ return {
338
+ onScroll,
339
+ style:
340
+ Platform.OS === 'web'
341
+ ? ({
342
+ transform: [{ translateY: webHidden ? travel : 0 }],
343
+ transitionProperty: 'transform',
344
+ transitionDuration: reducedMotion ? '0ms' : '180ms',
345
+ } as ViewStyle)
346
+ : style,
347
+ };
348
+ }
349
+
350
+ export interface ToolbarContainerProps extends ViewProps, MaterialOptions {
351
+ /** Floating navigation uses glass on supported iOS when a surface adapter is supplied. */
352
+ material?: 'auto' | 'glass' | 'solid';
353
+ }
354
+ function ContainerToolbar({
355
+ material = 'auto',
356
+ glassStyle,
357
+ tintColor,
358
+ fallbackColor,
359
+ interactive,
360
+ style,
361
+ className,
362
+ ...props
363
+ }: ToolbarContainerProps) {
364
+ const ios = useIsolinePlatform() === 'ios';
365
+ return (
366
+ <Surface
367
+ {...props}
368
+ material={ios ? material : 'solid'}
369
+ glassStyle={glassStyle}
370
+ tintColor={tintColor}
371
+ fallbackColor={fallbackColor}
372
+ interactive={interactive}
373
+ className={cn('flex-row items-center justify-between gap-3', className)}
374
+ style={[
375
+ {
376
+ minHeight: 56,
377
+ paddingHorizontal: 12,
378
+ paddingVertical: 6,
379
+ borderRadius: ios ? 28 : 20,
380
+ },
381
+ style,
382
+ ]}
383
+ />
384
+ );
385
+ }
386
+ export interface NavigationBarProps extends ToolbarContainerProps {
387
+ title: string;
388
+ leading?: React.ReactNode;
389
+ trailing?: React.ReactNode;
390
+ }
391
+ export function NavigationBar({ title, leading, trailing, ...props }: NavigationBarProps) {
392
+ const ios = useIsolinePlatform() === 'ios';
393
+ return (
394
+ <ContainerToolbar {...props}>
395
+ <View style={{ minWidth: 44 }}>{leading}</View>
396
+ <Text
397
+ accessibilityRole="header"
398
+ variant="subject"
399
+ style={{ flex: 1, textAlign: ios ? 'center' : 'left' }}
400
+ >
401
+ {title}
402
+ </Text>
403
+ <View style={{ minWidth: 44, alignItems: 'flex-end' }}>{trailing}</View>
404
+ </ContainerToolbar>
405
+ );
406
+ }