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,78 @@
|
|
|
1
|
+
import type {ButtonProps, ButtonVariant} from './types';
|
|
2
|
+
import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
|
|
3
|
+
import {Button as SwiftUIButton, HStack, Image, Text} from '@expo/ui/swift-ui';
|
|
4
|
+
import {buttonStyle, buttonBorderShape, controlSize, labelStyle, tint, disabled as disabledMod} from '@expo/ui/swift-ui/modifiers';
|
|
5
|
+
import {ICON_GAP, SIZE_ICON, iosSymbol, swiftBorderShape, swiftControlSize} from './shared';
|
|
6
|
+
import {onAccent as contrastOf} from '../accent';
|
|
7
|
+
import {useColor} from '../theme';
|
|
8
|
+
|
|
9
|
+
const VARIANT_STYLE: Record<ButtonVariant, 'borderedProminent' | 'bordered' | 'plain'> = {
|
|
10
|
+
filled: 'borderedProminent',
|
|
11
|
+
outlined: 'bordered',
|
|
12
|
+
text: 'plain',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* iOS renders SwiftUI's native `Button`. The variant maps to a `buttonStyle`
|
|
17
|
+
* modifier and the accent (theme tint by default, red for destructive) is
|
|
18
|
+
* applied with `tint` so the button is branded consistently across platforms.
|
|
19
|
+
*/
|
|
20
|
+
export function Button({
|
|
21
|
+
label,
|
|
22
|
+
onPress,
|
|
23
|
+
variant = 'filled',
|
|
24
|
+
role = 'default',
|
|
25
|
+
color,
|
|
26
|
+
size = 'medium',
|
|
27
|
+
shape,
|
|
28
|
+
prefixIcon,
|
|
29
|
+
suffixIcon,
|
|
30
|
+
hideLabel = false,
|
|
31
|
+
disabled,
|
|
32
|
+
testID,
|
|
33
|
+
}: ButtonProps) {
|
|
34
|
+
const themeTint = useColor('tint');
|
|
35
|
+
const destructive = useColor('destructive');
|
|
36
|
+
const themeOnAccent = useColor(role === 'destructive' ? 'onDestructive' : 'onTint');
|
|
37
|
+
const hasSuffix = !!suffixIcon && !hideLabel;
|
|
38
|
+
const iconOnly = hideLabel && !!prefixIcon;
|
|
39
|
+
const accent = color ?? (role === 'destructive' ? destructive : themeTint);
|
|
40
|
+
// A custom accent brings its own contrast color for filled content.
|
|
41
|
+
const onAccent = color ? contrastOf(color) : themeOnAccent;
|
|
42
|
+
const iconColor = variant === 'filled' ? onAccent : accent;
|
|
43
|
+
const modifiers: ViewModifier[] = [
|
|
44
|
+
buttonStyle(VARIANT_STYLE[variant]),
|
|
45
|
+
controlSize(swiftControlSize(size)),
|
|
46
|
+
tint(accent),
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
if (shape) modifiers.push(buttonBorderShape(swiftBorderShape(shape)));
|
|
50
|
+
if (disabled) modifiers.push(disabledMod(true));
|
|
51
|
+
if (iconOnly) modifiers.push(labelStyle('iconOnly'));
|
|
52
|
+
if (hasSuffix) {
|
|
53
|
+
return (
|
|
54
|
+
<SwiftUIButton
|
|
55
|
+
role={role === 'destructive' ? 'destructive' : 'default'}
|
|
56
|
+
onPress={onPress}
|
|
57
|
+
modifiers={modifiers}
|
|
58
|
+
testID={testID}>
|
|
59
|
+
<HStack spacing={ICON_GAP}>
|
|
60
|
+
{prefixIcon ? <Image systemName={iosSymbol(prefixIcon)} color={iconColor} size={SIZE_ICON[size]}/> : null}
|
|
61
|
+
<Text>{label}</Text>
|
|
62
|
+
<Image systemName={iosSymbol(suffixIcon!)} color={iconColor} size={SIZE_ICON[size]}/>
|
|
63
|
+
</HStack>
|
|
64
|
+
</SwiftUIButton>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<SwiftUIButton
|
|
70
|
+
label={label}
|
|
71
|
+
role={role === 'destructive' ? 'destructive' : 'default'}
|
|
72
|
+
systemImage={prefixIcon ? iosSymbol(prefixIcon) : undefined}
|
|
73
|
+
modifiers={modifiers}
|
|
74
|
+
onPress={onPress}
|
|
75
|
+
testID={testID}
|
|
76
|
+
/>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import './button.css';
|
|
2
|
+
import type {CSSProperties} from 'react';
|
|
3
|
+
import type {ButtonProps} from './types';
|
|
4
|
+
import {SymbolView} from 'expo-symbols';
|
|
5
|
+
import {onAccent as contrastOf} from '../accent';
|
|
6
|
+
import {useColor} from '../theme';
|
|
7
|
+
import {SIZE_ICON} from './shared';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* On web the button is a real `<button>` element styled via `button.css`, so it
|
|
11
|
+
* reads as a native web control rather than a ported Android/Material button.
|
|
12
|
+
* The accent color is passed through the `--ui-button-accent` custom property.
|
|
13
|
+
*/
|
|
14
|
+
export function Button({
|
|
15
|
+
label,
|
|
16
|
+
onPress,
|
|
17
|
+
variant = 'filled',
|
|
18
|
+
role = 'default',
|
|
19
|
+
size = 'medium',
|
|
20
|
+
color,
|
|
21
|
+
shape,
|
|
22
|
+
prefixIcon,
|
|
23
|
+
suffixIcon,
|
|
24
|
+
hideLabel = false,
|
|
25
|
+
disabled = false,
|
|
26
|
+
testID,
|
|
27
|
+
}: ButtonProps) {
|
|
28
|
+
const themeTint = useColor('tint');
|
|
29
|
+
const destructive = useColor('destructive');
|
|
30
|
+
const themeOnAccent = useColor(role === 'destructive' ? 'onDestructive' : 'onTint');
|
|
31
|
+
const accent = color ?? (role === 'destructive' ? destructive : themeTint);
|
|
32
|
+
// A custom accent brings its own contrast color for filled content.
|
|
33
|
+
const onAccent = color ? contrastOf(color) : themeOnAccent;
|
|
34
|
+
const iconOnly = hideLabel && !!prefixIcon;
|
|
35
|
+
const iconSize = SIZE_ICON[size];
|
|
36
|
+
const style = color
|
|
37
|
+
? ({'--ui-button-accent': color, '--ui-button-on-accent': onAccent} as CSSProperties)
|
|
38
|
+
: undefined;
|
|
39
|
+
const className = [
|
|
40
|
+
'ui-button',
|
|
41
|
+
`ui-button--${variant}`,
|
|
42
|
+
`ui-button--${size}`,
|
|
43
|
+
shape && `ui-button--${shape}`,
|
|
44
|
+
!shape && 'ui-button--pill',
|
|
45
|
+
iconOnly && 'ui-button--icon-only',
|
|
46
|
+
role === 'destructive' && 'ui-button--destructive',
|
|
47
|
+
].filter(Boolean).join(' ');
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
style={style}
|
|
53
|
+
className={className}
|
|
54
|
+
disabled={disabled}
|
|
55
|
+
onClick={onPress}
|
|
56
|
+
data-testid={testID}
|
|
57
|
+
aria-label={iconOnly ? label : undefined}>
|
|
58
|
+
{prefixIcon ? (
|
|
59
|
+
<SymbolView
|
|
60
|
+
name={prefixIcon.symbol}
|
|
61
|
+
size={iconSize}
|
|
62
|
+
tintColor={variant === 'filled' ? onAccent : accent}
|
|
63
|
+
/>
|
|
64
|
+
) : null}
|
|
65
|
+
{!iconOnly ? <span className="ui-button__label">{label}</span> : null}
|
|
66
|
+
{suffixIcon && !iconOnly ? (
|
|
67
|
+
<SymbolView
|
|
68
|
+
name={suffixIcon.symbol}
|
|
69
|
+
size={iconSize}
|
|
70
|
+
tintColor={variant === 'filled' ? onAccent : accent}
|
|
71
|
+
/>
|
|
72
|
+
) : null}
|
|
73
|
+
</button>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type {IconToken} from '../icons';
|
|
2
|
+
import type {SFSymbol} from 'expo-symbols';
|
|
3
|
+
import type {ButtonShape, ButtonSize} from './types';
|
|
4
|
+
|
|
5
|
+
export const ICON_GAP = 8;
|
|
6
|
+
|
|
7
|
+
export const SIZE_ICON: Record<ButtonSize, number> = {
|
|
8
|
+
small: 16,
|
|
9
|
+
medium: 18,
|
|
10
|
+
large: 20,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const SIZE_TEXT: Record<ButtonSize, number> = {
|
|
14
|
+
small: 13,
|
|
15
|
+
medium: 15,
|
|
16
|
+
large: 17,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function iosSymbol(token: IconToken): SFSymbol {
|
|
20
|
+
const {symbol} = token;
|
|
21
|
+
if (typeof symbol === 'string') return symbol;
|
|
22
|
+
return symbol.ios ?? 'questionmark';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function swiftControlSize(size: ButtonSize) {
|
|
26
|
+
return ({small: 'small', medium: 'regular', large: 'large'} as const)[size];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function swiftBorderShape(shape: ButtonShape) {
|
|
30
|
+
return ({rounded: 'roundedRectangle', pill: 'capsule', circle: 'circle'} as const)[shape];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function androidContentPadding(size: ButtonSize, hasIcon = false) {
|
|
34
|
+
switch (size) {
|
|
35
|
+
case 'small':
|
|
36
|
+
return {start: hasIcon ? 12 : 16, top: 6, end: 16, bottom: 6};
|
|
37
|
+
case 'medium':
|
|
38
|
+
return {start: hasIcon ? 16 : 24, top: 10, end: 24, bottom: 10};
|
|
39
|
+
case 'large':
|
|
40
|
+
return {start: hasIcon ? 20 : 28, top: 14, end: 28, bottom: 14};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type {IconToken} from '../icons';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cross-platform button.
|
|
5
|
+
*
|
|
6
|
+
* Bridges the SwiftUI `Button` on iOS, the Jetpack Compose Material 3 buttons on
|
|
7
|
+
* Android, and the HTML `<button>` element on web. The prop surface is the
|
|
8
|
+
* intersection of what every platform supports, so the same component renders a
|
|
9
|
+
* native-feeling button everywhere.
|
|
10
|
+
*
|
|
11
|
+
* Variants map to each platform's closest native equivalent:
|
|
12
|
+
*
|
|
13
|
+
* | variant | iOS `buttonStyle` | Android component | web |
|
|
14
|
+
* | ---------- | ------------------- | --------------------- | ---------------- |
|
|
15
|
+
* | `filled` | `borderedProminent` | `Button` | solid fill |
|
|
16
|
+
* | `outlined` | `bordered` | `OutlinedButton` | border, no fill |
|
|
17
|
+
* | `text` | `plain` | `TextButton` | text only |
|
|
18
|
+
*/
|
|
19
|
+
export type ButtonVariant = 'filled' | 'outlined' | 'text';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Semantic role of the button. `destructive` renders the button in a danger
|
|
23
|
+
* color (SwiftUI button role, Material error color, web danger styling).
|
|
24
|
+
*/
|
|
25
|
+
export type ButtonRole = 'default' | 'destructive';
|
|
26
|
+
|
|
27
|
+
/** Control size of the button. */
|
|
28
|
+
export type ButtonSize = 'small' | 'medium' | 'large';
|
|
29
|
+
|
|
30
|
+
/** Border shape of the button. */
|
|
31
|
+
export type ButtonShape = 'rounded' | 'pill' | 'circle';
|
|
32
|
+
|
|
33
|
+
export interface ButtonProps {
|
|
34
|
+
/**
|
|
35
|
+
* Text shown inside the button. Required for accessibility even when
|
|
36
|
+
* `hideLabel` is true (icon-only mode).
|
|
37
|
+
*/
|
|
38
|
+
label: string;
|
|
39
|
+
/** Called when the button is pressed. */
|
|
40
|
+
onPress?: () => void;
|
|
41
|
+
/**
|
|
42
|
+
* Visual emphasis of the button.
|
|
43
|
+
* @default 'filled'
|
|
44
|
+
*/
|
|
45
|
+
variant?: ButtonVariant;
|
|
46
|
+
/**
|
|
47
|
+
* Semantic role of the button.
|
|
48
|
+
* @default 'default'
|
|
49
|
+
*/
|
|
50
|
+
role?: ButtonRole;
|
|
51
|
+
/** Accent color (tint) for the button. Defaults to the platform/theme tint. */
|
|
52
|
+
color?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Control size of the button.
|
|
55
|
+
* @default 'medium'
|
|
56
|
+
*/
|
|
57
|
+
size?: ButtonSize;
|
|
58
|
+
/**
|
|
59
|
+
* Border shape of the button. When omitted, each platform uses its default.
|
|
60
|
+
*/
|
|
61
|
+
shape?: ButtonShape;
|
|
62
|
+
/** Leading icon. Also used as the sole icon when `hideLabel` is true. */
|
|
63
|
+
prefixIcon?: IconToken;
|
|
64
|
+
/** Trailing icon. Ignored when `hideLabel` is true. */
|
|
65
|
+
suffixIcon?: IconToken;
|
|
66
|
+
/** Show only the prefix icon; `label` is kept for accessibility. */
|
|
67
|
+
hideLabel?: boolean;
|
|
68
|
+
/** Disables interaction and dims the button. */
|
|
69
|
+
disabled?: boolean;
|
|
70
|
+
/** Identifier used to locate the component in end-to-end tests. */
|
|
71
|
+
testID?: string;
|
|
72
|
+
}
|
package/src/css.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type {DateTimePickerProps} from './types';
|
|
2
|
+
|
|
3
|
+
import {useState} from 'react';
|
|
4
|
+
import {useMaterialColors, Row, Text, Column, DatePickerDialog, TimePickerDialog} from '@expo/ui/jetpack-compose';
|
|
5
|
+
import {clip, Shapes, padding, clickable, background, fillMaxWidth} from '@expo/ui/jetpack-compose/modifiers';
|
|
6
|
+
import {useColor} from '../theme';
|
|
7
|
+
import {formatValue, useDateValue, withDatePart, withTimePart} from './shared';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Android has no inline date+time control, so the row shows the same iOS-style
|
|
11
|
+
* pill as web — built with Jetpack Compose primitives so it lives natively
|
|
12
|
+
* inside the surrounding `Host`/`FieldGroup`. Pressing the pill opens a
|
|
13
|
+
* Material date dialog and then, for `datetime` mode, a time dialog. Each
|
|
14
|
+
* dialog mounts on demand and unmounts once it reports a value or is dismissed.
|
|
15
|
+
*/
|
|
16
|
+
export function DateTimePicker({
|
|
17
|
+
label,
|
|
18
|
+
value,
|
|
19
|
+
onChange,
|
|
20
|
+
mode = 'datetime',
|
|
21
|
+
minimumDate,
|
|
22
|
+
maximumDate,
|
|
23
|
+
disabled,
|
|
24
|
+
accentColor,
|
|
25
|
+
}: DateTimePickerProps) {
|
|
26
|
+
const [current, setValue] = useDateValue(value, onChange);
|
|
27
|
+
const [stage, setStage] = useState<'idle' | 'date' | 'time'>('idle');
|
|
28
|
+
const [draft, setDraft] = useState<Date | null>(null);
|
|
29
|
+
const colors = useMaterialColors();
|
|
30
|
+
const tint = useColor('tint');
|
|
31
|
+
// The dialogs are tinted with the live accent seed by default so they
|
|
32
|
+
// follow a user-supplied accent even when the native host is not seeded.
|
|
33
|
+
const dialogColor = accentColor ?? tint;
|
|
34
|
+
const labelColor = disabled ? colors.onSurfaceVariant : colors.onSurface;
|
|
35
|
+
const valueColor = disabled ? colors.onSurfaceVariant : (accentColor ?? colors.onSurface);
|
|
36
|
+
const selectableDates = minimumDate || maximumDate ? {start: minimumDate, end: maximumDate} : undefined;
|
|
37
|
+
|
|
38
|
+
const open = () => {
|
|
39
|
+
setStage(mode === 'time' ? 'time' : 'date');
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const handleDate = (picked: Date) => {
|
|
43
|
+
const next = withDatePart(current, picked);
|
|
44
|
+
if (mode === 'datetime') {
|
|
45
|
+
setDraft(next);
|
|
46
|
+
setStage('time');
|
|
47
|
+
} else {
|
|
48
|
+
setValue(next);
|
|
49
|
+
setStage('idle');
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const handleTime = (picked: Date) => {
|
|
54
|
+
setValue(withTimePart(draft ?? current, picked));
|
|
55
|
+
setDraft(null);
|
|
56
|
+
setStage('idle');
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const dismissDate = () => {
|
|
60
|
+
setStage('idle');
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const dismissTime = () => {
|
|
64
|
+
if (draft) {
|
|
65
|
+
setValue(draft);
|
|
66
|
+
setDraft(null);
|
|
67
|
+
}
|
|
68
|
+
setStage('idle');
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<Column modifiers={[fillMaxWidth()]}>
|
|
73
|
+
<Row
|
|
74
|
+
verticalAlignment="center"
|
|
75
|
+
horizontalArrangement="spaceBetween"
|
|
76
|
+
modifiers={[fillMaxWidth()]}>
|
|
77
|
+
{label != null ? <Text color={labelColor}>{label}</Text> : null}
|
|
78
|
+
<Text
|
|
79
|
+
color={valueColor}
|
|
80
|
+
modifiers={[
|
|
81
|
+
clip(Shapes.RoundedCorner(8)),
|
|
82
|
+
background(colors.surfaceContainerHighest),
|
|
83
|
+
...(disabled ? [] : [clickable(open)]),
|
|
84
|
+
padding(12, 6, 12, 6),
|
|
85
|
+
]}>
|
|
86
|
+
{formatValue(current, mode)}
|
|
87
|
+
</Text>
|
|
88
|
+
</Row>
|
|
89
|
+
{stage === 'date' && (
|
|
90
|
+
<DatePickerDialog
|
|
91
|
+
initialDate={(draft ?? current).toISOString()}
|
|
92
|
+
color={dialogColor}
|
|
93
|
+
selectableDates={selectableDates}
|
|
94
|
+
onDateSelected={handleDate}
|
|
95
|
+
onDismissRequest={dismissDate}
|
|
96
|
+
/>
|
|
97
|
+
)}
|
|
98
|
+
{stage === 'time' && (
|
|
99
|
+
<TimePickerDialog
|
|
100
|
+
initialDate={(draft ?? current).toISOString()}
|
|
101
|
+
color={dialogColor}
|
|
102
|
+
onDateSelected={handleTime}
|
|
103
|
+
onDismissRequest={dismissTime}
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
</Column>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type {DateTimeMode, DateTimePickerProps} from './types';
|
|
2
|
+
import type {DatePickerComponent} from '@expo/ui/swift-ui';
|
|
3
|
+
import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
|
|
4
|
+
|
|
5
|
+
import {DatePicker} from '@expo/ui/swift-ui';
|
|
6
|
+
import {datePickerStyle, tint, disabled as disabledMod} from '@expo/ui/swift-ui/modifiers';
|
|
7
|
+
import {useDateValue} from './shared';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* iOS renders the picker inline using SwiftUI's compact `DatePicker`, which is
|
|
11
|
+
* exactly the rounded-pill row look the other platforms emulate. Drop it
|
|
12
|
+
* straight into a `FieldGroup.Section` alongside other rows.
|
|
13
|
+
*/
|
|
14
|
+
export function DateTimePicker({
|
|
15
|
+
label,
|
|
16
|
+
value,
|
|
17
|
+
onChange,
|
|
18
|
+
mode = 'datetime',
|
|
19
|
+
minimumDate,
|
|
20
|
+
maximumDate,
|
|
21
|
+
disabled,
|
|
22
|
+
accentColor,
|
|
23
|
+
testID,
|
|
24
|
+
}: DateTimePickerProps) {
|
|
25
|
+
const [current, setValue] = useDateValue(value, onChange);
|
|
26
|
+
const modifiers: ViewModifier[] = [datePickerStyle('compact')];
|
|
27
|
+
|
|
28
|
+
if (accentColor) modifiers.push(tint(accentColor));
|
|
29
|
+
if (disabled != null) modifiers.push(disabledMod(disabled));
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<DatePicker
|
|
33
|
+
title={label}
|
|
34
|
+
selection={current}
|
|
35
|
+
onDateChange={setValue}
|
|
36
|
+
displayedComponents={modeToComponents(mode)}
|
|
37
|
+
range={minimumDate || maximumDate ? {start: minimumDate, end: maximumDate} : undefined}
|
|
38
|
+
modifiers={modifiers}
|
|
39
|
+
testID={testID}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function modeToComponents(mode: DateTimeMode): DatePickerComponent[] {
|
|
45
|
+
switch (mode) {
|
|
46
|
+
case 'time':
|
|
47
|
+
return ['hourAndMinute'];
|
|
48
|
+
case 'datetime':
|
|
49
|
+
return ['date', 'hourAndMinute'];
|
|
50
|
+
case 'date':
|
|
51
|
+
default:
|
|
52
|
+
return ['date'];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type {ChangeEvent, CSSProperties, MouseEvent} from 'react';
|
|
2
|
+
import type {DateTimePickerProps} from './types';
|
|
3
|
+
|
|
4
|
+
import {Pressable, StyleSheet, View} from 'react-native';
|
|
5
|
+
import {Label} from '../typography';
|
|
6
|
+
import {theme} from '../theme';
|
|
7
|
+
import {formatValue, inputType, parseInputValue, toInputValue, useDateValue} 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 value, with a transparent native
|
|
12
|
+
* `<input>` layered on top. Clicking the pill opens the browser's built-in
|
|
13
|
+
* date/time picker.
|
|
14
|
+
*/
|
|
15
|
+
export function DateTimePicker({
|
|
16
|
+
label,
|
|
17
|
+
value,
|
|
18
|
+
onChange,
|
|
19
|
+
mode = 'datetime',
|
|
20
|
+
minimumDate,
|
|
21
|
+
maximumDate,
|
|
22
|
+
disabled,
|
|
23
|
+
accentColor,
|
|
24
|
+
testID,
|
|
25
|
+
style,
|
|
26
|
+
}: DateTimePickerProps) {
|
|
27
|
+
const [current, setValue] = useDateValue(value, onChange);
|
|
28
|
+
|
|
29
|
+
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
30
|
+
const next = parseInputValue(event.target.value, mode, current);
|
|
31
|
+
if (next) setValue(next);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const openPicker = (event: MouseEvent<HTMLInputElement>) => {
|
|
35
|
+
event.currentTarget.showPicker();
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<View style={[styles.row, style]} testID={testID}>
|
|
40
|
+
{label != null ? (
|
|
41
|
+
<Label
|
|
42
|
+
color="label"
|
|
43
|
+
style={[styles.label, disabled && styles.disabled]}>
|
|
44
|
+
{label}
|
|
45
|
+
</Label>
|
|
46
|
+
) : null}
|
|
47
|
+
<Pressable
|
|
48
|
+
disabled={disabled}
|
|
49
|
+
style={[styles.pill, disabled && styles.disabled]}>
|
|
50
|
+
{/* Label-colored value — iOS's compact DatePicker pill uses the
|
|
51
|
+
primary label color (unlike the menu Picker's gray value). */}
|
|
52
|
+
<Label
|
|
53
|
+
color="label"
|
|
54
|
+
style={[styles.value, accentColor != null && {color: accentColor}]}>
|
|
55
|
+
{formatValue(current, mode)}
|
|
56
|
+
</Label>
|
|
57
|
+
<input
|
|
58
|
+
type={inputType(mode)}
|
|
59
|
+
value={toInputValue(current, mode)}
|
|
60
|
+
min={minimumDate ? toInputValue(minimumDate, mode) : undefined}
|
|
61
|
+
max={maximumDate ? toInputValue(maximumDate, mode) : undefined}
|
|
62
|
+
disabled={disabled}
|
|
63
|
+
aria-label={label ?? 'Select date'}
|
|
64
|
+
onChange={handleChange}
|
|
65
|
+
onClick={openPicker}
|
|
66
|
+
style={{...overlayStyle, cursor: disabled ? 'default' : 'pointer'}}
|
|
67
|
+
/>
|
|
68
|
+
</Pressable>
|
|
69
|
+
</View>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const styles = StyleSheet.create({
|
|
74
|
+
row: {
|
|
75
|
+
flexDirection: 'row',
|
|
76
|
+
alignItems: 'center',
|
|
77
|
+
justifyContent: 'space-between',
|
|
78
|
+
gap: 8,
|
|
79
|
+
width: '100%',
|
|
80
|
+
},
|
|
81
|
+
label: {
|
|
82
|
+
flexShrink: 1,
|
|
83
|
+
},
|
|
84
|
+
pill: {
|
|
85
|
+
position: 'relative',
|
|
86
|
+
flexShrink: 0,
|
|
87
|
+
borderRadius: 8,
|
|
88
|
+
paddingHorizontal: 11,
|
|
89
|
+
paddingVertical: 4,
|
|
90
|
+
backgroundColor: theme.pillBackground,
|
|
91
|
+
},
|
|
92
|
+
value: {
|
|
93
|
+
flexShrink: 0,
|
|
94
|
+
},
|
|
95
|
+
disabled: {
|
|
96
|
+
opacity: 0.4,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const overlayStyle: CSSProperties = {
|
|
101
|
+
position: 'absolute',
|
|
102
|
+
top: 0,
|
|
103
|
+
left: 0,
|
|
104
|
+
right: 0,
|
|
105
|
+
bottom: 0,
|
|
106
|
+
width: '100%',
|
|
107
|
+
height: '100%',
|
|
108
|
+
margin: 0,
|
|
109
|
+
padding: 0,
|
|
110
|
+
border: 'none',
|
|
111
|
+
opacity: 0,
|
|
112
|
+
appearance: 'none',
|
|
113
|
+
// The native picker popup derives its colors from the input's own
|
|
114
|
+
// background/color (not `:root`), so theme them via CSS vars. `opacity: 0`
|
|
115
|
+
// keeps the overlay box invisible; the popup still uses these colors.
|
|
116
|
+
colorScheme: 'light dark',
|
|
117
|
+
color: 'var(--color-label)',
|
|
118
|
+
background: 'var(--color-background)',
|
|
119
|
+
};
|