@dxos/react-ui-menu 0.8.4-main.72ec0f3 → 0.8.4-main.7996785055

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.
Files changed (56) hide show
  1. package/dist/lib/browser/index.mjs +537 -387
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node-esm/index.mjs +537 -387
  5. package/dist/lib/node-esm/index.mjs.map +4 -4
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/builder.d.ts +7 -1
  8. package/dist/types/src/builder.d.ts.map +1 -1
  9. package/dist/types/src/components/ActionLabel.d.ts +3 -3
  10. package/dist/types/src/components/ActionLabel.d.ts.map +1 -1
  11. package/dist/types/src/components/DropdownMenu.d.ts +4 -2
  12. package/dist/types/src/components/DropdownMenu.d.ts.map +1 -1
  13. package/dist/types/src/components/Menu.d.ts +54 -0
  14. package/dist/types/src/components/Menu.d.ts.map +1 -0
  15. package/dist/types/src/components/Menu.stories.d.ts +21 -0
  16. package/dist/types/src/components/Menu.stories.d.ts.map +1 -0
  17. package/dist/types/src/components/Menu.test.d.ts +2 -0
  18. package/dist/types/src/components/Menu.test.d.ts.map +1 -0
  19. package/dist/types/src/components/ToolbarMenu.d.ts +11 -6
  20. package/dist/types/src/components/ToolbarMenu.d.ts.map +1 -1
  21. package/dist/types/src/components/ToolbarMenu.stories.d.ts +8 -2
  22. package/dist/types/src/components/ToolbarMenu.stories.d.ts.map +1 -1
  23. package/dist/types/src/components/index.d.ts +1 -3
  24. package/dist/types/src/components/index.d.ts.map +1 -1
  25. package/dist/types/src/hooks/useMenuActions.d.ts +5 -5
  26. package/dist/types/src/hooks/useMenuActions.d.ts.map +1 -1
  27. package/dist/types/src/index.d.ts +0 -1
  28. package/dist/types/src/index.d.ts.map +1 -1
  29. package/dist/types/src/testing/generator.d.ts +19 -13
  30. package/dist/types/src/testing/generator.d.ts.map +1 -1
  31. package/dist/types/src/translations.d.ts +2 -2
  32. package/dist/types/src/translations.d.ts.map +1 -1
  33. package/dist/types/src/types.d.ts +37 -26
  34. package/dist/types/src/types.d.ts.map +1 -1
  35. package/dist/types/src/util.d.ts +14 -5
  36. package/dist/types/src/util.d.ts.map +1 -1
  37. package/dist/types/tsconfig.tsbuildinfo +1 -1
  38. package/package.json +35 -29
  39. package/src/builder.ts +36 -9
  40. package/src/components/ActionLabel.tsx +5 -5
  41. package/src/components/DropdownMenu.tsx +12 -5
  42. package/src/components/Menu.stories.tsx +265 -0
  43. package/src/components/Menu.test.tsx +448 -0
  44. package/src/components/Menu.tsx +349 -0
  45. package/src/components/ToolbarMenu.stories.tsx +34 -25
  46. package/src/components/ToolbarMenu.tsx +136 -91
  47. package/src/components/index.ts +1 -3
  48. package/src/hooks/useMenuActions.ts +21 -51
  49. package/src/index.ts +0 -2
  50. package/src/testing/generator.ts +72 -62
  51. package/src/translations.ts +1 -1
  52. package/src/types.ts +39 -27
  53. package/src/util.ts +28 -14
  54. package/dist/types/src/components/MenuContext.d.ts +0 -15
  55. package/dist/types/src/components/MenuContext.d.ts.map +0 -1
  56. package/src/components/MenuContext.tsx +0 -54
@@ -2,16 +2,16 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import React, { Fragment, useCallback } from '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 { mx, textBlockWidth, toolbarInactive, toolbarLayout } from '@dxos/react-ui-theme';
9
+ import { composable, composableProps } from '@dxos/ui-theme';
10
+ import { type MenuActionProperties } from '@dxos/ui-types';
10
11
 
11
12
  import { translationKey } from '../translations';
12
13
  import {
13
14
  type MenuAction,
14
- type MenuActionProperties,
15
15
  type MenuItem,
16
16
  type MenuItemGroup,
17
17
  type MenuMultipleSelectActionGroup,
@@ -19,10 +19,11 @@ import {
19
19
  isMenuGroup,
20
20
  isSeparator,
21
21
  } from '../types';
22
+ import { executeMenuAction } from '../util';
22
23
 
23
24
  import { ActionLabel, actionLabel } from './ActionLabel';
24
25
  import { DropdownMenu } from './DropdownMenu';
25
- import { type MenuScopedProps, useMenu, useMenuItems } from './MenuContext';
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 & { textBlockWidth?: boolean };
44
+ export type ToolbarMenuProps = ToolbarRootProps;
44
45
 
45
46
  export type ToolbarMenuActionGroupProps = {
46
47
  group: MenuItemGroup<ToolbarMenuActionGroupProperties>;
@@ -52,39 +53,58 @@ export type ToolbarMenuActionProps = {
52
53
  action: MenuAction;
53
54
  };
54
55
 
55
- const ActionToolbarItem = ({ action, __menuScope }: MenuScopedProps<{ action: MenuAction }>) => {
56
- const { iconSize } = useMenu('ActionToolbarItem', __menuScope);
56
+ const ActionToolbarItem = ({ __menuScope, action }: MenuScopedProps<{ action: MenuAction }>) => {
57
+ const { iconSize, onAction } = useMenuScoped('ActionToolbarItem', __menuScope);
57
58
  const { t } = useTranslation(translationKey);
58
59
 
59
60
  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} /> };
61
+ const buttonVariant =
62
+ (action.properties as { variant?: string }).variant === 'primary' ? ('primary' as const) : ('ghost' as const);
64
63
 
65
- const handleClick = useCallback(() => action.data?.(), [action]);
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
+ }
66
83
 
67
- return hidden ? null : (
68
- <Root
84
+ return icon ? (
85
+ <NaturalToolbar.IconButton
69
86
  key={action.id}
70
- disabled={disabled}
71
- onClick={handleClick}
72
- variant='ghost'
73
- {...(testId && { 'data-testid': testId })}
74
- {...(rootProps as any)}
75
- classNames={classNames}
87
+ {...commonProps}
88
+ icon={icon}
89
+ size={iconSize}
90
+ iconOnly={iconOnly}
91
+ label={actionLabel(action, t)}
76
92
  />
93
+ ) : (
94
+ <NaturalToolbar.Button key={action.id} {...commonProps}>
95
+ <ActionLabel action={action} />
96
+ </NaturalToolbar.Button>
77
97
  );
78
98
  };
79
99
 
80
100
  const DropdownMenuToolbarItem = ({
101
+ __menuScope,
81
102
  group,
82
103
  items: propsItems,
83
- __menuScope,
84
104
  }: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
85
105
  const { iconOnly, disabled, testId } = group.properties;
86
106
  const { t } = useTranslation(translationKey);
87
- const { iconSize } = useMenu('DropdownMenuToolbarItem', __menuScope);
107
+ const { iconSize } = useMenuScoped('DropdownMenuToolbarItem', __menuScope);
88
108
  const items = useMenuItems(group, propsItems, 'DropdownMenuToolbarItem', __menuScope);
89
109
  const activeItem = items?.find((item) => !!(item as MenuAction).properties.checked);
90
110
  const icon =
@@ -95,12 +115,18 @@ const DropdownMenuToolbarItem = ({
95
115
  const Root = icon ? NaturalToolbar.IconButton : NaturalToolbar.Button;
96
116
  const labelAction = (group.properties as any).applyActive && activeItem ? (activeItem as MenuAction) : group;
97
117
  const rootProps = icon
98
- ? { icon, size: iconSize, iconOnly, label: actionLabel(labelAction, t), caretDown: true }
118
+ ? {
119
+ icon,
120
+ size: iconSize,
121
+ iconOnly,
122
+ label: actionLabel(labelAction, t),
123
+ caretDown: true,
124
+ }
99
125
  : {
100
126
  children: (
101
127
  <>
102
128
  <ActionLabel action={labelAction} />
103
- <Icon size={3} icon='ph--caret-down--bold' classNames='mis-1' />
129
+ <Icon size={3} icon='ph--caret-down--bold' classNames='ms-1' />
104
130
  </>
105
131
  ),
106
132
  };
@@ -114,95 +140,114 @@ const DropdownMenuToolbarItem = ({
114
140
  );
115
141
  };
116
142
 
117
- const ToggleGroupItem = ({ group, action, __menuScope }: MenuScopedProps<ToolbarMenuActionProps>) => {
118
- const { iconSize } = useMenu('ToggleGroupItem', __menuScope);
143
+ const ToggleGroupItem = ({ __menuScope, group, action }: MenuScopedProps<ToolbarMenuActionProps>) => {
144
+ const { iconSize, onAction } = useMenuScoped('ToggleGroupItem', __menuScope);
119
145
  const { t } = useTranslation(translationKey);
120
146
  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
147
 
127
- return hidden ? null : (
128
- <Root
129
- key={action.id}
130
- value={action.id as any}
131
- disabled={disabled}
132
- onClick={handleClick}
133
- variant='ghost'
134
- {...(testId && { 'data-testid': testId })}
135
- {...(rootProps as any)}
136
- classNames={classNames}
148
+ const handleClick = useCallback(() => {
149
+ if (onAction) {
150
+ onAction(action, { parent: group });
151
+ } else {
152
+ void executeMenuAction(action, { parent: group });
153
+ }
154
+ }, [action, group, onAction]);
155
+
156
+ const commonProps = {
157
+ value: action.id,
158
+ disabled,
159
+ variant: 'ghost' as const,
160
+ classNames,
161
+ onClick: handleClick,
162
+ ...(testId && { 'data-testid': testId }),
163
+ };
164
+
165
+ return hidden ? null : icon ? (
166
+ <NaturalToolbar.ToggleGroupIconItem
167
+ {...commonProps}
168
+ icon={icon}
169
+ size={iconSize}
170
+ iconOnly={iconOnly}
171
+ label={actionLabel(action, t)}
137
172
  />
173
+ ) : (
174
+ <NaturalToolbar.ToggleGroupItem {...commonProps}>
175
+ <ActionLabel action={action} />
176
+ </NaturalToolbar.ToggleGroupItem>
138
177
  );
139
178
  };
140
179
 
141
180
  const ToggleGroupToolbarItem = ({
142
- group,
143
- items: propsItems,
144
181
  __menuScope,
182
+ group,
183
+ items: itemsProp,
145
184
  }: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
146
- const items = useMenuItems(group, propsItems, 'ToggleGroupToolbarItem', __menuScope);
147
- const { selectCardinality, value } = group.properties;
148
-
149
- return (
150
- // TODO(thure): Fix.
151
- // @ts-ignore
152
- <NaturalToolbar.ToggleGroup type={selectCardinality} value={value}>
153
- {
154
- // TODO(thure): Handle other menu item types.
155
- (items as MenuAction[]).map((action) => (
156
- <ToggleGroupItem key={action.id} group={group} action={action} />
157
- ))
158
- }
159
- </NaturalToolbar.ToggleGroup>
160
- );
185
+ const items = useMenuItems(group, itemsProp, 'ToggleGroupToolbarItem', __menuScope);
186
+ const { selectCardinality } = group.properties;
187
+
188
+ // TODO(thure): Handle other menu item types.
189
+ const children = (items as MenuAction[]).map((action) => (
190
+ <ToggleGroupItem key={action.id} group={group} action={action} />
191
+ ));
192
+
193
+ if (selectCardinality === 'multiple') {
194
+ return (
195
+ <NaturalToolbar.ToggleGroup type='multiple' value={group.properties.value}>
196
+ {children}
197
+ </NaturalToolbar.ToggleGroup>
198
+ );
199
+ } else {
200
+ return (
201
+ <NaturalToolbar.ToggleGroup type='single' value={group.properties.value}>
202
+ {children}
203
+ </NaturalToolbar.ToggleGroup>
204
+ );
205
+ }
161
206
  };
162
207
 
163
- // TODO(burdon): Reconcile with react-ui/Toolbar (incl. textBlockWidth)
164
- export const ToolbarMenu = ({
165
- __menuScope,
166
- classNames,
167
- textBlockWidth: textBlockWidthParam,
168
- ...props
169
- }: MenuScopedProps<ToolbarMenuProps>) => {
170
- const items = useMenuItems(undefined, undefined, 'ToolbarMenu', __menuScope);
171
- const { attendableId } = useMenu('ToolbarMenu', __menuScope);
172
- 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
-
178
- return (
179
- <NaturalToolbar.Root
180
- {...props}
181
- classNames={[attendableId && !hasAttention && toolbarInactive, classNames]}
182
- layoutManaged={textBlockWidthParam}
183
- >
184
- <InnerRoot {...innerRootProps}>
185
- {items?.map((item: MenuItem, i: number) => (
186
- <ToolbarMenuItem key={item.id ?? i} item={item} />
208
+ export const ToolbarMenu = composable<HTMLDivElement, MenuScopedProps<ToolbarMenuProps>>(
209
+ ({ __menuScope, children, ...props }, forwardedRef) => {
210
+ const items = useMenuItems(undefined, undefined, 'ToolbarMenu', __menuScope);
211
+ const { attendableId, alwaysActive } = useMenuScoped('ToolbarMenu', __menuScope);
212
+ const { hasAttention } = useAttention(attendableId);
213
+
214
+ return (
215
+ <NaturalToolbar.Root
216
+ {...composableProps(props, { classNames: attendableId })}
217
+ disabled={!alwaysActive && !hasAttention}
218
+ ref={forwardedRef}
219
+ >
220
+ {items?.map((item: MenuItem) => (
221
+ <ToolbarMenuItem key={item.id} __menuScope={__menuScope} item={item} />
187
222
  ))}
188
- </InnerRoot>
189
- </NaturalToolbar.Root>
190
- );
191
- };
223
+ {children}
224
+ </NaturalToolbar.Root>
225
+ );
226
+ },
227
+ );
192
228
 
193
- const ToolbarMenuItem = ({ item }: { item: MenuItem }) => {
229
+ const ToolbarMenuItem = ({ __menuScope, item }: MenuScopedProps<{ item: MenuItem }>) => {
194
230
  if (isSeparator(item)) {
195
231
  return <NaturalToolbar.Separator variant={item.properties.variant} />;
196
232
  }
197
233
 
198
- // TODO(thure): Figure out type narrowing that doesn’t require so much use of `as`.
199
234
  if (isMenuGroup(item)) {
200
235
  if (item.properties.variant === 'dropdownMenu') {
201
- return <DropdownMenuToolbarItem group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>} />;
236
+ return (
237
+ <DropdownMenuToolbarItem
238
+ __menuScope={__menuScope}
239
+ group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>}
240
+ />
241
+ );
202
242
  } else {
203
- return <ToggleGroupToolbarItem group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>} />;
243
+ return (
244
+ <ToggleGroupToolbarItem
245
+ __menuScope={__menuScope}
246
+ group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>}
247
+ />
248
+ );
204
249
  }
205
250
  }
206
251
 
207
- return <ActionToolbarItem action={item as MenuAction} />;
252
+ return <ActionToolbarItem __menuScope={__menuScope} action={item as MenuAction} />;
208
253
  };
@@ -2,6 +2,4 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- export * from './DropdownMenu';
6
- export * from './ToolbarMenu';
7
- export * from './MenuContext';
5
+ export * from './Menu';
@@ -3,74 +3,44 @@
3
3
  //
4
4
 
5
5
  import { type Atom, RegistryContext, useAtomValue } from '@effect-atom/atom-react';
6
- import { useCallback, useContext, useEffect, useState } from 'react';
6
+ import { useCallback, useContext, useMemo } from 'react';
7
7
 
8
- import { type Edge, type Edges, Graph, type Node, type NodeArg, ROOT_ID } from '@dxos/app-graph';
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
- const edgesArrayToRecord = (edges: Edge[]): Record<string, Edges> => {
13
- return Object.fromEntries(
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
- useGroupItems: (sourceNode?: MenuItemGroup) => MenuItem[];
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
- const [graph] = useState(
55
- new Graph({
56
- registry,
57
- nodes: menuGraphProps.nodes as Node[],
58
- edges: edgesArrayToRecord(menuGraphProps.edges),
59
- }),
60
- );
61
-
62
- useEffect(() => {
63
- graph.addNodes(menuGraphProps.nodes);
64
- graph.addEdges(menuGraphProps.edges);
65
- }, [menuGraphProps]);
66
-
67
- const useGroupItems = useCallback(
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 { useGroupItems };
45
+ return { items };
76
46
  };
package/src/index.ts CHANGED
@@ -2,8 +2,6 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- export { atomFromSignal } from '@dxos/app-graph';
6
-
7
5
  export * from './components';
8
6
  export * from './hooks';
9
7
  export * from './types';
@@ -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 MenuAction, type MenuItem, type MenuItemGroup } from '../types';
12
+ import { type MenuItem, type MenuItemGroup, type MenuItemsAccessor } from '../types';
23
13
 
24
- export type CreateActionsParams = Partial<{
25
- type?: typeof ACTION_TYPE | typeof ACTION_GROUP_TYPE;
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?: CreateActionsParams) => {
50
- // eslint-disable-next-line no-console
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
- live({
55
- id: faker.string.uuid(),
56
- type,
57
- data: type === ACTION_GROUP_TYPE ? actionGroupSymbol : callback,
58
- properties: {
59
- label: faker.lorem.words(2),
60
- icon: faker.helpers.arrayElement(icons[faker.helpers.arrayElement(Object.keys(icons)) as keyof typeof icons]),
61
- disabled: faker.helpers.arrayElement([true, false]),
62
- ...(type === ACTION_GROUP_TYPE && { variant: 'dropdownMenu' }),
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
- export const createNestedActions = Atom.make(() => {
57
+ const buildNestedActions = (): ActionGraphProps => {
70
58
  const result: ActionGraphProps = { edges: [], nodes: [] };
71
- const actionGroups = createActions({ type: ACTION_GROUP_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 = (groupParams?: CreateActionsParams, params?: CreateActionsParams) => {
84
- const graph = new Graph();
85
- const actionGroups = createActions({ type: ACTION_GROUP_TYPE, ...groupParams });
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.addNodes([group as NodeArg<any>, ...(actions as NodeArg<any>[])]);
89
- graph.addEdges([
90
- { source: 'root', target: group.id },
91
- ...actions.map((action) => ({ source: group.id, target: action.id })),
92
- ]);
93
- void graph.expand(group.id);
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 resolveGroupItems = (groupNode?: MenuItemGroup) =>
96
- (graph.getActions(groupNode?.id ?? ROOT_ID) || null) as MenuItem[] | null;
97
- return { resolveGroupItems };
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
- export const mutateActionsOnInterval = (actions: Action[]) => {
101
- let cursor = 0;
102
- return setInterval(() => {
103
- const action = actions[cursor] as DeepWriteable<Action>;
104
- action.properties.icon = faker.helpers.arrayElement(
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
- const interval = mutateActionsOnInterval(actions);
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
+ };
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { type Resource } from '@dxos/react-ui';
6
6
 
7
- export const translationKey = 'react-ui-menu';
7
+ export const translationKey = '@dxos/react-ui-menu';
8
8
 
9
9
  export const translations = [
10
10
  {