@dxos/react-ui-menu 0.9.0 → 0.9.1-main.c7dcc2e112

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 (47) hide show
  1. package/dist/lib/browser/index.mjs +107 -42
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/browser/translations.mjs +3 -1
  5. package/dist/lib/browser/translations.mjs.map +2 -2
  6. package/dist/lib/node-esm/index.mjs +107 -42
  7. package/dist/lib/node-esm/index.mjs.map +4 -4
  8. package/dist/lib/node-esm/meta.json +1 -1
  9. package/dist/lib/node-esm/translations.mjs +3 -1
  10. package/dist/lib/node-esm/translations.mjs.map +2 -2
  11. package/dist/types/src/builder.d.ts +11 -3
  12. package/dist/types/src/builder.d.ts.map +1 -1
  13. package/dist/types/src/components/ActionLabel.d.ts +2 -2
  14. package/dist/types/src/components/ActionLabel.d.ts.map +1 -1
  15. package/dist/types/src/components/Menu.d.ts +3 -3
  16. package/dist/types/src/components/Menu.d.ts.map +1 -1
  17. package/dist/types/src/components/Menu.stories.d.ts +3 -1
  18. package/dist/types/src/components/Menu.stories.d.ts.map +1 -1
  19. package/dist/types/src/components/ToolbarMenu.d.ts +14 -12
  20. package/dist/types/src/components/ToolbarMenu.d.ts.map +1 -1
  21. package/dist/types/src/components/ToolbarMenu.stories.d.ts +3 -1
  22. package/dist/types/src/components/ToolbarMenu.stories.d.ts.map +1 -1
  23. package/dist/types/src/hooks/index.d.ts +1 -0
  24. package/dist/types/src/hooks/index.d.ts.map +1 -1
  25. package/dist/types/src/hooks/useGraphMenuActions.d.ts +31 -0
  26. package/dist/types/src/hooks/useGraphMenuActions.d.ts.map +1 -0
  27. package/dist/types/src/hooks/useGraphMenuActions.test.d.ts +2 -0
  28. package/dist/types/src/hooks/useGraphMenuActions.test.d.ts.map +1 -0
  29. package/dist/types/src/translations.d.ts +3 -1
  30. package/dist/types/src/translations.d.ts.map +1 -1
  31. package/dist/types/src/types.d.ts +7 -11
  32. package/dist/types/src/types.d.ts.map +1 -1
  33. package/dist/types/src/util.d.ts +7 -3
  34. package/dist/types/src/util.d.ts.map +1 -1
  35. package/dist/types/tsconfig.tsbuildinfo +1 -1
  36. package/package.json +15 -15
  37. package/src/builder.test.ts +50 -11
  38. package/src/builder.ts +29 -5
  39. package/src/components/ActionLabel.tsx +2 -2
  40. package/src/components/Menu.tsx +5 -10
  41. package/src/components/ToolbarMenu.tsx +65 -65
  42. package/src/hooks/index.ts +1 -0
  43. package/src/hooks/useGraphMenuActions.test.ts +93 -0
  44. package/src/hooks/useGraphMenuActions.ts +96 -0
  45. package/src/translations.ts +3 -1
  46. package/src/types.ts +9 -6
  47. package/src/util.ts +1 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-menu",
3
- "version": "0.9.0",
3
+ "version": "0.9.1-main.c7dcc2e112",
4
4
  "description": "Components for facilitating reactive and consistent menus.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -37,14 +37,14 @@
37
37
  "dependencies": {
38
38
  "@radix-ui/react-context": "1.1.1",
39
39
  "@radix-ui/react-use-controllable-state": "1.1.0",
40
- "@dxos/app-graph": "0.9.0",
41
- "@dxos/keyboard": "0.9.0",
42
- "@dxos/invariant": "0.9.0",
43
- "@dxos/react-ui-attention": "0.9.0",
44
- "@dxos/log": "0.9.0",
45
- "@dxos/effect": "0.9.0",
46
- "@dxos/ui-types": "0.9.0",
47
- "@dxos/util": "0.9.0"
40
+ "@dxos/app-graph": "0.9.1-main.c7dcc2e112",
41
+ "@dxos/effect": "0.9.1-main.c7dcc2e112",
42
+ "@dxos/invariant": "0.9.1-main.c7dcc2e112",
43
+ "@dxos/log": "0.9.1-main.c7dcc2e112",
44
+ "@dxos/react-ui-attention": "0.9.1-main.c7dcc2e112",
45
+ "@dxos/ui-types": "0.9.1-main.c7dcc2e112",
46
+ "@dxos/util": "0.9.1-main.c7dcc2e112",
47
+ "@dxos/keyboard": "0.9.1-main.c7dcc2e112"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@effect-atom/atom-react": "^0.5.0",
@@ -57,10 +57,10 @@
57
57
  "react": "~19.2.3",
58
58
  "react-dom": "~19.2.3",
59
59
  "vite": "^8.0.16",
60
- "@dxos/random": "0.9.0",
61
- "@dxos/react-ui": "0.9.0",
62
- "@dxos/storybook-utils": "0.9.0",
63
- "@dxos/ui-theme": "0.9.0"
60
+ "@dxos/react-ui": "0.9.1-main.c7dcc2e112",
61
+ "@dxos/random": "0.9.1-main.c7dcc2e112",
62
+ "@dxos/ui-theme": "0.9.1-main.c7dcc2e112",
63
+ "@dxos/storybook-utils": "0.9.1-main.c7dcc2e112"
64
64
  },
65
65
  "peerDependencies": {
66
66
  "@effect-atom/atom-react": "^0.5.0",
@@ -68,8 +68,8 @@
68
68
  "effect": "3.21.3",
69
69
  "react": "~19.2.3",
70
70
  "react-dom": "~19.2.3",
71
- "@dxos/react-ui": "0.9.0",
72
- "@dxos/ui-theme": "0.9.0"
71
+ "@dxos/ui-theme": "0.9.1-main.c7dcc2e112",
72
+ "@dxos/react-ui": "0.9.1-main.c7dcc2e112"
73
73
  },
74
74
  "publishConfig": {
75
75
  "access": "public"
@@ -74,7 +74,7 @@ describe('MenuBuilder', () => {
74
74
  test('merges pre-built subgraph', ({ expect }) => {
75
75
  const headings = MenuBuilder.make()
76
76
  .root({ label: 'headings' })
77
- .group('headings', { label: 'Headings', variant: 'dropdownMenu' }, (group) => {
77
+ .group('headings', { label: 'Headings', variant: 'dropdownMenu', icon: 'ph--text-h-one--regular' }, (group) => {
78
78
  group
79
79
  .action('h1', { label: 'H1', icon: 'ph--text-h-one--regular' }, () => {})
80
80
  .action('h2', { label: 'H2', icon: 'ph--text-h-two--regular' }, () => {});
@@ -115,11 +115,15 @@ describe('MenuBuilder', () => {
115
115
  .root({ label: 'toolbar' })
116
116
  .subgraph((sub) => {
117
117
  sub
118
- .group('headings', { label: 'Headings', variant: 'dropdownMenu' }, (group) => {
119
- group
120
- .action('h1', { label: 'H1', icon: 'ph--text-h-one--regular' }, () => {})
121
- .action('h2', { label: 'H2', icon: 'ph--text-h-two--regular' }, () => {});
122
- })
118
+ .group(
119
+ 'headings',
120
+ { label: 'Headings', variant: 'dropdownMenu', icon: 'ph--text-h-one--regular' },
121
+ (group) => {
122
+ group
123
+ .action('h1', { label: 'H1', icon: 'ph--text-h-one--regular' }, () => {})
124
+ .action('h2', { label: 'H2', icon: 'ph--text-h-two--regular' }, () => {});
125
+ },
126
+ )
123
127
  .group('formatting', { label: 'Formatting' }, (group) => {
124
128
  group.action('bold', { label: 'Bold', icon: 'ph--text-b--regular' }, () => {});
125
129
  });
@@ -160,11 +164,15 @@ describe('MenuBuilder', () => {
160
164
  test('composable curried subgraph functions', ({ expect }) => {
161
165
  // Simulates the curried pattern: addX(args) returns (builder) => void.
162
166
  const addHeadingsSection = () => (builder: import('./builder').ActionGroupBuilder) => {
163
- builder.group('headings', { label: 'Headings', variant: 'dropdownMenu' }, (group) => {
164
- group
165
- .action('h1', { label: 'H1', icon: 'ph--text-h-one--regular' }, () => {})
166
- .action('h2', { label: 'H2', icon: 'ph--text-h-two--regular' }, () => {});
167
- });
167
+ builder.group(
168
+ 'headings',
169
+ { label: 'Headings', variant: 'dropdownMenu', icon: 'ph--text-h-one--regular' },
170
+ (group) => {
171
+ group
172
+ .action('h1', { label: 'H1', icon: 'ph--text-h-one--regular' }, () => {})
173
+ .action('h2', { label: 'H2', icon: 'ph--text-h-two--regular' }, () => {});
174
+ },
175
+ );
168
176
  };
169
177
 
170
178
  const addFormattingSection = () => (builder: import('./builder').ActionGroupBuilder) => {
@@ -206,6 +214,37 @@ describe('MenuBuilder', () => {
206
214
  expect(graph.edges).toHaveLength(1);
207
215
  });
208
216
 
217
+ test('menu creates overflow dropdown with built-in trigger', ({ expect }) => {
218
+ const graph = MenuBuilder.make()
219
+ .root({ label: 'toolbar' })
220
+ .menu('more', (group) => {
221
+ group.action('save', { label: 'Save', icon: 'ph--cloud-arrow-up--regular' }, () => {});
222
+ })
223
+ .build();
224
+
225
+ const moreGroup = graph.nodes.find((node) => node.id === 'more');
226
+ expect(moreGroup).toBeDefined();
227
+ expect(moreGroup!.properties).toEqual({
228
+ label: ['toolbar-overflow.menu', { ns: '@dxos/react-ui-menu' }],
229
+ icon: 'ph--dots-three-vertical--regular',
230
+ iconOnly: true,
231
+ variant: 'dropdownMenu',
232
+ caretDown: false,
233
+ });
234
+ expect(graph.edges).toContainEqual({ source: 'root', target: 'more', relation: 'child' });
235
+ expect(graph.edges).toContainEqual({ source: 'more', target: 'save', relation: 'child' });
236
+ });
237
+
238
+ test('menu accepts custom group id', ({ expect }) => {
239
+ const graph = MenuBuilder.make()
240
+ .root({ label: 'toolbar' })
241
+ .menu('overflow', () => {})
242
+ .build();
243
+
244
+ expect(graph.nodes.find((node) => node.id === 'overflow')).toBeDefined();
245
+ expect(graph.nodes.find((node) => node.id === 'more')).toBeUndefined();
246
+ });
247
+
209
248
  test('nodes have correct types', ({ expect }) => {
210
249
  const graph = MenuBuilder.make()
211
250
  .root({ label: 'toolbar' })
package/src/builder.ts CHANGED
@@ -3,12 +3,23 @@
3
3
  //
4
4
 
5
5
  import { invariant } from '@dxos/invariant';
6
- import type { MenuActionProperties, MenuItemGroupProperties } from '@dxos/ui-types';
6
+ import type { DropdownMenuItemGroupProperties, MenuActionProperties, MenuItemGroupProperties } from '@dxos/ui-types';
7
+
8
+ import { translationKey } from '#translations';
7
9
 
8
10
  import type { ActionGraphProps } from './hooks';
9
11
  import { MenuSeparatorType } from './types';
10
12
  import { createMenuAction, createMenuItemGroup } from './util';
11
13
 
14
+ /** Default trigger for toolbar overflow (⋮) dropdown menus. */
15
+ const overflowMenuProperties = {
16
+ label: ['toolbar-overflow.menu', { ns: translationKey }],
17
+ icon: 'ph--dots-three-vertical--regular',
18
+ iconOnly: true,
19
+ caretDown: false,
20
+ variant: 'dropdownMenu',
21
+ } as const satisfies DropdownMenuItemGroupProperties;
22
+
12
23
  /** Callback that populates an action group builder. */
13
24
  export type ActionGroupBuilderFn = (builder: ActionGroupBuilder) => void;
14
25
 
@@ -31,7 +42,7 @@ export interface ActionGroupBuilder {
31
42
  action<P extends {} = {}>(id: string, props: P & MenuActionProperties, invoke: () => void): this;
32
43
 
33
44
  /** Add a nested action group. */
34
- group<P extends {} = {}>(id: string, props: P & MenuItemGroupProperties, cb: ActionGroupBuilderFn): this;
45
+ group(id: string, props: MenuItemGroupProperties, cb: ActionGroupBuilderFn): this;
35
46
 
36
47
  /** Merge pre-built nodes and edges, or build a subgraph via callback scoped to the current group. Falsy values are ignored. */
37
48
  subgraph(subgraphOrCb: ActionGraphProps | ActionGroupBuilderFn | false | null | undefined): this;
@@ -42,10 +53,19 @@ export interface ActionGroupBuilder {
42
53
 
43
54
  /** Top-level builder that creates the root group and produces the final action graph. */
44
55
  export interface MenuBuilder extends ActionGroupBuilder {
45
- /** Set properties on the root menu group. May only be called once. */
56
+ /**
57
+ * Set properties on the root menu group. May only be called once.
58
+ */
46
59
  root(props: MenuItemGroupProperties): this;
47
60
 
48
- /** Return the assembled action graph. */
61
+ /**
62
+ * Add a root-level toolbar overflow dropdown (⋮) with built-in trigger styling.
63
+ */
64
+ menu(id: string, cb: ActionGroupBuilderFn): this;
65
+
66
+ /**
67
+ * Return the assembled action graph.
68
+ */
49
69
  build(): ActionGraphProps;
50
70
  }
51
71
 
@@ -74,13 +94,17 @@ class MenuBuilderImpl implements MenuBuilder {
74
94
  return this;
75
95
  }
76
96
 
77
- group<P extends {} = {}>(id: string, props: P & MenuItemGroupProperties, cb: ActionGroupBuilderFn): this {
97
+ group(id: string, props: MenuItemGroupProperties, cb: ActionGroupBuilderFn): this {
78
98
  this._data.nodes.push(createMenuItemGroup(id, props));
79
99
  this._data.edges.push({ source: this._rootId, target: id, relation: 'child' });
80
100
  cb(new MenuBuilderImpl(this._data, id));
81
101
  return this;
82
102
  }
83
103
 
104
+ menu(id: string, cb: ActionGroupBuilderFn): this {
105
+ return this.group(id, overflowMenuProperties, cb);
106
+ }
107
+
84
108
  subgraph(subgraphOrCb: ActionGraphProps | ActionGroupBuilderFn | false | null | undefined): this {
85
109
  if (!subgraphOrCb) {
86
110
  return this;
@@ -8,13 +8,13 @@ import { type Node } from '@dxos/app-graph';
8
8
  import { keySymbols } from '@dxos/keyboard';
9
9
  import { type TFunction, toLocalizedString, useTranslation } from '@dxos/react-ui';
10
10
  import { mx } from '@dxos/ui-theme';
11
- import { type MenuActionProperties } from '@dxos/ui-types';
11
+ import { type MenuActionProperties, type MenuItemChrome } from '@dxos/ui-types';
12
12
 
13
13
  import { translationKey } from '#translations';
14
14
 
15
15
  import { getShortcut } from '../util';
16
16
 
17
- type Action = Node.Action<MenuActionProperties> | Node.ActionGroup<Omit<MenuActionProperties, 'variant'>>;
17
+ type Action = Node.Action<MenuActionProperties> | Node.ActionGroup<MenuItemChrome>;
18
18
 
19
19
  export const ActionLabel = ({ action }: { action: Action }) => {
20
20
  const { t } = useTranslation(translationKey);
@@ -14,8 +14,8 @@ import {
14
14
  type AddMenuItemsProps,
15
15
  type MenuAction,
16
16
  type MenuContextValue,
17
+ type MenuGroupContext,
17
18
  type MenuItem,
18
- type MenuItemGroup,
19
19
  type MenuItems,
20
20
  type MenuItemsAccessor,
21
21
  type MenuItemsMap,
@@ -70,12 +70,7 @@ const MenuDropdownContext = createContext<MenuDropdownContextValue>({
70
70
  const DEFAULT_PRIORITY = 100;
71
71
 
72
72
  const sortMenuItems = (items: MenuItems[]) =>
73
- [...items].sort((a, b) => {
74
- if (a.priority !== b.priority) {
75
- return a.priority - b.priority;
76
- }
77
- return a.id.localeCompare(b.id);
78
- });
73
+ [...items].sort((a, b) => (a.priority !== b.priority ? a.priority - b.priority : a.id.localeCompare(b.id)));
79
74
 
80
75
  type MenuProviderProps = PropsWithChildren<Partial<MenuContextValue>>;
81
76
 
@@ -135,7 +130,7 @@ const MenuProvider = ({
135
130
 
136
131
  const resolveItems = (
137
132
  baseItems: MenuItem[] | null,
138
- group: MenuItemGroup | undefined,
133
+ group: MenuGroupContext | undefined,
139
134
  entries: ReadonlyMap<string, MenuItems>,
140
135
  ): MenuItem[] | null => {
141
136
  const applicable = [...entries.values()].filter((entry) => !entry.groupFilter || entry.groupFilter(group));
@@ -171,7 +166,7 @@ const resolveItems = (
171
166
  //
172
167
 
173
168
  const useMenuItems = (
174
- group?: MenuItemGroup,
169
+ group?: MenuGroupContext,
175
170
  propsItems?: MenuItem[],
176
171
  consumerName: string = 'useMenuItemConsumer',
177
172
  __menuScope?: Scope,
@@ -238,7 +233,7 @@ const MenuRoot = ({ children, open, defaultOpen, onOpenChange, caller, ...props
238
233
  //
239
234
 
240
235
  type MenuContentProps = {
241
- group?: MenuItemGroup;
236
+ group?: MenuGroupContext;
242
237
  items?: MenuItem[];
243
238
  caller?: string;
244
239
  };
@@ -4,42 +4,24 @@
4
4
 
5
5
  import React, { useCallback } from 'react';
6
6
 
7
- import { Icon, Toolbar as NaturalToolbar, type ToolbarRootProps, useTranslation } from '@dxos/react-ui';
7
+ import { Toolbar as NaturalToolbar, type ToolbarRootProps, useTranslation } from '@dxos/react-ui';
8
8
  import { composable, composableProps } from '@dxos/react-ui';
9
9
  import { useAttention } from '@dxos/react-ui-attention';
10
- import { type MenuActionProperties } from '@dxos/ui-types';
10
+ import { type DropdownMenuItemGroupProperties, type ToggleGroupMenuItemGroupProperties } from '@dxos/ui-types';
11
11
 
12
12
  import { translationKey } from '#translations';
13
13
 
14
- import {
15
- type MenuAction,
16
- type MenuItem,
17
- type MenuItemGroup,
18
- type MenuMultipleSelectActionGroup,
19
- type MenuSingleSelectActionGroup,
20
- isMenuGroup,
21
- isSeparator,
22
- } from '../types';
14
+ import { type MenuAction, type MenuItem, type MenuItemGroup, isMenuGroup, isSeparator } from '../types';
23
15
  import { executeMenuAction } from '../util';
24
16
  import { ActionLabel, actionLabel } from './ActionLabel';
25
17
  import { DropdownMenu } from './DropdownMenu';
26
18
  import { type MenuScopedProps, useMenuItems, useMenuScoped } from './Menu';
27
19
 
28
- export type ToolbarMenuDropdownMenuActionGroup = Omit<MenuActionProperties, 'variant' | 'icon'> & {
29
- variant: 'dropdownMenu';
30
- icon: string;
31
- applyActive?: boolean;
32
- };
20
+ export type ToolbarMenuDropdownMenuActionGroup = DropdownMenuItemGroupProperties;
33
21
 
34
- export type ToolbarMenuToggleGroupActionGroup = Omit<MenuActionProperties, 'variant'> & {
35
- variant: 'toggleGroup';
36
- };
22
+ export type ToolbarMenuToggleGroupActionGroup = ToggleGroupMenuItemGroupProperties;
37
23
 
38
- export type ToolbarMenuActionGroupProperties = (
39
- | ToolbarMenuDropdownMenuActionGroup
40
- | ToolbarMenuToggleGroupActionGroup
41
- ) &
42
- (MenuSingleSelectActionGroup | MenuMultipleSelectActionGroup);
24
+ export type ToolbarMenuActionGroupProperties = DropdownMenuItemGroupProperties | ToggleGroupMenuItemGroupProperties;
43
25
 
44
26
  export type ToolbarMenuProps = ToolbarRootProps;
45
27
 
@@ -48,8 +30,18 @@ export type ToolbarMenuActionGroupProps = {
48
30
  items?: MenuItem[];
49
31
  };
50
32
 
33
+ export type ToolbarMenuDropdownGroupProps = {
34
+ group: MenuItemGroup<DropdownMenuItemGroupProperties>;
35
+ items?: MenuItem[];
36
+ };
37
+
38
+ export type ToolbarMenuToggleGroupProps = {
39
+ group: MenuItemGroup<ToggleGroupMenuItemGroupProperties>;
40
+ items?: MenuItem[];
41
+ };
42
+
51
43
  export type ToolbarMenuActionProps = {
52
- group: MenuItemGroup<ToolbarMenuActionGroupProperties>;
44
+ group: MenuItemGroup<ToggleGroupMenuItemGroupProperties>;
53
45
  action: MenuAction;
54
46
  };
55
47
 
@@ -102,46 +94,54 @@ const DropdownMenuToolbarItem = ({
102
94
  __menuScope,
103
95
  group,
104
96
  items: propsItems,
105
- }: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
106
- const { iconOnly, disabled, testId } = group.properties;
97
+ }: MenuScopedProps<ToolbarMenuDropdownGroupProps>) => {
107
98
  const { t } = useTranslation(translationKey);
108
99
  const { iconSize } = useMenuScoped('DropdownMenuToolbarItem', __menuScope);
109
100
  const items = useMenuItems(group, propsItems, 'DropdownMenuToolbarItem', __menuScope);
110
- const activeItem = items?.find((item) => !!(item as MenuAction).properties.checked);
101
+ const {
102
+ iconOnly,
103
+ disabled,
104
+ testId,
105
+ applyActive,
106
+ caretDown = true,
107
+ icon: groupIcon,
108
+ iconClassNames: groupIconClassNames,
109
+ } = group.properties;
110
+ const activeItem = items?.find((item) => !!(item as MenuAction).properties.checked) as MenuAction | undefined;
111
111
  const icon =
112
- ((group.properties as any).applyActive &&
112
+ (applyActive &&
113
113
  // TODO(thure): Handle other menu item types.
114
- (activeItem as MenuAction)?.properties.icon) ||
115
- group.properties.icon;
116
- // Follow the same `applyActive` rule for `iconClassNames` so a per-item
117
- // accent (e.g. tag colour) tracks the displayed icon.
118
- const iconClassNames =
119
- ((group.properties as any).applyActive && (activeItem as MenuAction)?.properties.iconClassNames) ||
120
- (group.properties as { iconClassNames?: any }).iconClassNames;
121
- const Root = icon ? NaturalToolbar.IconButton : NaturalToolbar.Button;
122
- const labelAction = (group.properties as any).applyActive && activeItem ? (activeItem as MenuAction) : group;
123
- const rootProps = icon
124
- ? {
125
- icon,
126
- size: iconSize,
127
- iconOnly,
128
- iconClassNames,
129
- label: actionLabel(labelAction, t),
130
- caretDown: true,
131
- }
132
- : {
133
- children: (
134
- <>
135
- <ActionLabel action={labelAction} />
136
- <Icon size={3} icon='ph--caret-down--bold' classNames='ms-1' />
137
- </>
138
- ),
139
- };
114
+ activeItem?.properties.icon) ||
115
+ groupIcon;
116
+ // Follow the same `applyActive` rule for `iconClassNames` so a per-item accent (e.g. tag colour) tracks the displayed icon.
117
+ const iconClassNames = (applyActive && activeItem?.properties.iconClassNames) || groupIconClassNames;
118
+ const labelAction = applyActive && activeItem ? activeItem : group;
140
119
 
141
120
  return (
142
121
  <DropdownMenu.Root group={group} items={items}>
143
122
  <DropdownMenu.Trigger asChild>
144
- <Root variant='ghost' disabled={disabled} {...(rootProps as any)} {...(testId && { 'data-testid': testId })} />
123
+ {icon ? (
124
+ <NaturalToolbar.IconButton
125
+ variant='ghost'
126
+ disabled={disabled}
127
+ icon={icon}
128
+ size={iconSize}
129
+ iconOnly={iconOnly}
130
+ iconClassNames={iconClassNames}
131
+ label={actionLabel(labelAction, t)}
132
+ caretDown={caretDown}
133
+ {...(testId && { 'data-testid': testId })}
134
+ />
135
+ ) : (
136
+ <NaturalToolbar.Button
137
+ variant='ghost'
138
+ disabled={disabled}
139
+ caretDown={caretDown}
140
+ {...(testId && { 'data-testid': testId })}
141
+ >
142
+ <ActionLabel action={labelAction} />
143
+ </NaturalToolbar.Button>
144
+ )}
145
145
  </DropdownMenu.Trigger>
146
146
  </DropdownMenu.Root>
147
147
  );
@@ -189,7 +189,7 @@ const ToggleGroupToolbarItem = ({
189
189
  __menuScope,
190
190
  group,
191
191
  items: itemsProp,
192
- }: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
192
+ }: MenuScopedProps<ToolbarMenuToggleGroupProps>) => {
193
193
  const items = useMenuItems(group, itemsProp, 'ToggleGroupToolbarItem', __menuScope);
194
194
  const { selectCardinality } = group.properties;
195
195
 
@@ -244,17 +244,17 @@ const ToolbarMenuItem = ({ __menuScope, item }: MenuScopedProps<{ item: MenuItem
244
244
  return (
245
245
  <DropdownMenuToolbarItem
246
246
  __menuScope={__menuScope}
247
- group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>}
248
- />
249
- );
250
- } else {
251
- return (
252
- <ToggleGroupToolbarItem
253
- __menuScope={__menuScope}
254
- group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>}
247
+ group={item as MenuItemGroup<DropdownMenuItemGroupProperties>}
255
248
  />
256
249
  );
257
250
  }
251
+
252
+ return (
253
+ <ToggleGroupToolbarItem
254
+ __menuScope={__menuScope}
255
+ group={item as MenuItemGroup<ToggleGroupMenuItemGroupProperties>}
256
+ />
257
+ );
258
258
  }
259
259
 
260
260
  return <ActionToolbarItem __menuScope={__menuScope} action={item as MenuAction} />;
@@ -2,4 +2,5 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
+ export * from './useGraphMenuActions';
5
6
  export * from './useMenuActions';
@@ -0,0 +1,93 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+ import { describe, test } from 'vitest';
7
+
8
+ import { Node } from '@dxos/app-graph';
9
+
10
+ import { buildGraphMenu } from './useGraphMenuActions';
11
+
12
+ const action = (id: string, properties: Record<string, any> = {}): Node.ActionLike => ({
13
+ id,
14
+ type: Node.ActionType,
15
+ properties,
16
+ data: () => Effect.void,
17
+ });
18
+
19
+ const group = (id: string, properties: Record<string, any> = {}): Node.ActionLike => ({
20
+ id,
21
+ type: Node.ActionGroupType,
22
+ properties,
23
+ data: Node.actionGroupSymbol,
24
+ });
25
+
26
+ const resolver =
27
+ (map: Record<string, Node.ActionLike[]>) =>
28
+ (id: string): Node.ActionLike[] =>
29
+ map[id] ?? [];
30
+
31
+ describe('buildGraphMenu', () => {
32
+ test('flattens a node’s actions under the menu root', ({ expect }) => {
33
+ const resolve = resolver({ event: [action('delete'), action('save')] });
34
+ const { nodes, edges } = buildGraphMenu(resolve, 'event');
35
+
36
+ expect(nodes.map((node) => node.id)).toEqual(['delete', 'save']);
37
+ expect(edges).toEqual([
38
+ { source: Node.RootId, target: 'delete', relation: 'child' },
39
+ { source: Node.RootId, target: 'save', relation: 'child' },
40
+ ]);
41
+ });
42
+
43
+ test('expands nested action groups via their own id', ({ expect }) => {
44
+ const resolve = resolver({
45
+ event: [action('delete'), group('more')],
46
+ more: [action('archive'), action('export')],
47
+ });
48
+ const { nodes, edges } = buildGraphMenu(resolve, 'event');
49
+
50
+ // root → delete, root → more (group), more → archive, more → export.
51
+ expect(nodes.map((node) => node.id)).toEqual(['delete', 'more', 'archive', 'export']);
52
+ expect(edges).toEqual([
53
+ { source: Node.RootId, target: 'delete', relation: 'child' },
54
+ { source: Node.RootId, target: 'more', relation: 'child' },
55
+ { source: 'more', target: 'archive', relation: 'child' },
56
+ { source: 'more', target: 'export', relation: 'child' },
57
+ ]);
58
+ });
59
+
60
+ test('roots the actions at a supplied group', ({ expect }) => {
61
+ const resolve = resolver({ event: [action('delete')] });
62
+ const { edges } = buildGraphMenu(resolve, 'event', { rootId: 'overflow' });
63
+
64
+ expect(edges).toEqual([{ source: 'overflow', target: 'delete', relation: 'child' }]);
65
+ });
66
+
67
+ test('filters actions by predicate', ({ expect }) => {
68
+ const resolve = resolver({
69
+ event: [action('delete', { disposition: 'menu' }), action('hidden', { disposition: 'item' })],
70
+ });
71
+ const { nodes } = buildGraphMenu(resolve, 'event', {
72
+ filter: (node) => node.properties.disposition === 'menu',
73
+ });
74
+
75
+ expect(nodes.map((node) => node.id)).toEqual(['delete']);
76
+ });
77
+
78
+ test('keeps a shared action’s node once but records each edge, tolerating cycles', ({ expect }) => {
79
+ // `more` references itself; the node is emitted once while both inbound edges survive.
80
+ const resolve = resolver({
81
+ event: [group('more')],
82
+ more: [action('archive'), group('more')],
83
+ });
84
+ const { nodes, edges } = buildGraphMenu(resolve, 'event');
85
+
86
+ expect(nodes.map((node) => node.id)).toEqual(['more', 'archive']);
87
+ expect(edges).toEqual([
88
+ { source: Node.RootId, target: 'more', relation: 'child' },
89
+ { source: 'more', target: 'archive', relation: 'child' },
90
+ { source: 'more', target: 'more', relation: 'child' },
91
+ ]);
92
+ });
93
+ });
@@ -0,0 +1,96 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { Atom } from '@effect-atom/atom-react';
6
+ import { useMemo } from 'react';
7
+
8
+ import { type Graph, Node } from '@dxos/app-graph';
9
+
10
+ import {
11
+ type ActionGraphEdges,
12
+ type ActionGraphNodes,
13
+ type ActionGraphProps,
14
+ type MenuActions,
15
+ useMenuActions,
16
+ } from './useMenuActions';
17
+
18
+ export type GraphMenuOptions = {
19
+ /** Group the actions descend from. Defaults to the menu root (top-level toolbar items). */
20
+ rootId?: string;
21
+ /** Keep only the actions the predicate accepts (e.g. by `disposition`). */
22
+ filter?: (action: Node.ActionLike) => boolean;
23
+ };
24
+
25
+ /**
26
+ * Flatten a node's contributed actions (and nested {@link Node.ActionGroup}s) into the
27
+ * `{ nodes, edges }` an {@link useMenuActions} / `Menu` consumes. The action nodes are spliced
28
+ * verbatim, so their Effect `data` (and captured `_actionContext`) execute via `executeMenuAction`
29
+ * without any `onAction` wiring.
30
+ *
31
+ * Pure over a `resolve` function so it is testable without a live graph; `resolve(id)` returns the
32
+ * direct actions for `id` (a node id at the top level, a group id when expanding a group).
33
+ */
34
+ export const buildGraphMenu = (
35
+ resolve: (id: string) => Node.ActionLike[],
36
+ nodeId: string,
37
+ options: GraphMenuOptions = {},
38
+ ): ActionGraphProps => {
39
+ const { rootId = Node.RootId, filter } = options;
40
+ const nodes: ActionGraphNodes = [];
41
+ const edges: ActionGraphEdges = [];
42
+ const seen = new Set<string>();
43
+
44
+ const visit = (parentId: string, sourceId: string) => {
45
+ for (const action of resolve(sourceId)) {
46
+ if (filter && !filter(action)) {
47
+ continue;
48
+ }
49
+ // Always record the parent→child edge (a shared action may sit under multiple groups).
50
+ edges.push({ source: parentId, target: action.id, relation: 'child' });
51
+ if (seen.has(action.id)) {
52
+ continue;
53
+ }
54
+ seen.add(action.id);
55
+ nodes.push(action as ActionGraphNodes[number]);
56
+ // A group's children are the actions contributed for the group's own id.
57
+ if (Node.isActionGroup(action)) {
58
+ visit(action.id, action.id);
59
+ }
60
+ }
61
+ };
62
+
63
+ visit(rootId, nodeId);
64
+ return { nodes, edges };
65
+ };
66
+
67
+ /**
68
+ * Read a node's contributed actions from the app-graph as menu `ActionGraphProps`, for splicing into a
69
+ * hand-built toolbar via `MenuBuilder.subgraph(graphActions(graph, get, nodeId))`. Reactive: reads the
70
+ * graph's action atoms through `get`, so contributed actions appear/disappear as extensions update.
71
+ */
72
+ export const graphActions = (
73
+ graph: Graph.ReadableGraph | undefined,
74
+ get: Atom.Context,
75
+ nodeId: string | undefined,
76
+ options?: GraphMenuOptions,
77
+ ): ActionGraphProps =>
78
+ graph && nodeId ? buildGraphMenu((id) => get(graph.actions(id)), nodeId, options) : { nodes: [], edges: [] };
79
+
80
+ /**
81
+ * Menu actions sourced entirely from a node's app-graph contributions — the common "show every action
82
+ * the graph offers for this object" toolbar/menu. Compose with hand-built items via {@link graphActions}.
83
+ */
84
+ export const useGraphMenuActions = (
85
+ graph: Graph.ReadableGraph | undefined,
86
+ nodeId: string | undefined,
87
+ options?: GraphMenuOptions,
88
+ ): MenuActions => {
89
+ const atom = useMemo(
90
+ () => Atom.make((get) => graphActions(graph, get, nodeId, options)),
91
+ // `options` is expected to be stable; `rootId` participates since it reshapes the graph.
92
+ // eslint-disable-next-line react-hooks/exhaustive-deps
93
+ [graph, nodeId, options?.rootId],
94
+ );
95
+ return useMenuActions(atom);
96
+ };