@dxos/react-ui-menu 0.7.5-feature-compute.4d9d99a
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/LICENSE +8 -0
- package/README.md +3 -0
- package/dist/lib/browser/index.mjs +438 -0
- package/dist/lib/browser/index.mjs.map +7 -0
- package/dist/lib/browser/meta.json +1 -0
- package/dist/lib/node/index.cjs +465 -0
- package/dist/lib/node/index.cjs.map +7 -0
- package/dist/lib/node/meta.json +1 -0
- package/dist/lib/node-esm/index.mjs +440 -0
- package/dist/lib/node-esm/index.mjs.map +7 -0
- package/dist/lib/node-esm/meta.json +1 -0
- package/dist/types/src/components/ActionLabel.d.ts +7 -0
- package/dist/types/src/components/ActionLabel.d.ts.map +1 -0
- package/dist/types/src/components/DropdownMenu.d.ts +20 -0
- package/dist/types/src/components/DropdownMenu.d.ts.map +1 -0
- package/dist/types/src/components/MenuContext.d.ts +13 -0
- package/dist/types/src/components/MenuContext.d.ts.map +1 -0
- package/dist/types/src/components/ToolbarMenu.d.ts +20 -0
- package/dist/types/src/components/ToolbarMenu.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +4 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/defs.d.ts +45 -0
- package/dist/types/src/defs.d.ts.map +1 -0
- package/dist/types/src/hooks.d.ts +17 -0
- package/dist/types/src/hooks.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +5 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/testing/Menus.stories.d.ts +25 -0
- package/dist/types/src/testing/Menus.stories.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +26 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/translations.d.ts +8 -0
- package/dist/types/src/translations.d.ts.map +1 -0
- package/dist/types/src/util.d.ts +45 -0
- package/dist/types/src/util.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +55 -0
- package/src/components/ActionLabel.tsx +30 -0
- package/src/components/DropdownMenu.tsx +112 -0
- package/src/components/MenuContext.tsx +57 -0
- package/src/components/ToolbarMenu.tsx +185 -0
- package/src/components/index.ts +7 -0
- package/src/defs.ts +54 -0
- package/src/hooks.ts +72 -0
- package/src/index.ts +8 -0
- package/src/testing/Menus.stories.tsx +67 -0
- package/src/testing/index.ts +111 -0
- package/src/translations.ts +13 -0
- package/src/util.ts +71 -0
package/src/defs.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
import { type Node, type Action, type ActionGroup, ACTION_GROUP_TYPE } from '@dxos/app-graph';
|
|
5
|
+
import { type ToolbarSeparatorProps, type Label, type IconButtonProps, type ClassNameValue } from '@dxos/react-ui';
|
|
6
|
+
|
|
7
|
+
export type MenuActionProperties = {
|
|
8
|
+
label: Label;
|
|
9
|
+
icon?: string;
|
|
10
|
+
value?: string;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
hidden?: boolean;
|
|
13
|
+
iconOnly?: boolean;
|
|
14
|
+
testId?: string;
|
|
15
|
+
variant?: 'action' | 'toggle';
|
|
16
|
+
checked?: boolean;
|
|
17
|
+
classNames?: ClassNameValue;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type MenuItemGroupProperties = {
|
|
21
|
+
label: Label;
|
|
22
|
+
icon?: string;
|
|
23
|
+
iconOnly?: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type MenuAction<P extends {} = {}> = Action<P & MenuActionProperties>;
|
|
27
|
+
|
|
28
|
+
export type MenuActionHandler<A extends Node = MenuAction> = (action: A) => void;
|
|
29
|
+
|
|
30
|
+
export const MenuSeparatorType = '@dxos/react-ui-toolbar/separator' as const;
|
|
31
|
+
|
|
32
|
+
export type MenuSeparator = Node<never, Pick<ToolbarSeparatorProps, 'variant'>> & {
|
|
33
|
+
type: typeof MenuSeparatorType;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const isSeparator = (node: Node): node is MenuSeparator => node.type === MenuSeparatorType;
|
|
37
|
+
|
|
38
|
+
export type MenuSingleSelectActionGroup = { selectCardinality: 'single'; value: string };
|
|
39
|
+
export type MenuMultipleSelectActionGroup = { selectCardinality: 'multiple'; value: string[] };
|
|
40
|
+
|
|
41
|
+
export type MenuItemGroup<P extends Record<string, any> = Record<string, any>> = ActionGroup<P>;
|
|
42
|
+
|
|
43
|
+
export const isMenuGroup = (node: Node): node is MenuItemGroup => node.type === ACTION_GROUP_TYPE;
|
|
44
|
+
|
|
45
|
+
export type MenuItem = MenuSeparator | MenuAction | MenuItemGroup;
|
|
46
|
+
|
|
47
|
+
export type MenuItemsResolver = (group?: MenuItemGroup) => MenuItem[] | null;
|
|
48
|
+
|
|
49
|
+
export type MenuContextValue<A extends Node = MenuAction> = {
|
|
50
|
+
resolveGroupItems: MenuItemsResolver;
|
|
51
|
+
iconSize: IconButtonProps['size'];
|
|
52
|
+
onAction: MenuActionHandler<A>;
|
|
53
|
+
attendableId?: string;
|
|
54
|
+
};
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { useSignalEffect } from '@preact/signals-react';
|
|
6
|
+
import { useCallback, useState } from 'react';
|
|
7
|
+
|
|
8
|
+
import { Graph, type NodeArg, type Node } from '@dxos/app-graph';
|
|
9
|
+
|
|
10
|
+
import { type MenuItem, type MenuItemGroup } from './defs';
|
|
11
|
+
|
|
12
|
+
type Edge = { source: string; target: string };
|
|
13
|
+
|
|
14
|
+
const edgesArrayToRecord = (edges: { source: string; target: string }[]): Record<string, string[]> => {
|
|
15
|
+
return Object.fromEntries(
|
|
16
|
+
Object.entries(
|
|
17
|
+
edges.reduce((acc: Record<string, { inbound: string[]; outbound: string[] }>, { source, target }) => {
|
|
18
|
+
if (!acc[source]) {
|
|
19
|
+
acc[source] = { inbound: [], outbound: [] };
|
|
20
|
+
}
|
|
21
|
+
if (!acc[target]) {
|
|
22
|
+
acc[target] = { inbound: [], outbound: [] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sourceEdges = acc[source];
|
|
26
|
+
if (!sourceEdges.outbound.includes(target)) {
|
|
27
|
+
sourceEdges.outbound.push(target);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const targetEdges = acc[target];
|
|
31
|
+
if (!targetEdges.inbound.includes(source)) {
|
|
32
|
+
targetEdges.inbound.push(source);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return acc;
|
|
36
|
+
}, {}),
|
|
37
|
+
).map(([id, { outbound }]): [string, string[]] => [id, outbound]),
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type ActionGraphNodes = NodeArg<any>[];
|
|
42
|
+
export type ActionGraphEdges = Edge[];
|
|
43
|
+
export type ActionGraphProps = { nodes: ActionGraphNodes; edges: ActionGraphEdges };
|
|
44
|
+
|
|
45
|
+
export const useMenuActions = (actionCreator: () => ActionGraphProps) => {
|
|
46
|
+
const initialMenuGraphProps = actionCreator();
|
|
47
|
+
|
|
48
|
+
const [graph] = useState(
|
|
49
|
+
new Graph({ nodes: initialMenuGraphProps.nodes as Node[], edges: edgesArrayToRecord(initialMenuGraphProps.edges) }),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
useSignalEffect(() => {
|
|
53
|
+
const menuGraphProps = actionCreator();
|
|
54
|
+
// @ts-ignore
|
|
55
|
+
graph._addNodes(menuGraphProps.nodes);
|
|
56
|
+
// @ts-ignore
|
|
57
|
+
graph._addEdges(menuGraphProps.edges);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const resolveGroupItems = useCallback(
|
|
61
|
+
(sourceNode: MenuItemGroup = graph.root as MenuItemGroup) => {
|
|
62
|
+
if (graph) {
|
|
63
|
+
return (graph.nodes(sourceNode, { filter: (n): n is any => true }) || null) as MenuItem[] | null;
|
|
64
|
+
} else {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
[graph],
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return { resolveGroupItems };
|
|
72
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import '@dxos-theme';
|
|
6
|
+
import React, { useCallback } from 'react';
|
|
7
|
+
|
|
8
|
+
import { faker } from '@dxos/random';
|
|
9
|
+
import { IconButton } from '@dxos/react-ui';
|
|
10
|
+
import { withLayout, withSignals, withTheme } from '@dxos/storybook-utils';
|
|
11
|
+
|
|
12
|
+
import { createActions, createNestedActions, createNestedActionsResolver, useMutateActions } from './index';
|
|
13
|
+
import { DropdownMenu as NaturalDropdownMenu, ToolbarMenu, MenuProvider } from '../components';
|
|
14
|
+
import { type MenuAction, type MenuActionHandler } from '../defs';
|
|
15
|
+
import { useMenuActions } from '../hooks';
|
|
16
|
+
import translations from '../translations';
|
|
17
|
+
|
|
18
|
+
faker.seed(1234);
|
|
19
|
+
|
|
20
|
+
export default {
|
|
21
|
+
title: 'ui/react-ui-menu/Menus',
|
|
22
|
+
component: ToolbarMenu,
|
|
23
|
+
decorators: [withTheme, withLayout({ tooltips: true }), withSignals],
|
|
24
|
+
parameters: { translations },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const menuActions = createActions() as MenuAction[];
|
|
28
|
+
const nestedMenuActions = createNestedActionsResolver();
|
|
29
|
+
|
|
30
|
+
const handleAction = (action: MenuAction) => console.log('[on action]', action);
|
|
31
|
+
|
|
32
|
+
export const DropdownMenu = {
|
|
33
|
+
render: () => {
|
|
34
|
+
useMutateActions(menuActions);
|
|
35
|
+
const resolveGroupItems = useCallback(() => menuActions, []);
|
|
36
|
+
return (
|
|
37
|
+
<MenuProvider resolveGroupItems={resolveGroupItems} onAction={handleAction as MenuActionHandler}>
|
|
38
|
+
<NaturalDropdownMenu.Root>
|
|
39
|
+
<NaturalDropdownMenu.Trigger asChild>
|
|
40
|
+
<IconButton icon='ph--list-checks--regular' size={5} label='Options' />
|
|
41
|
+
</NaturalDropdownMenu.Trigger>
|
|
42
|
+
</NaturalDropdownMenu.Root>
|
|
43
|
+
</MenuProvider>
|
|
44
|
+
);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const Toolbar = {
|
|
49
|
+
render: () => {
|
|
50
|
+
return (
|
|
51
|
+
<MenuProvider onAction={handleAction as MenuActionHandler} {...nestedMenuActions}>
|
|
52
|
+
<ToolbarMenu />
|
|
53
|
+
</MenuProvider>
|
|
54
|
+
);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const UseMenuActionsToolbar = {
|
|
59
|
+
render: () => {
|
|
60
|
+
const menu = useMenuActions(createNestedActions);
|
|
61
|
+
return (
|
|
62
|
+
<MenuProvider onAction={handleAction as MenuActionHandler} {...menu}>
|
|
63
|
+
<ToolbarMenu />
|
|
64
|
+
</MenuProvider>
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { useEffect } from 'react';
|
|
6
|
+
|
|
7
|
+
import { type Action, Graph, type NodeArg, ACTION_TYPE, ACTION_GROUP_TYPE, actionGroupSymbol } from '@dxos/app-graph';
|
|
8
|
+
import { create } from '@dxos/live-object';
|
|
9
|
+
import { faker } from '@dxos/random';
|
|
10
|
+
import { type DeepWriteable } from '@dxos/util';
|
|
11
|
+
|
|
12
|
+
import { type MenuAction, type MenuItemGroup, type MenuItem } from '../defs';
|
|
13
|
+
import { type ActionGraphProps } from '../hooks';
|
|
14
|
+
|
|
15
|
+
export type CreateActionsParams = Partial<{
|
|
16
|
+
type?: typeof ACTION_TYPE | typeof ACTION_GROUP_TYPE;
|
|
17
|
+
callback: () => void;
|
|
18
|
+
count: number;
|
|
19
|
+
}>;
|
|
20
|
+
|
|
21
|
+
const icons = {
|
|
22
|
+
regular: [
|
|
23
|
+
'ph--text-b--regular',
|
|
24
|
+
'ph--text-italic--regular',
|
|
25
|
+
'ph--text-h-five--regular',
|
|
26
|
+
'ph--chat-text--regular',
|
|
27
|
+
'ph--clipboard-text--regular',
|
|
28
|
+
'ph--link-simple--regular',
|
|
29
|
+
],
|
|
30
|
+
fill: [
|
|
31
|
+
'ph--text-b--fill',
|
|
32
|
+
'ph--text-italic--fill',
|
|
33
|
+
'ph--text-h-five--fill',
|
|
34
|
+
'ph--chat-text--fill',
|
|
35
|
+
'ph--clipboard-text--fill',
|
|
36
|
+
'ph--link-simple--fill',
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const createActions = (params?: CreateActionsParams) => {
|
|
41
|
+
// eslint-disable-next-line no-console
|
|
42
|
+
const { callback = () => console.log('invoke'), count = 12, type = ACTION_TYPE } = params ?? {};
|
|
43
|
+
return faker.helpers.multiple(
|
|
44
|
+
() =>
|
|
45
|
+
create({
|
|
46
|
+
id: faker.string.uuid(),
|
|
47
|
+
type,
|
|
48
|
+
data: type === ACTION_GROUP_TYPE ? actionGroupSymbol : callback,
|
|
49
|
+
properties: {
|
|
50
|
+
label: faker.lorem.words(2),
|
|
51
|
+
icon: faker.helpers.arrayElement(icons[faker.helpers.arrayElement(Object.keys(icons)) as keyof typeof icons]),
|
|
52
|
+
disabled: faker.helpers.arrayElement([true, false]),
|
|
53
|
+
...(type === ACTION_GROUP_TYPE && { variant: 'dropdownMenu' }),
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
{ count },
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const createNestedActions = () => {
|
|
61
|
+
const result: ActionGraphProps = { edges: [], nodes: [] };
|
|
62
|
+
const actionGroups = createActions({ type: ACTION_GROUP_TYPE });
|
|
63
|
+
actionGroups.forEach((group) => {
|
|
64
|
+
const actions = createActions();
|
|
65
|
+
result.nodes.push(group, ...actions);
|
|
66
|
+
result.edges.push(
|
|
67
|
+
{ source: 'root', target: group.id },
|
|
68
|
+
...actions.map((action) => ({ source: group.id, target: action.id })),
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
return result;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const createNestedActionsResolver = (groupParams?: CreateActionsParams, params?: CreateActionsParams) => {
|
|
75
|
+
const graph = new Graph();
|
|
76
|
+
const actionGroups = createActions({ type: ACTION_GROUP_TYPE, ...groupParams });
|
|
77
|
+
actionGroups.forEach((group) => {
|
|
78
|
+
const actions = createActions(params);
|
|
79
|
+
// TODO(thure): these methods exist on `graph` but are marked as `@internal`; how should consumers properly build
|
|
80
|
+
// graph “islands” for the purposes of these components?
|
|
81
|
+
// @ts-ignore
|
|
82
|
+
graph._addNodes([group as NodeArg<any>, ...(actions as NodeArg<any>[])]);
|
|
83
|
+
// @ts-ignore
|
|
84
|
+
graph._addEdges([
|
|
85
|
+
{ source: 'root', target: group.id },
|
|
86
|
+
...actions.map((action) => ({ source: group.id, target: action.id })),
|
|
87
|
+
]);
|
|
88
|
+
void graph.expand(group);
|
|
89
|
+
});
|
|
90
|
+
const resolveGroupItems = (groupNode?: MenuItemGroup) =>
|
|
91
|
+
(graph.actions(groupNode ?? graph.root) || null) as MenuItem[] | null;
|
|
92
|
+
return { resolveGroupItems };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const mutateActionsOnInterval = (actions: Action[]) => {
|
|
96
|
+
let cursor = 0;
|
|
97
|
+
return setInterval(() => {
|
|
98
|
+
const action = actions[cursor] as DeepWriteable<Action>;
|
|
99
|
+
action.properties.icon = faker.helpers.arrayElement(
|
|
100
|
+
action.properties.icon.endsWith('regular') ? icons.fill : icons.regular,
|
|
101
|
+
);
|
|
102
|
+
action.properties.disabled = !action.properties.disabled;
|
|
103
|
+
cursor = (cursor + actions.length + 1) % actions.length;
|
|
104
|
+
}, 1_000);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const useMutateActions = (actions: MenuAction[]) =>
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
const interval = mutateActionsOnInterval(actions);
|
|
110
|
+
return () => clearInterval(interval);
|
|
111
|
+
}, []);
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { ACTION_GROUP_TYPE, ACTION_TYPE, actionGroupSymbol, type ActionLike } from '@dxos/app-graph';
|
|
6
|
+
import { getHostPlatform } from '@dxos/util';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
type MenuAction,
|
|
10
|
+
type MenuActionProperties,
|
|
11
|
+
type MenuItemGroup,
|
|
12
|
+
type MenuItemGroupProperties,
|
|
13
|
+
type MenuSeparator,
|
|
14
|
+
} from './defs';
|
|
15
|
+
|
|
16
|
+
export const getShortcut = (action: ActionLike) => {
|
|
17
|
+
return typeof action.properties?.keyBinding === 'string'
|
|
18
|
+
? action.properties.keyBinding
|
|
19
|
+
: action.properties?.keyBinding?.[getHostPlatform()];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const fallbackIcon = 'ph--placeholder--regular';
|
|
23
|
+
|
|
24
|
+
// TODO(thure,wittjosiah): This is probably a smell; consider the whether react-ui-menu should use action nodes if the
|
|
25
|
+
// callback in `data` is never used. See https://github.com/dxos/dxos/pull/8506#discussion_r1925855118
|
|
26
|
+
const noop = () => {};
|
|
27
|
+
|
|
28
|
+
export const createMenuAction = <P extends {} = {}>(id: string, properties: P & MenuActionProperties) =>
|
|
29
|
+
({
|
|
30
|
+
id,
|
|
31
|
+
type: ACTION_TYPE,
|
|
32
|
+
properties,
|
|
33
|
+
data: noop,
|
|
34
|
+
}) satisfies MenuAction;
|
|
35
|
+
|
|
36
|
+
export const createMenuItemGroup = <
|
|
37
|
+
P extends MenuItemGroupProperties = MenuItemGroupProperties & Partial<MenuActionProperties>,
|
|
38
|
+
>(
|
|
39
|
+
id: string,
|
|
40
|
+
properties: P,
|
|
41
|
+
) =>
|
|
42
|
+
({
|
|
43
|
+
id,
|
|
44
|
+
type: ACTION_GROUP_TYPE,
|
|
45
|
+
properties,
|
|
46
|
+
data: actionGroupSymbol,
|
|
47
|
+
}) satisfies MenuItemGroup;
|
|
48
|
+
|
|
49
|
+
export const createGapSeparator = (id: string = 'gap', source: string = 'root') => ({
|
|
50
|
+
nodes: [
|
|
51
|
+
{
|
|
52
|
+
id,
|
|
53
|
+
type: '@dxos/react-ui-toolbar/separator',
|
|
54
|
+
properties: { variant: 'gap' },
|
|
55
|
+
data: undefined as never,
|
|
56
|
+
} satisfies MenuSeparator,
|
|
57
|
+
],
|
|
58
|
+
edges: [{ source, target: id }],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const createLineSeparator = (id: string = 'line', source: string = 'root') => ({
|
|
62
|
+
nodes: [
|
|
63
|
+
{
|
|
64
|
+
id,
|
|
65
|
+
type: '@dxos/react-ui-toolbar/separator',
|
|
66
|
+
properties: { variant: 'line' },
|
|
67
|
+
data: undefined as never,
|
|
68
|
+
} satisfies MenuSeparator,
|
|
69
|
+
],
|
|
70
|
+
edges: [{ source, target: id }],
|
|
71
|
+
});
|