expo-interface 0.1.1 → 0.3.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/README.md +119 -20
- package/package.json +9 -9
- package/src/accent.tsx +21 -8
- package/src/alert/alert.css +3 -3
- package/src/alert/index.tsx +1 -2
- package/src/button/button.css +12 -0
- package/src/button/index.android.tsx +14 -3
- package/src/button/index.ios.tsx +13 -4
- package/src/button/index.tsx +8 -1
- package/src/button/types.ts +20 -0
- package/src/checkbox/index.tsx +1 -1
- package/src/color-picker/color-picker.css +136 -0
- package/src/color-picker/index.android.tsx +145 -0
- package/src/color-picker/index.ios.tsx +80 -0
- package/src/color-picker/index.tsx +112 -0
- package/src/color-picker/shared.ts +204 -0
- package/src/color-picker/sheet.tsx +432 -0
- package/src/color-picker/types.ts +40 -0
- package/src/context-menu/index.android.tsx +39 -11
- package/src/context-menu/index.ios.tsx +3 -1
- package/src/context-menu/index.tsx +24 -7
- package/src/fab/fab.css +72 -0
- package/src/fab/index.android.tsx +72 -0
- package/src/fab/index.ios.tsx +68 -0
- package/src/fab/index.tsx +37 -0
- package/src/fab/shared.ts +23 -0
- package/src/fab/types.ts +39 -0
- package/src/field-group/field-group.css +23 -7
- package/src/field-group/index.android.tsx +44 -38
- package/src/field-group/index.tsx +36 -5
- package/src/field-group/index.web.tsx +27 -11
- package/src/field-group/shared.tsx +49 -0
- package/src/field-group/types.ts +25 -0
- package/src/gauge/gauge.css +200 -0
- package/src/gauge/index.android.tsx +212 -0
- package/src/gauge/index.ios.tsx +40 -0
- package/src/gauge/index.tsx +165 -0
- package/src/gauge/shared.ts +79 -0
- package/src/gauge/types.ts +64 -0
- package/src/header-menu/index.tsx +76 -0
- package/src/host/index.tsx +32 -0
- package/src/index.ts +35 -6
- package/src/keyboard/index.tsx +81 -0
- package/src/keyboard/library.native.ts +17 -0
- package/src/keyboard/library.ts +12 -0
- package/src/keyboard/types.ts +20 -0
- package/src/list-item/index.android.tsx +23 -6
- package/src/list-item/index.tsx +20 -4
- package/src/list-item/index.web.tsx +60 -0
- package/src/list-item/list-item.css +65 -0
- package/src/list-item/types.ts +21 -0
- package/src/menu/index.android.tsx +22 -9
- package/src/menu/index.ios.tsx +26 -11
- package/src/menu/index.tsx +26 -10
- package/src/menu/list.tsx +25 -10
- package/src/menu/menu.css +52 -0
- package/src/menu/types.ts +41 -2
- package/src/progress/progress.css +9 -0
- package/src/scheme.ts +140 -0
- package/src/screen/header.tsx +1 -0
- package/src/screen/index.tsx +49 -5
- package/src/segmented/index.tsx +1 -1
- package/src/segmented/segmented.css +6 -7
- package/src/slider/index.tsx +1 -1
- package/src/stepper/index.tsx +1 -1
- package/src/switch/index.tsx +2 -0
- package/src/tab-stack/index.tsx +15 -2
- package/src/tabs/index.tsx +2 -1
- package/src/tabs/index.web.tsx +29 -4
- package/src/tabs/types.ts +20 -2
- package/src/text-field/index.android.tsx +30 -6
- package/src/text-field/index.ios.tsx +16 -3
- package/src/text-field/index.tsx +24 -4
- package/src/text-field/inline.tsx +87 -0
- package/src/text-field/shared.ts +40 -1
- package/src/text-field/types.ts +47 -2
- package/src/theme.ts +44 -5
- package/src/qr/index.tsx +0 -26
package/src/screen/index.tsx
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import type {ColorSchemeName, ColorValue} from 'react-native';
|
|
2
2
|
import type {Edge} from 'react-native-safe-area-context';
|
|
3
|
+
import type {PropsWithChildren, ReactNode} from 'react';
|
|
3
4
|
|
|
4
5
|
import {Host} from '@expo/ui';
|
|
5
6
|
import {useEffect} from 'react';
|
|
6
7
|
import {StatusBar} from 'expo-status-bar';
|
|
7
|
-
import {SafeAreaView} from 'react-native-safe-area-context';
|
|
8
|
-
import {
|
|
8
|
+
import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
|
|
9
|
+
import {Appearance, Platform, StyleSheet, View} from 'react-native';
|
|
9
10
|
import {setBackgroundColorAsync} from 'expo-system-ui';
|
|
10
11
|
import {useAccentSeed} from '../accent';
|
|
12
|
+
import {useColorScheme} from '../scheme';
|
|
11
13
|
import * as theme from '../theme';
|
|
12
14
|
|
|
13
15
|
import {hostAccentProps} from './host-accent';
|
|
@@ -19,15 +21,31 @@ const BG_COLOR: Record<ColorSchemeName, ColorValue> = {
|
|
|
19
21
|
dark: theme.colors.dark.background,
|
|
20
22
|
};
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Web paints the palette's CSS variable: it follows the media query and a
|
|
26
|
+
* forced `data-theme` before any JavaScript runs, so the static export is not
|
|
27
|
+
* white behind dark controls until the first re-render. Native paints the
|
|
28
|
+
* scheme's literal color, which the system UI (`expo-system-ui`) also takes.
|
|
29
|
+
*/
|
|
30
|
+
const background = (scheme: ColorSchemeName): ColorValue =>
|
|
31
|
+
Platform.OS === 'web' ? theme.theme.background : BG_COLOR[scheme];
|
|
23
32
|
|
|
24
|
-
|
|
33
|
+
setBackgroundColorAsync(background(Appearance.getColorScheme() ?? 'unspecified'));
|
|
34
|
+
|
|
35
|
+
export interface ScreenProps extends PropsWithChildren {
|
|
25
36
|
/** Whether to expect an @expo/ui or normal RN component children. */
|
|
26
37
|
native?: boolean;
|
|
27
38
|
/** Screen sits below a stack header — skip redundant top inset/padding. */
|
|
28
39
|
header?: boolean;
|
|
29
40
|
/** Whether to apply a horizontal padding to the screen. */
|
|
30
41
|
gutter?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* A floating action button (`Fab`) the screen places itself: bottom
|
|
44
|
+
* trailing, `spacing.three` from the edges plus the safe-area bottom inset
|
|
45
|
+
* natively (which includes the tab bar when the screen shows one), fixed
|
|
46
|
+
* to the viewport on web.
|
|
47
|
+
*/
|
|
48
|
+
fab?: ReactNode;
|
|
31
49
|
}
|
|
32
50
|
|
|
33
51
|
export function Screen({
|
|
@@ -35,10 +53,12 @@ export function Screen({
|
|
|
35
53
|
native = false,
|
|
36
54
|
header = false,
|
|
37
55
|
gutter = false,
|
|
56
|
+
fab,
|
|
38
57
|
}: ScreenProps) {
|
|
39
58
|
const seed = useAccentSeed();
|
|
40
59
|
const scheme = useColorScheme();
|
|
41
|
-
const
|
|
60
|
+
const insets = useSafeAreaInsets();
|
|
61
|
+
const backgroundColor = background(scheme);
|
|
42
62
|
|
|
43
63
|
useEffect(() => {
|
|
44
64
|
setBackgroundColorAsync(backgroundColor);
|
|
@@ -58,6 +78,19 @@ export function Screen({
|
|
|
58
78
|
)}
|
|
59
79
|
</View>
|
|
60
80
|
</View>
|
|
81
|
+
{fab != null ? (
|
|
82
|
+
<View
|
|
83
|
+
testID="screen-fab"
|
|
84
|
+
pointerEvents="box-none"
|
|
85
|
+
style={[
|
|
86
|
+
styles.fab,
|
|
87
|
+
Platform.OS === 'web'
|
|
88
|
+
? styles.fabFixed
|
|
89
|
+
: {right: theme.spacing.three + insets.right, bottom: theme.spacing.three + insets.bottom},
|
|
90
|
+
]}>
|
|
91
|
+
{fab}
|
|
92
|
+
</View>
|
|
93
|
+
) : null}
|
|
61
94
|
</SafeAreaView>
|
|
62
95
|
);
|
|
63
96
|
}
|
|
@@ -76,4 +109,15 @@ const styles = StyleSheet.create({
|
|
|
76
109
|
gutter: {
|
|
77
110
|
paddingHorizontal: theme.spacing.three,
|
|
78
111
|
},
|
|
112
|
+
fab: {
|
|
113
|
+
position: 'absolute',
|
|
114
|
+
right: theme.spacing.three,
|
|
115
|
+
bottom: theme.spacing.three,
|
|
116
|
+
},
|
|
117
|
+
// react-native-web passes `fixed` through to the CSS; React Native's types do not know it.
|
|
118
|
+
fabFixed: {
|
|
119
|
+
position: 'fixed' as 'absolute',
|
|
120
|
+
right: theme.spacing.three,
|
|
121
|
+
bottom: theme.spacing.three,
|
|
122
|
+
},
|
|
79
123
|
});
|
package/src/segmented/index.tsx
CHANGED
|
@@ -24,7 +24,7 @@ function SegmentedControlComponent<T extends PickerValue>({
|
|
|
24
24
|
}: SegmentedControlProps<T>) {
|
|
25
25
|
const items = extractItems<T>(children);
|
|
26
26
|
const [current, setValue] = useSelectedValue(selectedValue, onValueChange, items[0]?.value);
|
|
27
|
-
const vars = flatten(
|
|
27
|
+
const vars = flatten(StyleSheet.flatten(style) as TextStyle) as CSSProperties;
|
|
28
28
|
return (
|
|
29
29
|
<div
|
|
30
30
|
className={['ui-segmented', disabled && 'ui-segmented--disabled'].filter(Boolean).join(' ')}
|
|
@@ -35,18 +35,17 @@
|
|
|
35
35
|
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/*
|
|
39
|
+
* Raised segment (iOS: white light / systemGray2-ish dark). `light-dark()`
|
|
40
|
+
* follows the element's `color-scheme`, which the theme CSS sets from the OS
|
|
41
|
+
* and which a host can force (Storybook's scheme toolbar does).
|
|
42
|
+
*/
|
|
38
43
|
.ui-segmented__item[aria-checked='true'] {
|
|
39
|
-
background: #ffffff;
|
|
44
|
+
background: light-dark(#ffffff, #636366);
|
|
40
45
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(0, 0, 0, 0.04);
|
|
41
46
|
font-weight: 600;
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
@media (prefers-color-scheme: dark) {
|
|
45
|
-
.ui-segmented__item[aria-checked='true'] {
|
|
46
|
-
background: #636366;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
49
|
.ui-segmented__item:disabled {
|
|
51
50
|
cursor: default;
|
|
52
51
|
}
|
package/src/slider/index.tsx
CHANGED
|
@@ -26,7 +26,7 @@ export function Slider({
|
|
|
26
26
|
}: SliderProps) {
|
|
27
27
|
const vars = {
|
|
28
28
|
...(accentColor ? {'--ui-slider-accent': accentColor} : null),
|
|
29
|
-
...flatten(
|
|
29
|
+
...flatten(StyleSheet.flatten(style) as TextStyle),
|
|
30
30
|
} as CSSProperties;
|
|
31
31
|
const read = (event: ChangeEvent<HTMLInputElement>) => Number(event.target.value);
|
|
32
32
|
return (
|
package/src/stepper/index.tsx
CHANGED
|
@@ -14,7 +14,7 @@ import {clampStep, stepBounds} from './shared';
|
|
|
14
14
|
export function Stepper(props: StepperProps) {
|
|
15
15
|
const {label, value, onValueChange, step = 1, min, max, formatValue, disabled, testID, style} = props;
|
|
16
16
|
const {canDecrement, canIncrement} = stepBounds(props);
|
|
17
|
-
const vars = flatten(
|
|
17
|
+
const vars = flatten(StyleSheet.flatten(style) as TextStyle) as CSSProperties;
|
|
18
18
|
return (
|
|
19
19
|
<div
|
|
20
20
|
className={['ui-stepper', disabled && 'ui-stepper--disabled'].filter(Boolean).join(' ')}
|
package/src/switch/index.tsx
CHANGED
|
@@ -28,6 +28,8 @@ export function Switch({
|
|
|
28
28
|
value={value}
|
|
29
29
|
onValueChange={onValueChange}
|
|
30
30
|
disabled={disabled}
|
|
31
|
+
// The label is a sibling, not a <label>, so name the input directly.
|
|
32
|
+
accessibilityLabel={label}
|
|
31
33
|
thumbColor={THUMB}
|
|
32
34
|
trackColor={{true: onColor, false: offColor}}
|
|
33
35
|
testID={label == null ? testID : undefined}
|
package/src/tab-stack/index.tsx
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
|
+
import type {ReactNode} from 'react';
|
|
1
2
|
import {Stack} from 'expo-router';
|
|
2
3
|
import {Platform} from 'react-native';
|
|
3
4
|
import {useNavTheme} from '../theme';
|
|
4
5
|
|
|
5
|
-
export
|
|
6
|
+
export interface TabStackProps {
|
|
7
|
+
/** Title of the tab's root screen, in its native header. */
|
|
8
|
+
title: string;
|
|
9
|
+
/**
|
|
10
|
+
* Content of the header's trailing slot on iOS and Android (the web shows
|
|
11
|
+
* no header for a tab root: its actions go in the tab bar). A `HeaderMenu`
|
|
12
|
+
* survives Android's header re-parenting; a plain `Menu` in a host does
|
|
13
|
+
* not.
|
|
14
|
+
*/
|
|
15
|
+
headerRight?: () => ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function TabStack({title, headerRight}: TabStackProps) {
|
|
6
19
|
const {colors} = useNavTheme();
|
|
7
20
|
|
|
8
21
|
return (
|
|
@@ -15,7 +28,7 @@ export function TabStack({title}: {title: string}) {
|
|
|
15
28
|
headerTitleStyle: {color: colors.text},
|
|
16
29
|
headerStyle: {backgroundColor: colors.background},
|
|
17
30
|
}}>
|
|
18
|
-
<Stack.Screen name="index" options={{title}}/>
|
|
31
|
+
<Stack.Screen name="index" options={{title, headerRight}}/>
|
|
19
32
|
</Stack>
|
|
20
33
|
);
|
|
21
34
|
}
|
package/src/tabs/index.tsx
CHANGED
|
@@ -2,13 +2,14 @@ import type {TabBarProps} from './types';
|
|
|
2
2
|
import {NativeTabs} from 'expo-router/unstable-native-tabs';
|
|
3
3
|
import {useColor} from '../theme';
|
|
4
4
|
|
|
5
|
-
export function Tabs({routes}: TabBarProps) {
|
|
5
|
+
export function Tabs({routes, hidden = false}: TabBarProps) {
|
|
6
6
|
const rippleColor = useColor('pillBackground');
|
|
7
7
|
const indicatorColor = useColor('backgroundElement');
|
|
8
8
|
const labelColor = useColor('label');
|
|
9
9
|
|
|
10
10
|
return (
|
|
11
11
|
<NativeTabs
|
|
12
|
+
hidden={hidden}
|
|
12
13
|
backgroundColor="transparent"
|
|
13
14
|
indicatorColor={indicatorColor}
|
|
14
15
|
rippleColor={rippleColor}
|
package/src/tabs/index.web.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {TabTriggerSlotProps, TabListProps} from 'expo-router/ui';
|
|
2
|
+
import type {ReactNode} from 'react';
|
|
2
3
|
import type {TabBarProps, TabRoute, WebLogo} from './types';
|
|
3
4
|
|
|
4
5
|
import {Tabs as WebTabs, TabSlot, TabList, TabTrigger} from 'expo-router/ui';
|
|
@@ -10,12 +11,20 @@ import app from 'expo-constants';
|
|
|
10
11
|
import {theme, spacing, bound} from '../theme';
|
|
11
12
|
import {Headline, Label} from '../typography';
|
|
12
13
|
|
|
13
|
-
export function Tabs({
|
|
14
|
+
export function Tabs({
|
|
15
|
+
routes,
|
|
16
|
+
hidden = false,
|
|
17
|
+
webLogo = 'icon-and-text',
|
|
18
|
+
webIcon,
|
|
19
|
+
webActions,
|
|
20
|
+
webActionsPlacement = 'before',
|
|
21
|
+
}: TabBarProps) {
|
|
14
22
|
return (
|
|
15
23
|
<WebTabs>
|
|
16
24
|
<TabSlot style={styles.slot}/>
|
|
25
|
+
{/* The triggers stay in the list even while the bar is hidden: that is where the router looks for the routes. */}
|
|
17
26
|
<TabList asChild>
|
|
18
|
-
<WebTabList logo={webLogo} icon={webIcon}>
|
|
27
|
+
<WebTabList logo={webLogo} icon={webIcon} hidden={hidden} actions={webActions} actionsPlacement={webActionsPlacement}>
|
|
19
28
|
{routes.map(route => (
|
|
20
29
|
<TabTrigger key={route.name} name={route.name} href={route.href} asChild>
|
|
21
30
|
<TabLink icon={route.icon}>{route.label}</TabLink>
|
|
@@ -27,12 +36,20 @@ export function Tabs({routes, webLogo = 'icon-and-text', webIcon}: TabBarProps)
|
|
|
27
36
|
);
|
|
28
37
|
}
|
|
29
38
|
|
|
30
|
-
|
|
39
|
+
interface WebTabListProps extends TabListProps {
|
|
40
|
+
logo: WebLogo;
|
|
41
|
+
icon?: TabBarProps['webIcon'];
|
|
42
|
+
hidden?: boolean;
|
|
43
|
+
actions?: ReactNode;
|
|
44
|
+
actionsPlacement?: 'before' | 'after';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function WebTabList({logo, icon, hidden = false, actions, actionsPlacement = 'before', ...props}: WebTabListProps) {
|
|
31
48
|
const isPreset = typeof logo === 'string';
|
|
32
49
|
const isTextOnly = logo === 'text-only';
|
|
33
50
|
const isIconOnly = logo === 'icon-only';
|
|
34
51
|
return (
|
|
35
|
-
<View {...props} style={styles.list}>
|
|
52
|
+
<View {...props} style={[styles.list, hidden && styles.hidden]}>
|
|
36
53
|
<View style={styles.inner}>
|
|
37
54
|
<View style={styles.logo}>
|
|
38
55
|
{!isPreset ? logo : (
|
|
@@ -52,7 +69,9 @@ export function WebTabList({logo, icon, ...props}: TabListProps & {logo: WebLogo
|
|
|
52
69
|
</>
|
|
53
70
|
)}
|
|
54
71
|
</View>
|
|
72
|
+
{actionsPlacement === 'before' ? actions : null}
|
|
55
73
|
{props.children}
|
|
74
|
+
{actionsPlacement === 'after' ? actions : null}
|
|
56
75
|
</View>
|
|
57
76
|
</View>
|
|
58
77
|
);
|
|
@@ -81,6 +100,9 @@ const styles = StyleSheet.create({
|
|
|
81
100
|
width: '100%',
|
|
82
101
|
padding: spacing.three,
|
|
83
102
|
},
|
|
103
|
+
hidden: {
|
|
104
|
+
display: 'none',
|
|
105
|
+
},
|
|
84
106
|
inner: {
|
|
85
107
|
flexGrow: 1,
|
|
86
108
|
flexDirection: 'row',
|
|
@@ -97,6 +119,9 @@ const styles = StyleSheet.create({
|
|
|
97
119
|
alignItems: 'center',
|
|
98
120
|
marginRight: 'auto',
|
|
99
121
|
gap: spacing.two,
|
|
122
|
+
// The slot shrinks before the tabs do, so a title in it is bounded by the bar.
|
|
123
|
+
flexShrink: 1,
|
|
124
|
+
minWidth: 0,
|
|
100
125
|
},
|
|
101
126
|
icon: {
|
|
102
127
|
width: 24,
|
package/src/tabs/types.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type {ImageSource} from 'expo-image';
|
|
|
8
8
|
* @see Tabs
|
|
9
9
|
*/
|
|
10
10
|
export interface TabBarProps {
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
12
|
* Configures the tab bar items for native & web.
|
|
13
13
|
* @example
|
|
14
14
|
* ```ts
|
|
@@ -29,9 +29,17 @@ export interface TabBarProps {
|
|
|
29
29
|
* ```
|
|
30
30
|
*/
|
|
31
31
|
routes: readonly TabRoute[];
|
|
32
|
+
/**
|
|
33
|
+
* Hides the tab bar while keeping the routes, so a screen that needs the
|
|
34
|
+
* whole display (an open document) can take it: natively the native tab
|
|
35
|
+
* bar's own `hidden`, on web the floating bar is not drawn.
|
|
36
|
+
* @default false
|
|
37
|
+
*/
|
|
38
|
+
hidden?: boolean;
|
|
32
39
|
/**
|
|
33
40
|
* Controls the web tab bar logo. Use a preset mode to show the app icon
|
|
34
|
-
* and/or name, or pass a custom node to replace them entirely.
|
|
41
|
+
* and/or name, or pass a custom node to replace them entirely. The slot
|
|
42
|
+
* shrinks before the tabs do, so a title in it is bounded by the bar.
|
|
35
43
|
* @default 'icon-and-text'
|
|
36
44
|
*/
|
|
37
45
|
webLogo?: WebLogo;
|
|
@@ -40,6 +48,16 @@ export interface TabBarProps {
|
|
|
40
48
|
* e.g. `require('./assets/icon.png')`. When omitted only the name is shown.
|
|
41
49
|
*/
|
|
42
50
|
webIcon?: ImageSource | number;
|
|
51
|
+
/**
|
|
52
|
+
* Content rendered in the web tab bar beside the tabs: a `Menu` with a
|
|
53
|
+
* `link` trigger, a button. See `webActionsPlacement`.
|
|
54
|
+
*/
|
|
55
|
+
webActions?: ReactNode;
|
|
56
|
+
/**
|
|
57
|
+
* Where `webActions` go: between the logo and the tabs, or after the tabs.
|
|
58
|
+
* @default 'before'
|
|
59
|
+
*/
|
|
60
|
+
webActionsPlacement?: 'before' | 'after';
|
|
43
61
|
}
|
|
44
62
|
|
|
45
63
|
export type WebLogo =
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type {TextFieldKeyboard, TextFieldProps} from './types';
|
|
2
|
-
import type {TextFieldColors, TextFieldKeyboardType} from '@expo/ui/jetpack-compose';
|
|
1
|
+
import type {TextFieldKeyboard, TextFieldProps, TextFieldReturnKey} from './types';
|
|
2
|
+
import type {TextFieldColors, TextFieldImeAction, TextFieldKeyboardActions, TextFieldKeyboardType} from '@expo/ui/jetpack-compose';
|
|
3
3
|
|
|
4
4
|
import {TextField as ComposeTextField, Text, useMaterialColors, useNativeState} from '@expo/ui/jetpack-compose';
|
|
5
5
|
import {fillMaxWidth, offset, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
|
|
6
6
|
import {useColor} from '../theme';
|
|
7
|
+
import {InlineTextField} from './inline';
|
|
7
8
|
import {useSyncedState} from './shared';
|
|
8
9
|
|
|
9
10
|
const TRANSPARENT = 'transparent';
|
|
@@ -18,14 +19,25 @@ const TRANSPARENT = 'transparent';
|
|
|
18
19
|
*/
|
|
19
20
|
const CONTENT_PADDING = 16;
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The `row` variant is the Compose field; `inline` is a React Native
|
|
24
|
+
* `TextInput` for fields inside a React Native layout.
|
|
25
|
+
*/
|
|
26
|
+
export function TextField(props: TextFieldProps) {
|
|
27
|
+
if (props.variant === 'inline') return <InlineTextField {...props}/>;
|
|
28
|
+
return <RowTextField {...props}/>;
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
/**
|
|
22
32
|
* Android's Material `TextField` ships with a filled background and a bottom
|
|
23
33
|
* indicator line that clash with the iOS `Form` look. Here those are stripped
|
|
24
34
|
* to transparent so the field reads as a plain borderless row — the placeholder
|
|
25
35
|
* doubles as the label — living natively inside the surrounding
|
|
26
|
-
* `Host`/`FieldGroup`.
|
|
36
|
+
* `Host`/`FieldGroup`. The keyboard's action key is `returnKeyType` (`done`
|
|
37
|
+
* when there is only an `onSubmit`); Compose keeps the field focused after
|
|
38
|
+
* it, so `submitBehavior` has nothing to add here.
|
|
27
39
|
*/
|
|
28
|
-
|
|
40
|
+
function RowTextField({
|
|
29
41
|
placeholder,
|
|
30
42
|
value,
|
|
31
43
|
onChangeText,
|
|
@@ -37,6 +49,7 @@ export function TextField({
|
|
|
37
49
|
autoCorrect,
|
|
38
50
|
multiline,
|
|
39
51
|
autoFocus,
|
|
52
|
+
returnKeyType,
|
|
40
53
|
maxLength,
|
|
41
54
|
accentColor,
|
|
42
55
|
testID,
|
|
@@ -79,9 +92,9 @@ export function TextField({
|
|
|
79
92
|
keyboardType: keyboardTypeFor(keyboardType, secureTextEntry),
|
|
80
93
|
capitalization: autoCapitalize,
|
|
81
94
|
autoCorrectEnabled: autoCorrect,
|
|
82
|
-
imeAction: onSubmit
|
|
95
|
+
imeAction: imeActionFor(returnKeyType, !!onSubmit),
|
|
83
96
|
}}
|
|
84
|
-
keyboardActions={onSubmit ?
|
|
97
|
+
keyboardActions={onSubmit ? keyboardActionsFor(onSubmit) : undefined}
|
|
85
98
|
colors={fieldColors}
|
|
86
99
|
textStyle={{fontSize: 16, color: colors.onSurface}}
|
|
87
100
|
modifiers={[
|
|
@@ -98,6 +111,17 @@ export function TextField({
|
|
|
98
111
|
);
|
|
99
112
|
}
|
|
100
113
|
|
|
114
|
+
/** The action key: the requested one, `done` for a bare `onSubmit`, the default otherwise. */
|
|
115
|
+
export function imeActionFor(returnKeyType: TextFieldReturnKey | undefined, hasSubmit: boolean): TextFieldImeAction {
|
|
116
|
+
if (returnKeyType) return returnKeyType;
|
|
117
|
+
return hasSubmit ? 'done' : 'default';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Every action key reports through `onSubmit`, whichever `imeAction` is shown. */
|
|
121
|
+
export function keyboardActionsFor(onSubmit: (text: string) => void): TextFieldKeyboardActions {
|
|
122
|
+
return {onDone: onSubmit, onGo: onSubmit, onNext: onSubmit, onSearch: onSubmit, onSend: onSubmit};
|
|
123
|
+
}
|
|
124
|
+
|
|
101
125
|
function keyboardTypeFor(
|
|
102
126
|
type: TextFieldKeyboard | undefined,
|
|
103
127
|
secure: boolean | undefined,
|
|
@@ -2,16 +2,27 @@ import type {TextFieldCapitalize, TextFieldProps} from './types';
|
|
|
2
2
|
import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
|
|
3
3
|
|
|
4
4
|
import {SecureField, TextField as SwiftUITextField, useNativeState} from '@expo/ui/swift-ui';
|
|
5
|
-
import {autocorrectionDisabled, disabled as disabledMod, keyboardType as keyboardTypeMod, onSubmit as onSubmitMod, textInputAutocapitalization, tint} from '@expo/ui/swift-ui/modifiers';
|
|
5
|
+
import {autocorrectionDisabled, disabled as disabledMod, keyboardType as keyboardTypeMod, onSubmit as onSubmitMod, submitLabel, textInputAutocapitalization, tint} from '@expo/ui/swift-ui/modifiers';
|
|
6
|
+
import {InlineTextField} from './inline';
|
|
6
7
|
import {keyboardTypeFor, useSyncedState} from './shared';
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The `row` variant is the SwiftUI field; `inline` is a React Native
|
|
11
|
+
* `TextInput` for fields inside a React Native layout.
|
|
12
|
+
*/
|
|
13
|
+
export function TextField(props: TextFieldProps) {
|
|
14
|
+
if (props.variant === 'inline') return <InlineTextField {...props}/>;
|
|
15
|
+
return <RowTextField {...props}/>;
|
|
16
|
+
}
|
|
17
|
+
|
|
8
18
|
/**
|
|
9
19
|
* iOS renders the field inline using SwiftUI's `TextField` (or `SecureField`
|
|
10
20
|
* for masked input), which is exactly the borderless `Form` row look the other
|
|
11
21
|
* platforms emulate: a placeholder that doubles as the label and the value
|
|
12
|
-
* filling the row. Drop it straight into a `FieldGroup.Section`.
|
|
22
|
+
* filling the row. Drop it straight into a `FieldGroup.Section`. The
|
|
23
|
+
* keyboard's action key is `returnKeyType` (the `submitLabel` modifier).
|
|
13
24
|
*/
|
|
14
|
-
|
|
25
|
+
function RowTextField({
|
|
15
26
|
placeholder,
|
|
16
27
|
value,
|
|
17
28
|
onChangeText,
|
|
@@ -23,6 +34,7 @@ export function TextField({
|
|
|
23
34
|
autoCorrect,
|
|
24
35
|
multiline,
|
|
25
36
|
autoFocus,
|
|
37
|
+
returnKeyType,
|
|
26
38
|
maxLength,
|
|
27
39
|
accentColor,
|
|
28
40
|
testID,
|
|
@@ -35,6 +47,7 @@ export function TextField({
|
|
|
35
47
|
if (keyboardType) modifiers.push(keyboardTypeMod(keyboardTypeFor(keyboardType)));
|
|
36
48
|
if (autoCorrect === false) modifiers.push(autocorrectionDisabled(true));
|
|
37
49
|
if (autoCapitalize) modifiers.push(textInputAutocapitalization(autocapitalizationFor(autoCapitalize)));
|
|
50
|
+
if (returnKeyType) modifiers.push(submitLabel(returnKeyType));
|
|
38
51
|
if (onSubmit) modifiers.push(onSubmitMod(() => onSubmit(text.value)));
|
|
39
52
|
if (disabled) modifiers.push(disabledMod(true));
|
|
40
53
|
|
package/src/text-field/index.tsx
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import type {TextFieldProps} from './types';
|
|
2
|
-
import type {TextStyle} from 'react-native';
|
|
2
|
+
import type {NativeSyntheticEvent, TextInputKeyPressEventData, TextStyle} from 'react-native';
|
|
3
3
|
|
|
4
|
+
import {useRef} from 'react';
|
|
4
5
|
import {StyleSheet, TextInput} from 'react-native';
|
|
5
6
|
import {fonts, fontWeights, theme, variants} from '../theme';
|
|
6
|
-
import {
|
|
7
|
+
import {InlineTextField} from './inline';
|
|
8
|
+
import {keyboardTypeFor, useAutoFocus, useTextValue} from './shared';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The `row` variant is the form row below; `inline` is the borderless field
|
|
12
|
+
* for a React Native layout (the same `TextInput`, sized to its room).
|
|
13
|
+
*/
|
|
14
|
+
export function TextField(props: TextFieldProps) {
|
|
15
|
+
if (props.variant === 'inline') return <InlineTextField {...props}/>;
|
|
16
|
+
return <RowTextField {...props}/>;
|
|
17
|
+
}
|
|
7
18
|
|
|
8
19
|
/**
|
|
9
20
|
* On web the field mirrors the native iOS/Android row: a borderless, full-width
|
|
@@ -11,11 +22,12 @@ import {keyboardTypeFor, useTextValue} from './shared';
|
|
|
11
22
|
* typography so it sits flush inside a `FieldGroup.Section`. The browser focus
|
|
12
23
|
* outline is suppressed to match the chromeless iOS `Form` look.
|
|
13
24
|
*/
|
|
14
|
-
|
|
25
|
+
function RowTextField({
|
|
15
26
|
placeholder,
|
|
16
27
|
value,
|
|
17
28
|
onChangeText,
|
|
18
29
|
onSubmit,
|
|
30
|
+
onKeyPress,
|
|
19
31
|
disabled,
|
|
20
32
|
secureTextEntry,
|
|
21
33
|
keyboardType,
|
|
@@ -23,16 +35,21 @@ export function TextField({
|
|
|
23
35
|
autoCorrect,
|
|
24
36
|
multiline,
|
|
25
37
|
autoFocus,
|
|
38
|
+
returnKeyType,
|
|
39
|
+
submitBehavior,
|
|
26
40
|
maxLength,
|
|
27
41
|
accentColor,
|
|
28
42
|
testID,
|
|
29
43
|
style,
|
|
30
44
|
}: TextFieldProps) {
|
|
45
|
+
const input = useRef<TextInput>(null);
|
|
31
46
|
const [current, setValue] = useTextValue(value, onChangeText);
|
|
32
47
|
const cursor = accentColor ?? (theme.tint as string);
|
|
48
|
+
useAutoFocus(input, autoFocus);
|
|
33
49
|
|
|
34
50
|
return (
|
|
35
51
|
<TextInput
|
|
52
|
+
ref={input}
|
|
36
53
|
value={current}
|
|
37
54
|
onChangeText={setValue}
|
|
38
55
|
placeholder={placeholder}
|
|
@@ -43,11 +60,14 @@ export function TextField({
|
|
|
43
60
|
autoCapitalize={autoCapitalize}
|
|
44
61
|
autoCorrect={autoCorrect}
|
|
45
62
|
multiline={multiline}
|
|
46
|
-
autoFocus={autoFocus}
|
|
47
63
|
maxLength={maxLength}
|
|
48
64
|
cursorColor={cursor}
|
|
49
65
|
selectionColor={cursor}
|
|
66
|
+
returnKeyType={returnKeyType}
|
|
67
|
+
submitBehavior={submitBehavior}
|
|
50
68
|
onSubmitEditing={onSubmit ? event => onSubmit(event.nativeEvent.text) : undefined}
|
|
69
|
+
onKeyPress={onKeyPress ? (event: NativeSyntheticEvent<TextInputKeyPressEventData & {shiftKey?: boolean}>) =>
|
|
70
|
+
onKeyPress(event.nativeEvent.key, event.nativeEvent.shiftKey === true) : undefined}
|
|
51
71
|
aria-label={placeholder}
|
|
52
72
|
testID={testID}
|
|
53
73
|
style={[styles.input, disabled && styles.disabled, style]}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type {NativeSyntheticEvent, TextInputKeyPressEventData} from 'react-native';
|
|
2
|
+
import type {TextFieldProps} from './types';
|
|
3
|
+
import {useRef} from 'react';
|
|
4
|
+
import {StyleSheet, TextInput} from 'react-native';
|
|
5
|
+
import {fonts, fontWeights, spacing, useColor} from '../theme';
|
|
6
|
+
import {keyboardTypeFor, useAutoFocus, useTextValue} from './shared';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The `inline` variant: a borderless React Native `TextInput` on every
|
|
10
|
+
* platform, for a field that sits inside a React Native layout (a search
|
|
11
|
+
* row in a toolbar, a prompt in a bar) where the native form control would
|
|
12
|
+
* need a host of its own. It grows to the room it is given, focuses on
|
|
13
|
+
* mount when asked (and makes sure the keyboard came on Android), and steps
|
|
14
|
+
* with the keyboard's action key (`returnKeyType` with `submitBehavior`).
|
|
15
|
+
*/
|
|
16
|
+
export function InlineTextField({
|
|
17
|
+
placeholder,
|
|
18
|
+
value,
|
|
19
|
+
onChangeText,
|
|
20
|
+
onSubmit,
|
|
21
|
+
onKeyPress,
|
|
22
|
+
disabled,
|
|
23
|
+
secureTextEntry,
|
|
24
|
+
keyboardType,
|
|
25
|
+
autoCapitalize,
|
|
26
|
+
autoCorrect,
|
|
27
|
+
multiline,
|
|
28
|
+
autoFocus,
|
|
29
|
+
returnKeyType,
|
|
30
|
+
submitBehavior,
|
|
31
|
+
maxLength,
|
|
32
|
+
accentColor,
|
|
33
|
+
testID,
|
|
34
|
+
style,
|
|
35
|
+
}: TextFieldProps) {
|
|
36
|
+
const input = useRef<TextInput>(null);
|
|
37
|
+
const [current, setValue] = useTextValue(value, onChangeText);
|
|
38
|
+
const label = useColor('label');
|
|
39
|
+
const placeholderColor = useColor('tertiaryLabel');
|
|
40
|
+
const tint = useColor('tint');
|
|
41
|
+
const cursor = accentColor ?? tint;
|
|
42
|
+
useAutoFocus(input, autoFocus);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<TextInput
|
|
46
|
+
ref={input}
|
|
47
|
+
value={current}
|
|
48
|
+
onChangeText={setValue}
|
|
49
|
+
placeholder={placeholder}
|
|
50
|
+
placeholderTextColor={placeholderColor}
|
|
51
|
+
editable={!disabled}
|
|
52
|
+
secureTextEntry={secureTextEntry}
|
|
53
|
+
keyboardType={keyboardTypeFor(keyboardType)}
|
|
54
|
+
autoCapitalize={autoCapitalize}
|
|
55
|
+
autoCorrect={autoCorrect}
|
|
56
|
+
multiline={multiline}
|
|
57
|
+
maxLength={maxLength}
|
|
58
|
+
cursorColor={cursor}
|
|
59
|
+
selectionColor={cursor}
|
|
60
|
+
returnKeyType={returnKeyType}
|
|
61
|
+
submitBehavior={submitBehavior}
|
|
62
|
+
onSubmitEditing={onSubmit ? event => onSubmit(event.nativeEvent.text) : undefined}
|
|
63
|
+
onKeyPress={onKeyPress ? (event: NativeSyntheticEvent<TextInputKeyPressEventData & {shiftKey?: boolean}>) =>
|
|
64
|
+
onKeyPress(event.nativeEvent.key, event.nativeEvent.shiftKey === true) : undefined}
|
|
65
|
+
aria-label={placeholder}
|
|
66
|
+
testID={testID}
|
|
67
|
+
style={[styles.input, {color: label}, disabled && styles.disabled, style]}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const styles = StyleSheet.create({
|
|
73
|
+
input: {
|
|
74
|
+
flexGrow: 1,
|
|
75
|
+
flexShrink: 1,
|
|
76
|
+
minWidth: 0,
|
|
77
|
+
margin: 0,
|
|
78
|
+
paddingVertical: spacing.one,
|
|
79
|
+
paddingHorizontal: spacing.two,
|
|
80
|
+
fontSize: 14,
|
|
81
|
+
fontFamily: fonts?.sans,
|
|
82
|
+
fontWeight: fontWeights.normal,
|
|
83
|
+
},
|
|
84
|
+
disabled: {
|
|
85
|
+
opacity: 0.4,
|
|
86
|
+
},
|
|
87
|
+
});
|