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