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.
Files changed (78) hide show
  1. package/README.md +119 -20
  2. package/package.json +9 -9
  3. package/src/accent.tsx +21 -8
  4. package/src/alert/alert.css +3 -3
  5. package/src/alert/index.tsx +1 -2
  6. package/src/button/button.css +12 -0
  7. package/src/button/index.android.tsx +14 -3
  8. package/src/button/index.ios.tsx +13 -4
  9. package/src/button/index.tsx +8 -1
  10. package/src/button/types.ts +20 -0
  11. package/src/checkbox/index.tsx +1 -1
  12. package/src/color-picker/color-picker.css +136 -0
  13. package/src/color-picker/index.android.tsx +145 -0
  14. package/src/color-picker/index.ios.tsx +80 -0
  15. package/src/color-picker/index.tsx +112 -0
  16. package/src/color-picker/shared.ts +204 -0
  17. package/src/color-picker/sheet.tsx +432 -0
  18. package/src/color-picker/types.ts +40 -0
  19. package/src/context-menu/index.android.tsx +39 -11
  20. package/src/context-menu/index.ios.tsx +3 -1
  21. package/src/context-menu/index.tsx +24 -7
  22. package/src/fab/fab.css +72 -0
  23. package/src/fab/index.android.tsx +72 -0
  24. package/src/fab/index.ios.tsx +68 -0
  25. package/src/fab/index.tsx +37 -0
  26. package/src/fab/shared.ts +23 -0
  27. package/src/fab/types.ts +39 -0
  28. package/src/field-group/field-group.css +23 -7
  29. package/src/field-group/index.android.tsx +44 -38
  30. package/src/field-group/index.tsx +36 -5
  31. package/src/field-group/index.web.tsx +27 -11
  32. package/src/field-group/shared.tsx +49 -0
  33. package/src/field-group/types.ts +25 -0
  34. package/src/gauge/gauge.css +200 -0
  35. package/src/gauge/index.android.tsx +212 -0
  36. package/src/gauge/index.ios.tsx +40 -0
  37. package/src/gauge/index.tsx +165 -0
  38. package/src/gauge/shared.ts +79 -0
  39. package/src/gauge/types.ts +64 -0
  40. package/src/header-menu/index.tsx +76 -0
  41. package/src/host/index.tsx +32 -0
  42. package/src/index.ts +35 -6
  43. package/src/keyboard/index.tsx +81 -0
  44. package/src/keyboard/library.native.ts +17 -0
  45. package/src/keyboard/library.ts +12 -0
  46. package/src/keyboard/types.ts +20 -0
  47. package/src/list-item/index.android.tsx +23 -6
  48. package/src/list-item/index.tsx +20 -4
  49. package/src/list-item/index.web.tsx +60 -0
  50. package/src/list-item/list-item.css +65 -0
  51. package/src/list-item/types.ts +21 -0
  52. package/src/menu/index.android.tsx +22 -9
  53. package/src/menu/index.ios.tsx +26 -11
  54. package/src/menu/index.tsx +26 -10
  55. package/src/menu/list.tsx +25 -10
  56. package/src/menu/menu.css +52 -0
  57. package/src/menu/types.ts +41 -2
  58. package/src/progress/progress.css +9 -0
  59. package/src/scheme.ts +140 -0
  60. package/src/screen/header.tsx +1 -0
  61. package/src/screen/index.tsx +49 -5
  62. package/src/segmented/index.tsx +1 -1
  63. package/src/segmented/segmented.css +6 -7
  64. package/src/slider/index.tsx +1 -1
  65. package/src/stepper/index.tsx +1 -1
  66. package/src/switch/index.tsx +2 -0
  67. package/src/tab-stack/index.tsx +15 -2
  68. package/src/tabs/index.tsx +2 -1
  69. package/src/tabs/index.web.tsx +29 -4
  70. package/src/tabs/types.ts +20 -2
  71. package/src/text-field/index.android.tsx +30 -6
  72. package/src/text-field/index.ios.tsx +16 -3
  73. package/src/text-field/index.tsx +24 -4
  74. package/src/text-field/inline.tsx +87 -0
  75. package/src/text-field/shared.ts +40 -1
  76. package/src/text-field/types.ts +47 -2
  77. package/src/theme.ts +44 -5
  78. package/src/qr/index.tsx +0 -26
@@ -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 {useColorScheme, Appearance, StyleSheet, View} from 'react-native';
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
- setBackgroundColorAsync(BG_COLOR[Appearance.getColorScheme() ?? 'unspecified']);
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
- interface ScreenProps extends React.PropsWithChildren {
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 backgroundColor = BG_COLOR[scheme ?? 'unspecified'];
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
  });
@@ -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((StyleSheet.flatten(style) ?? undefined) as TextStyle | undefined) as CSSProperties;
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
  }
@@ -26,7 +26,7 @@ export function Slider({
26
26
  }: SliderProps) {
27
27
  const vars = {
28
28
  ...(accentColor ? {'--ui-slider-accent': accentColor} : null),
29
- ...flatten((StyleSheet.flatten(style) ?? undefined) as TextStyle | undefined),
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 (
@@ -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((StyleSheet.flatten(style) ?? undefined) as TextStyle | undefined) as CSSProperties;
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(' ')}
@@ -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}
@@ -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 function TabStack({title}: {title: string}) {
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
  }
@@ -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}
@@ -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({routes, webLogo = 'icon-and-text', webIcon}: TabBarProps) {
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
- export function WebTabList({logo, icon, ...props}: TabListProps & {logo: WebLogo, icon?: TabBarProps['webIcon']}) {
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
- export function TextField({
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 ? 'done' : 'default',
95
+ imeAction: imeActionFor(returnKeyType, !!onSubmit),
83
96
  }}
84
- keyboardActions={onSubmit ? {onDone: onSubmit} : undefined}
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
- export function TextField({
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
 
@@ -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 {keyboardTypeFor, useTextValue} from './shared';
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
- export function TextField({
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
+ });