expo-interface 0.2.0 → 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 (58) hide show
  1. package/README.md +87 -10
  2. package/package.json +6 -1
  3. package/src/accent.tsx +21 -8
  4. package/src/button/button.css +6 -0
  5. package/src/button/index.android.tsx +5 -1
  6. package/src/button/index.ios.tsx +5 -1
  7. package/src/button/index.tsx +6 -1
  8. package/src/button/types.ts +13 -0
  9. package/src/color-picker/color-picker.css +70 -0
  10. package/src/color-picker/index.android.tsx +77 -49
  11. package/src/color-picker/index.ios.tsx +58 -11
  12. package/src/color-picker/index.tsx +65 -15
  13. package/src/color-picker/types.ts +9 -3
  14. package/src/context-menu/index.android.tsx +39 -11
  15. package/src/context-menu/index.ios.tsx +3 -1
  16. package/src/context-menu/index.tsx +24 -7
  17. package/src/fab/fab.css +72 -0
  18. package/src/fab/index.android.tsx +72 -0
  19. package/src/fab/index.ios.tsx +68 -0
  20. package/src/fab/index.tsx +37 -0
  21. package/src/fab/shared.ts +23 -0
  22. package/src/fab/types.ts +39 -0
  23. package/src/field-group/index.android.tsx +22 -16
  24. package/src/field-group/index.tsx +36 -5
  25. package/src/field-group/index.web.tsx +25 -9
  26. package/src/field-group/shared.tsx +49 -0
  27. package/src/field-group/types.ts +25 -0
  28. package/src/header-menu/index.tsx +76 -0
  29. package/src/host/index.tsx +32 -0
  30. package/src/index.ts +31 -5
  31. package/src/keyboard/index.tsx +81 -0
  32. package/src/keyboard/library.native.ts +17 -0
  33. package/src/keyboard/library.ts +12 -0
  34. package/src/keyboard/types.ts +20 -0
  35. package/src/list-item/index.android.tsx +23 -6
  36. package/src/list-item/index.tsx +20 -4
  37. package/src/list-item/index.web.tsx +60 -0
  38. package/src/list-item/list-item.css +65 -0
  39. package/src/list-item/types.ts +21 -0
  40. package/src/menu/index.android.tsx +22 -9
  41. package/src/menu/index.ios.tsx +26 -11
  42. package/src/menu/index.tsx +26 -10
  43. package/src/menu/list.tsx +25 -10
  44. package/src/menu/menu.css +52 -0
  45. package/src/menu/types.ts +41 -2
  46. package/src/scheme.ts +140 -0
  47. package/src/screen/index.tsx +49 -5
  48. package/src/tab-stack/index.tsx +15 -2
  49. package/src/tabs/index.tsx +2 -1
  50. package/src/tabs/index.web.tsx +29 -4
  51. package/src/tabs/types.ts +20 -2
  52. package/src/text-field/index.android.tsx +30 -6
  53. package/src/text-field/index.ios.tsx +16 -3
  54. package/src/text-field/index.tsx +24 -4
  55. package/src/text-field/inline.tsx +87 -0
  56. package/src/text-field/shared.ts +40 -1
  57. package/src/text-field/types.ts +47 -2
  58. package/src/theme.ts +44 -5
package/src/menu/types.ts CHANGED
@@ -2,12 +2,24 @@ import type {ReactNode} from 'react';
2
2
  import type {IconToken} from '../icons';
3
3
  import type {ButtonProps} from '../button/types';
4
4
 
5
- /** One entry of a `Menu` / `ContextMenu`. */
5
+ /** One entry of a `Menu` / `ContextMenu` / `Fab` menu. */
6
6
  export interface MenuItem {
7
7
  /** Item text. */
8
8
  label: string;
9
9
  /** Leading icon. */
10
10
  icon?: IconToken;
11
+ /**
12
+ * A color dot (`#rrggbb`) in place of the icon, for a palette. Drawn on
13
+ * Android and web; iOS menus render images monochrome, so the dot is not
14
+ * shown there.
15
+ */
16
+ swatch?: string;
17
+ /**
18
+ * The item is the current state (the block's kind, the sort order): a
19
+ * check mark. SwiftUI shows it as a checked `Toggle` item, Compose as a
20
+ * trailing check, the web popup as a tick.
21
+ */
22
+ active?: boolean;
11
23
  /**
12
24
  * `destructive` renders the item in the danger color.
13
25
  * @default 'default'
@@ -21,6 +33,9 @@ export interface MenuItem {
21
33
  onPress?: () => void;
22
34
  }
23
35
 
36
+ /** How the web `Menu` trigger looks. */
37
+ export type MenuTrigger = 'button' | 'link';
38
+
24
39
  /**
25
40
  * Cross-platform dropdown menu opened from a button.
26
41
  *
@@ -28,17 +43,30 @@ export interface MenuItem {
28
43
  * `DropdownMenu` on Android, and a `role="menu"` popup on web. The trigger
29
44
  * looks like the kit's `Button` and takes the same styling props.
30
45
  */
31
- export interface MenuProps extends Pick<ButtonProps, 'variant' | 'size' | 'shape' | 'color' | 'hideLabel' | 'disabled'> {
46
+ export interface MenuProps extends Pick<ButtonProps, 'variant' | 'size' | 'shape' | 'color' | 'tone' | 'hideLabel' | 'disabled'> {
32
47
  /** Trigger text (kept for accessibility when `hideLabel` is set). */
33
48
  label: string;
34
49
  /** Trigger icon. */
35
50
  icon?: IconToken;
36
51
  /** Entries shown when the menu opens. */
37
52
  items: MenuItem[];
53
+ /**
54
+ * Web only: `button` renders the kit's button, `link` a text link like the
55
+ * tab bar's tabs (the icon and the label in the tint), for a menu that
56
+ * sits in a bar.
57
+ * @default 'button'
58
+ */
59
+ trigger?: MenuTrigger;
38
60
  /** Identifier used to locate the trigger in end-to-end tests. */
39
61
  testID?: string;
40
62
  }
41
63
 
64
+ /** A point a `ContextMenu` opens at, in the coordinates of its content. */
65
+ export interface MenuPoint {
66
+ x: number;
67
+ y: number;
68
+ }
69
+
42
70
  /**
43
71
  * Cross-platform context menu attached to arbitrary content.
44
72
  *
@@ -55,6 +83,17 @@ export interface ContextMenuProps {
55
83
  onPress?: () => void;
56
84
  /** Disables the menu. */
57
85
  disabled?: boolean;
86
+ /**
87
+ * Opens the menu at this point whenever it changes, so a canvas can open
88
+ * it where it says it was asked for (a right click it received itself, a
89
+ * press on a block's grip). Relative to the content's top-left corner on
90
+ * Android and web (web also accepts viewport coordinates for content that
91
+ * fills it). iOS has no menu at a point: the long-press stays the only
92
+ * trigger there. Pair with `onDismiss` to clear it once the menu closes.
93
+ */
94
+ at?: MenuPoint | null;
95
+ /** Called when a menu opened by `at` (or a gesture) closes. */
96
+ onDismiss?: () => void;
58
97
  /** Identifier used to locate the trigger in end-to-end tests. */
59
98
  testID?: string;
60
99
  }
package/src/scheme.ts ADDED
@@ -0,0 +1,140 @@
1
+ import type {ColorSchemeName} from 'react-native';
2
+ import {useSyncExternalStore} from 'react';
3
+ import {Appearance, Platform} from 'react-native';
4
+ import {colors} from './theme';
5
+
6
+ /** The scheme the app is drawn in. */
7
+ export type ColorScheme = 'light' | 'dark';
8
+
9
+ /** What an app asks for: the system's scheme, or one forced by the user. */
10
+ export type ColorSchemeMode = 'system' | ColorScheme;
11
+
12
+ /**
13
+ * `localStorage` key under which `setColorScheme` keeps a forced scheme on
14
+ * web, read back by `getThemeBootScript` before the bundle runs.
15
+ */
16
+ export const SCHEME_STORAGE_KEY = 'expo-interface:scheme';
17
+
18
+ type Listener = (preferences: {colorScheme: ColorSchemeName}) => void;
19
+
20
+ /**
21
+ * Web: react-native-web's `Appearance` only mirrors the `prefers-color-scheme`
22
+ * media query and has no `setColorScheme`. Its two methods are replaced at
23
+ * load: `getColorScheme` answers the forced scheme while one is set, and the
24
+ * change listeners (what React Native's `useColorScheme`, `@expo/ui`'s `Host`
25
+ * and the kit's `useColorScheme` subscribe through) hear a forced change as
26
+ * well as a system one.
27
+ */
28
+ let forced: ColorScheme | null = null;
29
+ const listeners = new Set<Listener>();
30
+ let system: Pick<typeof Appearance, 'getColorScheme' | 'addChangeListener'> | undefined;
31
+
32
+ function patchAppearance() {
33
+ if (system) return;
34
+ const original = {
35
+ getColorScheme: Appearance.getColorScheme.bind(Appearance),
36
+ addChangeListener: Appearance.addChangeListener.bind(Appearance),
37
+ };
38
+ system = original;
39
+ Appearance.getColorScheme = () => forced ?? original.getColorScheme();
40
+ Appearance.addChangeListener = (listener: Listener) => {
41
+ listeners.add(listener);
42
+ // A system change is only news while the scheme follows the system.
43
+ const subscription = original.addChangeListener(preferences => {
44
+ if (!forced) listener(preferences);
45
+ });
46
+ return {
47
+ remove() {
48
+ listeners.delete(listener);
49
+ subscription.remove();
50
+ },
51
+ };
52
+ };
53
+ }
54
+
55
+ if (Platform.OS === 'web') patchAppearance();
56
+
57
+ const subscribe = (onChange: () => void) => {
58
+ const subscription = Appearance.addChangeListener(onChange);
59
+ return () => subscription.remove();
60
+ };
61
+
62
+ const snapshot = (): ColorScheme => (Appearance.getColorScheme() === 'dark' ? 'dark' : 'light');
63
+
64
+ /**
65
+ * The color scheme as an external store: `'light'` or `'dark'`, never
66
+ * unspecified. React Native's own `useColorScheme` re-subscribes on every
67
+ * render; on web, where the change is a `matchMedia` event, an ancestor that
68
+ * re-renders during that event (the router's screens do) makes the hook drop
69
+ * and re-add its listener mid-dispatch, and the event never reaches it. One
70
+ * stable subscription does not. Follows `setColorScheme` on every platform.
71
+ */
72
+ export function useColorScheme(): ColorScheme {
73
+ return useSyncExternalStore(subscribe, snapshot, snapshot);
74
+ }
75
+
76
+ const toKebab = (token: string) => token.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`);
77
+
78
+ /**
79
+ * Forces the color scheme, or follows the system again.
80
+ *
81
+ * - iOS and Android: `Appearance.setColorScheme`, so `useColorScheme`, the
82
+ * kit's `useColor` and every native control follow.
83
+ * - Web: the palette of the forced scheme is written inline on the root
84
+ * element as the `--color-*` variables (`tint`/`onTint` are left to
85
+ * `AccentProvider`), `color-scheme` is set so form controls and scrollbars
86
+ * follow, `data-theme` is set for `@expo/ui`'s own styles, the choice is
87
+ * saved under `storageKey` for `getThemeBootScript`, and the `Appearance`
88
+ * listeners hear the change.
89
+ */
90
+ export function setColorScheme(mode: ColorSchemeMode, storageKey = SCHEME_STORAGE_KEY): void {
91
+ if (Platform.OS !== 'web') {
92
+ Appearance.setColorScheme(mode === 'system' ? 'unspecified' : mode);
93
+ return;
94
+ }
95
+ patchAppearance();
96
+ forced = mode === 'system' ? null : mode;
97
+ if (typeof document !== 'undefined') {
98
+ const root = document.documentElement;
99
+ for (const token of Object.keys(colors.light) as (keyof typeof colors.light)[]) {
100
+ if (token === 'tint' || token === 'onTint') continue;
101
+ const name = `--color-${toKebab(token)}`;
102
+ if (forced) root.style.setProperty(name, colors[forced][token]);
103
+ else root.style.removeProperty(name);
104
+ }
105
+ root.style.colorScheme = forced ?? '';
106
+ if (forced) root.dataset.theme = forced;
107
+ else delete root.dataset.theme;
108
+ }
109
+ try {
110
+ if (forced) localStorage.setItem(storageKey, forced);
111
+ else localStorage.removeItem(storageKey);
112
+ } catch {
113
+ // Storage may be unavailable (privacy mode, server render); the scheme still applies.
114
+ }
115
+ const colorScheme: ColorSchemeName = forced ?? system!.getColorScheme() ?? 'light';
116
+ for (const listener of listeners) listener({colorScheme});
117
+ }
118
+
119
+ /**
120
+ * The scheme forced by `setColorScheme` on web, or `'system'`. Natively the
121
+ * forced scheme is `Appearance`'s own and this always answers `'system'`.
122
+ */
123
+ export function getColorSchemeMode(): ColorSchemeMode {
124
+ return forced ?? 'system';
125
+ }
126
+
127
+ /**
128
+ * Inline script for `+html.tsx`: before the bundle runs, applies a scheme
129
+ * saved by `setColorScheme` to the root element (`data-theme` and
130
+ * `color-scheme`), so the first paint of the static HTML is already in that
131
+ * scheme; `getThemeCSS` carries the matching `:root[data-theme]` palettes.
132
+ * A system scheme needs nothing: the variables switch on the media query.
133
+ *
134
+ * ```tsx
135
+ * <script dangerouslySetInnerHTML={{__html: getThemeBootScript()}}/>
136
+ * ```
137
+ */
138
+ export function getThemeBootScript(storageKey = SCHEME_STORAGE_KEY): string {
139
+ return `(function(){try{var t=localStorage.getItem(${JSON.stringify(storageKey)});if(t==='light'||t==='dark'){document.documentElement.dataset.theme=t;document.documentElement.style.colorScheme=t;}}catch(e){}})();`;
140
+ }
@@ -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
  });
@@ -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