@dxos/react-ui-menu 0.8.4-main.ead640a → 0.8.4-main.f466a3d56e
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 +102 -5
- package/dist/lib/browser/index.mjs +542 -420
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/translations.mjs +14 -0
- package/dist/lib/browser/translations.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +542 -420
- 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 +16 -0
- package/dist/lib/node-esm/translations.mjs.map +7 -0
- package/dist/types/src/builder.d.ts +27 -2
- package/dist/types/src/builder.d.ts.map +1 -1
- package/dist/types/src/builder.test.d.ts +2 -0
- package/dist/types/src/builder.test.d.ts.map +1 -0
- 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 +50 -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 +7 -6
- package/dist/types/src/components/ToolbarMenu.d.ts.map +1 -1
- package/dist/types/src/components/ToolbarMenu.stories.d.ts +16 -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 +15 -7
- package/dist/types/src/hooks/useMenuActions.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +1 -2
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/testing/generator.d.ts +20 -14
- 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 +38 -27
- 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 +45 -33
- package/src/builder.test.ts +224 -0
- package/src/builder.ts +69 -19
- package/src/components/ActionLabel.tsx +7 -6
- package/src/components/DropdownMenu.tsx +12 -6
- package/src/components/Menu.stories.tsx +266 -0
- package/src/components/Menu.test.tsx +447 -0
- package/src/components/Menu.tsx +348 -0
- package/src/components/ToolbarMenu.stories.tsx +50 -28
- package/src/components/ToolbarMenu.tsx +148 -90
- package/src/components/index.ts +1 -3
- package/src/hooks/useMenuActions.ts +39 -55
- package/src/index.ts +1 -3
- package/src/testing/generator.ts +74 -64
- package/src/translations.ts +1 -1
- package/src/types.ts +39 -27
- package/src/util.ts +34 -15
- package/dist/types/src/components/MenuContext.d.ts +0 -13
- package/dist/types/src/components/MenuContext.d.ts.map +0 -1
- package/src/components/MenuContext.tsx +0 -50
|
@@ -2,16 +2,17 @@
|
|
|
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 { composable, composableProps } from '@dxos/ui-theme';
|
|
10
|
+
import { type MenuActionProperties } from '@dxos/ui-types';
|
|
11
|
+
|
|
12
|
+
import { translationKey } from '#translations';
|
|
10
13
|
|
|
11
|
-
import { translationKey } from '../translations';
|
|
12
14
|
import {
|
|
13
15
|
type MenuAction,
|
|
14
|
-
type MenuActionProperties,
|
|
15
16
|
type MenuItem,
|
|
16
17
|
type MenuItemGroup,
|
|
17
18
|
type MenuMultipleSelectActionGroup,
|
|
@@ -19,10 +20,10 @@ import {
|
|
|
19
20
|
isMenuGroup,
|
|
20
21
|
isSeparator,
|
|
21
22
|
} from '../types';
|
|
22
|
-
|
|
23
|
+
import { executeMenuAction } from '../util';
|
|
23
24
|
import { ActionLabel, actionLabel } from './ActionLabel';
|
|
24
25
|
import { DropdownMenu } from './DropdownMenu';
|
|
25
|
-
import { type MenuScopedProps,
|
|
26
|
+
import { type MenuScopedProps, useMenuItems, useMenuScoped } from './Menu';
|
|
26
27
|
|
|
27
28
|
export type ToolbarMenuDropdownMenuActionGroup = Omit<MenuActionProperties, 'variant' | 'icon'> & {
|
|
28
29
|
variant: 'dropdownMenu';
|
|
@@ -40,7 +41,7 @@ export type ToolbarMenuActionGroupProperties = (
|
|
|
40
41
|
) &
|
|
41
42
|
(MenuSingleSelectActionGroup | MenuMultipleSelectActionGroup);
|
|
42
43
|
|
|
43
|
-
export type ToolbarMenuProps = ToolbarRootProps
|
|
44
|
+
export type ToolbarMenuProps = ToolbarRootProps;
|
|
44
45
|
|
|
45
46
|
export type ToolbarMenuActionGroupProps = {
|
|
46
47
|
group: MenuItemGroup<ToolbarMenuActionGroupProperties>;
|
|
@@ -52,37 +53,59 @@ export type ToolbarMenuActionProps = {
|
|
|
52
53
|
action: MenuAction;
|
|
53
54
|
};
|
|
54
55
|
|
|
55
|
-
const ActionToolbarItem = ({
|
|
56
|
-
const { iconSize } =
|
|
56
|
+
const ActionToolbarItem = ({ __menuScope, action }: MenuScopedProps<{ action: MenuAction }>) => {
|
|
57
|
+
const { iconSize, onAction } = useMenuScoped('ActionToolbarItem', __menuScope);
|
|
57
58
|
const { t } = useTranslation(translationKey);
|
|
58
|
-
const handleClick = useCallback(() => action.data?.(), [action]);
|
|
59
|
-
const { icon, iconOnly = true, disabled, testId, hidden, classNames } = action.properties;
|
|
60
|
-
const Root = icon ? NaturalToolbar.IconButton : NaturalToolbar.Button;
|
|
61
|
-
const rootProps = icon
|
|
62
|
-
? { icon, size: iconSize, iconOnly, label: actionLabel(action, t) }
|
|
63
|
-
: { children: <ActionLabel action={action} /> };
|
|
64
59
|
|
|
65
|
-
|
|
66
|
-
|
|
60
|
+
const { icon, iconOnly = true, disabled, testId, hidden, classNames, iconClassNames } = action.properties;
|
|
61
|
+
const buttonVariant =
|
|
62
|
+
(action.properties as { variant?: string }).variant === 'primary' ? ('primary' as const) : ('ghost' as const);
|
|
63
|
+
|
|
64
|
+
const handleClick = useCallback(() => {
|
|
65
|
+
if (onAction) {
|
|
66
|
+
onAction(action, {});
|
|
67
|
+
} else {
|
|
68
|
+
void executeMenuAction(action);
|
|
69
|
+
}
|
|
70
|
+
}, [action, onAction]);
|
|
71
|
+
|
|
72
|
+
const commonProps = {
|
|
73
|
+
variant: buttonVariant,
|
|
74
|
+
disabled,
|
|
75
|
+
classNames,
|
|
76
|
+
onClick: handleClick,
|
|
77
|
+
...(testId && { 'data-testid': testId }),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
if (hidden) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return icon ? (
|
|
85
|
+
<NaturalToolbar.IconButton
|
|
67
86
|
key={action.id}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
{
|
|
72
|
-
{
|
|
73
|
-
|
|
87
|
+
{...commonProps}
|
|
88
|
+
icon={icon}
|
|
89
|
+
size={iconSize}
|
|
90
|
+
iconOnly={iconOnly}
|
|
91
|
+
iconClassNames={iconClassNames}
|
|
92
|
+
label={actionLabel(action, t)}
|
|
74
93
|
/>
|
|
94
|
+
) : (
|
|
95
|
+
<NaturalToolbar.Button key={action.id} {...commonProps}>
|
|
96
|
+
<ActionLabel action={action} />
|
|
97
|
+
</NaturalToolbar.Button>
|
|
75
98
|
);
|
|
76
99
|
};
|
|
77
100
|
|
|
78
101
|
const DropdownMenuToolbarItem = ({
|
|
102
|
+
__menuScope,
|
|
79
103
|
group,
|
|
80
104
|
items: propsItems,
|
|
81
|
-
__menuScope,
|
|
82
105
|
}: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
|
|
83
106
|
const { iconOnly, disabled, testId } = group.properties;
|
|
84
107
|
const { t } = useTranslation(translationKey);
|
|
85
|
-
const { iconSize } =
|
|
108
|
+
const { iconSize } = useMenuScoped('DropdownMenuToolbarItem', __menuScope);
|
|
86
109
|
const items = useMenuItems(group, propsItems, 'DropdownMenuToolbarItem', __menuScope);
|
|
87
110
|
const activeItem = items?.find((item) => !!(item as MenuAction).properties.checked);
|
|
88
111
|
const icon =
|
|
@@ -90,15 +113,27 @@ const DropdownMenuToolbarItem = ({
|
|
|
90
113
|
// TODO(thure): Handle other menu item types.
|
|
91
114
|
(activeItem as MenuAction)?.properties.icon) ||
|
|
92
115
|
group.properties.icon;
|
|
116
|
+
// Follow the same `applyActive` rule for `iconClassNames` so a per-item
|
|
117
|
+
// accent (e.g. tag colour) tracks the displayed icon.
|
|
118
|
+
const iconClassNames =
|
|
119
|
+
((group.properties as any).applyActive && (activeItem as MenuAction)?.properties.iconClassNames) ||
|
|
120
|
+
(group.properties as { iconClassNames?: any }).iconClassNames;
|
|
93
121
|
const Root = icon ? NaturalToolbar.IconButton : NaturalToolbar.Button;
|
|
94
122
|
const labelAction = (group.properties as any).applyActive && activeItem ? (activeItem as MenuAction) : group;
|
|
95
123
|
const rootProps = icon
|
|
96
|
-
? {
|
|
124
|
+
? {
|
|
125
|
+
icon,
|
|
126
|
+
size: iconSize,
|
|
127
|
+
iconOnly,
|
|
128
|
+
iconClassNames,
|
|
129
|
+
label: actionLabel(labelAction, t),
|
|
130
|
+
caretDown: true,
|
|
131
|
+
}
|
|
97
132
|
: {
|
|
98
133
|
children: (
|
|
99
134
|
<>
|
|
100
135
|
<ActionLabel action={labelAction} />
|
|
101
|
-
<Icon size={3} icon='ph--caret-down--bold' classNames='
|
|
136
|
+
<Icon size={3} icon='ph--caret-down--bold' classNames='ms-1' />
|
|
102
137
|
</>
|
|
103
138
|
),
|
|
104
139
|
};
|
|
@@ -112,92 +147,115 @@ const DropdownMenuToolbarItem = ({
|
|
|
112
147
|
);
|
|
113
148
|
};
|
|
114
149
|
|
|
115
|
-
const ToggleGroupItem = ({ group, action
|
|
116
|
-
const { iconSize } =
|
|
150
|
+
const ToggleGroupItem = ({ __menuScope, group, action }: MenuScopedProps<ToolbarMenuActionProps>) => {
|
|
151
|
+
const { iconSize, onAction } = useMenuScoped('ToggleGroupItem', __menuScope);
|
|
117
152
|
const { t } = useTranslation(translationKey);
|
|
118
|
-
const { icon, iconOnly = true, disabled, testId, hidden, classNames } = action.properties;
|
|
119
|
-
const handleClick = useCallback(() => action.data?.({ parent: group }), [action, group]);
|
|
120
|
-
const Root = icon ? NaturalToolbar.ToggleGroupIconItem : NaturalToolbar.ToggleGroupItem;
|
|
121
|
-
const rootProps = icon
|
|
122
|
-
? { icon, size: iconSize, iconOnly, label: actionLabel(action, t) }
|
|
123
|
-
: { children: <ActionLabel action={action} /> };
|
|
153
|
+
const { icon, iconOnly = true, disabled, testId, hidden, classNames, iconClassNames } = action.properties;
|
|
124
154
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
155
|
+
const handleClick = useCallback(() => {
|
|
156
|
+
if (onAction) {
|
|
157
|
+
onAction(action, { parent: group });
|
|
158
|
+
} else {
|
|
159
|
+
void executeMenuAction(action, { parent: group });
|
|
160
|
+
}
|
|
161
|
+
}, [action, group, onAction]);
|
|
162
|
+
|
|
163
|
+
const commonProps = {
|
|
164
|
+
value: action.id,
|
|
165
|
+
disabled,
|
|
166
|
+
variant: 'ghost' as const,
|
|
167
|
+
classNames,
|
|
168
|
+
onClick: handleClick,
|
|
169
|
+
...(testId && { 'data-testid': testId }),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return hidden ? null : icon ? (
|
|
173
|
+
<NaturalToolbar.ToggleGroupIconItem
|
|
174
|
+
{...commonProps}
|
|
175
|
+
icon={icon}
|
|
176
|
+
size={iconSize}
|
|
177
|
+
iconOnly={iconOnly}
|
|
178
|
+
iconClassNames={iconClassNames}
|
|
179
|
+
label={actionLabel(action, t)}
|
|
135
180
|
/>
|
|
181
|
+
) : (
|
|
182
|
+
<NaturalToolbar.ToggleGroupItem {...commonProps}>
|
|
183
|
+
<ActionLabel action={action} />
|
|
184
|
+
</NaturalToolbar.ToggleGroupItem>
|
|
136
185
|
);
|
|
137
186
|
};
|
|
138
187
|
|
|
139
188
|
const ToggleGroupToolbarItem = ({
|
|
140
|
-
group,
|
|
141
|
-
items: propsItems,
|
|
142
189
|
__menuScope,
|
|
190
|
+
group,
|
|
191
|
+
items: itemsProp,
|
|
143
192
|
}: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
|
|
144
|
-
const items = useMenuItems(group,
|
|
145
|
-
const { selectCardinality
|
|
193
|
+
const items = useMenuItems(group, itemsProp, 'ToggleGroupToolbarItem', __menuScope);
|
|
194
|
+
const { selectCardinality } = group.properties;
|
|
146
195
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
196
|
+
// TODO(thure): Handle other menu item types.
|
|
197
|
+
const children = (items as MenuAction[]).map((action) => (
|
|
198
|
+
<ToggleGroupItem key={action.id} group={group} action={action} />
|
|
199
|
+
));
|
|
200
|
+
|
|
201
|
+
if (selectCardinality === 'multiple') {
|
|
202
|
+
return (
|
|
203
|
+
<NaturalToolbar.ToggleGroup type='multiple' value={group.properties.value}>
|
|
204
|
+
{children}
|
|
205
|
+
</NaturalToolbar.ToggleGroup>
|
|
206
|
+
);
|
|
207
|
+
} else {
|
|
208
|
+
return (
|
|
209
|
+
<NaturalToolbar.ToggleGroup type='single' value={group.properties.value}>
|
|
210
|
+
{children}
|
|
211
|
+
</NaturalToolbar.ToggleGroup>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
159
214
|
};
|
|
160
215
|
|
|
161
|
-
export const ToolbarMenu = (
|
|
162
|
-
__menuScope,
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}: MenuScopedProps<ToolbarMenuProps>) => {
|
|
167
|
-
const items = useMenuItems(undefined, undefined, 'ToolbarMenu', __menuScope);
|
|
168
|
-
const { attendableId } = useMenu('ToolbarMenu', __menuScope);
|
|
169
|
-
const { hasAttention } = useAttention(attendableId);
|
|
170
|
-
const InnerRoot = wrapContents ? 'div' : Fragment;
|
|
171
|
-
const innerRootProps = wrapContents ? { role: 'none', className: mx(textBlockWidth, toolbarLayout, 'bs-full') } : {};
|
|
216
|
+
export const ToolbarMenu = composable<HTMLDivElement, MenuScopedProps<ToolbarMenuProps>>(
|
|
217
|
+
({ __menuScope, children, ...props }, forwardedRef) => {
|
|
218
|
+
const items = useMenuItems(undefined, undefined, 'ToolbarMenu', __menuScope);
|
|
219
|
+
const { attendableId, alwaysActive } = useMenuScoped('ToolbarMenu', __menuScope);
|
|
220
|
+
const { hasAttention } = useAttention(attendableId);
|
|
172
221
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
<ToolbarMenuItem key={item.id ?? i} item={item} />
|
|
222
|
+
return (
|
|
223
|
+
<NaturalToolbar.Root
|
|
224
|
+
{...composableProps(props, { classNames: attendableId })}
|
|
225
|
+
disabled={!alwaysActive && !hasAttention}
|
|
226
|
+
ref={forwardedRef}
|
|
227
|
+
>
|
|
228
|
+
{items?.map((item: MenuItem) => (
|
|
229
|
+
<ToolbarMenuItem key={item.id} __menuScope={__menuScope} item={item} />
|
|
182
230
|
))}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
231
|
+
{children}
|
|
232
|
+
</NaturalToolbar.Root>
|
|
233
|
+
);
|
|
234
|
+
},
|
|
235
|
+
);
|
|
187
236
|
|
|
188
|
-
const ToolbarMenuItem = ({ item }: { item: MenuItem }) => {
|
|
237
|
+
const ToolbarMenuItem = ({ __menuScope, item }: MenuScopedProps<{ item: MenuItem }>) => {
|
|
189
238
|
if (isSeparator(item)) {
|
|
190
239
|
return <NaturalToolbar.Separator variant={item.properties.variant} />;
|
|
191
240
|
}
|
|
192
241
|
|
|
193
|
-
// TODO(thure): Figure out type narrowing that doesn’t require so much use of `as`.
|
|
194
242
|
if (isMenuGroup(item)) {
|
|
195
243
|
if (item.properties.variant === 'dropdownMenu') {
|
|
196
|
-
return
|
|
244
|
+
return (
|
|
245
|
+
<DropdownMenuToolbarItem
|
|
246
|
+
__menuScope={__menuScope}
|
|
247
|
+
group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>}
|
|
248
|
+
/>
|
|
249
|
+
);
|
|
197
250
|
} else {
|
|
198
|
-
return
|
|
251
|
+
return (
|
|
252
|
+
<ToggleGroupToolbarItem
|
|
253
|
+
__menuScope={__menuScope}
|
|
254
|
+
group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>}
|
|
255
|
+
/>
|
|
256
|
+
);
|
|
199
257
|
}
|
|
200
258
|
}
|
|
201
259
|
|
|
202
|
-
return <ActionToolbarItem action={item as MenuAction} />;
|
|
260
|
+
return <ActionToolbarItem __menuScope={__menuScope} action={item as MenuAction} />;
|
|
203
261
|
};
|
package/src/components/index.ts
CHANGED
|
@@ -2,73 +2,57 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import { useCallback, useContext,
|
|
5
|
+
import { Atom, RegistryContext, useAtomValue } from '@effect-atom/atom-react';
|
|
6
|
+
import { type DependencyList, 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
|
-
export type MenuActions = {
|
|
19
|
+
export type MenuActions = {
|
|
20
|
+
items: MenuItemsAccessor;
|
|
21
|
+
};
|
|
47
22
|
|
|
48
|
-
export const useMenuActions = (props:
|
|
23
|
+
export const useMenuActions = (props: Atom.Atom<ActionGraphProps>): MenuActions => {
|
|
49
24
|
const registry = useContext(RegistryContext);
|
|
50
|
-
const menuGraphProps =
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
(sourceNode?: MenuItemGroup) => {
|
|
67
|
-
const items = useRxValue(graph.connections(sourceNode?.id || ROOT_ID)) as MenuItem[];
|
|
68
|
-
return items;
|
|
25
|
+
const menuGraphProps = useAtomValue(props);
|
|
26
|
+
|
|
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>;
|
|
69
41
|
},
|
|
70
42
|
[graph],
|
|
71
43
|
);
|
|
72
44
|
|
|
73
|
-
return {
|
|
45
|
+
return { items };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Convenience wrapper around `useMenuActions` that creates the backing atom inline.
|
|
50
|
+
* Pass a builder thunk and a dependency list — the hook memoizes `Atom.make(build)` and threads
|
|
51
|
+
* it through `useMenuActions`. Saves the `useMemo(() => Atom.make(...), deps)` boilerplate when
|
|
52
|
+
* the action graph is composed from local state (e.g. a toolbar driven by component state).
|
|
53
|
+
*/
|
|
54
|
+
export const useMenuBuilder = (build: (get: Atom.Context) => ActionGraphProps, deps: DependencyList): MenuActions => {
|
|
55
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
56
|
+
const atom = useMemo(() => Atom.make(build), deps);
|
|
57
|
+
return useMenuActions(atom);
|
|
74
58
|
};
|
package/src/index.ts
CHANGED
|
@@ -2,10 +2,8 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
export { rxFromSignal } from '@dxos/app-graph';
|
|
6
|
-
|
|
7
5
|
export * from './components';
|
|
8
6
|
export * from './hooks';
|
|
9
7
|
export * from './types';
|
|
10
8
|
export { createMenuAction, createMenuItemGroup, createLineSeparator, createGapSeparator } from './util';
|
|
11
|
-
export { MenuBuilder } from './builder';
|
|
9
|
+
export { type ActionGroupBuilder, type ActionGroupBuilderFn, MenuBuilder } from './builder';
|
package/src/testing/generator.ts
CHANGED
|
@@ -2,27 +2,17 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
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
|
-
|
|
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';
|
|
18
|
-
import { faker } from '@dxos/random';
|
|
19
|
-
import { type DeepWriteable } from '@dxos/util';
|
|
8
|
+
import { Graph, Node } from '@dxos/app-graph';
|
|
9
|
+
import { random } from '@dxos/random';
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
},
|
|
64
|
-
}),
|
|
39
|
+
export const createActions = (params?: CreateActionsProps) => {
|
|
40
|
+
const { callback = () => console.log('invoke'), count = 12, type = Node.ActionType } = params ?? {};
|
|
41
|
+
return random.helpers.multiple(
|
|
42
|
+
() => ({
|
|
43
|
+
id: random.string.uuid(),
|
|
44
|
+
type,
|
|
45
|
+
data: type === Node.ActionGroupType ? Node.actionGroupSymbol : callback,
|
|
46
|
+
properties: {
|
|
47
|
+
label: random.lorem.words(2),
|
|
48
|
+
icon: random.helpers.arrayElement(icons[random.helpers.arrayElement(Object.keys(icons)) as keyof typeof icons]),
|
|
49
|
+
disabled: random.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: random.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
|
+
};
|