@dxos/react-ui-menu 0.10.0 → 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
@@ -2,7 +2,7 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import React, { useCallback } from 'react';
5
+ import React, { useCallback, useRef, useState } from 'react';
6
6
 
7
7
  import {
8
8
  Input,
@@ -14,15 +14,17 @@ import {
14
14
  } from '@dxos/react-ui';
15
15
  import { composable, composableProps } from '@dxos/react-ui';
16
16
  import { useAttention } from '@dxos/react-ui-attention';
17
+ import { mx } from '@dxos/ui-theme';
17
18
  import { type DropdownMenuItemGroupProperties, type ToggleGroupMenuItemGroupProperties } from '@dxos/ui-types';
18
19
 
19
20
  import { translationKey } from '#translations';
20
21
 
21
22
  import { type MenuAction, type MenuItem, type MenuItemGroup, isMenuGroup, isSeparator } from '../types';
22
23
  import { executeMenuAction } from '../util';
23
- import { ActionLabel, actionLabel } from './ActionLabel';
24
+ import { actionLabel } from './action-label';
25
+ import { ActionLabel } from './ActionLabel';
24
26
  import { DropdownMenu } from './DropdownMenu';
25
- import { type MenuScopedProps, useMenuItems, useMenuScoped } from './Menu';
27
+ import { type MenuScopedProps, useMenuItems, useMenuScoped } from './MenuContext';
26
28
 
27
29
  export type ToolbarMenuDropdownMenuActionGroup = DropdownMenuItemGroupProperties;
28
30
 
@@ -55,22 +57,32 @@ export type ToolbarMenuActionProps = {
55
57
  const ActionToolbarItem = ({ __menuScope, action }: MenuScopedProps<{ action: MenuAction }>) => {
56
58
  const { iconSize, onAction } = useMenuScoped('ActionToolbarItem', __menuScope);
57
59
  const { t } = useTranslation(translationKey);
60
+ const [pending, setPending] = useState(false);
61
+ const pendingRef = useRef(false);
58
62
 
59
- const { icon, iconOnly = true, disabled, testId, hidden, classNames, iconClassNames } = action.properties;
60
- const buttonVariant =
61
- (action.properties as { variant?: string }).variant === 'primary' ? ('primary' as const) : ('ghost' as const);
63
+ const { icon, iconOnly = true, disabled, testId, hidden, classNames, iconClassNames, spin } = action.properties;
64
+ const buttonVariant = action.properties.variant === 'primary' ? ('primary' as const) : ('ghost' as const);
62
65
 
63
66
  const handleClick = useCallback(() => {
67
+ if (pendingRef.current) {
68
+ return;
69
+ }
70
+ pendingRef.current = true;
71
+ setPending(true);
72
+ const done = () => {
73
+ pendingRef.current = false;
74
+ setPending(false);
75
+ };
64
76
  if (onAction) {
65
- onAction(action, {});
77
+ Promise.resolve(onAction(action, {})).then(done, done);
66
78
  } else {
67
- void executeMenuAction(action);
79
+ executeMenuAction(action).then(done, done);
68
80
  }
69
81
  }, [action, onAction]);
70
82
 
71
83
  const commonProps = {
72
84
  variant: buttonVariant,
73
- disabled,
85
+ disabled: disabled || pending,
74
86
  classNames,
75
87
  onClick: handleClick,
76
88
  ...(testId && { 'data-testid': testId }),
@@ -87,7 +99,7 @@ const ActionToolbarItem = ({ __menuScope, action }: MenuScopedProps<{ action: Me
87
99
  icon={icon}
88
100
  size={iconSize}
89
101
  iconOnly={iconOnly}
90
- iconClassNames={iconClassNames}
102
+ iconClassNames={mx(spin && 'animate-spin', iconClassNames)}
91
103
  label={actionLabel(action, t)}
92
104
  />
93
105
  ) : (
@@ -155,6 +167,7 @@ const DropdownMenuToolbarItem = ({
155
167
  caretDown = true,
156
168
  icon: groupIcon,
157
169
  iconClassNames: groupIconClassNames,
170
+ spin: groupSpin,
158
171
  } = group.properties;
159
172
  const activeItem = items?.find((item) => !!(item as MenuAction).properties.checked) as MenuAction | undefined;
160
173
  const icon =
@@ -164,6 +177,7 @@ const DropdownMenuToolbarItem = ({
164
177
  groupIcon;
165
178
  // Follow the same `applyActive` rule for `iconClassNames` so a per-item accent (e.g. tag colour) tracks the displayed icon.
166
179
  const iconClassNames = (applyActive && activeItem?.properties.iconClassNames) || groupIconClassNames;
180
+ const spin = (applyActive && activeItem?.properties.spin) || groupSpin;
167
181
  const labelAction = applyActive && activeItem ? activeItem : group;
168
182
 
169
183
  return (
@@ -176,7 +190,7 @@ const DropdownMenuToolbarItem = ({
176
190
  icon={icon}
177
191
  size={iconSize}
178
192
  iconOnly={iconOnly}
179
- iconClassNames={iconClassNames}
193
+ iconClassNames={mx(spin && 'animate-spin', iconClassNames)}
180
194
  label={actionLabel(labelAction, t)}
181
195
  caretDown={caretDown}
182
196
  {...(testId && { 'data-testid': testId })}
@@ -199,7 +213,7 @@ const DropdownMenuToolbarItem = ({
199
213
  const ToggleGroupItem = ({ __menuScope, group, action }: MenuScopedProps<ToolbarMenuActionProps>) => {
200
214
  const { iconSize, onAction } = useMenuScoped('ToggleGroupItem', __menuScope);
201
215
  const { t } = useTranslation(translationKey);
202
- const { icon, iconOnly = true, disabled, testId, hidden, classNames, iconClassNames } = action.properties;
216
+ const { icon, iconOnly = true, disabled, testId, hidden, classNames, iconClassNames, spin } = action.properties;
203
217
 
204
218
  const handleClick = useCallback(() => {
205
219
  if (onAction) {
@@ -224,7 +238,7 @@ const ToggleGroupItem = ({ __menuScope, group, action }: MenuScopedProps<Toolbar
224
238
  icon={icon}
225
239
  size={iconSize}
226
240
  iconOnly={iconOnly}
227
- iconClassNames={iconClassNames}
241
+ iconClassNames={mx(spin && 'animate-spin', iconClassNames)}
228
242
  label={actionLabel(action, t)}
229
243
  />
230
244
  ) : (
@@ -0,0 +1,20 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Node } from '@dxos/app-graph';
6
+ import { keySymbols } from '@dxos/keyboard';
7
+ import { type TFunction, toLocalizedString } from '@dxos/react-ui';
8
+ import { type MenuActionProperties, type MenuItemChrome } from '@dxos/ui-types';
9
+
10
+ import { getShortcut } from '../util';
11
+
12
+ type Action = Node.Action<MenuActionProperties> | Node.ActionGroup<MenuItemChrome>;
13
+
14
+ // Kept out of `ActionLabel.tsx`: react-refresh only fast-refreshes a module whose exports are all
15
+ // components, so a value exported beside them forces a full page reload on every edit.
16
+
17
+ export const actionLabel = (action: Action, t: TFunction) => {
18
+ const shortcut = getShortcut(action);
19
+ return `${toLocalizedString(action.properties!.label, t)}${shortcut ? ` (${keySymbols(shortcut).join('')})` : ''}`;
20
+ };
@@ -2,4 +2,7 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
+ export { type MenuScopedProps, menuContextDefaults, useMenu, useMenuItems, useMenuScoped } from './MenuContext';
6
+
7
+ export { actionLabel } from './action-label';
5
8
  export * from './Menu';
@@ -2,11 +2,12 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
- import { Atom } from '@effect-atom/atom-react';
5
+ import { Atom } from '@effect-atom/atom';
6
6
  import { useMemo } from 'react';
7
7
 
8
8
  import { type Graph, Node } from '@dxos/app-graph';
9
9
 
10
+ import { applyPresentation } from '../presentation';
10
11
  import {
11
12
  type ActionGraphEdges,
12
13
  type ActionGraphNodes,
@@ -20,6 +21,12 @@ export type GraphMenuOptions = {
20
21
  rootId?: string;
21
22
  /** Keep only the actions the predicate accepts (e.g. by `disposition`). */
22
23
  filter?: (action: Node.ActionLike) => boolean;
24
+ /**
25
+ * Disposition key this surface renders — when set, each action's `presentation[surface]` chrome
26
+ * override (if any) is merged into its properties, letting one action multi-target dispositions
27
+ * (e.g. `['toolbar', 'list-item']`) with per-surface chrome.
28
+ */
29
+ surface?: string;
23
30
  };
24
31
 
25
32
  /**
@@ -36,7 +43,7 @@ export const buildGraphMenu = (
36
43
  nodeId: string,
37
44
  options: GraphMenuOptions = {},
38
45
  ): ActionGraphProps => {
39
- const { rootId = Node.RootId, filter } = options;
46
+ const { rootId = Node.RootId, filter, surface } = options;
40
47
  const nodes: ActionGraphNodes = [];
41
48
  const edges: ActionGraphEdges = [];
42
49
  const seen = new Set<string>();
@@ -52,7 +59,7 @@ export const buildGraphMenu = (
52
59
  continue;
53
60
  }
54
61
  seen.add(action.id);
55
- nodes.push(action as ActionGraphNodes[number]);
62
+ nodes.push((surface ? applyPresentation(action, surface) : action) as ActionGraphNodes[number]);
56
63
  // A group's children are the actions contributed for the group's own id.
57
64
  if (Node.isActionGroup(action)) {
58
65
  visit(action.id, action.id);
@@ -68,6 +75,12 @@ export const buildGraphMenu = (
68
75
  * Read a node's contributed actions from the app-graph as menu `ActionGraphProps`, for splicing into a
69
76
  * hand-built toolbar via `MenuBuilder.subgraph(graphActions(graph, get, nodeId))`. Reactive: reads the
70
77
  * graph's action atoms through `get`, so contributed actions appear/disappear as extensions update.
78
+ *
79
+ * @idiom org.dxos.react-ui-menu.graphActionsToolbar
80
+ * applies: Splicing a node's app-graph-contributed toolbar actions into a hand-built object toolbar
81
+ * instead-of: Reading `graph.actions(nodeId)` and filtering/wiring `nodes`/`edges` by hand in every toolbar
82
+ * uses: {@link graphActions}, {@link MenuBuilder}, {@link useMenuBuilder}
83
+ * related: org.dxos.react-ui-menu.toolbarMenu
71
84
  */
72
85
  export const graphActions = (
73
86
  graph: Graph.ReadableGraph | undefined,
package/src/index.ts CHANGED
@@ -7,3 +7,5 @@ export * from './hooks';
7
7
  export * from './types';
8
8
  export { createGapSeparator, createLineSeparator, createMenuAction, createMenuItemGroup } from './util';
9
9
  export { type ActionGroupBuilder, type ActionGroupBuilderFn, MenuBuilder } from './builder';
10
+ export { TOOLBAR_DISPOSITION, isToolbarAction } from './toolbar';
11
+ export { applyPresentation } from './presentation';
@@ -0,0 +1,19 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Node } from '@dxos/app-graph';
6
+ import { type MenuActionProperties } from '@dxos/ui-types';
7
+
8
+ /**
9
+ * Merges an action's `presentation[surface]` chrome override (if any) into its properties, letting a
10
+ * single action declare multiple dispositions yet render appropriately in each surface — e.g. a
11
+ * primary toolbar button and a plain nav-tree row from the same definition.
12
+ */
13
+ export const applyPresentation = <A extends Node.ActionLike>(action: A, surface: string): A => {
14
+ const presentation = (action.properties as MenuActionProperties).presentation?.[surface];
15
+ if (!presentation) {
16
+ return action;
17
+ }
18
+ return { ...action, properties: { ...action.properties, ...presentation } };
19
+ };
package/src/toolbar.ts ADDED
@@ -0,0 +1,11 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { Node } from '@dxos/app-graph';
6
+
7
+ /** `disposition` value an action opts into to appear in an object toolbar (vs context-menu-only). */
8
+ export const TOOLBAR_DISPOSITION = 'toolbar';
9
+
10
+ /** Filter for {@link graphActions}: keeps only actions a producer opted into the toolbar. */
11
+ export const isToolbarAction = (action: Node.ActionLike): boolean => Node.hasDisposition(action, TOOLBAR_DISPOSITION);
package/src/types.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { type Atom } from '@effect-atom/atom-react';
5
+ import { type Atom } from '@effect-atom/atom';
6
6
 
7
7
  import { Node } from '@dxos/app-graph';
8
8
  import { type IconButtonProps, type ToolbarSeparatorProps } from '@dxos/react-ui';
package/src/util.ts CHANGED
@@ -36,14 +36,14 @@ export const fallbackIcon = 'ph--circle-dashed--regular';
36
36
 
37
37
  export const createMenuAction = <P extends {} = {}>(
38
38
  id: string,
39
- invoke: () => void,
39
+ invoke: (params?: Node.InvokeProps) => void,
40
40
  properties: P & MenuActionProperties,
41
41
  ) =>
42
42
  ({
43
43
  id,
44
44
  type: Node.ActionType,
45
45
  properties,
46
- data: () => Effect.sync(invoke),
46
+ data: (params?: Node.InvokeProps) => Effect.sync(() => invoke(params)),
47
47
  }) satisfies MenuAction;
48
48
 
49
49
  export const createMenuItemGroup = <P extends MenuItemGroupProperties>(id: string, properties: P) =>