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.
- package/README.md +87 -10
- package/package.json +6 -1
- package/src/accent.tsx +21 -8
- package/src/button/button.css +6 -0
- package/src/button/index.android.tsx +5 -1
- package/src/button/index.ios.tsx +5 -1
- package/src/button/index.tsx +6 -1
- package/src/button/types.ts +13 -0
- package/src/color-picker/color-picker.css +70 -0
- package/src/color-picker/index.android.tsx +77 -49
- package/src/color-picker/index.ios.tsx +58 -11
- package/src/color-picker/index.tsx +65 -15
- package/src/color-picker/types.ts +9 -3
- 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/index.android.tsx +22 -16
- package/src/field-group/index.tsx +36 -5
- package/src/field-group/index.web.tsx +25 -9
- package/src/field-group/shared.tsx +49 -0
- package/src/field-group/types.ts +25 -0
- package/src/header-menu/index.tsx +76 -0
- package/src/host/index.tsx +32 -0
- package/src/index.ts +31 -5
- 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/scheme.ts +140 -0
- package/src/screen/index.tsx +49 -5
- 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/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
|
+
}
|
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/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
|
|