@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.
- 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,77 @@
|
|
|
1
|
+
import { Platform, View, Switch as RNSwitch } from 'react-native';
|
|
2
|
+
import { Pressable } from './pressable.tsx';
|
|
3
|
+
|
|
4
|
+
import { useIsolinePlatform, useThemeColors } from '../provider.tsx';
|
|
5
|
+
|
|
6
|
+
export interface SwitchProps {
|
|
7
|
+
value: boolean;
|
|
8
|
+
onValueChange: (value: boolean) => void;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
accessibilityLabel?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Native switch geometry on each OS, with the app's shared colour roles. */
|
|
14
|
+
export function Switch({ value, onValueChange, disabled, accessibilityLabel }: SwitchProps) {
|
|
15
|
+
const colors = useThemeColors();
|
|
16
|
+
const ios = useIsolinePlatform() === 'ios';
|
|
17
|
+
|
|
18
|
+
if (Platform.OS === 'web')
|
|
19
|
+
return (
|
|
20
|
+
<Pressable
|
|
21
|
+
accessibilityRole="switch"
|
|
22
|
+
accessibilityLabel={accessibilityLabel}
|
|
23
|
+
accessibilityState={{ checked: value, disabled }}
|
|
24
|
+
disabled={disabled}
|
|
25
|
+
onPress={() => onValueChange(!value)}
|
|
26
|
+
style={{
|
|
27
|
+
minHeight: 44,
|
|
28
|
+
minWidth: ios ? 51 : 52,
|
|
29
|
+
justifyContent: 'center',
|
|
30
|
+
opacity: disabled ? 0.4 : 1,
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
<View
|
|
34
|
+
pointerEvents="none"
|
|
35
|
+
style={{
|
|
36
|
+
width: ios ? 51 : 52,
|
|
37
|
+
height: ios ? 31 : 32,
|
|
38
|
+
padding: ios ? 3 : 2,
|
|
39
|
+
borderRadius: 16,
|
|
40
|
+
borderWidth: ios ? 0 : 2,
|
|
41
|
+
borderColor: value ? colors.primary : colors.outline,
|
|
42
|
+
backgroundColor: value ? colors.primary : colors.outlineVariant,
|
|
43
|
+
justifyContent: 'center',
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
<View
|
|
47
|
+
style={{
|
|
48
|
+
width: ios ? 25 : 24,
|
|
49
|
+
height: ios ? 25 : 24,
|
|
50
|
+
borderRadius: 14,
|
|
51
|
+
backgroundColor: ios
|
|
52
|
+
? '#fff'
|
|
53
|
+
: value
|
|
54
|
+
? colors.onPrimary
|
|
55
|
+
: colors.onSurfaceVariant,
|
|
56
|
+
alignSelf: value ? 'flex-end' : 'flex-start',
|
|
57
|
+
}}
|
|
58
|
+
/>
|
|
59
|
+
</View>
|
|
60
|
+
</Pressable>
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<RNSwitch
|
|
65
|
+
value={value}
|
|
66
|
+
onValueChange={onValueChange}
|
|
67
|
+
disabled={disabled}
|
|
68
|
+
trackColor={{
|
|
69
|
+
false: colors.outlineVariant,
|
|
70
|
+
true: ios ? colors.primary : colors.primaryContainer,
|
|
71
|
+
}}
|
|
72
|
+
thumbColor={ios ? undefined : value ? colors.primary : colors.onSurfaceVariant}
|
|
73
|
+
ios_backgroundColor={colors.outlineVariant}
|
|
74
|
+
accessibilityLabel={accessibilityLabel}
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { I18nManager, Platform, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import Animated, {
|
|
4
|
+
useAnimatedStyle,
|
|
5
|
+
useSharedValue,
|
|
6
|
+
withSpring,
|
|
7
|
+
} from 'react-native-reanimated';
|
|
8
|
+
import {
|
|
9
|
+
Surface,
|
|
10
|
+
useIsolinePlatform,
|
|
11
|
+
useThemeColors,
|
|
12
|
+
useHaptics,
|
|
13
|
+
type MaterialOptions,
|
|
14
|
+
} from '../provider.tsx';
|
|
15
|
+
import { spatial } from '../motion.ts';
|
|
16
|
+
import { Pressable } from './pressable.tsx';
|
|
17
|
+
import { Text } from './text.tsx';
|
|
18
|
+
import type { IconProps } from './icons.tsx';
|
|
19
|
+
export interface TabBarItem<T extends string> {
|
|
20
|
+
id: T;
|
|
21
|
+
label: string;
|
|
22
|
+
icon?: React.ComponentType<IconProps>;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
badge?: string | number;
|
|
25
|
+
}
|
|
26
|
+
export interface TabBarProps<T extends string> extends MaterialOptions {
|
|
27
|
+
items: TabBarItem<T>[];
|
|
28
|
+
value: T;
|
|
29
|
+
onChange: (value: T) => void;
|
|
30
|
+
accessibilityLabel?: string;
|
|
31
|
+
style?: StyleProp<ViewStyle>;
|
|
32
|
+
}
|
|
33
|
+
/** Controlled navigation UI: route handling belongs to the consuming app. */
|
|
34
|
+
export function TabBar<T extends string>({
|
|
35
|
+
items,
|
|
36
|
+
value,
|
|
37
|
+
onChange,
|
|
38
|
+
accessibilityLabel,
|
|
39
|
+
style,
|
|
40
|
+
material = 'auto',
|
|
41
|
+
glassStyle,
|
|
42
|
+
tintColor,
|
|
43
|
+
fallbackColor,
|
|
44
|
+
interactive = false,
|
|
45
|
+
}: TabBarProps<T>) {
|
|
46
|
+
const colors = useThemeColors();
|
|
47
|
+
const ios = useIsolinePlatform() === 'ios';
|
|
48
|
+
const { selectionHaptic } = useHaptics();
|
|
49
|
+
const refs = React.useRef<Record<string, React.ComponentRef<typeof Pressable> | null>>({});
|
|
50
|
+
const [width, setWidth] = React.useState(0);
|
|
51
|
+
const index = items.findIndex((item) => item.id === value);
|
|
52
|
+
const focusId =
|
|
53
|
+
items.find((item) => item.id === value && !item.disabled)?.id ??
|
|
54
|
+
items.find((item) => !item.disabled)?.id;
|
|
55
|
+
const visualIndex = I18nManager.isRTL
|
|
56
|
+
? items.length - 1 - Math.max(0, index)
|
|
57
|
+
: Math.max(0, index);
|
|
58
|
+
const position = useSharedValue(visualIndex);
|
|
59
|
+
React.useEffect(() => {
|
|
60
|
+
position.value = withSpring(visualIndex, spatial.default);
|
|
61
|
+
}, [visualIndex, position]);
|
|
62
|
+
const segment = items.length ? Math.max(0, width - 8) / items.length : 0;
|
|
63
|
+
const motion = useAnimatedStyle(() => ({
|
|
64
|
+
transform: [{ translateX: position.value * segment }],
|
|
65
|
+
}));
|
|
66
|
+
const Marker = (Platform.OS === 'web' ? View : Animated.View) as typeof Animated.View;
|
|
67
|
+
return (
|
|
68
|
+
<Surface
|
|
69
|
+
material={ios ? material : 'solid'}
|
|
70
|
+
glassStyle={glassStyle}
|
|
71
|
+
tintColor={tintColor}
|
|
72
|
+
fallbackColor={fallbackColor}
|
|
73
|
+
interactive={interactive}
|
|
74
|
+
accessibilityRole="tablist"
|
|
75
|
+
accessibilityLabel={accessibilityLabel}
|
|
76
|
+
onLayout={(event) => setWidth(event.nativeEvent.layout.width)}
|
|
77
|
+
style={[
|
|
78
|
+
{ padding: 4, borderRadius: ios ? 32 : 24, flexDirection: 'row', minHeight: 64 },
|
|
79
|
+
style,
|
|
80
|
+
]}
|
|
81
|
+
>
|
|
82
|
+
{index >= 0 && width > 0 ? (
|
|
83
|
+
<Marker
|
|
84
|
+
pointerEvents="none"
|
|
85
|
+
style={[
|
|
86
|
+
{
|
|
87
|
+
position: 'absolute',
|
|
88
|
+
left: 4,
|
|
89
|
+
top: 4,
|
|
90
|
+
bottom: 4,
|
|
91
|
+
width: segment,
|
|
92
|
+
borderRadius: ios ? 28 : 20,
|
|
93
|
+
backgroundColor: colors.secondaryContainer,
|
|
94
|
+
},
|
|
95
|
+
Platform.OS === 'web'
|
|
96
|
+
? { transform: [{ translateX: visualIndex * segment }] }
|
|
97
|
+
: motion,
|
|
98
|
+
]}
|
|
99
|
+
/>
|
|
100
|
+
) : null}
|
|
101
|
+
{items.map((item) => {
|
|
102
|
+
const selected = item.id === value;
|
|
103
|
+
const Icon = item.icon;
|
|
104
|
+
return (
|
|
105
|
+
<Pressable
|
|
106
|
+
key={item.id}
|
|
107
|
+
ref={(node) => {
|
|
108
|
+
refs.current[item.id] = node;
|
|
109
|
+
}}
|
|
110
|
+
tabIndex={item.id === focusId ? 0 : -1}
|
|
111
|
+
{...(Platform.OS === 'web'
|
|
112
|
+
? {
|
|
113
|
+
onKeyDown: (
|
|
114
|
+
event: Parameters<
|
|
115
|
+
NonNullable<React.ComponentProps<typeof Pressable>['onKeyDown']>
|
|
116
|
+
>[0],
|
|
117
|
+
) => {
|
|
118
|
+
const key = event.nativeEvent.key;
|
|
119
|
+
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(key)) return;
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
const enabled = items.filter((tab) => !tab.disabled);
|
|
122
|
+
if (!enabled.length) return;
|
|
123
|
+
const current = enabled.findIndex((tab) => tab.id === item.id);
|
|
124
|
+
const delta =
|
|
125
|
+
(key === 'ArrowRight' ? 1 : -1) * (I18nManager.isRTL ? -1 : 1);
|
|
126
|
+
const next =
|
|
127
|
+
key === 'Home'
|
|
128
|
+
? enabled[0]
|
|
129
|
+
: key === 'End'
|
|
130
|
+
? enabled[enabled.length - 1]
|
|
131
|
+
: enabled[(current + delta + enabled.length) % enabled.length];
|
|
132
|
+
onChange(next.id);
|
|
133
|
+
refs.current[next.id]?.focus();
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
: {})}
|
|
137
|
+
disabled={item.disabled}
|
|
138
|
+
accessibilityRole="tab"
|
|
139
|
+
accessibilityLabel={item.label}
|
|
140
|
+
accessibilityState={{ selected, disabled: item.disabled }}
|
|
141
|
+
onPress={() => {
|
|
142
|
+
if (!selected) {
|
|
143
|
+
selectionHaptic();
|
|
144
|
+
onChange(item.id);
|
|
145
|
+
}
|
|
146
|
+
}}
|
|
147
|
+
style={{
|
|
148
|
+
flex: 1,
|
|
149
|
+
minWidth: 0,
|
|
150
|
+
minHeight: 56,
|
|
151
|
+
padding: 6,
|
|
152
|
+
alignItems: 'center',
|
|
153
|
+
justifyContent: 'center',
|
|
154
|
+
gap: 2,
|
|
155
|
+
borderRadius: 28,
|
|
156
|
+
opacity: item.disabled ? 0.4 : 1,
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
{Icon ? (
|
|
160
|
+
<Icon size={22} color={selected ? colors.primary : colors.onSurfaceVariant} />
|
|
161
|
+
) : null}
|
|
162
|
+
<Text
|
|
163
|
+
numberOfLines={1}
|
|
164
|
+
style={{
|
|
165
|
+
fontSize: 12,
|
|
166
|
+
lineHeight: 16,
|
|
167
|
+
fontWeight: selected ? '700' : '500',
|
|
168
|
+
color: selected ? colors.primary : colors.onSurfaceVariant,
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
{item.label}
|
|
172
|
+
</Text>
|
|
173
|
+
{item.badge !== undefined ? (
|
|
174
|
+
<View
|
|
175
|
+
style={{
|
|
176
|
+
position: 'absolute',
|
|
177
|
+
right: 6,
|
|
178
|
+
top: 2,
|
|
179
|
+
paddingHorizontal: 4,
|
|
180
|
+
minWidth: 16,
|
|
181
|
+
borderRadius: 8,
|
|
182
|
+
backgroundColor: colors.error,
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
<Text style={{ color: colors.onError, fontSize: 10, lineHeight: 14 }}>
|
|
186
|
+
{item.badge}
|
|
187
|
+
</Text>
|
|
188
|
+
</View>
|
|
189
|
+
) : null}
|
|
190
|
+
</Pressable>
|
|
191
|
+
);
|
|
192
|
+
})}
|
|
193
|
+
</Surface>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Platform, Text as RNText, TextInput, View } from 'react-native';
|
|
3
|
+
import Animated, {
|
|
4
|
+
interpolate,
|
|
5
|
+
interpolateColor,
|
|
6
|
+
useAnimatedStyle,
|
|
7
|
+
useSharedValue,
|
|
8
|
+
withSpring,
|
|
9
|
+
} from 'react-native-reanimated';
|
|
10
|
+
|
|
11
|
+
import { Text } from './text.tsx';
|
|
12
|
+
import { PasswordVisibility } from './password-visibility.tsx';
|
|
13
|
+
import { effects, spatial } from '../motion.ts';
|
|
14
|
+
import { corner } from '../shape.ts';
|
|
15
|
+
import { useIsolinePlatform, useThemeColors } from '../provider.tsx';
|
|
16
|
+
import { cn } from '../utils.ts';
|
|
17
|
+
|
|
18
|
+
type TextInputInstance = React.ElementRef<typeof TextInput>;
|
|
19
|
+
|
|
20
|
+
/** The single-line field's height, `h-14`. */
|
|
21
|
+
const FIELD_HEIGHT = 56;
|
|
22
|
+
|
|
23
|
+
export type TextFieldProps = React.ComponentProps<typeof TextInput> & {
|
|
24
|
+
label: string;
|
|
25
|
+
/** Shown below the field in the error colour, which the border and label pick up too. */
|
|
26
|
+
error?: string | null;
|
|
27
|
+
/**
|
|
28
|
+
* The `bg-*` class of whatever surface the field sits on. The floated
|
|
29
|
+
* label needs an opaque patch behind it to mask the border line it
|
|
30
|
+
* overlaps, and that patch has to match the surface underneath — it
|
|
31
|
+
* defaults to the app's popover surface because every field so far lives
|
|
32
|
+
* in a dialog or sheet.
|
|
33
|
+
*/
|
|
34
|
+
notchClassName?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Material 3 Expressive outlined text field.
|
|
39
|
+
*
|
|
40
|
+
* The label rests inside the field, where a placeholder would sit, until
|
|
41
|
+
* there is a reason to move it — focus, or a value — at which point it
|
|
42
|
+
* floats up to overlap the border. Painting an opaque patch behind it there
|
|
43
|
+
* is the cheap way to get the M3 "notch": the alternative is clipping the
|
|
44
|
+
* border into two arcs, which this does not need.
|
|
45
|
+
*
|
|
46
|
+
* A placeholder is a hint for the empty field the reader is typing into, so
|
|
47
|
+
* it only shows once the label has floated out of its way; while the label
|
|
48
|
+
* rests inside the field the two would sit on top of each other.
|
|
49
|
+
*/
|
|
50
|
+
export const TextField: React.ForwardRefExoticComponent<
|
|
51
|
+
TextFieldProps & React.RefAttributes<TextInputInstance>
|
|
52
|
+
> = React.forwardRef<TextInputInstance, TextFieldProps>(function TextField(
|
|
53
|
+
{
|
|
54
|
+
label,
|
|
55
|
+
error,
|
|
56
|
+
value,
|
|
57
|
+
placeholder,
|
|
58
|
+
style,
|
|
59
|
+
className,
|
|
60
|
+
notchClassName,
|
|
61
|
+
onFocus,
|
|
62
|
+
onBlur,
|
|
63
|
+
accessibilityLabel,
|
|
64
|
+
secureTextEntry,
|
|
65
|
+
...props
|
|
66
|
+
},
|
|
67
|
+
ref,
|
|
68
|
+
) {
|
|
69
|
+
const ios = useIsolinePlatform() === 'ios';
|
|
70
|
+
const web = Platform.OS === 'web';
|
|
71
|
+
const Box = (web ? View : Animated.View) as typeof Animated.View;
|
|
72
|
+
const Label = (web ? RNText : Animated.Text) as typeof Animated.Text;
|
|
73
|
+
const colors = useThemeColors();
|
|
74
|
+
const [revealed, setRevealed] = React.useState(false);
|
|
75
|
+
const [focused, setFocused] = React.useState(false);
|
|
76
|
+
const floated = focused || Boolean(value);
|
|
77
|
+
|
|
78
|
+
const float = useSharedValue(floated ? 1 : 0);
|
|
79
|
+
const focus = useSharedValue(focused ? 1 : 0);
|
|
80
|
+
|
|
81
|
+
React.useEffect(() => {
|
|
82
|
+
float.value = withSpring(floated ? 1 : 0, spatial.default);
|
|
83
|
+
}, [floated, float]);
|
|
84
|
+
|
|
85
|
+
React.useEffect(() => {
|
|
86
|
+
focus.value = withSpring(focused ? 1 : 0, effects.default);
|
|
87
|
+
}, [focused, focus]);
|
|
88
|
+
|
|
89
|
+
const handleFocus = React.useCallback<NonNullable<TextFieldProps['onFocus']>>(
|
|
90
|
+
(event) => {
|
|
91
|
+
setFocused(true);
|
|
92
|
+
onFocus?.(event);
|
|
93
|
+
},
|
|
94
|
+
[onFocus],
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const handleBlur = React.useCallback<NonNullable<TextFieldProps['onBlur']>>(
|
|
98
|
+
(event) => {
|
|
99
|
+
setFocused(false);
|
|
100
|
+
onBlur?.(event);
|
|
101
|
+
},
|
|
102
|
+
[onBlur],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const borderStyle = useAnimatedStyle(() => ({
|
|
106
|
+
borderColor: error
|
|
107
|
+
? colors.error
|
|
108
|
+
: interpolateColor(focus.value, [0, 1], [colors.outline, colors.primary]),
|
|
109
|
+
borderWidth: interpolate(focus.value, [0, 1], [1, 2]),
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
const labelWrapStyle = useAnimatedStyle(() => ({
|
|
113
|
+
transform: [
|
|
114
|
+
{ translateY: interpolate(float.value, [0, 1], [0, -FIELD_HEIGHT / 2]) },
|
|
115
|
+
{ scale: interpolate(float.value, [0, 1], [1, 0.75]) },
|
|
116
|
+
],
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
const labelTextStyle = useAnimatedStyle(() => ({
|
|
120
|
+
color: error
|
|
121
|
+
? colors.error
|
|
122
|
+
: interpolateColor(focus.value, [0, 1], [colors.onSurfaceVariant, colors.primary]),
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
if (ios)
|
|
126
|
+
return (
|
|
127
|
+
<View className="gap-1.5">
|
|
128
|
+
<Text variant="label" style={{ color: error ? colors.error : colors.onSurfaceVariant }}>
|
|
129
|
+
{label}
|
|
130
|
+
</Text>
|
|
131
|
+
<View style={{ position: 'relative' }}>
|
|
132
|
+
<TextInput
|
|
133
|
+
ref={ref}
|
|
134
|
+
value={value}
|
|
135
|
+
placeholder={placeholder}
|
|
136
|
+
placeholderTextColor={colors.onSurfaceVariant}
|
|
137
|
+
onFocus={handleFocus}
|
|
138
|
+
onBlur={handleBlur}
|
|
139
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
140
|
+
className={cn('text-foreground', className)}
|
|
141
|
+
style={[
|
|
142
|
+
{
|
|
143
|
+
minHeight: 44,
|
|
144
|
+
paddingHorizontal: 12,
|
|
145
|
+
paddingRight: secureTextEntry ? 56 : 12,
|
|
146
|
+
paddingVertical: 10,
|
|
147
|
+
borderRadius: 10,
|
|
148
|
+
backgroundColor: colors.surfaceContainerHigh,
|
|
149
|
+
borderWidth: 1,
|
|
150
|
+
borderColor: error ? colors.error : focused ? colors.primary : 'transparent',
|
|
151
|
+
fontSize: 17,
|
|
152
|
+
},
|
|
153
|
+
style,
|
|
154
|
+
]}
|
|
155
|
+
{...props}
|
|
156
|
+
autoCapitalize={props.autoCapitalize ?? (secureTextEntry ? 'none' : undefined)}
|
|
157
|
+
autoCorrect={props.autoCorrect ?? (secureTextEntry ? false : undefined)}
|
|
158
|
+
secureTextEntry={secureTextEntry && !revealed}
|
|
159
|
+
/>
|
|
160
|
+
{secureTextEntry ? (
|
|
161
|
+
<PasswordVisibility
|
|
162
|
+
revealed={revealed}
|
|
163
|
+
onPress={() => setRevealed((value) => !value)}
|
|
164
|
+
/>
|
|
165
|
+
) : null}
|
|
166
|
+
</View>
|
|
167
|
+
{error ? (
|
|
168
|
+
<Text variant="caption" accessibilityRole="alert" style={{ color: colors.error }}>
|
|
169
|
+
{error}
|
|
170
|
+
</Text>
|
|
171
|
+
) : null}
|
|
172
|
+
</View>
|
|
173
|
+
);
|
|
174
|
+
return (
|
|
175
|
+
<View className="gap-1.5">
|
|
176
|
+
<View className="relative">
|
|
177
|
+
<TextInput
|
|
178
|
+
ref={ref}
|
|
179
|
+
value={value}
|
|
180
|
+
onFocus={handleFocus}
|
|
181
|
+
onBlur={handleBlur}
|
|
182
|
+
className={cn(
|
|
183
|
+
'h-14 rounded px-4 pt-2 text-base text-foreground',
|
|
184
|
+
secureTextEntry && 'pr-14',
|
|
185
|
+
className,
|
|
186
|
+
)}
|
|
187
|
+
style={style}
|
|
188
|
+
placeholder={floated ? placeholder : undefined}
|
|
189
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
190
|
+
{...props}
|
|
191
|
+
autoCapitalize={props.autoCapitalize ?? (secureTextEntry ? 'none' : undefined)}
|
|
192
|
+
autoCorrect={props.autoCorrect ?? (secureTextEntry ? false : undefined)}
|
|
193
|
+
secureTextEntry={secureTextEntry && !revealed}
|
|
194
|
+
/>
|
|
195
|
+
|
|
196
|
+
<Box
|
|
197
|
+
pointerEvents="none"
|
|
198
|
+
style={[
|
|
199
|
+
web
|
|
200
|
+
? {
|
|
201
|
+
borderWidth: focused ? 2 : 1,
|
|
202
|
+
borderColor: error ? colors.error : focused ? colors.primary : colors.outline,
|
|
203
|
+
}
|
|
204
|
+
: borderStyle,
|
|
205
|
+
{ borderRadius: corner.extraSmall },
|
|
206
|
+
]}
|
|
207
|
+
className="absolute inset-0"
|
|
208
|
+
/>
|
|
209
|
+
|
|
210
|
+
{/* The label rests centred on the first line, where the text goes,
|
|
211
|
+
and floats by half the field's height onto the border. */}
|
|
212
|
+
<View
|
|
213
|
+
pointerEvents="none"
|
|
214
|
+
style={{ height: FIELD_HEIGHT }}
|
|
215
|
+
className="absolute left-3 top-0 justify-center"
|
|
216
|
+
>
|
|
217
|
+
<Box
|
|
218
|
+
style={[
|
|
219
|
+
web
|
|
220
|
+
? {
|
|
221
|
+
transform: [
|
|
222
|
+
{ translateY: floated ? -FIELD_HEIGHT / 2 : 0 },
|
|
223
|
+
{ scale: floated ? 0.75 : 1 },
|
|
224
|
+
],
|
|
225
|
+
}
|
|
226
|
+
: labelWrapStyle,
|
|
227
|
+
{ transformOrigin: 'left center' },
|
|
228
|
+
]}
|
|
229
|
+
className={cn('rounded px-1', notchClassName ?? 'bg-popover')}
|
|
230
|
+
>
|
|
231
|
+
<Label
|
|
232
|
+
style={
|
|
233
|
+
web
|
|
234
|
+
? {
|
|
235
|
+
color: error
|
|
236
|
+
? colors.error
|
|
237
|
+
: focused
|
|
238
|
+
? colors.primary
|
|
239
|
+
: colors.onSurfaceVariant,
|
|
240
|
+
}
|
|
241
|
+
: labelTextStyle
|
|
242
|
+
}
|
|
243
|
+
className="text-base"
|
|
244
|
+
>
|
|
245
|
+
{label}
|
|
246
|
+
</Label>
|
|
247
|
+
</Box>
|
|
248
|
+
</View>
|
|
249
|
+
{secureTextEntry ? (
|
|
250
|
+
<PasswordVisibility
|
|
251
|
+
revealed={revealed}
|
|
252
|
+
onPress={() => setRevealed((value) => !value)}
|
|
253
|
+
/>
|
|
254
|
+
) : null}
|
|
255
|
+
</View>
|
|
256
|
+
|
|
257
|
+
{error ? (
|
|
258
|
+
<Text variant="caption" className="text-destructive">
|
|
259
|
+
{error}
|
|
260
|
+
</Text>
|
|
261
|
+
) : null}
|
|
262
|
+
</View>
|
|
263
|
+
);
|
|
264
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { Text as RNText } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import { useIsolinePlatform, useIsolineTheme } from '../provider.tsx';
|
|
6
|
+
import { cn } from '../utils.ts';
|
|
7
|
+
import type { TextVariant } from '../theme.ts';
|
|
8
|
+
|
|
9
|
+
const textVariants = cva('text-foreground', {
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: 'text-base leading-6',
|
|
13
|
+
display: 'text-[40px] leading-[46px] font-extrabold tracking-[-0.02em]',
|
|
14
|
+
title: 'text-[32px] leading-9 font-extrabold tracking-[-0.015em]',
|
|
15
|
+
heading: 'text-[22px] leading-7 font-bold tracking-tight',
|
|
16
|
+
subject: 'text-base font-semibold tracking-tight',
|
|
17
|
+
body: 'text-base leading-6',
|
|
18
|
+
reader: 'text-[18px] leading-[30px]',
|
|
19
|
+
muted: 'text-sm text-muted-foreground',
|
|
20
|
+
caption: 'text-sm text-muted-foreground',
|
|
21
|
+
labelLarge: 'text-[15px] font-semibold tracking-[0.01em]',
|
|
22
|
+
label: 'text-sm font-semibold tracking-[0.01em] text-muted-foreground',
|
|
23
|
+
mono: 'font-mono text-sm text-muted-foreground',
|
|
24
|
+
value: 'font-mono text-[15px] leading-[22px]',
|
|
25
|
+
valueLarge: 'font-mono text-[22px] leading-7 font-bold',
|
|
26
|
+
meta: 'text-[13px] leading-[18px] text-muted-foreground',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
defaultVariants: { variant: 'default' },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export type TextProps = React.ComponentProps<typeof RNText> & VariantProps<typeof textVariants>;
|
|
33
|
+
export function Text({ className, variant, style, ...props }: TextProps) {
|
|
34
|
+
const theme = useIsolineTheme();
|
|
35
|
+
const ios = useIsolinePlatform() === 'ios';
|
|
36
|
+
const role = variant ?? 'default';
|
|
37
|
+
const iosType = {
|
|
38
|
+
default: { fontSize: 17, lineHeight: 22 },
|
|
39
|
+
body: { fontSize: 17, lineHeight: 22 },
|
|
40
|
+
display: { fontSize: 34, lineHeight: 41, fontWeight: '700' as const },
|
|
41
|
+
title: { fontSize: 28, lineHeight: 34, fontWeight: '700' as const },
|
|
42
|
+
heading: { fontSize: 22, lineHeight: 28, fontWeight: '600' as const },
|
|
43
|
+
caption: { fontSize: 13, lineHeight: 18 },
|
|
44
|
+
label: { fontSize: 13, lineHeight: 18 },
|
|
45
|
+
};
|
|
46
|
+
return (
|
|
47
|
+
<RNText
|
|
48
|
+
className={cn(textVariants({ variant }), className)}
|
|
49
|
+
style={[
|
|
50
|
+
ios && iosType[role as keyof typeof iosType],
|
|
51
|
+
theme.typography?.[(variant ?? 'default') as TextVariant],
|
|
52
|
+
style,
|
|
53
|
+
]}
|
|
54
|
+
{...props}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
export { textVariants };
|