@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.
- package/dist/lib/browser/index.mjs +156 -43
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/translations.mjs +3 -1
- package/dist/lib/browser/translations.mjs.map +2 -2
- package/dist/lib/node-esm/index.mjs +156 -43
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/translations.mjs +3 -1
- package/dist/lib/node-esm/translations.mjs.map +2 -2
- package/dist/types/src/builder.d.ts +15 -3
- package/dist/types/src/builder.d.ts.map +1 -1
- package/dist/types/src/components/ActionLabel.d.ts +2 -2
- package/dist/types/src/components/ActionLabel.d.ts.map +1 -1
- package/dist/types/src/components/Menu.d.ts +4 -4
- package/dist/types/src/components/Menu.d.ts.map +1 -1
- package/dist/types/src/components/Menu.stories.d.ts +3 -1
- package/dist/types/src/components/Menu.stories.d.ts.map +1 -1
- package/dist/types/src/components/ToolbarMenu.d.ts +14 -12
- package/dist/types/src/components/ToolbarMenu.d.ts.map +1 -1
- package/dist/types/src/components/ToolbarMenu.stories.d.ts +7 -1
- package/dist/types/src/components/ToolbarMenu.stories.d.ts.map +1 -1
- package/dist/types/src/hooks/index.d.ts +1 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useGraphMenuActions.d.ts +31 -0
- package/dist/types/src/hooks/useGraphMenuActions.d.ts.map +1 -0
- package/dist/types/src/hooks/useGraphMenuActions.test.d.ts +2 -0
- package/dist/types/src/hooks/useGraphMenuActions.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +1 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +3 -1
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/src/types.d.ts +7 -11
- package/dist/types/src/types.d.ts.map +1 -1
- package/dist/types/src/util.d.ts +8 -3
- package/dist/types/src/util.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +25 -25
- package/src/builder.test.ts +50 -11
- package/src/builder.ts +36 -5
- package/src/components/ActionLabel.tsx +2 -2
- package/src/components/Menu.tsx +7 -12
- package/src/components/ToolbarMenu.stories.tsx +31 -2
- package/src/components/ToolbarMenu.tsx +125 -66
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useGraphMenuActions.test.ts +93 -0
- package/src/hooks/useGraphMenuActions.ts +96 -0
- package/src/index.ts +1 -1
- package/src/translations.ts +3 -1
- package/src/types.ts +9 -6
- 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 {
|
|
8
|
+
export { createGapSeparator, createLineSeparator, createMenuAction, createMenuItemGroup } from './util';
|
|
9
9
|
export { type ActionGroupBuilder, type ActionGroupBuilderFn, MenuBuilder } from './builder';
|
package/src/translations.ts
CHANGED
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
|
-
|
|
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?:
|
|
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?:
|
|
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,
|