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,21 @@
|
|
|
1
|
+
import type {ListItemProps} from './types';
|
|
2
|
+
import {ListItem as UIListItem} from '@expo/ui';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Web/iOS render the universal `@expo/ui` `ListItem` (RN row on web, SwiftUI
|
|
6
|
+
* list row on iOS) through explicit slot props.
|
|
7
|
+
*/
|
|
8
|
+
export function ListItem({children, leading, trailing, supporting, onPress, testID}: ListItemProps) {
|
|
9
|
+
return (
|
|
10
|
+
<UIListItem
|
|
11
|
+
onPress={onPress}
|
|
12
|
+
leading={leading}
|
|
13
|
+
trailing={trailing}
|
|
14
|
+
supportingText={supporting}
|
|
15
|
+
testID={testID}>
|
|
16
|
+
{children}
|
|
17
|
+
</UIListItem>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type {ListItemProps} from './types';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type {ReactNode} from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Props for the app `ListItem`: a tappable row with leading/trailing slots
|
|
5
|
+
* and optional supporting text. Bridges the universal `@expo/ui` `ListItem`
|
|
6
|
+
* on web/iOS and the Material 3 Compose `ListItem` on Android.
|
|
7
|
+
*/
|
|
8
|
+
export interface ListItemProps {
|
|
9
|
+
/** Headline content; strings are wrapped in default label styling. */
|
|
10
|
+
children?: ReactNode;
|
|
11
|
+
/** Leading (start) slot — icon, avatar, etc. */
|
|
12
|
+
leading?: ReactNode;
|
|
13
|
+
/** Trailing (end) slot — chevron, value, control, etc. */
|
|
14
|
+
trailing?: ReactNode;
|
|
15
|
+
/** Secondary content below the headline; strings get subtle styling. */
|
|
16
|
+
supporting?: string | ReactNode;
|
|
17
|
+
/** Tap handler, active over the entire row. */
|
|
18
|
+
onPress?: () => void;
|
|
19
|
+
/** Identifier used to locate the row in end-to-end tests. */
|
|
20
|
+
testID?: string;
|
|
21
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type {PickerProps, PickerValue} from './types';
|
|
2
|
+
|
|
3
|
+
import {useState} from 'react';
|
|
4
|
+
import unfoldMore from '@expo/material-symbols/unfold_more.xml';
|
|
5
|
+
import {useMaterialColors, DropdownMenu, DropdownMenuItem, Column, Row, Text, Icon} from '@expo/ui/jetpack-compose';
|
|
6
|
+
import {background, clickable, clip, fillMaxWidth, padding, Shapes} from '@expo/ui/jetpack-compose/modifiers';
|
|
7
|
+
import {extractItems, labelFor, PickerItem, useSelectedValue} from './shared';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Android's stock dropdown spans the full row width with no label, so instead
|
|
11
|
+
* the row mirrors the iOS Form look — a leading label and a trailing rounded
|
|
12
|
+
* pill showing the value plus an up/down chevron — built with Jetpack Compose
|
|
13
|
+
* primitives so it lives natively inside the surrounding `Host`/`FieldGroup`.
|
|
14
|
+
* Pressing the pill anchors a Material dropdown menu of the options.
|
|
15
|
+
*/
|
|
16
|
+
function PickerComponent<T extends PickerValue>({
|
|
17
|
+
label,
|
|
18
|
+
children,
|
|
19
|
+
disabled,
|
|
20
|
+
accentColor,
|
|
21
|
+
selectedValue,
|
|
22
|
+
onValueChange,
|
|
23
|
+
}: PickerProps<T>) {
|
|
24
|
+
const colors = useMaterialColors();
|
|
25
|
+
const items = extractItems<T>(children);
|
|
26
|
+
const [current, setValue] = useSelectedValue(selectedValue, onValueChange, items[0]?.value);
|
|
27
|
+
const [expanded, setExpanded] = useState(false);
|
|
28
|
+
const colorLabel = disabled ? colors.onSurfaceVariant : colors.onSurface;
|
|
29
|
+
// Secondary by default — iOS renders the menu picker's value in gray inside
|
|
30
|
+
// a Form, and the web pill uses `secondaryLabel` to match.
|
|
31
|
+
const colorValue = accentColor && !disabled ? accentColor : colors.onSurfaceVariant;
|
|
32
|
+
return (
|
|
33
|
+
<Column modifiers={[fillMaxWidth()]}>
|
|
34
|
+
<Row
|
|
35
|
+
verticalAlignment="center"
|
|
36
|
+
horizontalArrangement="spaceBetween"
|
|
37
|
+
modifiers={[fillMaxWidth()]}>
|
|
38
|
+
{label != null ? <Text color={colorLabel}>{label}</Text> : null}
|
|
39
|
+
<DropdownMenu expanded={expanded} onDismissRequest={() => setExpanded(false)}>
|
|
40
|
+
<DropdownMenu.Trigger>
|
|
41
|
+
<Row
|
|
42
|
+
verticalAlignment="center"
|
|
43
|
+
horizontalArrangement={{spacedBy: 4}}
|
|
44
|
+
modifiers={[
|
|
45
|
+
clip(Shapes.RoundedCorner(8)),
|
|
46
|
+
background(colors.surfaceContainerHighest),
|
|
47
|
+
...(disabled ? [] : [clickable(() => setExpanded(true))]),
|
|
48
|
+
padding(12, 6, 12, 6),
|
|
49
|
+
]}>
|
|
50
|
+
<Text color={colorValue}>{labelFor(items, current)}</Text>
|
|
51
|
+
<Icon source={unfoldMore} size={16} tint={colorValue} />
|
|
52
|
+
</Row>
|
|
53
|
+
</DropdownMenu.Trigger>
|
|
54
|
+
<DropdownMenu.Items>
|
|
55
|
+
{items.map(item => (
|
|
56
|
+
<DropdownMenuItem
|
|
57
|
+
key={String(item.value)}
|
|
58
|
+
onClick={
|
|
59
|
+
disabled
|
|
60
|
+
? undefined
|
|
61
|
+
: () => {
|
|
62
|
+
setValue(item.value);
|
|
63
|
+
setExpanded(false);
|
|
64
|
+
}
|
|
65
|
+
}>
|
|
66
|
+
<DropdownMenuItem.Text>
|
|
67
|
+
<Text>{item.label}</Text>
|
|
68
|
+
</DropdownMenuItem.Text>
|
|
69
|
+
</DropdownMenuItem>
|
|
70
|
+
))}
|
|
71
|
+
</DropdownMenu.Items>
|
|
72
|
+
</DropdownMenu>
|
|
73
|
+
</Row>
|
|
74
|
+
</Column>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
PickerComponent.Item = PickerItem;
|
|
79
|
+
|
|
80
|
+
export {PickerComponent as Picker};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type {PickerProps, PickerValue} from './types';
|
|
2
|
+
import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
|
|
3
|
+
|
|
4
|
+
import {Picker as SwiftUIPicker, Text} from '@expo/ui/swift-ui';
|
|
5
|
+
import {pickerStyle, tag, tint, disabled as disabledMod} from '@expo/ui/swift-ui/modifiers';
|
|
6
|
+
import {extractItems, PickerItem, useSelectedValue} from './shared';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* iOS renders the picker inline using SwiftUI's `Picker` with the `menu` style,
|
|
10
|
+
* which is the rounded-pill Form row look the other platforms emulate: a
|
|
11
|
+
* leading label, the selected value, and a trailing chevron. Drop it straight
|
|
12
|
+
* into a `FieldGroup.Section` alongside other rows.
|
|
13
|
+
*/
|
|
14
|
+
function PickerComponent<T extends PickerValue>({
|
|
15
|
+
label,
|
|
16
|
+
testID,
|
|
17
|
+
children,
|
|
18
|
+
disabled,
|
|
19
|
+
accentColor,
|
|
20
|
+
selectedValue,
|
|
21
|
+
onValueChange,
|
|
22
|
+
}: PickerProps<T>) {
|
|
23
|
+
const items = extractItems<T>(children);
|
|
24
|
+
const [current, setValue] = useSelectedValue(selectedValue, onValueChange, items[0]?.value);
|
|
25
|
+
const modifiers: ViewModifier[] = [pickerStyle('menu')];
|
|
26
|
+
|
|
27
|
+
if (accentColor) modifiers.push(tint(accentColor));
|
|
28
|
+
if (disabled != null) modifiers.push(disabledMod(disabled));
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<SwiftUIPicker
|
|
32
|
+
label={label}
|
|
33
|
+
selection={current}
|
|
34
|
+
onSelectionChange={value => setValue(value as T)}
|
|
35
|
+
modifiers={modifiers}
|
|
36
|
+
testID={testID}>
|
|
37
|
+
{items.map(item => (
|
|
38
|
+
<Text key={String(item.value)} modifiers={[tag(item.value)]}>
|
|
39
|
+
{item.label}
|
|
40
|
+
</Text>
|
|
41
|
+
))}
|
|
42
|
+
</SwiftUIPicker>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
PickerComponent.Item = PickerItem;
|
|
47
|
+
|
|
48
|
+
export {PickerComponent as Picker};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type {ChangeEvent, CSSProperties} from 'react';
|
|
2
|
+
import type {PickerProps, PickerValue} from './types';
|
|
3
|
+
|
|
4
|
+
import {Pressable, StyleSheet, View} from 'react-native';
|
|
5
|
+
import {Label} from '../typography';
|
|
6
|
+
import {theme} from '../theme';
|
|
7
|
+
import {extractItems, labelFor, PickerItem, useSelectedValue} from './shared';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* On web the row mirrors the native iOS/Android pill: a label on the leading
|
|
11
|
+
* edge and a rounded pill showing the selected value plus an up/down chevron,
|
|
12
|
+
* with a transparent native `<select>` layered on top. Clicking the pill opens
|
|
13
|
+
* the browser's built-in option list.
|
|
14
|
+
*/
|
|
15
|
+
function PickerComponent<T extends PickerValue>({
|
|
16
|
+
style,
|
|
17
|
+
label,
|
|
18
|
+
testID,
|
|
19
|
+
children,
|
|
20
|
+
disabled,
|
|
21
|
+
accentColor,
|
|
22
|
+
selectedValue,
|
|
23
|
+
onValueChange,
|
|
24
|
+
}: PickerProps<T>) {
|
|
25
|
+
const items = extractItems<T>(children);
|
|
26
|
+
const [current, setValue] = useSelectedValue(selectedValue, onValueChange, items[0]?.value);
|
|
27
|
+
|
|
28
|
+
const handleChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
|
29
|
+
const item = items[event.target.selectedIndex];
|
|
30
|
+
if (item) setValue(item.value);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<View style={[styles.row, style]} testID={testID}>
|
|
35
|
+
{label != null ? (
|
|
36
|
+
<Label color="label" style={[styles.label, disabled && styles.disabled]}>
|
|
37
|
+
{label}
|
|
38
|
+
</Label>
|
|
39
|
+
) : null}
|
|
40
|
+
<Pressable disabled={disabled} style={[styles.pill, disabled && styles.disabled]}>
|
|
41
|
+
<Label
|
|
42
|
+
color="secondaryLabel"
|
|
43
|
+
style={[styles.value, accentColor != null && {color: accentColor}]}>
|
|
44
|
+
{labelFor(items, current)}
|
|
45
|
+
</Label>
|
|
46
|
+
<Chevron />
|
|
47
|
+
<select
|
|
48
|
+
value={current != null ? String(current) : undefined}
|
|
49
|
+
disabled={disabled}
|
|
50
|
+
aria-label={label ?? 'Select option'}
|
|
51
|
+
onChange={handleChange}
|
|
52
|
+
style={{...overlayStyle, cursor: disabled ? 'default' : 'pointer'}}>
|
|
53
|
+
{items.map(item => (
|
|
54
|
+
<option key={String(item.value)} value={String(item.value)} style={optionStyle}>
|
|
55
|
+
{item.label}
|
|
56
|
+
</option>
|
|
57
|
+
))}
|
|
58
|
+
</select>
|
|
59
|
+
</Pressable>
|
|
60
|
+
</View>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
PickerComponent.Item = PickerItem;
|
|
65
|
+
|
|
66
|
+
export {PickerComponent as Picker};
|
|
67
|
+
|
|
68
|
+
const styles = StyleSheet.create({
|
|
69
|
+
row: {
|
|
70
|
+
flexDirection: 'row',
|
|
71
|
+
alignItems: 'center',
|
|
72
|
+
justifyContent: 'space-between',
|
|
73
|
+
gap: 8,
|
|
74
|
+
width: '100%',
|
|
75
|
+
},
|
|
76
|
+
label: {
|
|
77
|
+
flexShrink: 1,
|
|
78
|
+
},
|
|
79
|
+
pill: {
|
|
80
|
+
position: 'relative',
|
|
81
|
+
flexShrink: 0,
|
|
82
|
+
flexDirection: 'row',
|
|
83
|
+
alignItems: 'center',
|
|
84
|
+
gap: 6,
|
|
85
|
+
borderRadius: 8,
|
|
86
|
+
paddingHorizontal: 11,
|
|
87
|
+
paddingVertical: 4,
|
|
88
|
+
backgroundColor: theme.pillBackground,
|
|
89
|
+
},
|
|
90
|
+
value: {
|
|
91
|
+
flexShrink: 0,
|
|
92
|
+
},
|
|
93
|
+
disabled: {
|
|
94
|
+
opacity: 0.4,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const overlayStyle: CSSProperties = {
|
|
99
|
+
position: 'absolute',
|
|
100
|
+
top: 0,
|
|
101
|
+
left: 0,
|
|
102
|
+
right: 0,
|
|
103
|
+
bottom: 0,
|
|
104
|
+
width: '100%',
|
|
105
|
+
height: '100%',
|
|
106
|
+
margin: 0,
|
|
107
|
+
padding: 0,
|
|
108
|
+
border: 'none',
|
|
109
|
+
opacity: 0,
|
|
110
|
+
appearance: 'none',
|
|
111
|
+
// The native options popup derives its colors from the select's own
|
|
112
|
+
// background/color (not `:root`), so theme them via CSS vars. `opacity: 0`
|
|
113
|
+
// keeps the overlay box invisible; the popup still uses these colors.
|
|
114
|
+
colorScheme: 'light dark',
|
|
115
|
+
color: 'var(--color-label)',
|
|
116
|
+
background: 'var(--color-background)',
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const optionStyle: CSSProperties = {
|
|
120
|
+
color: 'var(--color-label)',
|
|
121
|
+
background: 'var(--color-background)',
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const chevronStroke = {stroke: theme.tertiaryLabel as string} as CSSProperties;
|
|
125
|
+
|
|
126
|
+
function Chevron() {
|
|
127
|
+
return (
|
|
128
|
+
<svg width="11" height="16" viewBox="0 0 11 16" fill="none" aria-hidden="true">
|
|
129
|
+
<path
|
|
130
|
+
d="M2.5 6.5 L5.5 3.5 L8.5 6.5"
|
|
131
|
+
style={chevronStroke}
|
|
132
|
+
strokeWidth="1.6"
|
|
133
|
+
strokeLinecap="round"
|
|
134
|
+
strokeLinejoin="round"
|
|
135
|
+
/>
|
|
136
|
+
<path
|
|
137
|
+
d="M2.5 9.5 L5.5 12.5 L8.5 9.5"
|
|
138
|
+
style={chevronStroke}
|
|
139
|
+
strokeWidth="1.6"
|
|
140
|
+
strokeLinecap="round"
|
|
141
|
+
strokeLinejoin="round"
|
|
142
|
+
/>
|
|
143
|
+
</svg>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type {ReactElement, ReactNode} from 'react';
|
|
2
|
+
import type {PickerItemProps, PickerOption, PickerValue} from './types';
|
|
3
|
+
import {Children, isValidElement, useCallback, useState} from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Data-only option marker. Rendered as `<Picker.Item label value />` and never
|
|
7
|
+
* mounts anything itself — the parent `Picker` reads its props to build the
|
|
8
|
+
* platform-native list of options.
|
|
9
|
+
*/
|
|
10
|
+
export function PickerItem<T extends PickerValue>(_props: PickerItemProps<T>): null {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Walks `<Picker>` children and extracts each `<Picker.Item>`'s props.
|
|
16
|
+
* Non-`PickerItem` children are ignored.
|
|
17
|
+
* @param children - The picker children.
|
|
18
|
+
* @returns The list of options declared by the children.
|
|
19
|
+
*/
|
|
20
|
+
export function extractItems<T extends PickerValue>(children: ReactNode): PickerOption<T>[] {
|
|
21
|
+
return Children.toArray(children)
|
|
22
|
+
.filter(
|
|
23
|
+
(child): child is ReactElement<PickerItemProps<T>> =>
|
|
24
|
+
isValidElement(child) && child.type === PickerItem,
|
|
25
|
+
)
|
|
26
|
+
.map(child => ({label: child.props.label, value: child.props.value}));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Bridges controlled and uncontrolled usage, mirroring `useDateValue`. When
|
|
31
|
+
* `selectedValue` is provided the component is controlled; otherwise it falls
|
|
32
|
+
* back to internal state seeded with `fallback` (typically the first option).
|
|
33
|
+
* @param value - The current value of the picker.
|
|
34
|
+
* @param onChange - The function to call when the picker value changes.
|
|
35
|
+
* @param fallback - The value used when neither `value` nor internal state is set.
|
|
36
|
+
* @returns The current value and the function to call when the picker value changes.
|
|
37
|
+
*/
|
|
38
|
+
export function useSelectedValue<T extends PickerValue>(
|
|
39
|
+
value: T | undefined,
|
|
40
|
+
onChange: ((value: T) => void) | undefined,
|
|
41
|
+
fallback: T | undefined,
|
|
42
|
+
): [T | undefined, (next: T) => void] {
|
|
43
|
+
const [internal, setInternal] = useState<T | undefined>(value);
|
|
44
|
+
const current = value ?? internal ?? fallback;
|
|
45
|
+
|
|
46
|
+
const setValue = useCallback(
|
|
47
|
+
(next: T) => {
|
|
48
|
+
if (value === undefined) {
|
|
49
|
+
setInternal(next);
|
|
50
|
+
}
|
|
51
|
+
onChange?.(next);
|
|
52
|
+
},
|
|
53
|
+
[value, onChange],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return [current, setValue];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Finds the display label for the currently selected value.
|
|
61
|
+
* @param options - The available options.
|
|
62
|
+
* @param value - The selected value.
|
|
63
|
+
* @returns The matching label, or an empty string when nothing matches.
|
|
64
|
+
*/
|
|
65
|
+
export function labelFor<T extends PickerValue>(
|
|
66
|
+
options: PickerOption<T>[],
|
|
67
|
+
value: T | undefined,
|
|
68
|
+
): string {
|
|
69
|
+
return options.find(option => option.value === value)?.label ?? '';
|
|
70
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type {ReactNode} from 'react';
|
|
2
|
+
import type {StyleProp, ViewStyle} from 'react-native';
|
|
3
|
+
|
|
4
|
+
/** The type of values a {@link PickerItem} can carry. */
|
|
5
|
+
export type PickerValue = string | number;
|
|
6
|
+
|
|
7
|
+
/** Internal shape extracted from each `<Picker.Item>` child. */
|
|
8
|
+
export interface PickerOption<T extends PickerValue = PickerValue> {
|
|
9
|
+
label: string;
|
|
10
|
+
value: T;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Props for the `Picker.Item` component. A data-only marker used to declare the
|
|
15
|
+
* available options inside a {@link Picker}.
|
|
16
|
+
*/
|
|
17
|
+
export interface PickerItemProps<T extends PickerValue = PickerValue> {
|
|
18
|
+
/** Display text for the option. */
|
|
19
|
+
label: string;
|
|
20
|
+
/** Value reported through `onValueChange` when this option is selected. */
|
|
21
|
+
value: T;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Cross-platform dropdown picker with a conformed iOS-style appearance.
|
|
26
|
+
*
|
|
27
|
+
* The control may be used controlled (pass `selectedValue` + `onValueChange`)
|
|
28
|
+
* or uncontrolled (omit both and it manages its own state, seeded with the
|
|
29
|
+
* first option).
|
|
30
|
+
*/
|
|
31
|
+
export interface PickerProps<T extends PickerValue = PickerValue> {
|
|
32
|
+
/** Style applied to the row container (web/android only). */
|
|
33
|
+
style?: StyleProp<ViewStyle>;
|
|
34
|
+
/** Label rendered at the leading edge of the row, mirroring an iOS Form row. */
|
|
35
|
+
label?: string;
|
|
36
|
+
/** Identifier used to locate the component in end-to-end tests. */
|
|
37
|
+
testID?: string;
|
|
38
|
+
/** `Picker.Item` children that define the available options. */
|
|
39
|
+
children?: ReactNode;
|
|
40
|
+
/** Disables interaction. */
|
|
41
|
+
disabled?: boolean;
|
|
42
|
+
/** Tint applied to the value text (web/android) and the native picker (iOS). */
|
|
43
|
+
accentColor?: string;
|
|
44
|
+
/** Current value (controlled). When omitted the component keeps its own state. */
|
|
45
|
+
selectedValue?: T;
|
|
46
|
+
/** Called whenever the user selects an option. */
|
|
47
|
+
onValueChange?: (value: T) => void;
|
|
48
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type {ProgressProps} from './types';
|
|
2
|
+
import {LinearProgressIndicator} from '@expo/ui/jetpack-compose';
|
|
3
|
+
import {fillMaxWidth, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
|
|
4
|
+
import {useColor} from '../theme';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Android renders the Material 3 `LinearProgressIndicator`. It fills the row
|
|
8
|
+
* width so it matches the iOS and web bars.
|
|
9
|
+
*/
|
|
10
|
+
export function Progress({value, color, trackColor, testID}: ProgressProps) {
|
|
11
|
+
const accent = useColor('tint');
|
|
12
|
+
const track = useColor('backgroundSelected');
|
|
13
|
+
return (
|
|
14
|
+
<LinearProgressIndicator
|
|
15
|
+
progress={value ?? null}
|
|
16
|
+
color={color ?? accent}
|
|
17
|
+
trackColor={trackColor ?? track}
|
|
18
|
+
modifiers={[
|
|
19
|
+
fillMaxWidth(),
|
|
20
|
+
...(testID ? [testIDModifier(testID)] : []),
|
|
21
|
+
]}
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type {ProgressProps} from './types';
|
|
2
|
+
import {ProgressView} from '@expo/ui/swift-ui';
|
|
3
|
+
import {tint} from '@expo/ui/swift-ui/modifiers';
|
|
4
|
+
import {useColor} from '../theme';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* iOS renders SwiftUI's `ProgressView` in its linear style (the default for a
|
|
8
|
+
* determinate `value`). The bar is flexible-width and fills the available space
|
|
9
|
+
* of its parent layout. `trackColor` has no SwiftUI equivalent and is ignored.
|
|
10
|
+
*/
|
|
11
|
+
export function Progress({value, color, testID}: ProgressProps) {
|
|
12
|
+
const accent = useColor('tint');
|
|
13
|
+
return (
|
|
14
|
+
<ProgressView
|
|
15
|
+
value={value ?? null}
|
|
16
|
+
modifiers={[tint(color ?? accent)]}
|
|
17
|
+
testID={testID}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import './progress.css';
|
|
2
|
+
import type {CSSProperties} from 'react';
|
|
3
|
+
import type {ProgressProps} from './types';
|
|
4
|
+
import {useColor} from '../theme';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* On web the bar is a native `<meter>` element. Its bar/track colors are driven
|
|
8
|
+
* by the `--ui-progress-*` custom properties consumed in `global.css`, since the
|
|
9
|
+
* meter pseudo-elements can't be styled inline. Indeterminate meters don't
|
|
10
|
+
* exist on the web platform, so an undefined `value` renders an empty bar.
|
|
11
|
+
*/
|
|
12
|
+
export function Progress({value, color, trackColor, testID}: ProgressProps) {
|
|
13
|
+
const tint = useColor('tint');
|
|
14
|
+
const track = useColor('backgroundSelected');
|
|
15
|
+
const clamped = Math.max(0, Math.min(1, value ?? 0));
|
|
16
|
+
const vars = {
|
|
17
|
+
'--ui-progress-bar': color ?? tint,
|
|
18
|
+
'--ui-progress-track': trackColor ?? track,
|
|
19
|
+
} as CSSProperties;
|
|
20
|
+
return (
|
|
21
|
+
<meter
|
|
22
|
+
className="ui-progress"
|
|
23
|
+
min={0}
|
|
24
|
+
max={1}
|
|
25
|
+
value={clamped}
|
|
26
|
+
data-testid={testID}
|
|
27
|
+
style={vars}
|
|
28
|
+
/>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
.ui-progress {
|
|
2
|
+
width: 100%;
|
|
3
|
+
height: 6px;
|
|
4
|
+
border: none;
|
|
5
|
+
border-radius: 999px;
|
|
6
|
+
background: var(--ui-progress-track);
|
|
7
|
+
appearance: none;
|
|
8
|
+
-webkit-appearance: none;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.ui-progress::-webkit-meter-bar {
|
|
12
|
+
border: none;
|
|
13
|
+
border-radius: 999px;
|
|
14
|
+
background: var(--ui-progress-track);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.ui-progress::-webkit-meter-optimum-value,
|
|
18
|
+
.ui-progress::-webkit-meter-suboptimum-value,
|
|
19
|
+
.ui-progress::-webkit-meter-even-less-good-value {
|
|
20
|
+
border-radius: 999px;
|
|
21
|
+
background: var(--ui-progress-bar);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.ui-progress::-moz-meter-bar {
|
|
25
|
+
border-radius: 999px;
|
|
26
|
+
background: var(--ui-progress-bar);
|
|
27
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform determinate progress bar.
|
|
3
|
+
*
|
|
4
|
+
* Bridges the SwiftUI `ProgressView` on iOS, the Jetpack Compose
|
|
5
|
+
* `LinearProgressIndicator` on Android, and the HTML `<meter>` element on web.
|
|
6
|
+
*/
|
|
7
|
+
export interface ProgressProps {
|
|
8
|
+
/**
|
|
9
|
+
* Progress value between `0` and `1`.
|
|
10
|
+
* Omit for an indeterminate indicator (native only; web falls back to `0`).
|
|
11
|
+
*/
|
|
12
|
+
value?: number;
|
|
13
|
+
/** Color of the filled portion of the bar. Defaults to the theme tint. */
|
|
14
|
+
color?: string;
|
|
15
|
+
/** Color of the unfilled track behind the bar. */
|
|
16
|
+
trackColor?: string;
|
|
17
|
+
/** Identifier used to locate the component in end-to-end tests. */
|
|
18
|
+
testID?: string;
|
|
19
|
+
}
|
package/src/qr/index.tsx
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {useMemo} from 'react';
|
|
2
|
+
import {Image} from 'expo-image';
|
|
3
|
+
import qrcode from 'qrcode-generator';
|
|
4
|
+
|
|
5
|
+
export interface QRCodeProps {
|
|
6
|
+
/** Value encoded in the QR code. */
|
|
7
|
+
value: string;
|
|
8
|
+
/** Rendered width/height in points. */
|
|
9
|
+
size?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function QRCode({value, size = 200}: QRCodeProps) {
|
|
13
|
+
const uri = useMemo(() => {
|
|
14
|
+
const qr = qrcode(0, 'M');
|
|
15
|
+
qr.addData(value);
|
|
16
|
+
qr.make();
|
|
17
|
+
return qr.createDataURL(8, 2);
|
|
18
|
+
}, [value]);
|
|
19
|
+
return (
|
|
20
|
+
<Image
|
|
21
|
+
source={{uri}}
|
|
22
|
+
style={{width: size, height: size, borderRadius: 12}}
|
|
23
|
+
contentFit="contain"
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {Href, Link} from 'expo-router';
|
|
2
|
+
import {openBrowserAsync, WebBrowserPresentationStyle} from 'expo-web-browser';
|
|
3
|
+
|
|
4
|
+
type Props = Omit<React.ComponentProps<typeof Link>, 'href'> & {
|
|
5
|
+
href: Href & string
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function ExternalLink({href, ...rest}: Props) {
|
|
9
|
+
return (
|
|
10
|
+
<Link
|
|
11
|
+
target="_blank"
|
|
12
|
+
{...rest}
|
|
13
|
+
href={href}
|
|
14
|
+
onPress={async (event) => {
|
|
15
|
+
if (process.env.EXPO_OS !== 'web') {
|
|
16
|
+
// Prevent the default behavior of linking to the default browser on native.
|
|
17
|
+
event.preventDefault();
|
|
18
|
+
// Open the link in an in-app browser.
|
|
19
|
+
await openBrowserAsync(href, {
|
|
20
|
+
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {Platform, Pressable, StyleSheet, Text, View} from 'react-native';
|
|
2
|
+
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
|
3
|
+
import {SymbolView} from 'expo-symbols';
|
|
4
|
+
import {bound, spacing, useColor} from '../theme';
|
|
5
|
+
|
|
6
|
+
interface ScreenHeaderProps {
|
|
7
|
+
title: string;
|
|
8
|
+
onBack?: () => void;
|
|
9
|
+
trailing?: React.ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ScreenHeader({title, onBack, trailing}: ScreenHeaderProps) {
|
|
13
|
+
const insets = useSafeAreaInsets();
|
|
14
|
+
const label = useColor('label');
|
|
15
|
+
const background = useColor('background');
|
|
16
|
+
const paddingTop = Platform.OS === 'web' ? 0 : insets.top;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<View style={[styles.bar, {backgroundColor: background, paddingTop}]}>
|
|
20
|
+
<View style={styles.inner}>
|
|
21
|
+
{onBack ? (
|
|
22
|
+
<Pressable
|
|
23
|
+
onPress={onBack}
|
|
24
|
+
accessibilityLabel="Go back"
|
|
25
|
+
style={styles.back}>
|
|
26
|
+
<SymbolView
|
|
27
|
+
name={{web: 'arrow_back', ios: 'chevron.left', android: 'arrow_back'}}
|
|
28
|
+
size={24}
|
|
29
|
+
tintColor={label}
|
|
30
|
+
/>
|
|
31
|
+
</Pressable>
|
|
32
|
+
) : null}
|
|
33
|
+
<Text numberOfLines={1} style={[styles.title, {color: label}]}>
|
|
34
|
+
{title}
|
|
35
|
+
</Text>
|
|
36
|
+
{trailing}
|
|
37
|
+
</View>
|
|
38
|
+
</View>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const styles = StyleSheet.create({
|
|
43
|
+
bar: {
|
|
44
|
+
width: '100%',
|
|
45
|
+
alignItems: 'center',
|
|
46
|
+
},
|
|
47
|
+
inner: {
|
|
48
|
+
width: '100%',
|
|
49
|
+
alignItems: 'center',
|
|
50
|
+
flexDirection: 'row',
|
|
51
|
+
maxWidth: bound.contentMaxWidth,
|
|
52
|
+
paddingHorizontal: spacing.three,
|
|
53
|
+
height: 64,
|
|
54
|
+
gap: spacing.two,
|
|
55
|
+
},
|
|
56
|
+
back: {
|
|
57
|
+
padding: spacing.one,
|
|
58
|
+
marginLeft: -spacing.one,
|
|
59
|
+
},
|
|
60
|
+
title: {
|
|
61
|
+
flex: 1,
|
|
62
|
+
fontSize: 17,
|
|
63
|
+
fontWeight: '600',
|
|
64
|
+
},
|
|
65
|
+
});
|