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.
Files changed (78) hide show
  1. package/README.md +119 -20
  2. package/package.json +9 -9
  3. package/src/accent.tsx +21 -8
  4. package/src/alert/alert.css +3 -3
  5. package/src/alert/index.tsx +1 -2
  6. package/src/button/button.css +12 -0
  7. package/src/button/index.android.tsx +14 -3
  8. package/src/button/index.ios.tsx +13 -4
  9. package/src/button/index.tsx +8 -1
  10. package/src/button/types.ts +20 -0
  11. package/src/checkbox/index.tsx +1 -1
  12. package/src/color-picker/color-picker.css +136 -0
  13. package/src/color-picker/index.android.tsx +145 -0
  14. package/src/color-picker/index.ios.tsx +80 -0
  15. package/src/color-picker/index.tsx +112 -0
  16. package/src/color-picker/shared.ts +204 -0
  17. package/src/color-picker/sheet.tsx +432 -0
  18. package/src/color-picker/types.ts +40 -0
  19. package/src/context-menu/index.android.tsx +39 -11
  20. package/src/context-menu/index.ios.tsx +3 -1
  21. package/src/context-menu/index.tsx +24 -7
  22. package/src/fab/fab.css +72 -0
  23. package/src/fab/index.android.tsx +72 -0
  24. package/src/fab/index.ios.tsx +68 -0
  25. package/src/fab/index.tsx +37 -0
  26. package/src/fab/shared.ts +23 -0
  27. package/src/fab/types.ts +39 -0
  28. package/src/field-group/field-group.css +23 -7
  29. package/src/field-group/index.android.tsx +44 -38
  30. package/src/field-group/index.tsx +36 -5
  31. package/src/field-group/index.web.tsx +27 -11
  32. package/src/field-group/shared.tsx +49 -0
  33. package/src/field-group/types.ts +25 -0
  34. package/src/gauge/gauge.css +200 -0
  35. package/src/gauge/index.android.tsx +212 -0
  36. package/src/gauge/index.ios.tsx +40 -0
  37. package/src/gauge/index.tsx +165 -0
  38. package/src/gauge/shared.ts +79 -0
  39. package/src/gauge/types.ts +64 -0
  40. package/src/header-menu/index.tsx +76 -0
  41. package/src/host/index.tsx +32 -0
  42. package/src/index.ts +35 -6
  43. package/src/keyboard/index.tsx +81 -0
  44. package/src/keyboard/library.native.ts +17 -0
  45. package/src/keyboard/library.ts +12 -0
  46. package/src/keyboard/types.ts +20 -0
  47. package/src/list-item/index.android.tsx +23 -6
  48. package/src/list-item/index.tsx +20 -4
  49. package/src/list-item/index.web.tsx +60 -0
  50. package/src/list-item/list-item.css +65 -0
  51. package/src/list-item/types.ts +21 -0
  52. package/src/menu/index.android.tsx +22 -9
  53. package/src/menu/index.ios.tsx +26 -11
  54. package/src/menu/index.tsx +26 -10
  55. package/src/menu/list.tsx +25 -10
  56. package/src/menu/menu.css +52 -0
  57. package/src/menu/types.ts +41 -2
  58. package/src/progress/progress.css +9 -0
  59. package/src/scheme.ts +140 -0
  60. package/src/screen/header.tsx +1 -0
  61. package/src/screen/index.tsx +49 -5
  62. package/src/segmented/index.tsx +1 -1
  63. package/src/segmented/segmented.css +6 -7
  64. package/src/slider/index.tsx +1 -1
  65. package/src/stepper/index.tsx +1 -1
  66. package/src/switch/index.tsx +2 -0
  67. package/src/tab-stack/index.tsx +15 -2
  68. package/src/tabs/index.tsx +2 -1
  69. package/src/tabs/index.web.tsx +29 -4
  70. package/src/tabs/types.ts +20 -2
  71. package/src/text-field/index.android.tsx +30 -6
  72. package/src/text-field/index.ios.tsx +16 -3
  73. package/src/text-field/index.tsx +24 -4
  74. package/src/text-field/inline.tsx +87 -0
  75. package/src/text-field/shared.ts +40 -1
  76. package/src/text-field/types.ts +47 -2
  77. package/src/theme.ts +44 -5
  78. 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; destructive
13
- * items use the theme danger color.
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 `ContextMenu`. */
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.icon?.drawable ? (
56
+ {item.swatch ? (
53
57
  <DropdownMenuItem.LeadingIcon>
54
- <Icon source={item.icon.drawable} size={ICON_SIZE} tint={item.disabled ? colors.onSurfaceVariant : color}/>
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={item.disabled ? colors.onSurfaceVariant : color}>{item.label}</Text>
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
  );
@@ -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) and `Divider`s.
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(color ?? themeTint),
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 `ContextMenu`. */
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
- <Button
62
- label={item.label}
63
- systemImage={item.icon ? iosSymbol(item.icon) : undefined}
64
- role={item.role === 'destructive' ? 'destructive' : 'default'}
65
- onPress={item.onPress}
66
- modifiers={item.disabled ? [disabledMod(true)] : undefined}
67
- />
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
  </>
@@ -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 a `popovertarget` pointing
10
- * at the entries' native `popover`, so opening, closing, light dismiss and
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
- <Button
21
- {...button}
22
- label={label}
23
- prefixIcon={icon}
24
- popoverTarget={ident}
25
- testID={testID}
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 `ContextMenu`, rendered as a
34
- * native `popover="auto"` element. The browser handles the top layer, light
35
- * dismiss (outside click / Escape) and the trigger's `aria-expanded`; every
36
- * item carries `popovertargetaction="hide"` so picking one closes the menu
37
- * declaratively. Placement is CSS anchor positioning (see `menu.css`), with a
38
- * measured fallback for engines without it.
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') return;
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
- className={['ui-menu__item', item.role === 'destructive' && 'ui-menu__item--destructive'].filter(Boolean).join(' ')}
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.icon ? <SymbolView name={item.icon.symbol} size={ICON_SIZE} tintColor="currentColor"/> : null}
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
+ }
@@ -21,6 +21,7 @@ export function ScreenHeader({title, onBack, trailing}: ScreenHeaderProps) {
21
21
  {onBack ? (
22
22
  <Pressable
23
23
  onPress={onBack}
24
+ role="button"
24
25
  accessibilityLabel="Go back"
25
26
  style={styles.back}>
26
27
  <SymbolView