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,33 +1,80 @@
|
|
|
1
1
|
import type {ColorPickerProps} from './types';
|
|
2
2
|
import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
|
|
3
3
|
|
|
4
|
-
import {ColorPicker as SwiftUIColorPicker} from '@expo/ui/swift-ui';
|
|
5
|
-
import {disabled as disabledMod} from '@expo/ui/swift-ui/modifiers';
|
|
4
|
+
import {Button, ColorPicker as SwiftUIColorPicker, HStack, Spacer, Text, ZStack} from '@expo/ui/swift-ui';
|
|
5
|
+
import {accessibilityLabel, background, buttonStyle, disabled as disabledMod, frame, labelsHidden, shapes} from '@expo/ui/swift-ui/modifiers';
|
|
6
|
+
import {useColor} from '../theme';
|
|
7
|
+
import {parseColor, toHex} from './shared';
|
|
8
|
+
|
|
9
|
+
/** Diameter of a preset swatch, and of its ring when selected. */
|
|
10
|
+
const SWATCH = 30;
|
|
11
|
+
const SWATCH_INNER = 28;
|
|
12
|
+
const SWATCH_SELECTED = 22;
|
|
13
|
+
const NONE = '#00000000';
|
|
6
14
|
|
|
7
15
|
/**
|
|
8
16
|
* iOS renders SwiftUI's `ColorPicker`: the label with the rainbow-ringed
|
|
9
17
|
* color well at the trailing edge, which opens the system color picker.
|
|
10
18
|
* SwiftUI reports the selection as `#RRGGBBAA` when opacity is supported and
|
|
11
|
-
* `#RRGGBB` otherwise; the other platforms mirror both formats.
|
|
19
|
+
* `#RRGGBB` otherwise; the other platforms mirror both formats. With
|
|
20
|
+
* `swatches` the row is composed by hand: the label, a spacer, the preset
|
|
21
|
+
* circles (the selected one ringed in the label color) and the picker's
|
|
22
|
+
* well with its own label hidden.
|
|
12
23
|
*/
|
|
13
24
|
export function ColorPicker({
|
|
14
25
|
label,
|
|
15
26
|
value,
|
|
16
27
|
onValueChange,
|
|
17
28
|
supportsOpacity = true,
|
|
29
|
+
swatches,
|
|
18
30
|
disabled,
|
|
19
31
|
testID,
|
|
20
32
|
}: ColorPickerProps) {
|
|
33
|
+
const labelColor = useColor('label');
|
|
21
34
|
const modifiers: ViewModifier[] = [];
|
|
22
35
|
if (disabled) modifiers.push(disabledMod(true));
|
|
36
|
+
if (!swatches?.length) {
|
|
37
|
+
return (
|
|
38
|
+
<SwiftUIColorPicker
|
|
39
|
+
label={label}
|
|
40
|
+
selection={value}
|
|
41
|
+
supportsOpacity={supportsOpacity}
|
|
42
|
+
onSelectionChange={onValueChange}
|
|
43
|
+
modifiers={modifiers}
|
|
44
|
+
testID={testID}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const current = parseColor(value);
|
|
49
|
+
const currentHex = toHex({...current, a: 1}, false);
|
|
23
50
|
return (
|
|
24
|
-
<
|
|
25
|
-
label
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
51
|
+
<HStack spacing={8} modifiers={modifiers} testID={testID}>
|
|
52
|
+
{label != null ? <Text>{label}</Text> : null}
|
|
53
|
+
<Spacer/>
|
|
54
|
+
{swatches.map(seed => {
|
|
55
|
+
const selected = toHex(parseColor(seed), false) === currentHex;
|
|
56
|
+
const inner = selected ? SWATCH_SELECTED : SWATCH_INNER;
|
|
57
|
+
return (
|
|
58
|
+
<Button
|
|
59
|
+
key={seed}
|
|
60
|
+
onPress={() => onValueChange(toHex({...parseColor(seed), a: current.a}, supportsOpacity))}
|
|
61
|
+
modifiers={[buttonStyle('plain'), accessibilityLabel(`Color ${seed}`)]}>
|
|
62
|
+
<ZStack modifiers={[frame({width: SWATCH, height: SWATCH}), background(selected ? labelColor : NONE, shapes.circle())]}>
|
|
63
|
+
<ZStack modifiers={[frame({width: inner, height: inner}), background(seed, shapes.circle())]}>
|
|
64
|
+
<Spacer/>
|
|
65
|
+
</ZStack>
|
|
66
|
+
</ZStack>
|
|
67
|
+
</Button>
|
|
68
|
+
);
|
|
69
|
+
})}
|
|
70
|
+
<SwiftUIColorPicker
|
|
71
|
+
label={label}
|
|
72
|
+
selection={value}
|
|
73
|
+
supportsOpacity={supportsOpacity}
|
|
74
|
+
onSelectionChange={onValueChange}
|
|
75
|
+
modifiers={[labelsHidden()]}
|
|
76
|
+
testID={testID ? `${testID}-well` : undefined}
|
|
77
|
+
/>
|
|
78
|
+
</HStack>
|
|
32
79
|
);
|
|
33
80
|
}
|
|
@@ -13,13 +13,16 @@ import {parseColor, toCss, toHex, useColorValue} from './shared';
|
|
|
13
13
|
/**
|
|
14
14
|
* On web the row is a native `<button>` holding the label and the color
|
|
15
15
|
* well — a conic rainbow ring around the selected color, as on iOS — which
|
|
16
|
-
* opens the iOS picker redrawn in the kit's `Sheet`.
|
|
16
|
+
* opens the iOS picker redrawn in the kit's `Sheet`. With `swatches` the row
|
|
17
|
+
* is a `<div>` instead, so each preset and the well are buttons of their
|
|
18
|
+
* own (buttons cannot nest).
|
|
17
19
|
*/
|
|
18
20
|
export function ColorPicker({
|
|
19
21
|
label,
|
|
20
22
|
value,
|
|
21
23
|
onValueChange,
|
|
22
24
|
supportsOpacity = true,
|
|
25
|
+
swatches,
|
|
23
26
|
disabled,
|
|
24
27
|
testID,
|
|
25
28
|
style,
|
|
@@ -30,11 +33,69 @@ export function ColorPicker({
|
|
|
30
33
|
'--ui-color-picker-value': toCss(current),
|
|
31
34
|
...flatten(StyleSheet.flatten(style) as TextStyle),
|
|
32
35
|
} as CSSProperties;
|
|
36
|
+
const className = ['ui-color-picker', disabled && 'ui-color-picker--disabled'].filter(Boolean).join(' ');
|
|
37
|
+
const well = (
|
|
38
|
+
<span className="ui-color-picker__well" aria-hidden="true">
|
|
39
|
+
<span className="ui-color-picker__swatch"/>
|
|
40
|
+
</span>
|
|
41
|
+
);
|
|
42
|
+
const sheet = (
|
|
43
|
+
<Sheet isPresented={open} onDismiss={() => setOpen(false)}>
|
|
44
|
+
<ColorPickerSheet
|
|
45
|
+
title={label ?? 'Colors'}
|
|
46
|
+
value={toHex(current, true)}
|
|
47
|
+
supportsOpacity={supportsOpacity}
|
|
48
|
+
onValueChange={hex => setCurrent(parseColor(hex))}
|
|
49
|
+
onClose={() => setOpen(false)}
|
|
50
|
+
testID={testID ? `${testID}-sheet` : undefined}
|
|
51
|
+
/>
|
|
52
|
+
</Sheet>
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (swatches?.length) {
|
|
56
|
+
const currentHex = toHex({...current, a: 1}, false);
|
|
57
|
+
return (
|
|
58
|
+
<>
|
|
59
|
+
<div className={`${className} ui-color-picker--presets`} style={vars} data-testid={testID}>
|
|
60
|
+
{label != null ? <Label color="label" style={{flexShrink: 1}}>{label}</Label> : null}
|
|
61
|
+
<span className="ui-color-picker__presets">
|
|
62
|
+
{swatches.map(seed => {
|
|
63
|
+
const selected = toHex(parseColor(seed), false) === currentHex;
|
|
64
|
+
return (
|
|
65
|
+
<button
|
|
66
|
+
key={seed}
|
|
67
|
+
type="button"
|
|
68
|
+
className={['ui-color-picker__preset', selected && 'ui-color-picker__preset--selected'].filter(Boolean).join(' ')}
|
|
69
|
+
style={{'--ui-color-picker-preset': seed} as CSSProperties}
|
|
70
|
+
aria-label={`Color ${seed}`}
|
|
71
|
+
aria-pressed={selected}
|
|
72
|
+
disabled={disabled}
|
|
73
|
+
onClick={() => setCurrent({...parseColor(seed), a: current.a})}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
})}
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
className="ui-color-picker__open"
|
|
80
|
+
aria-label={label ?? 'Color'}
|
|
81
|
+
aria-haspopup="dialog"
|
|
82
|
+
aria-expanded={open}
|
|
83
|
+
disabled={disabled}
|
|
84
|
+
onClick={() => setOpen(true)}>
|
|
85
|
+
{well}
|
|
86
|
+
</button>
|
|
87
|
+
</span>
|
|
88
|
+
</div>
|
|
89
|
+
{sheet}
|
|
90
|
+
</>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
33
94
|
return (
|
|
34
95
|
<>
|
|
35
96
|
<button
|
|
36
97
|
type="button"
|
|
37
|
-
className={
|
|
98
|
+
className={className}
|
|
38
99
|
style={vars}
|
|
39
100
|
disabled={disabled}
|
|
40
101
|
aria-label={label == null ? 'Color' : undefined}
|
|
@@ -43,20 +104,9 @@ export function ColorPicker({
|
|
|
43
104
|
onClick={() => setOpen(true)}
|
|
44
105
|
data-testid={testID}>
|
|
45
106
|
{label != null ? <Label color="label" style={{flexShrink: 1}}>{label}</Label> : null}
|
|
46
|
-
|
|
47
|
-
<span className="ui-color-picker__swatch"/>
|
|
48
|
-
</span>
|
|
107
|
+
{well}
|
|
49
108
|
</button>
|
|
50
|
-
|
|
51
|
-
<ColorPickerSheet
|
|
52
|
-
title={label ?? 'Colors'}
|
|
53
|
-
value={toHex(current, true)}
|
|
54
|
-
supportsOpacity={supportsOpacity}
|
|
55
|
-
onValueChange={hex => setCurrent(parseColor(hex))}
|
|
56
|
-
onClose={() => setOpen(false)}
|
|
57
|
-
testID={testID ? `${testID}-sheet` : undefined}
|
|
58
|
-
/>
|
|
59
|
-
</Sheet>
|
|
109
|
+
{sheet}
|
|
60
110
|
</>
|
|
61
111
|
);
|
|
62
112
|
}
|
|
@@ -5,9 +5,9 @@ import type {StyleProp, ViewStyle} from 'react-native';
|
|
|
5
5
|
* opens the system color picker.
|
|
6
6
|
*
|
|
7
7
|
* Bridges the SwiftUI `ColorPicker` on iOS. Android (Jetpack Compose) and
|
|
8
|
-
* web (DOM) redraw the iOS row — the label and the
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* web (DOM) redraw the iOS row — the label and the well — and open a sheet
|
|
9
|
+
* that reproduces the iOS picker: Grid, Spectrum and Sliders tabs, the
|
|
10
|
+
* opacity slider and the preview swatch with saved colors.
|
|
11
11
|
* A controlled control: pair `value` with `onValueChange`.
|
|
12
12
|
*/
|
|
13
13
|
export interface ColorPickerProps {
|
|
@@ -25,6 +25,12 @@ export interface ColorPickerProps {
|
|
|
25
25
|
* @default true
|
|
26
26
|
*/
|
|
27
27
|
supportsOpacity?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Preset colors (`#RRGGBB`) drawn as a row of round swatches before the
|
|
30
|
+
* well on every platform, the selected one ringed; tapping one picks it.
|
|
31
|
+
* The well still opens the full picker for any other color.
|
|
32
|
+
*/
|
|
33
|
+
swatches?: string[];
|
|
28
34
|
/** Disables interaction. */
|
|
29
35
|
disabled?: boolean;
|
|
30
36
|
/** Identifier used to locate the component in end-to-end tests. */
|
|
@@ -2,26 +2,54 @@ import type {ContextMenuProps} from '../menu/types';
|
|
|
2
2
|
|
|
3
3
|
import {useState} from 'react';
|
|
4
4
|
import {Box, DropdownMenu} from '@expo/ui/jetpack-compose';
|
|
5
|
-
import {combinedClickable, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
|
|
5
|
+
import {combinedClickable, matchParentSize, offset, size, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
|
|
6
6
|
import {MenuItems} from '../menu/index.android';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Android wraps `children` in a `Box` with `combinedClickable`, so a
|
|
10
|
-
* long-press expands a Material 3 `DropdownMenu`
|
|
11
|
-
*
|
|
10
|
+
* long-press expands a Material 3 `DropdownMenu` while a tap goes to
|
|
11
|
+
* `onPress`. `children` must be Compose content.
|
|
12
|
+
*
|
|
13
|
+
* The dropdown is anchored to an invisible box laid over the content: the
|
|
14
|
+
* size of the content for a long-press (the menu opens below it, as a
|
|
15
|
+
* Compose menu does), or a zero-size box offset to the `at` point, which is
|
|
16
|
+
* how the menu opens where a canvas says it was asked for.
|
|
12
17
|
*/
|
|
13
|
-
export function ContextMenu({items, children, onPress, disabled, testID}: ContextMenuProps) {
|
|
18
|
+
export function ContextMenu({items, children, onPress, disabled, at, onDismiss, testID}: ContextMenuProps) {
|
|
14
19
|
const [expanded, setExpanded] = useState(false);
|
|
20
|
+
const [point, setPoint] = useState(at ?? null);
|
|
21
|
+
// A new `at` (including one given at mount) opens the menu there; derived
|
|
22
|
+
// from the prop as it changes.
|
|
23
|
+
const [seen, setSeen] = useState<ContextMenuProps['at']>(undefined);
|
|
24
|
+
if (at !== seen) {
|
|
25
|
+
setSeen(at);
|
|
26
|
+
if (at && !disabled) {
|
|
27
|
+
setPoint(at);
|
|
28
|
+
setExpanded(true);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const close = () => {
|
|
32
|
+
setExpanded(false);
|
|
33
|
+
onDismiss?.();
|
|
34
|
+
};
|
|
35
|
+
|
|
15
36
|
const modifiers = [
|
|
16
|
-
...(disabled ? [] : [combinedClickable({onClick: onPress, onLongClick: () =>
|
|
37
|
+
...(disabled ? [] : [combinedClickable({onClick: onPress, onLongClick: () => {
|
|
38
|
+
setPoint(null);
|
|
39
|
+
setExpanded(true);
|
|
40
|
+
}})]),
|
|
17
41
|
...(testID ? [testIDModifier(testID)] : []),
|
|
18
42
|
];
|
|
43
|
+
|
|
19
44
|
return (
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
45
|
+
<Box modifiers={modifiers}>
|
|
46
|
+
{children}
|
|
47
|
+
<DropdownMenu
|
|
48
|
+
expanded={expanded}
|
|
49
|
+
onDismissRequest={close}
|
|
50
|
+
modifiers={point ? [offset(point.x, point.y), size(0, 0)] : [matchParentSize()]}>
|
|
51
|
+
<MenuItems items={items} onClose={close}/>
|
|
52
|
+
</DropdownMenu>
|
|
53
|
+
</Box>
|
|
26
54
|
);
|
|
27
55
|
}
|
|
@@ -7,7 +7,9 @@ import {MenuItems} from '../menu/index.ios';
|
|
|
7
7
|
/**
|
|
8
8
|
* iOS renders SwiftUI's `contextMenu`: a long-press on `children` lifts it
|
|
9
9
|
* into a preview with the entries beneath. `children` must be SwiftUI
|
|
10
|
-
* content. A plain tap is passed to `onPress` through `onTapGesture`.
|
|
10
|
+
* content. A plain tap is passed to `onPress` through `onTapGesture`. SwiftUI
|
|
11
|
+
* has no menu at a point, so `at` is ignored here (the long-press stays the
|
|
12
|
+
* trigger) and `onDismiss` is not reported.
|
|
11
13
|
*/
|
|
12
14
|
export function ContextMenu({items, children, onPress, disabled, testID}: ContextMenuProps) {
|
|
13
15
|
if (disabled) return <>{children}</>;
|
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
import '../menu/menu.css';
|
|
2
2
|
import type {MouseEvent, PointerEvent} from 'react';
|
|
3
3
|
import type {ContextMenuProps} from '../menu/types';
|
|
4
|
-
import {useId, useRef, useState} from 'react';
|
|
4
|
+
import {useEffect, useId, useRef, useState} from 'react';
|
|
5
5
|
import {MenuList, menuIdent} from '../menu/list';
|
|
6
6
|
|
|
7
7
|
const LONG_PRESS_MS = 500;
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* On web the entries live in the same native `popover="auto"` element as
|
|
11
|
-
* `Menu`, opened with `showPopover()` on right-click (`contextmenu`)
|
|
12
|
-
*
|
|
13
|
-
* layer and light dismiss. The wrapper is `display: contents`,
|
|
14
|
-
* affect the layout of `children
|
|
11
|
+
* `Menu`, opened with `showPopover()` on right-click (`contextmenu`), a touch
|
|
12
|
+
* long-press, or the `at` prop, and placed at the pointer. The browser still
|
|
13
|
+
* owns the top layer and light dismiss. The wrapper is `display: contents`,
|
|
14
|
+
* so it doesn't affect the layout of `children`; `at` is measured against
|
|
15
|
+
* the content's first element (its own coordinates), falling back to the
|
|
16
|
+
* viewport when the content has no box of its own.
|
|
15
17
|
*/
|
|
16
|
-
export function ContextMenu({items, children, onPress, disabled, testID}: ContextMenuProps) {
|
|
18
|
+
export function ContextMenu({items, children, onPress, disabled, at, onDismiss, testID}: ContextMenuProps) {
|
|
17
19
|
const ident = menuIdent(useId());
|
|
18
20
|
const popover = useRef<HTMLDivElement>(null);
|
|
21
|
+
const wrapper = useRef<HTMLDivElement>(null);
|
|
19
22
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
20
23
|
const [position, setPosition] = useState<{x: number; y: number} | null>(null);
|
|
21
24
|
|
|
@@ -39,8 +42,22 @@ export function ContextMenu({items, children, onPress, disabled, testID}: Contex
|
|
|
39
42
|
timer.current = null;
|
|
40
43
|
};
|
|
41
44
|
|
|
45
|
+
// A point the content reports (a canvas's own right click): relative to
|
|
46
|
+
// the content's box, which `display: contents` leaves to its first child
|
|
47
|
+
// (the popover itself when the content has no element of its own, in which
|
|
48
|
+
// case the point is taken as viewport coordinates).
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!at || disabled) return;
|
|
51
|
+
const content = wrapper.current!.firstElementChild;
|
|
52
|
+
const rect = content !== popover.current ? content!.getBoundingClientRect() : {left: 0, top: 0};
|
|
53
|
+
setPosition({x: rect.left + at.x, y: rect.top + at.y});
|
|
54
|
+
const element = popover.current;
|
|
55
|
+
if (element && !element.matches(':popover-open')) element.showPopover();
|
|
56
|
+
}, [at, disabled]);
|
|
57
|
+
|
|
42
58
|
return (
|
|
43
59
|
<div
|
|
60
|
+
ref={wrapper}
|
|
44
61
|
className="ui-context-menu"
|
|
45
62
|
onContextMenu={onContextMenu}
|
|
46
63
|
onPointerDown={onPointerDown}
|
|
@@ -50,7 +67,7 @@ export function ContextMenu({items, children, onPress, disabled, testID}: Contex
|
|
|
50
67
|
onClick={disabled ? undefined : onPress}
|
|
51
68
|
data-testid={testID}>
|
|
52
69
|
{children}
|
|
53
|
-
<MenuList id={ident} items={items} position={position} popoverRef={popover}/>
|
|
70
|
+
<MenuList id={ident} items={items} position={position} popoverRef={popover} onClose={onDismiss}/>
|
|
54
71
|
</div>
|
|
55
72
|
);
|
|
56
73
|
}
|
package/src/fab/fab.css
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The Material floating action button geometry on the web: a 56px circle
|
|
3
|
+
* (40 small, 96 large) or a 56px capsule when extended, filled with the tint,
|
|
4
|
+
* the icon and label in the contrast color, lifted by a soft shadow. Where it
|
|
5
|
+
* floats is the screen's job (`Screen`'s `fab` slot fixes it to the viewport).
|
|
6
|
+
*/
|
|
7
|
+
.ui-fab__anchor {
|
|
8
|
+
display: inline-flex;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.ui-fab {
|
|
12
|
+
-webkit-appearance: none;
|
|
13
|
+
appearance: none;
|
|
14
|
+
display: inline-flex;
|
|
15
|
+
flex-direction: row;
|
|
16
|
+
align-items: center;
|
|
17
|
+
justify-content: center;
|
|
18
|
+
gap: 12px;
|
|
19
|
+
box-sizing: border-box;
|
|
20
|
+
width: 56px;
|
|
21
|
+
height: 56px;
|
|
22
|
+
margin: 0;
|
|
23
|
+
padding: 0;
|
|
24
|
+
border: none;
|
|
25
|
+
border-radius: 50%;
|
|
26
|
+
background: var(--color-tint);
|
|
27
|
+
color: var(--color-on-tint);
|
|
28
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
|
29
|
+
font-family: var(--font-display);
|
|
30
|
+
font-size: 15px;
|
|
31
|
+
font-weight: 600;
|
|
32
|
+
line-height: 1.2;
|
|
33
|
+
white-space: nowrap;
|
|
34
|
+
cursor: pointer;
|
|
35
|
+
user-select: none;
|
|
36
|
+
transition: filter 0.15s ease, opacity 0.15s ease;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.ui-fab--small {
|
|
40
|
+
width: 40px;
|
|
41
|
+
height: 40px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.ui-fab--large {
|
|
45
|
+
width: 96px;
|
|
46
|
+
height: 96px;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.ui-fab--extended {
|
|
50
|
+
width: auto;
|
|
51
|
+
height: 56px;
|
|
52
|
+
padding: 0 20px;
|
|
53
|
+
border-radius: 999px;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.ui-fab:hover:not(:disabled) {
|
|
57
|
+
filter: brightness(1.08);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.ui-fab:active:not(:disabled) {
|
|
61
|
+
filter: brightness(0.92);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.ui-fab:focus-visible {
|
|
65
|
+
outline: 2px solid var(--color-label);
|
|
66
|
+
outline-offset: 2px;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.ui-fab:disabled {
|
|
70
|
+
opacity: 0.45;
|
|
71
|
+
cursor: default;
|
|
72
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type {ModifierConfig} from '@expo/ui/jetpack-compose/modifiers';
|
|
2
|
+
import type {FabProps, FabSize} from './types';
|
|
3
|
+
|
|
4
|
+
import {useState} from 'react';
|
|
5
|
+
import {
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
ExtendedFloatingActionButton,
|
|
8
|
+
FloatingActionButton,
|
|
9
|
+
Icon,
|
|
10
|
+
LargeFloatingActionButton,
|
|
11
|
+
SmallFloatingActionButton,
|
|
12
|
+
Text,
|
|
13
|
+
} from '@expo/ui/jetpack-compose';
|
|
14
|
+
import {alpha, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
|
|
15
|
+
import {NativeHost} from '../host';
|
|
16
|
+
import {MenuItems} from '../menu/index.android';
|
|
17
|
+
import {useColor} from '../theme';
|
|
18
|
+
import {FAB_ICON} from './shared';
|
|
19
|
+
|
|
20
|
+
const VARIANT: Record<FabSize, typeof FloatingActionButton> = {
|
|
21
|
+
small: SmallFloatingActionButton,
|
|
22
|
+
regular: FloatingActionButton,
|
|
23
|
+
large: LargeFloatingActionButton,
|
|
24
|
+
extended: ExtendedFloatingActionButton,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Android renders the Material 3 `FloatingActionButton` family, filled with
|
|
29
|
+
* the accent (the container color) and the icon in `onTint`, inside its own
|
|
30
|
+
* accent-seeded host so it can float over a React Native screen. With
|
|
31
|
+
* `items` the button is the trigger of the same Compose `DropdownMenu` the
|
|
32
|
+
* kit's `Menu` anchors, so the menu is shared and only the trigger differs.
|
|
33
|
+
*/
|
|
34
|
+
export function Fab({label, icon, onPress, items, size = 'regular', disabled, testID}: FabProps) {
|
|
35
|
+
const tint = useColor('tint');
|
|
36
|
+
const onTint = useColor('onTint');
|
|
37
|
+
const [expanded, setExpanded] = useState(false);
|
|
38
|
+
const Component = VARIANT[size];
|
|
39
|
+
const modifiers: ModifierConfig[] = [];
|
|
40
|
+
if (disabled) modifiers.push(alpha(0.4));
|
|
41
|
+
if (testID) modifiers.push(testIDModifier(testID));
|
|
42
|
+
const press = items ? () => setExpanded(true) : onPress;
|
|
43
|
+
|
|
44
|
+
const button = (
|
|
45
|
+
<Component containerColor={tint} onClick={disabled ? undefined : press} modifiers={modifiers}>
|
|
46
|
+
<FloatingActionButton.Icon>
|
|
47
|
+
{icon.drawable ? (
|
|
48
|
+
<Icon source={icon.drawable} size={FAB_ICON[size]} tint={onTint} contentDescription={label}/>
|
|
49
|
+
) : (
|
|
50
|
+
// No drawable registered for this icon (see `icon()`): the label stands in.
|
|
51
|
+
<Text color={onTint}>{label.slice(0, 1)}</Text>
|
|
52
|
+
)}
|
|
53
|
+
</FloatingActionButton.Icon>
|
|
54
|
+
{size === 'extended' ? (
|
|
55
|
+
<ExtendedFloatingActionButton.Text>
|
|
56
|
+
<Text color={onTint} style={{fontWeight: '600'}}>{label}</Text>
|
|
57
|
+
</ExtendedFloatingActionButton.Text>
|
|
58
|
+
) : null}
|
|
59
|
+
</Component>
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<NativeHost fit>
|
|
64
|
+
{items ? (
|
|
65
|
+
<DropdownMenu expanded={expanded} onDismissRequest={() => setExpanded(false)}>
|
|
66
|
+
<DropdownMenu.Trigger>{button}</DropdownMenu.Trigger>
|
|
67
|
+
<MenuItems items={items} onClose={() => setExpanded(false)}/>
|
|
68
|
+
</DropdownMenu>
|
|
69
|
+
) : button}
|
|
70
|
+
</NativeHost>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
|
|
2
|
+
import type {FabProps} from './types';
|
|
3
|
+
|
|
4
|
+
import {Button, HStack, Image, Menu as SwiftUIMenu, Text} from '@expo/ui/swift-ui';
|
|
5
|
+
import {
|
|
6
|
+
accessibilityLabel,
|
|
7
|
+
background,
|
|
8
|
+
buttonStyle,
|
|
9
|
+
disabled as disabledMod,
|
|
10
|
+
font,
|
|
11
|
+
foregroundStyle,
|
|
12
|
+
frame,
|
|
13
|
+
opacity,
|
|
14
|
+
padding,
|
|
15
|
+
shadow,
|
|
16
|
+
shapes,
|
|
17
|
+
} from '@expo/ui/swift-ui/modifiers';
|
|
18
|
+
import {iosSymbol} from '../button/shared';
|
|
19
|
+
import {NativeHost} from '../host';
|
|
20
|
+
import {MenuItems} from '../menu/index.ios';
|
|
21
|
+
import {useColor} from '../theme';
|
|
22
|
+
import {FAB_EXTENDED_PADDING, FAB_GAP, FAB_ICON, FAB_SIZE} from './shared';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* iOS has no floating action button, so it is drawn in SwiftUI: a circle
|
|
26
|
+
* (56 pt, 40 small, 96 large) or a capsule when extended, filled with the
|
|
27
|
+
* tint, the icon in `onTint`, a soft shadow, as the label of a plain
|
|
28
|
+
* `Button`; with `items`, as the label of a SwiftUI `Menu`, whose entries are
|
|
29
|
+
* the ones the kit's `Menu` renders.
|
|
30
|
+
*/
|
|
31
|
+
export function Fab({label, icon, onPress, items, size = 'regular', disabled, testID}: FabProps) {
|
|
32
|
+
const tint = useColor('tint');
|
|
33
|
+
const onTint = useColor('onTint');
|
|
34
|
+
const circle = size !== 'extended';
|
|
35
|
+
const dimension = FAB_SIZE[size];
|
|
36
|
+
const faceModifiers: ViewModifier[] = [
|
|
37
|
+
circle ? frame({width: dimension, height: dimension}) : frame({height: dimension}),
|
|
38
|
+
];
|
|
39
|
+
if (!circle) faceModifiers.push(padding({horizontal: FAB_EXTENDED_PADDING}));
|
|
40
|
+
faceModifiers.push(
|
|
41
|
+
background(tint, circle ? shapes.circle() : shapes.capsule()),
|
|
42
|
+
shadow({radius: 4, y: 2, color: 'rgba(0, 0, 0, 0.25)'}),
|
|
43
|
+
);
|
|
44
|
+
const face = (
|
|
45
|
+
<HStack spacing={FAB_GAP} modifiers={faceModifiers}>
|
|
46
|
+
<Image systemName={iosSymbol(icon)} color={onTint} size={FAB_ICON[size]}/>
|
|
47
|
+
{circle ? null : (
|
|
48
|
+
<Text modifiers={[foregroundStyle({type: 'color', color: onTint}), font({weight: 'semibold'})]}>{label}</Text>
|
|
49
|
+
)}
|
|
50
|
+
</HStack>
|
|
51
|
+
);
|
|
52
|
+
const modifiers: ViewModifier[] = [buttonStyle('plain'), accessibilityLabel(label)];
|
|
53
|
+
if (disabled) modifiers.push(disabledMod(true), opacity(0.4));
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<NativeHost fit>
|
|
57
|
+
{items ? (
|
|
58
|
+
<SwiftUIMenu label={face} modifiers={modifiers} testID={testID}>
|
|
59
|
+
<MenuItems items={items}/>
|
|
60
|
+
</SwiftUIMenu>
|
|
61
|
+
) : (
|
|
62
|
+
<Button onPress={onPress} modifiers={modifiers} testID={testID}>
|
|
63
|
+
{face}
|
|
64
|
+
</Button>
|
|
65
|
+
)}
|
|
66
|
+
</NativeHost>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import './fab.css';
|
|
2
|
+
import type {CSSProperties} from 'react';
|
|
3
|
+
import type {FabProps} from './types';
|
|
4
|
+
import {useId, useRef} from 'react';
|
|
5
|
+
import {SymbolView} from 'expo-symbols';
|
|
6
|
+
import {MenuList, menuIdent} from '../menu/list';
|
|
7
|
+
import {FAB_ICON} from './shared';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* On web the button is a `<button>` with the Material geometry (a circle or
|
|
11
|
+
* an extended capsule, filled with the tint) styled via `fab.css`. With
|
|
12
|
+
* `items` it carries a `popovertarget` to the kit's popup menu, the same
|
|
13
|
+
* `MenuList` the `Menu` opens, anchored above the button by CSS anchor
|
|
14
|
+
* positioning. Where it floats is the screen's job (`Screen`'s `fab` slot).
|
|
15
|
+
*/
|
|
16
|
+
export function Fab({label, icon, onPress, items, size = 'regular', disabled, testID}: FabProps) {
|
|
17
|
+
const ident = menuIdent(useId());
|
|
18
|
+
const anchor = `--${ident}`;
|
|
19
|
+
const wrapper = useRef<HTMLSpanElement>(null);
|
|
20
|
+
const extended = size === 'extended';
|
|
21
|
+
return (
|
|
22
|
+
<span ref={wrapper} className="ui-fab__anchor" style={{anchorName: anchor} as CSSProperties}>
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
className={`ui-fab ui-fab--${size}`}
|
|
26
|
+
aria-label={extended ? undefined : label}
|
|
27
|
+
disabled={disabled}
|
|
28
|
+
onClick={items ? undefined : onPress}
|
|
29
|
+
popoverTarget={items ? ident : undefined}
|
|
30
|
+
data-testid={testID}>
|
|
31
|
+
<SymbolView name={icon.symbol} size={FAB_ICON[size]} tintColor="currentColor"/>
|
|
32
|
+
{extended ? <span className="ui-fab__label">{label}</span> : null}
|
|
33
|
+
</button>
|
|
34
|
+
{items ? <MenuList id={ident} items={items} anchor={anchor} anchorRef={wrapper}/> : null}
|
|
35
|
+
</span>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type {FabSize} from './types';
|
|
2
|
+
|
|
3
|
+
/** Diameter of the circular sizes and height of the extended one, in points (Material's). */
|
|
4
|
+
export const FAB_SIZE: Record<FabSize, number> = {
|
|
5
|
+
small: 40,
|
|
6
|
+
regular: 56,
|
|
7
|
+
large: 96,
|
|
8
|
+
extended: 56,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Icon size per `FabSize`. */
|
|
12
|
+
export const FAB_ICON: Record<FabSize, number> = {
|
|
13
|
+
small: 24,
|
|
14
|
+
regular: 24,
|
|
15
|
+
large: 36,
|
|
16
|
+
extended: 24,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Horizontal padding of the extended capsule. */
|
|
20
|
+
export const FAB_EXTENDED_PADDING = 20;
|
|
21
|
+
|
|
22
|
+
/** Gap between the icon and the label of the extended one. */
|
|
23
|
+
export const FAB_GAP = 12;
|