@dxos/react-ui-menu 0.8.4-main.7ace549 → 0.8.4-main.8360d9e660
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 +536 -386
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +536 -386
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/builder.d.ts +7 -1
- package/dist/types/src/builder.d.ts.map +1 -1
- package/dist/types/src/components/ActionLabel.d.ts +3 -3
- package/dist/types/src/components/ActionLabel.d.ts.map +1 -1
- package/dist/types/src/components/DropdownMenu.d.ts +4 -2
- package/dist/types/src/components/DropdownMenu.d.ts.map +1 -1
- package/dist/types/src/components/Menu.d.ts +48 -0
- package/dist/types/src/components/Menu.d.ts.map +1 -0
- package/dist/types/src/components/Menu.stories.d.ts +21 -0
- package/dist/types/src/components/Menu.stories.d.ts.map +1 -0
- package/dist/types/src/components/Menu.test.d.ts +2 -0
- package/dist/types/src/components/Menu.test.d.ts.map +1 -0
- package/dist/types/src/components/ToolbarMenu.d.ts +5 -6
- 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/ToolbarMenu.stories.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +1 -3
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useMenuActions.d.ts +5 -5
- package/dist/types/src/hooks/useMenuActions.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +0 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/testing/generator.d.ts +19 -13
- package/dist/types/src/testing/generator.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +2 -2
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/src/types.d.ts +37 -26
- package/dist/types/src/types.d.ts.map +1 -1
- package/dist/types/src/util.d.ts +14 -5
- package/dist/types/src/util.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +35 -29
- package/src/builder.ts +36 -9
- package/src/components/ActionLabel.tsx +5 -5
- package/src/components/DropdownMenu.tsx +12 -5
- package/src/components/Menu.stories.tsx +265 -0
- package/src/components/Menu.test.tsx +448 -0
- package/src/components/Menu.tsx +347 -0
- package/src/components/ToolbarMenu.stories.tsx +32 -24
- package/src/components/ToolbarMenu.tsx +122 -85
- package/src/components/index.ts +1 -3
- package/src/hooks/useMenuActions.ts +21 -51
- package/src/index.ts +0 -2
- package/src/testing/generator.ts +72 -62
- package/src/translations.ts +1 -1
- package/src/types.ts +39 -27
- package/src/util.ts +28 -14
- package/dist/types/src/components/MenuContext.d.ts +0 -15
- package/dist/types/src/components/MenuContext.d.ts.map +0 -1
- package/src/components/MenuContext.tsx +0 -54
|
@@ -2,16 +2,15 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import React, {
|
|
5
|
+
import React, { useCallback } from 'react';
|
|
6
6
|
|
|
7
7
|
import { Icon, Toolbar as NaturalToolbar, type ToolbarRootProps, useTranslation } from '@dxos/react-ui';
|
|
8
8
|
import { useAttention } from '@dxos/react-ui-attention';
|
|
9
|
-
import {
|
|
9
|
+
import { type MenuActionProperties } from '@dxos/ui-types';
|
|
10
10
|
|
|
11
11
|
import { translationKey } from '../translations';
|
|
12
12
|
import {
|
|
13
13
|
type MenuAction,
|
|
14
|
-
type MenuActionProperties,
|
|
15
14
|
type MenuItem,
|
|
16
15
|
type MenuItemGroup,
|
|
17
16
|
type MenuMultipleSelectActionGroup,
|
|
@@ -19,10 +18,11 @@ import {
|
|
|
19
18
|
isMenuGroup,
|
|
20
19
|
isSeparator,
|
|
21
20
|
} from '../types';
|
|
21
|
+
import { executeMenuAction } from '../util';
|
|
22
22
|
|
|
23
23
|
import { ActionLabel, actionLabel } from './ActionLabel';
|
|
24
24
|
import { DropdownMenu } from './DropdownMenu';
|
|
25
|
-
import { type MenuScopedProps,
|
|
25
|
+
import { type MenuScopedProps, useMenuItems, useMenuScoped } from './Menu';
|
|
26
26
|
|
|
27
27
|
export type ToolbarMenuDropdownMenuActionGroup = Omit<MenuActionProperties, 'variant' | 'icon'> & {
|
|
28
28
|
variant: 'dropdownMenu';
|
|
@@ -40,7 +40,7 @@ export type ToolbarMenuActionGroupProperties = (
|
|
|
40
40
|
) &
|
|
41
41
|
(MenuSingleSelectActionGroup | MenuMultipleSelectActionGroup);
|
|
42
42
|
|
|
43
|
-
export type ToolbarMenuProps = ToolbarRootProps
|
|
43
|
+
export type ToolbarMenuProps = ToolbarRootProps;
|
|
44
44
|
|
|
45
45
|
export type ToolbarMenuActionGroupProps = {
|
|
46
46
|
group: MenuItemGroup<ToolbarMenuActionGroupProperties>;
|
|
@@ -52,39 +52,58 @@ export type ToolbarMenuActionProps = {
|
|
|
52
52
|
action: MenuAction;
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
-
const ActionToolbarItem = ({
|
|
56
|
-
const { iconSize } =
|
|
55
|
+
const ActionToolbarItem = ({ __menuScope, action }: MenuScopedProps<{ action: MenuAction }>) => {
|
|
56
|
+
const { iconSize, onAction } = useMenuScoped('ActionToolbarItem', __menuScope);
|
|
57
57
|
const { t } = useTranslation(translationKey);
|
|
58
58
|
|
|
59
59
|
const { icon, iconOnly = true, disabled, testId, hidden, classNames } = action.properties;
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
? { icon, size: iconSize, iconOnly, label: actionLabel(action, t) }
|
|
63
|
-
: { children: <ActionLabel action={action} /> };
|
|
60
|
+
const buttonVariant =
|
|
61
|
+
(action.properties as { variant?: string }).variant === 'primary' ? ('primary' as const) : ('ghost' as const);
|
|
64
62
|
|
|
65
|
-
const handleClick = useCallback(() =>
|
|
63
|
+
const handleClick = useCallback(() => {
|
|
64
|
+
if (onAction) {
|
|
65
|
+
onAction(action, {});
|
|
66
|
+
} else {
|
|
67
|
+
void executeMenuAction(action);
|
|
68
|
+
}
|
|
69
|
+
}, [action, onAction]);
|
|
70
|
+
|
|
71
|
+
const commonProps = {
|
|
72
|
+
variant: buttonVariant,
|
|
73
|
+
disabled,
|
|
74
|
+
classNames,
|
|
75
|
+
onClick: handleClick,
|
|
76
|
+
...(testId && { 'data-testid': testId }),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (hidden) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
66
82
|
|
|
67
|
-
return
|
|
68
|
-
<
|
|
83
|
+
return icon ? (
|
|
84
|
+
<NaturalToolbar.IconButton
|
|
69
85
|
key={action.id}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
{
|
|
74
|
-
{
|
|
75
|
-
classNames={classNames}
|
|
86
|
+
{...commonProps}
|
|
87
|
+
icon={icon}
|
|
88
|
+
size={iconSize}
|
|
89
|
+
iconOnly={iconOnly}
|
|
90
|
+
label={actionLabel(action, t)}
|
|
76
91
|
/>
|
|
92
|
+
) : (
|
|
93
|
+
<NaturalToolbar.Button key={action.id} {...commonProps}>
|
|
94
|
+
<ActionLabel action={action} />
|
|
95
|
+
</NaturalToolbar.Button>
|
|
77
96
|
);
|
|
78
97
|
};
|
|
79
98
|
|
|
80
99
|
const DropdownMenuToolbarItem = ({
|
|
100
|
+
__menuScope,
|
|
81
101
|
group,
|
|
82
102
|
items: propsItems,
|
|
83
|
-
__menuScope,
|
|
84
103
|
}: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
|
|
85
104
|
const { iconOnly, disabled, testId } = group.properties;
|
|
86
105
|
const { t } = useTranslation(translationKey);
|
|
87
|
-
const { iconSize } =
|
|
106
|
+
const { iconSize } = useMenuScoped('DropdownMenuToolbarItem', __menuScope);
|
|
88
107
|
const items = useMenuItems(group, propsItems, 'DropdownMenuToolbarItem', __menuScope);
|
|
89
108
|
const activeItem = items?.find((item) => !!(item as MenuAction).properties.checked);
|
|
90
109
|
const icon =
|
|
@@ -95,12 +114,18 @@ const DropdownMenuToolbarItem = ({
|
|
|
95
114
|
const Root = icon ? NaturalToolbar.IconButton : NaturalToolbar.Button;
|
|
96
115
|
const labelAction = (group.properties as any).applyActive && activeItem ? (activeItem as MenuAction) : group;
|
|
97
116
|
const rootProps = icon
|
|
98
|
-
? {
|
|
117
|
+
? {
|
|
118
|
+
icon,
|
|
119
|
+
size: iconSize,
|
|
120
|
+
iconOnly,
|
|
121
|
+
label: actionLabel(labelAction, t),
|
|
122
|
+
caretDown: true,
|
|
123
|
+
}
|
|
99
124
|
: {
|
|
100
125
|
children: (
|
|
101
126
|
<>
|
|
102
127
|
<ActionLabel action={labelAction} />
|
|
103
|
-
<Icon size={3} icon='ph--caret-down--bold' classNames='
|
|
128
|
+
<Icon size={3} icon='ph--caret-down--bold' classNames='ms-1' />
|
|
104
129
|
</>
|
|
105
130
|
),
|
|
106
131
|
};
|
|
@@ -114,95 +139,107 @@ const DropdownMenuToolbarItem = ({
|
|
|
114
139
|
);
|
|
115
140
|
};
|
|
116
141
|
|
|
117
|
-
const ToggleGroupItem = ({ group, action
|
|
118
|
-
const { iconSize } =
|
|
142
|
+
const ToggleGroupItem = ({ __menuScope, group, action }: MenuScopedProps<ToolbarMenuActionProps>) => {
|
|
143
|
+
const { iconSize, onAction } = useMenuScoped('ToggleGroupItem', __menuScope);
|
|
119
144
|
const { t } = useTranslation(translationKey);
|
|
120
145
|
const { icon, iconOnly = true, disabled, testId, hidden, classNames } = action.properties;
|
|
121
|
-
const handleClick = useCallback(() => action.data?.({ parent: group }), [action, group]);
|
|
122
|
-
const Root = icon ? NaturalToolbar.ToggleGroupIconItem : NaturalToolbar.ToggleGroupItem;
|
|
123
|
-
const rootProps = icon
|
|
124
|
-
? { icon, size: iconSize, iconOnly, label: actionLabel(action, t) }
|
|
125
|
-
: { children: <ActionLabel action={action} /> };
|
|
126
146
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
147
|
+
const handleClick = useCallback(() => {
|
|
148
|
+
if (onAction) {
|
|
149
|
+
onAction(action, { parent: group });
|
|
150
|
+
} else {
|
|
151
|
+
void executeMenuAction(action, { parent: group });
|
|
152
|
+
}
|
|
153
|
+
}, [action, group, onAction]);
|
|
154
|
+
|
|
155
|
+
const commonProps = {
|
|
156
|
+
value: action.id,
|
|
157
|
+
disabled,
|
|
158
|
+
variant: 'ghost' as const,
|
|
159
|
+
classNames,
|
|
160
|
+
onClick: handleClick,
|
|
161
|
+
...(testId && { 'data-testid': testId }),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return hidden ? null : icon ? (
|
|
165
|
+
<NaturalToolbar.ToggleGroupIconItem
|
|
166
|
+
{...commonProps}
|
|
167
|
+
icon={icon}
|
|
168
|
+
size={iconSize}
|
|
169
|
+
iconOnly={iconOnly}
|
|
170
|
+
label={actionLabel(action, t)}
|
|
137
171
|
/>
|
|
172
|
+
) : (
|
|
173
|
+
<NaturalToolbar.ToggleGroupItem {...commonProps}>
|
|
174
|
+
<ActionLabel action={action} />
|
|
175
|
+
</NaturalToolbar.ToggleGroupItem>
|
|
138
176
|
);
|
|
139
177
|
};
|
|
140
178
|
|
|
141
179
|
const ToggleGroupToolbarItem = ({
|
|
142
|
-
group,
|
|
143
|
-
items: propsItems,
|
|
144
180
|
__menuScope,
|
|
181
|
+
group,
|
|
182
|
+
items: itemsProp,
|
|
145
183
|
}: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
|
|
146
|
-
const items = useMenuItems(group,
|
|
147
|
-
const { selectCardinality
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
184
|
+
const items = useMenuItems(group, itemsProp, 'ToggleGroupToolbarItem', __menuScope);
|
|
185
|
+
const { selectCardinality } = group.properties;
|
|
186
|
+
|
|
187
|
+
// TODO(thure): Handle other menu item types.
|
|
188
|
+
const children = (items as MenuAction[]).map((action) => (
|
|
189
|
+
<ToggleGroupItem key={action.id} group={group} action={action} />
|
|
190
|
+
));
|
|
191
|
+
|
|
192
|
+
if (selectCardinality === 'multiple') {
|
|
193
|
+
return (
|
|
194
|
+
<NaturalToolbar.ToggleGroup type='multiple' value={group.properties.value}>
|
|
195
|
+
{children}
|
|
196
|
+
</NaturalToolbar.ToggleGroup>
|
|
197
|
+
);
|
|
198
|
+
} else {
|
|
199
|
+
return (
|
|
200
|
+
<NaturalToolbar.ToggleGroup type='single' value={group.properties.value}>
|
|
201
|
+
{children}
|
|
202
|
+
</NaturalToolbar.ToggleGroup>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
161
205
|
};
|
|
162
206
|
|
|
163
|
-
|
|
164
|
-
export const ToolbarMenu = ({
|
|
165
|
-
__menuScope,
|
|
166
|
-
classNames,
|
|
167
|
-
textBlockWidth: textBlockWidthParam,
|
|
168
|
-
...props
|
|
169
|
-
}: MenuScopedProps<ToolbarMenuProps>) => {
|
|
207
|
+
export const ToolbarMenu = ({ __menuScope, classNames, ...props }: MenuScopedProps<ToolbarMenuProps>) => {
|
|
170
208
|
const items = useMenuItems(undefined, undefined, 'ToolbarMenu', __menuScope);
|
|
171
|
-
const { attendableId } =
|
|
209
|
+
const { attendableId, alwaysActive } = useMenuScoped('ToolbarMenu', __menuScope);
|
|
172
210
|
const { hasAttention } = useAttention(attendableId);
|
|
173
|
-
const InnerRoot = textBlockWidthParam ? 'div' : Fragment;
|
|
174
|
-
const innerRootProps = textBlockWidthParam
|
|
175
|
-
? { role: 'none', className: mx(textBlockWidth, toolbarLayout, 'bs-full') }
|
|
176
|
-
: {};
|
|
177
211
|
|
|
178
212
|
return (
|
|
179
|
-
<NaturalToolbar.Root
|
|
180
|
-
{
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
>
|
|
184
|
-
<InnerRoot {...innerRootProps}>
|
|
185
|
-
{items?.map((item: MenuItem, i: number) => (
|
|
186
|
-
<ToolbarMenuItem key={item.id ?? i} item={item} />
|
|
187
|
-
))}
|
|
188
|
-
</InnerRoot>
|
|
213
|
+
<NaturalToolbar.Root {...props} classNames={[attendableId, classNames]} disabled={!alwaysActive && !hasAttention}>
|
|
214
|
+
{items?.map((item: MenuItem) => (
|
|
215
|
+
<ToolbarMenuItem key={item.id} __menuScope={__menuScope} item={item} />
|
|
216
|
+
))}
|
|
189
217
|
</NaturalToolbar.Root>
|
|
190
218
|
);
|
|
191
219
|
};
|
|
192
220
|
|
|
193
|
-
const ToolbarMenuItem = ({ item }: { item: MenuItem }) => {
|
|
221
|
+
const ToolbarMenuItem = ({ __menuScope, item }: MenuScopedProps<{ item: MenuItem }>) => {
|
|
194
222
|
if (isSeparator(item)) {
|
|
195
223
|
return <NaturalToolbar.Separator variant={item.properties.variant} />;
|
|
196
224
|
}
|
|
197
225
|
|
|
198
|
-
// TODO(thure): Figure out type narrowing that doesn’t require so much use of `as`.
|
|
199
226
|
if (isMenuGroup(item)) {
|
|
200
227
|
if (item.properties.variant === 'dropdownMenu') {
|
|
201
|
-
return
|
|
228
|
+
return (
|
|
229
|
+
<DropdownMenuToolbarItem
|
|
230
|
+
__menuScope={__menuScope}
|
|
231
|
+
group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>}
|
|
232
|
+
/>
|
|
233
|
+
);
|
|
202
234
|
} else {
|
|
203
|
-
return
|
|
235
|
+
return (
|
|
236
|
+
<ToggleGroupToolbarItem
|
|
237
|
+
__menuScope={__menuScope}
|
|
238
|
+
group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>}
|
|
239
|
+
/>
|
|
240
|
+
);
|
|
204
241
|
}
|
|
205
242
|
}
|
|
206
243
|
|
|
207
|
-
return <ActionToolbarItem action={item as MenuAction} />;
|
|
244
|
+
return <ActionToolbarItem __menuScope={__menuScope} action={item as MenuAction} />;
|
|
208
245
|
};
|
package/src/components/index.ts
CHANGED
|
@@ -3,74 +3,44 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import { type Atom, RegistryContext, useAtomValue } from '@effect-atom/atom-react';
|
|
6
|
-
import { useCallback, useContext,
|
|
6
|
+
import { useCallback, useContext, useMemo } from 'react';
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { Graph, Node } from '@dxos/app-graph';
|
|
9
9
|
|
|
10
|
-
import { type MenuItem, type MenuItemGroup } from '../types';
|
|
10
|
+
import { type MenuItem, type MenuItemGroup, type MenuItemsAccessor } from '../types';
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
Object.entries(
|
|
15
|
-
edges.reduce((acc: Record<string, { inbound: string[]; outbound: string[] }>, { source, target }) => {
|
|
16
|
-
if (!acc[source]) {
|
|
17
|
-
acc[source] = { inbound: [], outbound: [] };
|
|
18
|
-
}
|
|
19
|
-
if (!acc[target]) {
|
|
20
|
-
acc[target] = { inbound: [], outbound: [] };
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const sourceEdges = acc[source];
|
|
24
|
-
if (!sourceEdges.outbound.includes(target)) {
|
|
25
|
-
sourceEdges.outbound.push(target);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const targetEdges = acc[target];
|
|
29
|
-
if (!targetEdges.inbound.includes(source)) {
|
|
30
|
-
targetEdges.inbound.push(source);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return acc;
|
|
34
|
-
}, {}),
|
|
35
|
-
),
|
|
36
|
-
);
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
export type ActionGraphNodes = NodeArg<any>[];
|
|
40
|
-
export type ActionGraphEdges = Edge[];
|
|
12
|
+
export type ActionGraphNodes = Node.NodeArg<any>[];
|
|
13
|
+
export type ActionGraphEdges = Graph.Edge[];
|
|
41
14
|
export type ActionGraphProps = {
|
|
42
15
|
nodes: ActionGraphNodes;
|
|
43
16
|
edges: ActionGraphEdges;
|
|
44
17
|
};
|
|
45
18
|
|
|
46
19
|
export type MenuActions = {
|
|
47
|
-
|
|
20
|
+
items: MenuItemsAccessor;
|
|
48
21
|
};
|
|
49
22
|
|
|
50
23
|
export const useMenuActions = (props: Atom.Atom<ActionGraphProps>): MenuActions => {
|
|
51
24
|
const registry = useContext(RegistryContext);
|
|
52
25
|
const menuGraphProps = useAtomValue(props);
|
|
53
26
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
(sourceNode?: MenuItemGroup) => {
|
|
69
|
-
const items = useAtomValue(graph.connections(sourceNode?.id || ROOT_ID)) as MenuItem[];
|
|
70
|
-
return items;
|
|
27
|
+
// Create a new graph whenever props change to preserve correct order.
|
|
28
|
+
// (Graph.addEdges appends rather than replaces, which breaks ordering on updates.)
|
|
29
|
+
// NOTE: Using useMemo rather than a ref-mutation pattern to avoid calling registry.set during render,
|
|
30
|
+
// which would trigger atom state updates in other components (setState-in-render React warning).
|
|
31
|
+
const graph = useMemo(() => {
|
|
32
|
+
const newGraph = Graph.make({ registry });
|
|
33
|
+
newGraph.pipe(Graph.addNodes(menuGraphProps.nodes as Node.NodeArg<any>[]), Graph.addEdges(menuGraphProps.edges));
|
|
34
|
+
return newGraph;
|
|
35
|
+
}, [registry, menuGraphProps]);
|
|
36
|
+
|
|
37
|
+
const items: MenuItemsAccessor = useCallback(
|
|
38
|
+
(group?: MenuItemGroup) => {
|
|
39
|
+
// TODO(wittjosiah): Migrate to using action relation instead of child.
|
|
40
|
+
return graph.connections(group?.id || Node.RootId, 'child') as Atom.Atom<MenuItem[] | null>;
|
|
71
41
|
},
|
|
72
42
|
[graph],
|
|
73
43
|
);
|
|
74
44
|
|
|
75
|
-
return {
|
|
45
|
+
return { items };
|
|
76
46
|
};
|
package/src/index.ts
CHANGED
package/src/testing/generator.ts
CHANGED
|
@@ -2,27 +2,17 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { Atom } from '@effect-atom/atom-react';
|
|
6
|
-
import { useEffect } from 'react';
|
|
5
|
+
import { Atom, Registry, RegistryContext } from '@effect-atom/atom-react';
|
|
6
|
+
import { useContext, useEffect } from 'react';
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
ACTION_GROUP_TYPE,
|
|
10
|
-
ACTION_TYPE,
|
|
11
|
-
type Action,
|
|
12
|
-
Graph,
|
|
13
|
-
type NodeArg,
|
|
14
|
-
ROOT_ID,
|
|
15
|
-
actionGroupSymbol,
|
|
16
|
-
} from '@dxos/app-graph';
|
|
17
|
-
import { live } from '@dxos/live-object';
|
|
8
|
+
import { Graph, Node } from '@dxos/app-graph';
|
|
18
9
|
import { faker } from '@dxos/random';
|
|
19
|
-
import { type DeepWriteable } from '@dxos/util';
|
|
20
10
|
|
|
21
11
|
import { type ActionGraphProps } from '../hooks/useMenuActions';
|
|
22
|
-
import { type
|
|
12
|
+
import { type MenuItem, type MenuItemGroup, type MenuItemsAccessor } from '../types';
|
|
23
13
|
|
|
24
|
-
export type
|
|
25
|
-
type?: typeof
|
|
14
|
+
export type CreateActionsProps = Partial<{
|
|
15
|
+
type?: typeof Node.ActionType | typeof Node.ActionGroupType;
|
|
26
16
|
callback: () => void;
|
|
27
17
|
count: number;
|
|
28
18
|
}>;
|
|
@@ -46,71 +36,91 @@ const icons = {
|
|
|
46
36
|
],
|
|
47
37
|
};
|
|
48
38
|
|
|
49
|
-
export const createActions = (params?:
|
|
50
|
-
|
|
51
|
-
const { callback = () => console.log('invoke'), count = 12, type = ACTION_TYPE } = params ?? {};
|
|
39
|
+
export const createActions = (params?: CreateActionsProps) => {
|
|
40
|
+
const { callback = () => console.log('invoke'), count = 12, type = Node.ActionType } = params ?? {};
|
|
52
41
|
return faker.helpers.multiple(
|
|
53
|
-
() =>
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}),
|
|
42
|
+
() => ({
|
|
43
|
+
id: faker.string.uuid(),
|
|
44
|
+
type,
|
|
45
|
+
data: type === Node.ActionGroupType ? Node.actionGroupSymbol : callback,
|
|
46
|
+
properties: {
|
|
47
|
+
label: faker.lorem.words(2),
|
|
48
|
+
icon: faker.helpers.arrayElement(icons[faker.helpers.arrayElement(Object.keys(icons)) as keyof typeof icons]),
|
|
49
|
+
disabled: faker.helpers.arrayElement([true, false]),
|
|
50
|
+
...(type === Node.ActionGroupType && { variant: 'dropdownMenu' }),
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
65
53
|
{ count },
|
|
66
54
|
);
|
|
67
55
|
};
|
|
68
56
|
|
|
69
|
-
|
|
57
|
+
const buildNestedActions = (): ActionGraphProps => {
|
|
70
58
|
const result: ActionGraphProps = { edges: [], nodes: [] };
|
|
71
|
-
const actionGroups = createActions({ type:
|
|
59
|
+
const actionGroups = createActions({ type: Node.ActionGroupType });
|
|
72
60
|
actionGroups.forEach((group) => {
|
|
73
61
|
const actions = createActions();
|
|
74
62
|
result.nodes.push(group, ...actions);
|
|
75
63
|
result.edges.push(
|
|
76
|
-
{ source: 'root', target: group.id },
|
|
77
|
-
...actions.map((action) => ({ source: group.id, target: action.id })),
|
|
64
|
+
{ source: 'root', target: group.id, relation: 'child' },
|
|
65
|
+
...actions.map((action) => ({ source: group.id, target: action.id, relation: 'child' })),
|
|
78
66
|
);
|
|
79
67
|
});
|
|
80
68
|
return result;
|
|
81
|
-
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const createNestedActions = Atom.make(buildNestedActions()).pipe(Atom.keepAlive);
|
|
82
72
|
|
|
83
|
-
export const createNestedActionsResolver = (
|
|
84
|
-
|
|
85
|
-
|
|
73
|
+
export const createNestedActionsResolver = (props?: {
|
|
74
|
+
groupParams?: CreateActionsProps;
|
|
75
|
+
params?: CreateActionsProps;
|
|
76
|
+
registry?: Registry.Registry;
|
|
77
|
+
}) => {
|
|
78
|
+
const { groupParams, params, registry } = props ?? {};
|
|
79
|
+
const graph = Graph.make({ ...(registry && { registry }) });
|
|
80
|
+
const actionGroups = createActions({ type: Node.ActionGroupType, ...groupParams });
|
|
86
81
|
actionGroups.forEach((group) => {
|
|
87
82
|
const actions = createActions(params);
|
|
88
|
-
graph.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
83
|
+
graph.pipe(
|
|
84
|
+
Graph.addNodes([group as Node.NodeArg<any>, ...(actions as Node.NodeArg<any>[])]),
|
|
85
|
+
Graph.addEdges([
|
|
86
|
+
{ source: 'root', target: group.id, relation: 'child' },
|
|
87
|
+
...actions.map((action) => ({ source: group.id, target: action.id, relation: 'child' })),
|
|
88
|
+
]),
|
|
89
|
+
Graph.expand(group.id, 'child'),
|
|
90
|
+
);
|
|
94
91
|
});
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
return {
|
|
92
|
+
const items: MenuItemsAccessor = (group?: MenuItemGroup) =>
|
|
93
|
+
graph.connections(group?.id ?? Node.RootId, 'child') as Atom.Atom<MenuItem[] | null>;
|
|
94
|
+
return { items };
|
|
98
95
|
};
|
|
99
96
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
action.properties.icon.endsWith('regular') ? icons.fill : icons.regular,
|
|
106
|
-
);
|
|
107
|
-
action.properties.disabled = !action.properties.disabled;
|
|
108
|
-
cursor = (cursor + actions.length + 1) % actions.length;
|
|
109
|
-
}, 1_000);
|
|
110
|
-
};
|
|
97
|
+
/**
|
|
98
|
+
* Hook to mutate actions in an atom on an interval for testing reactivity.
|
|
99
|
+
*/
|
|
100
|
+
export const useMutateActions = (actionsAtom: Atom.Writable<ActionGraphProps>) => {
|
|
101
|
+
const registry = useContext(RegistryContext);
|
|
111
102
|
|
|
112
|
-
export const useMutateActions = (actions: MenuAction[]) =>
|
|
113
103
|
useEffect(() => {
|
|
114
|
-
|
|
104
|
+
let cursor = 0;
|
|
105
|
+
const interval = setInterval(() => {
|
|
106
|
+
const current = registry.get(actionsAtom);
|
|
107
|
+
const nodes = current.nodes.map((node, index) => {
|
|
108
|
+
if (index !== cursor) {
|
|
109
|
+
return node;
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
...node,
|
|
113
|
+
properties: {
|
|
114
|
+
...node.properties,
|
|
115
|
+
icon: faker.helpers.arrayElement(node.properties?.icon?.endsWith('regular') ? icons.fill : icons.regular),
|
|
116
|
+
disabled: !node.properties?.disabled,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
registry.set(actionsAtom, { ...current, nodes });
|
|
121
|
+
cursor = (cursor + 1) % current.nodes.length;
|
|
122
|
+
}, 1_000);
|
|
123
|
+
|
|
115
124
|
return () => clearInterval(interval);
|
|
116
|
-
}, []);
|
|
125
|
+
}, [actionsAtom, registry]);
|
|
126
|
+
};
|