expo-interface 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -20
- package/package.json +9 -9
- package/src/accent.tsx +21 -8
- package/src/alert/alert.css +3 -3
- package/src/alert/index.tsx +1 -2
- package/src/button/button.css +12 -0
- package/src/button/index.android.tsx +14 -3
- package/src/button/index.ios.tsx +13 -4
- package/src/button/index.tsx +8 -1
- package/src/button/types.ts +20 -0
- package/src/checkbox/index.tsx +1 -1
- package/src/color-picker/color-picker.css +136 -0
- package/src/color-picker/index.android.tsx +145 -0
- package/src/color-picker/index.ios.tsx +80 -0
- package/src/color-picker/index.tsx +112 -0
- package/src/color-picker/shared.ts +204 -0
- package/src/color-picker/sheet.tsx +432 -0
- package/src/color-picker/types.ts +40 -0
- 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/field-group.css +23 -7
- package/src/field-group/index.android.tsx +44 -38
- package/src/field-group/index.tsx +36 -5
- package/src/field-group/index.web.tsx +27 -11
- package/src/field-group/shared.tsx +49 -0
- package/src/field-group/types.ts +25 -0
- package/src/gauge/gauge.css +200 -0
- package/src/gauge/index.android.tsx +212 -0
- package/src/gauge/index.ios.tsx +40 -0
- package/src/gauge/index.tsx +165 -0
- package/src/gauge/shared.ts +79 -0
- package/src/gauge/types.ts +64 -0
- package/src/header-menu/index.tsx +76 -0
- package/src/host/index.tsx +32 -0
- package/src/index.ts +35 -6
- 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/progress/progress.css +9 -0
- package/src/scheme.ts +140 -0
- package/src/screen/header.tsx +1 -0
- package/src/screen/index.tsx +49 -5
- package/src/segmented/index.tsx +1 -1
- package/src/segmented/segmented.css +6 -7
- package/src/slider/index.tsx +1 -1
- package/src/stepper/index.tsx +1 -1
- package/src/switch/index.tsx +2 -0
- 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/qr/index.tsx +0 -26
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type {ModifierConfig} from '@expo/ui/jetpack-compose/modifiers';
|
|
2
|
+
import type {FabProps, FabSize} from './types';
|
|
3
|
+
|
|
4
|
+
import {useState} from 'react';
|
|
5
|
+
import {
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
ExtendedFloatingActionButton,
|
|
8
|
+
FloatingActionButton,
|
|
9
|
+
Icon,
|
|
10
|
+
LargeFloatingActionButton,
|
|
11
|
+
SmallFloatingActionButton,
|
|
12
|
+
Text,
|
|
13
|
+
} from '@expo/ui/jetpack-compose';
|
|
14
|
+
import {alpha, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
|
|
15
|
+
import {NativeHost} from '../host';
|
|
16
|
+
import {MenuItems} from '../menu/index.android';
|
|
17
|
+
import {useColor} from '../theme';
|
|
18
|
+
import {FAB_ICON} from './shared';
|
|
19
|
+
|
|
20
|
+
const VARIANT: Record<FabSize, typeof FloatingActionButton> = {
|
|
21
|
+
small: SmallFloatingActionButton,
|
|
22
|
+
regular: FloatingActionButton,
|
|
23
|
+
large: LargeFloatingActionButton,
|
|
24
|
+
extended: ExtendedFloatingActionButton,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Android renders the Material 3 `FloatingActionButton` family, filled with
|
|
29
|
+
* the accent (the container color) and the icon in `onTint`, inside its own
|
|
30
|
+
* accent-seeded host so it can float over a React Native screen. With
|
|
31
|
+
* `items` the button is the trigger of the same Compose `DropdownMenu` the
|
|
32
|
+
* kit's `Menu` anchors, so the menu is shared and only the trigger differs.
|
|
33
|
+
*/
|
|
34
|
+
export function Fab({label, icon, onPress, items, size = 'regular', disabled, testID}: FabProps) {
|
|
35
|
+
const tint = useColor('tint');
|
|
36
|
+
const onTint = useColor('onTint');
|
|
37
|
+
const [expanded, setExpanded] = useState(false);
|
|
38
|
+
const Component = VARIANT[size];
|
|
39
|
+
const modifiers: ModifierConfig[] = [];
|
|
40
|
+
if (disabled) modifiers.push(alpha(0.4));
|
|
41
|
+
if (testID) modifiers.push(testIDModifier(testID));
|
|
42
|
+
const press = items ? () => setExpanded(true) : onPress;
|
|
43
|
+
|
|
44
|
+
const button = (
|
|
45
|
+
<Component containerColor={tint} onClick={disabled ? undefined : press} modifiers={modifiers}>
|
|
46
|
+
<FloatingActionButton.Icon>
|
|
47
|
+
{icon.drawable ? (
|
|
48
|
+
<Icon source={icon.drawable} size={FAB_ICON[size]} tint={onTint} contentDescription={label}/>
|
|
49
|
+
) : (
|
|
50
|
+
// No drawable registered for this icon (see `icon()`): the label stands in.
|
|
51
|
+
<Text color={onTint}>{label.slice(0, 1)}</Text>
|
|
52
|
+
)}
|
|
53
|
+
</FloatingActionButton.Icon>
|
|
54
|
+
{size === 'extended' ? (
|
|
55
|
+
<ExtendedFloatingActionButton.Text>
|
|
56
|
+
<Text color={onTint} style={{fontWeight: '600'}}>{label}</Text>
|
|
57
|
+
</ExtendedFloatingActionButton.Text>
|
|
58
|
+
) : null}
|
|
59
|
+
</Component>
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<NativeHost fit>
|
|
64
|
+
{items ? (
|
|
65
|
+
<DropdownMenu expanded={expanded} onDismissRequest={() => setExpanded(false)}>
|
|
66
|
+
<DropdownMenu.Trigger>{button}</DropdownMenu.Trigger>
|
|
67
|
+
<MenuItems items={items} onClose={() => setExpanded(false)}/>
|
|
68
|
+
</DropdownMenu>
|
|
69
|
+
) : button}
|
|
70
|
+
</NativeHost>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
|
|
2
|
+
import type {FabProps} from './types';
|
|
3
|
+
|
|
4
|
+
import {Button, HStack, Image, Menu as SwiftUIMenu, Text} from '@expo/ui/swift-ui';
|
|
5
|
+
import {
|
|
6
|
+
accessibilityLabel,
|
|
7
|
+
background,
|
|
8
|
+
buttonStyle,
|
|
9
|
+
disabled as disabledMod,
|
|
10
|
+
font,
|
|
11
|
+
foregroundStyle,
|
|
12
|
+
frame,
|
|
13
|
+
opacity,
|
|
14
|
+
padding,
|
|
15
|
+
shadow,
|
|
16
|
+
shapes,
|
|
17
|
+
} from '@expo/ui/swift-ui/modifiers';
|
|
18
|
+
import {iosSymbol} from '../button/shared';
|
|
19
|
+
import {NativeHost} from '../host';
|
|
20
|
+
import {MenuItems} from '../menu/index.ios';
|
|
21
|
+
import {useColor} from '../theme';
|
|
22
|
+
import {FAB_EXTENDED_PADDING, FAB_GAP, FAB_ICON, FAB_SIZE} from './shared';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* iOS has no floating action button, so it is drawn in SwiftUI: a circle
|
|
26
|
+
* (56 pt, 40 small, 96 large) or a capsule when extended, filled with the
|
|
27
|
+
* tint, the icon in `onTint`, a soft shadow, as the label of a plain
|
|
28
|
+
* `Button`; with `items`, as the label of a SwiftUI `Menu`, whose entries are
|
|
29
|
+
* the ones the kit's `Menu` renders.
|
|
30
|
+
*/
|
|
31
|
+
export function Fab({label, icon, onPress, items, size = 'regular', disabled, testID}: FabProps) {
|
|
32
|
+
const tint = useColor('tint');
|
|
33
|
+
const onTint = useColor('onTint');
|
|
34
|
+
const circle = size !== 'extended';
|
|
35
|
+
const dimension = FAB_SIZE[size];
|
|
36
|
+
const faceModifiers: ViewModifier[] = [
|
|
37
|
+
circle ? frame({width: dimension, height: dimension}) : frame({height: dimension}),
|
|
38
|
+
];
|
|
39
|
+
if (!circle) faceModifiers.push(padding({horizontal: FAB_EXTENDED_PADDING}));
|
|
40
|
+
faceModifiers.push(
|
|
41
|
+
background(tint, circle ? shapes.circle() : shapes.capsule()),
|
|
42
|
+
shadow({radius: 4, y: 2, color: 'rgba(0, 0, 0, 0.25)'}),
|
|
43
|
+
);
|
|
44
|
+
const face = (
|
|
45
|
+
<HStack spacing={FAB_GAP} modifiers={faceModifiers}>
|
|
46
|
+
<Image systemName={iosSymbol(icon)} color={onTint} size={FAB_ICON[size]}/>
|
|
47
|
+
{circle ? null : (
|
|
48
|
+
<Text modifiers={[foregroundStyle({type: 'color', color: onTint}), font({weight: 'semibold'})]}>{label}</Text>
|
|
49
|
+
)}
|
|
50
|
+
</HStack>
|
|
51
|
+
);
|
|
52
|
+
const modifiers: ViewModifier[] = [buttonStyle('plain'), accessibilityLabel(label)];
|
|
53
|
+
if (disabled) modifiers.push(disabledMod(true), opacity(0.4));
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<NativeHost fit>
|
|
57
|
+
{items ? (
|
|
58
|
+
<SwiftUIMenu label={face} modifiers={modifiers} testID={testID}>
|
|
59
|
+
<MenuItems items={items}/>
|
|
60
|
+
</SwiftUIMenu>
|
|
61
|
+
) : (
|
|
62
|
+
<Button onPress={onPress} modifiers={modifiers} testID={testID}>
|
|
63
|
+
{face}
|
|
64
|
+
</Button>
|
|
65
|
+
)}
|
|
66
|
+
</NativeHost>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import './fab.css';
|
|
2
|
+
import type {CSSProperties} from 'react';
|
|
3
|
+
import type {FabProps} from './types';
|
|
4
|
+
import {useId, useRef} from 'react';
|
|
5
|
+
import {SymbolView} from 'expo-symbols';
|
|
6
|
+
import {MenuList, menuIdent} from '../menu/list';
|
|
7
|
+
import {FAB_ICON} from './shared';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* On web the button is a `<button>` with the Material geometry (a circle or
|
|
11
|
+
* an extended capsule, filled with the tint) styled via `fab.css`. With
|
|
12
|
+
* `items` it carries a `popovertarget` to the kit's popup menu, the same
|
|
13
|
+
* `MenuList` the `Menu` opens, anchored above the button by CSS anchor
|
|
14
|
+
* positioning. Where it floats is the screen's job (`Screen`'s `fab` slot).
|
|
15
|
+
*/
|
|
16
|
+
export function Fab({label, icon, onPress, items, size = 'regular', disabled, testID}: FabProps) {
|
|
17
|
+
const ident = menuIdent(useId());
|
|
18
|
+
const anchor = `--${ident}`;
|
|
19
|
+
const wrapper = useRef<HTMLSpanElement>(null);
|
|
20
|
+
const extended = size === 'extended';
|
|
21
|
+
return (
|
|
22
|
+
<span ref={wrapper} className="ui-fab__anchor" style={{anchorName: anchor} as CSSProperties}>
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
className={`ui-fab ui-fab--${size}`}
|
|
26
|
+
aria-label={extended ? undefined : label}
|
|
27
|
+
disabled={disabled}
|
|
28
|
+
onClick={items ? undefined : onPress}
|
|
29
|
+
popoverTarget={items ? ident : undefined}
|
|
30
|
+
data-testid={testID}>
|
|
31
|
+
<SymbolView name={icon.symbol} size={FAB_ICON[size]} tintColor="currentColor"/>
|
|
32
|
+
{extended ? <span className="ui-fab__label">{label}</span> : null}
|
|
33
|
+
</button>
|
|
34
|
+
{items ? <MenuList id={ident} items={items} anchor={anchor} anchorRef={wrapper}/> : null}
|
|
35
|
+
</span>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type {FabSize} from './types';
|
|
2
|
+
|
|
3
|
+
/** Diameter of the circular sizes and height of the extended one, in points (Material's). */
|
|
4
|
+
export const FAB_SIZE: Record<FabSize, number> = {
|
|
5
|
+
small: 40,
|
|
6
|
+
regular: 56,
|
|
7
|
+
large: 96,
|
|
8
|
+
extended: 56,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Icon size per `FabSize`. */
|
|
12
|
+
export const FAB_ICON: Record<FabSize, number> = {
|
|
13
|
+
small: 24,
|
|
14
|
+
regular: 24,
|
|
15
|
+
large: 36,
|
|
16
|
+
extended: 24,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Horizontal padding of the extended capsule. */
|
|
20
|
+
export const FAB_EXTENDED_PADDING = 20;
|
|
21
|
+
|
|
22
|
+
/** Gap between the icon and the label of the extended one. */
|
|
23
|
+
export const FAB_GAP = 12;
|
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
|
+
}
|
|
@@ -7,12 +7,28 @@
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
/*
|
|
10
|
-
* @expo/ui
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* @expo/ui paints inverted iOS grouped-list colors on web (grey page, white
|
|
11
|
+
* cards in light mode). Match native instead: the page is the screen
|
|
12
|
+
* background and the cards the raised element color, in both schemes, so the
|
|
13
|
+
* cards follow the kit palette rather than @expo/ui's own (which switches on
|
|
14
|
+
* `prefers-color-scheme` and can disagree with a forced scheme).
|
|
15
|
+
*
|
|
16
|
+
* The section card reads `--expo-ui-background`, so remap that variable here
|
|
17
|
+
* rather than matching react-native-web class names: those carry the property
|
|
18
|
+
* name (`r-borderRadius-…`) only in development and are hashed to `r-…` in a
|
|
19
|
+
* production build. Nothing else the kit renders inside a group reads it.
|
|
13
20
|
*/
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
.field-group {
|
|
22
|
+
--expo-ui-background: var(--color-background-element);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* Row dividers: @expo/ui paints them in its gray-100, which is within a few
|
|
27
|
+
* shades of the card color above in both schemes. The selected-background
|
|
28
|
+
* token is one step away from the card in either scheme: visible, not heavy.
|
|
29
|
+
* `role="separator"` is the only stable hook; kit overlays inside a row (a
|
|
30
|
+
* Menu's own separator) carry `ui-*` classes and keep their color.
|
|
31
|
+
*/
|
|
32
|
+
.field-group [role='separator']:not([class^='ui-']) {
|
|
33
|
+
background-color: var(--color-background-selected);
|
|
18
34
|
}
|
|
@@ -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,12 +56,14 @@ 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');
|
|
63
|
+
const label = useColor('label');
|
|
62
64
|
if (hidden) return null;
|
|
63
65
|
|
|
64
|
-
const {header, footer, rows} = extractSlots(children);
|
|
66
|
+
const {header, footer, rows} = extractSlots(children, label);
|
|
65
67
|
const headerNode = header ?? (title ? (
|
|
66
68
|
<Text
|
|
67
69
|
color={subtle}
|
|
@@ -69,6 +71,11 @@ function Section({children, title, titleUppercase = false, hidden}: FieldSection
|
|
|
69
71
|
{titleUppercase ? title.toUpperCase() : title}
|
|
70
72
|
</Text>
|
|
71
73
|
) : null);
|
|
74
|
+
const footerNode = footer ?? (footerText != null ? (
|
|
75
|
+
<Text color={footerColor === 'destructive' ? danger : subtle} style={{typography: 'bodySmall'}}>
|
|
76
|
+
{footerText}
|
|
77
|
+
</Text>
|
|
78
|
+
) : null);
|
|
72
79
|
|
|
73
80
|
return (
|
|
74
81
|
<Column verticalArrangement={{spacedBy: 4}} modifiers={[fillMaxWidth()]}>
|
|
@@ -93,7 +100,7 @@ function Section({children, title, titleUppercase = false, hidden}: FieldSection
|
|
|
93
100
|
))}
|
|
94
101
|
</Column>
|
|
95
102
|
) : null}
|
|
96
|
-
{
|
|
103
|
+
{footerNode ? <Column modifiers={[padding(16, 4, 16, 0)]}>{footerNode}</Column> : null}
|
|
97
104
|
</Column>
|
|
98
105
|
);
|
|
99
106
|
}
|
|
@@ -104,7 +111,7 @@ export const FieldGroup = Object.assign(FieldGroupBase, {
|
|
|
104
111
|
SectionFooter,
|
|
105
112
|
});
|
|
106
113
|
|
|
107
|
-
export type {FieldGroupProps};
|
|
114
|
+
export type {FieldGroupProps, FieldGroupSectionProps};
|
|
108
115
|
|
|
109
116
|
/**
|
|
110
117
|
* Per-position corner radii producing the Material 3 grouped-list look:
|
|
@@ -118,33 +125,33 @@ function cornerRadii(index: number, total: number) {
|
|
|
118
125
|
return {topStart: top, topEnd: top, bottomStart: bottom, bottomEnd: bottom};
|
|
119
126
|
}
|
|
120
127
|
|
|
121
|
-
/**
|
|
122
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Pulls `SectionHeader`/`SectionFooter` slots out of a section's children.
|
|
130
|
+
* Empty children (`null`, booleans) are dropped, and raw strings or numbers
|
|
131
|
+
* become a `Text` row, since Compose slots cannot render them directly.
|
|
132
|
+
*/
|
|
133
|
+
function extractSlots(children: ReactNode, textColor: string) {
|
|
123
134
|
let header: ReactNode | undefined;
|
|
124
135
|
let footer: ReactNode | undefined;
|
|
125
136
|
const rows: ReactNode[] = [];
|
|
126
137
|
|
|
127
138
|
const walk = (node: ReactNode) => {
|
|
128
|
-
Children.
|
|
139
|
+
for (const child of Children.toArray(node)) {
|
|
129
140
|
if (!isValidElement(child)) {
|
|
130
|
-
rows.push(child);
|
|
131
|
-
|
|
141
|
+
rows.push(<Text color={textColor}>{String(child)}</Text>);
|
|
142
|
+
continue;
|
|
132
143
|
}
|
|
133
144
|
const props = child.props as {children?: ReactNode};
|
|
134
145
|
if (child.type === SectionHeader) {
|
|
135
146
|
header = props.children;
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
if (child.type === SectionFooter) {
|
|
147
|
+
} else if (child.type === SectionFooter) {
|
|
139
148
|
footer = props.children;
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
if (child.type === Fragment) {
|
|
149
|
+
} else if (child.type === Fragment) {
|
|
143
150
|
walk(props.children);
|
|
144
|
-
|
|
151
|
+
} else {
|
|
152
|
+
rows.push(child);
|
|
145
153
|
}
|
|
146
|
-
|
|
147
|
-
});
|
|
154
|
+
}
|
|
148
155
|
};
|
|
149
156
|
|
|
150
157
|
walk(children);
|
|
@@ -175,13 +182,12 @@ function groupChildren(children: ReactNode): ReactNode[] {
|
|
|
175
182
|
return;
|
|
176
183
|
}
|
|
177
184
|
if (isValidElement(child) && child.type === Fragment) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
+
// The recursion yields sections only (loose rows come back wrapped in
|
|
186
|
+
// their own implicit section), so they close any open implicit section.
|
|
187
|
+
const nested = groupChildren((child.props as {children?: ReactNode}).children);
|
|
188
|
+
if (nested.length > 0) {
|
|
189
|
+
flush();
|
|
190
|
+
result.push(...nested);
|
|
185
191
|
}
|
|
186
192
|
return;
|
|
187
193
|
}
|
|
@@ -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
|
-
* scroll background so the wrapper supplies the app palette; section cards
|
|
7
|
-
*
|
|
19
|
+
* scroll background so the wrapper supplies the app palette; section cards
|
|
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};
|