expo-interface 0.2.0 → 0.3.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.
- package/README.md +87 -10
- package/package.json +6 -1
- package/src/accent.tsx +21 -8
- package/src/button/button.css +6 -0
- package/src/button/index.android.tsx +5 -1
- package/src/button/index.ios.tsx +5 -1
- package/src/button/index.tsx +6 -1
- package/src/button/types.ts +13 -0
- package/src/color-picker/color-picker.css +70 -0
- package/src/color-picker/index.android.tsx +77 -49
- package/src/color-picker/index.ios.tsx +58 -11
- package/src/color-picker/index.tsx +65 -15
- package/src/color-picker/types.ts +9 -3
- package/src/context-menu/index.android.tsx +39 -11
- package/src/context-menu/index.ios.tsx +3 -1
- package/src/context-menu/index.tsx +24 -7
- package/src/fab/fab.css +72 -0
- package/src/fab/index.android.tsx +72 -0
- package/src/fab/index.ios.tsx +68 -0
- package/src/fab/index.tsx +37 -0
- package/src/fab/shared.ts +23 -0
- package/src/fab/types.ts +39 -0
- package/src/field-group/index.android.tsx +22 -16
- package/src/field-group/index.tsx +36 -5
- package/src/field-group/index.web.tsx +25 -9
- package/src/field-group/shared.tsx +49 -0
- package/src/field-group/types.ts +25 -0
- package/src/header-menu/index.tsx +76 -0
- package/src/host/index.tsx +32 -0
- package/src/index.ts +31 -5
- package/src/keyboard/index.tsx +81 -0
- package/src/keyboard/library.native.ts +17 -0
- package/src/keyboard/library.ts +12 -0
- package/src/keyboard/types.ts +20 -0
- package/src/list-item/index.android.tsx +23 -6
- package/src/list-item/index.tsx +20 -4
- package/src/list-item/index.web.tsx +60 -0
- package/src/list-item/list-item.css +65 -0
- package/src/list-item/types.ts +21 -0
- package/src/menu/index.android.tsx +22 -9
- package/src/menu/index.ios.tsx +26 -11
- package/src/menu/index.tsx +26 -10
- package/src/menu/list.tsx +25 -10
- package/src/menu/menu.css +52 -0
- package/src/menu/types.ts +41 -2
- package/src/scheme.ts +140 -0
- package/src/screen/index.tsx +49 -5
- package/src/tab-stack/index.tsx +15 -2
- package/src/tabs/index.tsx +2 -1
- package/src/tabs/index.web.tsx +29 -4
- package/src/tabs/types.ts +20 -2
- package/src/text-field/index.android.tsx +30 -6
- package/src/text-field/index.ios.tsx +16 -3
- package/src/text-field/index.tsx +24 -4
- package/src/text-field/inline.tsx +87 -0
- package/src/text-field/shared.ts +40 -1
- package/src/text-field/types.ts +47 -2
- package/src/theme.ts +44 -5
|
@@ -1,22 +1,39 @@
|
|
|
1
1
|
import type {ReactNode} from 'react';
|
|
2
2
|
import type {ListItemProps} from './types';
|
|
3
|
-
import {ListItem as ComposeListItem, Text} from '@expo/ui/jetpack-compose';
|
|
4
|
-
import {clickable, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
|
|
3
|
+
import {ListItem as ComposeListItem, Row, Text, TextButton} from '@expo/ui/jetpack-compose';
|
|
4
|
+
import {clickable, testID as testIDModifier, wrapContentHeight, wrapContentWidth} from '@expo/ui/jetpack-compose/modifiers';
|
|
5
5
|
import {useColor} from '../theme';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Android uses the Material 3 Compose `ListItem` directly so the container
|
|
9
9
|
* can be made transparent — the M3 default paints the Host palette's
|
|
10
10
|
* `surface`, which reads as a grey panel over the app's screen background
|
|
11
|
-
* (web/iOS rows are transparent).
|
|
11
|
+
* (web/iOS rows are transparent). An `action` is a `TextButton` in the
|
|
12
|
+
* trailing slot, after any `trailing` content.
|
|
12
13
|
*/
|
|
13
|
-
export function ListItem({children, leading, trailing, supporting, onPress, testID}: ListItemProps) {
|
|
14
|
+
export function ListItem({children, leading, trailing, action, supporting, onPress, testID}: ListItemProps) {
|
|
14
15
|
const label = useColor('label');
|
|
15
16
|
const subtle = useColor('secondaryLabel');
|
|
17
|
+
const tint = useColor('tint');
|
|
18
|
+
const destructive = useColor('destructive');
|
|
19
|
+
const muted = useColor('tertiaryLabel');
|
|
16
20
|
const modifiers = [
|
|
17
21
|
...(onPress ? [clickable(onPress)] : []),
|
|
18
22
|
...(testID ? [testIDModifier(testID)] : []),
|
|
19
23
|
];
|
|
24
|
+
const actionColor = action?.role === 'destructive' ? destructive : tint;
|
|
25
|
+
const trailingContent = action ? (
|
|
26
|
+
<Row verticalAlignment="center" horizontalArrangement={{spacedBy: 8}}>
|
|
27
|
+
{trailing}
|
|
28
|
+
<TextButton
|
|
29
|
+
onClick={action.disabled ? undefined : action.onPress}
|
|
30
|
+
enabled={!action.disabled}
|
|
31
|
+
colors={{contentColor: actionColor}}
|
|
32
|
+
modifiers={[wrapContentWidth('end'), wrapContentHeight('centerVertically')]}>
|
|
33
|
+
<Text color={action.disabled ? muted : actionColor}>{action.label}</Text>
|
|
34
|
+
</TextButton>
|
|
35
|
+
</Row>
|
|
36
|
+
) : trailing;
|
|
20
37
|
return (
|
|
21
38
|
<ComposeListItem colors={{containerColor: '#00000000'}} modifiers={modifiers}>
|
|
22
39
|
<ComposeListItem.HeadlineContent>
|
|
@@ -34,8 +51,8 @@ export function ListItem({children, leading, trailing, supporting, onPress, test
|
|
|
34
51
|
)}
|
|
35
52
|
</ComposeListItem.SupportingContent>
|
|
36
53
|
) : null}
|
|
37
|
-
{
|
|
38
|
-
<ComposeListItem.TrailingContent>{
|
|
54
|
+
{trailingContent != null ? (
|
|
55
|
+
<ComposeListItem.TrailingContent>{trailingContent}</ComposeListItem.TrailingContent>
|
|
39
56
|
) : null}
|
|
40
57
|
</ComposeListItem>
|
|
41
58
|
);
|
package/src/list-item/index.tsx
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
import type {ListItemProps} from './types';
|
|
2
2
|
import {ListItem as UIListItem} from '@expo/ui';
|
|
3
|
+
import {Button} from '../button';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
6
|
+
* iOS renders the universal `@expo/ui` `ListItem` (a SwiftUI list row)
|
|
7
|
+
* through explicit slot props. An `action` is the kit's text `Button` at the
|
|
8
|
+
* end of the trailing slot, after any `trailing` content; the row itself
|
|
9
|
+
* only responds when it also has an `onPress`.
|
|
7
10
|
*/
|
|
8
|
-
export function ListItem({children, leading, trailing, supporting, onPress, testID}: ListItemProps) {
|
|
11
|
+
export function ListItem({children, leading, trailing, action, supporting, onPress, testID}: ListItemProps) {
|
|
12
|
+
const trailingContent = action ? (
|
|
13
|
+
<>
|
|
14
|
+
{trailing}
|
|
15
|
+
<Button
|
|
16
|
+
label={action.label}
|
|
17
|
+
variant="text"
|
|
18
|
+
size="small"
|
|
19
|
+
role={action.role === 'destructive' ? 'destructive' : 'default'}
|
|
20
|
+
disabled={action.disabled}
|
|
21
|
+
onPress={action.onPress}
|
|
22
|
+
/>
|
|
23
|
+
</>
|
|
24
|
+
) : trailing;
|
|
9
25
|
return (
|
|
10
26
|
<UIListItem
|
|
11
27
|
onPress={onPress}
|
|
12
28
|
leading={leading}
|
|
13
|
-
trailing={
|
|
29
|
+
trailing={trailingContent}
|
|
14
30
|
supportingText={supporting}
|
|
15
31
|
testID={testID}>
|
|
16
32
|
{children}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import './list-item.css';
|
|
2
|
+
import type {ListItemProps} from './types';
|
|
3
|
+
import {ListItem as UIListItem} from '@expo/ui';
|
|
4
|
+
import {Button} from '../button';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Web renders the universal `@expo/ui` `ListItem` (a React Native row)
|
|
8
|
+
* through explicit slot props. With an `action` the row is drawn here
|
|
9
|
+
* instead, so the action's `<button>` sits beside the row's own (a `<button>`
|
|
10
|
+
* when the row has an `onPress`, a `<div>` otherwise) rather than inside it:
|
|
11
|
+
* nested buttons are not valid HTML, and a click on the action would also
|
|
12
|
+
* press the row.
|
|
13
|
+
*/
|
|
14
|
+
export function ListItem({children, leading, trailing, action, supporting, onPress, testID}: ListItemProps) {
|
|
15
|
+
if (!action) {
|
|
16
|
+
return (
|
|
17
|
+
<UIListItem
|
|
18
|
+
onPress={onPress}
|
|
19
|
+
leading={leading}
|
|
20
|
+
trailing={trailing}
|
|
21
|
+
supportingText={supporting}
|
|
22
|
+
testID={testID}>
|
|
23
|
+
{children}
|
|
24
|
+
</UIListItem>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
const content = (
|
|
28
|
+
<>
|
|
29
|
+
{leading != null ? <span className="ui-list-item__slot">{leading}</span> : null}
|
|
30
|
+
<span className="ui-list-item__main">
|
|
31
|
+
<span className="ui-list-item__headline">{children}</span>
|
|
32
|
+
{supporting != null ? (
|
|
33
|
+
typeof supporting === 'string' || typeof supporting === 'number'
|
|
34
|
+
? <span className="ui-list-item__supporting">{supporting}</span>
|
|
35
|
+
: supporting
|
|
36
|
+
) : null}
|
|
37
|
+
</span>
|
|
38
|
+
{trailing != null ? <span className="ui-list-item__slot">{trailing}</span> : null}
|
|
39
|
+
</>
|
|
40
|
+
);
|
|
41
|
+
return (
|
|
42
|
+
<div className="ui-list-item" data-testid={testID}>
|
|
43
|
+
{onPress ? (
|
|
44
|
+
<button type="button" className="ui-list-item__row ui-list-item__row--pressable" onClick={onPress}>{content}</button>
|
|
45
|
+
) : (
|
|
46
|
+
<div className="ui-list-item__row">{content}</div>
|
|
47
|
+
)}
|
|
48
|
+
<Button
|
|
49
|
+
label={action.label}
|
|
50
|
+
variant="text"
|
|
51
|
+
size="small"
|
|
52
|
+
role={action.role === 'destructive' ? 'destructive' : 'default'}
|
|
53
|
+
disabled={action.disabled}
|
|
54
|
+
onPress={action.onPress}
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type {ListItemProps} from './types';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The web row with a trailing action: the universal row's geometry (12px by
|
|
3
|
+
* 16px padding, a 12px gap between the slots), the row's own control beside
|
|
4
|
+
* the action's `<button>` rather than around it.
|
|
5
|
+
*/
|
|
6
|
+
.ui-list-item {
|
|
7
|
+
display: flex;
|
|
8
|
+
flex-direction: row;
|
|
9
|
+
align-items: center;
|
|
10
|
+
gap: 12px;
|
|
11
|
+
box-sizing: border-box;
|
|
12
|
+
width: 100%;
|
|
13
|
+
padding: 12px 16px;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.ui-list-item__row {
|
|
17
|
+
-webkit-appearance: none;
|
|
18
|
+
appearance: none;
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-direction: row;
|
|
21
|
+
align-items: center;
|
|
22
|
+
gap: 12px;
|
|
23
|
+
flex: 1;
|
|
24
|
+
min-width: 0;
|
|
25
|
+
margin: 0;
|
|
26
|
+
padding: 0;
|
|
27
|
+
border: none;
|
|
28
|
+
background: transparent;
|
|
29
|
+
color: var(--color-label);
|
|
30
|
+
font: inherit;
|
|
31
|
+
text-align: start;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.ui-list-item__row--pressable {
|
|
35
|
+
cursor: pointer;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.ui-list-item__main {
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
gap: 2px;
|
|
42
|
+
flex: 1;
|
|
43
|
+
min-width: 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.ui-list-item__headline {
|
|
47
|
+
font-family: var(--font-display);
|
|
48
|
+
font-size: 14px;
|
|
49
|
+
line-height: 18px;
|
|
50
|
+
color: var(--color-label);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.ui-list-item__supporting {
|
|
54
|
+
font-family: var(--font-display);
|
|
55
|
+
font-size: 13px;
|
|
56
|
+
line-height: 18px;
|
|
57
|
+
color: var(--color-secondary-label);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.ui-list-item__slot {
|
|
61
|
+
display: flex;
|
|
62
|
+
flex-direction: row;
|
|
63
|
+
align-items: center;
|
|
64
|
+
flex-shrink: 0;
|
|
65
|
+
}
|
package/src/list-item/types.ts
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import type {ReactNode} from 'react';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* A text action at the trailing edge of a `ListItem` (sign in, sign out,
|
|
5
|
+
* clear cache): a Compose `TextButton`, a borderless SwiftUI `Button`, a DOM
|
|
6
|
+
* button. The row itself stays inert unless it also has an `onPress`.
|
|
7
|
+
*/
|
|
8
|
+
export interface ListItemAction {
|
|
9
|
+
/** The action's text. */
|
|
10
|
+
label: string;
|
|
11
|
+
/** Called when the action is pressed. */
|
|
12
|
+
onPress: () => void;
|
|
13
|
+
/** Greys the action out and ignores presses. */
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* `destructive` renders the action in the danger color.
|
|
17
|
+
* @default 'default'
|
|
18
|
+
*/
|
|
19
|
+
role?: 'default' | 'destructive';
|
|
20
|
+
}
|
|
21
|
+
|
|
3
22
|
/**
|
|
4
23
|
* Props for the app `ListItem`: a tappable row with leading/trailing slots
|
|
5
24
|
* and optional supporting text. Bridges the universal `@expo/ui` `ListItem`
|
|
@@ -12,6 +31,8 @@ export interface ListItemProps {
|
|
|
12
31
|
leading?: ReactNode;
|
|
13
32
|
/** Trailing (end) slot — chevron, value, control, etc. */
|
|
14
33
|
trailing?: ReactNode;
|
|
34
|
+
/** A text action rendered natively at the trailing edge, after `trailing`. */
|
|
35
|
+
action?: ListItemAction;
|
|
15
36
|
/** Secondary content below the headline; strings get subtle styling. */
|
|
16
37
|
supporting?: string | ReactNode;
|
|
17
38
|
/** Tap handler, active over the entire row. */
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import type {MenuItem, MenuProps} from './types';
|
|
2
2
|
|
|
3
3
|
import {Fragment, useState} from 'react';
|
|
4
|
-
import {DropdownMenu, DropdownMenuItem, HorizontalDivider, Icon, Text, useMaterialColors} from '@expo/ui/jetpack-compose';
|
|
4
|
+
import {Box, DropdownMenu, DropdownMenuItem, HorizontalDivider, Icon, Text, useMaterialColors} from '@expo/ui/jetpack-compose';
|
|
5
|
+
import {background, clip, Shapes, size as sizeModifier} from '@expo/ui/jetpack-compose/modifiers';
|
|
5
6
|
import {Button} from '../button';
|
|
6
7
|
import {useColor} from '../theme';
|
|
7
8
|
|
|
8
9
|
const ICON_SIZE = 20;
|
|
10
|
+
const SWATCH_SIZE = 16;
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Android anchors a Material 3 `DropdownMenu` to the kit's `Button`. Entries
|
|
12
|
-
* are `DropdownMenuItem`s with an optional drawable leading icon
|
|
13
|
-
* items use the theme
|
|
14
|
+
* are `DropdownMenuItem`s with an optional drawable leading icon (or a color
|
|
15
|
+
* dot), a trailing check when active; destructive items use the theme
|
|
16
|
+
* danger color.
|
|
14
17
|
*/
|
|
15
|
-
export function Menu({label, icon, items, testID, ...button}: MenuProps) {
|
|
18
|
+
export function Menu({label, icon, items, testID, trigger: _trigger, ...button}: MenuProps) {
|
|
16
19
|
const [expanded, setExpanded] = useState(false);
|
|
17
20
|
return (
|
|
18
21
|
<DropdownMenu expanded={expanded} onDismissRequest={() => setExpanded(false)}>
|
|
@@ -30,7 +33,7 @@ export function Menu({label, icon, items, testID, ...button}: MenuProps) {
|
|
|
30
33
|
);
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
/** Compose `DropdownMenu.Items` shared by `Menu` and `
|
|
36
|
+
/** Compose `DropdownMenu.Items` shared by `Menu`, `ContextMenu` and `Fab`. */
|
|
34
37
|
export function MenuItems({items, onClose}: {items: MenuItem[]; onClose: () => void}) {
|
|
35
38
|
const colors = useMaterialColors();
|
|
36
39
|
const destructive = useColor('destructive');
|
|
@@ -39,24 +42,34 @@ export function MenuItems({items, onClose}: {items: MenuItem[]; onClose: () => v
|
|
|
39
42
|
<DropdownMenu.Items>
|
|
40
43
|
{items.map((item, index) => {
|
|
41
44
|
const color = item.role === 'destructive' ? destructive : colors.onSurface;
|
|
45
|
+
const contentColor = item.disabled ? colors.onSurfaceVariant : color;
|
|
42
46
|
return (
|
|
43
47
|
<Fragment key={index}>
|
|
44
48
|
{item.separator && index > 0 ? <HorizontalDivider color={separator}/> : null}
|
|
45
49
|
<DropdownMenuItem
|
|
46
50
|
enabled={!item.disabled}
|
|
47
|
-
elementColors={{textColor: color, leadingIconColor: color}}
|
|
51
|
+
elementColors={{textColor: color, leadingIconColor: color, trailingIconColor: color}}
|
|
48
52
|
onClick={item.disabled ? undefined : () => {
|
|
49
53
|
onClose();
|
|
50
54
|
item.onPress?.();
|
|
51
55
|
}}>
|
|
52
|
-
{item.
|
|
56
|
+
{item.swatch ? (
|
|
53
57
|
<DropdownMenuItem.LeadingIcon>
|
|
54
|
-
<
|
|
58
|
+
<Box modifiers={[sizeModifier(SWATCH_SIZE, SWATCH_SIZE), clip(Shapes.Circle), background(item.swatch)]}/>
|
|
59
|
+
</DropdownMenuItem.LeadingIcon>
|
|
60
|
+
) : item.icon?.drawable ? (
|
|
61
|
+
<DropdownMenuItem.LeadingIcon>
|
|
62
|
+
<Icon source={item.icon.drawable} size={ICON_SIZE} tint={contentColor}/>
|
|
55
63
|
</DropdownMenuItem.LeadingIcon>
|
|
56
64
|
) : null}
|
|
57
65
|
<DropdownMenuItem.Text>
|
|
58
|
-
<Text color={
|
|
66
|
+
<Text color={contentColor}>{item.label}</Text>
|
|
59
67
|
</DropdownMenuItem.Text>
|
|
68
|
+
{item.active ? (
|
|
69
|
+
<DropdownMenuItem.TrailingIcon>
|
|
70
|
+
<Text color={contentColor}>✓</Text>
|
|
71
|
+
</DropdownMenuItem.TrailingIcon>
|
|
72
|
+
) : null}
|
|
60
73
|
</DropdownMenuItem>
|
|
61
74
|
</Fragment>
|
|
62
75
|
);
|
package/src/menu/index.ios.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import type {MenuItem, MenuProps} from './types';
|
|
|
2
2
|
import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
|
|
3
3
|
|
|
4
4
|
import {Fragment} from 'react';
|
|
5
|
-
import {Button, Divider, Menu as SwiftUIMenu} from '@expo/ui/swift-ui';
|
|
5
|
+
import {Button, Divider, Menu as SwiftUIMenu, Toggle} from '@expo/ui/swift-ui';
|
|
6
6
|
import {buttonBorderShape, buttonStyle, controlSize, disabled as disabledMod, labelStyle, tint} from '@expo/ui/swift-ui/modifiers';
|
|
7
7
|
import {iosSymbol, swiftBorderShape, swiftControlSize} from '../button/shared';
|
|
8
8
|
import {useColor} from '../theme';
|
|
@@ -16,7 +16,8 @@ const VARIANT_STYLE = {
|
|
|
16
16
|
/**
|
|
17
17
|
* iOS renders SwiftUI's `Menu`, styled with the same `buttonStyle` / `tint`
|
|
18
18
|
* mapping as the kit's `Button` so the trigger matches. Entries are SwiftUI
|
|
19
|
-
* `Button`s (with SF Symbol and `destructive` role)
|
|
19
|
+
* `Button`s (with SF Symbol and `destructive` role), checked `Toggle`s for
|
|
20
|
+
* active entries, and `Divider`s.
|
|
20
21
|
*/
|
|
21
22
|
export function Menu({
|
|
22
23
|
label,
|
|
@@ -26,15 +27,18 @@ export function Menu({
|
|
|
26
27
|
size = 'medium',
|
|
27
28
|
shape,
|
|
28
29
|
color,
|
|
30
|
+
tone = 'accent',
|
|
29
31
|
hideLabel,
|
|
30
32
|
disabled,
|
|
31
33
|
testID,
|
|
32
34
|
}: MenuProps) {
|
|
33
35
|
const themeTint = useColor('tint');
|
|
36
|
+
const themeLabel = useColor('label');
|
|
37
|
+
const accent = color ?? (variant === 'text' && tone === 'label' ? themeLabel : themeTint);
|
|
34
38
|
const modifiers: ViewModifier[] = [
|
|
35
39
|
buttonStyle(VARIANT_STYLE[variant]),
|
|
36
40
|
controlSize(swiftControlSize(size)),
|
|
37
|
-
tint(
|
|
41
|
+
tint(accent),
|
|
38
42
|
];
|
|
39
43
|
if (shape) modifiers.push(buttonBorderShape(swiftBorderShape(shape)));
|
|
40
44
|
if (hideLabel && icon) modifiers.push(labelStyle('iconOnly'));
|
|
@@ -51,20 +55,31 @@ export function Menu({
|
|
|
51
55
|
);
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
/** SwiftUI menu entries shared by `Menu` and `
|
|
58
|
+
/** SwiftUI menu entries shared by `Menu`, `ContextMenu` and `Fab`. */
|
|
55
59
|
export function MenuItems({items}: {items: MenuItem[]}) {
|
|
56
60
|
return (
|
|
57
61
|
<>
|
|
58
62
|
{items.map((item, index) => (
|
|
59
63
|
<Fragment key={index}>
|
|
60
64
|
{item.separator && index > 0 ? <Divider/> : null}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
{item.active ? (
|
|
66
|
+
// A checked toggle is how a SwiftUI menu shows the current state.
|
|
67
|
+
<Toggle
|
|
68
|
+
isOn
|
|
69
|
+
label={item.label}
|
|
70
|
+
systemImage={item.icon ? iosSymbol(item.icon) : undefined}
|
|
71
|
+
onIsOnChange={() => item.onPress?.()}
|
|
72
|
+
modifiers={item.disabled ? [disabledMod(true)] : undefined}
|
|
73
|
+
/>
|
|
74
|
+
) : (
|
|
75
|
+
<Button
|
|
76
|
+
label={item.label}
|
|
77
|
+
systemImage={item.icon ? iosSymbol(item.icon) : undefined}
|
|
78
|
+
role={item.role === 'destructive' ? 'destructive' : 'default'}
|
|
79
|
+
onPress={item.onPress}
|
|
80
|
+
modifiers={item.disabled ? [disabledMod(true)] : undefined}
|
|
81
|
+
/>
|
|
82
|
+
)}
|
|
68
83
|
</Fragment>
|
|
69
84
|
))}
|
|
70
85
|
</>
|
package/src/menu/index.tsx
CHANGED
|
@@ -2,28 +2,44 @@ import './menu.css';
|
|
|
2
2
|
import type {CSSProperties} from 'react';
|
|
3
3
|
import type {MenuProps} from './types';
|
|
4
4
|
import {useId, useRef} from 'react';
|
|
5
|
+
import {SymbolView} from 'expo-symbols';
|
|
5
6
|
import {Button} from '../button';
|
|
7
|
+
import {SIZE_ICON} from '../button/shared';
|
|
6
8
|
import {MenuList, menuIdent} from './list';
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
|
-
* On web the trigger is the kit's `<button>` with
|
|
10
|
-
*
|
|
11
|
+
* On web the trigger is the kit's `<button>` (or, with `trigger="link"`, a
|
|
12
|
+
* text link like the tab bar's tabs) with a `popovertarget` pointing at the
|
|
13
|
+
* entries' native `popover`, so opening, closing, light dismiss and
|
|
11
14
|
* `aria-expanded` are all handled by the browser. The wrapper carries the
|
|
12
15
|
* `anchor-name` that CSS anchor positioning places the popup against.
|
|
13
16
|
*/
|
|
14
|
-
export function Menu({label, icon, items, testID, ...button}: MenuProps) {
|
|
17
|
+
export function Menu({label, icon, items, trigger = 'button', testID, ...button}: MenuProps) {
|
|
15
18
|
const ident = menuIdent(useId());
|
|
16
19
|
const anchor = `--${ident}`;
|
|
17
20
|
const wrapper = useRef<HTMLSpanElement>(null);
|
|
18
21
|
return (
|
|
19
22
|
<span ref={wrapper} className="ui-menu" style={{anchorName: anchor} as CSSProperties}>
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
{trigger === 'link' ? (
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
className="ui-menu__link"
|
|
27
|
+
disabled={button.disabled}
|
|
28
|
+
popoverTarget={ident}
|
|
29
|
+
data-testid={testID}
|
|
30
|
+
aria-label={button.hideLabel ? label : undefined}>
|
|
31
|
+
{icon ? <SymbolView name={icon.symbol} size={SIZE_ICON[button.size ?? 'medium']} tintColor="currentColor"/> : null}
|
|
32
|
+
{button.hideLabel ? null : <span>{label}</span>}
|
|
33
|
+
</button>
|
|
34
|
+
) : (
|
|
35
|
+
<Button
|
|
36
|
+
{...button}
|
|
37
|
+
label={label}
|
|
38
|
+
prefixIcon={icon}
|
|
39
|
+
popoverTarget={ident}
|
|
40
|
+
testID={testID}
|
|
41
|
+
/>
|
|
42
|
+
)}
|
|
27
43
|
<MenuList id={ident} items={items} anchor={anchor} anchorRef={wrapper}/>
|
|
28
44
|
</span>
|
|
29
45
|
);
|
package/src/menu/list.tsx
CHANGED
|
@@ -27,17 +27,19 @@ interface MenuListProps {
|
|
|
27
27
|
position?: {x: number; y: number} | null;
|
|
28
28
|
/** Exposes the popover element so callers can `showPopover()` programmatically. */
|
|
29
29
|
popoverRef?: React.RefObject<HTMLDivElement | null>;
|
|
30
|
+
/** Called when the popover closes (light dismiss, Escape, or a pick). */
|
|
31
|
+
onClose?: () => void;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
/**
|
|
33
|
-
* Web `role="menu"` popup shared by `Menu` and `
|
|
34
|
-
* native `popover="auto"` element. The browser handles the top layer,
|
|
35
|
-
* dismiss (outside click / Escape) and the trigger's `aria-expanded`;
|
|
36
|
-
* item carries `popovertargetaction="hide"` so picking one closes the
|
|
37
|
-
* declaratively. Placement is CSS anchor positioning (see `menu.css`),
|
|
38
|
-
* measured fallback for engines without it.
|
|
35
|
+
* Web `role="menu"` popup shared by `Menu`, `ContextMenu` and `Fab`, rendered
|
|
36
|
+
* as a native `popover="auto"` element. The browser handles the top layer,
|
|
37
|
+
* light dismiss (outside click / Escape) and the trigger's `aria-expanded`;
|
|
38
|
+
* every item carries `popovertargetaction="hide"` so picking one closes the
|
|
39
|
+
* menu declaratively. Placement is CSS anchor positioning (see `menu.css`),
|
|
40
|
+
* with a measured fallback for engines without it.
|
|
39
41
|
*/
|
|
40
|
-
export function MenuList({id, items, anchor, anchorRef, position, popoverRef}: MenuListProps) {
|
|
42
|
+
export function MenuList({id, items, anchor, anchorRef, position, popoverRef, onClose}: MenuListProps) {
|
|
41
43
|
const localRef = useRef<HTMLDivElement>(null);
|
|
42
44
|
const ref = popoverRef ?? localRef;
|
|
43
45
|
const anchored = !!anchor && !position;
|
|
@@ -51,7 +53,10 @@ export function MenuList({id, items, anchor, anchorRef, position, popoverRef}: M
|
|
|
51
53
|
|
|
52
54
|
const onToggle = (event: ToggleEvent<HTMLDivElement>) => {
|
|
53
55
|
const popover = event.currentTarget;
|
|
54
|
-
if (event.newState !== 'open')
|
|
56
|
+
if (event.newState !== 'open') {
|
|
57
|
+
onClose?.();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
55
60
|
// Fallback placement: below the trigger, right-aligned, kept on screen.
|
|
56
61
|
if (anchored && !ANCHOR_SUPPORTED && anchorRef?.current) {
|
|
57
62
|
const rect = anchorRef.current.getBoundingClientRect();
|
|
@@ -83,13 +88,23 @@ export function MenuList({id, items, anchor, anchorRef, position, popoverRef}: M
|
|
|
83
88
|
<button
|
|
84
89
|
type="button"
|
|
85
90
|
role="menuitem"
|
|
86
|
-
|
|
91
|
+
aria-current={item.active ? 'true' : undefined}
|
|
92
|
+
className={[
|
|
93
|
+
'ui-menu__item',
|
|
94
|
+
item.role === 'destructive' && 'ui-menu__item--destructive',
|
|
95
|
+
item.active && 'ui-menu__item--active',
|
|
96
|
+
].filter(Boolean).join(' ')}
|
|
87
97
|
disabled={item.disabled}
|
|
88
98
|
popoverTarget={id}
|
|
89
99
|
popoverTargetAction="hide"
|
|
90
100
|
onClick={item.onPress}>
|
|
91
|
-
{item.
|
|
101
|
+
{item.swatch ? (
|
|
102
|
+
<span className="ui-menu__swatch" style={{background: item.swatch}} aria-hidden="true"/>
|
|
103
|
+
) : item.icon ? (
|
|
104
|
+
<SymbolView name={item.icon.symbol} size={ICON_SIZE} tintColor="currentColor"/>
|
|
105
|
+
) : null}
|
|
92
106
|
<span>{item.label}</span>
|
|
107
|
+
{item.active ? <span className="ui-menu__check" aria-hidden="true">✓</span> : null}
|
|
93
108
|
</button>
|
|
94
109
|
</div>
|
|
95
110
|
))}
|
package/src/menu/menu.css
CHANGED
|
@@ -9,6 +9,38 @@
|
|
|
9
9
|
display: inline-flex;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/* The `link` trigger: a text link like the tab bar's tabs, in the tint. */
|
|
13
|
+
.ui-menu__link {
|
|
14
|
+
-webkit-appearance: none;
|
|
15
|
+
appearance: none;
|
|
16
|
+
display: inline-flex;
|
|
17
|
+
flex-direction: row;
|
|
18
|
+
align-items: center;
|
|
19
|
+
gap: 8px;
|
|
20
|
+
margin: 0;
|
|
21
|
+
padding: 0;
|
|
22
|
+
border: none;
|
|
23
|
+
background: transparent;
|
|
24
|
+
color: var(--color-tint);
|
|
25
|
+
font-family: var(--font-display);
|
|
26
|
+
font-size: 14px;
|
|
27
|
+
font-weight: 500;
|
|
28
|
+
line-height: 18px;
|
|
29
|
+
white-space: nowrap;
|
|
30
|
+
cursor: pointer;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.ui-menu__link:hover:not(:disabled),
|
|
34
|
+
.ui-menu__link:focus-visible {
|
|
35
|
+
opacity: 0.7;
|
|
36
|
+
outline: none;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.ui-menu__link:disabled {
|
|
40
|
+
opacity: 0.4;
|
|
41
|
+
cursor: default;
|
|
42
|
+
}
|
|
43
|
+
|
|
12
44
|
.ui-menu__list {
|
|
13
45
|
box-sizing: border-box;
|
|
14
46
|
min-width: 200px;
|
|
@@ -72,6 +104,26 @@
|
|
|
72
104
|
color: var(--color-destructive);
|
|
73
105
|
}
|
|
74
106
|
|
|
107
|
+
/* The current state: highlighted like a hover, with a tick at the trailing edge. */
|
|
108
|
+
.ui-menu__item--active {
|
|
109
|
+
background: var(--color-background-selected);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.ui-menu__check {
|
|
113
|
+
margin-inline-start: auto;
|
|
114
|
+
padding-inline-start: 12px;
|
|
115
|
+
font-weight: 600;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* A color dot in place of the icon, for a palette. */
|
|
119
|
+
.ui-menu__swatch {
|
|
120
|
+
flex-shrink: 0;
|
|
121
|
+
width: 16px;
|
|
122
|
+
height: 16px;
|
|
123
|
+
border-radius: 50%;
|
|
124
|
+
border: 1px solid var(--color-separator);
|
|
125
|
+
}
|
|
126
|
+
|
|
75
127
|
.ui-menu__separator {
|
|
76
128
|
height: 1px;
|
|
77
129
|
margin: 6px 4px;
|