expo-interface 0.1.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/LICENSE +21 -0
- package/README.md +227 -0
- package/package.json +78 -0
- package/src/accent.tsx +62 -0
- package/src/button/button.css +118 -0
- package/src/button/index.android.tsx +114 -0
- package/src/button/index.ios.tsx +78 -0
- package/src/button/index.tsx +75 -0
- package/src/button/shared.ts +42 -0
- package/src/button/types.ts +72 -0
- package/src/css.d.ts +2 -0
- package/src/date-time/index.android.tsx +108 -0
- package/src/date-time/index.ios.tsx +54 -0
- package/src/date-time/index.tsx +119 -0
- package/src/date-time/shared.ts +160 -0
- package/src/date-time/types.ts +41 -0
- package/src/field-group/field-group.css +18 -0
- package/src/field-group/index.android.tsx +193 -0
- package/src/field-group/index.tsx +8 -0
- package/src/field-group/index.web.tsx +24 -0
- package/src/fill/index.android.ts +4 -0
- package/src/fill/index.ios.ts +9 -0
- package/src/fill/index.ts +10 -0
- package/src/global.css +17 -0
- package/src/icons.ts +42 -0
- package/src/index.ts +59 -0
- package/src/link.ts +36 -0
- package/src/list-item/index.android.tsx +52 -0
- package/src/list-item/index.tsx +21 -0
- package/src/list-item/types.ts +21 -0
- package/src/picker/index.android.tsx +80 -0
- package/src/picker/index.ios.tsx +48 -0
- package/src/picker/index.tsx +145 -0
- package/src/picker/shared.ts +70 -0
- package/src/picker/types.ts +48 -0
- package/src/progress/index.android.tsx +24 -0
- package/src/progress/index.ios.tsx +20 -0
- package/src/progress/index.tsx +30 -0
- package/src/progress/progress.css +27 -0
- package/src/progress/types.ts +19 -0
- package/src/qr/index.tsx +26 -0
- package/src/router/external-link.tsx +26 -0
- package/src/screen/header.tsx +65 -0
- package/src/screen/host-accent.android.ts +8 -0
- package/src/screen/host-accent.ios.ts +9 -0
- package/src/screen/host-accent.ts +15 -0
- package/src/screen/index.tsx +79 -0
- package/src/sheet/index.android.tsx +25 -0
- package/src/sheet/index.ios.tsx +20 -0
- package/src/sheet/index.tsx +18 -0
- package/src/stack-header/index.tsx +3 -0
- package/src/stack-header/index.web.tsx +33 -0
- package/src/switch/index.android.tsx +60 -0
- package/src/switch/index.ios.tsx +33 -0
- package/src/switch/index.tsx +62 -0
- package/src/switch/types.ts +23 -0
- package/src/tab-stack/index.tsx +21 -0
- package/src/tabs/index.tsx +34 -0
- package/src/tabs/index.web.tsx +114 -0
- package/src/tabs/types.ts +64 -0
- package/src/text-field/index.android.tsx +123 -0
- package/src/text-field/index.ios.tsx +73 -0
- package/src/text-field/index.tsx +78 -0
- package/src/text-field/shared.ts +83 -0
- package/src/text-field/types.ts +64 -0
- package/src/theme.ts +528 -0
- package/src/typography/index.tsx +83 -0
- package/src/typography/index.web.tsx +80 -0
- package/src/typography/types.ts +45 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Android: seed the Compose `Host` so all Material 3 children are themed by
|
|
3
|
+
* the accent-derived palette (`SchemeTonalSpot`), and no-arg
|
|
4
|
+
* `useMaterialColors()` calls inside the subtree read the same palette.
|
|
5
|
+
*/
|
|
6
|
+
export function hostAccentProps(seed: string) {
|
|
7
|
+
return {seedColor: seed};
|
|
8
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import {tint} from '@expo/ui/swift-ui/modifiers';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* iOS: apply the accent seed as a Host-level SwiftUI `tint`, which cascades
|
|
5
|
+
* to every SwiftUI child (buttons, pickers, switches, text fields).
|
|
6
|
+
*/
|
|
7
|
+
export function hostAccentProps(seed: string) {
|
|
8
|
+
return {modifiers: [tint(seed)]};
|
|
9
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extra `Host` props that apply the accent seed to the native UI toolkit.
|
|
3
|
+
* Resolved per platform by Metro:
|
|
4
|
+
* - `.android.ts`: `{seedColor}` — themes all Compose children with the
|
|
5
|
+
* seeded Material 3 palette and publishes it via `HostPaletteContext`.
|
|
6
|
+
* - `.ios.ts`: `{modifiers: [tint(seed)]}` — cascades the accent to all
|
|
7
|
+
* SwiftUI children (standard `.tint` inheritance).
|
|
8
|
+
* - Web (this file): nothing; the accent flows through CSS custom properties.
|
|
9
|
+
*
|
|
10
|
+
* Typed loosely because `seedColor`/`modifiers` are platform-entry props that
|
|
11
|
+
* the universal `Host` type does not declare.
|
|
12
|
+
*/
|
|
13
|
+
export function hostAccentProps(_seed: string): Record<string, never> {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type {ColorSchemeName, ColorValue} from 'react-native';
|
|
2
|
+
import type {Edge} from 'react-native-safe-area-context';
|
|
3
|
+
|
|
4
|
+
import {Host} from '@expo/ui';
|
|
5
|
+
import {useEffect} from 'react';
|
|
6
|
+
import {StatusBar} from 'expo-status-bar';
|
|
7
|
+
import {SafeAreaView} from 'react-native-safe-area-context';
|
|
8
|
+
import {useColorScheme, Appearance, StyleSheet, View} from 'react-native';
|
|
9
|
+
import {setBackgroundColorAsync} from 'expo-system-ui';
|
|
10
|
+
import {useAccentSeed} from '../accent';
|
|
11
|
+
import * as theme from '../theme';
|
|
12
|
+
|
|
13
|
+
import {hostAccentProps} from './host-accent';
|
|
14
|
+
|
|
15
|
+
const CONTENT_EDGES: Edge[] = ['left', 'right', 'bottom'];
|
|
16
|
+
const BG_COLOR: Record<ColorSchemeName, ColorValue> = {
|
|
17
|
+
unspecified: theme.colors.light.background,
|
|
18
|
+
light: theme.colors.light.background,
|
|
19
|
+
dark: theme.colors.dark.background,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
setBackgroundColorAsync(BG_COLOR[Appearance.getColorScheme() ?? 'unspecified']);
|
|
23
|
+
|
|
24
|
+
interface ScreenProps extends React.PropsWithChildren {
|
|
25
|
+
/** Whether to expect an @expo/ui or normal RN component children. */
|
|
26
|
+
native?: boolean;
|
|
27
|
+
/** Screen sits below a stack header — skip redundant top inset/padding. */
|
|
28
|
+
header?: boolean;
|
|
29
|
+
/** Whether to apply a horizontal padding to the screen. */
|
|
30
|
+
gutter?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function Screen({
|
|
34
|
+
children,
|
|
35
|
+
native = false,
|
|
36
|
+
header = false,
|
|
37
|
+
gutter = false,
|
|
38
|
+
}: ScreenProps) {
|
|
39
|
+
const seed = useAccentSeed();
|
|
40
|
+
const scheme = useColorScheme();
|
|
41
|
+
const backgroundColor = BG_COLOR[scheme ?? 'unspecified'];
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
setBackgroundColorAsync(backgroundColor);
|
|
45
|
+
}, [backgroundColor]);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<SafeAreaView
|
|
49
|
+
style={{flex: 1, backgroundColor}}
|
|
50
|
+
edges={header ? CONTENT_EDGES : undefined}>
|
|
51
|
+
<StatusBar style={scheme === 'dark' ? 'light' : 'dark'}/>
|
|
52
|
+
<View style={[styles.root, {paddingTop: header ? 0 : theme.inset.topBar}]}>
|
|
53
|
+
<View style={[styles.content, gutter ? styles.gutter : undefined]}>
|
|
54
|
+
{!native ? children : (
|
|
55
|
+
<Host style={{flex: 1}} {...hostAccentProps(seed)}>
|
|
56
|
+
{children}
|
|
57
|
+
</Host>
|
|
58
|
+
)}
|
|
59
|
+
</View>
|
|
60
|
+
</View>
|
|
61
|
+
</SafeAreaView>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const styles = StyleSheet.create({
|
|
66
|
+
root: {
|
|
67
|
+
flex: 1,
|
|
68
|
+
alignItems: 'center',
|
|
69
|
+
gap: theme.spacing.three,
|
|
70
|
+
},
|
|
71
|
+
content: {
|
|
72
|
+
flex: 1,
|
|
73
|
+
width: '100%',
|
|
74
|
+
maxWidth: theme.bound.contentMaxWidth,
|
|
75
|
+
},
|
|
76
|
+
gutter: {
|
|
77
|
+
paddingHorizontal: theme.spacing.three,
|
|
78
|
+
},
|
|
79
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type {BottomSheetProps} from '@expo/ui';
|
|
2
|
+
|
|
3
|
+
import {HostPaletteContext, useMaterialColors} from '@expo/ui/jetpack-compose';
|
|
4
|
+
import {BottomSheet} from '@expo/ui';
|
|
5
|
+
import {useAccentSeed} from '../accent';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Android: the sheet's internal `Host` is not seeded (no `seedColor` prop is
|
|
9
|
+
* forwarded by `BottomSheet`), so overlay `HostPaletteContext` with the
|
|
10
|
+
* accent-seeded Material 3 palette. Every `useMaterialColors()` consumer in
|
|
11
|
+
* the sheet (text fields, pickers, switches, list rows) then resolves the
|
|
12
|
+
* same palette as the seeded `Screen` Host. Controls that also take explicit
|
|
13
|
+
* accent colors (switch track, dialog tint, cursor) read `useColor('tint')`.
|
|
14
|
+
*/
|
|
15
|
+
export function Sheet({children, ...props}: BottomSheetProps) {
|
|
16
|
+
const seed = useAccentSeed();
|
|
17
|
+
const palette = useMaterialColors({seedColor: seed});
|
|
18
|
+
return (
|
|
19
|
+
<BottomSheet {...props}>
|
|
20
|
+
<HostPaletteContext.Provider value={palette}>
|
|
21
|
+
{children}
|
|
22
|
+
</HostPaletteContext.Provider>
|
|
23
|
+
</BottomSheet>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type {BottomSheetProps} from '@expo/ui';
|
|
2
|
+
|
|
3
|
+
import {tint} from '@expo/ui/swift-ui/modifiers';
|
|
4
|
+
import {BottomSheet} from '@expo/ui';
|
|
5
|
+
import {useAccentSeed} from '../accent';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* iOS: apply the accent seed as a `tint` presentation modifier on the sheet's
|
|
9
|
+
* content, mirroring the Host-level cascade in `Screen` (`hostAccentProps`).
|
|
10
|
+
* Without it, SwiftUI children in the sheet (toggles, pickers, text fields)
|
|
11
|
+
* would render the default systemBlue instead of the user-supplied accent.
|
|
12
|
+
*/
|
|
13
|
+
export function Sheet({children, modifiers, ...props}: BottomSheetProps) {
|
|
14
|
+
const seed = useAccentSeed();
|
|
15
|
+
return (
|
|
16
|
+
<BottomSheet {...props} modifiers={[tint(seed), ...(modifiers ?? [])]}>
|
|
17
|
+
{children}
|
|
18
|
+
</BottomSheet>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type {BottomSheetProps} from '@expo/ui';
|
|
2
|
+
import {BottomSheet} from '@expo/ui';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Accent-aware bottom sheet. `@expo/ui`'s `BottomSheet` mounts its own `Host`
|
|
6
|
+
* without the accent seed (unlike `Screen`, which applies `hostAccentProps`),
|
|
7
|
+
* so sheet content would fall back to the platform default tint. Resolved per
|
|
8
|
+
* platform by Metro:
|
|
9
|
+
* - `.ios.tsx`: prepends a `tint(seed)` presentation modifier so the accent
|
|
10
|
+
* cascades to all SwiftUI children in the sheet.
|
|
11
|
+
* - `.android.tsx`: overlays `HostPaletteContext` with the seeded Material 3
|
|
12
|
+
* palette so `useMaterialColors()` consumers match the seeded `Screen` Host.
|
|
13
|
+
* - Web (this file): accent flows through CSS custom properties; vaul sheet
|
|
14
|
+
* width is constrained via `global.css`.
|
|
15
|
+
*/
|
|
16
|
+
export function Sheet(props: BottomSheetProps) {
|
|
17
|
+
return <BottomSheet {...props}/>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {ScreenHeader} from '../screen/header';
|
|
2
|
+
|
|
3
|
+
interface StackHeaderProps {
|
|
4
|
+
navigation: {goBack: () => void};
|
|
5
|
+
route: {name: string};
|
|
6
|
+
back?: unknown;
|
|
7
|
+
options: {
|
|
8
|
+
title?: string;
|
|
9
|
+
headerTitle?: string | (() => React.ReactNode);
|
|
10
|
+
headerRight?: (props: {tintColor?: string}) => React.ReactNode;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ConstrainedStackHeader({
|
|
15
|
+
navigation,
|
|
16
|
+
route,
|
|
17
|
+
back,
|
|
18
|
+
options,
|
|
19
|
+
}: StackHeaderProps) {
|
|
20
|
+
const title = typeof options.headerTitle === 'string'
|
|
21
|
+
? options.headerTitle
|
|
22
|
+
: options.title ?? route.name;
|
|
23
|
+
|
|
24
|
+
const trailing = options.headerRight?.({});
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<ScreenHeader
|
|
28
|
+
title={title}
|
|
29
|
+
onBack={back ? () => navigation.goBack() : undefined}
|
|
30
|
+
trailing={trailing}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type {SwitchProps} from './types';
|
|
2
|
+
|
|
3
|
+
import {Row, Switch as ComposeSwitch, Text, useMaterialColors} from '@expo/ui/jetpack-compose';
|
|
4
|
+
import {fillMaxWidth, graphicsLayer, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
|
|
5
|
+
import {useColor} from '../theme';
|
|
6
|
+
|
|
7
|
+
// Material 3's Switch has a fixed size; scale it down visually while keeping it
|
|
8
|
+
// pinned to the trailing edge (transformOrigin at the right).
|
|
9
|
+
const SCALE = 0.80;
|
|
10
|
+
|
|
11
|
+
// iOS switches use a white thumb when on; M3 defaults to `onPrimary`.
|
|
12
|
+
const THUMB_ON = '#FFFFFF';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The stock universal switch packs the label and toggle tightly together. Here
|
|
16
|
+
* the row fills the available width and pushes the Material switch to the
|
|
17
|
+
* trailing edge, mirroring the iOS Form row. The "on" state is colored with
|
|
18
|
+
* the live accent seed (matching the iOS Host `tint` cascade and the web
|
|
19
|
+
* `--color-tint` default) instead of the host's tonal M3 primary, so the
|
|
20
|
+
* switch reads identically across platforms — including inside sheets whose
|
|
21
|
+
* native host is not seeded.
|
|
22
|
+
*/
|
|
23
|
+
export function Switch({
|
|
24
|
+
label,
|
|
25
|
+
value,
|
|
26
|
+
onValueChange,
|
|
27
|
+
disabled,
|
|
28
|
+
accentColor,
|
|
29
|
+
testID,
|
|
30
|
+
}: SwitchProps) {
|
|
31
|
+
const colors = useMaterialColors();
|
|
32
|
+
const tint = useColor('tint');
|
|
33
|
+
const toggle = (
|
|
34
|
+
<ComposeSwitch
|
|
35
|
+
value={value}
|
|
36
|
+
onCheckedChange={disabled ? undefined : onValueChange}
|
|
37
|
+
enabled={!disabled}
|
|
38
|
+
colors={{
|
|
39
|
+
checkedTrackColor: accentColor ?? tint,
|
|
40
|
+
checkedThumbColor: THUMB_ON,
|
|
41
|
+
}}
|
|
42
|
+
modifiers={[
|
|
43
|
+
graphicsLayer({scaleX: SCALE, scaleY: SCALE, transformOriginX: 1}),
|
|
44
|
+
...(testID ? [testIDModifier(testID)] : []),
|
|
45
|
+
]}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
if (label == null) return toggle;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Row
|
|
53
|
+
verticalAlignment="center"
|
|
54
|
+
horizontalArrangement="spaceBetween"
|
|
55
|
+
modifiers={[fillMaxWidth()]}>
|
|
56
|
+
<Text color={disabled ? colors.onSurfaceVariant : colors.onSurface}>{label}</Text>
|
|
57
|
+
{toggle}
|
|
58
|
+
</Row>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type {SwitchProps} from './types';
|
|
2
|
+
import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
|
|
3
|
+
|
|
4
|
+
import {Toggle} from '@expo/ui/swift-ui';
|
|
5
|
+
import {disabled as disabledMod, tint} from '@expo/ui/swift-ui/modifiers';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* iOS renders the toggle inline using SwiftUI's `Toggle`, which is exactly the
|
|
9
|
+
* Form row look the other platforms emulate: a leading label with the switch
|
|
10
|
+
* pinned to the trailing edge. Drop it straight into a `FieldGroup.Section`.
|
|
11
|
+
*/
|
|
12
|
+
export function Switch({
|
|
13
|
+
label,
|
|
14
|
+
value,
|
|
15
|
+
onValueChange,
|
|
16
|
+
disabled,
|
|
17
|
+
accentColor,
|
|
18
|
+
testID,
|
|
19
|
+
}: SwitchProps) {
|
|
20
|
+
const modifiers: ViewModifier[] = [];
|
|
21
|
+
if (accentColor) modifiers.push(tint(accentColor));
|
|
22
|
+
if (disabled) modifiers.push(disabledMod(true));
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<Toggle
|
|
26
|
+
isOn={value}
|
|
27
|
+
onIsOnChange={onValueChange}
|
|
28
|
+
label={label}
|
|
29
|
+
modifiers={modifiers}
|
|
30
|
+
testID={testID}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type {SwitchProps} from './types';
|
|
2
|
+
import {Switch as RNSwitch, StyleSheet, View} from 'react-native';
|
|
3
|
+
import {Label} from '../typography';
|
|
4
|
+
import {theme} from '../theme';
|
|
5
|
+
|
|
6
|
+
/** iOS switches use a white thumb in both states; web defaults to a green thumb. */
|
|
7
|
+
const THUMB = '#ffffff';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* On web the row mirrors the native iOS/Android layout: a label on the leading
|
|
11
|
+
* edge and the native React Native Web switch pinned to the trailing edge.
|
|
12
|
+
* The "on" track defaults to the accent tint for iOS parity — on iOS the
|
|
13
|
+
* Host-level `tint` cascade colors the SwiftUI `Toggle` with the seed.
|
|
14
|
+
*/
|
|
15
|
+
export function Switch({
|
|
16
|
+
label,
|
|
17
|
+
value,
|
|
18
|
+
onValueChange,
|
|
19
|
+
disabled,
|
|
20
|
+
accentColor,
|
|
21
|
+
testID,
|
|
22
|
+
style,
|
|
23
|
+
}: SwitchProps) {
|
|
24
|
+
const onColor = accentColor ?? (theme.tint as string);
|
|
25
|
+
const offColor = theme.switchTrack;
|
|
26
|
+
const toggle = (
|
|
27
|
+
<RNSwitch
|
|
28
|
+
value={value}
|
|
29
|
+
onValueChange={onValueChange}
|
|
30
|
+
disabled={disabled}
|
|
31
|
+
thumbColor={THUMB}
|
|
32
|
+
trackColor={{true: onColor, false: offColor}}
|
|
33
|
+
testID={label == null ? testID : undefined}
|
|
34
|
+
{...({activeThumbColor: THUMB} as object)}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
return label != null ? (
|
|
39
|
+
<View style={[styles.row, style]} testID={testID}>
|
|
40
|
+
<Label color="label" style={[styles.label, disabled && styles.disabled]}>
|
|
41
|
+
{label}
|
|
42
|
+
</Label>
|
|
43
|
+
{toggle}
|
|
44
|
+
</View>
|
|
45
|
+
) : toggle;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const styles = StyleSheet.create({
|
|
49
|
+
row: {
|
|
50
|
+
flexDirection: 'row',
|
|
51
|
+
alignItems: 'center',
|
|
52
|
+
justifyContent: 'space-between',
|
|
53
|
+
gap: 8,
|
|
54
|
+
width: '100%',
|
|
55
|
+
},
|
|
56
|
+
label: {
|
|
57
|
+
flexShrink: 1,
|
|
58
|
+
},
|
|
59
|
+
disabled: {
|
|
60
|
+
opacity: 0.4,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type {StyleProp, ViewStyle} from 'react-native';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cross-platform toggle with a conformed iOS-style appearance.
|
|
5
|
+
*
|
|
6
|
+
* A controlled control: pair `value` with `onValueChange` to manage state.
|
|
7
|
+
*/
|
|
8
|
+
export interface SwitchProps {
|
|
9
|
+
/** Label rendered at the leading edge of the row, mirroring an iOS Form row. */
|
|
10
|
+
label?: string;
|
|
11
|
+
/** Whether the switch is on. */
|
|
12
|
+
value: boolean;
|
|
13
|
+
/** Called when the user toggles the switch. */
|
|
14
|
+
onValueChange: (value: boolean) => void;
|
|
15
|
+
/** Disables interaction. */
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
/** Tint applied to the "on" track (overrides the theme accent tint). */
|
|
18
|
+
accentColor?: string;
|
|
19
|
+
/** Identifier used to locate the component in end-to-end tests. */
|
|
20
|
+
testID?: string;
|
|
21
|
+
/** Style applied to the row container (web/android only). */
|
|
22
|
+
style?: StyleProp<ViewStyle>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {Stack} from 'expo-router';
|
|
2
|
+
import {Platform} from 'react-native';
|
|
3
|
+
import {useNavTheme} from '../theme';
|
|
4
|
+
|
|
5
|
+
export function TabStack({title}: {title: string}) {
|
|
6
|
+
const {colors} = useNavTheme();
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<Stack
|
|
10
|
+
screenOptions={{
|
|
11
|
+
headerShown: Platform.OS !== 'web',
|
|
12
|
+
headerShadowVisible: false,
|
|
13
|
+
headerBackButtonDisplayMode: 'minimal',
|
|
14
|
+
headerTintColor: colors.text,
|
|
15
|
+
headerTitleStyle: {color: colors.text},
|
|
16
|
+
headerStyle: {backgroundColor: colors.background},
|
|
17
|
+
}}>
|
|
18
|
+
<Stack.Screen name="index" options={{title}}/>
|
|
19
|
+
</Stack>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type {TabBarProps} from './types';
|
|
2
|
+
import {NativeTabs} from 'expo-router/unstable-native-tabs';
|
|
3
|
+
import {useColor} from '../theme';
|
|
4
|
+
|
|
5
|
+
export function Tabs({routes}: TabBarProps) {
|
|
6
|
+
const rippleColor = useColor('pillBackground');
|
|
7
|
+
const indicatorColor = useColor('backgroundElement');
|
|
8
|
+
const labelColor = useColor('label');
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<NativeTabs
|
|
12
|
+
backgroundColor="transparent"
|
|
13
|
+
indicatorColor={indicatorColor}
|
|
14
|
+
rippleColor={rippleColor}
|
|
15
|
+
labelStyle={{selected: {color: labelColor}}}
|
|
16
|
+
// Monochrome selected icon to match the label (and the web tab bar);
|
|
17
|
+
// without it iOS falls back to the default system tint.
|
|
18
|
+
iconColor={{selected: labelColor}}>
|
|
19
|
+
{routes.map(route => (
|
|
20
|
+
<NativeTabs.Trigger
|
|
21
|
+
key={route.name}
|
|
22
|
+
name={route.name}>
|
|
23
|
+
<NativeTabs.Trigger.Label>
|
|
24
|
+
{route.label}
|
|
25
|
+
</NativeTabs.Trigger.Label>
|
|
26
|
+
<NativeTabs.Trigger.Icon
|
|
27
|
+
sf={route.icon.ios}
|
|
28
|
+
md={route.icon.android}
|
|
29
|
+
/>
|
|
30
|
+
</NativeTabs.Trigger>
|
|
31
|
+
))}
|
|
32
|
+
</NativeTabs>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type {TabTriggerSlotProps, TabListProps} from 'expo-router/ui';
|
|
2
|
+
import type {TabBarProps, TabRoute, WebLogo} from './types';
|
|
3
|
+
|
|
4
|
+
import {Tabs as WebTabs, TabSlot, TabList, TabTrigger} from 'expo-router/ui';
|
|
5
|
+
import {View, Pressable, StyleSheet} from 'react-native';
|
|
6
|
+
import {SymbolView} from 'expo-symbols';
|
|
7
|
+
import {Image} from 'expo-image';
|
|
8
|
+
import app from 'expo-constants';
|
|
9
|
+
|
|
10
|
+
import {theme, spacing, bound} from '../theme';
|
|
11
|
+
import {Headline, Label} from '../typography';
|
|
12
|
+
|
|
13
|
+
export function Tabs({routes, webLogo = 'icon-and-text', webIcon}: TabBarProps) {
|
|
14
|
+
return (
|
|
15
|
+
<WebTabs>
|
|
16
|
+
<TabSlot style={styles.slot}/>
|
|
17
|
+
<TabList asChild>
|
|
18
|
+
<WebTabList logo={webLogo} icon={webIcon}>
|
|
19
|
+
{routes.map(route => (
|
|
20
|
+
<TabTrigger key={route.name} name={route.name} href={route.href} asChild>
|
|
21
|
+
<TabLink icon={route.icon}>{route.label}</TabLink>
|
|
22
|
+
</TabTrigger>
|
|
23
|
+
))}
|
|
24
|
+
</WebTabList>
|
|
25
|
+
</TabList>
|
|
26
|
+
</WebTabs>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function WebTabList({logo, icon, ...props}: TabListProps & {logo: WebLogo, icon?: TabBarProps['webIcon']}) {
|
|
31
|
+
const isPreset = typeof logo === 'string';
|
|
32
|
+
const isTextOnly = logo === 'text-only';
|
|
33
|
+
const isIconOnly = logo === 'icon-only';
|
|
34
|
+
return (
|
|
35
|
+
<View {...props} style={styles.list}>
|
|
36
|
+
<View style={styles.inner}>
|
|
37
|
+
<View style={styles.logo}>
|
|
38
|
+
{!isPreset ? logo : (
|
|
39
|
+
<>
|
|
40
|
+
{!isTextOnly && icon != null && (
|
|
41
|
+
<Image
|
|
42
|
+
style={styles.icon}
|
|
43
|
+
source={icon}
|
|
44
|
+
contentFit="contain"
|
|
45
|
+
/>
|
|
46
|
+
)}
|
|
47
|
+
{!isIconOnly && (
|
|
48
|
+
<Headline color="label">
|
|
49
|
+
{app.expoConfig?.name}
|
|
50
|
+
</Headline>
|
|
51
|
+
)}
|
|
52
|
+
</>
|
|
53
|
+
)}
|
|
54
|
+
</View>
|
|
55
|
+
{props.children}
|
|
56
|
+
</View>
|
|
57
|
+
</View>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function TabLink({children, isFocused, icon, ...props}: TabTriggerSlotProps & {icon: TabRoute['icon']}) {
|
|
62
|
+
return (
|
|
63
|
+
<Pressable {...props} style={({pressed}) => pressed && styles.pressed}>
|
|
64
|
+
<View style={styles.link}>
|
|
65
|
+
<SymbolView name={icon} size={18} tintColor={isFocused ? theme.label : theme.secondaryLabel}/>
|
|
66
|
+
<Label color={isFocused ? 'label' : 'secondaryLabel'}>{children}</Label>
|
|
67
|
+
</View>
|
|
68
|
+
</Pressable>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const styles = StyleSheet.create({
|
|
73
|
+
slot: {
|
|
74
|
+
height: '100%',
|
|
75
|
+
},
|
|
76
|
+
list: {
|
|
77
|
+
position: 'absolute',
|
|
78
|
+
flexDirection: 'row',
|
|
79
|
+
justifyContent: 'center',
|
|
80
|
+
alignItems: 'center',
|
|
81
|
+
width: '100%',
|
|
82
|
+
padding: spacing.three,
|
|
83
|
+
},
|
|
84
|
+
inner: {
|
|
85
|
+
flexGrow: 1,
|
|
86
|
+
flexDirection: 'row',
|
|
87
|
+
alignItems: 'center',
|
|
88
|
+
maxWidth: bound.contentMaxWidth,
|
|
89
|
+
paddingHorizontal: spacing.five,
|
|
90
|
+
paddingVertical: spacing.three,
|
|
91
|
+
gap: spacing.three,
|
|
92
|
+
borderRadius: spacing.five,
|
|
93
|
+
backgroundColor: theme.backgroundElement,
|
|
94
|
+
},
|
|
95
|
+
logo: {
|
|
96
|
+
flexDirection: 'row',
|
|
97
|
+
alignItems: 'center',
|
|
98
|
+
marginRight: 'auto',
|
|
99
|
+
gap: spacing.two,
|
|
100
|
+
},
|
|
101
|
+
icon: {
|
|
102
|
+
width: 24,
|
|
103
|
+
height: 24,
|
|
104
|
+
borderRadius: spacing.two,
|
|
105
|
+
},
|
|
106
|
+
link: {
|
|
107
|
+
flexDirection: 'row',
|
|
108
|
+
alignItems: 'center',
|
|
109
|
+
gap: spacing.two,
|
|
110
|
+
},
|
|
111
|
+
pressed: {
|
|
112
|
+
opacity: 0.7,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type {Href} from 'expo-router';
|
|
2
|
+
import type {SFSymbol, AndroidSymbol} from 'expo-symbols';
|
|
3
|
+
import type {ReactNode} from 'react';
|
|
4
|
+
import type {ImageSource} from 'expo-image';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Props for the tab bar component.
|
|
8
|
+
* @see Tabs
|
|
9
|
+
*/
|
|
10
|
+
export interface TabBarProps {
|
|
11
|
+
/**
|
|
12
|
+
* Configures the tab bar items for native & web.
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* const routes: readonly TabRoute[] = [
|
|
16
|
+
* {
|
|
17
|
+
* href: '/',
|
|
18
|
+
* name: 'index',
|
|
19
|
+
* label: 'Home',
|
|
20
|
+
* icon: {ios: 'house', android: 'home', web: 'home'},
|
|
21
|
+
* },
|
|
22
|
+
* {
|
|
23
|
+
* href: '/settings',
|
|
24
|
+
* name: 'settings',
|
|
25
|
+
* label: 'Settings',
|
|
26
|
+
* icon: {ios: 'gearshape', android: 'settings', web: 'settings'},
|
|
27
|
+
* },
|
|
28
|
+
* ];
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
routes: readonly TabRoute[];
|
|
32
|
+
/**
|
|
33
|
+
* 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.
|
|
35
|
+
* @default 'icon-and-text'
|
|
36
|
+
*/
|
|
37
|
+
webLogo?: WebLogo;
|
|
38
|
+
/**
|
|
39
|
+
* App icon rendered by the `icon-only` and `icon-and-text` web logo presets,
|
|
40
|
+
* e.g. `require('./assets/icon.png')`. When omitted only the name is shown.
|
|
41
|
+
*/
|
|
42
|
+
webIcon?: ImageSource | number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type WebLogo =
|
|
46
|
+
| 'icon-only'
|
|
47
|
+
| 'text-only'
|
|
48
|
+
| 'icon-and-text'
|
|
49
|
+
| ReactNode;
|
|
50
|
+
|
|
51
|
+
export interface TabRoute {
|
|
52
|
+
/** Route segment, matching the file name in `src/app`. */
|
|
53
|
+
name: string;
|
|
54
|
+
/** Navigation target used by the web tab bar. */
|
|
55
|
+
href: Href;
|
|
56
|
+
/** Visible tab label. */
|
|
57
|
+
label: string;
|
|
58
|
+
/** Symbol name per platform. */
|
|
59
|
+
icon: {
|
|
60
|
+
ios: SFSymbol;
|
|
61
|
+
android: AndroidSymbol;
|
|
62
|
+
web: AndroidSymbol;
|
|
63
|
+
};
|
|
64
|
+
}
|