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.
Files changed (66) hide show
  1. package/README.md +260 -180
  2. package/package.json +41 -10
  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 +61 -0
  7. package/src/alert/shared.ts +10 -0
  8. package/src/alert/types.ts +58 -0
  9. package/src/button/index.tsx +17 -1
  10. package/src/checkbox/checkbox.css +29 -0
  11. package/src/checkbox/index.android.tsx +54 -0
  12. package/src/checkbox/index.ios.tsx +50 -0
  13. package/src/checkbox/index.tsx +50 -0
  14. package/src/checkbox/types.ts +27 -0
  15. package/src/collapsible/collapsible.css +32 -0
  16. package/src/collapsible/index.android.tsx +34 -0
  17. package/src/collapsible/index.ios.tsx +31 -0
  18. package/src/collapsible/index.tsx +41 -0
  19. package/src/collapsible/shared.ts +21 -0
  20. package/src/collapsible/types.ts +24 -0
  21. package/src/context-menu/index.android.tsx +27 -0
  22. package/src/context-menu/index.ios.tsx +22 -0
  23. package/src/context-menu/index.tsx +56 -0
  24. package/src/date-time/index.android.tsx +3 -2
  25. package/src/divider/divider.css +16 -0
  26. package/src/divider/index.android.tsx +26 -0
  27. package/src/divider/index.ios.tsx +18 -0
  28. package/src/divider/index.tsx +21 -0
  29. package/src/divider/types.ts +20 -0
  30. package/src/index.ts +20 -2
  31. package/src/menu/index.android.tsx +66 -0
  32. package/src/menu/index.ios.tsx +72 -0
  33. package/src/menu/index.tsx +30 -0
  34. package/src/menu/list.tsx +98 -0
  35. package/src/menu/menu.css +83 -0
  36. package/src/menu/types.ts +60 -0
  37. package/src/picker/index.android.tsx +3 -2
  38. package/src/progress/index.android.tsx +23 -9
  39. package/src/progress/index.ios.tsx +7 -6
  40. package/src/progress/index.tsx +43 -6
  41. package/src/progress/progress.css +24 -0
  42. package/src/progress/types.ts +23 -6
  43. package/src/qr/index.tsx +2 -2
  44. package/src/segmented/index.android.tsx +67 -0
  45. package/src/segmented/index.ios.tsx +47 -0
  46. package/src/segmented/index.tsx +58 -0
  47. package/src/segmented/segmented.css +56 -0
  48. package/src/segmented/types.ts +31 -0
  49. package/src/slider/index.android.tsx +87 -0
  50. package/src/slider/index.ios.tsx +64 -0
  51. package/src/slider/index.tsx +55 -0
  52. package/src/slider/slider.css +30 -0
  53. package/src/slider/types.ts +39 -0
  54. package/src/stepper/index.android.tsx +58 -0
  55. package/src/stepper/index.ios.tsx +55 -0
  56. package/src/stepper/index.tsx +47 -0
  57. package/src/stepper/shared.ts +17 -0
  58. package/src/stepper/stepper.css +61 -0
  59. package/src/stepper/types.ts +35 -0
  60. package/src/text-field/shared.ts +2 -0
  61. package/src/tooltip/index.android.tsx +23 -0
  62. package/src/tooltip/index.ios.tsx +17 -0
  63. package/src/tooltip/index.tsx +41 -0
  64. package/src/tooltip/tooltip.css +40 -0
  65. package/src/tooltip/types.ts +25 -0
  66. 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
+ }
@@ -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
- }: ButtonProps) {
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
+ }