@dxos/react-ui-menu 0.9.0 → 0.9.1-staging.ee54ba693a

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 (51) hide show
  1. package/dist/lib/browser/index.mjs +156 -43
  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 +156 -43
  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 +15 -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 +4 -4
  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 +7 -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/index.d.ts +1 -1
  30. package/dist/types/src/index.d.ts.map +1 -1
  31. package/dist/types/src/translations.d.ts +3 -1
  32. package/dist/types/src/translations.d.ts.map +1 -1
  33. package/dist/types/src/types.d.ts +7 -11
  34. package/dist/types/src/types.d.ts.map +1 -1
  35. package/dist/types/src/util.d.ts +8 -3
  36. package/dist/types/src/util.d.ts.map +1 -1
  37. package/dist/types/tsconfig.tsbuildinfo +1 -1
  38. package/package.json +25 -25
  39. package/src/builder.test.ts +50 -11
  40. package/src/builder.ts +36 -5
  41. package/src/components/ActionLabel.tsx +2 -2
  42. package/src/components/Menu.tsx +7 -12
  43. package/src/components/ToolbarMenu.stories.tsx +31 -2
  44. package/src/components/ToolbarMenu.tsx +125 -66
  45. package/src/hooks/index.ts +1 -0
  46. package/src/hooks/useGraphMenuActions.test.ts +93 -0
  47. package/src/hooks/useGraphMenuActions.ts +96 -0
  48. package/src/index.ts +1 -1
  49. package/src/translations.ts +3 -1
  50. package/src/types.ts +9 -6
  51. 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-staging.ee54ba693a",
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,39 +37,39 @@
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-staging.ee54ba693a",
41
+ "@dxos/invariant": "0.9.1-staging.ee54ba693a",
42
+ "@dxos/keyboard": "0.9.1-staging.ee54ba693a",
43
+ "@dxos/effect": "0.9.1-staging.ee54ba693a",
44
+ "@dxos/react-ui-attention": "0.9.1-staging.ee54ba693a",
45
+ "@dxos/util": "0.9.1-staging.ee54ba693a",
46
+ "@dxos/log": "0.9.1-staging.ee54ba693a",
47
+ "@dxos/ui-types": "0.9.1-staging.ee54ba693a"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@effect-atom/atom-react": "^0.5.0",
51
- "@effect/platform": "0.96.1",
51
+ "@effect/platform": "0.96.2",
52
52
  "@testing-library/jest-dom": "^6.9.1",
53
- "@testing-library/react": "^16.3.0",
54
- "@types/react": "~19.2.7",
53
+ "@testing-library/react": "^16.3.2",
54
+ "@types/react": "~19.2.17",
55
55
  "@types/react-dom": "~19.2.3",
56
- "effect": "3.21.3",
57
- "react": "~19.2.3",
58
- "react-dom": "~19.2.3",
56
+ "effect": "3.21.4",
57
+ "react": "~19.2.7",
58
+ "react-dom": "~19.2.7",
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/random": "0.9.1-staging.ee54ba693a",
61
+ "@dxos/storybook-utils": "0.9.1-staging.ee54ba693a",
62
+ "@dxos/react-ui": "0.9.1-staging.ee54ba693a",
63
+ "@dxos/ui-theme": "0.9.1-staging.ee54ba693a"
64
64
  },
65
65
  "peerDependencies": {
66
66
  "@effect-atom/atom-react": "^0.5.0",
67
- "@effect/platform": "0.96.1",
68
- "effect": "3.21.3",
69
- "react": "~19.2.3",
70
- "react-dom": "~19.2.3",
71
- "@dxos/react-ui": "0.9.0",
72
- "@dxos/ui-theme": "0.9.0"
67
+ "@effect/platform": "0.96.2",
68
+ "effect": "3.21.4",
69
+ "react": "~19.2.7",
70
+ "react-dom": "~19.2.7",
71
+ "@dxos/ui-theme": "0.9.1-staging.ee54ba693a",
72
+ "@dxos/react-ui": "0.9.1-staging.ee54ba693a"
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
 
@@ -30,8 +41,11 @@ export interface ActionGroupBuilder {
30
41
  /** Add an action node as a child of the current group. */
31
42
  action<P extends {} = {}>(id: string, props: P & MenuActionProperties, invoke: () => void): this;
32
43
 
44
+ /** Add a switch action rendered as a labeled Input.Switch. */
45
+ switch(id: string, props: Omit<MenuActionProperties, 'variant'> & { checked: boolean }, invoke: () => void): this;
46
+
33
47
  /** Add a nested action group. */
34
- group<P extends {} = {}>(id: string, props: P & MenuItemGroupProperties, cb: ActionGroupBuilderFn): this;
48
+ group(id: string, props: MenuItemGroupProperties, cb: ActionGroupBuilderFn): this;
35
49
 
36
50
  /** Merge pre-built nodes and edges, or build a subgraph via callback scoped to the current group. Falsy values are ignored. */
37
51
  subgraph(subgraphOrCb: ActionGraphProps | ActionGroupBuilderFn | false | null | undefined): this;
@@ -42,10 +56,19 @@ export interface ActionGroupBuilder {
42
56
 
43
57
  /** Top-level builder that creates the root group and produces the final action graph. */
44
58
  export interface MenuBuilder extends ActionGroupBuilder {
45
- /** Set properties on the root menu group. May only be called once. */
59
+ /**
60
+ * Set properties on the root menu group. May only be called once.
61
+ */
46
62
  root(props: MenuItemGroupProperties): this;
47
63
 
48
- /** Return the assembled action graph. */
64
+ /**
65
+ * Add a root-level toolbar overflow dropdown (⋮) with built-in trigger styling.
66
+ */
67
+ menu(id: string, cb: ActionGroupBuilderFn): this;
68
+
69
+ /**
70
+ * Return the assembled action graph.
71
+ */
49
72
  build(): ActionGraphProps;
50
73
  }
51
74
 
@@ -74,13 +97,21 @@ class MenuBuilderImpl implements MenuBuilder {
74
97
  return this;
75
98
  }
76
99
 
77
- group<P extends {} = {}>(id: string, props: P & MenuItemGroupProperties, cb: ActionGroupBuilderFn): this {
100
+ switch(id: string, props: Omit<MenuActionProperties, 'variant'> & { checked: boolean }, invoke: () => void): this {
101
+ return this.action(id, { ...props, variant: 'switch' }, invoke);
102
+ }
103
+
104
+ group(id: string, props: MenuItemGroupProperties, cb: ActionGroupBuilderFn): this {
78
105
  this._data.nodes.push(createMenuItemGroup(id, props));
79
106
  this._data.edges.push({ source: this._rootId, target: id, relation: 'child' });
80
107
  cb(new MenuBuilderImpl(this._data, id));
81
108
  return this;
82
109
  }
83
110
 
111
+ menu(id: string, cb: ActionGroupBuilderFn): this {
112
+ return this.group(id, overflowMenuProperties, cb);
113
+ }
114
+
84
115
  subgraph(subgraphOrCb: ActionGraphProps | ActionGroupBuilderFn | false | null | undefined): this {
85
116
  if (!subgraphOrCb) {
86
117
  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);
@@ -5,7 +5,7 @@
5
5
  import { Atom, RegistryContext, useAtomValue } from '@effect-atom/atom-react';
6
6
  import { type Scope, createContextScope } from '@radix-ui/react-context';
7
7
  import { useControllableState } from '@radix-ui/react-use-controllable-state';
8
- import React, { createContext, type MouseEvent, type PropsWithChildren, useCallback, useContext, useMemo } from 'react';
8
+ import React, { type MouseEvent, type PropsWithChildren, createContext, useCallback, useContext, useMemo } from 'react';
9
9
 
10
10
  import { log } from '@dxos/log';
11
11
  import { type DropdownMenuRootProps, Icon, DropdownMenu as NaturalDropdownMenu } from '@dxos/react-ui';
@@ -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
  };
@@ -339,10 +334,10 @@ export { Menu, menuContextDefaults, useMenu, useMenuItems, useMenuScoped };
339
334
  export type { MenuContentProps, MenuRootProps, MenuScopedProps };
340
335
 
341
336
  export type {
342
- ToolbarMenuProps,
343
337
  ToolbarMenuActionGroupProperties,
344
338
  ToolbarMenuActionGroupProps,
345
339
  ToolbarMenuActionProps,
346
340
  ToolbarMenuDropdownMenuActionGroup,
341
+ ToolbarMenuProps,
347
342
  ToolbarMenuToggleGroupActionGroup,
348
343
  } from './ToolbarMenu';
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { Atom, RegistryContext } from '@effect-atom/atom-react';
6
6
  import { type Meta, type StoryObj } from '@storybook/react-vite';
7
- import React, { useContext, useMemo } from 'react';
7
+ import React, { useContext, useMemo, useState } from 'react';
8
8
 
9
9
  import { random } from '@dxos/random';
10
10
  import { IconButton } from '@dxos/react-ui';
@@ -13,8 +13,9 @@ import { withRegistry } from '@dxos/storybook-utils';
13
13
 
14
14
  import { translations } from '#translations';
15
15
 
16
+ import { MenuBuilder } from '../builder';
16
17
  import { Menu } from '../components';
17
- import { type ActionGraphProps, useMenuActions } from '../hooks';
18
+ import { type ActionGraphProps, useMenuActions, useMenuBuilder } from '../hooks';
18
19
  import { createActions, createNestedActions, createNestedActionsResolver, useMutateActions } from '../testing';
19
20
 
20
21
  random.seed(1234);
@@ -93,3 +94,31 @@ export const UseMenuActionsToolbar: Story = {
93
94
  );
94
95
  },
95
96
  };
97
+
98
+ /**
99
+ * Toolbar with both labeled and tooltip-only (iconOnly) Input.Switch items.
100
+ */
101
+ export const SwitchToolbar: Story = {
102
+ render: () => {
103
+ const [wordWrap, setWordWrap] = useState(false);
104
+ const [lineNumbers, setLineNumbers] = useState(true);
105
+
106
+ const menuActions = useMenuBuilder(
107
+ () =>
108
+ MenuBuilder.make()
109
+ .root({ label: 'Editor settings' })
110
+ .switch('word-wrap', { label: 'Word wrap', checked: wordWrap }, () => setWordWrap((v) => !v))
111
+ .switch('line-numbers', { label: 'Line numbers', iconOnly: true, checked: lineNumbers }, () =>
112
+ setLineNumbers((v) => !v),
113
+ )
114
+ .build(),
115
+ [wordWrap, lineNumbers],
116
+ );
117
+
118
+ return (
119
+ <Menu.Root {...menuActions} alwaysActive>
120
+ <Menu.Toolbar />
121
+ </Menu.Root>
122
+ );
123
+ },
124
+ };
@@ -4,42 +4,31 @@
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 {
8
+ Input,
9
+ Toolbar as NaturalToolbar,
10
+ type ToolbarRootProps,
11
+ Tooltip,
12
+ toLocalizedString,
13
+ useTranslation,
14
+ } from '@dxos/react-ui';
8
15
  import { composable, composableProps } from '@dxos/react-ui';
9
16
  import { useAttention } from '@dxos/react-ui-attention';
10
- import { type MenuActionProperties } from '@dxos/ui-types';
17
+ import { type DropdownMenuItemGroupProperties, type ToggleGroupMenuItemGroupProperties } from '@dxos/ui-types';
11
18
 
12
19
  import { translationKey } from '#translations';
13
20
 
14
- import {
15
- type MenuAction,
16
- type MenuItem,
17
- type MenuItemGroup,
18
- type MenuMultipleSelectActionGroup,
19
- type MenuSingleSelectActionGroup,
20
- isMenuGroup,
21
- isSeparator,
22
- } from '../types';
21
+ import { type MenuAction, type MenuItem, type MenuItemGroup, isMenuGroup, isSeparator } from '../types';
23
22
  import { executeMenuAction } from '../util';
24
23
  import { ActionLabel, actionLabel } from './ActionLabel';
25
24
  import { DropdownMenu } from './DropdownMenu';
26
25
  import { type MenuScopedProps, useMenuItems, useMenuScoped } from './Menu';
27
26
 
28
- export type ToolbarMenuDropdownMenuActionGroup = Omit<MenuActionProperties, 'variant' | 'icon'> & {
29
- variant: 'dropdownMenu';
30
- icon: string;
31
- applyActive?: boolean;
32
- };
27
+ export type ToolbarMenuDropdownMenuActionGroup = DropdownMenuItemGroupProperties;
33
28
 
34
- export type ToolbarMenuToggleGroupActionGroup = Omit<MenuActionProperties, 'variant'> & {
35
- variant: 'toggleGroup';
36
- };
29
+ export type ToolbarMenuToggleGroupActionGroup = ToggleGroupMenuItemGroupProperties;
37
30
 
38
- export type ToolbarMenuActionGroupProperties = (
39
- | ToolbarMenuDropdownMenuActionGroup
40
- | ToolbarMenuToggleGroupActionGroup
41
- ) &
42
- (MenuSingleSelectActionGroup | MenuMultipleSelectActionGroup);
31
+ export type ToolbarMenuActionGroupProperties = DropdownMenuItemGroupProperties | ToggleGroupMenuItemGroupProperties;
43
32
 
44
33
  export type ToolbarMenuProps = ToolbarRootProps;
45
34
 
@@ -48,8 +37,18 @@ export type ToolbarMenuActionGroupProps = {
48
37
  items?: MenuItem[];
49
38
  };
50
39
 
40
+ export type ToolbarMenuDropdownGroupProps = {
41
+ group: MenuItemGroup<DropdownMenuItemGroupProperties>;
42
+ items?: MenuItem[];
43
+ };
44
+
45
+ export type ToolbarMenuToggleGroupProps = {
46
+ group: MenuItemGroup<ToggleGroupMenuItemGroupProperties>;
47
+ items?: MenuItem[];
48
+ };
49
+
51
50
  export type ToolbarMenuActionProps = {
52
- group: MenuItemGroup<ToolbarMenuActionGroupProperties>;
51
+ group: MenuItemGroup<ToggleGroupMenuItemGroupProperties>;
53
52
  action: MenuAction;
54
53
  };
55
54
 
@@ -98,50 +97,100 @@ const ActionToolbarItem = ({ __menuScope, action }: MenuScopedProps<{ action: Me
98
97
  );
99
98
  };
100
99
 
100
+ const SwitchToolbarItem = ({ __menuScope, action }: MenuScopedProps<{ action: MenuAction }>) => {
101
+ const { onAction } = useMenuScoped('SwitchToolbarItem', __menuScope);
102
+ const { t } = useTranslation(translationKey);
103
+ const { label, iconOnly, disabled, testId, hidden, checked } = action.properties;
104
+ const labelStr = toLocalizedString(label, t);
105
+
106
+ const handleCheckedChange = useCallback(() => {
107
+ if (onAction) {
108
+ onAction(action, {});
109
+ } else {
110
+ void executeMenuAction(action);
111
+ }
112
+ }, [action, onAction]);
113
+
114
+ if (hidden) {
115
+ return null;
116
+ }
117
+
118
+ const switchInput = (
119
+ <Input.Switch
120
+ checked={checked}
121
+ disabled={disabled}
122
+ aria-label={iconOnly ? labelStr : undefined}
123
+ onCheckedChange={handleCheckedChange}
124
+ {...(testId && { 'data-testid': testId })}
125
+ />
126
+ );
127
+
128
+ return (
129
+ <Input.Root>
130
+ {iconOnly ? (
131
+ <Tooltip.Trigger asChild content={labelStr}>
132
+ <Input.Block>{switchInput}</Input.Block>
133
+ </Tooltip.Trigger>
134
+ ) : (
135
+ <Input.Block>{switchInput}</Input.Block>
136
+ )}
137
+ {!iconOnly && <Input.Label>{labelStr}</Input.Label>}
138
+ </Input.Root>
139
+ );
140
+ };
141
+
101
142
  const DropdownMenuToolbarItem = ({
102
143
  __menuScope,
103
144
  group,
104
145
  items: propsItems,
105
- }: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
106
- const { iconOnly, disabled, testId } = group.properties;
146
+ }: MenuScopedProps<ToolbarMenuDropdownGroupProps>) => {
107
147
  const { t } = useTranslation(translationKey);
108
148
  const { iconSize } = useMenuScoped('DropdownMenuToolbarItem', __menuScope);
109
149
  const items = useMenuItems(group, propsItems, 'DropdownMenuToolbarItem', __menuScope);
110
- const activeItem = items?.find((item) => !!(item as MenuAction).properties.checked);
150
+ const {
151
+ iconOnly,
152
+ disabled,
153
+ testId,
154
+ applyActive,
155
+ caretDown = true,
156
+ icon: groupIcon,
157
+ iconClassNames: groupIconClassNames,
158
+ } = group.properties;
159
+ const activeItem = items?.find((item) => !!(item as MenuAction).properties.checked) as MenuAction | undefined;
111
160
  const icon =
112
- ((group.properties as any).applyActive &&
161
+ (applyActive &&
113
162
  // 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
- };
163
+ activeItem?.properties.icon) ||
164
+ groupIcon;
165
+ // Follow the same `applyActive` rule for `iconClassNames` so a per-item accent (e.g. tag colour) tracks the displayed icon.
166
+ const iconClassNames = (applyActive && activeItem?.properties.iconClassNames) || groupIconClassNames;
167
+ const labelAction = applyActive && activeItem ? activeItem : group;
140
168
 
141
169
  return (
142
170
  <DropdownMenu.Root group={group} items={items}>
143
171
  <DropdownMenu.Trigger asChild>
144
- <Root variant='ghost' disabled={disabled} {...(rootProps as any)} {...(testId && { 'data-testid': testId })} />
172
+ {icon ? (
173
+ <NaturalToolbar.IconButton
174
+ variant='ghost'
175
+ disabled={disabled}
176
+ icon={icon}
177
+ size={iconSize}
178
+ iconOnly={iconOnly}
179
+ iconClassNames={iconClassNames}
180
+ label={actionLabel(labelAction, t)}
181
+ caretDown={caretDown}
182
+ {...(testId && { 'data-testid': testId })}
183
+ />
184
+ ) : (
185
+ <NaturalToolbar.Button
186
+ variant='ghost'
187
+ disabled={disabled}
188
+ caretDown={caretDown}
189
+ {...(testId && { 'data-testid': testId })}
190
+ >
191
+ <ActionLabel action={labelAction} />
192
+ </NaturalToolbar.Button>
193
+ )}
145
194
  </DropdownMenu.Trigger>
146
195
  </DropdownMenu.Root>
147
196
  );
@@ -189,7 +238,7 @@ const ToggleGroupToolbarItem = ({
189
238
  __menuScope,
190
239
  group,
191
240
  items: itemsProp,
192
- }: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
241
+ }: MenuScopedProps<ToolbarMenuToggleGroupProps>) => {
193
242
  const items = useMenuItems(group, itemsProp, 'ToggleGroupToolbarItem', __menuScope);
194
243
  const { selectCardinality } = group.properties;
195
244
 
@@ -244,18 +293,28 @@ const ToolbarMenuItem = ({ __menuScope, item }: MenuScopedProps<{ item: MenuItem
244
293
  return (
245
294
  <DropdownMenuToolbarItem
246
295
  __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>}
296
+ group={item as MenuItemGroup<DropdownMenuItemGroupProperties>}
255
297
  />
256
298
  );
257
299
  }
300
+
301
+ return (
302
+ <ToggleGroupToolbarItem
303
+ __menuScope={__menuScope}
304
+ group={item as MenuItemGroup<ToggleGroupMenuItemGroupProperties>}
305
+ />
306
+ );
307
+ }
308
+
309
+ const action = item as MenuAction;
310
+ if (action.properties?.variant === 'switch') {
311
+ return <SwitchToolbarItem __menuScope={__menuScope} action={action} />;
312
+ }
313
+
314
+ // The contributor owns the rendered element (interactions the action model cannot express).
315
+ if (action.properties?.variant === 'custom' && action.properties.render) {
316
+ return <>{action.properties.render()}</>;
258
317
  }
259
318
 
260
- return <ActionToolbarItem __menuScope={__menuScope} action={item as MenuAction} />;
319
+ return <ActionToolbarItem __menuScope={__menuScope} action={action} />;
261
320
  };
@@ -2,4 +2,5 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
+ export * from './useGraphMenuActions';
5
6
  export * from './useMenuActions';