@dxos/react-ui-menu 0.8.4-main.b97322e → 0.8.4-main.bc2380dfbc

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 (67) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/browser/index.mjs +548 -403
  3. package/dist/lib/browser/index.mjs.map +4 -4
  4. package/dist/lib/browser/meta.json +1 -1
  5. package/dist/lib/browser/translations.mjs +14 -0
  6. package/dist/lib/browser/translations.mjs.map +7 -0
  7. package/dist/lib/node-esm/index.mjs +548 -403
  8. package/dist/lib/node-esm/index.mjs.map +4 -4
  9. package/dist/lib/node-esm/meta.json +1 -1
  10. package/dist/lib/node-esm/translations.mjs +16 -0
  11. package/dist/lib/node-esm/translations.mjs.map +7 -0
  12. package/dist/types/src/builder.d.ts +28 -3
  13. package/dist/types/src/builder.d.ts.map +1 -1
  14. package/dist/types/src/builder.test.d.ts +2 -0
  15. package/dist/types/src/builder.test.d.ts.map +1 -0
  16. package/dist/types/src/components/ActionLabel.d.ts +3 -3
  17. package/dist/types/src/components/ActionLabel.d.ts.map +1 -1
  18. package/dist/types/src/components/DropdownMenu.d.ts +4 -2
  19. package/dist/types/src/components/DropdownMenu.d.ts.map +1 -1
  20. package/dist/types/src/components/Menu.d.ts +50 -0
  21. package/dist/types/src/components/Menu.d.ts.map +1 -0
  22. package/dist/types/src/components/Menu.stories.d.ts +21 -0
  23. package/dist/types/src/components/Menu.stories.d.ts.map +1 -0
  24. package/dist/types/src/components/Menu.test.d.ts +2 -0
  25. package/dist/types/src/components/Menu.test.d.ts.map +1 -0
  26. package/dist/types/src/components/ToolbarMenu.d.ts +7 -6
  27. package/dist/types/src/components/ToolbarMenu.d.ts.map +1 -1
  28. package/dist/types/src/components/ToolbarMenu.stories.d.ts +34 -0
  29. package/dist/types/src/components/ToolbarMenu.stories.d.ts.map +1 -0
  30. package/dist/types/src/components/index.d.ts +1 -3
  31. package/dist/types/src/components/index.d.ts.map +1 -1
  32. package/dist/types/src/hooks/useMenuActions.d.ts +16 -7
  33. package/dist/types/src/hooks/useMenuActions.d.ts.map +1 -1
  34. package/dist/types/src/index.d.ts +1 -2
  35. package/dist/types/src/index.d.ts.map +1 -1
  36. package/dist/types/src/testing/generator.d.ts +20 -14
  37. package/dist/types/src/testing/generator.d.ts.map +1 -1
  38. package/dist/types/src/translations.d.ts +2 -2
  39. package/dist/types/src/translations.d.ts.map +1 -1
  40. package/dist/types/src/types.d.ts +38 -27
  41. package/dist/types/src/types.d.ts.map +1 -1
  42. package/dist/types/src/util.d.ts +14 -5
  43. package/dist/types/src/util.d.ts.map +1 -1
  44. package/dist/types/tsconfig.tsbuildinfo +1 -1
  45. package/package.json +46 -33
  46. package/src/builder.test.ts +224 -0
  47. package/src/builder.ts +76 -26
  48. package/src/components/ActionLabel.tsx +8 -7
  49. package/src/components/DropdownMenu.tsx +14 -7
  50. package/src/components/Menu.stories.tsx +266 -0
  51. package/src/components/Menu.test.tsx +447 -0
  52. package/src/components/Menu.tsx +348 -0
  53. package/src/components/ToolbarMenu.stories.tsx +95 -0
  54. package/src/components/ToolbarMenu.tsx +169 -101
  55. package/src/components/index.ts +1 -3
  56. package/src/hooks/useMenuActions.ts +42 -53
  57. package/src/index.ts +1 -3
  58. package/src/testing/generator.ts +74 -64
  59. package/src/translations.ts +1 -1
  60. package/src/types.ts +39 -27
  61. package/src/util.ts +34 -15
  62. package/dist/types/src/components/MenuContext.d.ts +0 -13
  63. package/dist/types/src/components/MenuContext.d.ts.map +0 -1
  64. package/dist/types/src/components/Menus.stories.d.ts +0 -25
  65. package/dist/types/src/components/Menus.stories.d.ts.map +0 -1
  66. package/src/components/MenuContext.tsx +0 -50
  67. package/src/components/Menus.stories.tsx +0 -69
@@ -2,26 +2,28 @@
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, toolbarLayout } from '@dxos/react-ui-theme';
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 { actionLabel, ActionLabel } from './ActionLabel';
12
- import { DropdownMenu } from './DropdownMenu';
13
- import { type MenuScopedProps, useMenu, useMenuItems } from './MenuContext';
14
- import { translationKey } from '../translations';
15
14
  import {
16
15
  type MenuAction,
17
16
  type MenuItem,
18
- isMenuGroup,
19
- isSeparator,
20
17
  type MenuItemGroup,
21
- type MenuActionProperties,
22
18
  type MenuMultipleSelectActionGroup,
23
19
  type MenuSingleSelectActionGroup,
20
+ isMenuGroup,
21
+ isSeparator,
24
22
  } from '../types';
23
+ import { executeMenuAction } from '../util';
24
+ import { ActionLabel, actionLabel } from './ActionLabel';
25
+ import { DropdownMenu } from './DropdownMenu';
26
+ import { type MenuScopedProps, useMenuItems, useMenuScoped } from './Menu';
25
27
 
26
28
  export type ToolbarMenuDropdownMenuActionGroup = Omit<MenuActionProperties, 'variant' | 'icon'> & {
27
29
  variant: 'dropdownMenu';
@@ -39,7 +41,7 @@ export type ToolbarMenuActionGroupProperties = (
39
41
  ) &
40
42
  (MenuSingleSelectActionGroup | MenuMultipleSelectActionGroup);
41
43
 
42
- export type ToolbarMenuProps = ToolbarRootProps & { textBlockWidth?: boolean };
44
+ export type ToolbarMenuProps = ToolbarRootProps;
43
45
 
44
46
  export type ToolbarMenuActionGroupProps = {
45
47
  group: MenuItemGroup<ToolbarMenuActionGroupProperties>;
@@ -51,36 +53,59 @@ export type ToolbarMenuActionProps = {
51
53
  action: MenuAction;
52
54
  };
53
55
 
54
- const ActionToolbarItem = ({ action, __menuScope }: MenuScopedProps<{ action: MenuAction }>) => {
55
- const { iconSize } = useMenu('ActionToolbarItem', __menuScope);
56
+ const ActionToolbarItem = ({ __menuScope, action }: MenuScopedProps<{ action: MenuAction }>) => {
57
+ const { iconSize, onAction } = useMenuScoped('ActionToolbarItem', __menuScope);
56
58
  const { t } = useTranslation(translationKey);
57
- const handleClick = useCallback(() => action.data?.(), [action]);
58
- const { icon, iconOnly = true, disabled, testId, hidden, classNames } = action.properties;
59
- const Root = icon ? NaturalToolbar.IconButton : NaturalToolbar.Button;
60
- const rootProps = icon
61
- ? { icon, size: iconSize, iconOnly, label: actionLabel(action, t) }
62
- : { children: <ActionLabel action={action} /> };
63
- return hidden ? null : (
64
- <Root
59
+
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
65
86
  key={action.id}
66
- disabled={disabled}
67
- onClick={handleClick}
68
- variant='ghost'
69
- {...(testId && { 'data-testid': testId })}
70
- {...(rootProps as any)}
71
- classNames={classNames}
87
+ {...commonProps}
88
+ icon={icon}
89
+ size={iconSize}
90
+ iconOnly={iconOnly}
91
+ iconClassNames={iconClassNames}
92
+ label={actionLabel(action, t)}
72
93
  />
94
+ ) : (
95
+ <NaturalToolbar.Button key={action.id} {...commonProps}>
96
+ <ActionLabel action={action} />
97
+ </NaturalToolbar.Button>
73
98
  );
74
99
  };
75
100
 
76
101
  const DropdownMenuToolbarItem = ({
102
+ __menuScope,
77
103
  group,
78
104
  items: propsItems,
79
- __menuScope,
80
105
  }: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
81
106
  const { iconOnly, disabled, testId } = group.properties;
82
107
  const { t } = useTranslation(translationKey);
83
- const { iconSize } = useMenu('DropdownMenuToolbarItem', __menuScope);
108
+ const { iconSize } = useMenuScoped('DropdownMenuToolbarItem', __menuScope);
84
109
  const items = useMenuItems(group, propsItems, 'DropdownMenuToolbarItem', __menuScope);
85
110
  const activeItem = items?.find((item) => !!(item as MenuAction).properties.checked);
86
111
  const icon =
@@ -88,18 +113,31 @@ const DropdownMenuToolbarItem = ({
88
113
  // TODO(thure): Handle other menu item types.
89
114
  (activeItem as MenuAction)?.properties.icon) ||
90
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;
91
121
  const Root = icon ? NaturalToolbar.IconButton : NaturalToolbar.Button;
92
122
  const labelAction = (group.properties as any).applyActive && activeItem ? (activeItem as MenuAction) : group;
93
123
  const rootProps = icon
94
- ? { icon, size: iconSize, iconOnly, label: actionLabel(labelAction, t), caretDown: true }
124
+ ? {
125
+ icon,
126
+ size: iconSize,
127
+ iconOnly,
128
+ iconClassNames,
129
+ label: actionLabel(labelAction, t),
130
+ caretDown: true,
131
+ }
95
132
  : {
96
133
  children: (
97
134
  <>
98
135
  <ActionLabel action={labelAction} />
99
- <Icon size={3} icon='ph--caret-down--bold' classNames='mis-1' />
136
+ <Icon size={3} icon='ph--caret-down--bold' classNames='ms-1' />
100
137
  </>
101
138
  ),
102
139
  };
140
+
103
141
  return (
104
142
  <DropdownMenu.Root group={group} items={items}>
105
143
  <DropdownMenu.Trigger asChild>
@@ -109,85 +147,115 @@ const DropdownMenuToolbarItem = ({
109
147
  );
110
148
  };
111
149
 
112
- const ToggleGroupItem = ({ group, action, __menuScope }: MenuScopedProps<ToolbarMenuActionProps>) => {
113
- const { iconSize } = useMenu('ToggleGroupItem', __menuScope);
150
+ const ToggleGroupItem = ({ __menuScope, group, action }: MenuScopedProps<ToolbarMenuActionProps>) => {
151
+ const { iconSize, onAction } = useMenuScoped('ToggleGroupItem', __menuScope);
114
152
  const { t } = useTranslation(translationKey);
115
- const { icon, iconOnly = true, disabled, testId, hidden, classNames } = action.properties;
116
- const handleClick = useCallback(() => action.data?.({ parent: group }), [action, group]);
117
- const Root = icon ? NaturalToolbar.ToggleGroupIconItem : NaturalToolbar.ToggleGroupItem;
118
- const rootProps = icon
119
- ? { icon, size: iconSize, iconOnly, label: actionLabel(action, t) }
120
- : { children: <ActionLabel action={action} /> };
121
- return hidden ? null : (
122
- <Root
123
- key={action.id}
124
- value={action.id as any}
125
- disabled={disabled}
126
- onClick={handleClick}
127
- variant='ghost'
128
- {...(testId && { 'data-testid': testId })}
129
- {...(rootProps as any)}
130
- classNames={classNames}
153
+ const { icon, iconOnly = true, disabled, testId, hidden, classNames, iconClassNames } = action.properties;
154
+
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)}
131
180
  />
181
+ ) : (
182
+ <NaturalToolbar.ToggleGroupItem {...commonProps}>
183
+ <ActionLabel action={action} />
184
+ </NaturalToolbar.ToggleGroupItem>
132
185
  );
133
186
  };
134
187
 
135
188
  const ToggleGroupToolbarItem = ({
136
- group,
137
- items: propsItems,
138
189
  __menuScope,
190
+ group,
191
+ items: itemsProp,
139
192
  }: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
140
- const items = useMenuItems(group, propsItems, 'ToggleGroupToolbarItem', __menuScope);
141
- const { selectCardinality, value } = group.properties;
142
- return (
143
- // TODO(thure): The type here is difficult to manage, what do?
144
- // @ts-ignore
145
- <NaturalToolbar.ToggleGroup type={selectCardinality} value={value}>
146
- {
147
- // TODO(thure): Handle other menu item types.
148
- (items as MenuAction[]).map((action) => (
149
- <ToggleGroupItem key={action.id} group={group} action={action} />
150
- ))
151
- }
152
- </NaturalToolbar.ToggleGroup>
153
- );
193
+ const items = useMenuItems(group, itemsProp, 'ToggleGroupToolbarItem', __menuScope);
194
+ const { selectCardinality } = group.properties;
195
+
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
+ }
154
214
  };
155
215
 
156
- export const ToolbarMenu = ({
157
- __menuScope,
158
- classNames,
159
- textBlockWidth: wrapContents,
160
- ...props
161
- }: MenuScopedProps<ToolbarMenuProps>) => {
162
- const items = useMenuItems(undefined, undefined, 'ToolbarMenu', __menuScope);
163
- const { attendableId } = useMenu('ToolbarMenu', __menuScope);
164
- const { hasAttention } = useAttention(attendableId);
165
- const InnerRoot = wrapContents ? 'div' : Fragment;
166
- const innerRootProps = wrapContents ? { role: 'none', className: mx(textBlockWidth, toolbarLayout, 'bs-full') } : {};
167
- return (
168
- <NaturalToolbar.Root
169
- {...props}
170
- layoutManaged={wrapContents}
171
- classNames={[attendableId && !hasAttention && '*:opacity-20 !bg-transparent', classNames]}
172
- >
173
- <InnerRoot {...innerRootProps}>
174
- {items?.map((item: MenuItem, i: number) =>
175
- isSeparator(item) ? (
176
- <NaturalToolbar.Separator key={i} variant={item.properties.variant} />
177
- ) : isMenuGroup(item) ? (
178
- item.properties.variant === 'dropdownMenu' ? (
179
- // TODO(thure): Figure out type narrowing that doesn’t require so much use of `as`.
180
- <DropdownMenuToolbarItem key={item.id} group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>} />
181
- ) : (
182
- // TODO(thure): Figure out type narrowing that doesn’t require so much use of `as`.
183
- <ToggleGroupToolbarItem key={item.id} group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>} />
184
- )
185
- ) : (
186
- // TODO(thure): Figure out type narrowing that doesn’t require so much use of `as`.
187
- <ActionToolbarItem key={item.id} action={item as MenuAction} />
188
- ),
189
- )}
190
- </InnerRoot>
191
- </NaturalToolbar.Root>
192
- );
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);
221
+
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} />
230
+ ))}
231
+ {children}
232
+ </NaturalToolbar.Root>
233
+ );
234
+ },
235
+ );
236
+
237
+ const ToolbarMenuItem = ({ __menuScope, item }: MenuScopedProps<{ item: MenuItem }>) => {
238
+ if (isSeparator(item)) {
239
+ return <NaturalToolbar.Separator variant={item.properties.variant} />;
240
+ }
241
+
242
+ if (isMenuGroup(item)) {
243
+ if (item.properties.variant === 'dropdownMenu') {
244
+ return (
245
+ <DropdownMenuToolbarItem
246
+ __menuScope={__menuScope}
247
+ group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>}
248
+ />
249
+ );
250
+ } else {
251
+ return (
252
+ <ToggleGroupToolbarItem
253
+ __menuScope={__menuScope}
254
+ group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>}
255
+ />
256
+ );
257
+ }
258
+ }
259
+
260
+ return <ActionToolbarItem __menuScope={__menuScope} action={item as MenuAction} />;
193
261
  };
@@ -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';
@@ -2,68 +2,57 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { RegistryContext, type Rx, useRxValue } from '@effect-rx/rx-react';
6
- import { useCallback, useContext, useEffect, useState } from 'react';
5
+ import { Atom, RegistryContext, useAtomValue } from '@effect-atom/atom-react';
6
+ import { type DependencyList, useCallback, useContext, useMemo } from 'react';
7
7
 
8
- import { type Edge, type Edges, Graph, type NodeArg, type Node, 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
- );
12
+ export type ActionGraphNodes = Node.NodeArg<any>[];
13
+ export type ActionGraphEdges = Graph.Edge[];
14
+ export type ActionGraphProps = {
15
+ nodes: ActionGraphNodes;
16
+ edges: ActionGraphEdges;
37
17
  };
38
18
 
39
- export type ActionGraphNodes = NodeArg<any>[];
40
- export type ActionGraphEdges = Edge[];
41
- export type ActionGraphProps = { nodes: ActionGraphNodes; edges: ActionGraphEdges };
19
+ export type MenuActions = {
20
+ items: MenuItemsAccessor;
21
+ };
42
22
 
43
- export const useMenuActions = (props: Rx.Rx<ActionGraphProps>) => {
23
+ export const useMenuActions = (props: Atom.Atom<ActionGraphProps>): MenuActions => {
44
24
  const registry = useContext(RegistryContext);
45
- const menuGraphProps = useRxValue(props);
46
-
47
- const [graph] = useState(
48
- new Graph({
49
- registry,
50
- nodes: menuGraphProps.nodes as Node[],
51
- edges: edgesArrayToRecord(menuGraphProps.edges),
52
- }),
53
- );
54
-
55
- useEffect(() => {
56
- graph.addNodes(menuGraphProps.nodes);
57
- graph.addEdges(menuGraphProps.edges);
58
- }, [menuGraphProps]);
59
-
60
- const useGroupItems = useCallback(
61
- (sourceNode?: MenuItemGroup) => {
62
- const items = useRxValue(graph.connections(sourceNode?.id || ROOT_ID)) as MenuItem[];
63
- 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>;
64
41
  },
65
42
  [graph],
66
43
  );
67
44
 
68
- return { useGroupItems };
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);
69
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';
@@ -2,27 +2,17 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { Rx } from '@effect-rx/rx-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
- type Action,
10
- Graph,
11
- type NodeArg,
12
- ACTION_TYPE,
13
- ACTION_GROUP_TYPE,
14
- actionGroupSymbol,
15
- ROOT_ID,
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 MenuAction, type MenuItemGroup, type MenuItem } 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 ?? {};
52
- 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
- }),
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
- export const createNestedActions = Rx.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: 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
+ };