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,160 @@
|
|
|
1
|
+
import type {DateTimeMode} from './types';
|
|
2
|
+
import {useCallback, useState} from 'react';
|
|
3
|
+
|
|
4
|
+
const dateFormatter = new Intl.DateTimeFormat('en-GB', {
|
|
5
|
+
day: 'numeric',
|
|
6
|
+
month: 'short',
|
|
7
|
+
year: 'numeric',
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const timeFormatter = new Intl.DateTimeFormat('en-US', {
|
|
11
|
+
hour: 'numeric',
|
|
12
|
+
minute: '2-digit',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Formats a value the way it appears inside the pill,
|
|
17
|
+
* e.g. `15 Jun 2026` or `9:00 AM`.
|
|
18
|
+
* @param date - The date to format.
|
|
19
|
+
* @param mode - The mode of the date time picker.
|
|
20
|
+
* @returns The formatted date as a string.
|
|
21
|
+
*/
|
|
22
|
+
export function formatValue(date: Date, mode: DateTimeMode): string {
|
|
23
|
+
switch (mode) {
|
|
24
|
+
case 'date':
|
|
25
|
+
return dateFormatter.format(date);
|
|
26
|
+
case 'time':
|
|
27
|
+
return timeFormatter.format(date);
|
|
28
|
+
case 'datetime':
|
|
29
|
+
default:
|
|
30
|
+
return `${dateFormatter.format(date)}, ${timeFormatter.format(date)}`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Bridges controlled and uncontrolled usage. When `value` is provided the
|
|
36
|
+
* component is controlled; otherwise it falls back to internal state.
|
|
37
|
+
* @param value - The current value of the date time picker.
|
|
38
|
+
* @param onChange - The function to call when the date time picker value changes.
|
|
39
|
+
* @returns The current value and the function to call when the date time picker value changes.
|
|
40
|
+
*/
|
|
41
|
+
export function useDateValue(
|
|
42
|
+
value: Date | undefined,
|
|
43
|
+
onChange: ((date: Date) => void) | undefined,
|
|
44
|
+
): [Date, (next: Date) => void] {
|
|
45
|
+
const [internal, setInternal] = useState(() => value ?? new Date());
|
|
46
|
+
const current = value ?? internal;
|
|
47
|
+
const setValue = useCallback(
|
|
48
|
+
(next: Date) => {
|
|
49
|
+
if (value === undefined) {
|
|
50
|
+
setInternal(next);
|
|
51
|
+
}
|
|
52
|
+
onChange?.(next);
|
|
53
|
+
},
|
|
54
|
+
[value, onChange],
|
|
55
|
+
);
|
|
56
|
+
return [current, setValue];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Replaces the calendar day of `base` with the one from `picked`, keeping the time.
|
|
61
|
+
* @param base - The base date to replace the calendar day of.
|
|
62
|
+
* @param picked - The date to replace the calendar day of `base` with.
|
|
63
|
+
* @returns The date with the calendar day replaced.
|
|
64
|
+
*/
|
|
65
|
+
export function withDatePart(base: Date, picked: Date): Date {
|
|
66
|
+
const next = new Date(base);
|
|
67
|
+
next.setFullYear(picked.getFullYear(), picked.getMonth(), picked.getDate());
|
|
68
|
+
return next;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Replaces the time of `base` with the one from `picked`, keeping the calendar day.
|
|
73
|
+
* @param base - The base date to replace the time of.
|
|
74
|
+
* @param picked - The date to replace the time of `base` with.
|
|
75
|
+
* @returns The date with the time replaced.
|
|
76
|
+
*/
|
|
77
|
+
export function withTimePart(base: Date, picked: Date): Date {
|
|
78
|
+
const next = new Date(base);
|
|
79
|
+
next.setHours(picked.getHours(), picked.getMinutes(), 0, 0);
|
|
80
|
+
return next;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The HTML input `type` that matches a given mode.
|
|
85
|
+
* @param mode - The mode of the date time picker.
|
|
86
|
+
* @returns The HTML input `type` that matches the given mode.
|
|
87
|
+
*/
|
|
88
|
+
export function inputType(mode: DateTimeMode): 'date' | 'time' | 'datetime-local' {
|
|
89
|
+
switch (mode) {
|
|
90
|
+
case 'date':
|
|
91
|
+
return 'date';
|
|
92
|
+
case 'time':
|
|
93
|
+
return 'time';
|
|
94
|
+
case 'datetime':
|
|
95
|
+
default:
|
|
96
|
+
return 'datetime-local';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Serializes a `Date` into the local-time string an HTML input expects.
|
|
102
|
+
* @param date - The date to serialize.
|
|
103
|
+
* @param mode - The mode of the date time picker.
|
|
104
|
+
* @returns The serialized date as a string.
|
|
105
|
+
*/
|
|
106
|
+
export function toInputValue(date: Date, mode: DateTimeMode): string {
|
|
107
|
+
const datePart = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
108
|
+
const timePart = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
109
|
+
switch (mode) {
|
|
110
|
+
case 'date':
|
|
111
|
+
return datePart;
|
|
112
|
+
case 'time':
|
|
113
|
+
return timePart;
|
|
114
|
+
case 'datetime':
|
|
115
|
+
default:
|
|
116
|
+
return `${datePart}T${timePart}`;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Parses an HTML input value back into a `Date`, merging it onto `base` so the
|
|
122
|
+
* untouched component (date or time) is preserved. Returns `null` for empty or
|
|
123
|
+
* malformed input.
|
|
124
|
+
* @param raw - The raw input value to parse.
|
|
125
|
+
* @param mode - The mode of the date time picker.
|
|
126
|
+
* @param base - The base date to merge the parsed value onto.
|
|
127
|
+
* @returns The parsed date or null if the input is empty or malformed.
|
|
128
|
+
*/
|
|
129
|
+
export function parseInputValue(raw: string, mode: DateTimeMode, base: Date): Date | null {
|
|
130
|
+
if (!raw) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
if (mode === 'time') {
|
|
134
|
+
const [hours, minutes] = raw.split(':').map(Number);
|
|
135
|
+
if (Number.isNaN(hours) || Number.isNaN(minutes)) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const next = new Date(base);
|
|
139
|
+
next.setHours(hours, minutes, 0, 0);
|
|
140
|
+
return next;
|
|
141
|
+
}
|
|
142
|
+
const [datePart, timePart] = raw.split('T');
|
|
143
|
+
const [year, month, day] = datePart.split('-').map(Number);
|
|
144
|
+
if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const next = new Date(base);
|
|
148
|
+
next.setFullYear(year, month - 1, day);
|
|
149
|
+
if (mode === 'datetime' && timePart) {
|
|
150
|
+
const [hours, minutes] = timePart.split(':').map(Number);
|
|
151
|
+
if (!Number.isNaN(hours) && !Number.isNaN(minutes)) {
|
|
152
|
+
next.setHours(hours, minutes, 0, 0);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return next;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function pad(value: number): string {
|
|
159
|
+
return String(value).padStart(2, '0');
|
|
160
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type {StyleProp, ViewStyle} from 'react-native';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which components the picker edits.
|
|
5
|
+
* - `date` selects a calendar day.
|
|
6
|
+
* - `time` selects an hour and minute.
|
|
7
|
+
* - `datetime` selects both.
|
|
8
|
+
*/
|
|
9
|
+
export type DateTimeMode = 'date' | 'time' | 'datetime';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Cross-platform date/time picker with a conformed iOS-style appearance.
|
|
13
|
+
*
|
|
14
|
+
* The control may be used controlled (pass `value` + `onChange`) or
|
|
15
|
+
* uncontrolled (omit both and it manages its own state).
|
|
16
|
+
*/
|
|
17
|
+
export interface DateTimePickerProps {
|
|
18
|
+
/** Label rendered at the leading edge of the row, mirroring an iOS Form row. */
|
|
19
|
+
label?: string;
|
|
20
|
+
/** Current value (controlled). When omitted the component keeps its own state. */
|
|
21
|
+
value?: Date;
|
|
22
|
+
/** Called whenever the user commits a new date/time. */
|
|
23
|
+
onChange?: (date: Date) => void;
|
|
24
|
+
/**
|
|
25
|
+
* Which components to edit.
|
|
26
|
+
* @default 'datetime'
|
|
27
|
+
*/
|
|
28
|
+
mode?: DateTimeMode;
|
|
29
|
+
/** Earliest selectable date. */
|
|
30
|
+
minimumDate?: Date;
|
|
31
|
+
/** Latest selectable date. */
|
|
32
|
+
maximumDate?: Date;
|
|
33
|
+
/** Disables interaction. */
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
/** Tint applied to the value text (web/android) and the native picker (iOS). */
|
|
36
|
+
accentColor?: string;
|
|
37
|
+
/** Identifier used to locate the component in end-to-end tests. */
|
|
38
|
+
testID?: string;
|
|
39
|
+
/** Style applied to the row container (web/android only). */
|
|
40
|
+
style?: StyleProp<ViewStyle>;
|
|
41
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
.field-group {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex: 1;
|
|
4
|
+
flex-direction: column;
|
|
5
|
+
min-height: 0;
|
|
6
|
+
background-color: var(--color-background);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* @expo/ui hardcodes inverted iOS grouped-list colors on web (#f2f2f7 page,
|
|
11
|
+
* #ffffff cards). Light mode should match native: white page, grey cards.
|
|
12
|
+
* Target only section cards — not nested pills/controls inside rows.
|
|
13
|
+
*/
|
|
14
|
+
@media (prefers-color-scheme: light) {
|
|
15
|
+
.field-group [class*='r-borderRadius-']:not([class*='r-borderRadius-'] [class*='r-borderRadius-']) {
|
|
16
|
+
background-color: var(--color-background-element) !important;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type {ReactNode, ReactElement} from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
FieldGroupProps,
|
|
4
|
+
FieldSectionProps,
|
|
5
|
+
FieldSectionHeaderProps,
|
|
6
|
+
FieldSectionFooterProps,
|
|
7
|
+
} from '@expo/ui';
|
|
8
|
+
import {Children, Fragment, isValidElement} from 'react';
|
|
9
|
+
import {Box, Column, LazyColumn, Text} from '@expo/ui/jetpack-compose';
|
|
10
|
+
import {
|
|
11
|
+
background,
|
|
12
|
+
clip,
|
|
13
|
+
defaultMinSize,
|
|
14
|
+
fillMaxWidth,
|
|
15
|
+
padding,
|
|
16
|
+
Shapes,
|
|
17
|
+
testID as testIDModifier,
|
|
18
|
+
type ModifierConfig,
|
|
19
|
+
} from '@expo/ui/jetpack-compose/modifiers';
|
|
20
|
+
import {useColor} from '../theme';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Android `FieldGroup`. Mirrors `@expo/ui`'s Material 3 connected-list
|
|
24
|
+
* layout, with app deviations for web parity:
|
|
25
|
+
* - the group has no background (the universal one paints the Host palette's
|
|
26
|
+
* `surface`, a grey panel over the app's screen background);
|
|
27
|
+
* - rows use the `backgroundElement` token instead of the seeded
|
|
28
|
+
* `surfaceContainer`, matching the web/iOS card color;
|
|
29
|
+
* - rows are plain `Box`es (min height 56dp, the M3 one-line `ListItem`
|
|
30
|
+
* height) rather than `ListItem`s, whose extra 8dp vertical padding would
|
|
31
|
+
* inflate rows holding a Material `TextField` (a rigid 56dp itself) to
|
|
32
|
+
* 72dp, taller than their text-row siblings.
|
|
33
|
+
*/
|
|
34
|
+
function FieldGroupBase({children, style, hidden, testID}: FieldGroupProps) {
|
|
35
|
+
if (hidden) return null;
|
|
36
|
+
const modifiers: ModifierConfig[] = [];
|
|
37
|
+
if (style?.backgroundColor) modifiers.push(background(String(style.backgroundColor)));
|
|
38
|
+
if (testID) modifiers.push(testIDModifier(testID));
|
|
39
|
+
return (
|
|
40
|
+
<LazyColumn
|
|
41
|
+
verticalArrangement={{spacedBy: 24}}
|
|
42
|
+
contentPadding={{start: 16, end: 16, top: 16, bottom: 16}}
|
|
43
|
+
modifiers={modifiers}>
|
|
44
|
+
{groupChildren(children)}
|
|
45
|
+
</LazyColumn>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Marker component tagging the custom header slot of a `Section`. */
|
|
50
|
+
function SectionHeader(props: FieldSectionHeaderProps) {
|
|
51
|
+
return <>{props.children}</>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Marker component tagging the footer slot of a `Section`. */
|
|
55
|
+
function SectionFooter(props: FieldSectionFooterProps) {
|
|
56
|
+
return <>{props.children}</>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function Section({children, title, titleUppercase = false, hidden}: FieldSectionProps) {
|
|
60
|
+
const card = useColor('backgroundElement');
|
|
61
|
+
const subtle = useColor('secondaryLabel');
|
|
62
|
+
if (hidden) return null;
|
|
63
|
+
|
|
64
|
+
const {header, footer, rows} = extractSlots(children);
|
|
65
|
+
const headerNode = header ?? (title ? (
|
|
66
|
+
<Text
|
|
67
|
+
color={subtle}
|
|
68
|
+
style={{typography: 'titleMedium', letterSpacing: titleUppercase ? 0.5 : undefined}}>
|
|
69
|
+
{titleUppercase ? title.toUpperCase() : title}
|
|
70
|
+
</Text>
|
|
71
|
+
) : null);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Column verticalArrangement={{spacedBy: 4}} modifiers={[fillMaxWidth()]}>
|
|
75
|
+
{headerNode ? (
|
|
76
|
+
<Column modifiers={[padding(16, 0, 16, 8)]}>{headerNode}</Column>
|
|
77
|
+
) : null}
|
|
78
|
+
{rows.length > 0 ? (
|
|
79
|
+
<Column verticalArrangement={{spacedBy: 2}} modifiers={[fillMaxWidth()]}>
|
|
80
|
+
{rows.map((child, index) => (
|
|
81
|
+
<Box
|
|
82
|
+
key={index}
|
|
83
|
+
contentAlignment="centerStart"
|
|
84
|
+
modifiers={[
|
|
85
|
+
fillMaxWidth(),
|
|
86
|
+
defaultMinSize({minHeight: 56}),
|
|
87
|
+
clip(Shapes.RoundedCorner(cornerRadii(index, rows.length))),
|
|
88
|
+
background(card),
|
|
89
|
+
padding(16, 0, 16, 0),
|
|
90
|
+
]}>
|
|
91
|
+
{child}
|
|
92
|
+
</Box>
|
|
93
|
+
))}
|
|
94
|
+
</Column>
|
|
95
|
+
) : null}
|
|
96
|
+
{footer ? <Column modifiers={[padding(16, 4, 16, 0)]}>{footer}</Column> : null}
|
|
97
|
+
</Column>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const FieldGroup = Object.assign(FieldGroupBase, {
|
|
102
|
+
Section,
|
|
103
|
+
SectionHeader,
|
|
104
|
+
SectionFooter,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export type {FieldGroupProps};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Per-position corner radii producing the Material 3 grouped-list look:
|
|
111
|
+
* fully rounded at the section's ends, slightly rounded between rows.
|
|
112
|
+
*/
|
|
113
|
+
function cornerRadii(index: number, total: number) {
|
|
114
|
+
const full = 20;
|
|
115
|
+
const small = 4;
|
|
116
|
+
const top = total <= 1 || index === 0 ? full : small;
|
|
117
|
+
const bottom = total <= 1 || index === total - 1 ? full : small;
|
|
118
|
+
return {topStart: top, topEnd: top, bottomStart: bottom, bottomEnd: bottom};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Pulls `SectionHeader`/`SectionFooter` slots out of a section's children. */
|
|
122
|
+
function extractSlots(children: ReactNode) {
|
|
123
|
+
let header: ReactNode | undefined;
|
|
124
|
+
let footer: ReactNode | undefined;
|
|
125
|
+
const rows: ReactNode[] = [];
|
|
126
|
+
|
|
127
|
+
const walk = (node: ReactNode) => {
|
|
128
|
+
Children.forEach(node, child => {
|
|
129
|
+
if (!isValidElement(child)) {
|
|
130
|
+
rows.push(child);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const props = child.props as {children?: ReactNode};
|
|
134
|
+
if (child.type === SectionHeader) {
|
|
135
|
+
header = props.children;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (child.type === SectionFooter) {
|
|
139
|
+
footer = props.children;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (child.type === Fragment) {
|
|
143
|
+
walk(props.children);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
rows.push(child);
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
walk(children);
|
|
151
|
+
return {header, footer, rows};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Mirrors SwiftUI `Form`'s behavior of wrapping consecutive non-`Section`
|
|
156
|
+
* children in an implicit section, like the universal `FieldGroup` does.
|
|
157
|
+
*/
|
|
158
|
+
function groupChildren(children: ReactNode): ReactNode[] {
|
|
159
|
+
const result: ReactNode[] = [];
|
|
160
|
+
let buffered: ReactNode[] = [];
|
|
161
|
+
|
|
162
|
+
const flush = () => {
|
|
163
|
+
if (buffered.length === 0) return;
|
|
164
|
+
result.push(<Section key={`__implicit-section-${result.length}__`}>{buffered}</Section>);
|
|
165
|
+
buffered = [];
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const isSection = (child: ReactNode): child is ReactElement =>
|
|
169
|
+
isValidElement(child) && child.type === Section;
|
|
170
|
+
|
|
171
|
+
Children.forEach(children, child => {
|
|
172
|
+
if (isSection(child)) {
|
|
173
|
+
flush();
|
|
174
|
+
result.push(child);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (isValidElement(child) && child.type === Fragment) {
|
|
178
|
+
for (const nested of groupChildren((child.props as {children?: ReactNode}).children)) {
|
|
179
|
+
if (isSection(nested)) {
|
|
180
|
+
flush();
|
|
181
|
+
result.push(nested);
|
|
182
|
+
} else {
|
|
183
|
+
buffered.push(nested);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
buffered.push(child);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
flush();
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App `FieldGroup`: a scrollable container of grouped settings-style rows.
|
|
3
|
+
* iOS uses `@expo/ui`'s SwiftUI `Form`. Android (`index.android.tsx`) keeps
|
|
4
|
+
* the Material 3 connected-list look with app palette colors. Web
|
|
5
|
+
* (`index.web.tsx` + `field-group.css`) re-themes the universal component via
|
|
6
|
+
* CSS instead of forking its layout.
|
|
7
|
+
*/
|
|
8
|
+
export {FieldGroup, type FieldGroupProps} from '@expo/ui';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import './field-group.css';
|
|
2
|
+
import {FieldGroup as BaseFieldGroup, type FieldGroupProps} from '@expo/ui';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Web hook for `field-group.css`. Clears the universal component's hardcoded
|
|
6
|
+
* scroll background so the wrapper supplies the app palette; section cards are
|
|
7
|
+
* recolored in CSS (light mode only — dark matches @expo/ui).
|
|
8
|
+
*/
|
|
9
|
+
function FieldGroup({style, ...props}: FieldGroupProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div className="field-group">
|
|
12
|
+
<BaseFieldGroup
|
|
13
|
+
{...props}
|
|
14
|
+
style={{...style, backgroundColor: 'transparent'}}
|
|
15
|
+
/>
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
FieldGroup.Section = BaseFieldGroup.Section;
|
|
21
|
+
FieldGroup.SectionHeader = BaseFieldGroup.SectionHeader;
|
|
22
|
+
FieldGroup.SectionFooter = BaseFieldGroup.SectionFooter;
|
|
23
|
+
|
|
24
|
+
export {FieldGroup, type FieldGroupProps};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type {UniversalBaseProps} from '@expo/ui';
|
|
2
|
+
import {frame} from '@expo/ui/swift-ui/modifiers';
|
|
3
|
+
|
|
4
|
+
// SwiftUI fills width with `.frame(maxWidth: .infinity)`, but `Infinity` can't
|
|
5
|
+
// survive JSON serialization to the native modifier (it becomes null, a no-op).
|
|
6
|
+
// A large finite max width fills the available space inside the constrained Host.
|
|
7
|
+
const FILL = 100000;
|
|
8
|
+
|
|
9
|
+
export const fillWidth: NonNullable<UniversalBaseProps['modifiers']> = [frame({maxWidth: FILL})];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type {UniversalBaseProps} from '@expo/ui';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Modifiers that make a universal `Column`/`Row` span its parent's full width.
|
|
5
|
+
*
|
|
6
|
+
* On web the universal layout primitives already stretch to fill the cross-axis,
|
|
7
|
+
* so no modifier is needed. On Android (Compose) and iOS (SwiftUI) the
|
|
8
|
+
* containers wrap their content by default, so a platform modifier is required.
|
|
9
|
+
*/
|
|
10
|
+
export const fillWidth: NonNullable<UniversalBaseProps['modifiers']> = [];
|
package/src/global.css
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--font-display: Spline Sans, Inter, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
|
|
3
|
+
--font-rounded: 'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif;
|
|
4
|
+
--font-serif: Georgia, 'Times New Roman', serif;
|
|
5
|
+
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/* @expo/ui BottomSheet sets full-width inline styles on web; override to match Screen. */
|
|
9
|
+
[data-vaul-drawer][data-vaul-drawer-direction="bottom"] {
|
|
10
|
+
width: 100% !important;
|
|
11
|
+
max-width: 600px !important;
|
|
12
|
+
margin-inline: auto !important;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
body {
|
|
16
|
+
background-color: var(--color-background);
|
|
17
|
+
}
|
package/src/icons.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type {ImageSourcePropType} from 'react-native';
|
|
2
|
+
import type {SymbolViewProps} from 'expo-symbols';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A platform-agnostic icon reference consumed by `Button` and friends.
|
|
6
|
+
*
|
|
7
|
+
* - `symbol`: the `expo-symbols` name, either a single string or a
|
|
8
|
+
* `{ios, android, web}` map (SF Symbol on iOS, Material Symbol elsewhere).
|
|
9
|
+
* - `drawable`: optional Android drawable (for example an
|
|
10
|
+
* `@expo/material-symbols/<name>.xml` import) used by Jetpack Compose
|
|
11
|
+
* controls, which render drawables rather than symbol glyphs.
|
|
12
|
+
*/
|
|
13
|
+
export interface IconToken {
|
|
14
|
+
symbol: SymbolViewProps['name'];
|
|
15
|
+
drawable?: ImageSourcePropType;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Builds an `IconToken`. Keep Android drawables in a `.android.ts` file so
|
|
20
|
+
* the XML assets are only bundled on Android:
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* // icons.drawables.android.ts
|
|
24
|
+
* import share from '@expo/material-symbols/share.xml';
|
|
25
|
+
* export const drawables = {share};
|
|
26
|
+
*
|
|
27
|
+
* // icons.drawables.ts (iOS/web stub)
|
|
28
|
+
* export const drawables: Record<string, ImageSourcePropType | undefined> = {};
|
|
29
|
+
*
|
|
30
|
+
* // icons.ts
|
|
31
|
+
* export const share = icon(
|
|
32
|
+
* {ios: 'square.and.arrow.up', android: 'share', web: 'share'},
|
|
33
|
+
* drawables.share,
|
|
34
|
+
* );
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function icon(
|
|
38
|
+
symbol: SymbolViewProps['name'],
|
|
39
|
+
drawable?: ImageSourcePropType,
|
|
40
|
+
): IconToken {
|
|
41
|
+
return {symbol, drawable};
|
|
42
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/// <reference path="./css.d.ts" />
|
|
2
|
+
// Theme & foundations
|
|
3
|
+
export * from './theme';
|
|
4
|
+
export * from './accent';
|
|
5
|
+
export * from './icons';
|
|
6
|
+
export * from './link';
|
|
7
|
+
export {fillWidth} from './fill';
|
|
8
|
+
|
|
9
|
+
// Layout
|
|
10
|
+
export {Screen} from './screen';
|
|
11
|
+
export {ScreenHeader} from './screen/header';
|
|
12
|
+
export {hostAccentProps} from './screen/host-accent';
|
|
13
|
+
export {Sheet} from './sheet';
|
|
14
|
+
export {ConstrainedStackHeader} from './stack-header';
|
|
15
|
+
export {TabStack} from './tab-stack';
|
|
16
|
+
export {Tabs} from './tabs';
|
|
17
|
+
export type {TabBarProps, TabRoute, WebLogo} from './tabs/types';
|
|
18
|
+
|
|
19
|
+
// Components
|
|
20
|
+
export {Button} from './button';
|
|
21
|
+
export type {ButtonProps, ButtonRole, ButtonShape, ButtonSize, ButtonVariant} from './button/types';
|
|
22
|
+
export {DateTimePicker} from './date-time';
|
|
23
|
+
export type {DateTimeMode, DateTimePickerProps} from './date-time/types';
|
|
24
|
+
export {FieldGroup, type FieldGroupProps} from './field-group';
|
|
25
|
+
export {ListItem} from './list-item';
|
|
26
|
+
export type {ListItemProps} from './list-item/types';
|
|
27
|
+
export {Picker} from './picker';
|
|
28
|
+
export type {PickerItemProps, PickerOption, PickerProps, PickerValue} from './picker/types';
|
|
29
|
+
export {Progress} from './progress';
|
|
30
|
+
export type {ProgressProps} from './progress/types';
|
|
31
|
+
export {QRCode, type QRCodeProps} from './qr';
|
|
32
|
+
export {Switch} from './switch';
|
|
33
|
+
export type {SwitchProps} from './switch/types';
|
|
34
|
+
export {TextField} from './text-field';
|
|
35
|
+
export type {TextFieldCapitalize, TextFieldKeyboard, TextFieldProps} from './text-field/types';
|
|
36
|
+
export {ExternalLink} from './router/external-link';
|
|
37
|
+
|
|
38
|
+
// Typography
|
|
39
|
+
export {
|
|
40
|
+
Typography,
|
|
41
|
+
LargeTitle,
|
|
42
|
+
Title,
|
|
43
|
+
Title2,
|
|
44
|
+
Title3,
|
|
45
|
+
Headline,
|
|
46
|
+
Body,
|
|
47
|
+
Callout,
|
|
48
|
+
Subheadline,
|
|
49
|
+
Footnote,
|
|
50
|
+
Caption,
|
|
51
|
+
Label,
|
|
52
|
+
} from './typography';
|
|
53
|
+
export type {
|
|
54
|
+
TypographyAlign,
|
|
55
|
+
TypographyProps,
|
|
56
|
+
TypographyStyle,
|
|
57
|
+
TypographyVariant,
|
|
58
|
+
TypographyWeight,
|
|
59
|
+
} from './typography/types';
|
package/src/link.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {Platform, Share} from 'react-native';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Opens the native share sheet (iOS/Android) or the Web Share API.
|
|
5
|
+
* Falls back silently if the user cancels or sharing is unavailable.
|
|
6
|
+
*/
|
|
7
|
+
export async function shareUrl(url: string, message?: string) {
|
|
8
|
+
try {
|
|
9
|
+
if (Platform.OS === 'ios') {
|
|
10
|
+
await Share.share({url, message});
|
|
11
|
+
} else {
|
|
12
|
+
await Share.share({message: message ? `${message}\n${url}` : url});
|
|
13
|
+
}
|
|
14
|
+
} catch {
|
|
15
|
+
// Cancelled or unsupported — nothing to do.
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Copies text to the clipboard. Uses the Web Clipboard API on web; on native,
|
|
21
|
+
* where no clipboard module is linked, falls back to the share sheet.
|
|
22
|
+
* Resolves to `true` when copied to the clipboard.
|
|
23
|
+
*/
|
|
24
|
+
export async function copyText(text: string): Promise<boolean> {
|
|
25
|
+
const clipboard = globalThis.navigator?.clipboard;
|
|
26
|
+
if (Platform.OS === 'web' && clipboard) {
|
|
27
|
+
try {
|
|
28
|
+
await clipboard.writeText(text);
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
await shareUrl(text);
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type {ReactNode} from 'react';
|
|
2
|
+
import type {ListItemProps} from './types';
|
|
3
|
+
import {ListItem as ComposeListItem, Text} from '@expo/ui/jetpack-compose';
|
|
4
|
+
import {clickable, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
|
|
5
|
+
import {useColor} from '../theme';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Android uses the Material 3 Compose `ListItem` directly so the container
|
|
9
|
+
* can be made transparent — the M3 default paints the Host palette's
|
|
10
|
+
* `surface`, which reads as a grey panel over the app's screen background
|
|
11
|
+
* (web/iOS rows are transparent).
|
|
12
|
+
*/
|
|
13
|
+
export function ListItem({children, leading, trailing, supporting, onPress, testID}: ListItemProps) {
|
|
14
|
+
const label = useColor('label');
|
|
15
|
+
const subtle = useColor('secondaryLabel');
|
|
16
|
+
const modifiers = [
|
|
17
|
+
...(onPress ? [clickable(onPress)] : []),
|
|
18
|
+
...(testID ? [testIDModifier(testID)] : []),
|
|
19
|
+
];
|
|
20
|
+
return (
|
|
21
|
+
<ComposeListItem colors={{containerColor: '#00000000'}} modifiers={modifiers}>
|
|
22
|
+
<ComposeListItem.HeadlineContent>
|
|
23
|
+
{wrapText(children, label)}
|
|
24
|
+
</ComposeListItem.HeadlineContent>
|
|
25
|
+
{leading != null ? (
|
|
26
|
+
<ComposeListItem.LeadingContent>{leading}</ComposeListItem.LeadingContent>
|
|
27
|
+
) : null}
|
|
28
|
+
{supporting != null ? (
|
|
29
|
+
<ComposeListItem.SupportingContent>
|
|
30
|
+
{typeof supporting === 'string' || typeof supporting === 'number' ? (
|
|
31
|
+
<Text color={subtle} style={{fontSize: 14}}>{supporting}</Text>
|
|
32
|
+
) : (
|
|
33
|
+
supporting
|
|
34
|
+
)}
|
|
35
|
+
</ComposeListItem.SupportingContent>
|
|
36
|
+
) : null}
|
|
37
|
+
{trailing != null ? (
|
|
38
|
+
<ComposeListItem.TrailingContent>{trailing}</ComposeListItem.TrailingContent>
|
|
39
|
+
) : null}
|
|
40
|
+
</ComposeListItem>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Compose slots can't render raw strings — they need a Text composable.
|
|
45
|
+
function wrapText(node: ReactNode, color: string): ReactNode {
|
|
46
|
+
if (typeof node === 'string' || typeof node === 'number') {
|
|
47
|
+
return <Text color={color}>{node}</Text>;
|
|
48
|
+
}
|
|
49
|
+
return node;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type {ListItemProps} from './types';
|