expo-interface 0.1.0 → 0.1.1
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 +260 -180
- package/package.json +41 -10
- package/src/alert/alert.css +49 -0
- package/src/alert/index.android.tsx +68 -0
- package/src/alert/index.ios.tsx +47 -0
- package/src/alert/index.tsx +61 -0
- package/src/alert/shared.ts +10 -0
- package/src/alert/types.ts +58 -0
- package/src/button/index.tsx +17 -1
- package/src/checkbox/checkbox.css +29 -0
- package/src/checkbox/index.android.tsx +54 -0
- package/src/checkbox/index.ios.tsx +50 -0
- package/src/checkbox/index.tsx +50 -0
- package/src/checkbox/types.ts +27 -0
- package/src/collapsible/collapsible.css +32 -0
- package/src/collapsible/index.android.tsx +34 -0
- package/src/collapsible/index.ios.tsx +31 -0
- package/src/collapsible/index.tsx +41 -0
- package/src/collapsible/shared.ts +21 -0
- package/src/collapsible/types.ts +24 -0
- package/src/context-menu/index.android.tsx +27 -0
- package/src/context-menu/index.ios.tsx +22 -0
- package/src/context-menu/index.tsx +56 -0
- package/src/date-time/index.android.tsx +3 -2
- package/src/divider/divider.css +16 -0
- package/src/divider/index.android.tsx +26 -0
- package/src/divider/index.ios.tsx +18 -0
- package/src/divider/index.tsx +21 -0
- package/src/divider/types.ts +20 -0
- package/src/index.ts +20 -2
- package/src/menu/index.android.tsx +66 -0
- package/src/menu/index.ios.tsx +72 -0
- package/src/menu/index.tsx +30 -0
- package/src/menu/list.tsx +98 -0
- package/src/menu/menu.css +83 -0
- package/src/menu/types.ts +60 -0
- package/src/picker/index.android.tsx +3 -2
- package/src/progress/index.android.tsx +23 -9
- package/src/progress/index.ios.tsx +7 -6
- package/src/progress/index.tsx +43 -6
- package/src/progress/progress.css +24 -0
- package/src/progress/types.ts +23 -6
- package/src/qr/index.tsx +2 -2
- package/src/segmented/index.android.tsx +67 -0
- package/src/segmented/index.ios.tsx +47 -0
- package/src/segmented/index.tsx +58 -0
- package/src/segmented/segmented.css +56 -0
- package/src/segmented/types.ts +31 -0
- package/src/slider/index.android.tsx +87 -0
- package/src/slider/index.ios.tsx +64 -0
- package/src/slider/index.tsx +55 -0
- package/src/slider/slider.css +30 -0
- package/src/slider/types.ts +39 -0
- package/src/stepper/index.android.tsx +58 -0
- package/src/stepper/index.ios.tsx +55 -0
- package/src/stepper/index.tsx +47 -0
- package/src/stepper/shared.ts +17 -0
- package/src/stepper/stepper.css +61 -0
- package/src/stepper/types.ts +35 -0
- package/src/text-field/shared.ts +2 -0
- package/src/tooltip/index.android.tsx +23 -0
- package/src/tooltip/index.ios.tsx +17 -0
- package/src/tooltip/index.tsx +41 -0
- package/src/tooltip/tooltip.css +40 -0
- package/src/tooltip/types.ts +25 -0
- package/src/link.ts +0 -36
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type {AlertProps} from './types';
|
|
2
|
+
|
|
3
|
+
import {AlertDialog, Column, Row, Text, useMaterialColors} from '@expo/ui/jetpack-compose';
|
|
4
|
+
import {testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
|
|
5
|
+
import {Button} from '../button';
|
|
6
|
+
import {useColor} from '../theme';
|
|
7
|
+
import {DEFAULT_ACTIONS, splitActions} from './shared';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Android renders the Material 3 `AlertDialog` while `visible`. The dialog
|
|
11
|
+
* has two button slots: the `cancel` action takes the dismiss slot and the
|
|
12
|
+
* remaining actions share the confirm slot as a row of text buttons (a
|
|
13
|
+
* column with `sheet`, mirroring the stacked iOS action sheet). Buttons are
|
|
14
|
+
* tinted with the live accent seed so they follow a user-supplied accent even
|
|
15
|
+
* when the native host is not seeded.
|
|
16
|
+
*/
|
|
17
|
+
export function Alert({title, message, visible, onDismiss, actions = DEFAULT_ACTIONS, sheet, children, testID}: AlertProps) {
|
|
18
|
+
const colors = useMaterialColors();
|
|
19
|
+
const tint = useColor('tint');
|
|
20
|
+
const {cancel, others} = splitActions(actions);
|
|
21
|
+
const Actions = sheet ? Column : Row;
|
|
22
|
+
const press = (action: {onPress?: () => void}) => () => {
|
|
23
|
+
action.onPress?.();
|
|
24
|
+
onDismiss?.();
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<>
|
|
29
|
+
{children}
|
|
30
|
+
{visible ? (
|
|
31
|
+
<AlertDialog
|
|
32
|
+
onDismissRequest={onDismiss}
|
|
33
|
+
colors={{containerColor: colors.surfaceContainerHigh}}
|
|
34
|
+
modifiers={testID ? [testIDModifier(testID)] : undefined}>
|
|
35
|
+
<AlertDialog.Title>
|
|
36
|
+
<Text color={colors.onSurface} style={{typography: 'headlineSmall'}}>{title}</Text>
|
|
37
|
+
</AlertDialog.Title>
|
|
38
|
+
{message ? (
|
|
39
|
+
<AlertDialog.Text>
|
|
40
|
+
<Text color={colors.onSurfaceVariant} style={{typography: 'bodyMedium'}}>{message}</Text>
|
|
41
|
+
</AlertDialog.Text>
|
|
42
|
+
) : null}
|
|
43
|
+
{others.length > 0 ? (
|
|
44
|
+
<AlertDialog.ConfirmButton>
|
|
45
|
+
<Actions>
|
|
46
|
+
{others.map((action, index) => (
|
|
47
|
+
<Button
|
|
48
|
+
key={index}
|
|
49
|
+
label={action.label}
|
|
50
|
+
variant="text"
|
|
51
|
+
color={action.role === 'destructive' ? undefined : tint}
|
|
52
|
+
role={action.role === 'destructive' ? 'destructive' : 'default'}
|
|
53
|
+
onPress={press(action)}
|
|
54
|
+
/>
|
|
55
|
+
))}
|
|
56
|
+
</Actions>
|
|
57
|
+
</AlertDialog.ConfirmButton>
|
|
58
|
+
) : null}
|
|
59
|
+
{cancel ? (
|
|
60
|
+
<AlertDialog.DismissButton>
|
|
61
|
+
<Button label={cancel.label} variant="text" color={tint} onPress={press(cancel)}/>
|
|
62
|
+
</AlertDialog.DismissButton>
|
|
63
|
+
) : null}
|
|
64
|
+
</AlertDialog>
|
|
65
|
+
) : null}
|
|
66
|
+
</>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type {AlertProps} from './types';
|
|
2
|
+
|
|
3
|
+
import {Alert as SwiftUIAlert, Button, ConfirmationDialog, Spacer, Text} from '@expo/ui/swift-ui';
|
|
4
|
+
import {frame} from '@expo/ui/swift-ui/modifiers';
|
|
5
|
+
import {DEFAULT_ACTIONS} from './shared';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* iOS renders SwiftUI's `Alert`, or `ConfirmationDialog` (the action sheet)
|
|
9
|
+
* with `sheet`. SwiftUI presents from a view in the hierarchy, so the trigger
|
|
10
|
+
* slot holds `children` or, when none is given, a zero-size `Spacer` anchor.
|
|
11
|
+
* Action buttons carry their SwiftUI role (`cancel` bold / `destructive` red)
|
|
12
|
+
* and dismiss automatically; the presented-state change then reports
|
|
13
|
+
* `onDismiss`.
|
|
14
|
+
*/
|
|
15
|
+
export function Alert({title, message, visible, onDismiss, actions = DEFAULT_ACTIONS, sheet, children, testID}: AlertProps) {
|
|
16
|
+
const Component = sheet ? ConfirmationDialog : SwiftUIAlert;
|
|
17
|
+
const onPresentedChange = (presented: boolean) => {
|
|
18
|
+
if (!presented) onDismiss?.();
|
|
19
|
+
};
|
|
20
|
+
return (
|
|
21
|
+
<Component
|
|
22
|
+
title={title}
|
|
23
|
+
isPresented={visible}
|
|
24
|
+
onIsPresentedChange={onPresentedChange}
|
|
25
|
+
testID={testID}
|
|
26
|
+
{...(sheet ? {titleVisibility: 'visible' as const} : null)}>
|
|
27
|
+
<Component.Trigger>
|
|
28
|
+
{children ?? <Spacer modifiers={[frame({width: 0, height: 0})]}/>}
|
|
29
|
+
</Component.Trigger>
|
|
30
|
+
{message ? (
|
|
31
|
+
<Component.Message>
|
|
32
|
+
<Text>{message}</Text>
|
|
33
|
+
</Component.Message>
|
|
34
|
+
) : null}
|
|
35
|
+
<Component.Actions>
|
|
36
|
+
{actions.map((action, index) => (
|
|
37
|
+
<Button
|
|
38
|
+
key={index}
|
|
39
|
+
label={action.label}
|
|
40
|
+
role={action.role ?? 'default'}
|
|
41
|
+
onPress={action.onPress}
|
|
42
|
+
/>
|
|
43
|
+
))}
|
|
44
|
+
</Component.Actions>
|
|
45
|
+
</Component>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import './alert.css';
|
|
2
|
+
import type {SyntheticEvent} from 'react';
|
|
3
|
+
import type {AlertProps} from './types';
|
|
4
|
+
import {useEffect, useRef} from 'react';
|
|
5
|
+
import {Button} from '../button';
|
|
6
|
+
import {Body, Headline} from '../typography';
|
|
7
|
+
import {DEFAULT_ACTIONS, splitActions} from './shared';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* On web the alert is a real `<dialog>` opened with `showModal()`, so it sits
|
|
11
|
+
* in the top layer with a backdrop, traps focus, and closes on Escape.
|
|
12
|
+
* Actions render as the kit's text buttons; `sheet` anchors the dialog to
|
|
13
|
+
* the bottom edge with the actions stacked, like an iOS action sheet.
|
|
14
|
+
*/
|
|
15
|
+
export function Alert({title, message, visible, onDismiss, actions = DEFAULT_ACTIONS, sheet, children, testID}: AlertProps) {
|
|
16
|
+
const ref = useRef<HTMLDialogElement>(null);
|
|
17
|
+
const {cancel, others} = splitActions(actions);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const dialog = ref.current;
|
|
21
|
+
if (!dialog) return;
|
|
22
|
+
if (visible && !dialog.open) dialog.showModal();
|
|
23
|
+
else if (!visible && dialog.open) dialog.close();
|
|
24
|
+
}, [visible]);
|
|
25
|
+
|
|
26
|
+
const onBackdrop = (event: SyntheticEvent<HTMLDialogElement, MouseEvent>) => {
|
|
27
|
+
if (event.target === ref.current) ref.current?.close();
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
{children}
|
|
33
|
+
<dialog
|
|
34
|
+
ref={ref}
|
|
35
|
+
className={['ui-alert', sheet && 'ui-alert--sheet'].filter(Boolean).join(' ')}
|
|
36
|
+
aria-label={title}
|
|
37
|
+
onClose={onDismiss}
|
|
38
|
+
onClick={onBackdrop}
|
|
39
|
+
data-testid={testID}>
|
|
40
|
+
<div className="ui-alert__body">
|
|
41
|
+
<Headline testID={testID ? `${testID}-title` : undefined}>{title}</Headline>
|
|
42
|
+
{message ? <Body color="secondaryLabel">{message}</Body> : null}
|
|
43
|
+
</div>
|
|
44
|
+
<div className="ui-alert__actions">
|
|
45
|
+
{[...others, ...(cancel ? [cancel] : [])].map((action, index) => (
|
|
46
|
+
<Button
|
|
47
|
+
key={index}
|
|
48
|
+
label={action.label}
|
|
49
|
+
variant={sheet ? 'outlined' : 'text'}
|
|
50
|
+
role={action.role === 'destructive' ? 'destructive' : 'default'}
|
|
51
|
+
onPress={() => {
|
|
52
|
+
action.onPress?.();
|
|
53
|
+
ref.current?.close();
|
|
54
|
+
}}
|
|
55
|
+
/>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
</dialog>
|
|
59
|
+
</>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type {AlertAction} from './types';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_ACTIONS: AlertAction[] = [{label: 'OK', role: 'cancel'}];
|
|
4
|
+
|
|
5
|
+
/** Splits actions into the cancel action (at most one) and the rest. */
|
|
6
|
+
export function splitActions(actions: AlertAction[] = DEFAULT_ACTIONS) {
|
|
7
|
+
const cancel = actions.find(action => action.role === 'cancel');
|
|
8
|
+
const others = actions.filter(action => action !== cancel);
|
|
9
|
+
return {cancel, others};
|
|
10
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type {ReactNode} from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Semantic role of an alert action. `cancel` is the dismissive action (bold
|
|
5
|
+
* on iOS, the dismiss slot on Android); `destructive` is rendered in the
|
|
6
|
+
* danger color.
|
|
7
|
+
*/
|
|
8
|
+
export type AlertActionRole = 'default' | 'cancel' | 'destructive';
|
|
9
|
+
|
|
10
|
+
export interface AlertAction {
|
|
11
|
+
/** Button text. */
|
|
12
|
+
label: string;
|
|
13
|
+
/**
|
|
14
|
+
* Role of the action.
|
|
15
|
+
* @default 'default'
|
|
16
|
+
*/
|
|
17
|
+
role?: AlertActionRole;
|
|
18
|
+
/** Called when the action is pressed; the alert then closes. */
|
|
19
|
+
onPress?: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Cross-platform alert dialog.
|
|
24
|
+
*
|
|
25
|
+
* Bridges the SwiftUI `Alert` (or `ConfirmationDialog` action sheet with
|
|
26
|
+
* `sheet`) on iOS, the Jetpack Compose Material 3 `AlertDialog` on Android,
|
|
27
|
+
* and the HTML `<dialog>` element on web. Presentation is controlled: set
|
|
28
|
+
* `visible` and clear it from `onDismiss`, which fires whenever the alert
|
|
29
|
+
* closes — after any action, or when the user dismisses it.
|
|
30
|
+
*/
|
|
31
|
+
export interface AlertProps {
|
|
32
|
+
/** Title shown at the top of the alert. */
|
|
33
|
+
title: string;
|
|
34
|
+
/** Optional body text under the title. */
|
|
35
|
+
message?: string;
|
|
36
|
+
/** Whether the alert is presented. */
|
|
37
|
+
visible: boolean;
|
|
38
|
+
/** Called when the alert closes for any reason. */
|
|
39
|
+
onDismiss?: () => void;
|
|
40
|
+
/**
|
|
41
|
+
* Buttons shown in the alert.
|
|
42
|
+
* @default [{label: 'OK', role: 'cancel'}]
|
|
43
|
+
*/
|
|
44
|
+
actions?: AlertAction[];
|
|
45
|
+
/**
|
|
46
|
+
* Present as an action sheet (iOS `confirmationDialog`, bottom-anchored on
|
|
47
|
+
* web) with actions stacked vertically, instead of a centered alert.
|
|
48
|
+
*/
|
|
49
|
+
sheet?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Optional trigger rendered in place (for example the `Button` that opens
|
|
52
|
+
* the alert). SwiftUI presents alerts from a view in the hierarchy, so on
|
|
53
|
+
* iOS an invisible zero-size anchor is used when no trigger is given.
|
|
54
|
+
*/
|
|
55
|
+
children?: ReactNode;
|
|
56
|
+
/** Identifier used to locate the component in end-to-end tests. */
|
|
57
|
+
testID?: string;
|
|
58
|
+
}
|
package/src/button/index.tsx
CHANGED
|
@@ -6,6 +6,18 @@ import {onAccent as contrastOf} from '../accent';
|
|
|
6
6
|
import {useColor} from '../theme';
|
|
7
7
|
import {SIZE_ICON} from './shared';
|
|
8
8
|
|
|
9
|
+
/** Web-only additions: hook the button up to a native `popover` element. */
|
|
10
|
+
interface WebButtonProps extends ButtonProps {
|
|
11
|
+
/**
|
|
12
|
+
* `id` of a `[popover]` element this button toggles (the `popovertarget`
|
|
13
|
+
* attribute). The browser then manages open/close, `aria-expanded` and
|
|
14
|
+
* light-dismiss without JavaScript.
|
|
15
|
+
*/
|
|
16
|
+
popoverTarget?: string;
|
|
17
|
+
/** @default 'toggle' */
|
|
18
|
+
popoverTargetAction?: 'toggle' | 'show' | 'hide';
|
|
19
|
+
}
|
|
20
|
+
|
|
9
21
|
/**
|
|
10
22
|
* On web the button is a real `<button>` element styled via `button.css`, so it
|
|
11
23
|
* reads as a native web control rather than a ported Android/Material button.
|
|
@@ -24,7 +36,9 @@ export function Button({
|
|
|
24
36
|
hideLabel = false,
|
|
25
37
|
disabled = false,
|
|
26
38
|
testID,
|
|
27
|
-
|
|
39
|
+
popoverTarget,
|
|
40
|
+
popoverTargetAction,
|
|
41
|
+
}: WebButtonProps) {
|
|
28
42
|
const themeTint = useColor('tint');
|
|
29
43
|
const destructive = useColor('destructive');
|
|
30
44
|
const themeOnAccent = useColor(role === 'destructive' ? 'onDestructive' : 'onTint');
|
|
@@ -53,6 +67,8 @@ export function Button({
|
|
|
53
67
|
className={className}
|
|
54
68
|
disabled={disabled}
|
|
55
69
|
onClick={onPress}
|
|
70
|
+
popoverTarget={popoverTarget}
|
|
71
|
+
popoverTargetAction={popoverTarget ? popoverTargetAction : undefined}
|
|
56
72
|
data-testid={testID}
|
|
57
73
|
aria-label={iconOnly ? label : undefined}>
|
|
58
74
|
{prefixIcon ? (
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
.ui-checkbox {
|
|
2
|
+
--ui-checkbox-accent: var(--color-tint);
|
|
3
|
+
display: flex;
|
|
4
|
+
flex-direction: row;
|
|
5
|
+
align-items: center;
|
|
6
|
+
justify-content: space-between;
|
|
7
|
+
gap: 8px;
|
|
8
|
+
width: 100%;
|
|
9
|
+
cursor: pointer;
|
|
10
|
+
user-select: none;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.ui-checkbox__input {
|
|
14
|
+
flex-shrink: 0;
|
|
15
|
+
width: 20px;
|
|
16
|
+
height: 20px;
|
|
17
|
+
margin: 0;
|
|
18
|
+
cursor: pointer;
|
|
19
|
+
accent-color: var(--ui-checkbox-accent);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.ui-checkbox--disabled,
|
|
23
|
+
.ui-checkbox__input:disabled {
|
|
24
|
+
cursor: default;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.ui-checkbox--disabled {
|
|
28
|
+
opacity: 0.4;
|
|
29
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type {CheckboxProps} from './types';
|
|
2
|
+
|
|
3
|
+
import {Checkbox as ComposeCheckbox, Row, Text, useMaterialColors} from '@expo/ui/jetpack-compose';
|
|
4
|
+
import {fillMaxWidth, testID as testIDModifier, toggleable} from '@expo/ui/jetpack-compose/modifiers';
|
|
5
|
+
import {onAccent} from '../accent';
|
|
6
|
+
import {useColor} from '../theme';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Android renders the Material 3 `Checkbox`. The row fills the available
|
|
10
|
+
* width and pins the box to the trailing edge, mirroring the iOS Form row,
|
|
11
|
+
* and the checked state is colored with the live accent seed so it matches
|
|
12
|
+
* the iOS glyph and the web `accent-color` — including inside sheets whose
|
|
13
|
+
* native host is not seeded.
|
|
14
|
+
*/
|
|
15
|
+
export function Checkbox({
|
|
16
|
+
label,
|
|
17
|
+
value,
|
|
18
|
+
onValueChange,
|
|
19
|
+
disabled,
|
|
20
|
+
accentColor,
|
|
21
|
+
testID,
|
|
22
|
+
}: CheckboxProps) {
|
|
23
|
+
const colors = useMaterialColors();
|
|
24
|
+
const tint = useColor('tint');
|
|
25
|
+
const accent = accentColor ?? tint;
|
|
26
|
+
const box = (
|
|
27
|
+
<ComposeCheckbox
|
|
28
|
+
value={value}
|
|
29
|
+
onCheckedChange={disabled ? undefined : onValueChange}
|
|
30
|
+
enabled={!disabled}
|
|
31
|
+
colors={{
|
|
32
|
+
checkedColor: accent,
|
|
33
|
+
checkmarkColor: onAccent(accent),
|
|
34
|
+
uncheckedColor: colors.onSurfaceVariant,
|
|
35
|
+
}}
|
|
36
|
+
modifiers={testID ? [testIDModifier(testID)] : []}
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
if (label == null) return box;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Row
|
|
44
|
+
verticalAlignment="center"
|
|
45
|
+
horizontalArrangement="spaceBetween"
|
|
46
|
+
modifiers={[
|
|
47
|
+
fillMaxWidth(),
|
|
48
|
+
...(disabled ? [] : [toggleable(value, () => onValueChange(!value), {role: 'checkbox'})]),
|
|
49
|
+
]}>
|
|
50
|
+
<Text color={disabled ? colors.onSurfaceVariant : colors.onSurface}>{label}</Text>
|
|
51
|
+
{box}
|
|
52
|
+
</Row>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type {CheckboxProps} from './types';
|
|
2
|
+
import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
|
|
3
|
+
|
|
4
|
+
import {Button, HStack, Image, Spacer, Text} from '@expo/ui/swift-ui';
|
|
5
|
+
import {buttonStyle, disabled as disabledMod, foregroundStyle} from '@expo/ui/swift-ui/modifiers';
|
|
6
|
+
import {fillWidth} from '../fill';
|
|
7
|
+
import {useColor} from '../theme';
|
|
8
|
+
|
|
9
|
+
const ICON_SIZE = 22;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* SwiftUI on iOS has no checkbox control (the `.checkbox` toggle style is
|
|
13
|
+
* macOS only), so the row is the platform idiom instead: a plain `Button`
|
|
14
|
+
* holding the label and a tinted `checkmark.square.fill` / `square` glyph
|
|
15
|
+
* pinned to the trailing edge. The glyph follows the accent seed like the
|
|
16
|
+
* Host `tint` cascade; `accentColor` overrides it per instance.
|
|
17
|
+
*/
|
|
18
|
+
export function Checkbox({
|
|
19
|
+
label,
|
|
20
|
+
value,
|
|
21
|
+
onValueChange,
|
|
22
|
+
disabled,
|
|
23
|
+
accentColor,
|
|
24
|
+
testID,
|
|
25
|
+
}: CheckboxProps) {
|
|
26
|
+
const tint = useColor('tint');
|
|
27
|
+
const labelColor = useColor(disabled ? 'secondaryLabel' : 'label');
|
|
28
|
+
const modifiers: ViewModifier[] = [buttonStyle('plain')];
|
|
29
|
+
if (disabled) modifiers.push(disabledMod(true));
|
|
30
|
+
|
|
31
|
+
const glyph = (
|
|
32
|
+
<Image
|
|
33
|
+
systemName={value ? 'checkmark.square.fill' : 'square'}
|
|
34
|
+
color={value ? (accentColor ?? tint) : labelColor}
|
|
35
|
+
size={ICON_SIZE}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Button onPress={() => onValueChange(!value)} modifiers={modifiers} testID={testID}>
|
|
41
|
+
{label == null ? glyph : (
|
|
42
|
+
<HStack spacing={8} modifiers={fillWidth}>
|
|
43
|
+
<Text modifiers={[foregroundStyle(labelColor)]}>{label}</Text>
|
|
44
|
+
<Spacer/>
|
|
45
|
+
{glyph}
|
|
46
|
+
</HStack>
|
|
47
|
+
)}
|
|
48
|
+
</Button>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import './checkbox.css';
|
|
2
|
+
import type {CSSProperties} from 'react';
|
|
3
|
+
import type {CheckboxProps} from './types';
|
|
4
|
+
import {StyleSheet, type TextStyle} from 'react-native';
|
|
5
|
+
import {Label} from '../typography';
|
|
6
|
+
import {flatten} from '../theme';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* On web the box is a native `<input type="checkbox">` themed through the
|
|
10
|
+
* `accent-color` CSS property. The whole row is a `<label>`, so clicking the
|
|
11
|
+
* text toggles the box, and the layout mirrors the native rows: label on the
|
|
12
|
+
* leading edge, box pinned to the trailing edge.
|
|
13
|
+
*/
|
|
14
|
+
export function Checkbox({
|
|
15
|
+
label,
|
|
16
|
+
value,
|
|
17
|
+
onValueChange,
|
|
18
|
+
disabled,
|
|
19
|
+
accentColor,
|
|
20
|
+
testID,
|
|
21
|
+
style,
|
|
22
|
+
}: CheckboxProps) {
|
|
23
|
+
const vars = {
|
|
24
|
+
...(accentColor ? {'--ui-checkbox-accent': accentColor} : null),
|
|
25
|
+
...flatten((StyleSheet.flatten(style) ?? undefined) as TextStyle | undefined),
|
|
26
|
+
} as CSSProperties;
|
|
27
|
+
const input = (
|
|
28
|
+
<input
|
|
29
|
+
className="ui-checkbox__input"
|
|
30
|
+
type="checkbox"
|
|
31
|
+
checked={value}
|
|
32
|
+
disabled={disabled}
|
|
33
|
+
onChange={event => onValueChange(event.target.checked)}
|
|
34
|
+
data-testid={label == null ? testID : undefined}
|
|
35
|
+
style={label == null ? vars : undefined}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
if (label == null) return input;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<label
|
|
43
|
+
className={['ui-checkbox', disabled && 'ui-checkbox--disabled'].filter(Boolean).join(' ')}
|
|
44
|
+
style={vars}
|
|
45
|
+
data-testid={testID}>
|
|
46
|
+
<Label color="label" style={{flexShrink: 1}}>{label}</Label>
|
|
47
|
+
{input}
|
|
48
|
+
</label>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type {StyleProp, ViewStyle} from 'react-native';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cross-platform checkbox with a conformed Form-row appearance.
|
|
5
|
+
*
|
|
6
|
+
* Bridges a SwiftUI checkmark button on iOS (iOS has no native checkbox
|
|
7
|
+
* control — a tinted `checkmark.square` glyph is the platform idiom), the
|
|
8
|
+
* Jetpack Compose Material 3 `Checkbox` on Android, and the HTML
|
|
9
|
+
* `<input type="checkbox">` element on web. A controlled control: pair
|
|
10
|
+
* `value` with `onValueChange`.
|
|
11
|
+
*/
|
|
12
|
+
export interface CheckboxProps {
|
|
13
|
+
/** Label rendered at the leading edge of the row, mirroring an iOS Form row. */
|
|
14
|
+
label?: string;
|
|
15
|
+
/** Whether the box is checked. */
|
|
16
|
+
value: boolean;
|
|
17
|
+
/** Called when the user toggles the box. */
|
|
18
|
+
onValueChange: (value: boolean) => void;
|
|
19
|
+
/** Disables interaction. */
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
/** Tint applied to the checked box (overrides the theme accent tint). */
|
|
22
|
+
accentColor?: string;
|
|
23
|
+
/** Identifier used to locate the component in end-to-end tests. */
|
|
24
|
+
testID?: string;
|
|
25
|
+
/** Style applied to the row container (web only). */
|
|
26
|
+
style?: StyleProp<ViewStyle>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
.ui-collapsible {
|
|
2
|
+
width: 100%;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.ui-collapsible__summary {
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-direction: row;
|
|
8
|
+
align-items: center;
|
|
9
|
+
justify-content: space-between;
|
|
10
|
+
gap: 8px;
|
|
11
|
+
list-style: none;
|
|
12
|
+
cursor: pointer;
|
|
13
|
+
user-select: none;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.ui-collapsible__summary::-webkit-details-marker {
|
|
17
|
+
display: none;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.ui-collapsible__chevron {
|
|
21
|
+
flex-shrink: 0;
|
|
22
|
+
color: var(--color-tertiary-label);
|
|
23
|
+
transition: transform 0.15s ease;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.ui-collapsible[open] .ui-collapsible__chevron {
|
|
27
|
+
transform: rotate(90deg);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.ui-collapsible__content {
|
|
31
|
+
padding-top: 8px;
|
|
32
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type {CollapsibleProps} from './types';
|
|
2
|
+
|
|
3
|
+
import {Collapsible as UICollapsible} from '@expo/ui';
|
|
4
|
+
import {Column} from '@expo/ui/jetpack-compose';
|
|
5
|
+
import {fillMaxWidth, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
|
|
6
|
+
import {useColor} from '../theme';
|
|
7
|
+
import {useExpanded} from './shared';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Android renders `@expo/ui`'s universal `Collapsible`, which is already the
|
|
11
|
+
* Material 3 Expressive expandable list item (rounded card whose container
|
|
12
|
+
* tints from transparent to `surfaceContainer` while open, animated chevron).
|
|
13
|
+
* It reads the seeded Host palette, so it matches the rest of the screen.
|
|
14
|
+
*/
|
|
15
|
+
export function Collapsible({
|
|
16
|
+
label,
|
|
17
|
+
expanded,
|
|
18
|
+
defaultExpanded = false,
|
|
19
|
+
onExpandedChange,
|
|
20
|
+
children,
|
|
21
|
+
testID,
|
|
22
|
+
}: CollapsibleProps) {
|
|
23
|
+
const [open, setOpen] = useExpanded(expanded, defaultExpanded, onExpandedChange);
|
|
24
|
+
const labelColor = useColor('label');
|
|
25
|
+
const collapsible = (
|
|
26
|
+
<UICollapsible isOpen={open} onOpenChange={setOpen} label={label} labelStyle={{color: labelColor}}>
|
|
27
|
+
{children}
|
|
28
|
+
</UICollapsible>
|
|
29
|
+
);
|
|
30
|
+
// `@expo/ui`'s universal Collapsible takes no testID, so a full-width
|
|
31
|
+
// Compose wrapper carries it.
|
|
32
|
+
if (testID == null) return collapsible;
|
|
33
|
+
return <Column modifiers={[fillMaxWidth(), testIDModifier(testID)]}>{collapsible}</Column>;
|
|
34
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type {CollapsibleProps} from './types';
|
|
2
|
+
|
|
3
|
+
import {DisclosureGroup, Text} from '@expo/ui/swift-ui';
|
|
4
|
+
import {foregroundStyle} from '@expo/ui/swift-ui/modifiers';
|
|
5
|
+
import {useColor} from '../theme';
|
|
6
|
+
import {useExpanded} from './shared';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* iOS renders SwiftUI's `DisclosureGroup`: a row with the label and a
|
|
10
|
+
* trailing chevron that rotates open, with the content revealed below. Drop
|
|
11
|
+
* it straight into a `FieldGroup.Section`.
|
|
12
|
+
*/
|
|
13
|
+
export function Collapsible({
|
|
14
|
+
label,
|
|
15
|
+
expanded,
|
|
16
|
+
defaultExpanded = false,
|
|
17
|
+
onExpandedChange,
|
|
18
|
+
children,
|
|
19
|
+
testID,
|
|
20
|
+
}: CollapsibleProps) {
|
|
21
|
+
const [open, setOpen] = useExpanded(expanded, defaultExpanded, onExpandedChange);
|
|
22
|
+
const labelColor = useColor('label');
|
|
23
|
+
return (
|
|
24
|
+
<DisclosureGroup isExpanded={open} onIsExpandedChange={setOpen} testID={testID}>
|
|
25
|
+
<DisclosureGroup.Label>
|
|
26
|
+
<Text modifiers={[foregroundStyle(labelColor)]}>{label}</Text>
|
|
27
|
+
</DisclosureGroup.Label>
|
|
28
|
+
{children}
|
|
29
|
+
</DisclosureGroup>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import './collapsible.css';
|
|
2
|
+
import type {SyntheticEvent} from 'react';
|
|
3
|
+
import type {CollapsibleProps} from './types';
|
|
4
|
+
import {Label} from '../typography';
|
|
5
|
+
import {useExpanded} from './shared';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* On web the disclosure is a real `<details>` element: the header is its
|
|
9
|
+
* `<summary>` with a trailing chevron that rotates while open, mirroring the
|
|
10
|
+
* iOS `DisclosureGroup` row.
|
|
11
|
+
*/
|
|
12
|
+
export function Collapsible({
|
|
13
|
+
label,
|
|
14
|
+
expanded,
|
|
15
|
+
defaultExpanded = false,
|
|
16
|
+
onExpandedChange,
|
|
17
|
+
children,
|
|
18
|
+
testID,
|
|
19
|
+
}: CollapsibleProps) {
|
|
20
|
+
const [open, setOpen] = useExpanded(expanded, defaultExpanded, onExpandedChange);
|
|
21
|
+
const onToggle = (event: SyntheticEvent<HTMLDetailsElement>) => {
|
|
22
|
+
const next = event.currentTarget.open;
|
|
23
|
+
if (next === open) return;
|
|
24
|
+
// Controlled: snap the DOM back to the prop so `expanded` stays the
|
|
25
|
+
// source of truth — React re-applies the parent's decision on the next
|
|
26
|
+
// render, or the element stays put when the change was ignored.
|
|
27
|
+
if (expanded !== undefined) event.currentTarget.open = open;
|
|
28
|
+
setOpen(next);
|
|
29
|
+
};
|
|
30
|
+
return (
|
|
31
|
+
<details className="ui-collapsible" open={open} onToggle={onToggle} data-testid={testID}>
|
|
32
|
+
<summary className="ui-collapsible__summary">
|
|
33
|
+
<Label color="label" style={{flexShrink: 1}}>{label}</Label>
|
|
34
|
+
<svg className="ui-collapsible__chevron" width="8" height="14" viewBox="0 0 8 14" fill="none" aria-hidden="true">
|
|
35
|
+
<path d="M1.5 1.5 L6.5 7 L1.5 12.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
|
|
36
|
+
</svg>
|
|
37
|
+
</summary>
|
|
38
|
+
<div className="ui-collapsible__content">{children}</div>
|
|
39
|
+
</details>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {useCallback, useState} from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bridges controlled and uncontrolled expansion, mirroring `useSelectedValue`.
|
|
5
|
+
*/
|
|
6
|
+
export function useExpanded(
|
|
7
|
+
expanded: boolean | undefined,
|
|
8
|
+
defaultExpanded: boolean,
|
|
9
|
+
onChange: ((expanded: boolean) => void) | undefined,
|
|
10
|
+
): [boolean, (next: boolean) => void] {
|
|
11
|
+
const [internal, setInternal] = useState(defaultExpanded);
|
|
12
|
+
const current = expanded ?? internal;
|
|
13
|
+
const setExpanded = useCallback(
|
|
14
|
+
(next: boolean) => {
|
|
15
|
+
if (expanded === undefined) setInternal(next);
|
|
16
|
+
onChange?.(next);
|
|
17
|
+
},
|
|
18
|
+
[expanded, onChange],
|
|
19
|
+
);
|
|
20
|
+
return [current, setExpanded];
|
|
21
|
+
}
|