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,22 +1,39 @@
1
1
  import type {ReactNode} from 'react';
2
2
  import type {ListItemProps} from './types';
3
- import {ListItem as ComposeListItem, Text} from '@expo/ui/jetpack-compose';
4
- import {clickable, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
3
+ import {ListItem as ComposeListItem, Row, Text, TextButton} from '@expo/ui/jetpack-compose';
4
+ import {clickable, testID as testIDModifier, wrapContentHeight, wrapContentWidth} from '@expo/ui/jetpack-compose/modifiers';
5
5
  import {useColor} from '../theme';
6
6
 
7
7
  /**
8
8
  * Android uses the Material 3 Compose `ListItem` directly so the container
9
9
  * can be made transparent — the M3 default paints the Host palette's
10
10
  * `surface`, which reads as a grey panel over the app's screen background
11
- * (web/iOS rows are transparent).
11
+ * (web/iOS rows are transparent). An `action` is a `TextButton` in the
12
+ * trailing slot, after any `trailing` content.
12
13
  */
13
- export function ListItem({children, leading, trailing, supporting, onPress, testID}: ListItemProps) {
14
+ export function ListItem({children, leading, trailing, action, supporting, onPress, testID}: ListItemProps) {
14
15
  const label = useColor('label');
15
16
  const subtle = useColor('secondaryLabel');
17
+ const tint = useColor('tint');
18
+ const destructive = useColor('destructive');
19
+ const muted = useColor('tertiaryLabel');
16
20
  const modifiers = [
17
21
  ...(onPress ? [clickable(onPress)] : []),
18
22
  ...(testID ? [testIDModifier(testID)] : []),
19
23
  ];
24
+ const actionColor = action?.role === 'destructive' ? destructive : tint;
25
+ const trailingContent = action ? (
26
+ <Row verticalAlignment="center" horizontalArrangement={{spacedBy: 8}}>
27
+ {trailing}
28
+ <TextButton
29
+ onClick={action.disabled ? undefined : action.onPress}
30
+ enabled={!action.disabled}
31
+ colors={{contentColor: actionColor}}
32
+ modifiers={[wrapContentWidth('end'), wrapContentHeight('centerVertically')]}>
33
+ <Text color={action.disabled ? muted : actionColor}>{action.label}</Text>
34
+ </TextButton>
35
+ </Row>
36
+ ) : trailing;
20
37
  return (
21
38
  <ComposeListItem colors={{containerColor: '#00000000'}} modifiers={modifiers}>
22
39
  <ComposeListItem.HeadlineContent>
@@ -34,8 +51,8 @@ export function ListItem({children, leading, trailing, supporting, onPress, test
34
51
  )}
35
52
  </ComposeListItem.SupportingContent>
36
53
  ) : null}
37
- {trailing != null ? (
38
- <ComposeListItem.TrailingContent>{trailing}</ComposeListItem.TrailingContent>
54
+ {trailingContent != null ? (
55
+ <ComposeListItem.TrailingContent>{trailingContent}</ComposeListItem.TrailingContent>
39
56
  ) : null}
40
57
  </ComposeListItem>
41
58
  );
@@ -1,16 +1,32 @@
1
1
  import type {ListItemProps} from './types';
2
2
  import {ListItem as UIListItem} from '@expo/ui';
3
+ import {Button} from '../button';
3
4
 
4
5
  /**
5
- * Web/iOS render the universal `@expo/ui` `ListItem` (RN row on web, SwiftUI
6
- * list row on iOS) through explicit slot props.
6
+ * iOS renders the universal `@expo/ui` `ListItem` (a SwiftUI list row)
7
+ * through explicit slot props. An `action` is the kit's text `Button` at the
8
+ * end of the trailing slot, after any `trailing` content; the row itself
9
+ * only responds when it also has an `onPress`.
7
10
  */
8
- export function ListItem({children, leading, trailing, supporting, onPress, testID}: ListItemProps) {
11
+ export function ListItem({children, leading, trailing, action, supporting, onPress, testID}: ListItemProps) {
12
+ const trailingContent = action ? (
13
+ <>
14
+ {trailing}
15
+ <Button
16
+ label={action.label}
17
+ variant="text"
18
+ size="small"
19
+ role={action.role === 'destructive' ? 'destructive' : 'default'}
20
+ disabled={action.disabled}
21
+ onPress={action.onPress}
22
+ />
23
+ </>
24
+ ) : trailing;
9
25
  return (
10
26
  <UIListItem
11
27
  onPress={onPress}
12
28
  leading={leading}
13
- trailing={trailing}
29
+ trailing={trailingContent}
14
30
  supportingText={supporting}
15
31
  testID={testID}>
16
32
  {children}
@@ -0,0 +1,60 @@
1
+ import './list-item.css';
2
+ import type {ListItemProps} from './types';
3
+ import {ListItem as UIListItem} from '@expo/ui';
4
+ import {Button} from '../button';
5
+
6
+ /**
7
+ * Web renders the universal `@expo/ui` `ListItem` (a React Native row)
8
+ * through explicit slot props. With an `action` the row is drawn here
9
+ * instead, so the action's `<button>` sits beside the row's own (a `<button>`
10
+ * when the row has an `onPress`, a `<div>` otherwise) rather than inside it:
11
+ * nested buttons are not valid HTML, and a click on the action would also
12
+ * press the row.
13
+ */
14
+ export function ListItem({children, leading, trailing, action, supporting, onPress, testID}: ListItemProps) {
15
+ if (!action) {
16
+ return (
17
+ <UIListItem
18
+ onPress={onPress}
19
+ leading={leading}
20
+ trailing={trailing}
21
+ supportingText={supporting}
22
+ testID={testID}>
23
+ {children}
24
+ </UIListItem>
25
+ );
26
+ }
27
+ const content = (
28
+ <>
29
+ {leading != null ? <span className="ui-list-item__slot">{leading}</span> : null}
30
+ <span className="ui-list-item__main">
31
+ <span className="ui-list-item__headline">{children}</span>
32
+ {supporting != null ? (
33
+ typeof supporting === 'string' || typeof supporting === 'number'
34
+ ? <span className="ui-list-item__supporting">{supporting}</span>
35
+ : supporting
36
+ ) : null}
37
+ </span>
38
+ {trailing != null ? <span className="ui-list-item__slot">{trailing}</span> : null}
39
+ </>
40
+ );
41
+ return (
42
+ <div className="ui-list-item" data-testid={testID}>
43
+ {onPress ? (
44
+ <button type="button" className="ui-list-item__row ui-list-item__row--pressable" onClick={onPress}>{content}</button>
45
+ ) : (
46
+ <div className="ui-list-item__row">{content}</div>
47
+ )}
48
+ <Button
49
+ label={action.label}
50
+ variant="text"
51
+ size="small"
52
+ role={action.role === 'destructive' ? 'destructive' : 'default'}
53
+ disabled={action.disabled}
54
+ onPress={action.onPress}
55
+ />
56
+ </div>
57
+ );
58
+ }
59
+
60
+ export type {ListItemProps} from './types';
@@ -0,0 +1,65 @@
1
+ /*
2
+ * The web row with a trailing action: the universal row's geometry (12px by
3
+ * 16px padding, a 12px gap between the slots), the row's own control beside
4
+ * the action's `<button>` rather than around it.
5
+ */
6
+ .ui-list-item {
7
+ display: flex;
8
+ flex-direction: row;
9
+ align-items: center;
10
+ gap: 12px;
11
+ box-sizing: border-box;
12
+ width: 100%;
13
+ padding: 12px 16px;
14
+ }
15
+
16
+ .ui-list-item__row {
17
+ -webkit-appearance: none;
18
+ appearance: none;
19
+ display: flex;
20
+ flex-direction: row;
21
+ align-items: center;
22
+ gap: 12px;
23
+ flex: 1;
24
+ min-width: 0;
25
+ margin: 0;
26
+ padding: 0;
27
+ border: none;
28
+ background: transparent;
29
+ color: var(--color-label);
30
+ font: inherit;
31
+ text-align: start;
32
+ }
33
+
34
+ .ui-list-item__row--pressable {
35
+ cursor: pointer;
36
+ }
37
+
38
+ .ui-list-item__main {
39
+ display: flex;
40
+ flex-direction: column;
41
+ gap: 2px;
42
+ flex: 1;
43
+ min-width: 0;
44
+ }
45
+
46
+ .ui-list-item__headline {
47
+ font-family: var(--font-display);
48
+ font-size: 14px;
49
+ line-height: 18px;
50
+ color: var(--color-label);
51
+ }
52
+
53
+ .ui-list-item__supporting {
54
+ font-family: var(--font-display);
55
+ font-size: 13px;
56
+ line-height: 18px;
57
+ color: var(--color-secondary-label);
58
+ }
59
+
60
+ .ui-list-item__slot {
61
+ display: flex;
62
+ flex-direction: row;
63
+ align-items: center;
64
+ flex-shrink: 0;
65
+ }
@@ -1,5 +1,24 @@
1
1
  import type {ReactNode} from 'react';
2
2
 
3
+ /**
4
+ * A text action at the trailing edge of a `ListItem` (sign in, sign out,
5
+ * clear cache): a Compose `TextButton`, a borderless SwiftUI `Button`, a DOM
6
+ * button. The row itself stays inert unless it also has an `onPress`.
7
+ */
8
+ export interface ListItemAction {
9
+ /** The action's text. */
10
+ label: string;
11
+ /** Called when the action is pressed. */
12
+ onPress: () => void;
13
+ /** Greys the action out and ignores presses. */
14
+ disabled?: boolean;
15
+ /**
16
+ * `destructive` renders the action in the danger color.
17
+ * @default 'default'
18
+ */
19
+ role?: 'default' | 'destructive';
20
+ }
21
+
3
22
  /**
4
23
  * Props for the app `ListItem`: a tappable row with leading/trailing slots
5
24
  * and optional supporting text. Bridges the universal `@expo/ui` `ListItem`
@@ -12,6 +31,8 @@ export interface ListItemProps {
12
31
  leading?: ReactNode;
13
32
  /** Trailing (end) slot — chevron, value, control, etc. */
14
33
  trailing?: ReactNode;
34
+ /** A text action rendered natively at the trailing edge, after `trailing`. */
35
+ action?: ListItemAction;
15
36
  /** Secondary content below the headline; strings get subtle styling. */
16
37
  supporting?: string | ReactNode;
17
38
  /** Tap handler, active over the entire row. */
@@ -1,18 +1,21 @@
1
1
  import type {MenuItem, MenuProps} from './types';
2
2
 
3
3
  import {Fragment, useState} from 'react';
4
- import {DropdownMenu, DropdownMenuItem, HorizontalDivider, Icon, Text, useMaterialColors} from '@expo/ui/jetpack-compose';
4
+ import {Box, DropdownMenu, DropdownMenuItem, HorizontalDivider, Icon, Text, useMaterialColors} from '@expo/ui/jetpack-compose';
5
+ import {background, clip, Shapes, size as sizeModifier} from '@expo/ui/jetpack-compose/modifiers';
5
6
  import {Button} from '../button';
6
7
  import {useColor} from '../theme';
7
8
 
8
9
  const ICON_SIZE = 20;
10
+ const SWATCH_SIZE = 16;
9
11
 
10
12
  /**
11
13
  * Android anchors a Material 3 `DropdownMenu` to the kit's `Button`. Entries
12
- * are `DropdownMenuItem`s with an optional drawable leading icon; 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;