@dxos/react-ui-menu 0.9.0 → 0.9.1-main.c7dcc2e112
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 +107 -42
- 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 +107 -42
- 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 +11 -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 +3 -3
- 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 +3 -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/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 +7 -3
- package/dist/types/src/util.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +15 -15
- package/src/builder.test.ts +50 -11
- package/src/builder.ts +29 -5
- package/src/components/ActionLabel.tsx +2 -2
- package/src/components/Menu.tsx +5 -10
- package/src/components/ToolbarMenu.tsx +65 -65
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useGraphMenuActions.test.ts +93 -0
- package/src/hooks/useGraphMenuActions.ts +96 -0
- package/src/translations.ts +3 -1
- package/src/types.ts +9 -6
- 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.
|
|
3
|
+
"version": "0.9.1-main.c7dcc2e112",
|
|
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,14 +37,14 @@
|
|
|
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.
|
|
41
|
-
"@dxos/
|
|
42
|
-
"@dxos/invariant": "0.9.
|
|
43
|
-
"@dxos/
|
|
44
|
-
"@dxos/
|
|
45
|
-
"@dxos/
|
|
46
|
-
"@dxos/
|
|
47
|
-
"@dxos/
|
|
40
|
+
"@dxos/app-graph": "0.9.1-main.c7dcc2e112",
|
|
41
|
+
"@dxos/effect": "0.9.1-main.c7dcc2e112",
|
|
42
|
+
"@dxos/invariant": "0.9.1-main.c7dcc2e112",
|
|
43
|
+
"@dxos/log": "0.9.1-main.c7dcc2e112",
|
|
44
|
+
"@dxos/react-ui-attention": "0.9.1-main.c7dcc2e112",
|
|
45
|
+
"@dxos/ui-types": "0.9.1-main.c7dcc2e112",
|
|
46
|
+
"@dxos/util": "0.9.1-main.c7dcc2e112",
|
|
47
|
+
"@dxos/keyboard": "0.9.1-main.c7dcc2e112"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@effect-atom/atom-react": "^0.5.0",
|
|
@@ -57,10 +57,10 @@
|
|
|
57
57
|
"react": "~19.2.3",
|
|
58
58
|
"react-dom": "~19.2.3",
|
|
59
59
|
"vite": "^8.0.16",
|
|
60
|
-
"@dxos/
|
|
61
|
-
"@dxos/
|
|
62
|
-
"@dxos/
|
|
63
|
-
"@dxos/
|
|
60
|
+
"@dxos/react-ui": "0.9.1-main.c7dcc2e112",
|
|
61
|
+
"@dxos/random": "0.9.1-main.c7dcc2e112",
|
|
62
|
+
"@dxos/ui-theme": "0.9.1-main.c7dcc2e112",
|
|
63
|
+
"@dxos/storybook-utils": "0.9.1-main.c7dcc2e112"
|
|
64
64
|
},
|
|
65
65
|
"peerDependencies": {
|
|
66
66
|
"@effect-atom/atom-react": "^0.5.0",
|
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
"effect": "3.21.3",
|
|
69
69
|
"react": "~19.2.3",
|
|
70
70
|
"react-dom": "~19.2.3",
|
|
71
|
-
"@dxos/
|
|
72
|
-
"@dxos/ui
|
|
71
|
+
"@dxos/ui-theme": "0.9.1-main.c7dcc2e112",
|
|
72
|
+
"@dxos/react-ui": "0.9.1-main.c7dcc2e112"
|
|
73
73
|
},
|
|
74
74
|
"publishConfig": {
|
|
75
75
|
"access": "public"
|
package/src/builder.test.ts
CHANGED
|
@@ -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(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
|
@@ -31,7 +42,7 @@ export interface ActionGroupBuilder {
|
|
|
31
42
|
action<P extends {} = {}>(id: string, props: P & MenuActionProperties, invoke: () => void): this;
|
|
32
43
|
|
|
33
44
|
/** Add a nested action group. */
|
|
34
|
-
group
|
|
45
|
+
group(id: string, props: MenuItemGroupProperties, cb: ActionGroupBuilderFn): this;
|
|
35
46
|
|
|
36
47
|
/** Merge pre-built nodes and edges, or build a subgraph via callback scoped to the current group. Falsy values are ignored. */
|
|
37
48
|
subgraph(subgraphOrCb: ActionGraphProps | ActionGroupBuilderFn | false | null | undefined): this;
|
|
@@ -42,10 +53,19 @@ export interface ActionGroupBuilder {
|
|
|
42
53
|
|
|
43
54
|
/** Top-level builder that creates the root group and produces the final action graph. */
|
|
44
55
|
export interface MenuBuilder extends ActionGroupBuilder {
|
|
45
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Set properties on the root menu group. May only be called once.
|
|
58
|
+
*/
|
|
46
59
|
root(props: MenuItemGroupProperties): this;
|
|
47
60
|
|
|
48
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Add a root-level toolbar overflow dropdown (⋮) with built-in trigger styling.
|
|
63
|
+
*/
|
|
64
|
+
menu(id: string, cb: ActionGroupBuilderFn): this;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Return the assembled action graph.
|
|
68
|
+
*/
|
|
49
69
|
build(): ActionGraphProps;
|
|
50
70
|
}
|
|
51
71
|
|
|
@@ -74,13 +94,17 @@ class MenuBuilderImpl implements MenuBuilder {
|
|
|
74
94
|
return this;
|
|
75
95
|
}
|
|
76
96
|
|
|
77
|
-
group
|
|
97
|
+
group(id: string, props: MenuItemGroupProperties, cb: ActionGroupBuilderFn): this {
|
|
78
98
|
this._data.nodes.push(createMenuItemGroup(id, props));
|
|
79
99
|
this._data.edges.push({ source: this._rootId, target: id, relation: 'child' });
|
|
80
100
|
cb(new MenuBuilderImpl(this._data, id));
|
|
81
101
|
return this;
|
|
82
102
|
}
|
|
83
103
|
|
|
104
|
+
menu(id: string, cb: ActionGroupBuilderFn): this {
|
|
105
|
+
return this.group(id, overflowMenuProperties, cb);
|
|
106
|
+
}
|
|
107
|
+
|
|
84
108
|
subgraph(subgraphOrCb: ActionGraphProps | ActionGroupBuilderFn | false | null | undefined): this {
|
|
85
109
|
if (!subgraphOrCb) {
|
|
86
110
|
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<
|
|
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);
|
package/src/components/Menu.tsx
CHANGED
|
@@ -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:
|
|
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?:
|
|
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?:
|
|
236
|
+
group?: MenuGroupContext;
|
|
242
237
|
items?: MenuItem[];
|
|
243
238
|
caller?: string;
|
|
244
239
|
};
|
|
@@ -4,42 +4,24 @@
|
|
|
4
4
|
|
|
5
5
|
import React, { useCallback } from 'react';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { Toolbar as NaturalToolbar, type ToolbarRootProps, useTranslation } from '@dxos/react-ui';
|
|
8
8
|
import { composable, composableProps } from '@dxos/react-ui';
|
|
9
9
|
import { useAttention } from '@dxos/react-ui-attention';
|
|
10
|
-
import { type
|
|
10
|
+
import { type DropdownMenuItemGroupProperties, type ToggleGroupMenuItemGroupProperties } from '@dxos/ui-types';
|
|
11
11
|
|
|
12
12
|
import { translationKey } from '#translations';
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
type MenuAction,
|
|
16
|
-
type MenuItem,
|
|
17
|
-
type MenuItemGroup,
|
|
18
|
-
type MenuMultipleSelectActionGroup,
|
|
19
|
-
type MenuSingleSelectActionGroup,
|
|
20
|
-
isMenuGroup,
|
|
21
|
-
isSeparator,
|
|
22
|
-
} from '../types';
|
|
14
|
+
import { type MenuAction, type MenuItem, type MenuItemGroup, isMenuGroup, isSeparator } from '../types';
|
|
23
15
|
import { executeMenuAction } from '../util';
|
|
24
16
|
import { ActionLabel, actionLabel } from './ActionLabel';
|
|
25
17
|
import { DropdownMenu } from './DropdownMenu';
|
|
26
18
|
import { type MenuScopedProps, useMenuItems, useMenuScoped } from './Menu';
|
|
27
19
|
|
|
28
|
-
export type ToolbarMenuDropdownMenuActionGroup =
|
|
29
|
-
variant: 'dropdownMenu';
|
|
30
|
-
icon: string;
|
|
31
|
-
applyActive?: boolean;
|
|
32
|
-
};
|
|
20
|
+
export type ToolbarMenuDropdownMenuActionGroup = DropdownMenuItemGroupProperties;
|
|
33
21
|
|
|
34
|
-
export type ToolbarMenuToggleGroupActionGroup =
|
|
35
|
-
variant: 'toggleGroup';
|
|
36
|
-
};
|
|
22
|
+
export type ToolbarMenuToggleGroupActionGroup = ToggleGroupMenuItemGroupProperties;
|
|
37
23
|
|
|
38
|
-
export type ToolbarMenuActionGroupProperties =
|
|
39
|
-
| ToolbarMenuDropdownMenuActionGroup
|
|
40
|
-
| ToolbarMenuToggleGroupActionGroup
|
|
41
|
-
) &
|
|
42
|
-
(MenuSingleSelectActionGroup | MenuMultipleSelectActionGroup);
|
|
24
|
+
export type ToolbarMenuActionGroupProperties = DropdownMenuItemGroupProperties | ToggleGroupMenuItemGroupProperties;
|
|
43
25
|
|
|
44
26
|
export type ToolbarMenuProps = ToolbarRootProps;
|
|
45
27
|
|
|
@@ -48,8 +30,18 @@ export type ToolbarMenuActionGroupProps = {
|
|
|
48
30
|
items?: MenuItem[];
|
|
49
31
|
};
|
|
50
32
|
|
|
33
|
+
export type ToolbarMenuDropdownGroupProps = {
|
|
34
|
+
group: MenuItemGroup<DropdownMenuItemGroupProperties>;
|
|
35
|
+
items?: MenuItem[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type ToolbarMenuToggleGroupProps = {
|
|
39
|
+
group: MenuItemGroup<ToggleGroupMenuItemGroupProperties>;
|
|
40
|
+
items?: MenuItem[];
|
|
41
|
+
};
|
|
42
|
+
|
|
51
43
|
export type ToolbarMenuActionProps = {
|
|
52
|
-
group: MenuItemGroup<
|
|
44
|
+
group: MenuItemGroup<ToggleGroupMenuItemGroupProperties>;
|
|
53
45
|
action: MenuAction;
|
|
54
46
|
};
|
|
55
47
|
|
|
@@ -102,46 +94,54 @@ const DropdownMenuToolbarItem = ({
|
|
|
102
94
|
__menuScope,
|
|
103
95
|
group,
|
|
104
96
|
items: propsItems,
|
|
105
|
-
}: MenuScopedProps<
|
|
106
|
-
const { iconOnly, disabled, testId } = group.properties;
|
|
97
|
+
}: MenuScopedProps<ToolbarMenuDropdownGroupProps>) => {
|
|
107
98
|
const { t } = useTranslation(translationKey);
|
|
108
99
|
const { iconSize } = useMenuScoped('DropdownMenuToolbarItem', __menuScope);
|
|
109
100
|
const items = useMenuItems(group, propsItems, 'DropdownMenuToolbarItem', __menuScope);
|
|
110
|
-
const
|
|
101
|
+
const {
|
|
102
|
+
iconOnly,
|
|
103
|
+
disabled,
|
|
104
|
+
testId,
|
|
105
|
+
applyActive,
|
|
106
|
+
caretDown = true,
|
|
107
|
+
icon: groupIcon,
|
|
108
|
+
iconClassNames: groupIconClassNames,
|
|
109
|
+
} = group.properties;
|
|
110
|
+
const activeItem = items?.find((item) => !!(item as MenuAction).properties.checked) as MenuAction | undefined;
|
|
111
111
|
const icon =
|
|
112
|
-
(
|
|
112
|
+
(applyActive &&
|
|
113
113
|
// TODO(thure): Handle other menu item types.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
// Follow the same `applyActive` rule for `iconClassNames` so a per-item
|
|
117
|
-
|
|
118
|
-
const
|
|
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
|
-
};
|
|
114
|
+
activeItem?.properties.icon) ||
|
|
115
|
+
groupIcon;
|
|
116
|
+
// Follow the same `applyActive` rule for `iconClassNames` so a per-item accent (e.g. tag colour) tracks the displayed icon.
|
|
117
|
+
const iconClassNames = (applyActive && activeItem?.properties.iconClassNames) || groupIconClassNames;
|
|
118
|
+
const labelAction = applyActive && activeItem ? activeItem : group;
|
|
140
119
|
|
|
141
120
|
return (
|
|
142
121
|
<DropdownMenu.Root group={group} items={items}>
|
|
143
122
|
<DropdownMenu.Trigger asChild>
|
|
144
|
-
|
|
123
|
+
{icon ? (
|
|
124
|
+
<NaturalToolbar.IconButton
|
|
125
|
+
variant='ghost'
|
|
126
|
+
disabled={disabled}
|
|
127
|
+
icon={icon}
|
|
128
|
+
size={iconSize}
|
|
129
|
+
iconOnly={iconOnly}
|
|
130
|
+
iconClassNames={iconClassNames}
|
|
131
|
+
label={actionLabel(labelAction, t)}
|
|
132
|
+
caretDown={caretDown}
|
|
133
|
+
{...(testId && { 'data-testid': testId })}
|
|
134
|
+
/>
|
|
135
|
+
) : (
|
|
136
|
+
<NaturalToolbar.Button
|
|
137
|
+
variant='ghost'
|
|
138
|
+
disabled={disabled}
|
|
139
|
+
caretDown={caretDown}
|
|
140
|
+
{...(testId && { 'data-testid': testId })}
|
|
141
|
+
>
|
|
142
|
+
<ActionLabel action={labelAction} />
|
|
143
|
+
</NaturalToolbar.Button>
|
|
144
|
+
)}
|
|
145
145
|
</DropdownMenu.Trigger>
|
|
146
146
|
</DropdownMenu.Root>
|
|
147
147
|
);
|
|
@@ -189,7 +189,7 @@ const ToggleGroupToolbarItem = ({
|
|
|
189
189
|
__menuScope,
|
|
190
190
|
group,
|
|
191
191
|
items: itemsProp,
|
|
192
|
-
}: MenuScopedProps<
|
|
192
|
+
}: MenuScopedProps<ToolbarMenuToggleGroupProps>) => {
|
|
193
193
|
const items = useMenuItems(group, itemsProp, 'ToggleGroupToolbarItem', __menuScope);
|
|
194
194
|
const { selectCardinality } = group.properties;
|
|
195
195
|
|
|
@@ -244,17 +244,17 @@ const ToolbarMenuItem = ({ __menuScope, item }: MenuScopedProps<{ item: MenuItem
|
|
|
244
244
|
return (
|
|
245
245
|
<DropdownMenuToolbarItem
|
|
246
246
|
__menuScope={__menuScope}
|
|
247
|
-
group={item as MenuItemGroup<
|
|
248
|
-
/>
|
|
249
|
-
);
|
|
250
|
-
} else {
|
|
251
|
-
return (
|
|
252
|
-
<ToggleGroupToolbarItem
|
|
253
|
-
__menuScope={__menuScope}
|
|
254
|
-
group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>}
|
|
247
|
+
group={item as MenuItemGroup<DropdownMenuItemGroupProperties>}
|
|
255
248
|
/>
|
|
256
249
|
);
|
|
257
250
|
}
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<ToggleGroupToolbarItem
|
|
254
|
+
__menuScope={__menuScope}
|
|
255
|
+
group={item as MenuItemGroup<ToggleGroupMenuItemGroupProperties>}
|
|
256
|
+
/>
|
|
257
|
+
);
|
|
258
258
|
}
|
|
259
259
|
|
|
260
260
|
return <ActionToolbarItem __menuScope={__menuScope} action={item as MenuAction} />;
|
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
+
};
|