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.
Files changed (58) hide show
  1. package/README.md +87 -10
  2. package/package.json +6 -1
  3. package/src/accent.tsx +21 -8
  4. package/src/button/button.css +6 -0
  5. package/src/button/index.android.tsx +5 -1
  6. package/src/button/index.ios.tsx +5 -1
  7. package/src/button/index.tsx +6 -1
  8. package/src/button/types.ts +13 -0
  9. package/src/color-picker/color-picker.css +70 -0
  10. package/src/color-picker/index.android.tsx +77 -49
  11. package/src/color-picker/index.ios.tsx +58 -11
  12. package/src/color-picker/index.tsx +65 -15
  13. package/src/color-picker/types.ts +9 -3
  14. package/src/context-menu/index.android.tsx +39 -11
  15. package/src/context-menu/index.ios.tsx +3 -1
  16. package/src/context-menu/index.tsx +24 -7
  17. package/src/fab/fab.css +72 -0
  18. package/src/fab/index.android.tsx +72 -0
  19. package/src/fab/index.ios.tsx +68 -0
  20. package/src/fab/index.tsx +37 -0
  21. package/src/fab/shared.ts +23 -0
  22. package/src/fab/types.ts +39 -0
  23. package/src/field-group/index.android.tsx +22 -16
  24. package/src/field-group/index.tsx +36 -5
  25. package/src/field-group/index.web.tsx +25 -9
  26. package/src/field-group/shared.tsx +49 -0
  27. package/src/field-group/types.ts +25 -0
  28. package/src/header-menu/index.tsx +76 -0
  29. package/src/host/index.tsx +32 -0
  30. package/src/index.ts +31 -5
  31. package/src/keyboard/index.tsx +81 -0
  32. package/src/keyboard/library.native.ts +17 -0
  33. package/src/keyboard/library.ts +12 -0
  34. package/src/keyboard/types.ts +20 -0
  35. package/src/list-item/index.android.tsx +23 -6
  36. package/src/list-item/index.tsx +20 -4
  37. package/src/list-item/index.web.tsx +60 -0
  38. package/src/list-item/list-item.css +65 -0
  39. package/src/list-item/types.ts +21 -0
  40. package/src/menu/index.android.tsx +22 -9
  41. package/src/menu/index.ios.tsx +26 -11
  42. package/src/menu/index.tsx +26 -10
  43. package/src/menu/list.tsx +25 -10
  44. package/src/menu/menu.css +52 -0
  45. package/src/menu/types.ts +41 -2
  46. package/src/scheme.ts +140 -0
  47. package/src/screen/index.tsx +49 -5
  48. package/src/tab-stack/index.tsx +15 -2
  49. package/src/tabs/index.tsx +2 -1
  50. package/src/tabs/index.web.tsx +29 -4
  51. package/src/tabs/types.ts +20 -2
  52. package/src/text-field/index.android.tsx +30 -6
  53. package/src/text-field/index.ios.tsx +16 -3
  54. package/src/text-field/index.tsx +24 -4
  55. package/src/text-field/inline.tsx +87 -0
  56. package/src/text-field/shared.ts +40 -1
  57. package/src/text-field/types.ts +47 -2
  58. 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
- <SwiftUIColorPicker
25
- label={label}
26
- selection={value}
27
- supportsOpacity={supportsOpacity}
28
- onSelectionChange={onValueChange}
29
- modifiers={modifiers}
30
- testID={testID}
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={['ui-color-picker', disabled && 'ui-color-picker--disabled'].filter(Boolean).join(' ')}
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
- <span className="ui-color-picker__well" aria-hidden="true">
47
- <span className="ui-color-picker__swatch"/>
48
- </span>
107
+ {well}
49
108
  </button>
50
- <Sheet isPresented={open} onDismiss={() => setOpen(false)}>
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 rainbow-ringed well — and
9
- * open a sheet that reproduces the iOS picker: Grid, Spectrum and Sliders
10
- * tabs, the opacity slider and the preview swatch with saved colors.
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` anchored to it while a tap
11
- * goes to `onPress`. `children` must be Compose content.
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: () => setExpanded(true)})]),
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
- <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>
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`) 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`.
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
  }
@@ -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;