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,24 @@
1
+ import type {ReactNode} from 'react';
2
+
3
+ /**
4
+ * Cross-platform disclosure: a tappable header that shows or hides content.
5
+ *
6
+ * Bridges the SwiftUI `DisclosureGroup` on iOS, the Jetpack Compose
7
+ * expandable list item on Android (via `@expo/ui`'s universal `Collapsible`),
8
+ * and the HTML `<details>` element on web. May be used controlled
9
+ * (`expanded` + `onExpandedChange`) or uncontrolled (`defaultExpanded`).
10
+ */
11
+ export interface CollapsibleProps {
12
+ /** Text rendered in the tappable header. */
13
+ label: string;
14
+ /** Whether the content is shown (controlled). */
15
+ expanded?: boolean;
16
+ /** Initial state when uncontrolled. */
17
+ defaultExpanded?: boolean;
18
+ /** Called when the user toggles the header. */
19
+ onExpandedChange?: (expanded: boolean) => void;
20
+ /** Content shown while expanded. Must be native (`@expo/ui`) content on iOS/Android. */
21
+ children?: ReactNode;
22
+ /** Identifier used to locate the component in end-to-end tests. */
23
+ testID?: string;
24
+ }
@@ -0,0 +1,27 @@
1
+ import type {ContextMenuProps} from '../menu/types';
2
+
3
+ import {useState} from 'react';
4
+ import {Box, DropdownMenu} from '@expo/ui/jetpack-compose';
5
+ import {combinedClickable, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
6
+ import {MenuItems} from '../menu/index.android';
7
+
8
+ /**
9
+ * Android wraps `children` in a `Box` with `combinedClickable`, so a
10
+ * long-press expands a Material 3 `DropdownMenu` anchored to it while a tap
11
+ * goes to `onPress`. `children` must be Compose content.
12
+ */
13
+ export function ContextMenu({items, children, onPress, disabled, testID}: ContextMenuProps) {
14
+ const [expanded, setExpanded] = useState(false);
15
+ const modifiers = [
16
+ ...(disabled ? [] : [combinedClickable({onClick: onPress, onLongClick: () => setExpanded(true)})]),
17
+ ...(testID ? [testIDModifier(testID)] : []),
18
+ ];
19
+ return (
20
+ <DropdownMenu expanded={expanded} onDismissRequest={() => setExpanded(false)}>
21
+ <DropdownMenu.Trigger>
22
+ <Box modifiers={modifiers}>{children}</Box>
23
+ </DropdownMenu.Trigger>
24
+ <MenuItems items={items} onClose={() => setExpanded(false)}/>
25
+ </DropdownMenu>
26
+ );
27
+ }
@@ -0,0 +1,22 @@
1
+ import type {ContextMenuProps} from '../menu/types';
2
+
3
+ import {ContextMenu as SwiftUIContextMenu} from '@expo/ui/swift-ui';
4
+ import {onTapGesture} from '@expo/ui/swift-ui/modifiers';
5
+ import {MenuItems} from '../menu/index.ios';
6
+
7
+ /**
8
+ * iOS renders SwiftUI's `contextMenu`: a long-press on `children` lifts it
9
+ * into a preview with the entries beneath. `children` must be SwiftUI
10
+ * content. A plain tap is passed to `onPress` through `onTapGesture`.
11
+ */
12
+ export function ContextMenu({items, children, onPress, disabled, testID}: ContextMenuProps) {
13
+ if (disabled) return <>{children}</>;
14
+ return (
15
+ <SwiftUIContextMenu modifiers={onPress ? [onTapGesture(onPress)] : undefined} testID={testID}>
16
+ <SwiftUIContextMenu.Trigger>{children}</SwiftUIContextMenu.Trigger>
17
+ <SwiftUIContextMenu.Items>
18
+ <MenuItems items={items}/>
19
+ </SwiftUIContextMenu.Items>
20
+ </SwiftUIContextMenu>
21
+ );
22
+ }
@@ -0,0 +1,56 @@
1
+ import '../menu/menu.css';
2
+ import type {MouseEvent, PointerEvent} from 'react';
3
+ import type {ContextMenuProps} from '../menu/types';
4
+ import {useId, useRef, useState} from 'react';
5
+ import {MenuList, menuIdent} from '../menu/list';
6
+
7
+ const LONG_PRESS_MS = 500;
8
+
9
+ /**
10
+ * On web the entries live in the same native `popover="auto"` element as
11
+ * `Menu`, opened with `showPopover()` on right-click (`contextmenu`) or a
12
+ * touch long-press and placed at the pointer. The browser still owns the top
13
+ * layer and light dismiss. The wrapper is `display: contents`, so it doesn't
14
+ * affect the layout of `children`.
15
+ */
16
+ export function ContextMenu({items, children, onPress, disabled, testID}: ContextMenuProps) {
17
+ const ident = menuIdent(useId());
18
+ const popover = useRef<HTMLDivElement>(null);
19
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
20
+ const [position, setPosition] = useState<{x: number; y: number} | null>(null);
21
+
22
+ const open = (x: number, y: number) => {
23
+ setPosition({x, y});
24
+ const element = popover.current;
25
+ if (element && !element.matches(':popover-open')) element.showPopover();
26
+ };
27
+ const onContextMenu = (event: MouseEvent) => {
28
+ if (disabled) return;
29
+ event.preventDefault();
30
+ open(event.clientX, event.clientY);
31
+ };
32
+ const onPointerDown = (event: PointerEvent) => {
33
+ if (disabled || event.pointerType !== 'touch') return;
34
+ const {clientX: x, clientY: y} = event;
35
+ timer.current = setTimeout(() => open(x, y), LONG_PRESS_MS);
36
+ };
37
+ const cancelPress = () => {
38
+ if (timer.current) clearTimeout(timer.current);
39
+ timer.current = null;
40
+ };
41
+
42
+ return (
43
+ <div
44
+ className="ui-context-menu"
45
+ onContextMenu={onContextMenu}
46
+ onPointerDown={onPointerDown}
47
+ onPointerUp={cancelPress}
48
+ onPointerCancel={cancelPress}
49
+ onPointerMove={cancelPress}
50
+ onClick={disabled ? undefined : onPress}
51
+ data-testid={testID}>
52
+ {children}
53
+ <MenuList id={ident} items={items} position={position} popoverRef={popover}/>
54
+ </div>
55
+ );
56
+ }
@@ -2,7 +2,7 @@ import type {DateTimePickerProps} from './types';
2
2
 
3
3
  import {useState} from 'react';
4
4
  import {useMaterialColors, Row, Text, Column, DatePickerDialog, TimePickerDialog} from '@expo/ui/jetpack-compose';
5
- import {clip, Shapes, padding, clickable, background, fillMaxWidth} from '@expo/ui/jetpack-compose/modifiers';
5
+ import {clip, Shapes, padding, clickable, background, fillMaxWidth, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
6
6
  import {useColor} from '../theme';
7
7
  import {formatValue, useDateValue, withDatePart, withTimePart} from './shared';
8
8
 
@@ -22,6 +22,7 @@ export function DateTimePicker({
22
22
  maximumDate,
23
23
  disabled,
24
24
  accentColor,
25
+ testID,
25
26
  }: DateTimePickerProps) {
26
27
  const [current, setValue] = useDateValue(value, onChange);
27
28
  const [stage, setStage] = useState<'idle' | 'date' | 'time'>('idle');
@@ -69,7 +70,7 @@ export function DateTimePicker({
69
70
  };
70
71
 
71
72
  return (
72
- <Column modifiers={[fillMaxWidth()]}>
73
+ <Column modifiers={[fillMaxWidth(), ...(testID ? [testIDModifier(testID)] : [])]}>
73
74
  <Row
74
75
  verticalAlignment="center"
75
76
  horizontalArrangement="spaceBetween"
@@ -0,0 +1,16 @@
1
+ .ui-divider {
2
+ --ui-divider-color: var(--color-separator);
3
+ flex-shrink: 0;
4
+ align-self: stretch;
5
+ width: 100%;
6
+ height: 1px;
7
+ margin: 0;
8
+ border: none;
9
+ background: var(--ui-divider-color);
10
+ }
11
+
12
+ .ui-divider--vertical {
13
+ width: 1px;
14
+ height: auto;
15
+ min-height: 1em;
16
+ }
@@ -0,0 +1,26 @@
1
+ import type {DividerProps} from './types';
2
+
3
+ import {StyleSheet} from 'react-native';
4
+ import {HorizontalDivider, VerticalDivider} from '@expo/ui/jetpack-compose';
5
+ import {padding, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
6
+ import {useColor} from '../theme';
7
+
8
+ /**
9
+ * Android renders the Material 3 `HorizontalDivider` (or `VerticalDivider`)
10
+ * as a single-pixel line in the theme `separator` color, matching iOS/web.
11
+ */
12
+ export function Divider({vertical, color, inset, testID}: DividerProps) {
13
+ const separator = useColor('separator');
14
+ const Component = vertical ? VerticalDivider : HorizontalDivider;
15
+ const modifiers = [
16
+ ...(inset ? [vertical ? padding(0, inset, 0, 0) : padding(inset, 0, 0, 0)] : []),
17
+ ...(testID ? [testIDModifier(testID)] : []),
18
+ ];
19
+ return (
20
+ <Component
21
+ color={color ?? separator}
22
+ thickness={StyleSheet.hairlineWidth}
23
+ modifiers={modifiers}
24
+ />
25
+ );
26
+ }
@@ -0,0 +1,18 @@
1
+ import type {DividerProps} from './types';
2
+ import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
3
+
4
+ import {Divider as SwiftUIDivider} from '@expo/ui/swift-ui';
5
+ import {background, padding} from '@expo/ui/swift-ui/modifiers';
6
+
7
+ /**
8
+ * iOS renders SwiftUI's `Divider`, which is horizontal inside a `VStack` /
9
+ * `Form` and vertical inside an `HStack` on its own — `vertical` only picks
10
+ * the inset axis here, the surrounding stack decides the orientation. A custom
11
+ * `color` is painted with a `background` modifier (dividers take no tint).
12
+ */
13
+ export function Divider({vertical, color, inset, testID}: DividerProps) {
14
+ const modifiers: ViewModifier[] = [];
15
+ if (inset) modifiers.push(padding(vertical ? {top: inset} : {leading: inset}));
16
+ if (color) modifiers.push(background(color));
17
+ return <SwiftUIDivider modifiers={modifiers} testID={testID}/>;
18
+ }
@@ -0,0 +1,21 @@
1
+ import './divider.css';
2
+ import type {CSSProperties} from 'react';
3
+ import type {DividerProps} from './types';
4
+
5
+ /**
6
+ * On web the rule is a real `<hr>` element colored through the
7
+ * `--ui-divider-color` custom property (the theme `separator` by default).
8
+ */
9
+ export function Divider({vertical, color, inset, testID}: DividerProps) {
10
+ const style: Record<string, string | number> = {};
11
+ if (color) style['--ui-divider-color'] = color;
12
+ if (inset) style[vertical ? 'marginTop' : 'marginLeft'] = inset;
13
+ return (
14
+ <hr
15
+ className={['ui-divider', vertical && 'ui-divider--vertical'].filter(Boolean).join(' ')}
16
+ aria-orientation={vertical ? 'vertical' : 'horizontal'}
17
+ style={style as CSSProperties}
18
+ data-testid={testID}
19
+ />
20
+ );
21
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Cross-platform hairline separator.
3
+ *
4
+ * Bridges the SwiftUI `Divider` on iOS, the Jetpack Compose Material 3
5
+ * `HorizontalDivider` / `VerticalDivider` on Android, and the HTML `<hr>`
6
+ * element on web.
7
+ */
8
+ export interface DividerProps {
9
+ /** Draw a vertical rule (for use inside a row) instead of a horizontal one. */
10
+ vertical?: boolean;
11
+ /** Line color. Defaults to the theme `separator` token. */
12
+ color?: string;
13
+ /**
14
+ * Leading inset in points/dp, e.g. to align the rule with row content that
15
+ * sits after a leading icon.
16
+ */
17
+ inset?: number;
18
+ /** Identifier used to locate the component in end-to-end tests. */
19
+ testID?: string;
20
+ }
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,39 @@ 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 {ContextMenu} from './context-menu';
22
28
  export {DateTimePicker} from './date-time';
23
29
  export type {DateTimeMode, DateTimePickerProps} from './date-time/types';
30
+ export {Divider} from './divider';
31
+ export type {DividerProps} from './divider/types';
24
32
  export {FieldGroup, type FieldGroupProps} from './field-group';
25
33
  export {ListItem} from './list-item';
26
34
  export type {ListItemProps} from './list-item/types';
35
+ export {Menu} from './menu';
36
+ export type {ContextMenuProps, MenuItem, MenuProps} from './menu/types';
27
37
  export {Picker} from './picker';
28
38
  export type {PickerItemProps, PickerOption, PickerProps, PickerValue} from './picker/types';
29
39
  export {Progress} from './progress';
30
- export type {ProgressProps} from './progress/types';
40
+ export type {ProgressProps, ProgressVariant} from './progress/types';
31
41
  export {QRCode, type QRCodeProps} from './qr';
42
+ export {SegmentedControl} from './segmented';
43
+ export type {SegmentedControlProps} from './segmented/types';
44
+ export {Slider} from './slider';
45
+ export type {SliderProps} from './slider/types';
46
+ export {Stepper} from './stepper';
47
+ export type {StepperProps} from './stepper/types';
32
48
  export {Switch} from './switch';
33
49
  export type {SwitchProps} from './switch/types';
50
+ export {Tooltip} from './tooltip';
51
+ export type {TooltipProps} from './tooltip/types';
34
52
  export {TextField} from './text-field';
35
53
  export type {TextFieldCapitalize, TextFieldKeyboard, TextFieldProps} from './text-field/types';
36
54
  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
+ }
@@ -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
+ }