@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.
- package/dist/lib/index.mjs +747 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/translations.mjs +7 -0
- package/dist/lib/translations.mjs.map +1 -0
- package/dist/types/src/builder.d.ts +1 -1
- package/dist/types/src/builder.d.ts.map +1 -1
- package/dist/types/src/components/ActionLabel.d.ts +0 -2
- package/dist/types/src/components/ActionLabel.d.ts.map +1 -1
- package/dist/types/src/components/DropdownMenu.d.ts +2 -4
- package/dist/types/src/components/DropdownMenu.d.ts.map +1 -1
- package/dist/types/src/components/Menu.d.ts +5 -15
- package/dist/types/src/components/Menu.d.ts.map +1 -1
- package/dist/types/src/components/MenuContext.d.ts +28 -0
- package/dist/types/src/components/MenuContext.d.ts.map +1 -0
- package/dist/types/src/components/ToolbarMenu.d.ts +2 -2
- package/dist/types/src/components/ToolbarMenu.d.ts.map +1 -1
- package/dist/types/src/components/ToolbarMenu.stories.d.ts +2 -2
- package/dist/types/src/components/action-label.d.ts +7 -0
- package/dist/types/src/components/action-label.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +2 -0
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useGraphMenuActions.d.ts +13 -1
- package/dist/types/src/hooks/useGraphMenuActions.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +2 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/presentation.d.ts +8 -0
- package/dist/types/src/presentation.d.ts.map +1 -0
- package/dist/types/src/toolbar.d.ts +6 -0
- package/dist/types/src/toolbar.d.ts.map +1 -0
- package/dist/types/src/types.d.ts +1 -1
- package/dist/types/src/types.d.ts.map +1 -1
- package/dist/types/src/util.d.ts +5 -3
- package/dist/types/src/util.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +19 -20
- package/src/builder.ts +3 -3
- package/src/components/ActionLabel.tsx +1 -6
- package/src/components/DropdownMenu.tsx +30 -8
- package/src/components/Menu.stories.tsx +1 -1
- package/src/components/Menu.test.tsx +3 -2
- package/src/components/Menu.tsx +32 -122
- package/src/components/MenuContext.ts +129 -0
- package/src/components/ToolbarMenu.tsx +27 -13
- package/src/components/action-label.ts +20 -0
- package/src/components/index.ts +3 -0
- package/src/hooks/useGraphMenuActions.ts +16 -3
- package/src/index.ts +2 -0
- package/src/presentation.ts +19 -0
- package/src/toolbar.ts +11 -0
- package/src/types.ts +1 -1
- package/src/util.ts +2 -2
- package/dist/lib/browser/index.mjs +0 -797
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/browser/translations.mjs +0 -16
- package/dist/lib/browser/translations.mjs.map +0 -7
- package/dist/lib/node-esm/index.mjs +0 -799
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
- package/dist/lib/node-esm/translations.mjs +0 -18
- 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.
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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.
|
|
41
|
-
"@dxos/
|
|
42
|
-
"@dxos/
|
|
43
|
-
"@dxos/
|
|
44
|
-
"@dxos/
|
|
45
|
-
"@dxos/ui-types": "0.
|
|
46
|
-
"@dxos/util": "0.
|
|
47
|
-
"@dxos/react-ui-attention": "0.
|
|
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.
|
|
60
|
-
"@dxos/random": "0.
|
|
61
|
-
"@dxos/
|
|
62
|
-
"@dxos/
|
|
63
|
-
"@dxos/
|
|
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/react-ui": "0.
|
|
72
|
-
"@dxos/ui-theme": "0.
|
|
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 {
|
|
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 './
|
|
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 &&
|
|
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,
|
|
92
|
+
onAction(action, params);
|
|
75
93
|
} else {
|
|
76
|
-
void executeMenuAction(action,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
12
|
+
import { Menu } from './Menu';
|
|
13
|
+
import { useMenu, useMenuItems } from './MenuContext';
|
|
13
14
|
|
|
14
15
|
const TEST_CONTRIBUTOR = 'TestContributor';
|
|
15
16
|
|
package/src/components/Menu.tsx
CHANGED
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { Atom, RegistryContext
|
|
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,
|
|
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 &&
|
|
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,
|
|
196
|
+
onAction(action, params);
|
|
291
197
|
} else {
|
|
292
|
-
void executeMenuAction(action,
|
|
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
|
-
|
|
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
|
|
242
|
+
export { Menu };
|
|
333
243
|
|
|
334
|
-
export type { MenuContentProps, MenuRootProps
|
|
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
|
+
};
|