@dxos/react-ui-menu 0.8.4-main.e8ec1fe → 0.8.4-main.ef1bc66f44

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 (43) hide show
  1. package/dist/lib/browser/index.mjs +289 -333
  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 +289 -333
  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 +1 -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.map +1 -1
  12. package/dist/types/src/components/MenuContext.d.ts +1 -1
  13. package/dist/types/src/components/MenuContext.d.ts.map +1 -1
  14. package/dist/types/src/components/ToolbarMenu.d.ts +3 -2
  15. package/dist/types/src/components/ToolbarMenu.d.ts.map +1 -1
  16. package/dist/types/src/components/ToolbarMenu.stories.d.ts +2 -2
  17. package/dist/types/src/components/ToolbarMenu.stories.d.ts.map +1 -1
  18. package/dist/types/src/hooks/useMenuActions.d.ts +3 -3
  19. package/dist/types/src/hooks/useMenuActions.d.ts.map +1 -1
  20. package/dist/types/src/index.d.ts +0 -1
  21. package/dist/types/src/index.d.ts.map +1 -1
  22. package/dist/types/src/testing/generator.d.ts +12 -10
  23. package/dist/types/src/testing/generator.d.ts.map +1 -1
  24. package/dist/types/src/translations.d.ts +2 -2
  25. package/dist/types/src/translations.d.ts.map +1 -1
  26. package/dist/types/src/types.d.ts +13 -24
  27. package/dist/types/src/types.d.ts.map +1 -1
  28. package/dist/types/src/util.d.ts +12 -5
  29. package/dist/types/src/util.d.ts.map +1 -1
  30. package/dist/types/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +31 -28
  32. package/src/builder.ts +1 -1
  33. package/src/components/ActionLabel.tsx +4 -4
  34. package/src/components/DropdownMenu.tsx +10 -3
  35. package/src/components/MenuContext.tsx +11 -1
  36. package/src/components/ToolbarMenu.stories.tsx +16 -8
  37. package/src/components/ToolbarMenu.tsx +33 -13
  38. package/src/hooks/useMenuActions.ts +14 -43
  39. package/src/index.ts +0 -2
  40. package/src/testing/generator.ts +63 -58
  41. package/src/translations.ts +1 -1
  42. package/src/types.ts +14 -26
  43. package/src/util.ts +26 -13
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-menu",
3
- "version": "0.8.4-main.e8ec1fe",
3
+ "version": "0.8.4-main.ef1bc66f44",
4
4
  "description": "Components for facilitating reactive and consistent menus.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dxos/dxos"
10
+ },
7
11
  "license": "MIT",
8
12
  "author": "DXOS.org",
9
- "sideEffects": true,
13
+ "sideEffects": false,
10
14
  "type": "module",
11
15
  "exports": {
12
16
  ".": {
@@ -25,39 +29,38 @@
25
29
  "src"
26
30
  ],
27
31
  "dependencies": {
28
- "@preact-signals/safe-react": "^0.9.0",
29
- "@preact/signals-react": "^3.3.0",
30
32
  "@radix-ui/react-context": "1.1.1",
31
33
  "@radix-ui/react-use-controllable-state": "1.1.0",
32
- "@dxos/app-graph": "0.8.4-main.e8ec1fe",
33
- "@dxos/invariant": "0.8.4-main.e8ec1fe",
34
- "@dxos/keyboard": "0.8.4-main.e8ec1fe",
35
- "@dxos/react-ui-attention": "0.8.4-main.e8ec1fe",
36
- "@dxos/util": "0.8.4-main.e8ec1fe"
34
+ "@dxos/app-graph": "0.8.4-main.ef1bc66f44",
35
+ "@dxos/invariant": "0.8.4-main.ef1bc66f44",
36
+ "@dxos/keyboard": "0.8.4-main.ef1bc66f44",
37
+ "@dxos/ui-types": "0.8.4-main.ef1bc66f44",
38
+ "@dxos/react-ui-attention": "0.8.4-main.ef1bc66f44",
39
+ "@dxos/util": "0.8.4-main.ef1bc66f44",
40
+ "@dxos/effect": "0.8.4-main.ef1bc66f44"
37
41
  },
38
42
  "devDependencies": {
39
- "@effect-atom/atom-react": "^0.3.4",
40
- "@effect/platform": "0.92.1",
41
- "@types/react": "~19.2.2",
42
- "@types/react-dom": "~19.2.2",
43
- "effect": "3.18.3",
44
- "react": "~19.2.0",
45
- "react-dom": "~19.2.0",
43
+ "@effect-atom/atom-react": "^0.5.0",
44
+ "@effect/platform": "0.94.4",
45
+ "@types/react": "~19.2.7",
46
+ "@types/react-dom": "~19.2.3",
47
+ "effect": "3.19.16",
48
+ "react": "~19.2.3",
49
+ "react-dom": "~19.2.3",
46
50
  "vite": "7.1.9",
47
- "@dxos/live-object": "0.8.4-main.e8ec1fe",
48
- "@dxos/react-ui": "0.8.4-main.e8ec1fe",
49
- "@dxos/react-ui-theme": "0.8.4-main.e8ec1fe",
50
- "@dxos/random": "0.8.4-main.e8ec1fe",
51
- "@dxos/storybook-utils": "0.8.4-main.e8ec1fe"
51
+ "@dxos/random": "0.8.4-main.ef1bc66f44",
52
+ "@dxos/react-ui": "0.8.4-main.ef1bc66f44",
53
+ "@dxos/ui-theme": "0.8.4-main.ef1bc66f44",
54
+ "@dxos/storybook-utils": "0.8.4-main.ef1bc66f44"
52
55
  },
53
56
  "peerDependencies": {
54
- "@effect-atom/atom-react": "^0.3.4",
55
- "@effect/platform": "^0.80.12",
56
- "effect": "^3.13.3",
57
- "react": "^19.0.0",
58
- "react-dom": "^19.0.0",
59
- "@dxos/react-ui": "0.8.4-main.e8ec1fe",
60
- "@dxos/react-ui-theme": "0.8.4-main.e8ec1fe"
57
+ "@effect-atom/atom-react": "^0.5.0",
58
+ "@effect/platform": "0.94.4",
59
+ "effect": "3.19.16",
60
+ "react": "~19.2.3",
61
+ "react-dom": "~19.2.3",
62
+ "@dxos/react-ui": "0.8.4-main.ef1bc66f44",
63
+ "@dxos/ui-theme": "0.8.4-main.ef1bc66f44"
61
64
  },
62
65
  "publishConfig": {
63
66
  "access": "public"
package/src/builder.ts CHANGED
@@ -3,9 +3,9 @@
3
3
  //
4
4
 
5
5
  import { invariant } from '@dxos/invariant';
6
+ import type { MenuActionProperties, MenuItemGroupProperties } from '@dxos/ui-types';
6
7
 
7
8
  import type { ActionGraphProps } from './hooks';
8
- import type { MenuActionProperties, MenuItemGroupProperties } from './types';
9
9
  import { createMenuAction, createMenuItemGroup } from './util';
10
10
 
11
11
  export interface ActionGroupBuilder {
@@ -4,16 +4,16 @@
4
4
 
5
5
  import React from 'react';
6
6
 
7
- import { type ActionGroup, type Action as GraphAction } from '@dxos/app-graph';
7
+ import { type Node } from '@dxos/app-graph';
8
8
  import { keySymbols } from '@dxos/keyboard';
9
9
  import { type TFunction, toLocalizedString, useTranslation } from '@dxos/react-ui';
10
- import { descriptionText, mx } from '@dxos/react-ui-theme';
10
+ import { descriptionText, mx } from '@dxos/ui-theme';
11
+ import { type MenuActionProperties } from '@dxos/ui-types';
11
12
 
12
13
  import { translationKey } from '../translations';
13
- import { type MenuActionProperties } from '../types';
14
14
  import { getShortcut } from '../util';
15
15
 
16
- type Action = GraphAction<MenuActionProperties> | ActionGroup<Omit<MenuActionProperties, 'variant'>>;
16
+ type Action = Node.Action<MenuActionProperties> | Node.ActionGroup<Omit<MenuActionProperties, 'variant'>>;
17
17
 
18
18
  export const ActionLabel = ({ action }: { action: Action }) => {
19
19
  const { t } = useTranslation(translationKey);
@@ -8,6 +8,7 @@ import React, { type MouseEvent, useCallback } from 'react';
8
8
  import { type DropdownMenuRootProps, Icon, DropdownMenu as NaturalDropdownMenu } from '@dxos/react-ui';
9
9
 
10
10
  import { type MenuAction, type MenuItem, type MenuItemGroup } from '../types';
11
+ import { executeMenuAction } from '../util';
11
12
 
12
13
  import { ActionLabel } from './ActionLabel';
13
14
  import { type MenuScopedProps, useMenu, useMenuItems } from './MenuContext';
@@ -60,17 +61,23 @@ const DropdownMenuRoot = ({
60
61
  onChange: onOpenChange,
61
62
  });
62
63
 
64
+ const { onAction } = useMenu('DropdownMenuRoot', __menuScope);
65
+
63
66
  const handleActionClick = useCallback(
64
67
  (action: MenuAction, event: MouseEvent) => {
65
68
  if (action.properties?.disabled) {
66
69
  return;
67
70
  }
68
71
  event.stopPropagation();
69
- // TODO(thure): Why does Dialog’s modal-ness cause issues if we don’t explicitly close the menu here?
72
+ // TODO(thure): Why does Dialog's modal-ness cause issues if we don't explicitly close the menu here?
70
73
  setOptionsMenuOpen(false);
71
- void action.data?.({ parent: group, caller });
74
+ if (onAction) {
75
+ onAction(action, { parent: group, caller });
76
+ } else {
77
+ void executeMenuAction(action, { parent: group, caller });
78
+ }
72
79
  },
73
- [group, caller],
80
+ [group, caller, onAction],
74
81
  );
75
82
 
76
83
  const items = useMenuItems(group, propsItems);
@@ -18,6 +18,7 @@ const [MenuContextProvider, useMenu] = createMenuContext<MenuContextValue>(MENU_
18
18
  export const menuContextDefaults: MenuContextValue = {
19
19
  iconSize: 5,
20
20
  useGroupItems: () => null,
21
+ onAction: undefined,
21
22
  };
22
23
 
23
24
  const useMenuScope = createMenuScope();
@@ -29,10 +30,19 @@ const MenuProvider = ({
29
30
  useGroupItems = menuContextDefaults.useGroupItems,
30
31
  iconSize = menuContextDefaults.iconSize,
31
32
  attendableId,
33
+ alwaysActive,
34
+ onAction,
32
35
  }: MenuProviderProps) => {
33
36
  const { scope } = useMenuScope(undefined);
34
37
  return (
35
- <MenuContextProvider useGroupItems={useGroupItems} iconSize={iconSize} attendableId={attendableId} scope={scope}>
38
+ <MenuContextProvider
39
+ useGroupItems={useGroupItems}
40
+ iconSize={iconSize}
41
+ attendableId={attendableId}
42
+ alwaysActive={alwaysActive}
43
+ onAction={onAction}
44
+ scope={scope}
45
+ >
36
46
  {children}
37
47
  </MenuContextProvider>
38
48
  );
@@ -2,25 +2,26 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
+ import { Atom } from '@effect-atom/atom-react';
5
6
  import { type Meta, type StoryObj } from '@storybook/react-vite';
6
- import React, { useCallback, useMemo } from 'react';
7
+ import React, { useMemo } from 'react';
7
8
 
8
9
  import { faker } from '@dxos/random';
9
10
  import { IconButton } from '@dxos/react-ui';
10
11
  import { withTheme } from '@dxos/react-ui/testing';
12
+ import { withRegistry } from '@dxos/storybook-utils';
11
13
 
12
14
  import { MenuProvider, DropdownMenu as NaturalDropdownMenu, ToolbarMenu } from '../components';
13
- import { useMenuActions } from '../hooks';
15
+ import { type ActionGraphProps, useMenuActions } from '../hooks';
14
16
  import { createActions, createNestedActions, createNestedActionsResolver, useMutateActions } from '../testing';
15
17
  import { translations } from '../translations';
16
- import { type MenuAction } from '../types';
17
18
 
18
19
  faker.seed(1234);
19
20
 
20
21
  const meta = {
21
22
  title: 'ui/react-ui-menu/ToolbarMenu',
22
23
  component: ToolbarMenu,
23
- decorators: [withTheme],
24
+ decorators: [withTheme(), withRegistry],
24
25
  parameters: {
25
26
  translations,
26
27
  },
@@ -32,12 +33,18 @@ type Story = StoryObj<typeof meta>;
32
33
 
33
34
  export const DropdownMenu: Story = {
34
35
  render: () => {
35
- const menuActions = useMemo(() => createActions() as MenuAction[], []);
36
- const resolveGroupItems = useCallback(() => menuActions, [menuActions]);
37
- useMutateActions(menuActions);
36
+ const actionsAtom = useMemo(() => {
37
+ const actions = createActions();
38
+ return Atom.make<ActionGraphProps>({
39
+ nodes: actions,
40
+ edges: actions.map((action) => ({ source: 'root', target: action.id })),
41
+ }).pipe(Atom.keepAlive);
42
+ }, []);
43
+ useMutateActions(actionsAtom);
44
+ const menu = useMenuActions(actionsAtom);
38
45
 
39
46
  return (
40
- <MenuProvider useGroupItems={resolveGroupItems}>
47
+ <MenuProvider {...menu}>
41
48
  <NaturalDropdownMenu.Root>
42
49
  <NaturalDropdownMenu.Trigger asChild>
43
50
  <IconButton icon='ph--list-checks--regular' label='Options' />
@@ -62,6 +69,7 @@ export const Toolbar: Story = {
62
69
 
63
70
  export const UseMenuActionsToolbar: Story = {
64
71
  render: () => {
72
+ useMutateActions(createNestedActions);
65
73
  const menu = useMenuActions(createNestedActions);
66
74
 
67
75
  return (
@@ -6,12 +6,12 @@ import React, { Fragment, 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 { mx, textBlockWidth, toolbarLayout } 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,6 +19,7 @@ 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';
@@ -53,7 +54,7 @@ export type ToolbarMenuActionProps = {
53
54
  };
54
55
 
55
56
  const ActionToolbarItem = ({ action, __menuScope }: MenuScopedProps<{ action: MenuAction }>) => {
56
- const { iconSize } = useMenu('ActionToolbarItem', __menuScope);
57
+ const { iconSize, onAction } = useMenu('ActionToolbarItem', __menuScope);
57
58
  const { t } = useTranslation(translationKey);
58
59
 
59
60
  const { icon, iconOnly = true, disabled, testId, hidden, classNames } = action.properties;
@@ -62,7 +63,13 @@ const ActionToolbarItem = ({ action, __menuScope }: MenuScopedProps<{ action: Me
62
63
  ? { icon, size: iconSize, iconOnly, label: actionLabel(action, t) }
63
64
  : { children: <ActionLabel action={action} /> };
64
65
 
65
- const handleClick = useCallback(() => action.data?.(), [action]);
66
+ const handleClick = useCallback(() => {
67
+ if (onAction) {
68
+ onAction(action, {});
69
+ } else {
70
+ void executeMenuAction(action);
71
+ }
72
+ }, [action, onAction]);
66
73
 
67
74
  return hidden ? null : (
68
75
  <Root
@@ -95,7 +102,13 @@ const DropdownMenuToolbarItem = ({
95
102
  const Root = icon ? NaturalToolbar.IconButton : NaturalToolbar.Button;
96
103
  const labelAction = (group.properties as any).applyActive && activeItem ? (activeItem as MenuAction) : group;
97
104
  const rootProps = icon
98
- ? { icon, size: iconSize, iconOnly, label: actionLabel(labelAction, t), caretDown: true }
105
+ ? {
106
+ icon,
107
+ size: iconSize,
108
+ iconOnly,
109
+ label: actionLabel(labelAction, t),
110
+ caretDown: true,
111
+ }
99
112
  : {
100
113
  children: (
101
114
  <>
@@ -115,10 +128,16 @@ const DropdownMenuToolbarItem = ({
115
128
  };
116
129
 
117
130
  const ToggleGroupItem = ({ group, action, __menuScope }: MenuScopedProps<ToolbarMenuActionProps>) => {
118
- const { iconSize } = useMenu('ToggleGroupItem', __menuScope);
131
+ const { iconSize, onAction } = useMenu('ToggleGroupItem', __menuScope);
119
132
  const { t } = useTranslation(translationKey);
120
133
  const { icon, iconOnly = true, disabled, testId, hidden, classNames } = action.properties;
121
- const handleClick = useCallback(() => action.data?.({ parent: group }), [action, group]);
134
+ const handleClick = useCallback(() => {
135
+ if (onAction) {
136
+ onAction(action, { parent: group });
137
+ } else {
138
+ void executeMenuAction(action, { parent: group });
139
+ }
140
+ }, [action, group, onAction]);
122
141
  const Root = icon ? NaturalToolbar.ToggleGroupIconItem : NaturalToolbar.ToggleGroupItem;
123
142
  const rootProps = icon
124
143
  ? { icon, size: iconSize, iconOnly, label: actionLabel(action, t) }
@@ -164,22 +183,23 @@ const ToggleGroupToolbarItem = ({
164
183
  export const ToolbarMenu = ({
165
184
  __menuScope,
166
185
  classNames,
167
- textBlockWidth: textBlockWidthParam,
186
+ textBlockWidth: textBlockWidthProp,
168
187
  ...props
169
188
  }: MenuScopedProps<ToolbarMenuProps>) => {
170
189
  const items = useMenuItems(undefined, undefined, 'ToolbarMenu', __menuScope);
171
- const { attendableId } = useMenu('ToolbarMenu', __menuScope);
190
+ const { attendableId, alwaysActive } = useMenu('ToolbarMenu', __menuScope);
172
191
  const { hasAttention } = useAttention(attendableId);
173
- const InnerRoot = textBlockWidthParam ? 'div' : Fragment;
174
- const innerRootProps = textBlockWidthParam
192
+ const InnerRoot = textBlockWidthProp ? 'div' : Fragment;
193
+ const innerRootProps = textBlockWidthProp
175
194
  ? { role: 'none', className: mx(textBlockWidth, toolbarLayout, 'bs-full') }
176
195
  : {};
177
196
 
178
197
  return (
179
198
  <NaturalToolbar.Root
180
199
  {...props}
181
- classNames={[attendableId && !hasAttention && toolbarInactive, classNames]}
182
- layoutManaged={textBlockWidthParam}
200
+ classNames={[attendableId, classNames]}
201
+ disabled={!alwaysActive && !hasAttention}
202
+ layoutManaged={textBlockWidthProp}
183
203
  >
184
204
  <InnerRoot {...innerRootProps}>
185
205
  {items?.map((item: MenuItem, i: number) => (
@@ -3,41 +3,14 @@
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, useRef } 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
10
  import { type MenuItem, type MenuItemGroup } 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;
@@ -51,22 +24,20 @@ 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
- );
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
+ const graphRef = useRef<{ graph: Graph.WritableGraph; props: ActionGraphProps } | null>(null);
30
+ if (!graphRef.current || graphRef.current.props !== menuGraphProps) {
31
+ const newGraph = Graph.make({ registry });
32
+ newGraph.pipe(Graph.addNodes(menuGraphProps.nodes as Node.NodeArg<any>[]), Graph.addEdges(menuGraphProps.edges));
33
+ graphRef.current = { graph: newGraph, props: menuGraphProps };
34
+ }
61
35
 
62
- useEffect(() => {
63
- graph.addNodes(menuGraphProps.nodes);
64
- graph.addEdges(menuGraphProps.edges);
65
- }, [menuGraphProps]);
36
+ const graph = graphRef.current.graph;
66
37
 
67
38
  const useGroupItems = useCallback(
68
39
  (sourceNode?: MenuItemGroup) => {
69
- const items = useAtomValue(graph.connections(sourceNode?.id || ROOT_ID)) as MenuItem[];
40
+ const items = useAtomValue(graph.connections(sourceNode?.id || Node.RootId)) as MenuItem[];
70
41
  return items;
71
42
  },
72
43
  [graph],
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, 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 } 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,29 +36,27 @@ 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);
@@ -78,39 +66,56 @@ export const createNestedActions = Atom.make(() => {
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 = (groupParams?: CreateActionsProps, params?: CreateActionsProps) => {
74
+ const graph = Graph.make();
75
+ const actionGroups = createActions({ type: Node.ActionGroupType, ...groupParams });
86
76
  actionGroups.forEach((group) => {
87
77
  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);
78
+ graph.pipe(
79
+ Graph.addNodes([group as Node.NodeArg<any>, ...(actions as Node.NodeArg<any>[])]),
80
+ Graph.addEdges([
81
+ { source: 'root', target: group.id },
82
+ ...actions.map((action) => ({ source: group.id, target: action.id })),
83
+ ]),
84
+ Graph.expand(group.id),
85
+ );
94
86
  });
95
87
  const resolveGroupItems = (groupNode?: MenuItemGroup) =>
96
- (graph.getActions(groupNode?.id ?? ROOT_ID) || null) as MenuItem[] | null;
88
+ (Graph.getActions(graph, groupNode?.id ?? Node.RootId) || null) as MenuItem[] | null;
97
89
  return { resolveGroupItems };
98
90
  };
99
91
 
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
- };
92
+ /**
93
+ * Hook to mutate actions in an atom on an interval for testing reactivity.
94
+ */
95
+ export const useMutateActions = (actionsAtom: Atom.Writable<ActionGraphProps>) => {
96
+ const registry = useContext(RegistryContext);
111
97
 
112
- export const useMutateActions = (actions: MenuAction[]) =>
113
98
  useEffect(() => {
114
- const interval = mutateActionsOnInterval(actions);
99
+ let cursor = 0;
100
+ const interval = setInterval(() => {
101
+ const current = registry.get(actionsAtom);
102
+ const nodes = current.nodes.map((node, index) => {
103
+ if (index !== cursor) {
104
+ return node;
105
+ }
106
+ return {
107
+ ...node,
108
+ properties: {
109
+ ...node.properties,
110
+ icon: faker.helpers.arrayElement(node.properties?.icon?.endsWith('regular') ? icons.fill : icons.regular),
111
+ disabled: !node.properties?.disabled,
112
+ },
113
+ };
114
+ });
115
+ registry.set(actionsAtom, { ...current, nodes });
116
+ cursor = (cursor + 1) % current.nodes.length;
117
+ }, 1_000);
118
+
115
119
  return () => clearInterval(interval);
116
- }, []);
120
+ }, [actionsAtom, registry]);
121
+ };
@@ -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
  {