expo-interface 0.1.1 → 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 +119 -20
- package/package.json +9 -9
- package/src/accent.tsx +21 -8
- package/src/alert/alert.css +3 -3
- package/src/alert/index.tsx +1 -2
- package/src/button/button.css +12 -0
- package/src/button/index.android.tsx +14 -3
- package/src/button/index.ios.tsx +13 -4
- package/src/button/index.tsx +8 -1
- package/src/button/types.ts +20 -0
- package/src/checkbox/index.tsx +1 -1
- package/src/color-picker/color-picker.css +136 -0
- package/src/color-picker/index.android.tsx +145 -0
- package/src/color-picker/index.ios.tsx +80 -0
- package/src/color-picker/index.tsx +112 -0
- package/src/color-picker/shared.ts +204 -0
- package/src/color-picker/sheet.tsx +432 -0
- package/src/color-picker/types.ts +40 -0
- 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/field-group.css +23 -7
- package/src/field-group/index.android.tsx +44 -38
- package/src/field-group/index.tsx +36 -5
- package/src/field-group/index.web.tsx +27 -11
- package/src/field-group/shared.tsx +49 -0
- package/src/field-group/types.ts +25 -0
- package/src/gauge/gauge.css +200 -0
- package/src/gauge/index.android.tsx +212 -0
- package/src/gauge/index.ios.tsx +40 -0
- package/src/gauge/index.tsx +165 -0
- package/src/gauge/shared.ts +79 -0
- package/src/gauge/types.ts +64 -0
- package/src/header-menu/index.tsx +76 -0
- package/src/host/index.tsx +32 -0
- package/src/index.ts +35 -6
- 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/progress/progress.css +9 -0
- package/src/scheme.ts +140 -0
- package/src/screen/header.tsx +1 -0
- package/src/screen/index.tsx +49 -5
- package/src/segmented/index.tsx +1 -1
- package/src/segmented/segmented.css +6 -7
- package/src/slider/index.tsx +1 -1
- package/src/stepper/index.tsx +1 -1
- package/src/switch/index.tsx +2 -0
- 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
- package/src/qr/index.tsx +0 -26
|
@@ -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;
|
package/src/menu/types.ts
CHANGED
|
@@ -2,12 +2,24 @@ import type {ReactNode} from 'react';
|
|
|
2
2
|
import type {IconToken} from '../icons';
|
|
3
3
|
import type {ButtonProps} from '../button/types';
|
|
4
4
|
|
|
5
|
-
/** One entry of a `Menu` / `ContextMenu
|
|
5
|
+
/** One entry of a `Menu` / `ContextMenu` / `Fab` menu. */
|
|
6
6
|
export interface MenuItem {
|
|
7
7
|
/** Item text. */
|
|
8
8
|
label: string;
|
|
9
9
|
/** Leading icon. */
|
|
10
10
|
icon?: IconToken;
|
|
11
|
+
/**
|
|
12
|
+
* A color dot (`#rrggbb`) in place of the icon, for a palette. Drawn on
|
|
13
|
+
* Android and web; iOS menus render images monochrome, so the dot is not
|
|
14
|
+
* shown there.
|
|
15
|
+
*/
|
|
16
|
+
swatch?: string;
|
|
17
|
+
/**
|
|
18
|
+
* The item is the current state (the block's kind, the sort order): a
|
|
19
|
+
* check mark. SwiftUI shows it as a checked `Toggle` item, Compose as a
|
|
20
|
+
* trailing check, the web popup as a tick.
|
|
21
|
+
*/
|
|
22
|
+
active?: boolean;
|
|
11
23
|
/**
|
|
12
24
|
* `destructive` renders the item in the danger color.
|
|
13
25
|
* @default 'default'
|
|
@@ -21,6 +33,9 @@ export interface MenuItem {
|
|
|
21
33
|
onPress?: () => void;
|
|
22
34
|
}
|
|
23
35
|
|
|
36
|
+
/** How the web `Menu` trigger looks. */
|
|
37
|
+
export type MenuTrigger = 'button' | 'link';
|
|
38
|
+
|
|
24
39
|
/**
|
|
25
40
|
* Cross-platform dropdown menu opened from a button.
|
|
26
41
|
*
|
|
@@ -28,17 +43,30 @@ export interface MenuItem {
|
|
|
28
43
|
* `DropdownMenu` on Android, and a `role="menu"` popup on web. The trigger
|
|
29
44
|
* looks like the kit's `Button` and takes the same styling props.
|
|
30
45
|
*/
|
|
31
|
-
export interface MenuProps extends Pick<ButtonProps, 'variant' | 'size' | 'shape' | 'color' | 'hideLabel' | 'disabled'> {
|
|
46
|
+
export interface MenuProps extends Pick<ButtonProps, 'variant' | 'size' | 'shape' | 'color' | 'tone' | 'hideLabel' | 'disabled'> {
|
|
32
47
|
/** Trigger text (kept for accessibility when `hideLabel` is set). */
|
|
33
48
|
label: string;
|
|
34
49
|
/** Trigger icon. */
|
|
35
50
|
icon?: IconToken;
|
|
36
51
|
/** Entries shown when the menu opens. */
|
|
37
52
|
items: MenuItem[];
|
|
53
|
+
/**
|
|
54
|
+
* Web only: `button` renders the kit's button, `link` a text link like the
|
|
55
|
+
* tab bar's tabs (the icon and the label in the tint), for a menu that
|
|
56
|
+
* sits in a bar.
|
|
57
|
+
* @default 'button'
|
|
58
|
+
*/
|
|
59
|
+
trigger?: MenuTrigger;
|
|
38
60
|
/** Identifier used to locate the trigger in end-to-end tests. */
|
|
39
61
|
testID?: string;
|
|
40
62
|
}
|
|
41
63
|
|
|
64
|
+
/** A point a `ContextMenu` opens at, in the coordinates of its content. */
|
|
65
|
+
export interface MenuPoint {
|
|
66
|
+
x: number;
|
|
67
|
+
y: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
42
70
|
/**
|
|
43
71
|
* Cross-platform context menu attached to arbitrary content.
|
|
44
72
|
*
|
|
@@ -55,6 +83,17 @@ export interface ContextMenuProps {
|
|
|
55
83
|
onPress?: () => void;
|
|
56
84
|
/** Disables the menu. */
|
|
57
85
|
disabled?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Opens the menu at this point whenever it changes, so a canvas can open
|
|
88
|
+
* it where it says it was asked for (a right click it received itself, a
|
|
89
|
+
* press on a block's grip). Relative to the content's top-left corner on
|
|
90
|
+
* Android and web (web also accepts viewport coordinates for content that
|
|
91
|
+
* fills it). iOS has no menu at a point: the long-press stays the only
|
|
92
|
+
* trigger there. Pair with `onDismiss` to clear it once the menu closes.
|
|
93
|
+
*/
|
|
94
|
+
at?: MenuPoint | null;
|
|
95
|
+
/** Called when a menu opened by `at` (or a gesture) closes. */
|
|
96
|
+
onDismiss?: () => void;
|
|
58
97
|
/** Identifier used to locate the trigger in end-to-end tests. */
|
|
59
98
|
testID?: string;
|
|
60
99
|
}
|
|
@@ -8,6 +8,15 @@
|
|
|
8
8
|
-webkit-appearance: none;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/*
|
|
12
|
+
* With `appearance: none` Chromium lays the bar out as an inline-block on a
|
|
13
|
+
* line box, so the value only paints about half the track height. Flex
|
|
14
|
+
* stretches the bar (and its value) to the full 6px.
|
|
15
|
+
*/
|
|
16
|
+
.ui-progress::-webkit-meter-inner-element {
|
|
17
|
+
display: flex;
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
.ui-progress::-webkit-meter-bar {
|
|
12
21
|
border: none;
|
|
13
22
|
border-radius: 999px;
|
package/src/scheme.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type {ColorSchemeName} from 'react-native';
|
|
2
|
+
import {useSyncExternalStore} from 'react';
|
|
3
|
+
import {Appearance, Platform} from 'react-native';
|
|
4
|
+
import {colors} from './theme';
|
|
5
|
+
|
|
6
|
+
/** The scheme the app is drawn in. */
|
|
7
|
+
export type ColorScheme = 'light' | 'dark';
|
|
8
|
+
|
|
9
|
+
/** What an app asks for: the system's scheme, or one forced by the user. */
|
|
10
|
+
export type ColorSchemeMode = 'system' | ColorScheme;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `localStorage` key under which `setColorScheme` keeps a forced scheme on
|
|
14
|
+
* web, read back by `getThemeBootScript` before the bundle runs.
|
|
15
|
+
*/
|
|
16
|
+
export const SCHEME_STORAGE_KEY = 'expo-interface:scheme';
|
|
17
|
+
|
|
18
|
+
type Listener = (preferences: {colorScheme: ColorSchemeName}) => void;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Web: react-native-web's `Appearance` only mirrors the `prefers-color-scheme`
|
|
22
|
+
* media query and has no `setColorScheme`. Its two methods are replaced at
|
|
23
|
+
* load: `getColorScheme` answers the forced scheme while one is set, and the
|
|
24
|
+
* change listeners (what React Native's `useColorScheme`, `@expo/ui`'s `Host`
|
|
25
|
+
* and the kit's `useColorScheme` subscribe through) hear a forced change as
|
|
26
|
+
* well as a system one.
|
|
27
|
+
*/
|
|
28
|
+
let forced: ColorScheme | null = null;
|
|
29
|
+
const listeners = new Set<Listener>();
|
|
30
|
+
let system: Pick<typeof Appearance, 'getColorScheme' | 'addChangeListener'> | undefined;
|
|
31
|
+
|
|
32
|
+
function patchAppearance() {
|
|
33
|
+
if (system) return;
|
|
34
|
+
const original = {
|
|
35
|
+
getColorScheme: Appearance.getColorScheme.bind(Appearance),
|
|
36
|
+
addChangeListener: Appearance.addChangeListener.bind(Appearance),
|
|
37
|
+
};
|
|
38
|
+
system = original;
|
|
39
|
+
Appearance.getColorScheme = () => forced ?? original.getColorScheme();
|
|
40
|
+
Appearance.addChangeListener = (listener: Listener) => {
|
|
41
|
+
listeners.add(listener);
|
|
42
|
+
// A system change is only news while the scheme follows the system.
|
|
43
|
+
const subscription = original.addChangeListener(preferences => {
|
|
44
|
+
if (!forced) listener(preferences);
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
remove() {
|
|
48
|
+
listeners.delete(listener);
|
|
49
|
+
subscription.remove();
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (Platform.OS === 'web') patchAppearance();
|
|
56
|
+
|
|
57
|
+
const subscribe = (onChange: () => void) => {
|
|
58
|
+
const subscription = Appearance.addChangeListener(onChange);
|
|
59
|
+
return () => subscription.remove();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const snapshot = (): ColorScheme => (Appearance.getColorScheme() === 'dark' ? 'dark' : 'light');
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The color scheme as an external store: `'light'` or `'dark'`, never
|
|
66
|
+
* unspecified. React Native's own `useColorScheme` re-subscribes on every
|
|
67
|
+
* render; on web, where the change is a `matchMedia` event, an ancestor that
|
|
68
|
+
* re-renders during that event (the router's screens do) makes the hook drop
|
|
69
|
+
* and re-add its listener mid-dispatch, and the event never reaches it. One
|
|
70
|
+
* stable subscription does not. Follows `setColorScheme` on every platform.
|
|
71
|
+
*/
|
|
72
|
+
export function useColorScheme(): ColorScheme {
|
|
73
|
+
return useSyncExternalStore(subscribe, snapshot, snapshot);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const toKebab = (token: string) => token.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Forces the color scheme, or follows the system again.
|
|
80
|
+
*
|
|
81
|
+
* - iOS and Android: `Appearance.setColorScheme`, so `useColorScheme`, the
|
|
82
|
+
* kit's `useColor` and every native control follow.
|
|
83
|
+
* - Web: the palette of the forced scheme is written inline on the root
|
|
84
|
+
* element as the `--color-*` variables (`tint`/`onTint` are left to
|
|
85
|
+
* `AccentProvider`), `color-scheme` is set so form controls and scrollbars
|
|
86
|
+
* follow, `data-theme` is set for `@expo/ui`'s own styles, the choice is
|
|
87
|
+
* saved under `storageKey` for `getThemeBootScript`, and the `Appearance`
|
|
88
|
+
* listeners hear the change.
|
|
89
|
+
*/
|
|
90
|
+
export function setColorScheme(mode: ColorSchemeMode, storageKey = SCHEME_STORAGE_KEY): void {
|
|
91
|
+
if (Platform.OS !== 'web') {
|
|
92
|
+
Appearance.setColorScheme(mode === 'system' ? 'unspecified' : mode);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
patchAppearance();
|
|
96
|
+
forced = mode === 'system' ? null : mode;
|
|
97
|
+
if (typeof document !== 'undefined') {
|
|
98
|
+
const root = document.documentElement;
|
|
99
|
+
for (const token of Object.keys(colors.light) as (keyof typeof colors.light)[]) {
|
|
100
|
+
if (token === 'tint' || token === 'onTint') continue;
|
|
101
|
+
const name = `--color-${toKebab(token)}`;
|
|
102
|
+
if (forced) root.style.setProperty(name, colors[forced][token]);
|
|
103
|
+
else root.style.removeProperty(name);
|
|
104
|
+
}
|
|
105
|
+
root.style.colorScheme = forced ?? '';
|
|
106
|
+
if (forced) root.dataset.theme = forced;
|
|
107
|
+
else delete root.dataset.theme;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
if (forced) localStorage.setItem(storageKey, forced);
|
|
111
|
+
else localStorage.removeItem(storageKey);
|
|
112
|
+
} catch {
|
|
113
|
+
// Storage may be unavailable (privacy mode, server render); the scheme still applies.
|
|
114
|
+
}
|
|
115
|
+
const colorScheme: ColorSchemeName = forced ?? system!.getColorScheme() ?? 'light';
|
|
116
|
+
for (const listener of listeners) listener({colorScheme});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The scheme forced by `setColorScheme` on web, or `'system'`. Natively the
|
|
121
|
+
* forced scheme is `Appearance`'s own and this always answers `'system'`.
|
|
122
|
+
*/
|
|
123
|
+
export function getColorSchemeMode(): ColorSchemeMode {
|
|
124
|
+
return forced ?? 'system';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Inline script for `+html.tsx`: before the bundle runs, applies a scheme
|
|
129
|
+
* saved by `setColorScheme` to the root element (`data-theme` and
|
|
130
|
+
* `color-scheme`), so the first paint of the static HTML is already in that
|
|
131
|
+
* scheme; `getThemeCSS` carries the matching `:root[data-theme]` palettes.
|
|
132
|
+
* A system scheme needs nothing: the variables switch on the media query.
|
|
133
|
+
*
|
|
134
|
+
* ```tsx
|
|
135
|
+
* <script dangerouslySetInnerHTML={{__html: getThemeBootScript()}}/>
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
export function getThemeBootScript(storageKey = SCHEME_STORAGE_KEY): string {
|
|
139
|
+
return `(function(){try{var t=localStorage.getItem(${JSON.stringify(storageKey)});if(t==='light'||t==='dark'){document.documentElement.dataset.theme=t;document.documentElement.style.colorScheme=t;}}catch(e){}})();`;
|
|
140
|
+
}
|