@colixsystems/widget-sdk 0.244.0 → 0.246.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/dist/index.d.ts CHANGED
@@ -1790,6 +1790,17 @@ export interface AgentActivity {
1790
1790
  table?: string;
1791
1791
  record_id?: string;
1792
1792
  fields?: string[];
1793
+ /** A read's own search text: what the agent looked FOR, never what it found. */
1794
+ search?: string;
1795
+ /** A read's own filter conditions, as the agent asked for them. */
1796
+ filters?: AgentActivityFilter[];
1797
+ }
1798
+
1799
+ /** One condition a read filtered by; `operator` is eq, neq, gt, gte, lt, lte or contains. */
1800
+ export interface AgentActivityFilter {
1801
+ column: string;
1802
+ operator: string;
1803
+ value: string;
1793
1804
  }
1794
1805
 
1795
1806
  export interface AgentApi {
@@ -3859,6 +3870,11 @@ export const Avatar: (props: {
3859
3870
  seed?: string;
3860
3871
  /** An absolute image URL. */
3861
3872
  url?: string;
3873
+ /**
3874
+ * A filestore file id (a FILE column's value). Takes precedence over `url`;
3875
+ * the initials show while it loads and stay if it cannot load.
3876
+ */
3877
+ fileId?: string | null;
3862
3878
  /** A size token, or a number of px (clamped to 16-160). */
3863
3879
  size?: "sm" | "md" | "lg" | "xl" | number;
3864
3880
  shape?: "circle" | "rounded";
@@ -3867,6 +3883,36 @@ export const Avatar: (props: {
3867
3883
  testID?: string;
3868
3884
  }) => any;
3869
3885
 
3886
+ /**
3887
+ * A stored file's picture, or one at a URL: a themed `<Skeleton>` while it
3888
+ * loads and a fallback, never a broken image, when it cannot. A `fileId` is
3889
+ * resolved through `useFilestoreFile`, and an expired signed URL is fetched
3890
+ * again once before the fallback shows.
3891
+ *
3892
+ * @example
3893
+ * <FileImage fileId={record.Photo} variant="card" aspectRatio={4 / 3} />
3894
+ */
3895
+ export const FileImage: (props: {
3896
+ /** A filestore file id (a FILE column's value). Wins over `uri`. */
3897
+ fileId?: string | null;
3898
+ /** A URL you already have, e.g. a `useFilestoreFiles()` row's `urls.card`. */
3899
+ uri?: string | null;
3900
+ /** The size-ladder rung to load. Default "large". */
3901
+ variant?: "thumbnail" | "card" | "large" | "hero" | "original";
3902
+ /** Width / height. Unset, the box takes the picture's own shape once loaded. */
3903
+ aspectRatio?: number;
3904
+ /** Corner radius: px, a theme radius name, or "none". Default the theme's `md`. */
3905
+ radius?: number | "none" | "sm" | "md" | "lg" | "pill";
3906
+ resizeMode?: "cover" | "contain" | "stretch" | "center";
3907
+ /** What shows when the picture cannot load. Default "icon". */
3908
+ fallback?: "icon" | "blank";
3909
+ /** Unset, the picture is decorative and hidden from assistive technology. */
3910
+ accessibilityLabel?: string;
3911
+ style?: any;
3912
+ /** Also tags `<testID>-image`, `<testID>-loading` and `<testID>-fallback`. */
3913
+ testID?: string;
3914
+ }) => any;
3915
+
3870
3916
  /** Themed completion bar or ring for real progress toward a known goal. */
3871
3917
  export const Progress: (props: {
3872
3918
  /** Percent complete; clamped to 0-100. */
@@ -3968,38 +4014,61 @@ export const Card: (props: {
3968
4014
  export type CardScrim = "bottom" | "top" | "left" | "right" | "full" | "none";
3969
4015
  export type CardImagePosition = "center" | "top" | "bottom" | "left" | "right";
3970
4016
 
3971
- export type ButtonVariant = "primary" | "secondary" | "outlined" | "danger";
4017
+ export type ButtonVariant = "primary" | "secondary" | "outlined" | "danger" | "ghost" | "link";
4018
+ export type ButtonSize = "sm" | "md" | "lg";
3972
4019
  export const BUTTON_VARIANTS: readonly ButtonVariant[];
3973
4020
 
4021
+ /** Per-button surface fields; unset ones follow the theme. */
4022
+ export interface ButtonFields {
4023
+ background?: string;
4024
+ textColor?: string;
4025
+ borderColor?: string;
4026
+ borderWidth?: number;
4027
+ paddingX?: number;
4028
+ paddingY?: number;
4029
+ radius?: number;
4030
+ fontWeight?: string;
4031
+ fontSize?: number;
4032
+ shadow?: "none" | "sm" | "md" | "lg";
4033
+ gradient?: { from: string; to: string; angle?: number };
4034
+ }
4035
+
3974
4036
  /**
3975
4037
  * Themed button (Design > Components > Buttons, Danger buttons, Button outline). Omit
3976
- * `onPress` for a purely visual button inside something pressable.
4038
+ * `onPress` for a purely visual button inside something pressable. `ghost` and
4039
+ * `link` draw no fill or border and take only the look's shape and type.
3977
4040
  *
3978
4041
  * @example
3979
- * <Button title="Save" onPress={save} />
4042
+ * <Button title="Save" onPress={save} busy={saving} />
3980
4043
  * <Button title="Delete" variant="danger" icon="trash-2" onPress={remove} />
4044
+ * <Button title="Load more" variant="ghost" size="sm" onPress={next} />
4045
+ */
4046
+ /**
4047
+ * What a `<Menu>`, `<Popover>` or `<Tooltip>` puts on the button it wraps, so
4048
+ * a button can open one and expose whether it is open.
3981
4049
  */
3982
- export const Button: (props: {
4050
+ export interface ButtonTriggerProps {
4051
+ /** A `<Tooltip>` shows on a long press. */
4052
+ onLongPress?: () => void;
4053
+ "aria-haspopup"?: "menu" | "dialog" | boolean;
4054
+ "aria-expanded"?: boolean;
4055
+ "aria-describedby"?: string;
4056
+ }
4057
+
4058
+ export const Button: (props: ButtonTriggerProps & {
3983
4059
  title: ReactNode;
3984
4060
  variant?: ButtonVariant;
4061
+ /** sm (36 high) | md (44, default) | lg (52). */
4062
+ size?: ButtonSize;
3985
4063
  icon?: string;
3986
4064
  iconPosition?: "leading" | "trailing";
3987
4065
  onPress?: () => void;
3988
4066
  disabled?: boolean;
3989
- /** Per-button surface fields; unset ones follow the theme. */
3990
- fields?: {
3991
- background?: string;
3992
- textColor?: string;
3993
- borderColor?: string;
3994
- borderWidth?: number;
3995
- paddingX?: number;
3996
- paddingY?: number;
3997
- radius?: number;
3998
- fontWeight?: string;
3999
- fontSize?: number;
4000
- shadow?: "none" | "sm" | "md" | "lg";
4001
- gradient?: { from: string; to: string; angle?: number };
4002
- };
4067
+ /** A pending action: a spinner over the kept label; it cannot be pressed. */
4068
+ busy?: boolean;
4069
+ /** A toggle's on/off state; omit for a plain button. */
4070
+ selected?: boolean;
4071
+ fields?: ButtonFields;
4003
4072
  style?: any;
4004
4073
  labelStyle?: any;
4005
4074
  accessibilityLabel?: string;
@@ -4008,6 +4077,34 @@ export const Button: (props: {
4008
4077
  ref?: { current: any } | ((node: any) => void);
4009
4078
  }) => any;
4010
4079
 
4080
+ /**
4081
+ * A square themed button that shows only an icon, at least 36px on both hosts.
4082
+ * `accessibilityLabel` is its only name, so it is required.
4083
+ *
4084
+ * @example
4085
+ * <IconButton icon="pencil" accessibilityLabel="Edit order" onPress={edit} />
4086
+ * <IconButton icon="bold" accessibilityLabel="Bold" selected={bold} onPress={toggleBold} />
4087
+ */
4088
+ export const IconButton: (props: ButtonTriggerProps & {
4089
+ /** A lucide icon name, drawn with `<Icon>`. */
4090
+ icon: string;
4091
+ accessibilityLabel: string;
4092
+ /** ghost (default) | primary | secondary | outlined | danger. */
4093
+ variant?: Exclude<ButtonVariant, "link">;
4094
+ /** sm (36) | md (44, default) | lg (52). */
4095
+ size?: ButtonSize;
4096
+ /** The glyph's colour, over the variant's. */
4097
+ color?: string;
4098
+ onPress?: () => void;
4099
+ disabled?: boolean;
4100
+ busy?: boolean;
4101
+ selected?: boolean;
4102
+ fields?: ButtonFields;
4103
+ style?: any;
4104
+ testID?: string;
4105
+ ref?: { current: any } | ((node: any) => void);
4106
+ }) => any;
4107
+
4011
4108
  /**
4012
4109
  * What every form control shares: the label drawn with it (and read by a
4013
4110
  * screen reader), a hint below it, and an error that replaces the hint and
@@ -4193,6 +4290,48 @@ export const SearchField: (props: FormControlProps & {
4193
4290
  [prop: string]: any;
4194
4291
  }) => any;
4195
4292
 
4293
+ export type RadioGroupVariant = "list" | "card" | "segmented";
4294
+
4295
+ export interface RadioOption<V extends string | number = string | number> {
4296
+ value: V;
4297
+ label: ReactNode;
4298
+ /** A second line under the label; not drawn by the segmented variant. */
4299
+ description?: ReactNode;
4300
+ /** A lucide icon name drawn before the label. */
4301
+ icon?: string;
4302
+ disabled?: boolean;
4303
+ accessibilityLabel?: string;
4304
+ testID?: string;
4305
+ }
4306
+
4307
+ /**
4308
+ * Themed pick-ONE of a short list that stays on screen. `list` (default) is
4309
+ * the app's input panel with one row per option, `card` one bordered card per
4310
+ * option (with its `description`), both from Design > Components > Inputs;
4311
+ * `segmented` is the segmented tab strip from Design > Components > Tabs,
4312
+ * its options sharing the row evenly. Radio role and checked state on both
4313
+ * hosts; on the web the group is one tab stop, the arrow keys move the
4314
+ * choice and Space picks the focused option. Other props land on the group.
4315
+ *
4316
+ * @example
4317
+ * <RadioGroup label="Delivery" value={speed} onValueChange={setSpeed}
4318
+ * options={[{ value: "standard", label: "Standard" }, { value: "express", label: "Express" }]} />
4319
+ */
4320
+ export const RadioGroup: <V extends string | number = string | number>(
4321
+ props: FormControlProps & {
4322
+ options: Array<RadioOption<V>>;
4323
+ value?: V | null;
4324
+ /** Called when a different option is picked. */
4325
+ onValueChange?: (value: V, option: RadioOption<V>) => void;
4326
+ variant?: RadioGroupVariant;
4327
+ /** The group. */
4328
+ style?: any;
4329
+ /** The box around a group that shows a label, helper or error. */
4330
+ containerStyle?: any;
4331
+ [prop: string]: any;
4332
+ },
4333
+ ) => any;
4334
+
4196
4335
  export type TabStyle = "segmented" | "pill" | "underline";
4197
4336
 
4198
4337
  /**
@@ -4418,6 +4557,107 @@ export const FileUpload: (props: FormControlProps & {
4418
4557
  containerStyle?: any;
4419
4558
  }) => any;
4420
4559
 
4560
+ export type PopoverPlacement = "bottom" | "top";
4561
+
4562
+ /** One `<Menu>` item. */
4563
+ export interface MenuItem {
4564
+ label: string;
4565
+ /** Runs after the menu closes. */
4566
+ onPress?: () => void;
4567
+ /** A Lucide icon name, or an element drawn before the label. */
4568
+ icon?: string | ReactNode;
4569
+ /** Drawn in the theme's danger colour. */
4570
+ danger?: boolean;
4571
+ /** Dimmed, skipped by the arrow keys, and cannot be picked. */
4572
+ disabled?: boolean;
4573
+ accessibilityLabel?: string;
4574
+ testID?: string;
4575
+ key?: string;
4576
+ }
4577
+
4578
+ /**
4579
+ * A list of actions opened from a trigger: a row's "…" button, a toolbar's
4580
+ * overflow. Beside the trigger on a wide screen, a bottom sheet on a phone;
4581
+ * drawn over the screen, so no card or list clips it. The backdrop, Escape
4582
+ * (web) and the Android back button close it, and focus returns to the
4583
+ * trigger. On the web it is a `menu` of `menuitem`s: the arrow keys, Home and
4584
+ * End move between enabled items and Space or Enter picks one. A picked
4585
+ * item's `onPress` runs once the menu has left the screen, so it can open a
4586
+ * `<ConfirmDialog>`. Follows the theme's `popover` scope (Design > Components
4587
+ * > Dropdown menus).
4588
+ *
4589
+ * @example
4590
+ * <Menu
4591
+ * trigger={<IconButton icon="more-horizontal" accessibilityLabel="More actions" />}
4592
+ * accessibilityLabel="Order actions"
4593
+ * items={[
4594
+ * { label: "Edit", icon: "pencil", onPress: edit },
4595
+ * canDelete && { label: "Delete", icon: "trash-2", danger: true, onPress: askDelete },
4596
+ * ]}
4597
+ * />
4598
+ */
4599
+ export const Menu: (props: {
4600
+ /** ONE pressable element; the menu adds the press that opens it. */
4601
+ trigger: ReactNode;
4602
+ /** Falsy entries are skipped, so `cond && { … }` works. */
4603
+ items: Array<MenuItem | null | false | undefined>;
4604
+ accessibilityLabel?: string;
4605
+ /** Default "bottom"; it flips at the screen edge. */
4606
+ placement?: PopoverPlacement;
4607
+ /** The sheet backdrop's name. Default "Close". */
4608
+ closeAccessibilityLabel?: string;
4609
+ /** On the panel; the item list takes `-items`. */
4610
+ testID?: string;
4611
+ }) => any;
4612
+
4613
+ /**
4614
+ * Free content beside a trigger: quick filters, a swatch picker, a short
4615
+ * detail card. Placed like `<Menu>`, closed the same ways. Components inside
4616
+ * read a theme inked for the panel. Follows the theme's `popover` scope.
4617
+ *
4618
+ * @example
4619
+ * <Popover trigger={<Button variant="secondary" title="Filters" />} accessibilityLabel="Filters">
4620
+ * {({ close }) => <Button title="Apply" onPress={() => { apply(); close(); }} />}
4621
+ * </Popover>
4622
+ */
4623
+ export const Popover: (props: {
4624
+ /** ONE pressable element; the popover adds the press that opens it. */
4625
+ trigger: ReactNode;
4626
+ children?: ReactNode | ((api: { close: () => void }) => ReactNode);
4627
+ /** Pass with `onVisibleChange` to open and close it from your own code. */
4628
+ visible?: boolean;
4629
+ onVisibleChange?: (visible: boolean) => void;
4630
+ /** Default "bottom"; it flips at the screen edge. */
4631
+ placement?: PopoverPlacement;
4632
+ accessibilityLabel?: string;
4633
+ /** The sheet backdrop's name. Default "Close". */
4634
+ closeAccessibilityLabel?: string;
4635
+ /** On the panel. */
4636
+ testID?: string;
4637
+ }) => any;
4638
+
4639
+ /**
4640
+ * A short label for the one pressable element it wraps, usually an
4641
+ * icon-only button that keeps its own `accessibilityLabel`. On the web it
4642
+ * shows on hover and keyboard focus; a touch screen has no hover, so there a
4643
+ * long press shows it (a bottom sheet on a phone). A child with its own
4644
+ * `onLongPress` keeps it and gets no long-press tooltip. The tooltip wraps
4645
+ * the child in a `View`. Follows the theme's `popover` scope.
4646
+ *
4647
+ * @example
4648
+ * <Tooltip label="Delete">
4649
+ * <IconButton icon="trash-2" accessibilityLabel="Delete" onPress={remove} />
4650
+ * </Tooltip>
4651
+ */
4652
+ export const Tooltip: (props: {
4653
+ label: string;
4654
+ /** Default "top"; it flips at the screen edge. */
4655
+ placement?: PopoverPlacement;
4656
+ /** On the label. */
4657
+ testID?: string;
4658
+ children: ReactNode;
4659
+ }) => any;
4660
+
4421
4661
  /**
4422
4662
  * Themed collapsible sections. The surface comes from the theme's `card`
4423
4663
  * scope and the headers and dividers from its `list` scope, so every
package/dist/index.js CHANGED
@@ -241,6 +241,8 @@ export { List, ListRow, ListHeader, EmptyState } from "./list.js";
241
241
  export { Badge, Avatar, Progress, Stat, Divider } from "./detail-parts.js";
242
242
  export { initialsFor } from "./initials.js";
243
243
  export { avatarColorFor } from "./avatar-color.js";
244
+ // A stored file's picture: a themed placeholder, then a fallback on failure.
245
+ export { FileImage } from "./file-image.js";
244
246
  // The one card surface: the theme's `card` scope, a gradient fill, margin.
245
247
  export { Card } from "./card.js";
246
248
  export {
@@ -258,7 +260,7 @@ export {
258
260
  cardGradientProps,
259
261
  } from "./card-style.js";
260
262
  // The themed button; follows the theme's button, destructive and ring scopes.
261
- export { Button, BUTTON_VARIANTS } from "./button.js";
263
+ export { Button, IconButton, BUTTON_VARIANTS } from "./button.js";
262
264
  export {
263
265
  BUTTON_PADDING_MAX,
264
266
  BUTTON_BORDER_WIDTH_MAX,
@@ -278,6 +280,8 @@ export { Checkbox } from "./checkbox.js";
278
280
  export { Switch } from "./switch.js";
279
281
  export { Select } from "./select.js";
280
282
  export { SearchField } from "./search-field.js";
283
+ // Pick one of a short visible list; `muted` scope, or `tabs` when segmented.
284
+ export { RadioGroup } from "./radio-group.js";
281
285
  // Space for a control whose role is not a button; a no-op on native.
282
286
  export { spaceActivates } from "./form-control-logic.js";
283
287
  // A themed tab strip; follows the theme's `tabs` scope.
@@ -294,6 +298,8 @@ export { ConfirmDialog } from "./confirm-dialog.js";
294
298
  export { Chip, ChipGroup } from "./chip.js";
295
299
  // A themed file chooser on `<FilePicker>`; follows the theme's `muted` scope.
296
300
  export { FileUpload } from "./file-upload.js";
301
+ // Anchored actions, content and labels; follow the theme's `popover` scope.
302
+ export { Menu, Popover, Tooltip } from "./popover.js";
297
303
  // The semantic text tiers, shared with the built-in widgets.
298
304
  export {
299
305
  textRoleStyle,
@@ -234,6 +234,8 @@ export { List, ListRow, ListHeader, EmptyState } from "./list.native.js";
234
234
  export { Badge, Avatar, Progress, Stat, Divider } from "./detail-parts.native.js";
235
235
  export { initialsFor } from "./initials.js";
236
236
  export { avatarColorFor } from "./avatar-color.js";
237
+ // A stored file's picture: a themed placeholder, then a fallback on failure.
238
+ export { FileImage } from "./file-image.native.js";
237
239
  // The one card surface: the theme's `card` scope, a gradient fill, margin.
238
240
  export { Card } from "./card.native.js";
239
241
  export {
@@ -251,7 +253,7 @@ export {
251
253
  cardGradientProps,
252
254
  } from "./card-style.js";
253
255
  // The themed button; follows the theme's button, destructive and ring scopes.
254
- export { Button, BUTTON_VARIANTS } from "./button.native.js";
256
+ export { Button, IconButton, BUTTON_VARIANTS } from "./button.native.js";
255
257
  export {
256
258
  BUTTON_PADDING_MAX,
257
259
  BUTTON_BORDER_WIDTH_MAX,
@@ -271,6 +273,8 @@ export { Checkbox } from "./checkbox.native.js";
271
273
  export { Switch } from "./switch.native.js";
272
274
  export { Select } from "./select.native.js";
273
275
  export { SearchField } from "./search-field.native.js";
276
+ // Pick one of a short visible list; `muted` scope, or `tabs` when segmented.
277
+ export { RadioGroup } from "./radio-group.native.js";
274
278
  // Space for a control whose role is not a button; a no-op on native.
275
279
  export { spaceActivates } from "./form-control-logic.js";
276
280
  // A themed tab strip; follows the theme's `tabs` scope.
@@ -287,6 +291,8 @@ export { ConfirmDialog } from "./confirm-dialog.native.js";
287
291
  export { Chip, ChipGroup } from "./chip.native.js";
288
292
  // A themed file chooser on `<FilePicker>`; follows the theme's `muted` scope.
289
293
  export { FileUpload } from "./file-upload.native.js";
294
+ // Anchored actions, content and labels; follow the theme's `popover` scope.
295
+ export { Menu, Popover, Tooltip } from "./popover.native.js";
290
296
  // The semantic text tiers, shared with the built-in widgets.
291
297
  export {
292
298
  textRoleStyle,
@@ -0,0 +1,168 @@
1
+ // The ONE implementation behind `<Menu>`: a list of actions opened from a
2
+ // trigger, on the anchored surface `<Popover>` uses. Its rows are `<Select>`'s
3
+ // option rows, so a menu and a dropdown in one app look alike.
4
+
5
+ import React from "react";
6
+ import { useHostTheme } from "./hooks.js";
7
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
8
+ import { Icon } from "./icon.js";
9
+ import { optionRowStyle, resolveFormControlTokens } from "./form-control-tokens.js";
10
+ import { isText, optionKeyDown } from "./form-control-logic.js";
11
+ import { initialFocusIndex } from "./select-logic.js";
12
+ import { POPOVER_DEFAULTS, menuItemList } from "./popover-tokens.js";
13
+ import { triggerWith } from "./popover-view.js";
14
+
15
+ const h = React.createElement;
16
+ const ICON_SIZE = 18;
17
+ const NOTHING_PICKED = () => false;
18
+
19
+ /**
20
+ * Bind the shared menu to one platform's primitives and anchored surface.
21
+ *
22
+ * @param {{View: any, Text: any, Pressable: any, ScrollView: any, Platform: any}} rn
23
+ * @param {any} AnchoredSurface — from `makePopover()` for the same host.
24
+ */
25
+ export function makeMenu(rn, AnchoredSurface) {
26
+ const { Text, Pressable, ScrollView, Platform } = rn;
27
+
28
+ // Keyboard focus is a web concern: a device moves its reader into the
29
+ // sheet by itself, and a programmatic focus there would fight it.
30
+ function focusNode(node) {
31
+ if (Platform.OS === "web" && node && typeof node.focus === "function") node.focus();
32
+ }
33
+
34
+ function itemGlyph(icon, color) {
35
+ if (typeof icon === "string" && icon) return h(Icon, { name: icon, size: ICON_SIZE, color });
36
+ return React.isValidElement(icon) ? icon : null;
37
+ }
38
+
39
+ function MenuItems({ t, items, onPick, accessibilityLabel, testID }) {
40
+ const nodes = React.useRef([]);
41
+ const initial = initialFocusIndex(items, NOTHING_PICKED);
42
+ const pendingFocus = React.useRef(true);
43
+ return h(
44
+ ScrollView,
45
+ {
46
+ style: { flexShrink: 1 },
47
+ role: "menu",
48
+ accessibilityLabel,
49
+ keyboardShouldPersistTaps: "handled",
50
+ testID: testID ? `${testID}-items` : undefined,
51
+ },
52
+ ...items.map((item, index) => {
53
+ // The web host still delivers keys to a disabled Pressable.
54
+ const activate = () => {
55
+ if (!item.disabled) onPick(item);
56
+ };
57
+ const color = item.danger ? t.popover.dangerColor : t.popover.textColor;
58
+ return h(
59
+ Pressable,
60
+ {
61
+ key: item.key,
62
+ ref: (node) => {
63
+ nodes.current[index] = node;
64
+ if (node && index === initial && pendingFocus.current) {
65
+ pendingFocus.current = false;
66
+ focusNode(node);
67
+ }
68
+ },
69
+ onPress: activate,
70
+ onKeyDown: optionKeyDown({
71
+ options: items,
72
+ index,
73
+ focusAt: (next) => focusNode(nodes.current[next]),
74
+ activate,
75
+ }),
76
+ disabled: item.disabled,
77
+ role: "menuitem",
78
+ "aria-disabled": item.disabled,
79
+ accessibilityLabel: item.accessibilityLabel,
80
+ testID: item.testID,
81
+ style: ({ pressed }) => optionRowStyle(t, { pressed, disabled: item.disabled }),
82
+ },
83
+ itemGlyph(item.icon, color),
84
+ h(
85
+ Text,
86
+ { style: { fontFamily: t.fontFamily, fontSize: t.fontSize, color, flexShrink: 1 } },
87
+ item.label,
88
+ ),
89
+ );
90
+ }),
91
+ );
92
+ }
93
+
94
+ /**
95
+ * A list of actions opened from a trigger: a row's "…" button, a toolbar's
96
+ * overflow. Beside the trigger on a wide screen, a bottom sheet on a phone.
97
+ * Each item closes the menu, then runs its `onPress` once the menu has left
98
+ * the screen, so an item can open a dialog of its own.
99
+ *
100
+ * @param {object} props
101
+ * @param {any} props.trigger — the pressable element that opens it.
102
+ * @param {Array<{label: string, onPress?: () => void, icon?: string|any,
103
+ * danger?: boolean, disabled?: boolean, testID?: string,
104
+ * accessibilityLabel?: string, key?: string}|null|false>} props.items —
105
+ * falsy entries are skipped, so `cond && {…}` works.
106
+ * @param {string} [props.accessibilityLabel] — names the menu.
107
+ * @param {"bottom"|"top"} [props.placement] — default "bottom"; flips.
108
+ * @param {string} [props.closeAccessibilityLabel] — the sheet backdrop's name.
109
+ * @param {string} [props.testID] — on the panel; the list takes `-items`.
110
+ */
111
+ function Menu({
112
+ trigger,
113
+ items,
114
+ accessibilityLabel,
115
+ placement = "bottom",
116
+ closeAccessibilityLabel,
117
+ testID,
118
+ }) {
119
+ const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
120
+ const t = resolveFormControlTokens(theme);
121
+ const anchorRef = React.useRef(null);
122
+ const [open, setOpen] = React.useState(false);
123
+ const list = menuItemList(items);
124
+ const close = () => setOpen(false);
125
+ // A dialog opened before the menu is gone would lose focus on the web and
126
+ // fail to present on iOS.
127
+ const picked = React.useRef(null);
128
+ const pick = (item) => {
129
+ picked.current = item.onPress;
130
+ close();
131
+ };
132
+ const runPicked = () => {
133
+ const run = picked.current;
134
+ picked.current = null;
135
+ if (typeof run === "function") run();
136
+ };
137
+
138
+ return h(
139
+ React.Fragment,
140
+ null,
141
+ triggerWith(trigger, {
142
+ anchorRef,
143
+ onOpen: () => setOpen(true),
144
+ extraProps: { "aria-haspopup": "menu", "aria-expanded": open },
145
+ }),
146
+ h(
147
+ AnchoredSurface,
148
+ {
149
+ visible: open && list.length > 0,
150
+ onRequestClose: close,
151
+ onDismiss: runPicked,
152
+ anchorRef,
153
+ placement,
154
+ padding: t.rowGap,
155
+ minWidth: POPOVER_DEFAULTS.menuMinWidth,
156
+ maxWidth: POPOVER_DEFAULTS.menuMaxWidth,
157
+ accessibilityLabel: isText(accessibilityLabel) ? String(accessibilityLabel) : undefined,
158
+ closeAccessibilityLabel,
159
+ testID,
160
+ },
161
+ h(MenuItems, { t, items: list, onPick: pick, accessibilityLabel, testID }),
162
+ ),
163
+ );
164
+ }
165
+
166
+ Menu.displayName = "Menu";
167
+ return Menu;
168
+ }
@@ -85,6 +85,56 @@ export function overlayCornerStyle(radius, presentation) {
85
85
  : { borderRadius: radius };
86
86
  }
87
87
 
88
+ /**
89
+ * The screen box and the panel box of one overlay. An anchored overlay (a
90
+ * menu or popover placed at its trigger) has no scrim, and its panel sits at
91
+ * `anchoredAt`, a window position, instead of in the centre or at the bottom.
92
+ *
93
+ * @param {object} tokens — `resolveOverlayTokens()`'s result.
94
+ * @param {{top: number, left: number}|null} [anchoredAt]
95
+ */
96
+ export function overlayFrameStyles(tokens, anchoredAt) {
97
+ const look = {
98
+ backgroundColor: tokens.surface,
99
+ padding: tokens.padding,
100
+ ...tokens.elevation,
101
+ };
102
+ if (anchoredAt) {
103
+ return {
104
+ backdrop: { flex: 1, backgroundColor: "transparent" },
105
+ panel: {
106
+ position: "absolute",
107
+ top: anchoredAt.top,
108
+ left: anchoredAt.left,
109
+ ...look,
110
+ ...overlayCornerStyle(tokens.radius, "dialog"),
111
+ },
112
+ };
113
+ }
114
+ return {
115
+ backdrop: {
116
+ flex: 1,
117
+ alignItems: "center",
118
+ justifyContent: tokens.verticalAlign,
119
+ padding: tokens.screenPadding,
120
+ ...(tokens.topGap ? { paddingTop: tokens.topGap } : null),
121
+ backgroundColor: tokens.scrim,
122
+ },
123
+ panel: {
124
+ width: "100%",
125
+ // Capped to the padded screen box so a tall panel scrolls inside its own
126
+ // ScrollView instead of overflowing past the viewport unreachably.
127
+ maxHeight: "100%",
128
+ flexShrink: 1,
129
+ ...(tokens.maxWidth ? { maxWidth: tokens.maxWidth } : { flex: 1 }),
130
+ backgroundColor: look.backgroundColor,
131
+ ...overlayCornerStyle(tokens.radius, tokens.presentation),
132
+ padding: look.padding,
133
+ ...tokens.elevation,
134
+ },
135
+ };
136
+ }
137
+
88
138
  /**
89
139
  * Resolve the values one overlay is painted with, from the workspace theme.
90
140
  *