@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
@@ -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
+ };
package/src/index.ts CHANGED
@@ -5,5 +5,5 @@
5
5
  export * from './components';
6
6
  export * from './hooks';
7
7
  export * from './types';
8
- export { createMenuAction, createMenuItemGroup, createLineSeparator, createGapSeparator } from './util';
8
+ export { createGapSeparator, createLineSeparator, createMenuAction, createMenuItemGroup } from './util';
9
9
  export { type ActionGroupBuilder, type ActionGroupBuilderFn, MenuBuilder } from './builder';
@@ -9,7 +9,9 @@ export const translationKey = '@dxos/react-ui-menu';
9
9
  export const translations = [
10
10
  {
11
11
  'en-US': {
12
- [translationKey]: {},
12
+ [translationKey]: {
13
+ 'toolbar-overflow.menu': 'More',
14
+ },
13
15
  },
14
16
  },
15
17
  ] as const satisfies Resource[];
package/src/types.ts CHANGED
@@ -18,17 +18,20 @@ export type MenuSeparator = Node.Node<never, Pick<ToolbarSeparatorProps, 'varian
18
18
 
19
19
  export const isSeparator = (node: Node.Node): node is MenuSeparator => node.type === MenuSeparatorType;
20
20
 
21
- export type MenuSingleSelectActionGroup = { selectCardinality: 'single'; value: string };
22
- export type MenuMultipleSelectActionGroup = { selectCardinality: 'multiple'; value: string[] };
23
-
24
21
  export type MenuItemGroup<P extends Record<string, any> = Record<string, any>> = Node.ActionGroup<P>;
25
22
 
26
23
  export const isMenuGroup = (node: Node.Node): node is MenuItemGroup => node.type === Node.ActionGroupType;
27
24
 
28
- export type MenuItem = MenuSeparator | MenuAction | MenuItemGroup;
25
+ /** Graph-sourced menu nodes carry plugin-specific properties validated at runtime. */
26
+ export type GraphMenuItem = Node.Action | Node.ActionGroup;
27
+
28
+ export type MenuItem = MenuSeparator | MenuAction | MenuItemGroup | GraphMenuItem;
29
+
30
+ /** Group context for graph-backed dropdown menus (any graph node, not only action groups). */
31
+ export type MenuGroupContext = MenuItemGroup | Node.Node;
29
32
 
30
33
  /** Atom-family-style accessor: returns an atom of items for a given group (or root when undefined). */
31
- export type MenuItemsAccessor = (group?: MenuItemGroup) => Atom.Atom<MenuItem[] | null>;
34
+ export type MenuItemsAccessor = (group?: MenuGroupContext) => Atom.Atom<MenuItem[] | null>;
32
35
 
33
36
  export type ActionExecutor = (action: MenuAction, params: Node.InvokeProps) => void;
34
37
 
@@ -39,7 +42,7 @@ export type AddMenuItemsProps = {
39
42
  mode: MenuItemsMode;
40
43
  priority?: number;
41
44
  items: MenuItem[];
42
- groupFilter?: (group?: MenuItemGroup) => boolean;
45
+ groupFilter?: (group?: MenuGroupContext) => boolean;
43
46
  };
44
47
 
45
48
  export type MenuItems = Omit<AddMenuItemsProps, 'priority'> & { priority: number };
package/src/util.ts CHANGED
@@ -46,12 +46,7 @@ export const createMenuAction = <P extends {} = {}>(
46
46
  data: () => Effect.sync(invoke),
47
47
  }) satisfies MenuAction;
48
48
 
49
- export const createMenuItemGroup = <
50
- P extends MenuItemGroupProperties = MenuItemGroupProperties & Partial<MenuActionProperties>,
51
- >(
52
- id: string,
53
- properties: P,
54
- ) =>
49
+ export const createMenuItemGroup = <P extends MenuItemGroupProperties>(id: string, properties: P) =>
55
50
  ({
56
51
  id,
57
52
  type: Node.ActionGroupType,