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,165 @@
1
+ import './gauge.css';
2
+ import type {CSSProperties} from 'react';
3
+ import type {GaugeProps} from './types';
4
+ import {StyleSheet, type TextStyle} from 'react-native';
5
+ import {flatten} from '../theme';
6
+ import {fraction, gauge, markerOffset} from './shared';
7
+
8
+ const polar = (angle: number, radius: number, center: number) => ({
9
+ x: center + radius * Math.cos((angle * Math.PI) / 180),
10
+ y: center + radius * Math.sin((angle * Math.PI) / 180),
11
+ });
12
+
13
+ /**
14
+ * On web each SwiftUI gauge style is redrawn with DOM elements (the bars)
15
+ * and inline SVG (the rings), sized in CSS pixels to the geometry measured
16
+ * from iOS. Colors flow through custom properties: the accent tints the
17
+ * indicator and the value labels, the descriptive label keeps the label
18
+ * color, and the marker knockout paints the scheme background.
19
+ */
20
+ export function Gauge({
21
+ value,
22
+ min = 0,
23
+ max = 1,
24
+ variant = 'automatic',
25
+ label,
26
+ currentValueLabel,
27
+ minimumValueLabel,
28
+ maximumValueLabel,
29
+ accentColor,
30
+ testID,
31
+ style,
32
+ }: GaugeProps) {
33
+ const f = fraction(value, min, max);
34
+ const vars = {
35
+ ...(accentColor ? {'--ui-gauge-accent': accentColor} : null),
36
+ ...flatten(StyleSheet.flatten(style) as TextStyle),
37
+ } as CSSProperties;
38
+ const meter = {
39
+ role: 'meter',
40
+ 'aria-label': label,
41
+ 'aria-valuemin': min,
42
+ 'aria-valuemax': max,
43
+ 'aria-valuenow': Math.min(max, Math.max(min, value)),
44
+ 'aria-valuetext': currentValueLabel,
45
+ } as const;
46
+ const bounds = {
47
+ min: minimumValueLabel != null ? <span className="ui-gauge__bound">{minimumValueLabel}</span> : null,
48
+ max: maximumValueLabel != null ? <span className="ui-gauge__bound">{maximumValueLabel}</span> : null,
49
+ };
50
+
51
+ if (variant === 'circular' || variant === 'circularCapacity') {
52
+ const {size, stroke, arcStart, arcSweep, dot, knockout} = gauge.ring;
53
+ const center = size / 2;
54
+ const radius = (size - stroke) / 2;
55
+ const start = polar(arcStart, radius, center);
56
+ const end = polar(arcStart + arcSweep, radius, center);
57
+ const marker = markerOffset(f);
58
+ const circumference = 2 * Math.PI * radius;
59
+ const text = currentValueLabel ?? label;
60
+ return (
61
+ <div
62
+ {...meter}
63
+ className={`ui-gauge-ring ui-gauge-ring--${variant}`}
64
+ style={{...vars, width: size, height: size}}
65
+ data-testid={testID}>
66
+ <svg className="ui-gauge-ring__svg" width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true">
67
+ {variant === 'circular' ? (
68
+ <>
69
+ <path
70
+ className="ui-gauge-ring__arc"
71
+ // The 240° sweep is always the large arc, drawn clockwise.
72
+ d={`M ${start.x} ${start.y} A ${radius} ${radius} 0 1 1 ${end.x} ${end.y}`}
73
+ fill="none"
74
+ strokeWidth={stroke}
75
+ strokeLinecap="round"
76
+ />
77
+ <circle className="ui-gauge-ring__knockout" cx={center + marker.x} cy={center + marker.y} r={knockout / 2}/>
78
+ <circle className="ui-gauge-ring__dot" cx={center + marker.x} cy={center + marker.y} r={dot / 2}/>
79
+ </>
80
+ ) : (
81
+ <>
82
+ <circle className="ui-gauge-ring__track" cx={center} cy={center} r={radius} fill="none" strokeWidth={stroke}/>
83
+ {f > 0 ? (
84
+ <circle
85
+ className="ui-gauge-ring__fill"
86
+ cx={center}
87
+ cy={center}
88
+ r={radius}
89
+ fill="none"
90
+ strokeWidth={stroke}
91
+ strokeLinecap="round"
92
+ strokeDasharray={`${f * circumference} ${circumference}`}
93
+ transform={`rotate(-90 ${center} ${center})`}
94
+ />
95
+ ) : null}
96
+ </>
97
+ )}
98
+ </svg>
99
+ {text != null ? (
100
+ <span className={['ui-gauge-ring__center', currentValueLabel == null && 'ui-gauge-ring__center--label'].filter(Boolean).join(' ')}>
101
+ {text}
102
+ </span>
103
+ ) : null}
104
+ {variant === 'circular' && (bounds.min || bounds.max) ? (
105
+ <span className="ui-gauge-ring__bounds">
106
+ {bounds.min ?? <span/>}
107
+ {bounds.max ?? <span/>}
108
+ </span>
109
+ ) : null}
110
+ </div>
111
+ );
112
+ }
113
+
114
+ if (variant === 'linear') {
115
+ return (
116
+ <div {...meter} className="ui-gauge ui-gauge--linear" style={vars} data-testid={testID}>
117
+ {bounds.min}
118
+ <span className="ui-gauge__track">
119
+ <span className="ui-gauge__marker" style={{left: `calc(${gauge.linear.dot / 2}px + ${f} * (100% - ${gauge.linear.dot}px))`}}>
120
+ <span className="ui-gauge__dot"/>
121
+ </span>
122
+ </span>
123
+ {bounds.max}
124
+ </div>
125
+ );
126
+ }
127
+
128
+ const fill = <span className="ui-gauge__fill" style={{width: `${f * 100}%`}}/>;
129
+
130
+ if (variant === 'linearCapacity') {
131
+ // A grid whose columns exist only for the bounds that are set, so the
132
+ // stacked label and current value start exactly at the bar's leading edge.
133
+ const columns = [bounds.min && 'auto', 'minmax(0, 1fr)', bounds.max && 'auto'].filter(Boolean).join(' ');
134
+ const barColumn = bounds.min ? 2 : 1;
135
+ const barRow = label != null ? 2 : 1;
136
+ const stacked = {gridColumn: barColumn} as const;
137
+ return (
138
+ <div
139
+ {...meter}
140
+ className="ui-gauge ui-gauge--linear-capacity"
141
+ style={{...vars, gridTemplateColumns: columns}}
142
+ data-testid={testID}>
143
+ {label != null ? <span className="ui-gauge__label" style={{...stacked, gridRow: 1}}>{label}</span> : null}
144
+ {bounds.min ? <span className="ui-gauge__bound" style={{gridColumn: 1, gridRow: barRow}}>{minimumValueLabel}</span> : null}
145
+ <span className="ui-gauge__track" style={{...stacked, gridRow: barRow}}>{fill}</span>
146
+ {bounds.max ? <span className="ui-gauge__bound" style={{gridColumn: barColumn + 1, gridRow: barRow}}>{maximumValueLabel}</span> : null}
147
+ {currentValueLabel != null ? (
148
+ <span className="ui-gauge__current" style={{...stacked, gridRow: barRow + 1}}>{currentValueLabel}</span>
149
+ ) : null}
150
+ </div>
151
+ );
152
+ }
153
+
154
+ return (
155
+ <div {...meter} className="ui-gauge ui-gauge--automatic" style={vars} data-testid={testID}>
156
+ {label != null ? <span className="ui-gauge__label">{label}</span> : null}
157
+ <span className="ui-gauge__row">
158
+ {bounds.min}
159
+ <span className="ui-gauge__track">{fill}</span>
160
+ {bounds.max}
161
+ </span>
162
+ {currentValueLabel != null ? <span className="ui-gauge__current">{currentValueLabel}</span> : null}
163
+ </div>
164
+ );
165
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Geometry of the SwiftUI gauge styles, in points, measured from iOS
3
+ * screenshots so the Android and web redraws match the native control.
4
+ */
5
+ export const gauge = {
6
+ /** Body text of the labels (the SwiftUI default font). */
7
+ fontSize: 17,
8
+ lineHeight: 22,
9
+ /** Space between a bounds label and the bar. */
10
+ rowGap: 8,
11
+
12
+ /** `automatic`: capacity bar height and the gaps to the labels above/below. */
13
+ automatic: {bar: 15, gapAbove: 14, gapBelow: 9},
14
+
15
+ /** `linear`: bar height, marker dot (same size) and its background knockout. */
16
+ linear: {bar: 7.5, dot: 7.5, knockout: 15},
17
+
18
+ /** `linearCapacity`: bar height, stack spacing and the current value font. */
19
+ linearCapacity: {bar: 4, gap: 4, currentFontSize: 12, currentLineHeight: 16},
20
+
21
+ /** Circular styles: ring diameter/stroke, arc, marker and label metrics. */
22
+ ring: {
23
+ size: 58,
24
+ stroke: 5.5,
25
+ /** Open ring (`circular`): arc start angle (degrees, clockwise from 3 o'clock) and sweep. */
26
+ arcStart: 150,
27
+ arcSweep: 240,
28
+ dot: 5.5,
29
+ knockout: 10,
30
+ centerFontSize: 24,
31
+ centerLineHeight: 29,
32
+ boundsFontSize: 10,
33
+ boundsLineHeight: 12,
34
+ /**
35
+ * Width of the bounds row and the offset of its center below the ring
36
+ * center: clear of the 24pt value above it and inside the arc's gap.
37
+ */
38
+ boundsWidth: 34,
39
+ boundsOffset: 17,
40
+ /** Opacity of the unfilled track in `circularCapacity`. */
41
+ trackOpacity: 0.3,
42
+ },
43
+ } as const;
44
+
45
+ /**
46
+ * Unfilled track fills, as `#RRGGBBAA` per scheme: iOS `tertiarySystemFill`
47
+ * behind the `automatic` bar and `systemFill` behind the `linearCapacity` bar.
48
+ */
49
+ export const track = {
50
+ automatic: {light: '#7676801F', dark: '#7676803D'},
51
+ linearCapacity: {light: '#78788033', dark: '#7878805C'},
52
+ } as const;
53
+
54
+ /** Position of `value` within `[min, max]`, clamped to `0…1`. */
55
+ export function fraction(value: number, min: number, max: number): number {
56
+ if (max <= min) return 0;
57
+ return Math.min(1, Math.max(0, (value - min) / (max - min)));
58
+ }
59
+
60
+ /**
61
+ * Center of the `circular` marker for a fraction, relative to the ring center
62
+ * (y grows downwards), on the stroke's center line.
63
+ */
64
+ export function markerOffset(f: number): {x: number; y: number} {
65
+ const angle = ((gauge.ring.arcStart + f * gauge.ring.arcSweep) * Math.PI) / 180;
66
+ const radius = (gauge.ring.size - gauge.ring.stroke) / 2;
67
+ return {x: radius * Math.cos(angle), y: radius * Math.sin(angle)};
68
+ }
69
+
70
+ /**
71
+ * `#RRGGBB` (or `#RGB`/`#RRGGBBAA`) color with its alpha replaced. Other
72
+ * color syntaxes are returned unchanged.
73
+ */
74
+ export function withAlpha(color: string, alpha: number): string {
75
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(color.trim())?.[1];
76
+ if (!hex) return color;
77
+ const rgb = hex.length === 3 ? hex.split('').map(c => c + c).join('') : hex.slice(0, 6);
78
+ return `#${rgb}${Math.round(alpha * 255).toString(16).padStart(2, '0')}`.toUpperCase();
79
+ }
@@ -0,0 +1,64 @@
1
+ import type {StyleProp, ViewStyle} from 'react-native';
2
+
3
+ /**
4
+ * Visual style of the gauge, named after the SwiftUI `GaugeStyle` it renders
5
+ * (the `gaugeStyle` modifier names in `@expo/ui`):
6
+ * - `automatic` — the default in-app style: a capacity bar with the label
7
+ * centered above and the current value centered below.
8
+ * - `linear` — `accessoryLinear`: a bar with a point marker at the current
9
+ * value (no label).
10
+ * - `linearCapacity` — `accessoryLinearCapacity`: a thin capacity bar with
11
+ * the label above and the current value below, aligned to its leading edge.
12
+ * - `circular` — `accessoryCircular`: an open ring with a point marker, the
13
+ * current value in the center and the bounds under the ring.
14
+ * - `circularCapacity` — `accessoryCircularCapacity`: a closed ring filled to
15
+ * the current value with the current value in the center.
16
+ */
17
+ export type GaugeVariant = 'automatic' | 'linear' | 'linearCapacity' | 'circular' | 'circularCapacity';
18
+
19
+ /**
20
+ * Cross-platform gauge showing a value within a range.
21
+ *
22
+ * Bridges the SwiftUI `Gauge` (iOS 16+) on iOS; Android (Jetpack Compose) and
23
+ * web (DOM/SVG) redraw each SwiftUI style with the same geometry: the
24
+ * bounds and current value labels take the accent, the descriptive label
25
+ * keeps the label color, exactly as a tinted SwiftUI gauge does.
26
+ */
27
+ export interface GaugeProps {
28
+ /** Current value, between `min` and `max`. */
29
+ value: number;
30
+ /**
31
+ * Lower bound of the range.
32
+ * @default 0
33
+ */
34
+ min?: number;
35
+ /**
36
+ * Upper bound of the range.
37
+ * @default 1
38
+ */
39
+ max?: number;
40
+ /**
41
+ * SwiftUI gauge style to render.
42
+ * @default 'automatic'
43
+ */
44
+ variant?: GaugeVariant;
45
+ /**
46
+ * Text describing the gauge's purpose. Shown above the bar in the linear
47
+ * capacity styles and in the center of the circular styles when there is
48
+ * no `currentValueLabel`; the `linear` style only exposes it to assistive
49
+ * technology.
50
+ */
51
+ label?: string;
52
+ /** Text showing the current value. */
53
+ currentValueLabel?: string;
54
+ /** Text showing the lower bound. */
55
+ minimumValueLabel?: string;
56
+ /** Text showing the upper bound. */
57
+ maximumValueLabel?: string;
58
+ /** Tint of the indicator and the value labels (overrides the theme accent). */
59
+ accentColor?: string;
60
+ /** Identifier used to locate the component in end-to-end tests. */
61
+ testID?: string;
62
+ /** Style applied to the container (web only). */
63
+ style?: StyleProp<ViewStyle>;
64
+ }
package/src/index.ts CHANGED
@@ -3,7 +3,6 @@
3
3
  export * from './theme';
4
4
  export * from './accent';
5
5
  export * from './icons';
6
- export * from './link';
7
6
  export {fillWidth} from './fill';
8
7
 
9
8
  // Layout
@@ -17,20 +16,42 @@ export {Tabs} from './tabs';
17
16
  export type {TabBarProps, TabRoute, WebLogo} from './tabs/types';
18
17
 
19
18
  // Components
19
+ export {Alert} from './alert';
20
+ export type {AlertAction, AlertActionRole, AlertProps} from './alert/types';
20
21
  export {Button} from './button';
21
22
  export type {ButtonProps, ButtonRole, ButtonShape, ButtonSize, ButtonVariant} from './button/types';
23
+ export {Checkbox} from './checkbox';
24
+ export type {CheckboxProps} from './checkbox/types';
25
+ export {Collapsible} from './collapsible';
26
+ export type {CollapsibleProps} from './collapsible/types';
27
+ export {ColorPicker} from './color-picker';
28
+ export type {ColorPickerProps} from './color-picker/types';
29
+ export {ContextMenu} from './context-menu';
22
30
  export {DateTimePicker} from './date-time';
23
31
  export type {DateTimeMode, DateTimePickerProps} from './date-time/types';
32
+ export {Divider} from './divider';
33
+ export type {DividerProps} from './divider/types';
24
34
  export {FieldGroup, type FieldGroupProps} from './field-group';
35
+ export {Gauge} from './gauge';
36
+ export type {GaugeProps, GaugeVariant} from './gauge/types';
25
37
  export {ListItem} from './list-item';
26
38
  export type {ListItemProps} from './list-item/types';
39
+ export {Menu} from './menu';
40
+ export type {ContextMenuProps, MenuItem, MenuProps} from './menu/types';
27
41
  export {Picker} from './picker';
28
42
  export type {PickerItemProps, PickerOption, PickerProps, PickerValue} from './picker/types';
29
43
  export {Progress} from './progress';
30
- export type {ProgressProps} from './progress/types';
31
- export {QRCode, type QRCodeProps} from './qr';
44
+ export type {ProgressProps, ProgressVariant} from './progress/types';
45
+ export {SegmentedControl} from './segmented';
46
+ export type {SegmentedControlProps} from './segmented/types';
47
+ export {Slider} from './slider';
48
+ export type {SliderProps} from './slider/types';
49
+ export {Stepper} from './stepper';
50
+ export type {StepperProps} from './stepper/types';
32
51
  export {Switch} from './switch';
33
52
  export type {SwitchProps} from './switch/types';
53
+ export {Tooltip} from './tooltip';
54
+ export type {TooltipProps} from './tooltip/types';
34
55
  export {TextField} from './text-field';
35
56
  export type {TextFieldCapitalize, TextFieldKeyboard, TextFieldProps} from './text-field/types';
36
57
  export {ExternalLink} from './router/external-link';
@@ -0,0 +1,66 @@
1
+ import type {MenuItem, MenuProps} from './types';
2
+
3
+ import {Fragment, useState} from 'react';
4
+ import {DropdownMenu, DropdownMenuItem, HorizontalDivider, Icon, Text, useMaterialColors} from '@expo/ui/jetpack-compose';
5
+ import {Button} from '../button';
6
+ import {useColor} from '../theme';
7
+
8
+ const ICON_SIZE = 20;
9
+
10
+ /**
11
+ * Android anchors a Material 3 `DropdownMenu` to the kit's `Button`. Entries
12
+ * are `DropdownMenuItem`s with an optional drawable leading icon; destructive
13
+ * items use the theme danger color.
14
+ */
15
+ export function Menu({label, icon, items, testID, ...button}: MenuProps) {
16
+ const [expanded, setExpanded] = useState(false);
17
+ return (
18
+ <DropdownMenu expanded={expanded} onDismissRequest={() => setExpanded(false)}>
19
+ <DropdownMenu.Trigger>
20
+ <Button
21
+ {...button}
22
+ label={label}
23
+ prefixIcon={icon}
24
+ onPress={() => setExpanded(true)}
25
+ testID={testID}
26
+ />
27
+ </DropdownMenu.Trigger>
28
+ <MenuItems items={items} onClose={() => setExpanded(false)}/>
29
+ </DropdownMenu>
30
+ );
31
+ }
32
+
33
+ /** Compose `DropdownMenu.Items` shared by `Menu` and `ContextMenu`. */
34
+ export function MenuItems({items, onClose}: {items: MenuItem[]; onClose: () => void}) {
35
+ const colors = useMaterialColors();
36
+ const destructive = useColor('destructive');
37
+ const separator = useColor('separator');
38
+ return (
39
+ <DropdownMenu.Items>
40
+ {items.map((item, index) => {
41
+ const color = item.role === 'destructive' ? destructive : colors.onSurface;
42
+ return (
43
+ <Fragment key={index}>
44
+ {item.separator && index > 0 ? <HorizontalDivider color={separator}/> : null}
45
+ <DropdownMenuItem
46
+ enabled={!item.disabled}
47
+ elementColors={{textColor: color, leadingIconColor: color}}
48
+ onClick={item.disabled ? undefined : () => {
49
+ onClose();
50
+ item.onPress?.();
51
+ }}>
52
+ {item.icon?.drawable ? (
53
+ <DropdownMenuItem.LeadingIcon>
54
+ <Icon source={item.icon.drawable} size={ICON_SIZE} tint={item.disabled ? colors.onSurfaceVariant : color}/>
55
+ </DropdownMenuItem.LeadingIcon>
56
+ ) : null}
57
+ <DropdownMenuItem.Text>
58
+ <Text color={item.disabled ? colors.onSurfaceVariant : color}>{item.label}</Text>
59
+ </DropdownMenuItem.Text>
60
+ </DropdownMenuItem>
61
+ </Fragment>
62
+ );
63
+ })}
64
+ </DropdownMenu.Items>
65
+ );
66
+ }
@@ -0,0 +1,72 @@
1
+ import type {MenuItem, MenuProps} from './types';
2
+ import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
3
+
4
+ import {Fragment} from 'react';
5
+ import {Button, Divider, Menu as SwiftUIMenu} from '@expo/ui/swift-ui';
6
+ import {buttonBorderShape, buttonStyle, controlSize, disabled as disabledMod, labelStyle, tint} from '@expo/ui/swift-ui/modifiers';
7
+ import {iosSymbol, swiftBorderShape, swiftControlSize} from '../button/shared';
8
+ import {useColor} from '../theme';
9
+
10
+ const VARIANT_STYLE = {
11
+ filled: 'borderedProminent',
12
+ outlined: 'bordered',
13
+ text: 'plain',
14
+ } as const;
15
+
16
+ /**
17
+ * iOS renders SwiftUI's `Menu`, styled with the same `buttonStyle` / `tint`
18
+ * mapping as the kit's `Button` so the trigger matches. Entries are SwiftUI
19
+ * `Button`s (with SF Symbol and `destructive` role) and `Divider`s.
20
+ */
21
+ export function Menu({
22
+ label,
23
+ icon,
24
+ items,
25
+ variant = 'filled',
26
+ size = 'medium',
27
+ shape,
28
+ color,
29
+ hideLabel,
30
+ disabled,
31
+ testID,
32
+ }: MenuProps) {
33
+ const themeTint = useColor('tint');
34
+ const modifiers: ViewModifier[] = [
35
+ buttonStyle(VARIANT_STYLE[variant]),
36
+ controlSize(swiftControlSize(size)),
37
+ tint(color ?? themeTint),
38
+ ];
39
+ if (shape) modifiers.push(buttonBorderShape(swiftBorderShape(shape)));
40
+ if (hideLabel && icon) modifiers.push(labelStyle('iconOnly'));
41
+ if (disabled) modifiers.push(disabledMod(true));
42
+
43
+ return (
44
+ <SwiftUIMenu
45
+ label={label}
46
+ systemImage={icon ? iosSymbol(icon) : undefined}
47
+ modifiers={modifiers}
48
+ testID={testID}>
49
+ <MenuItems items={items}/>
50
+ </SwiftUIMenu>
51
+ );
52
+ }
53
+
54
+ /** SwiftUI menu entries shared by `Menu` and `ContextMenu`. */
55
+ export function MenuItems({items}: {items: MenuItem[]}) {
56
+ return (
57
+ <>
58
+ {items.map((item, index) => (
59
+ <Fragment key={index}>
60
+ {item.separator && index > 0 ? <Divider/> : null}
61
+ <Button
62
+ label={item.label}
63
+ systemImage={item.icon ? iosSymbol(item.icon) : undefined}
64
+ role={item.role === 'destructive' ? 'destructive' : 'default'}
65
+ onPress={item.onPress}
66
+ modifiers={item.disabled ? [disabledMod(true)] : undefined}
67
+ />
68
+ </Fragment>
69
+ ))}
70
+ </>
71
+ );
72
+ }
@@ -0,0 +1,30 @@
1
+ import './menu.css';
2
+ import type {CSSProperties} from 'react';
3
+ import type {MenuProps} from './types';
4
+ import {useId, useRef} from 'react';
5
+ import {Button} from '../button';
6
+ import {MenuList, menuIdent} from './list';
7
+
8
+ /**
9
+ * On web the trigger is the kit's `<button>` with a `popovertarget` pointing
10
+ * at the entries' native `popover`, so opening, closing, light dismiss and
11
+ * `aria-expanded` are all handled by the browser. The wrapper carries the
12
+ * `anchor-name` that CSS anchor positioning places the popup against.
13
+ */
14
+ export function Menu({label, icon, items, testID, ...button}: MenuProps) {
15
+ const ident = menuIdent(useId());
16
+ const anchor = `--${ident}`;
17
+ const wrapper = useRef<HTMLSpanElement>(null);
18
+ return (
19
+ <span ref={wrapper} className="ui-menu" style={{anchorName: anchor} as CSSProperties}>
20
+ <Button
21
+ {...button}
22
+ label={label}
23
+ prefixIcon={icon}
24
+ popoverTarget={ident}
25
+ testID={testID}
26
+ />
27
+ <MenuList id={ident} items={items} anchor={anchor} anchorRef={wrapper}/>
28
+ </span>
29
+ );
30
+ }
@@ -0,0 +1,98 @@
1
+ import type {CSSProperties, ToggleEvent} from 'react';
2
+ import type {MenuItem} from './types';
3
+ import {useRef} from 'react';
4
+ import {SymbolView} from 'expo-symbols';
5
+
6
+ const ICON_SIZE = 16;
7
+ const VIEWPORT_GAP = 8;
8
+
9
+ /** Whether the browser lays out `position-anchor` natively (Baseline 2026). */
10
+ const ANCHOR_SUPPORTED =
11
+ typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('position-anchor', '--ui-menu');
12
+
13
+ /** Turns a React `useId()` value into a valid CSS `<dashed-ident>` / HTML id. */
14
+ export function menuIdent(id: string): string {
15
+ return `ui-menu-${id.replace(/[^A-Za-z0-9_-]/g, '_')}`;
16
+ }
17
+
18
+ interface MenuListProps {
19
+ /** HTML id of the popover, referenced by the trigger's `popovertarget`. */
20
+ id: string;
21
+ items: MenuItem[];
22
+ /** `anchor-name` of the trigger; the popup is laid out relative to it. */
23
+ anchor?: string;
24
+ /** Element to measure when the browser lacks CSS anchor positioning. */
25
+ anchorRef?: React.RefObject<HTMLElement | null>;
26
+ /** Fixed viewport position (context menus) instead of an anchor. */
27
+ position?: {x: number; y: number} | null;
28
+ /** Exposes the popover element so callers can `showPopover()` programmatically. */
29
+ popoverRef?: React.RefObject<HTMLDivElement | null>;
30
+ }
31
+
32
+ /**
33
+ * Web `role="menu"` popup shared by `Menu` and `ContextMenu`, rendered as a
34
+ * native `popover="auto"` element. The browser handles the top layer, light
35
+ * dismiss (outside click / Escape) and the trigger's `aria-expanded`; every
36
+ * item carries `popovertargetaction="hide"` so picking one closes the menu
37
+ * declaratively. Placement is CSS anchor positioning (see `menu.css`), with a
38
+ * measured fallback for engines without it.
39
+ */
40
+ export function MenuList({id, items, anchor, anchorRef, position, popoverRef}: MenuListProps) {
41
+ const localRef = useRef<HTMLDivElement>(null);
42
+ const ref = popoverRef ?? localRef;
43
+ const anchored = !!anchor && !position;
44
+
45
+ const style: Record<string, string | number> = {};
46
+ if (anchored && ANCHOR_SUPPORTED) style.positionAnchor = anchor;
47
+ if (position) {
48
+ style.left = position.x;
49
+ style.top = position.y;
50
+ }
51
+
52
+ const onToggle = (event: ToggleEvent<HTMLDivElement>) => {
53
+ const popover = event.currentTarget;
54
+ if (event.newState !== 'open') return;
55
+ // Fallback placement: below the trigger, right-aligned, kept on screen.
56
+ if (anchored && !ANCHOR_SUPPORTED && anchorRef?.current) {
57
+ const rect = anchorRef.current.getBoundingClientRect();
58
+ popover.style.left = `${Math.max(VIEWPORT_GAP, rect.right - popover.offsetWidth)}px`;
59
+ popover.style.top = `${rect.bottom + 4}px`;
60
+ }
61
+ // Pointer placement: nudge back inside the viewport.
62
+ if (position) {
63
+ const maxX = window.innerWidth - popover.offsetWidth - VIEWPORT_GAP;
64
+ const maxY = window.innerHeight - popover.offsetHeight - VIEWPORT_GAP;
65
+ popover.style.left = `${Math.max(VIEWPORT_GAP, Math.min(position.x, maxX))}px`;
66
+ popover.style.top = `${Math.max(VIEWPORT_GAP, Math.min(position.y, maxY))}px`;
67
+ }
68
+ (popover.querySelector('button:not(:disabled)') as HTMLButtonElement | null)?.focus();
69
+ };
70
+
71
+ return (
72
+ <div
73
+ ref={ref}
74
+ id={id}
75
+ role="menu"
76
+ popover="auto"
77
+ className={['ui-menu__list', anchored && ANCHOR_SUPPORTED && 'ui-menu__list--anchored'].filter(Boolean).join(' ')}
78
+ style={style as CSSProperties}
79
+ onToggle={onToggle}>
80
+ {items.map((item, index) => (
81
+ <div key={index}>
82
+ {item.separator && index > 0 ? <div className="ui-menu__separator" role="separator"/> : null}
83
+ <button
84
+ type="button"
85
+ role="menuitem"
86
+ className={['ui-menu__item', item.role === 'destructive' && 'ui-menu__item--destructive'].filter(Boolean).join(' ')}
87
+ disabled={item.disabled}
88
+ popoverTarget={id}
89
+ popoverTargetAction="hide"
90
+ onClick={item.onPress}>
91
+ {item.icon ? <SymbolView name={item.icon.symbol} size={ICON_SIZE} tintColor="currentColor"/> : null}
92
+ <span>{item.label}</span>
93
+ </button>
94
+ </div>
95
+ ))}
96
+ </div>
97
+ );
98
+ }