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/fab/types.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type {IconToken} from '../icons';
|
|
2
|
+
import type {MenuItem} from '../menu/types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Size of a `Fab`. `regular`, `small` and `large` are circles (56, 40 and
|
|
6
|
+
* 96 points); `extended` is a capsule that shows the label beside the icon.
|
|
7
|
+
*/
|
|
8
|
+
export type FabSize = 'small' | 'regular' | 'large' | 'extended';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Floating action button: the screen's primary action, floating over its
|
|
12
|
+
* content at the bottom trailing corner (`Screen`'s `fab` slot places it).
|
|
13
|
+
*
|
|
14
|
+
* Android renders the Material 3 `FloatingActionButton` family; iOS, which
|
|
15
|
+
* has no such control, draws the same geometry in SwiftUI (a circle or
|
|
16
|
+
* capsule filled with the tint, the icon in `onTint`, a soft shadow); web
|
|
17
|
+
* renders a DOM button with that geometry. With `items` the button opens a
|
|
18
|
+
* menu instead of pressing: the same menu `Menu` renders, only the trigger
|
|
19
|
+
* differs.
|
|
20
|
+
*/
|
|
21
|
+
export interface FabProps {
|
|
22
|
+
/** Accessibility name of the button, and the text of an `extended` one. */
|
|
23
|
+
label: string;
|
|
24
|
+
/** The icon. */
|
|
25
|
+
icon: IconToken;
|
|
26
|
+
/** Called when the button is pressed (ignored when `items` are given). */
|
|
27
|
+
onPress?: () => void;
|
|
28
|
+
/** With items the button opens a menu instead of pressing. */
|
|
29
|
+
items?: MenuItem[];
|
|
30
|
+
/**
|
|
31
|
+
* The size.
|
|
32
|
+
* @default 'regular'
|
|
33
|
+
*/
|
|
34
|
+
size?: FabSize;
|
|
35
|
+
/** Disables interaction and dims the button. */
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
/** Identifier used to locate the button in end-to-end tests. */
|
|
38
|
+
testID?: string;
|
|
39
|
+
}
|
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import type {ReactNode, ReactElement} from 'react';
|
|
2
|
-
import type {
|
|
3
|
-
|
|
4
|
-
FieldSectionProps,
|
|
5
|
-
FieldSectionHeaderProps,
|
|
6
|
-
FieldSectionFooterProps,
|
|
7
|
-
} from '@expo/ui';
|
|
2
|
+
import type {FieldSectionHeaderProps, FieldSectionFooterProps} from '@expo/ui';
|
|
3
|
+
import type {FieldGroupProps, FieldGroupSectionProps} from './types';
|
|
8
4
|
import {Children, Fragment, isValidElement} from 'react';
|
|
9
|
-
import {Box, Column,
|
|
5
|
+
import {Box, Column, Text} from '@expo/ui/jetpack-compose';
|
|
10
6
|
import {
|
|
11
7
|
background,
|
|
12
8
|
clip,
|
|
@@ -15,6 +11,7 @@ import {
|
|
|
15
11
|
padding,
|
|
16
12
|
Shapes,
|
|
17
13
|
testID as testIDModifier,
|
|
14
|
+
verticalScroll,
|
|
18
15
|
type ModifierConfig,
|
|
19
16
|
} from '@expo/ui/jetpack-compose/modifiers';
|
|
20
17
|
import {useColor} from '../theme';
|
|
@@ -22,6 +19,12 @@ import {useColor} from '../theme';
|
|
|
22
19
|
/**
|
|
23
20
|
* Android `FieldGroup`. Mirrors `@expo/ui`'s Material 3 connected-list
|
|
24
21
|
* layout, with app deviations for web parity:
|
|
22
|
+
* - the group is a scrolling `Column`, not a `LazyColumn`: a lazy list
|
|
23
|
+
* disposes and recomposes its items as it scrolls and on any state change,
|
|
24
|
+
* and every React Native view hosted inside an item is then added to the
|
|
25
|
+
* view tree a second time while still attached ("The specified child
|
|
26
|
+
* already has a parent"). A settings form has a dozen rows and does not
|
|
27
|
+
* need laziness; composed once, its rows can hold anything the kit offers;
|
|
25
28
|
* - the group has no background (the universal one paints the Host palette's
|
|
26
29
|
* `surface`, a grey panel over the app's screen background);
|
|
27
30
|
* - rows use the `backgroundElement` token instead of the seeded
|
|
@@ -33,16 +36,13 @@ import {useColor} from '../theme';
|
|
|
33
36
|
*/
|
|
34
37
|
function FieldGroupBase({children, style, hidden, testID}: FieldGroupProps) {
|
|
35
38
|
if (hidden) return null;
|
|
36
|
-
const modifiers: ModifierConfig[] = [];
|
|
39
|
+
const modifiers: ModifierConfig[] = [fillMaxWidth(), verticalScroll(), padding(16, 16, 16, 16)];
|
|
37
40
|
if (style?.backgroundColor) modifiers.push(background(String(style.backgroundColor)));
|
|
38
41
|
if (testID) modifiers.push(testIDModifier(testID));
|
|
39
42
|
return (
|
|
40
|
-
<
|
|
41
|
-
verticalArrangement={{spacedBy: 24}}
|
|
42
|
-
contentPadding={{start: 16, end: 16, top: 16, bottom: 16}}
|
|
43
|
-
modifiers={modifiers}>
|
|
43
|
+
<Column verticalArrangement={{spacedBy: 24}} modifiers={modifiers}>
|
|
44
44
|
{groupChildren(children)}
|
|
45
|
-
</
|
|
45
|
+
</Column>
|
|
46
46
|
);
|
|
47
47
|
}
|
|
48
48
|
|
|
@@ -56,9 +56,10 @@ function SectionFooter(props: FieldSectionFooterProps) {
|
|
|
56
56
|
return <>{props.children}</>;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
function Section({children, title, titleUppercase = false, hidden}:
|
|
59
|
+
function Section({children, title, titleUppercase = false, hidden, footer: footerText, footerColor = 'secondaryLabel'}: FieldGroupSectionProps) {
|
|
60
60
|
const card = useColor('backgroundElement');
|
|
61
61
|
const subtle = useColor('secondaryLabel');
|
|
62
|
+
const danger = useColor('destructive');
|
|
62
63
|
const label = useColor('label');
|
|
63
64
|
if (hidden) return null;
|
|
64
65
|
|
|
@@ -70,6 +71,11 @@ function Section({children, title, titleUppercase = false, hidden}: FieldSection
|
|
|
70
71
|
{titleUppercase ? title.toUpperCase() : title}
|
|
71
72
|
</Text>
|
|
72
73
|
) : null);
|
|
74
|
+
const footerNode = footer ?? (footerText != null ? (
|
|
75
|
+
<Text color={footerColor === 'destructive' ? danger : subtle} style={{typography: 'bodySmall'}}>
|
|
76
|
+
{footerText}
|
|
77
|
+
</Text>
|
|
78
|
+
) : null);
|
|
73
79
|
|
|
74
80
|
return (
|
|
75
81
|
<Column verticalArrangement={{spacedBy: 4}} modifiers={[fillMaxWidth()]}>
|
|
@@ -94,7 +100,7 @@ function Section({children, title, titleUppercase = false, hidden}: FieldSection
|
|
|
94
100
|
))}
|
|
95
101
|
</Column>
|
|
96
102
|
) : null}
|
|
97
|
-
{
|
|
103
|
+
{footerNode ? <Column modifiers={[padding(16, 4, 16, 0)]}>{footerNode}</Column> : null}
|
|
98
104
|
</Column>
|
|
99
105
|
);
|
|
100
106
|
}
|
|
@@ -105,7 +111,7 @@ export const FieldGroup = Object.assign(FieldGroupBase, {
|
|
|
105
111
|
SectionFooter,
|
|
106
112
|
});
|
|
107
113
|
|
|
108
|
-
export type {FieldGroupProps};
|
|
114
|
+
export type {FieldGroupProps, FieldGroupSectionProps};
|
|
109
115
|
|
|
110
116
|
/**
|
|
111
117
|
* Per-position corner radii producing the Material 3 grouped-list look:
|
|
@@ -1,8 +1,39 @@
|
|
|
1
|
+
import type {FieldGroupProps, FieldGroupSectionProps, FieldSectionFooterColor} from './types';
|
|
2
|
+
import {FieldGroup as Base} from '@expo/ui';
|
|
3
|
+
import {Text} from '@expo/ui/swift-ui';
|
|
4
|
+
import {font, foregroundStyle} from '@expo/ui/swift-ui/modifiers';
|
|
5
|
+
import {useColor} from '../theme';
|
|
6
|
+
import {baseSection, mapSections} from './shared';
|
|
7
|
+
|
|
1
8
|
/**
|
|
2
9
|
* App `FieldGroup`: a scrollable container of grouped settings-style rows.
|
|
3
|
-
* iOS uses `@expo/ui`'s SwiftUI `Form
|
|
4
|
-
* the
|
|
5
|
-
* (`index.
|
|
6
|
-
*
|
|
10
|
+
* iOS (this file) uses `@expo/ui`'s SwiftUI `Form`, whose `Section` gains
|
|
11
|
+
* the `footer` prop as the native section footer slot. Android
|
|
12
|
+
* (`index.android.tsx`) keeps the Material 3 connected-list look with app
|
|
13
|
+
* palette colors. Web (`index.web.tsx` + `field-group.css`) re-themes the
|
|
14
|
+
* universal component via CSS instead of forking its layout.
|
|
7
15
|
*/
|
|
8
|
-
|
|
16
|
+
|
|
17
|
+
/** The section `footer` as SwiftUI text, in the footnote size and the token's color. */
|
|
18
|
+
function FooterText({color, children}: {color: FieldSectionFooterColor; children: string}) {
|
|
19
|
+
const resolved = useColor(color);
|
|
20
|
+
return <Text modifiers={[foregroundStyle({type: 'color', color: resolved}), font({size: 13})]}>{children}</Text>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const renderFooter = (footer: string, color: FieldSectionFooterColor) => <FooterText color={color}>{footer}</FooterText>;
|
|
24
|
+
|
|
25
|
+
function Section(props: FieldGroupSectionProps) {
|
|
26
|
+
return baseSection(props, renderFooter);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function FieldGroupBase({children, ...props}: FieldGroupProps) {
|
|
30
|
+
return <Base {...props}>{mapSections(children, Section, renderFooter)}</Base>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const FieldGroup = Object.assign(FieldGroupBase, {
|
|
34
|
+
Section,
|
|
35
|
+
SectionHeader: Base.SectionHeader,
|
|
36
|
+
SectionFooter: Base.SectionFooter,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export type {FieldGroupProps, FieldGroupSectionProps};
|
|
@@ -1,24 +1,40 @@
|
|
|
1
1
|
import './field-group.css';
|
|
2
|
-
import {
|
|
2
|
+
import type {FieldGroupProps, FieldGroupSectionProps, FieldSectionFooterColor} from './types';
|
|
3
|
+
import {FieldGroup as Base} from '@expo/ui';
|
|
4
|
+
import {Footnote} from '../typography';
|
|
5
|
+
import {baseSection, mapSections} from './shared';
|
|
6
|
+
|
|
7
|
+
const renderFooter = (footer: string, color: FieldSectionFooterColor) => <Footnote color={color}>{footer}</Footnote>;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Web: the universal `FieldGroup.Section` with the `footer` prop as its
|
|
11
|
+
* footer slot, drawn as the kit's `Footnote`.
|
|
12
|
+
*/
|
|
13
|
+
function Section(props: FieldGroupSectionProps) {
|
|
14
|
+
return baseSection(props, renderFooter);
|
|
15
|
+
}
|
|
3
16
|
|
|
4
17
|
/**
|
|
5
18
|
* Web hook for `field-group.css`. Clears the universal component's hardcoded
|
|
6
19
|
* scroll background so the wrapper supplies the app palette; section cards
|
|
7
20
|
* and row dividers are recolored to the kit tokens in the CSS.
|
|
8
21
|
*/
|
|
9
|
-
function
|
|
22
|
+
function FieldGroupBase({style, children, ...props}: FieldGroupProps) {
|
|
10
23
|
return (
|
|
11
24
|
<div className="field-group">
|
|
12
|
-
<
|
|
25
|
+
<Base
|
|
13
26
|
{...props}
|
|
14
|
-
style={{...style, backgroundColor: 'transparent'}}
|
|
15
|
-
|
|
27
|
+
style={{...style, backgroundColor: 'transparent'}}>
|
|
28
|
+
{mapSections(children, Section, renderFooter)}
|
|
29
|
+
</Base>
|
|
16
30
|
</div>
|
|
17
31
|
);
|
|
18
32
|
}
|
|
19
33
|
|
|
20
|
-
FieldGroup
|
|
21
|
-
|
|
22
|
-
|
|
34
|
+
export const FieldGroup = Object.assign(FieldGroupBase, {
|
|
35
|
+
Section,
|
|
36
|
+
SectionHeader: Base.SectionHeader,
|
|
37
|
+
SectionFooter: Base.SectionFooter,
|
|
38
|
+
});
|
|
23
39
|
|
|
24
|
-
export {
|
|
40
|
+
export type {FieldGroupProps, FieldGroupSectionProps};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type {ComponentType, ReactElement, ReactNode} from 'react';
|
|
2
|
+
import type {FieldGroupSectionProps, FieldSectionFooterColor} from './types';
|
|
3
|
+
import {Children, Fragment, isValidElement} from 'react';
|
|
4
|
+
import {FieldGroup as Base} from '@expo/ui';
|
|
5
|
+
|
|
6
|
+
/** Renders the `footer` prop's text in the platform's own text control. */
|
|
7
|
+
export type FooterRenderer = (footer: string, color: FieldSectionFooterColor) => ReactNode;
|
|
8
|
+
|
|
9
|
+
/** Whether the section's children already carry a `SectionFooter` slot. */
|
|
10
|
+
export function hasFooterSlot(children: ReactNode): boolean {
|
|
11
|
+
return Children.toArray(children).some(child => isValidElement(child) && child.type === Base.SectionFooter);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The universal `FieldGroup.Section` with the `footer` prop turned into a
|
|
16
|
+
* `SectionFooter` slot (unless the children carry one already).
|
|
17
|
+
*/
|
|
18
|
+
export function baseSection(
|
|
19
|
+
{footer, footerColor = 'secondaryLabel', children, ...props}: FieldGroupSectionProps,
|
|
20
|
+
renderFooter: FooterRenderer,
|
|
21
|
+
): ReactElement {
|
|
22
|
+
const note = footer != null && !hasFooterSlot(children) ? (
|
|
23
|
+
<Base.SectionFooter>{renderFooter(footer, footerColor)}</Base.SectionFooter>
|
|
24
|
+
) : null;
|
|
25
|
+
return (
|
|
26
|
+
<Base.Section {...props}>
|
|
27
|
+
{children}
|
|
28
|
+
{note}
|
|
29
|
+
</Base.Section>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The universal `FieldGroup` groups loose rows into implicit sections by
|
|
35
|
+
* checking each child's type against its own `Section`, so a kit `Section`
|
|
36
|
+
* wrapping it would be grouped as a row. The kit sections are therefore
|
|
37
|
+
* replaced by the universal ones (with the footer slot) before the children
|
|
38
|
+
* reach the group; fragments are walked like the group walks them.
|
|
39
|
+
*/
|
|
40
|
+
export function mapSections(children: ReactNode, Section: ComponentType<FieldGroupSectionProps>, renderFooter: FooterRenderer): ReactNode {
|
|
41
|
+
return Children.map(children, child => {
|
|
42
|
+
if (!isValidElement(child)) return child;
|
|
43
|
+
if (child.type === Section) return baseSection(child.props as FieldGroupSectionProps, renderFooter);
|
|
44
|
+
if (child.type === Fragment) {
|
|
45
|
+
return <Fragment>{mapSections((child.props as {children?: ReactNode}).children, Section, renderFooter)}</Fragment>;
|
|
46
|
+
}
|
|
47
|
+
return child;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type {FieldGroupProps, FieldSectionProps} from '@expo/ui';
|
|
2
|
+
|
|
3
|
+
/** Color of a section's `footer` text. */
|
|
4
|
+
export type FieldSectionFooterColor = 'secondaryLabel' | 'destructive';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Props of `FieldGroup.Section`: `@expo/ui`'s, plus a plain-text footer.
|
|
8
|
+
* SwiftUI's `Section` has a footer, the web draws a caption under the group
|
|
9
|
+
* and Android under the rows, so a note under a section (what a setting
|
|
10
|
+
* does, why it failed) is one prop on every platform.
|
|
11
|
+
*/
|
|
12
|
+
export interface FieldGroupSectionProps extends FieldSectionProps {
|
|
13
|
+
/**
|
|
14
|
+
* A note under the rows, in the secondary color. Ignored when a
|
|
15
|
+
* `<FieldGroup.SectionFooter>` child is given.
|
|
16
|
+
*/
|
|
17
|
+
footer?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Color of the footer text: `destructive` for an error.
|
|
20
|
+
* @default 'secondaryLabel'
|
|
21
|
+
*/
|
|
22
|
+
footerColor?: FieldSectionFooterColor;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type {FieldGroupProps};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type {ButtonTone} from '../button/types';
|
|
2
|
+
import type {IconToken} from '../icons';
|
|
3
|
+
import type {MenuItem} from '../menu/types';
|
|
4
|
+
import {Platform} from 'react-native';
|
|
5
|
+
import {useIsFocused} from 'expo-router';
|
|
6
|
+
import {NativeHost} from '../host';
|
|
7
|
+
import {Menu} from '../menu';
|
|
8
|
+
|
|
9
|
+
export interface HeaderMenuProps {
|
|
10
|
+
/** Trigger text (kept for accessibility when `hideLabel` is set). */
|
|
11
|
+
label: string;
|
|
12
|
+
/** Trigger icon. */
|
|
13
|
+
icon?: IconToken;
|
|
14
|
+
/** Entries shown when the menu opens. */
|
|
15
|
+
items: MenuItem[];
|
|
16
|
+
/** Show only the icon; `label` is kept for accessibility. */
|
|
17
|
+
hideLabel?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Color of the trigger: the accent, or the label color like the header's
|
|
20
|
+
* own buttons.
|
|
21
|
+
* @default 'accent'
|
|
22
|
+
*/
|
|
23
|
+
tone?: ButtonTone;
|
|
24
|
+
/** Disables the trigger. */
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
/** Identifier used to locate the trigger in end-to-end tests. */
|
|
27
|
+
testID?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A `Menu` for a stack header's trailing slot (`TabStack`'s `headerRight`,
|
|
32
|
+
* a `Stack.Screen`'s `headerRight` option): a small text trigger in its own
|
|
33
|
+
* accent-seeded host, so it can live in the React Native header.
|
|
34
|
+
*
|
|
35
|
+
* On Android the native stack re-parents the header's views on a tab switch,
|
|
36
|
+
* and a Compose view refuses a second parent ("The specified child already
|
|
37
|
+
* has a parent"). The host is therefore keyed on the screen's focus, so the
|
|
38
|
+
* Compose view is created afresh each time the header is rebuilt rather
|
|
39
|
+
* than re-added. Needs a navigator above it on Android (it reads the
|
|
40
|
+
* screen's focus); on web it is the plain `Menu` trigger, for a custom
|
|
41
|
+
* header such as `ConstrainedStackHeader`.
|
|
42
|
+
*/
|
|
43
|
+
export function HeaderMenu(props: HeaderMenuProps) {
|
|
44
|
+
if (Platform.OS === 'web') return <HeaderMenuTrigger {...props}/>;
|
|
45
|
+
if (Platform.OS === 'android') return <AndroidHeaderMenu {...props}/>;
|
|
46
|
+
return (
|
|
47
|
+
<NativeHost fit>
|
|
48
|
+
<HeaderMenuTrigger {...props}/>
|
|
49
|
+
</NativeHost>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function HeaderMenuTrigger({label, icon, items, hideLabel, tone = 'accent', disabled, testID}: HeaderMenuProps) {
|
|
54
|
+
return (
|
|
55
|
+
<Menu
|
|
56
|
+
label={label}
|
|
57
|
+
icon={icon}
|
|
58
|
+
items={items}
|
|
59
|
+
hideLabel={hideLabel}
|
|
60
|
+
tone={tone}
|
|
61
|
+
disabled={disabled}
|
|
62
|
+
variant="text"
|
|
63
|
+
size="small"
|
|
64
|
+
testID={testID}
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function AndroidHeaderMenu(props: HeaderMenuProps) {
|
|
70
|
+
// Keyed on the screen's focus: a fresh host, and Compose view, per rebuild.
|
|
71
|
+
return (
|
|
72
|
+
<NativeHost key={useIsFocused() ? 'focused' : 'blurred'} fit>
|
|
73
|
+
<HeaderMenuTrigger {...props}/>
|
|
74
|
+
</NativeHost>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type {PropsWithChildren} from 'react';
|
|
2
|
+
import type {StyleProp, ViewStyle} from 'react-native';
|
|
3
|
+
import {Host} from '@expo/ui';
|
|
4
|
+
import {useAccentSeed} from '../accent';
|
|
5
|
+
import {hostAccentProps} from '../screen/host-accent';
|
|
6
|
+
|
|
7
|
+
export interface NativeHostProps extends PropsWithChildren {
|
|
8
|
+
style?: StyleProp<ViewStyle>;
|
|
9
|
+
/**
|
|
10
|
+
* Size the host to its content on both axes (a group of buttons in a row);
|
|
11
|
+
* by default only vertically, the width filling its container.
|
|
12
|
+
* @default false
|
|
13
|
+
*/
|
|
14
|
+
fit?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* An accent-seeded `@expo/ui` `Host` for controls that sit inside a React
|
|
19
|
+
* Native layout: a toolbar next to a canvas, a search row, a floating
|
|
20
|
+
* button. `Screen native` mounts the same host around a whole screen; this
|
|
21
|
+
* one is for the places a screen cannot be native, sized to its content and
|
|
22
|
+
* seeded like the screen would be (`hostAccentProps`). On web it is a plain
|
|
23
|
+
* view carrying the `@expo/ui` palette.
|
|
24
|
+
*/
|
|
25
|
+
export function NativeHost({children, style, fit = false}: NativeHostProps) {
|
|
26
|
+
const seed = useAccentSeed();
|
|
27
|
+
return (
|
|
28
|
+
<Host matchContents={fit ? true : {vertical: true}} style={style} {...hostAccentProps(seed)}>
|
|
29
|
+
{children}
|
|
30
|
+
</Host>
|
|
31
|
+
);
|
|
32
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,22 +4,36 @@ export * from './theme';
|
|
|
4
4
|
export * from './accent';
|
|
5
5
|
export * from './icons';
|
|
6
6
|
export {fillWidth} from './fill';
|
|
7
|
+
export {
|
|
8
|
+
SCHEME_STORAGE_KEY,
|
|
9
|
+
getColorSchemeMode,
|
|
10
|
+
getThemeBootScript,
|
|
11
|
+
setColorScheme,
|
|
12
|
+
useColorScheme,
|
|
13
|
+
} from './scheme';
|
|
14
|
+
export type {ColorScheme, ColorSchemeMode} from './scheme';
|
|
7
15
|
|
|
8
16
|
// Layout
|
|
9
17
|
export {Screen} from './screen';
|
|
18
|
+
export type {ScreenProps} from './screen';
|
|
10
19
|
export {ScreenHeader} from './screen/header';
|
|
11
20
|
export {hostAccentProps} from './screen/host-accent';
|
|
21
|
+
export {NativeHost} from './host';
|
|
22
|
+
export type {NativeHostProps} from './host';
|
|
12
23
|
export {Sheet} from './sheet';
|
|
13
24
|
export {ConstrainedStackHeader} from './stack-header';
|
|
14
25
|
export {TabStack} from './tab-stack';
|
|
26
|
+
export type {TabStackProps} from './tab-stack';
|
|
15
27
|
export {Tabs} from './tabs';
|
|
16
28
|
export type {TabBarProps, TabRoute, WebLogo} from './tabs/types';
|
|
29
|
+
export {KeyboardBar} from './keyboard';
|
|
30
|
+
export type {KeyboardBarProps, KeyboardLibrary, KeyboardState} from './keyboard';
|
|
17
31
|
|
|
18
32
|
// Components
|
|
19
33
|
export {Alert} from './alert';
|
|
20
34
|
export type {AlertAction, AlertActionRole, AlertProps} from './alert/types';
|
|
21
35
|
export {Button} from './button';
|
|
22
|
-
export type {ButtonProps, ButtonRole, ButtonShape, ButtonSize, ButtonVariant} from './button/types';
|
|
36
|
+
export type {ButtonProps, ButtonRole, ButtonShape, ButtonSize, ButtonTone, ButtonVariant} from './button/types';
|
|
23
37
|
export {Checkbox} from './checkbox';
|
|
24
38
|
export type {CheckboxProps} from './checkbox/types';
|
|
25
39
|
export {Collapsible} from './collapsible';
|
|
@@ -31,13 +45,18 @@ export {DateTimePicker} from './date-time';
|
|
|
31
45
|
export type {DateTimeMode, DateTimePickerProps} from './date-time/types';
|
|
32
46
|
export {Divider} from './divider';
|
|
33
47
|
export type {DividerProps} from './divider/types';
|
|
34
|
-
export {
|
|
48
|
+
export {Fab} from './fab';
|
|
49
|
+
export type {FabProps, FabSize} from './fab/types';
|
|
50
|
+
export {FieldGroup} from './field-group';
|
|
51
|
+
export type {FieldGroupProps, FieldGroupSectionProps, FieldSectionFooterColor} from './field-group/types';
|
|
35
52
|
export {Gauge} from './gauge';
|
|
36
53
|
export type {GaugeProps, GaugeVariant} from './gauge/types';
|
|
54
|
+
export {HeaderMenu} from './header-menu';
|
|
55
|
+
export type {HeaderMenuProps} from './header-menu';
|
|
37
56
|
export {ListItem} from './list-item';
|
|
38
|
-
export type {ListItemProps} from './list-item/types';
|
|
57
|
+
export type {ListItemAction, ListItemProps} from './list-item/types';
|
|
39
58
|
export {Menu} from './menu';
|
|
40
|
-
export type {ContextMenuProps, MenuItem, MenuProps} from './menu/types';
|
|
59
|
+
export type {ContextMenuProps, MenuItem, MenuPoint, MenuProps, MenuTrigger} from './menu/types';
|
|
41
60
|
export {Picker} from './picker';
|
|
42
61
|
export type {PickerItemProps, PickerOption, PickerProps, PickerValue} from './picker/types';
|
|
43
62
|
export {Progress} from './progress';
|
|
@@ -53,7 +72,14 @@ export type {SwitchProps} from './switch/types';
|
|
|
53
72
|
export {Tooltip} from './tooltip';
|
|
54
73
|
export type {TooltipProps} from './tooltip/types';
|
|
55
74
|
export {TextField} from './text-field';
|
|
56
|
-
export type {
|
|
75
|
+
export type {
|
|
76
|
+
TextFieldCapitalize,
|
|
77
|
+
TextFieldKeyboard,
|
|
78
|
+
TextFieldProps,
|
|
79
|
+
TextFieldReturnKey,
|
|
80
|
+
TextFieldSubmitBehavior,
|
|
81
|
+
TextFieldVariant,
|
|
82
|
+
} from './text-field/types';
|
|
57
83
|
export {ExternalLink} from './router/external-link';
|
|
58
84
|
|
|
59
85
|
// Typography
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type {PropsWithChildren} from 'react';
|
|
2
|
+
import type {StyleProp, ViewStyle} from 'react-native';
|
|
3
|
+
import type {KeyboardLibrary} from './types';
|
|
4
|
+
import {useCallback, useEffect, useRef, useState} from 'react';
|
|
5
|
+
import {StyleSheet, useWindowDimensions, View} from 'react-native';
|
|
6
|
+
import {useColor} from '../theme';
|
|
7
|
+
import {loadKeyboardController} from './library';
|
|
8
|
+
|
|
9
|
+
/** The keyboard library, when the app has it: loaded once, natively only. */
|
|
10
|
+
const library = loadKeyboardController();
|
|
11
|
+
|
|
12
|
+
export interface KeyboardBarProps extends PropsWithChildren {
|
|
13
|
+
/**
|
|
14
|
+
* The keyboard's height from the screen's bottom edge once it is up, `0`
|
|
15
|
+
* once it is away, for the screen's content to pad or scroll by (it is
|
|
16
|
+
* not resized: the bar rides over it).
|
|
17
|
+
*/
|
|
18
|
+
onKeyboard?: (height: number) => void;
|
|
19
|
+
/** Style of the bar (its background is the screen's, opaque). */
|
|
20
|
+
style?: StyleProp<ViewStyle>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A bottom bar that sticks to the keyboard: it rides up on it by a
|
|
25
|
+
* transform animated with the keyboard on the UI thread, so the layout never
|
|
26
|
+
* changes (no resize, no lines shaking with the animation), and reports the
|
|
27
|
+
* keyboard's height instead so the content can keep its caret above it.
|
|
28
|
+
* Opaque in `background`, since it rides over the content's bottom.
|
|
29
|
+
*
|
|
30
|
+
* Needs `react-native-keyboard-controller` (an optional peer, whose
|
|
31
|
+
* `KeyboardProvider` the kit's `AccentProvider` mounts natively); without it,
|
|
32
|
+
* and on web, where the browser keeps the page above the keyboard itself,
|
|
33
|
+
* the bar is a plain view.
|
|
34
|
+
*/
|
|
35
|
+
export function KeyboardBar({children, onKeyboard, style}: KeyboardBarProps) {
|
|
36
|
+
const background = useColor('background');
|
|
37
|
+
if (!library) {
|
|
38
|
+
return <View style={[{backgroundColor: background}, style]}>{children}</View>;
|
|
39
|
+
}
|
|
40
|
+
return (
|
|
41
|
+
<Sticky library={library} background={background} onKeyboard={onKeyboard} style={style}>
|
|
42
|
+
{children}
|
|
43
|
+
</Sticky>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface StickyProps extends KeyboardBarProps {
|
|
48
|
+
library: KeyboardLibrary;
|
|
49
|
+
background: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function Sticky({library: {KeyboardStickyView, useKeyboardState}, background, children, onKeyboard, style}: StickyProps) {
|
|
53
|
+
const height = useKeyboardState(s => (s.isVisible ? s.height : 0));
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
onKeyboard?.(height);
|
|
56
|
+
}, [height, onKeyboard]);
|
|
57
|
+
// The keyboard's height is from the window's bottom edge, the bar sits
|
|
58
|
+
// above the navigation inset: the sticky translation is corrected by what
|
|
59
|
+
// lies under the bar, measured while the keyboard is away (a measure while
|
|
60
|
+
// it is up would see the bar moved).
|
|
61
|
+
const window = useWindowDimensions().height;
|
|
62
|
+
const view = useRef<View>(null);
|
|
63
|
+
const [below, setBelow] = useState(0);
|
|
64
|
+
const onLayout = useCallback(() => {
|
|
65
|
+
if (height > 0) return;
|
|
66
|
+
view.current?.measureInWindow((_x, y, _w, h) => setBelow(Math.max(0, window - (y + h))));
|
|
67
|
+
}, [height, window]);
|
|
68
|
+
return (
|
|
69
|
+
<KeyboardStickyView offset={{opened: below}}>
|
|
70
|
+
<View ref={view} onLayout={onLayout} style={[styles.bar, {backgroundColor: background}, style]}>
|
|
71
|
+
{children}
|
|
72
|
+
</View>
|
|
73
|
+
</KeyboardStickyView>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const styles = StyleSheet.create({
|
|
78
|
+
bar: {width: '100%'},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export type {KeyboardLibrary, KeyboardState} from './types';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type {KeyboardLibrary} from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* iOS and Android: `react-native-keyboard-controller` when the app has it
|
|
5
|
+
* installed (an optional peer), `null` otherwise. The `require` sits in a
|
|
6
|
+
* `try` so Metro treats the dependency as optional and a bundle without the
|
|
7
|
+
* library still builds; `KeyboardBar` and `AccentProvider` then fall back to
|
|
8
|
+
* plain views.
|
|
9
|
+
*/
|
|
10
|
+
export function loadKeyboardController(): KeyboardLibrary | null {
|
|
11
|
+
try {
|
|
12
|
+
// eslint-disable-next-line typescript/no-require-imports -- optional peer, resolved only when installed.
|
|
13
|
+
return require('react-native-keyboard-controller') as KeyboardLibrary;
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type {KeyboardLibrary} from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Web (and any platform without a native keyboard): nothing is loaded. The
|
|
5
|
+
* browser keeps the page above the keyboard itself, and
|
|
6
|
+
* `react-native-keyboard-controller` must never reach the web bundle: it is
|
|
7
|
+
* built on Reanimated, whose server render (the static export) calls
|
|
8
|
+
* `requestAnimationFrame` and dies. `library.native.ts` loads it natively.
|
|
9
|
+
*/
|
|
10
|
+
export function loadKeyboardController(): KeyboardLibrary | null {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type {ComponentType, PropsWithChildren} from 'react';
|
|
2
|
+
|
|
3
|
+
/** What `react-native-keyboard-controller` reports about the keyboard. */
|
|
4
|
+
export interface KeyboardState {
|
|
5
|
+
isVisible: boolean;
|
|
6
|
+
/** The keyboard's height from the window's bottom edge. */
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The pieces of `react-native-keyboard-controller` the kit uses. The library
|
|
12
|
+
* is an optional peer: `KeyboardBar` sticks to the keyboard through it when
|
|
13
|
+
* it is installed and is a plain view otherwise (and always on web, where
|
|
14
|
+
* the library's Reanimated cannot render on the server).
|
|
15
|
+
*/
|
|
16
|
+
export interface KeyboardLibrary {
|
|
17
|
+
KeyboardProvider: ComponentType<PropsWithChildren>;
|
|
18
|
+
KeyboardStickyView: ComponentType<PropsWithChildren<{offset?: {closed?: number; opened?: number}}>>;
|
|
19
|
+
useKeyboardState: <T>(selector: (state: KeyboardState) => T) => T;
|
|
20
|
+
}
|