expo-interface 0.1.0 → 0.2.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.
Files changed (88) hide show
  1. package/README.md +282 -180
  2. package/package.json +39 -13
  3. package/src/alert/alert.css +49 -0
  4. package/src/alert/index.android.tsx +68 -0
  5. package/src/alert/index.ios.tsx +47 -0
  6. package/src/alert/index.tsx +60 -0
  7. package/src/alert/shared.ts +10 -0
  8. package/src/alert/types.ts +58 -0
  9. package/src/button/button.css +6 -0
  10. package/src/button/index.android.tsx +9 -2
  11. package/src/button/index.ios.tsx +8 -3
  12. package/src/button/index.tsx +19 -1
  13. package/src/button/types.ts +7 -0
  14. package/src/checkbox/checkbox.css +29 -0
  15. package/src/checkbox/index.android.tsx +54 -0
  16. package/src/checkbox/index.ios.tsx +50 -0
  17. package/src/checkbox/index.tsx +50 -0
  18. package/src/checkbox/types.ts +27 -0
  19. package/src/collapsible/collapsible.css +32 -0
  20. package/src/collapsible/index.android.tsx +34 -0
  21. package/src/collapsible/index.ios.tsx +31 -0
  22. package/src/collapsible/index.tsx +41 -0
  23. package/src/collapsible/shared.ts +21 -0
  24. package/src/collapsible/types.ts +24 -0
  25. package/src/color-picker/color-picker.css +66 -0
  26. package/src/color-picker/index.android.tsx +117 -0
  27. package/src/color-picker/index.ios.tsx +33 -0
  28. package/src/color-picker/index.tsx +62 -0
  29. package/src/color-picker/shared.ts +204 -0
  30. package/src/color-picker/sheet.tsx +432 -0
  31. package/src/color-picker/types.ts +34 -0
  32. package/src/context-menu/index.android.tsx +27 -0
  33. package/src/context-menu/index.ios.tsx +22 -0
  34. package/src/context-menu/index.tsx +56 -0
  35. package/src/date-time/index.android.tsx +3 -2
  36. package/src/divider/divider.css +16 -0
  37. package/src/divider/index.android.tsx +26 -0
  38. package/src/divider/index.ios.tsx +18 -0
  39. package/src/divider/index.tsx +21 -0
  40. package/src/divider/types.ts +20 -0
  41. package/src/field-group/field-group.css +23 -7
  42. package/src/field-group/index.android.tsx +22 -22
  43. package/src/field-group/index.web.tsx +2 -2
  44. package/src/gauge/gauge.css +200 -0
  45. package/src/gauge/index.android.tsx +212 -0
  46. package/src/gauge/index.ios.tsx +40 -0
  47. package/src/gauge/index.tsx +165 -0
  48. package/src/gauge/shared.ts +79 -0
  49. package/src/gauge/types.ts +64 -0
  50. package/src/index.ts +24 -3
  51. package/src/menu/index.android.tsx +66 -0
  52. package/src/menu/index.ios.tsx +72 -0
  53. package/src/menu/index.tsx +30 -0
  54. package/src/menu/list.tsx +98 -0
  55. package/src/menu/menu.css +83 -0
  56. package/src/menu/types.ts +60 -0
  57. package/src/picker/index.android.tsx +3 -2
  58. package/src/progress/index.android.tsx +23 -9
  59. package/src/progress/index.ios.tsx +7 -6
  60. package/src/progress/index.tsx +43 -6
  61. package/src/progress/progress.css +33 -0
  62. package/src/progress/types.ts +23 -6
  63. package/src/screen/header.tsx +1 -0
  64. package/src/segmented/index.android.tsx +67 -0
  65. package/src/segmented/index.ios.tsx +47 -0
  66. package/src/segmented/index.tsx +58 -0
  67. package/src/segmented/segmented.css +55 -0
  68. package/src/segmented/types.ts +31 -0
  69. package/src/slider/index.android.tsx +87 -0
  70. package/src/slider/index.ios.tsx +64 -0
  71. package/src/slider/index.tsx +55 -0
  72. package/src/slider/slider.css +30 -0
  73. package/src/slider/types.ts +39 -0
  74. package/src/stepper/index.android.tsx +58 -0
  75. package/src/stepper/index.ios.tsx +55 -0
  76. package/src/stepper/index.tsx +47 -0
  77. package/src/stepper/shared.ts +17 -0
  78. package/src/stepper/stepper.css +61 -0
  79. package/src/stepper/types.ts +35 -0
  80. package/src/switch/index.tsx +2 -0
  81. package/src/text-field/shared.ts +2 -0
  82. package/src/tooltip/index.android.tsx +23 -0
  83. package/src/tooltip/index.ios.tsx +17 -0
  84. package/src/tooltip/index.tsx +41 -0
  85. package/src/tooltip/tooltip.css +40 -0
  86. package/src/tooltip/types.ts +25 -0
  87. package/src/link.ts +0 -36
  88. package/src/qr/index.tsx +0 -26
@@ -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,60 @@
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 (visible && !dialog.open) dialog.showModal();
22
+ else if (!visible && dialog.open) dialog.close();
23
+ }, [visible]);
24
+
25
+ const onBackdrop = (event: SyntheticEvent<HTMLDialogElement, MouseEvent>) => {
26
+ if (event.target === ref.current) ref.current?.close();
27
+ };
28
+
29
+ return (
30
+ <>
31
+ {children}
32
+ <dialog
33
+ ref={ref}
34
+ className={['ui-alert', sheet && 'ui-alert--sheet'].filter(Boolean).join(' ')}
35
+ aria-label={title}
36
+ onClose={onDismiss}
37
+ onClick={onBackdrop}
38
+ data-testid={testID}>
39
+ <div className="ui-alert__body">
40
+ <Headline testID={testID ? `${testID}-title` : undefined}>{title}</Headline>
41
+ {message ? <Body color="secondaryLabel">{message}</Body> : null}
42
+ </div>
43
+ <div className="ui-alert__actions">
44
+ {[...others, ...(cancel ? [cancel] : [])].map((action, index) => (
45
+ <Button
46
+ key={index}
47
+ label={action.label}
48
+ variant={sheet ? 'outlined' : 'text'}
49
+ role={action.role === 'destructive' ? 'destructive' : 'default'}
50
+ onPress={() => {
51
+ action.onPress?.();
52
+ ref.current?.close();
53
+ }}
54
+ />
55
+ ))}
56
+ </div>
57
+ </dialog>
58
+ </>
59
+ );
60
+ }
@@ -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
+ }
@@ -6,6 +6,8 @@
6
6
  display: inline-flex;
7
7
  align-items: center;
8
8
  justify-content: center;
9
+ /* Hug the content even inside a flex column that stretches its children. */
10
+ width: fit-content;
9
11
  border-radius: 999px;
10
12
  border: 1px solid transparent;
11
13
  padding: 10px 18px;
@@ -90,6 +92,10 @@
90
92
  padding-inline: 14px;
91
93
  }
92
94
 
95
+ .ui-button--fill {
96
+ width: 100%;
97
+ }
98
+
93
99
  .ui-button--destructive {
94
100
  --ui-button-accent: var(--color-destructive);
95
101
  --ui-button-on-accent: var(--color-on-destructive);
@@ -1,5 +1,5 @@
1
1
  import type {ButtonProps, ButtonShape, ButtonVariant} from './types';
2
- import {testID as testIDModifier, width} from '@expo/ui/jetpack-compose/modifiers';
2
+ import {fillMaxWidth, testID as testIDModifier, width, wrapContentHeight, wrapContentWidth} from '@expo/ui/jetpack-compose/modifiers';
3
3
  import {Button as ComposeButton, OutlinedButton, TextButton, Icon, IconButton, FilledIconButton, OutlinedIconButton, Spacer, Shape, Text} from '@expo/ui/jetpack-compose';
4
4
  import {SIZE_ICON, SIZE_TEXT, androidContentPadding} from './shared';
5
5
  import {onAccent as contrastOf} from '../accent';
@@ -47,6 +47,7 @@ export function Button({
47
47
  suffixIcon,
48
48
  hideLabel = false,
49
49
  disabled,
50
+ fillWidth = false,
50
51
  testID,
51
52
  }: ButtonProps) {
52
53
  const themeTint = useColor('tint');
@@ -63,7 +64,13 @@ export function Button({
63
64
  const iconSize = SIZE_ICON[size];
64
65
  const textSize = SIZE_TEXT[size];
65
66
  const resolvedShape = resolveShape(shape);
66
- const modifiers = testID ? [testIDModifier(testID)] : [];
67
+ // A `Host` measures a direct child with its own (tight) constraints, which
68
+ // a Compose button would fill on both axes; wrap to the content instead.
69
+ const modifiers = [
70
+ fillWidth ? fillMaxWidth() : wrapContentWidth('start'),
71
+ wrapContentHeight('top'),
72
+ ];
73
+ if (testID) modifiers.push(testIDModifier(testID));
67
74
  const iconOnly = hideLabel && !!prefixIcon?.drawable;
68
75
 
69
76
  if (iconOnly) {
@@ -4,6 +4,7 @@ import {Button as SwiftUIButton, HStack, Image, Text} from '@expo/ui/swift-ui';
4
4
  import {buttonStyle, buttonBorderShape, controlSize, labelStyle, tint, disabled as disabledMod} from '@expo/ui/swift-ui/modifiers';
5
5
  import {ICON_GAP, SIZE_ICON, iosSymbol, swiftBorderShape, swiftControlSize} from './shared';
6
6
  import {onAccent as contrastOf} from '../accent';
7
+ import {fillWidth as fillWidthModifiers} from '../fill';
7
8
  import {useColor} from '../theme';
8
9
 
9
10
  const VARIANT_STYLE: Record<ButtonVariant, 'borderedProminent' | 'bordered' | 'plain'> = {
@@ -29,6 +30,7 @@ export function Button({
29
30
  suffixIcon,
30
31
  hideLabel = false,
31
32
  disabled,
33
+ fillWidth = false,
32
34
  testID,
33
35
  }: ButtonProps) {
34
36
  const themeTint = useColor('tint');
@@ -49,17 +51,20 @@ export function Button({
49
51
  if (shape) modifiers.push(buttonBorderShape(swiftBorderShape(shape)));
50
52
  if (disabled) modifiers.push(disabledMod(true));
51
53
  if (iconOnly) modifiers.push(labelStyle('iconOnly'));
52
- if (hasSuffix) {
54
+ // SwiftUI has no trailing-icon `Label`, and a bordered style only paints
55
+ // behind the label, so both cases compose the label by hand: the frame
56
+ // that fills the width goes on the label, not the button.
57
+ if ((hasSuffix || fillWidth) && !iconOnly) {
53
58
  return (
54
59
  <SwiftUIButton
55
60
  role={role === 'destructive' ? 'destructive' : 'default'}
56
61
  onPress={onPress}
57
62
  modifiers={modifiers}
58
63
  testID={testID}>
59
- <HStack spacing={ICON_GAP}>
64
+ <HStack spacing={ICON_GAP} modifiers={fillWidth ? fillWidthModifiers : undefined}>
60
65
  {prefixIcon ? <Image systemName={iosSymbol(prefixIcon)} color={iconColor} size={SIZE_ICON[size]}/> : null}
61
66
  <Text>{label}</Text>
62
- <Image systemName={iosSymbol(suffixIcon!)} color={iconColor} size={SIZE_ICON[size]}/>
67
+ {hasSuffix ? <Image systemName={iosSymbol(suffixIcon!)} color={iconColor} size={SIZE_ICON[size]}/> : null}
63
68
  </HStack>
64
69
  </SwiftUIButton>
65
70
  );
@@ -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.
@@ -23,8 +35,11 @@ export function Button({
23
35
  suffixIcon,
24
36
  hideLabel = false,
25
37
  disabled = false,
38
+ fillWidth = false,
26
39
  testID,
27
- }: ButtonProps) {
40
+ popoverTarget,
41
+ popoverTargetAction,
42
+ }: WebButtonProps) {
28
43
  const themeTint = useColor('tint');
29
44
  const destructive = useColor('destructive');
30
45
  const themeOnAccent = useColor(role === 'destructive' ? 'onDestructive' : 'onTint');
@@ -44,6 +59,7 @@ export function Button({
44
59
  !shape && 'ui-button--pill',
45
60
  iconOnly && 'ui-button--icon-only',
46
61
  role === 'destructive' && 'ui-button--destructive',
62
+ fillWidth && 'ui-button--fill',
47
63
  ].filter(Boolean).join(' ');
48
64
 
49
65
  return (
@@ -53,6 +69,8 @@ export function Button({
53
69
  className={className}
54
70
  disabled={disabled}
55
71
  onClick={onPress}
72
+ popoverTarget={popoverTarget}
73
+ popoverTargetAction={popoverTarget ? popoverTargetAction : undefined}
56
74
  data-testid={testID}
57
75
  aria-label={iconOnly ? label : undefined}>
58
76
  {prefixIcon ? (
@@ -67,6 +67,13 @@ export interface ButtonProps {
67
67
  hideLabel?: boolean;
68
68
  /** Disables interaction and dims the button. */
69
69
  disabled?: boolean;
70
+ /**
71
+ * Stretch the button to its container's full width. By default the button
72
+ * hugs its content, even when the parent would stretch it (a flex column on
73
+ * web, a `Host` on Android).
74
+ * @default false
75
+ */
76
+ fillWidth?: boolean;
70
77
  /** Identifier used to locate the component in end-to-end tests. */
71
78
  testID?: string;
72
79
  }
@@ -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) as TextStyle),
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
+ }