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,83 @@
1
+ /*
2
+ * The popup is a native `[popover="auto"]` element: the browser puts it in the
3
+ * top layer, closes it on outside click / Escape (light dismiss), and toggles
4
+ * it from the trigger's `popovertarget` — no JS open/close state. Placement is
5
+ * CSS anchor positioning relative to the trigger's `anchor-name`, flipping to
6
+ * stay on screen. Context menus instead set `left`/`top` at the pointer.
7
+ */
8
+ .ui-menu {
9
+ display: inline-flex;
10
+ }
11
+
12
+ .ui-menu__list {
13
+ box-sizing: border-box;
14
+ min-width: 200px;
15
+ inset: auto;
16
+ margin: 0;
17
+ padding: 6px;
18
+ border: 1px solid var(--color-separator);
19
+ border-radius: 12px;
20
+ background: var(--color-background-element);
21
+ color: var(--color-label);
22
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18);
23
+ overflow: visible;
24
+ }
25
+
26
+ .ui-menu__list--anchored {
27
+ position-area: bottom span-left;
28
+ position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
29
+ margin-block: 4px;
30
+ }
31
+
32
+ .ui-menu__list::backdrop {
33
+ background: transparent;
34
+ }
35
+
36
+ .ui-menu__item {
37
+ -webkit-appearance: none;
38
+ appearance: none;
39
+ display: flex;
40
+ flex-direction: row;
41
+ align-items: center;
42
+ gap: 10px;
43
+ box-sizing: border-box;
44
+ width: 100%;
45
+ margin: 0;
46
+ padding: 8px 10px;
47
+ border: none;
48
+ border-radius: 8px;
49
+ background: transparent;
50
+ color: var(--color-label);
51
+ font-family: var(--font-display);
52
+ font-size: 14px;
53
+ font-weight: 500;
54
+ line-height: 18px;
55
+ text-align: left;
56
+ white-space: nowrap;
57
+ cursor: pointer;
58
+ }
59
+
60
+ .ui-menu__item:hover:not(:disabled),
61
+ .ui-menu__item:focus-visible {
62
+ background: color-mix(in srgb, var(--color-label) 8%, transparent);
63
+ outline: none;
64
+ }
65
+
66
+ .ui-menu__item:disabled {
67
+ opacity: 0.4;
68
+ cursor: default;
69
+ }
70
+
71
+ .ui-menu__item--destructive {
72
+ color: var(--color-destructive);
73
+ }
74
+
75
+ .ui-menu__separator {
76
+ height: 1px;
77
+ margin: 6px 4px;
78
+ background: var(--color-separator);
79
+ }
80
+
81
+ .ui-context-menu {
82
+ display: contents;
83
+ }
@@ -0,0 +1,60 @@
1
+ import type {ReactNode} from 'react';
2
+ import type {IconToken} from '../icons';
3
+ import type {ButtonProps} from '../button/types';
4
+
5
+ /** One entry of a `Menu` / `ContextMenu`. */
6
+ export interface MenuItem {
7
+ /** Item text. */
8
+ label: string;
9
+ /** Leading icon. */
10
+ icon?: IconToken;
11
+ /**
12
+ * `destructive` renders the item in the danger color.
13
+ * @default 'default'
14
+ */
15
+ role?: 'default' | 'destructive';
16
+ /** Greys the item out and ignores presses. */
17
+ disabled?: boolean;
18
+ /** Draw a separator above this item. */
19
+ separator?: boolean;
20
+ /** Called when the item is selected; the menu then closes. */
21
+ onPress?: () => void;
22
+ }
23
+
24
+ /**
25
+ * Cross-platform dropdown menu opened from a button.
26
+ *
27
+ * Bridges the SwiftUI `Menu` on iOS, the Jetpack Compose Material 3
28
+ * `DropdownMenu` on Android, and a `role="menu"` popup on web. The trigger
29
+ * looks like the kit's `Button` and takes the same styling props.
30
+ */
31
+ export interface MenuProps extends Pick<ButtonProps, 'variant' | 'size' | 'shape' | 'color' | 'hideLabel' | 'disabled'> {
32
+ /** Trigger text (kept for accessibility when `hideLabel` is set). */
33
+ label: string;
34
+ /** Trigger icon. */
35
+ icon?: IconToken;
36
+ /** Entries shown when the menu opens. */
37
+ items: MenuItem[];
38
+ /** Identifier used to locate the trigger in end-to-end tests. */
39
+ testID?: string;
40
+ }
41
+
42
+ /**
43
+ * Cross-platform context menu attached to arbitrary content.
44
+ *
45
+ * Opens on long-press (iOS `contextMenu`, Android `DropdownMenu` anchored by
46
+ * `combinedClickable`) or right-click / long-press on web. `children` must
47
+ * be native (`@expo/ui`) content on iOS/Android.
48
+ */
49
+ export interface ContextMenuProps {
50
+ /** Entries shown when the menu opens. */
51
+ items: MenuItem[];
52
+ /** Content that triggers the menu. */
53
+ children: ReactNode;
54
+ /** Called on a plain tap of the content (Android/web; iOS taps pass through). */
55
+ onPress?: () => void;
56
+ /** Disables the menu. */
57
+ disabled?: boolean;
58
+ /** Identifier used to locate the trigger in end-to-end tests. */
59
+ testID?: string;
60
+ }
@@ -3,7 +3,7 @@ import type {PickerProps, PickerValue} from './types';
3
3
  import {useState} from 'react';
4
4
  import unfoldMore from '@expo/material-symbols/unfold_more.xml';
5
5
  import {useMaterialColors, DropdownMenu, DropdownMenuItem, Column, Row, Text, Icon} from '@expo/ui/jetpack-compose';
6
- import {background, clickable, clip, fillMaxWidth, padding, Shapes} from '@expo/ui/jetpack-compose/modifiers';
6
+ import {background, clickable, clip, fillMaxWidth, padding, Shapes, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
7
7
  import {extractItems, labelFor, PickerItem, useSelectedValue} from './shared';
8
8
 
9
9
  /**
@@ -20,6 +20,7 @@ function PickerComponent<T extends PickerValue>({
20
20
  accentColor,
21
21
  selectedValue,
22
22
  onValueChange,
23
+ testID,
23
24
  }: PickerProps<T>) {
24
25
  const colors = useMaterialColors();
25
26
  const items = extractItems<T>(children);
@@ -30,7 +31,7 @@ function PickerComponent<T extends PickerValue>({
30
31
  // a Form, and the web pill uses `secondaryLabel` to match.
31
32
  const colorValue = accentColor && !disabled ? accentColor : colors.onSurfaceVariant;
32
33
  return (
33
- <Column modifiers={[fillMaxWidth()]}>
34
+ <Column modifiers={[fillMaxWidth(), ...(testID ? [testIDModifier(testID)] : [])]}>
34
35
  <Row
35
36
  verticalAlignment="center"
36
37
  horizontalArrangement="spaceBetween"
@@ -1,24 +1,38 @@
1
1
  import type {ProgressProps} from './types';
2
- import {LinearProgressIndicator} from '@expo/ui/jetpack-compose';
3
- import {fillMaxWidth, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
2
+ import {CircularProgressIndicator, LinearProgressIndicator} from '@expo/ui/jetpack-compose';
3
+ import {fillMaxWidth, size as sizeMod, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
4
4
  import {useColor} from '../theme';
5
5
 
6
+ const STROKE = 3;
7
+
6
8
  /**
7
- * Android renders the Material 3 `LinearProgressIndicator`. It fills the row
8
- * width so it matches the iOS and web bars.
9
+ * Android renders the Material 3 `LinearProgressIndicator` (filling the row
10
+ * width to match the iOS and web bars) or `CircularProgressIndicator` sized
11
+ * to `size`.
9
12
  */
10
- export function Progress({value, color, trackColor, testID}: ProgressProps) {
13
+ export function Progress({value, variant = 'linear', size = 24, color, trackColor, testID}: ProgressProps) {
11
14
  const accent = useColor('tint');
12
15
  const track = useColor('backgroundSelected');
16
+ const testMods = testID ? [testIDModifier(testID)] : [];
17
+
18
+ if (variant === 'circular') {
19
+ return (
20
+ <CircularProgressIndicator
21
+ progress={value ?? null}
22
+ color={color ?? accent}
23
+ trackColor={trackColor ?? track}
24
+ strokeWidth={STROKE}
25
+ modifiers={[sizeMod(size, size), ...testMods]}
26
+ />
27
+ );
28
+ }
29
+
13
30
  return (
14
31
  <LinearProgressIndicator
15
32
  progress={value ?? null}
16
33
  color={color ?? accent}
17
34
  trackColor={trackColor ?? track}
18
- modifiers={[
19
- fillMaxWidth(),
20
- ...(testID ? [testIDModifier(testID)] : []),
21
- ]}
35
+ modifiers={[fillMaxWidth(), ...testMods]}
22
36
  />
23
37
  );
24
38
  }
@@ -1,19 +1,20 @@
1
1
  import type {ProgressProps} from './types';
2
2
  import {ProgressView} from '@expo/ui/swift-ui';
3
- import {tint} from '@expo/ui/swift-ui/modifiers';
3
+ import {progressViewStyle, tint} from '@expo/ui/swift-ui/modifiers';
4
4
  import {useColor} from '../theme';
5
5
 
6
6
  /**
7
- * iOS renders SwiftUI's `ProgressView` in its linear style (the default for a
8
- * determinate `value`). The bar is flexible-width and fills the available space
9
- * of its parent layout. `trackColor` has no SwiftUI equivalent and is ignored.
7
+ * iOS renders SwiftUI's `ProgressView` in its linear style (a flexible-width
8
+ * bar that fills its parent) or circular style (the system ring / activity
9
+ * spinner, at its native size). `trackColor` has no SwiftUI equivalent and is
10
+ * ignored.
10
11
  */
11
- export function Progress({value, color, testID}: ProgressProps) {
12
+ export function Progress({value, variant = 'linear', color, testID}: ProgressProps) {
12
13
  const accent = useColor('tint');
13
14
  return (
14
15
  <ProgressView
15
16
  value={value ?? null}
16
- modifiers={[tint(color ?? accent)]}
17
+ modifiers={[progressViewStyle(variant), tint(color ?? accent)]}
17
18
  testID={testID}
18
19
  />
19
20
  );
@@ -3,20 +3,57 @@ import type {CSSProperties} from 'react';
3
3
  import type {ProgressProps} from './types';
4
4
  import {useColor} from '../theme';
5
5
 
6
+ const STROKE = 3;
7
+
6
8
  /**
7
- * On web the bar is a native `<meter>` element. Its bar/track colors are driven
8
- * by the `--ui-progress-*` custom properties consumed in `global.css`, since the
9
- * meter pseudo-elements can't be styled inline. Indeterminate meters don't
10
- * exist on the web platform, so an undefined `value` renders an empty bar.
9
+ * On web the linear bar is a native `<meter>` element whose bar/track colors
10
+ * are driven by the `--ui-progress-*` custom properties (meter pseudo-elements
11
+ * can't be styled inline). Indeterminate meters don't exist on the web
12
+ * platform, so an undefined `value` renders an empty bar. The circular
13
+ * variant is an SVG ring; without a `value` it spins as an activity indicator.
11
14
  */
12
- export function Progress({value, color, trackColor, testID}: ProgressProps) {
15
+ export function Progress({value, variant = 'linear', size = 24, color, trackColor, testID}: ProgressProps) {
13
16
  const tint = useColor('tint');
14
17
  const track = useColor('backgroundSelected');
15
- const clamped = Math.max(0, Math.min(1, value ?? 0));
16
18
  const vars = {
17
19
  '--ui-progress-bar': color ?? tint,
18
20
  '--ui-progress-track': trackColor ?? track,
19
21
  } as CSSProperties;
22
+
23
+ if (variant === 'circular') {
24
+ const indeterminate = value == null;
25
+ const clamped = indeterminate ? 0.25 : Math.max(0, Math.min(1, value));
26
+ const radius = (size - STROKE) / 2;
27
+ const circumference = 2 * Math.PI * radius;
28
+ return (
29
+ <svg
30
+ className={['ui-progress-ring', indeterminate && 'ui-progress-ring--indeterminate'].filter(Boolean).join(' ')}
31
+ width={size}
32
+ height={size}
33
+ viewBox={`0 0 ${size} ${size}`}
34
+ role="progressbar"
35
+ aria-valuemin={0}
36
+ aria-valuemax={1}
37
+ aria-valuenow={indeterminate ? undefined : clamped}
38
+ data-testid={testID}
39
+ style={vars}>
40
+ <circle className="ui-progress-ring__track" cx={size / 2} cy={size / 2} r={radius} fill="none" strokeWidth={STROKE}/>
41
+ <circle
42
+ className="ui-progress-ring__bar"
43
+ cx={size / 2}
44
+ cy={size / 2}
45
+ r={radius}
46
+ fill="none"
47
+ strokeWidth={STROKE}
48
+ strokeLinecap="round"
49
+ strokeDasharray={circumference}
50
+ strokeDashoffset={circumference * (1 - clamped)}
51
+ />
52
+ </svg>
53
+ );
54
+ }
55
+
56
+ const clamped = Math.max(0, Math.min(1, value ?? 0));
20
57
  return (
21
58
  <meter
22
59
  className="ui-progress"
@@ -8,6 +8,15 @@
8
8
  -webkit-appearance: none;
9
9
  }
10
10
 
11
+ /*
12
+ * With `appearance: none` Chromium lays the bar out as an inline-block on a
13
+ * line box, so the value only paints about half the track height. Flex
14
+ * stretches the bar (and its value) to the full 6px.
15
+ */
16
+ .ui-progress::-webkit-meter-inner-element {
17
+ display: flex;
18
+ }
19
+
11
20
  .ui-progress::-webkit-meter-bar {
12
21
  border: none;
13
22
  border-radius: 999px;
@@ -25,3 +34,27 @@
25
34
  border-radius: 999px;
26
35
  background: var(--ui-progress-bar);
27
36
  }
37
+
38
+ .ui-progress-ring {
39
+ flex-shrink: 0;
40
+ display: inline-block;
41
+ transform: rotate(-90deg);
42
+ }
43
+
44
+ .ui-progress-ring__track {
45
+ stroke: var(--ui-progress-track);
46
+ }
47
+
48
+ .ui-progress-ring__bar {
49
+ stroke: var(--ui-progress-bar);
50
+ transition: stroke-dashoffset 0.2s ease;
51
+ }
52
+
53
+ .ui-progress-ring--indeterminate {
54
+ animation: ui-progress-spin 0.9s linear infinite;
55
+ }
56
+
57
+ @keyframes ui-progress-spin {
58
+ from { transform: rotate(-90deg); }
59
+ to { transform: rotate(270deg); }
60
+ }
@@ -1,18 +1,35 @@
1
+ /** Shape of the indicator. */
2
+ export type ProgressVariant = 'linear' | 'circular';
3
+
1
4
  /**
2
- * Cross-platform determinate progress bar.
5
+ * Cross-platform progress indicator.
3
6
  *
4
- * Bridges the SwiftUI `ProgressView` on iOS, the Jetpack Compose
5
- * `LinearProgressIndicator` on Android, and the HTML `<meter>` element on web.
7
+ * Bridges the SwiftUI `ProgressView` (linear or circular style) on iOS, the
8
+ * Jetpack Compose `LinearProgressIndicator` / `CircularProgressIndicator` on
9
+ * Android, and the HTML `<meter>` element (linear) or an SVG ring (circular)
10
+ * on web.
6
11
  */
7
12
  export interface ProgressProps {
8
13
  /**
9
14
  * Progress value between `0` and `1`.
10
- * Omit for an indeterminate indicator (native only; web falls back to `0`).
15
+ * Omit for an indeterminate indicator (a spinner when `circular`; the linear
16
+ * web bar has no indeterminate state and renders empty).
11
17
  */
12
18
  value?: number;
13
- /** Color of the filled portion of the bar. Defaults to the theme tint. */
19
+ /**
20
+ * Bar or ring.
21
+ * @default 'linear'
22
+ */
23
+ variant?: ProgressVariant;
24
+ /**
25
+ * Diameter of the circular indicator in points/dp. Ignored for `linear`,
26
+ * and on iOS where the system spinner keeps its native size.
27
+ * @default 24
28
+ */
29
+ size?: number;
30
+ /** Color of the filled portion. Defaults to the theme tint. */
14
31
  color?: string;
15
- /** Color of the unfilled track behind the bar. */
32
+ /** Color of the unfilled track behind the bar/ring. */
16
33
  trackColor?: string;
17
34
  /** Identifier used to locate the component in end-to-end tests. */
18
35
  testID?: string;
@@ -21,6 +21,7 @@ export function ScreenHeader({title, onBack, trailing}: ScreenHeaderProps) {
21
21
  {onBack ? (
22
22
  <Pressable
23
23
  onPress={onBack}
24
+ role="button"
24
25
  accessibilityLabel="Go back"
25
26
  style={styles.back}>
26
27
  <SymbolView
@@ -0,0 +1,67 @@
1
+ import type {PickerValue} from '../picker/types';
2
+ import type {SegmentedControlProps} from './types';
3
+
4
+ import {Row, SegmentedButton, SingleChoiceSegmentedButtonRow, Text, useMaterialColors} from '@expo/ui/jetpack-compose';
5
+ import {fillMaxWidth, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
6
+ import {onAccent} from '../accent';
7
+ import {useColor} from '../theme';
8
+ import {extractItems, PickerItem, useSelectedValue} from '../picker/shared';
9
+
10
+ /**
11
+ * Android renders the Material 3 `SingleChoiceSegmentedButtonRow`. The
12
+ * selected segment is filled with the live accent seed (matching the iOS
13
+ * tint cascade and the web accent) and the row pins the control to the
14
+ * trailing edge, mirroring the iOS Form row.
15
+ */
16
+ function SegmentedControlComponent<T extends PickerValue>({
17
+ label,
18
+ children,
19
+ selectedValue,
20
+ onValueChange,
21
+ disabled,
22
+ accentColor,
23
+ testID,
24
+ }: SegmentedControlProps<T>) {
25
+ const colors = useMaterialColors();
26
+ const tint = useColor('tint');
27
+ const accent = accentColor ?? tint;
28
+ const items = extractItems<T>(children);
29
+ const [current, setValue] = useSelectedValue(selectedValue, onValueChange, items[0]?.value);
30
+ const segmentColors = {
31
+ activeContainerColor: accent,
32
+ activeContentColor: onAccent(accent),
33
+ activeBorderColor: colors.outline,
34
+ inactiveContainerColor: '#00000000',
35
+ inactiveContentColor: colors.onSurface,
36
+ inactiveBorderColor: colors.outline,
37
+ };
38
+
39
+ return (
40
+ <Row
41
+ verticalAlignment="center"
42
+ horizontalArrangement="spaceBetween"
43
+ modifiers={[fillMaxWidth(), ...(testID ? [testIDModifier(testID)] : [])]}>
44
+ {label != null ? (
45
+ <Text color={disabled ? colors.onSurfaceVariant : colors.onSurface}>{label}</Text>
46
+ ) : null}
47
+ <SingleChoiceSegmentedButtonRow>
48
+ {items.map(item => (
49
+ <SegmentedButton
50
+ key={String(item.value)}
51
+ selected={item.value === current}
52
+ enabled={!disabled}
53
+ colors={segmentColors}
54
+ onClick={disabled ? undefined : () => setValue(item.value)}>
55
+ <SegmentedButton.Label>
56
+ <Text>{item.label}</Text>
57
+ </SegmentedButton.Label>
58
+ </SegmentedButton>
59
+ ))}
60
+ </SingleChoiceSegmentedButtonRow>
61
+ </Row>
62
+ );
63
+ }
64
+
65
+ SegmentedControlComponent.Item = PickerItem;
66
+
67
+ export {SegmentedControlComponent as SegmentedControl};
@@ -0,0 +1,47 @@
1
+ import type {PickerValue} from '../picker/types';
2
+ import type {SegmentedControlProps} from './types';
3
+ import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
4
+
5
+ import {Picker as SwiftUIPicker, Text} from '@expo/ui/swift-ui';
6
+ import {pickerStyle, tag, tint, disabled as disabledMod} from '@expo/ui/swift-ui/modifiers';
7
+ import {extractItems, PickerItem, useSelectedValue} from '../picker/shared';
8
+
9
+ /**
10
+ * iOS renders SwiftUI's `Picker` in the `segmented` style — the system
11
+ * `UISegmentedControl` — with the label leading inside a Form row. Drop it
12
+ * straight into a `FieldGroup.Section`.
13
+ */
14
+ function SegmentedControlComponent<T extends PickerValue>({
15
+ label,
16
+ children,
17
+ selectedValue,
18
+ onValueChange,
19
+ disabled,
20
+ accentColor,
21
+ testID,
22
+ }: SegmentedControlProps<T>) {
23
+ const items = extractItems<T>(children);
24
+ const [current, setValue] = useSelectedValue(selectedValue, onValueChange, items[0]?.value);
25
+ const modifiers: ViewModifier[] = [pickerStyle('segmented')];
26
+ if (accentColor) modifiers.push(tint(accentColor));
27
+ if (disabled) modifiers.push(disabledMod(true));
28
+
29
+ return (
30
+ <SwiftUIPicker
31
+ label={label}
32
+ selection={current}
33
+ onSelectionChange={value => setValue(value as T)}
34
+ modifiers={modifiers}
35
+ testID={testID}>
36
+ {items.map(item => (
37
+ <Text key={String(item.value)} modifiers={[tag(item.value)]}>
38
+ {item.label}
39
+ </Text>
40
+ ))}
41
+ </SwiftUIPicker>
42
+ );
43
+ }
44
+
45
+ SegmentedControlComponent.Item = PickerItem;
46
+
47
+ export {SegmentedControlComponent as SegmentedControl};
@@ -0,0 +1,58 @@
1
+ import './segmented.css';
2
+ import type {CSSProperties} from 'react';
3
+ import type {PickerValue} from '../picker/types';
4
+ import type {SegmentedControlProps} from './types';
5
+ import {StyleSheet, type TextStyle} from 'react-native';
6
+ import {Label} from '../typography';
7
+ import {flatten} from '../theme';
8
+ import {extractItems, PickerItem, useSelectedValue} from '../picker/shared';
9
+
10
+ /**
11
+ * On web the control is a `radiogroup` of native `<button>`s styled after the
12
+ * iOS segmented control (grey pill, raised selected segment). The row mirrors
13
+ * the native layout: label on the leading edge, control on the trailing edge.
14
+ */
15
+ function SegmentedControlComponent<T extends PickerValue>({
16
+ label,
17
+ children,
18
+ selectedValue,
19
+ onValueChange,
20
+ disabled,
21
+ accentColor,
22
+ testID,
23
+ style,
24
+ }: SegmentedControlProps<T>) {
25
+ const items = extractItems<T>(children);
26
+ const [current, setValue] = useSelectedValue(selectedValue, onValueChange, items[0]?.value);
27
+ const vars = flatten(StyleSheet.flatten(style) as TextStyle) as CSSProperties;
28
+ return (
29
+ <div
30
+ className={['ui-segmented', disabled && 'ui-segmented--disabled'].filter(Boolean).join(' ')}
31
+ style={vars}
32
+ data-testid={testID}>
33
+ {label != null ? <Label color="label" style={{flexShrink: 1}}>{label}</Label> : null}
34
+ <div className="ui-segmented__group" role="radiogroup" aria-label={label}>
35
+ {items.map(item => {
36
+ const selected = item.value === current;
37
+ return (
38
+ <button
39
+ key={String(item.value)}
40
+ type="button"
41
+ role="radio"
42
+ className="ui-segmented__item"
43
+ aria-checked={selected}
44
+ disabled={disabled}
45
+ style={selected && accentColor ? {color: accentColor} : undefined}
46
+ onClick={() => setValue(item.value)}>
47
+ {item.label}
48
+ </button>
49
+ );
50
+ })}
51
+ </div>
52
+ </div>
53
+ );
54
+ }
55
+
56
+ SegmentedControlComponent.Item = PickerItem;
57
+
58
+ export {SegmentedControlComponent as SegmentedControl};
@@ -0,0 +1,55 @@
1
+ .ui-segmented {
2
+ display: flex;
3
+ flex-direction: row;
4
+ align-items: center;
5
+ justify-content: space-between;
6
+ gap: 12px;
7
+ width: 100%;
8
+ }
9
+
10
+ .ui-segmented__group {
11
+ display: inline-flex;
12
+ flex-shrink: 0;
13
+ flex-direction: row;
14
+ padding: 2px;
15
+ border-radius: 9px;
16
+ background: var(--color-pill-background);
17
+ }
18
+
19
+ .ui-segmented__item {
20
+ -webkit-appearance: none;
21
+ appearance: none;
22
+ min-width: 48px;
23
+ margin: 0;
24
+ padding: 4px 12px;
25
+ border: none;
26
+ border-radius: 7px;
27
+ background: transparent;
28
+ color: var(--color-label);
29
+ font-family: var(--font-display);
30
+ font-size: 13px;
31
+ font-weight: 500;
32
+ line-height: 18px;
33
+ white-space: nowrap;
34
+ cursor: pointer;
35
+ transition: background-color 0.15s ease, box-shadow 0.15s ease;
36
+ }
37
+
38
+ /*
39
+ * Raised segment (iOS: white light / systemGray2-ish dark). `light-dark()`
40
+ * follows the element's `color-scheme`, which the theme CSS sets from the OS
41
+ * and which a host can force (Storybook's scheme toolbar does).
42
+ */
43
+ .ui-segmented__item[aria-checked='true'] {
44
+ background: light-dark(#ffffff, #636366);
45
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(0, 0, 0, 0.04);
46
+ font-weight: 600;
47
+ }
48
+
49
+ .ui-segmented__item:disabled {
50
+ cursor: default;
51
+ }
52
+
53
+ .ui-segmented--disabled {
54
+ opacity: 0.4;
55
+ }
@@ -0,0 +1,31 @@
1
+ import type {ReactNode} from 'react';
2
+ import type {StyleProp, ViewStyle} from 'react-native';
3
+ import type {PickerValue} from '../picker/types';
4
+
5
+ /**
6
+ * Cross-platform segmented control: a single-choice row of segments.
7
+ *
8
+ * Bridges the SwiftUI `Picker` in its `segmented` style on iOS, the Jetpack
9
+ * Compose Material 3 `SingleChoiceSegmentedButtonRow` on Android, and a
10
+ * `radiogroup` of native `<button>`s on web. Options are declared with
11
+ * `SegmentedControl.Item` children, exactly like `Picker`. May be used
12
+ * controlled (`selectedValue` + `onValueChange`) or uncontrolled.
13
+ */
14
+ export interface SegmentedControlProps<T extends PickerValue = PickerValue> {
15
+ /** Label rendered at the leading edge of the row, mirroring an iOS Form row. */
16
+ label?: string;
17
+ /** `SegmentedControl.Item` children that define the segments. */
18
+ children?: ReactNode;
19
+ /** Current value (controlled). When omitted the component keeps its own state. */
20
+ selectedValue?: T;
21
+ /** Called whenever the user selects a segment. */
22
+ onValueChange?: (value: T) => void;
23
+ /** Disables interaction. */
24
+ disabled?: boolean;
25
+ /** Tint of the selected segment (Android/web) or the picker (iOS). */
26
+ accentColor?: string;
27
+ /** Identifier used to locate the component in end-to-end tests. */
28
+ testID?: string;
29
+ /** Style applied to the row container (web only). */
30
+ style?: StyleProp<ViewStyle>;
31
+ }