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.
- package/README.md +260 -180
- package/package.json +41 -10
- package/src/alert/alert.css +49 -0
- package/src/alert/index.android.tsx +68 -0
- package/src/alert/index.ios.tsx +47 -0
- package/src/alert/index.tsx +61 -0
- package/src/alert/shared.ts +10 -0
- package/src/alert/types.ts +58 -0
- package/src/button/index.tsx +17 -1
- package/src/checkbox/checkbox.css +29 -0
- package/src/checkbox/index.android.tsx +54 -0
- package/src/checkbox/index.ios.tsx +50 -0
- package/src/checkbox/index.tsx +50 -0
- package/src/checkbox/types.ts +27 -0
- package/src/collapsible/collapsible.css +32 -0
- package/src/collapsible/index.android.tsx +34 -0
- package/src/collapsible/index.ios.tsx +31 -0
- package/src/collapsible/index.tsx +41 -0
- package/src/collapsible/shared.ts +21 -0
- package/src/collapsible/types.ts +24 -0
- package/src/context-menu/index.android.tsx +27 -0
- package/src/context-menu/index.ios.tsx +22 -0
- package/src/context-menu/index.tsx +56 -0
- package/src/date-time/index.android.tsx +3 -2
- package/src/divider/divider.css +16 -0
- package/src/divider/index.android.tsx +26 -0
- package/src/divider/index.ios.tsx +18 -0
- package/src/divider/index.tsx +21 -0
- package/src/divider/types.ts +20 -0
- package/src/index.ts +20 -2
- package/src/menu/index.android.tsx +66 -0
- package/src/menu/index.ios.tsx +72 -0
- package/src/menu/index.tsx +30 -0
- package/src/menu/list.tsx +98 -0
- package/src/menu/menu.css +83 -0
- package/src/menu/types.ts +60 -0
- package/src/picker/index.android.tsx +3 -2
- package/src/progress/index.android.tsx +23 -9
- package/src/progress/index.ios.tsx +7 -6
- package/src/progress/index.tsx +43 -6
- package/src/progress/progress.css +24 -0
- package/src/progress/types.ts +23 -6
- package/src/qr/index.tsx +2 -2
- package/src/segmented/index.android.tsx +67 -0
- package/src/segmented/index.ios.tsx +47 -0
- package/src/segmented/index.tsx +58 -0
- package/src/segmented/segmented.css +56 -0
- package/src/segmented/types.ts +31 -0
- package/src/slider/index.android.tsx +87 -0
- package/src/slider/index.ios.tsx +64 -0
- package/src/slider/index.tsx +55 -0
- package/src/slider/slider.css +30 -0
- package/src/slider/types.ts +39 -0
- package/src/stepper/index.android.tsx +58 -0
- package/src/stepper/index.ios.tsx +55 -0
- package/src/stepper/index.tsx +47 -0
- package/src/stepper/shared.ts +17 -0
- package/src/stepper/stepper.css +61 -0
- package/src/stepper/types.ts +35 -0
- package/src/text-field/shared.ts +2 -0
- package/src/tooltip/index.android.tsx +23 -0
- package/src/tooltip/index.ios.tsx +17 -0
- package/src/tooltip/index.tsx +41 -0
- package/src/tooltip/tooltip.css +40 -0
- package/src/tooltip/types.ts +25 -0
- 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
|
+
}
|