@dxos/react-ui-menu 0.9.1-staging.ee54ba693a → 0.11.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 (61) hide show
  1. package/dist/lib/index.mjs +747 -0
  2. package/dist/lib/index.mjs.map +1 -0
  3. package/dist/lib/translations.mjs +7 -0
  4. package/dist/lib/translations.mjs.map +1 -0
  5. package/dist/types/src/builder.d.ts +1 -1
  6. package/dist/types/src/builder.d.ts.map +1 -1
  7. package/dist/types/src/components/ActionLabel.d.ts +0 -2
  8. package/dist/types/src/components/ActionLabel.d.ts.map +1 -1
  9. package/dist/types/src/components/DropdownMenu.d.ts +2 -4
  10. package/dist/types/src/components/DropdownMenu.d.ts.map +1 -1
  11. package/dist/types/src/components/Menu.d.ts +5 -15
  12. package/dist/types/src/components/Menu.d.ts.map +1 -1
  13. package/dist/types/src/components/MenuContext.d.ts +28 -0
  14. package/dist/types/src/components/MenuContext.d.ts.map +1 -0
  15. package/dist/types/src/components/ToolbarMenu.d.ts +2 -2
  16. package/dist/types/src/components/ToolbarMenu.d.ts.map +1 -1
  17. package/dist/types/src/components/ToolbarMenu.stories.d.ts +2 -2
  18. package/dist/types/src/components/action-label.d.ts +7 -0
  19. package/dist/types/src/components/action-label.d.ts.map +1 -0
  20. package/dist/types/src/components/index.d.ts +2 -0
  21. package/dist/types/src/components/index.d.ts.map +1 -1
  22. package/dist/types/src/hooks/useGraphMenuActions.d.ts +13 -1
  23. package/dist/types/src/hooks/useGraphMenuActions.d.ts.map +1 -1
  24. package/dist/types/src/index.d.ts +2 -0
  25. package/dist/types/src/index.d.ts.map +1 -1
  26. package/dist/types/src/presentation.d.ts +8 -0
  27. package/dist/types/src/presentation.d.ts.map +1 -0
  28. package/dist/types/src/toolbar.d.ts +6 -0
  29. package/dist/types/src/toolbar.d.ts.map +1 -0
  30. package/dist/types/src/types.d.ts +1 -1
  31. package/dist/types/src/types.d.ts.map +1 -1
  32. package/dist/types/src/util.d.ts +5 -3
  33. package/dist/types/src/util.d.ts.map +1 -1
  34. package/dist/types/tsconfig.tsbuildinfo +1 -1
  35. package/package.json +19 -20
  36. package/src/builder.ts +3 -3
  37. package/src/components/ActionLabel.tsx +1 -6
  38. package/src/components/DropdownMenu.tsx +30 -8
  39. package/src/components/Menu.stories.tsx +1 -1
  40. package/src/components/Menu.test.tsx +3 -2
  41. package/src/components/Menu.tsx +32 -122
  42. package/src/components/MenuContext.ts +129 -0
  43. package/src/components/ToolbarMenu.tsx +27 -13
  44. package/src/components/action-label.ts +20 -0
  45. package/src/components/index.ts +3 -0
  46. package/src/hooks/useGraphMenuActions.ts +16 -3
  47. package/src/index.ts +2 -0
  48. package/src/presentation.ts +19 -0
  49. package/src/toolbar.ts +11 -0
  50. package/src/types.ts +1 -1
  51. package/src/util.ts +2 -2
  52. package/dist/lib/browser/index.mjs +0 -797
  53. package/dist/lib/browser/index.mjs.map +0 -7
  54. package/dist/lib/browser/meta.json +0 -1
  55. package/dist/lib/browser/translations.mjs +0 -16
  56. package/dist/lib/browser/translations.mjs.map +0 -7
  57. package/dist/lib/node-esm/index.mjs +0 -799
  58. package/dist/lib/node-esm/index.mjs.map +0 -7
  59. package/dist/lib/node-esm/meta.json +0 -1
  60. package/dist/lib/node-esm/translations.mjs +0 -18
  61. package/dist/lib/node-esm/translations.mjs.map +0 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-menu",
3
- "version": "0.9.1-staging.ee54ba693a",
3
+ "version": "0.11.0",
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",
@@ -19,14 +19,12 @@
19
19
  ".": {
20
20
  "source": "./src/index.ts",
21
21
  "types": "./dist/types/src/index.d.ts",
22
- "browser": "./dist/lib/browser/index.mjs",
23
- "node": "./dist/lib/node-esm/index.mjs"
22
+ "import": "./dist/lib/index.mjs"
24
23
  },
25
24
  "./translations": {
26
25
  "source": "./src/translations.ts",
27
26
  "types": "./dist/types/src/translations.d.ts",
28
- "browser": "./dist/lib/browser/translations.mjs",
29
- "node": "./dist/lib/node-esm/translations.mjs"
27
+ "import": "./dist/lib/translations.mjs"
30
28
  }
31
29
  },
32
30
  "types": "dist/types/src/index.d.ts",
@@ -35,16 +33,17 @@
35
33
  "src"
36
34
  ],
37
35
  "dependencies": {
36
+ "@effect-atom/atom": "^0.5.3",
38
37
  "@radix-ui/react-context": "1.1.1",
39
38
  "@radix-ui/react-use-controllable-state": "1.1.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"
39
+ "@dxos/app-graph": "0.11.0",
40
+ "@dxos/effect": "0.11.0",
41
+ "@dxos/invariant": "0.11.0",
42
+ "@dxos/log": "0.11.0",
43
+ "@dxos/keyboard": "0.11.0",
44
+ "@dxos/ui-types": "0.11.0",
45
+ "@dxos/util": "0.11.0",
46
+ "@dxos/react-ui-attention": "0.11.0"
48
47
  },
49
48
  "devDependencies": {
50
49
  "@effect-atom/atom-react": "^0.5.0",
@@ -56,11 +55,11 @@
56
55
  "effect": "3.21.4",
57
56
  "react": "~19.2.7",
58
57
  "react-dom": "~19.2.7",
59
- "vite": "^8.0.16",
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"
58
+ "vite": "^8.1.4",
59
+ "@dxos/random": "0.11.0",
60
+ "@dxos/storybook-utils": "0.11.0",
61
+ "@dxos/ui-theme": "0.11.0",
62
+ "@dxos/react-ui": "0.11.0"
64
63
  },
65
64
  "peerDependencies": {
66
65
  "@effect-atom/atom-react": "^0.5.0",
@@ -68,8 +67,8 @@
68
67
  "effect": "3.21.4",
69
68
  "react": "~19.2.7",
70
69
  "react-dom": "~19.2.7",
71
- "@dxos/ui-theme": "0.9.1-staging.ee54ba693a",
72
- "@dxos/react-ui": "0.9.1-staging.ee54ba693a"
70
+ "@dxos/react-ui": "^0.11.0",
71
+ "@dxos/ui-theme": "^0.11.0"
73
72
  },
74
73
  "publishConfig": {
75
74
  "access": "public"
package/src/builder.ts CHANGED
@@ -64,7 +64,7 @@ export interface MenuBuilder extends ActionGroupBuilder {
64
64
  /**
65
65
  * Add a root-level toolbar overflow dropdown (⋮) with built-in trigger styling.
66
66
  */
67
- menu(id: string, cb: ActionGroupBuilderFn): this;
67
+ menu(id: string, cb: ActionGroupBuilderFn, testId?: string): this;
68
68
 
69
69
  /**
70
70
  * Return the assembled action graph.
@@ -108,8 +108,8 @@ class MenuBuilderImpl implements MenuBuilder {
108
108
  return this;
109
109
  }
110
110
 
111
- menu(id: string, cb: ActionGroupBuilderFn): this {
112
- return this.group(id, overflowMenuProperties, cb);
111
+ menu(id: string, cb: ActionGroupBuilderFn, testId?: string): this {
112
+ return this.group(id, testId === undefined ? overflowMenuProperties : { ...overflowMenuProperties, testId }, cb);
113
113
  }
114
114
 
115
115
  subgraph(subgraphOrCb: ActionGraphProps | ActionGroupBuilderFn | false | null | undefined): this {
@@ -6,7 +6,7 @@ import React from 'react';
6
6
 
7
7
  import { type Node } from '@dxos/app-graph';
8
8
  import { keySymbols } from '@dxos/keyboard';
9
- import { type TFunction, toLocalizedString, useTranslation } from '@dxos/react-ui';
9
+ import { toLocalizedString, useTranslation } from '@dxos/react-ui';
10
10
  import { mx } from '@dxos/ui-theme';
11
11
  import { type MenuActionProperties, type MenuItemChrome } from '@dxos/ui-types';
12
12
 
@@ -26,8 +26,3 @@ export const ActionLabel = ({ action }: { action: Action }) => {
26
26
  </>
27
27
  );
28
28
  };
29
-
30
- export const actionLabel = (action: Action, t: TFunction) => {
31
- const shortcut = getShortcut(action);
32
- return `${toLocalizedString(action.properties!.label, t)}${shortcut ? ` (${keySymbols(shortcut).join('')})` : ''}`;
33
- };
@@ -6,11 +6,12 @@ import { useControllableState } from '@radix-ui/react-use-controllable-state';
6
6
  import React, { type MouseEvent, useCallback } from 'react';
7
7
 
8
8
  import { type DropdownMenuRootProps, Icon, DropdownMenu as NaturalDropdownMenu } from '@dxos/react-ui';
9
+ import { mx } from '@dxos/ui-theme';
9
10
 
10
- import { type MenuAction, type MenuItem, type MenuItemGroup } from '../types';
11
+ import { type MenuAction, type MenuItem, type MenuItemGroup, isSeparator } from '../types';
11
12
  import { executeMenuAction } from '../util';
12
13
  import { ActionLabel } from './ActionLabel';
13
- import { type MenuScopedProps, useMenuItems, useMenuScoped } from './Menu';
14
+ import { type MenuScopedProps, useMenuItems, useMenuScoped } from './MenuContext';
14
15
 
15
16
  export type DropdownMenuProps = DropdownMenuRootProps & {
16
17
  group?: MenuItemGroup;
@@ -20,25 +21,41 @@ export type DropdownMenuProps = DropdownMenuRootProps & {
20
21
 
21
22
  const DropdownMenuItem = ({
22
23
  item,
24
+ group,
23
25
  onClick,
24
26
  __menuScope,
25
27
  }: MenuScopedProps<{
26
28
  item: MenuItem;
29
+ group?: MenuItemGroup;
27
30
  onClick: (action: MenuAction, event: MouseEvent) => void;
28
31
  }>) => {
29
32
  // TODO(thure): handle other items.
30
33
  const action = item as MenuAction;
31
34
  const handleClick = useCallback((event: MouseEvent) => onClick(action, event), [action, onClick]);
32
35
  const { iconSize } = useMenuScoped('DropdownMenuItem', __menuScope);
36
+ // An item that declares `checked` is a select-group member: expose the checked role + state to AT so
37
+ // the current value is announced, not conveyed by the trailing check icon alone. Mutually-exclusive
38
+ // (single-select) groups use radio semantics; multi-select groups use checkbox.
39
+ const checkable = typeof action.properties?.checked === 'boolean';
40
+ const role = group?.properties?.selectCardinality === 'multiple' ? 'menuitemcheckbox' : 'menuitemradio';
33
41
  return (
34
42
  <NaturalDropdownMenu.Item
35
43
  onClick={handleClick}
36
44
  classNames='gap-2'
37
45
  disabled={action.properties?.disabled}
46
+ {...(checkable && { role, 'aria-checked': !!action.properties?.checked })}
38
47
  {...(action.properties?.testId && { 'data-testid': action.properties.testId })}
39
48
  >
40
- {action.properties?.icon && <Icon icon={action.properties!.icon} size={iconSize} />}
49
+ {action.properties?.icon && (
50
+ <Icon
51
+ icon={action.properties.icon}
52
+ size={iconSize}
53
+ classNames={mx(action.properties.spin && 'animate-spin', action.properties.iconClassNames)}
54
+ />
55
+ )}
41
56
  <ActionLabel action={action} />
57
+ {/* Trailing check marks the current value of a single-select group (`checked`). */}
58
+ {action.properties?.checked && <Icon icon='ph--check--regular' size={iconSize} classNames='mis-auto' />}
42
59
  </NaturalDropdownMenu.Item>
43
60
  );
44
61
  };
@@ -70,10 +87,11 @@ const DropdownMenuRoot = ({
70
87
  event.stopPropagation();
71
88
  // TODO(thure): Why does Dialog's modal-ness cause issues if we don't explicitly close the menu here?
72
89
  setOptionsMenuOpen(false);
90
+ const params = { parent: group, caller, modifiers: { shift: event.shiftKey } };
73
91
  if (onAction) {
74
- onAction(action, { parent: group, caller });
92
+ onAction(action, params);
75
93
  } else {
76
- void executeMenuAction(action, { parent: group, caller });
94
+ void executeMenuAction(action, params);
77
95
  }
78
96
  },
79
97
  [group, caller, onAction],
@@ -87,9 +105,13 @@ const DropdownMenuRoot = ({
87
105
  <NaturalDropdownMenu.Portal>
88
106
  <NaturalDropdownMenu.Content>
89
107
  <NaturalDropdownMenu.Viewport>
90
- {items?.map((item) => (
91
- <DropdownMenuItem key={item.id} item={item} onClick={handleActionClick} />
92
- ))}
108
+ {items?.map((item) =>
109
+ isSeparator(item) ? (
110
+ <NaturalDropdownMenu.Separator key={item.id} />
111
+ ) : (
112
+ <DropdownMenuItem key={item.id} item={item} group={group} onClick={handleActionClick} />
113
+ ),
114
+ )}
93
115
  </NaturalDropdownMenu.Viewport>
94
116
  <NaturalDropdownMenu.Arrow />
95
117
  </NaturalDropdownMenu.Content>
@@ -2,7 +2,7 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { Atom } from '@effect-atom/atom-react';
5
+ import { Atom } from '@effect-atom/atom';
6
6
  import { type Meta, type StoryObj } from '@storybook/react-vite';
7
7
  import React, { useEffect, useMemo, useState } from 'react';
8
8
  import { expect, userEvent, within } from 'storybook/test';
@@ -2,14 +2,15 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { Atom } from '@effect-atom/atom-react';
5
+ import { Atom } from '@effect-atom/atom';
6
6
  import { cleanup, render, renderHook, screen } from '@testing-library/react';
7
7
  import React, { type PropsWithChildren, useEffect } from 'react';
8
8
  import { afterEach, describe, test } from 'vitest';
9
9
 
10
10
  import { type MenuItem, type MenuItemsAccessor } from '../types';
11
11
  import { createMenuAction } from '../util';
12
- import { Menu, useMenu, useMenuItems } from './Menu';
12
+ import { Menu } from './Menu';
13
+ import { useMenu, useMenuItems } from './MenuContext';
13
14
 
14
15
  const TEST_CONTRIBUTOR = 'TestContributor';
15
16
 
@@ -2,13 +2,12 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { Atom, RegistryContext, useAtomValue } from '@effect-atom/atom-react';
6
- import { type Scope, createContextScope } from '@radix-ui/react-context';
5
+ import { Atom, RegistryContext } from '@effect-atom/atom-react';
7
6
  import { useControllableState } from '@radix-ui/react-use-controllable-state';
8
- import React, { type MouseEvent, type PropsWithChildren, createContext, useCallback, useContext, useMemo } from 'react';
7
+ import React, { type MouseEvent, type PropsWithChildren, useCallback, useContext, useMemo } from 'react';
9
8
 
10
- import { log } from '@dxos/log';
11
9
  import { type DropdownMenuRootProps, Icon, DropdownMenu as NaturalDropdownMenu } from '@dxos/react-ui';
10
+ import { mx } from '@dxos/ui-theme';
12
11
 
13
12
  import {
14
13
  type AddMenuItemsProps,
@@ -16,62 +15,28 @@ import {
16
15
  type MenuContextValue,
17
16
  type MenuGroupContext,
18
17
  type MenuItem,
19
- type MenuItems,
20
- type MenuItemsAccessor,
21
18
  type MenuItemsMap,
19
+ isSeparator,
22
20
  } from '../types';
23
21
  import { executeMenuAction } from '../util';
24
22
  import { ActionLabel } from './ActionLabel';
23
+ import {
24
+ MenuContextProvider,
25
+ MenuDropdownContext,
26
+ type MenuScopedProps,
27
+ menuContextDefaults,
28
+ useMenuItems,
29
+ useMenuScope,
30
+ useMenuScoped,
31
+ } from './MenuContext';
25
32
  import { ToolbarMenu } from './ToolbarMenu';
26
33
 
27
- //
28
- // Scoped context.
29
- //
30
-
31
- type MenuScopedProps<P> = P & { __menuScope?: Scope };
32
-
33
- const MENU_NAME = 'Menu';
34
-
35
- const [createMenuContext, createMenuScope] = createContextScope(MENU_NAME, []);
36
-
37
- const nullItemsAtom = Atom.make<MenuItem[] | null>(null);
38
- const defaultItemsAccessor: MenuItemsAccessor = () => nullItemsAtom;
39
-
40
- const menuContextDefaults: MenuContextValue = {
41
- iconSize: 5,
42
- items: defaultItemsAccessor,
43
- onAction: undefined,
44
- menuItemsAtom: Atom.make<MenuItemsMap>(new Map()),
45
- addMenuItems: () => {},
46
- removeMenuItems: () => {},
47
- };
48
-
49
- const [MenuContextProvider, useMenuScoped] = createMenuContext<MenuContextValue>(MENU_NAME, menuContextDefaults);
50
-
51
- const useMenuScope = createMenuScope();
52
-
53
- //
54
- // Dropdown context (internal) — allows Menu.Content to close the parent dropdown.
55
- //
56
-
57
- type MenuDropdownContextValue = {
58
- closeMenu: () => void;
59
- caller?: string;
60
- };
61
-
62
- const MenuDropdownContext = createContext<MenuDropdownContextValue>({
63
- closeMenu: () => {},
64
- });
65
-
66
34
  //
67
35
  // MenuProvider (internal) — the context provider used by Menu.Root.
68
36
  //
69
37
 
70
38
  const DEFAULT_PRIORITY = 100;
71
39
 
72
- const sortMenuItems = (items: MenuItems[]) =>
73
- [...items].sort((a, b) => (a.priority !== b.priority ? a.priority - b.priority : a.id.localeCompare(b.id)));
74
-
75
40
  type MenuProviderProps = PropsWithChildren<Partial<MenuContextValue>>;
76
41
 
77
42
  const MenuProvider = ({
@@ -124,72 +89,6 @@ const MenuProvider = ({
124
89
  );
125
90
  };
126
91
 
127
- //
128
- // Item resolution.
129
- //
130
-
131
- const resolveItems = (
132
- baseItems: MenuItem[] | null,
133
- group: MenuGroupContext | undefined,
134
- entries: ReadonlyMap<string, MenuItems>,
135
- ): MenuItem[] | null => {
136
- const applicable = [...entries.values()].filter((entry) => !entry.groupFilter || entry.groupFilter(group));
137
- if (applicable.length === 0) {
138
- return baseItems;
139
- }
140
-
141
- const sorted = sortMenuItems(applicable);
142
-
143
- const replacements = sorted.filter((entry) => entry.mode === 'replacement');
144
- if (replacements.length > 0) {
145
- if (replacements.length > 1) {
146
- log.warn('multiple replacement entries found', {
147
- ids: replacements.map((r) => r.id).join(', '),
148
- using: replacements[0].id,
149
- });
150
- }
151
- return replacements[0].items;
152
- }
153
-
154
- const additive = sorted.filter((entry) => entry.mode === 'additive');
155
- const additiveItems = additive.flatMap((entry) => entry.items);
156
-
157
- if (!baseItems || baseItems.length === 0) {
158
- return additiveItems.length > 0 ? additiveItems : null;
159
- }
160
-
161
- return [...baseItems, ...additiveItems];
162
- };
163
-
164
- //
165
- // Public hooks.
166
- //
167
-
168
- const useMenuItems = (
169
- group?: MenuGroupContext,
170
- propsItems?: MenuItem[],
171
- consumerName: string = 'useMenuItemConsumer',
172
- __menuScope?: Scope,
173
- ) => {
174
- const { items, menuItemsAtom } = useMenuScoped(consumerName, __menuScope);
175
- const groupItems = useAtomValue(items(group));
176
- const entries = useAtomValue(menuItemsAtom) ?? new Map();
177
-
178
- const baseItems = useMemo(() => propsItems ?? groupItems ?? null, [propsItems, groupItems]);
179
-
180
- const resolved = useMemo(
181
- () => resolveItems(baseItems, group, entries as ReadonlyMap<string, MenuItems>),
182
- [baseItems, group, entries],
183
- );
184
-
185
- return resolved ?? undefined;
186
- };
187
-
188
- /** Returns the menu context without Radix scope. */
189
- const useMenu = (consumerName: string): MenuContextValue => {
190
- return useMenuScoped(consumerName, undefined);
191
- };
192
-
193
92
  //
194
93
  // Menu.Root
195
94
  //
@@ -256,7 +155,13 @@ const MenuContentItem = ({
256
155
  disabled={action.properties?.disabled}
257
156
  {...(action.properties?.testId && { 'data-testid': action.properties.testId })}
258
157
  >
259
- {action.properties?.icon && <Icon icon={action.properties!.icon} size={iconSize} />}
158
+ {action.properties?.icon && (
159
+ <Icon
160
+ icon={action.properties.icon}
161
+ size={iconSize}
162
+ classNames={mx(action.properties.spin && 'animate-spin', action.properties.iconClassNames)}
163
+ />
164
+ )}
260
165
  <ActionLabel action={action} />
261
166
  </NaturalDropdownMenu.Item>
262
167
  );
@@ -286,10 +191,11 @@ const MenuContent = ({
286
191
  }
287
192
  event.stopPropagation();
288
193
  closeMenu();
194
+ const params = { parent: group, caller, modifiers: { shift: event.shiftKey } };
289
195
  if (onAction) {
290
- onAction(action, { parent: group, caller });
196
+ onAction(action, params);
291
197
  } else {
292
- void executeMenuAction(action, { parent: group, caller });
198
+ void executeMenuAction(action, params);
293
199
  }
294
200
  },
295
201
  [group, caller, onAction, closeMenu],
@@ -299,9 +205,13 @@ const MenuContent = ({
299
205
  <NaturalDropdownMenu.Portal>
300
206
  <NaturalDropdownMenu.Content>
301
207
  <NaturalDropdownMenu.Viewport>
302
- {resolvedItems?.map((item) => (
303
- <MenuContentItem key={item.id} item={item} onClick={handleActionClick} />
304
- ))}
208
+ {resolvedItems?.map((item) =>
209
+ isSeparator(item) ? (
210
+ <NaturalDropdownMenu.Separator key={item.id} />
211
+ ) : (
212
+ <MenuContentItem key={item.id} item={item} onClick={handleActionClick} />
213
+ ),
214
+ )}
305
215
  </NaturalDropdownMenu.Viewport>
306
216
  <NaturalDropdownMenu.Arrow />
307
217
  </NaturalDropdownMenu.Content>
@@ -329,9 +239,9 @@ const Menu = {
329
239
  Toolbar: ToolbarMenu,
330
240
  };
331
241
 
332
- export { Menu, menuContextDefaults, useMenu, useMenuItems, useMenuScoped };
242
+ export { Menu };
333
243
 
334
- export type { MenuContentProps, MenuRootProps, MenuScopedProps };
244
+ export type { MenuContentProps, MenuRootProps };
335
245
 
336
246
  export type {
337
247
  ToolbarMenuActionGroupProperties,
@@ -0,0 +1,129 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { Atom, useAtomValue } from '@effect-atom/atom-react';
6
+ import { type Scope, createContextScope } from '@radix-ui/react-context';
7
+ import { createContext, useMemo } from 'react';
8
+
9
+ import { log } from '@dxos/log';
10
+
11
+ import {
12
+ type MenuContextValue,
13
+ type MenuGroupContext,
14
+ type MenuItem,
15
+ type MenuItems,
16
+ type MenuItemsAccessor,
17
+ type MenuItemsMap,
18
+ } from '../types';
19
+
20
+ // Kept out of `Menu.tsx`: react-refresh only fast-refreshes a module whose exports are all
21
+ // components, so contexts and hooks exported beside them force a full page reload on every edit.
22
+
23
+ //
24
+ // Scoped context.
25
+ //
26
+
27
+ export type MenuScopedProps<P> = P & { __menuScope?: Scope };
28
+
29
+ export const MENU_NAME = 'Menu';
30
+
31
+ export const [createMenuContext, createMenuScope] = createContextScope(MENU_NAME, []);
32
+
33
+ const nullItemsAtom = Atom.make<MenuItem[] | null>(null);
34
+ const defaultItemsAccessor: MenuItemsAccessor = () => nullItemsAtom;
35
+
36
+ export const menuContextDefaults: MenuContextValue = {
37
+ iconSize: 5,
38
+ items: defaultItemsAccessor,
39
+ onAction: undefined,
40
+ menuItemsAtom: Atom.make<MenuItemsMap>(new Map()),
41
+ addMenuItems: () => {},
42
+ removeMenuItems: () => {},
43
+ };
44
+
45
+ export const [MenuContextProvider, useMenuScoped] = createMenuContext<MenuContextValue>(MENU_NAME, menuContextDefaults);
46
+
47
+ export const useMenuScope = createMenuScope();
48
+
49
+ //
50
+ // Dropdown context (internal) — allows Menu.Content to close the parent dropdown.
51
+ //
52
+
53
+ export type MenuDropdownContextValue = {
54
+ closeMenu: () => void;
55
+ caller?: string;
56
+ };
57
+
58
+ export const MenuDropdownContext = createContext<MenuDropdownContextValue>({
59
+ closeMenu: () => {},
60
+ });
61
+
62
+ //
63
+ // Item resolution.
64
+ //
65
+
66
+ const sortMenuItems = (items: MenuItems[]) =>
67
+ [...items].sort((a, b) => (a.priority !== b.priority ? a.priority - b.priority : a.id.localeCompare(b.id)));
68
+
69
+ export const resolveItems = (
70
+ baseItems: MenuItem[] | null,
71
+ group: MenuGroupContext | undefined,
72
+ entries: ReadonlyMap<string, MenuItems>,
73
+ ): MenuItem[] | null => {
74
+ const applicable = [...entries.values()].filter((entry) => !entry.groupFilter || entry.groupFilter(group));
75
+ if (applicable.length === 0) {
76
+ return baseItems;
77
+ }
78
+
79
+ const sorted = sortMenuItems(applicable);
80
+
81
+ const replacements = sorted.filter((entry) => entry.mode === 'replacement');
82
+ if (replacements.length > 0) {
83
+ if (replacements.length > 1) {
84
+ log.warn('multiple replacement entries found', {
85
+ ids: replacements.map((r) => r.id).join(', '),
86
+ using: replacements[0].id,
87
+ });
88
+ }
89
+ return replacements[0].items;
90
+ }
91
+
92
+ const additive = sorted.filter((entry) => entry.mode === 'additive');
93
+ const additiveItems = additive.flatMap((entry) => entry.items);
94
+
95
+ if (!baseItems || baseItems.length === 0) {
96
+ return additiveItems.length > 0 ? additiveItems : null;
97
+ }
98
+
99
+ return [...baseItems, ...additiveItems];
100
+ };
101
+
102
+ //
103
+ // Public hooks.
104
+ //
105
+
106
+ export const useMenuItems = (
107
+ group?: MenuGroupContext,
108
+ propsItems?: MenuItem[],
109
+ consumerName: string = 'useMenuItemConsumer',
110
+ __menuScope?: Scope,
111
+ ) => {
112
+ const { items, menuItemsAtom } = useMenuScoped(consumerName, __menuScope);
113
+ const groupItems = useAtomValue(items(group));
114
+ const entries = useAtomValue(menuItemsAtom) ?? new Map();
115
+
116
+ const baseItems = useMemo(() => propsItems ?? groupItems ?? null, [propsItems, groupItems]);
117
+
118
+ const resolved = useMemo(
119
+ () => resolveItems(baseItems, group, entries as ReadonlyMap<string, MenuItems>),
120
+ [baseItems, group, entries],
121
+ );
122
+
123
+ return resolved ?? undefined;
124
+ };
125
+
126
+ /** Returns the menu context without Radix scope. */
127
+ export const useMenu = (consumerName: string): MenuContextValue => {
128
+ return useMenuScoped(consumerName, undefined);
129
+ };