@dxos/react-ui-menu 0.8.4-main.c4373fc → 0.8.4-main.c85a9c8dae

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 +547 -404
  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 +547 -404
  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 +1 -1
  12. package/dist/types/src/components/DropdownMenu.d.ts.map +1 -1
  13. package/dist/types/src/components/Menu.d.ts +46 -0
  14. package/dist/types/src/components/Menu.d.ts.map +1 -0
  15. package/dist/types/src/components/Menu.test.d.ts +2 -0
  16. package/dist/types/src/components/Menu.test.d.ts.map +1 -0
  17. package/dist/types/src/components/MenuItems.stories.d.ts +21 -0
  18. package/dist/types/src/components/MenuItems.stories.d.ts.map +1 -0
  19. package/dist/types/src/components/ToolbarMenu.d.ts +4 -3
  20. package/dist/types/src/components/ToolbarMenu.d.ts.map +1 -1
  21. package/dist/types/src/components/ToolbarMenu.stories.d.ts +2 -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 +8 -7
  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 +14 -12
  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 +36 -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.test.tsx +448 -0
  43. package/src/components/Menu.tsx +349 -0
  44. package/src/components/MenuItems.stories.tsx +265 -0
  45. package/src/components/ToolbarMenu.stories.tsx +30 -23
  46. package/src/components/ToolbarMenu.tsx +41 -16
  47. package/src/components/index.ts +1 -3
  48. package/src/hooks/useMenuActions.ts +29 -54
  49. package/src/index.ts +0 -2
  50. package/src/testing/generator.ts +67 -62
  51. package/src/translations.ts +1 -1
  52. package/src/types.ts +38 -27
  53. package/src/util.ts +34 -15
  54. package/dist/types/src/components/MenuContext.d.ts +0 -13
  55. package/dist/types/src/components/MenuContext.d.ts.map +0 -1
  56. package/src/components/MenuContext.tsx +0 -50
@@ -2,29 +2,30 @@
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
- import { MenuProvider, DropdownMenu as NaturalDropdownMenu, ToolbarMenu } from '../components';
13
- import { useMenuActions } from '../hooks';
14
+ import { Menu } from '../components';
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
- component: ToolbarMenu,
23
- decorators: [withTheme],
23
+ component: Menu.Toolbar,
24
+ decorators: [withTheme(), withRegistry],
24
25
  parameters: {
25
26
  translations,
26
27
  },
27
- } satisfies Meta<typeof ToolbarMenu>;
28
+ } satisfies Meta<typeof Menu.Toolbar>;
28
29
 
29
30
  export default meta;
30
31
 
@@ -32,18 +33,23 @@ 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, relation: 'child' })),
41
+ }).pipe(Atom.keepAlive);
42
+ }, []);
43
+ useMutateActions(actionsAtom);
44
+ const menu = useMenuActions(actionsAtom);
38
45
 
39
46
  return (
40
- <MenuProvider useGroupItems={resolveGroupItems}>
41
- <NaturalDropdownMenu.Root>
42
- <NaturalDropdownMenu.Trigger asChild>
43
- <IconButton icon='ph--list-checks--regular' size={5} label='Options' />
44
- </NaturalDropdownMenu.Trigger>
45
- </NaturalDropdownMenu.Root>
46
- </MenuProvider>
47
+ <Menu.Root {...menu}>
48
+ <Menu.Trigger asChild>
49
+ <IconButton icon='ph--list-checks--regular' label='Options' />
50
+ </Menu.Trigger>
51
+ <Menu.Content />
52
+ </Menu.Root>
47
53
  );
48
54
  },
49
55
  };
@@ -53,21 +59,22 @@ export const Toolbar: Story = {
53
59
  const nestedMenuActions = useMemo(() => createNestedActionsResolver(), []);
54
60
 
55
61
  return (
56
- <MenuProvider {...nestedMenuActions}>
57
- <ToolbarMenu />
58
- </MenuProvider>
62
+ <Menu.Root {...nestedMenuActions}>
63
+ <Menu.Toolbar />
64
+ </Menu.Root>
59
65
  );
60
66
  },
61
67
  };
62
68
 
63
69
  export const UseMenuActionsToolbar: Story = {
64
70
  render: () => {
71
+ useMutateActions(createNestedActions);
65
72
  const menu = useMenuActions(createNestedActions);
66
73
 
67
74
  return (
68
- <MenuProvider {...menu}>
69
- <ToolbarMenu />
70
- </MenuProvider>
75
+ <Menu.Root {...menu}>
76
+ <Menu.Toolbar />
77
+ </Menu.Root>
71
78
  );
72
79
  },
73
80
  };
@@ -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, 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,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';
@@ -53,15 +54,23 @@ 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 } = useMenuScoped('ActionToolbarItem', __menuScope);
57
58
  const { t } = useTranslation(translationKey);
58
- const handleClick = useCallback(() => action.data?.(), [action]);
59
+
59
60
  const { icon, iconOnly = true, disabled, testId, hidden, classNames } = action.properties;
60
61
  const Root = icon ? NaturalToolbar.IconButton : NaturalToolbar.Button;
61
62
  const rootProps = icon
62
63
  ? { icon, size: iconSize, iconOnly, label: actionLabel(action, t) }
63
64
  : { children: <ActionLabel action={action} /> };
64
65
 
66
+ const handleClick = useCallback(() => {
67
+ if (onAction) {
68
+ onAction(action, {});
69
+ } else {
70
+ void executeMenuAction(action);
71
+ }
72
+ }, [action, onAction]);
73
+
65
74
  return hidden ? null : (
66
75
  <Root
67
76
  key={action.id}
@@ -82,7 +91,7 @@ const DropdownMenuToolbarItem = ({
82
91
  }: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
83
92
  const { iconOnly, disabled, testId } = group.properties;
84
93
  const { t } = useTranslation(translationKey);
85
- const { iconSize } = useMenu('DropdownMenuToolbarItem', __menuScope);
94
+ const { iconSize } = useMenuScoped('DropdownMenuToolbarItem', __menuScope);
86
95
  const items = useMenuItems(group, propsItems, 'DropdownMenuToolbarItem', __menuScope);
87
96
  const activeItem = items?.find((item) => !!(item as MenuAction).properties.checked);
88
97
  const icon =
@@ -93,12 +102,18 @@ const DropdownMenuToolbarItem = ({
93
102
  const Root = icon ? NaturalToolbar.IconButton : NaturalToolbar.Button;
94
103
  const labelAction = (group.properties as any).applyActive && activeItem ? (activeItem as MenuAction) : group;
95
104
  const rootProps = icon
96
- ? { 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
+ }
97
112
  : {
98
113
  children: (
99
114
  <>
100
115
  <ActionLabel action={labelAction} />
101
- <Icon size={3} icon='ph--caret-down--bold' classNames='mis-1' />
116
+ <Icon size={3} icon='ph--caret-down--bold' classNames='ms-1' />
102
117
  </>
103
118
  ),
104
119
  };
@@ -113,10 +128,16 @@ const DropdownMenuToolbarItem = ({
113
128
  };
114
129
 
115
130
  const ToggleGroupItem = ({ group, action, __menuScope }: MenuScopedProps<ToolbarMenuActionProps>) => {
116
- const { iconSize } = useMenu('ToggleGroupItem', __menuScope);
131
+ const { iconSize, onAction } = useMenuScoped('ToggleGroupItem', __menuScope);
117
132
  const { t } = useTranslation(translationKey);
118
133
  const { icon, iconOnly = true, disabled, testId, hidden, classNames } = action.properties;
119
- 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]);
120
141
  const Root = icon ? NaturalToolbar.ToggleGroupIconItem : NaturalToolbar.ToggleGroupItem;
121
142
  const rootProps = icon
122
143
  ? { icon, size: iconSize, iconOnly, label: actionLabel(action, t) }
@@ -158,23 +179,27 @@ const ToggleGroupToolbarItem = ({
158
179
  );
159
180
  };
160
181
 
182
+ // TODO(burdon): Reconcile with react-ui/Toolbar (incl. textBlockWidth)
161
183
  export const ToolbarMenu = ({
162
184
  __menuScope,
163
185
  classNames,
164
- textBlockWidth: wrapContents,
186
+ textBlockWidth: textBlockWidthProp,
165
187
  ...props
166
188
  }: MenuScopedProps<ToolbarMenuProps>) => {
167
189
  const items = useMenuItems(undefined, undefined, 'ToolbarMenu', __menuScope);
168
- const { attendableId } = useMenu('ToolbarMenu', __menuScope);
190
+ const { attendableId, alwaysActive } = useMenuScoped('ToolbarMenu', __menuScope);
169
191
  const { hasAttention } = useAttention(attendableId);
170
- const InnerRoot = wrapContents ? 'div' : Fragment;
171
- const innerRootProps = wrapContents ? { role: 'none', className: mx(textBlockWidth, toolbarLayout, 'bs-full') } : {};
192
+ const InnerRoot = textBlockWidthProp ? 'div' : Fragment;
193
+ const innerRootProps = textBlockWidthProp
194
+ ? { role: 'none', className: mx(textBlockWidth, toolbarLayout, 'h-full') }
195
+ : {};
172
196
 
173
197
  return (
174
198
  <NaturalToolbar.Root
175
199
  {...props}
176
- layoutManaged={wrapContents}
177
- classNames={[attendableId && !hasAttention && '*:opacity-20 !bg-transparent', classNames]}
200
+ classNames={[attendableId, classNames]}
201
+ disabled={!alwaysActive && !hasAttention}
202
+ layoutManaged={textBlockWidthProp}
178
203
  >
179
204
  <InnerRoot {...innerRootProps}>
180
205
  {items?.map((item: MenuItem, i: number) => (
@@ -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,71 +2,46 @@
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 { type Atom, RegistryContext, useAtomValue } from '@effect-atom/atom-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
- 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
- export const useMenuActions = (props: Rx.Rx<ActionGraphProps>) => {
47
- const registry = useContext(RegistryContext);
48
- const menuGraphProps = useRxValue(props);
49
-
50
- const [graph] = useState(
51
- new Graph({
52
- registry,
53
- nodes: menuGraphProps.nodes as Node[],
54
- edges: edgesArrayToRecord(menuGraphProps.edges),
55
- }),
56
- );
57
-
58
- useEffect(() => {
59
- graph.addNodes(menuGraphProps.nodes);
60
- graph.addEdges(menuGraphProps.edges);
61
- }, [menuGraphProps]);
19
+ export type MenuActions = {
20
+ items: MenuItemsAccessor;
21
+ };
62
22
 
63
- const useGroupItems = useCallback(
64
- (sourceNode?: MenuItemGroup) => {
65
- const items = useRxValue(graph.connections(sourceNode?.id || ROOT_ID)) as MenuItem[];
66
- return items;
23
+ export const useMenuActions = (props: Atom.Atom<ActionGraphProps>): MenuActions => {
24
+ const registry = useContext(RegistryContext);
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
+ 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
+ }
35
+
36
+ const graph = graphRef.current.graph;
37
+
38
+ const items: MenuItemsAccessor = useCallback(
39
+ (group?: MenuItemGroup) => {
40
+ // TODO(wittjosiah): Migrate to using action relation instead of child.
41
+ return graph.connections(group?.id || Node.RootId, 'child') as Atom.Atom<MenuItem[] | null>;
67
42
  },
68
43
  [graph],
69
44
  );
70
45
 
71
- return { useGroupItems };
46
+ return { items };
72
47
  };
package/src/index.ts CHANGED
@@ -2,8 +2,6 @@
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';
@@ -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, 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,86 @@ 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 = 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 = (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, relation: 'child' },
82
+ ...actions.map((action) => ({ source: group.id, target: action.id, relation: 'child' })),
83
+ ]),
84
+ Graph.expand(group.id, 'child'),
85
+ );
94
86
  });
95
- const resolveGroupItems = (groupNode?: MenuItemGroup) =>
96
- (graph.getActions(groupNode?.id ?? ROOT_ID) || null) as MenuItem[] | null;
97
- return { resolveGroupItems };
87
+ const items: MenuItemsAccessor = (group?: MenuItemGroup) =>
88
+ graph.connections(group?.id ?? Node.RootId, 'child') as Atom.Atom<MenuItem[] | null>;
89
+ return { items };
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
  {
package/src/types.ts CHANGED
@@ -2,51 +2,62 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { ACTION_GROUP_TYPE, type Action, type ActionGroup, type Node } from '@dxos/app-graph';
6
- import { type ClassNameValue, type IconButtonProps, type Label, type ToolbarSeparatorProps } from '@dxos/react-ui';
7
-
8
- export type MenuActionProperties = {
9
- label: Label;
10
- icon?: string;
11
- value?: string;
12
- disabled?: boolean;
13
- hidden?: boolean;
14
- iconOnly?: boolean;
15
- testId?: string;
16
- variant?: 'action' | 'toggle';
17
- checked?: boolean;
18
- classNames?: ClassNameValue;
19
- };
5
+ import { type Atom } from '@effect-atom/atom-react';
20
6
 
21
- export type MenuItemGroupProperties = {
22
- label: Label;
23
- icon?: string;
24
- iconOnly?: boolean;
25
- };
7
+ import { Node } from '@dxos/app-graph';
8
+ import { type IconButtonProps, type ToolbarSeparatorProps } from '@dxos/react-ui';
9
+ import { type MenuActionProperties } from '@dxos/ui-types';
26
10
 
27
- export type MenuAction<P extends {} = {}> = Action<P & MenuActionProperties>;
11
+ export type MenuAction<P extends {} = {}> = Node.Action<P & MenuActionProperties>;
28
12
 
29
13
  export const MenuSeparatorType = '@dxos/react-ui-toolbar/separator' as const;
30
14
 
31
- export type MenuSeparator = Node<never, Pick<ToolbarSeparatorProps, 'variant'>> & {
15
+ export type MenuSeparator = Node.Node<never, Pick<ToolbarSeparatorProps, 'variant'>> & {
32
16
  type: typeof MenuSeparatorType;
33
17
  };
34
18
 
35
- export const isSeparator = (node: Node): node is MenuSeparator => node.type === MenuSeparatorType;
19
+ export const isSeparator = (node: Node.Node): node is MenuSeparator => node.type === MenuSeparatorType;
36
20
 
37
21
  export type MenuSingleSelectActionGroup = { selectCardinality: 'single'; value: string };
38
22
  export type MenuMultipleSelectActionGroup = { selectCardinality: 'multiple'; value: string[] };
39
23
 
40
- export type MenuItemGroup<P extends Record<string, any> = Record<string, any>> = ActionGroup<P>;
24
+ export type MenuItemGroup<P extends Record<string, any> = Record<string, any>> = Node.ActionGroup<P>;
41
25
 
42
- export const isMenuGroup = (node: Node): node is MenuItemGroup => node.type === ACTION_GROUP_TYPE;
26
+ export const isMenuGroup = (node: Node.Node): node is MenuItemGroup => node.type === Node.ActionGroupType;
43
27
 
44
28
  export type MenuItem = MenuSeparator | MenuAction | MenuItemGroup;
45
29
 
46
- export type MenuItemsResolver = (group?: MenuItemGroup) => MenuItem[] | null;
30
+ /** Atom-family-style accessor: returns an atom of items for a given group (or root when undefined). */
31
+ export type MenuItemsAccessor = (group?: MenuItemGroup) => Atom.Atom<MenuItem[] | null>;
32
+
33
+ export type ActionExecutor = (action: MenuAction, params: Node.InvokeProps) => void;
34
+
35
+ export type MenuItemsMode = 'additive' | 'replacement';
36
+
37
+ export type AddMenuItemsProps = {
38
+ id: string;
39
+ mode: MenuItemsMode;
40
+ priority?: number;
41
+ items: MenuItem[];
42
+ groupFilter?: (group?: MenuItemGroup) => boolean;
43
+ };
44
+
45
+ export type MenuItems = Omit<AddMenuItemsProps, 'priority'> & { priority: number };
46
+ export type MenuItemsMap = Map<string, MenuItems>;
47
47
 
48
48
  export type MenuContextValue = {
49
- useGroupItems: MenuItemsResolver;
49
+ /** Atom-family accessor for base menu items, keyed by group (similar to Tree model). */
50
+ items: MenuItemsAccessor;
50
51
  iconSize: IconButtonProps['size'];
51
52
  attendableId?: string;
53
+ /** If true, the menu is always active regardless of attention state. */
54
+ alwaysActive?: boolean;
55
+ /** Atom holding the current set of imperatively added menu items. */
56
+ menuItemsAtom: Atom.Atom<MenuItemsMap>;
57
+ /** Imperatively add menu items to the nearest MenuProvider. */
58
+ addMenuItems: (props: AddMenuItemsProps) => void;
59
+ /** Remove previously added menu items by id. */
60
+ removeMenuItems: (id: string) => void;
61
+ /** Optional action executor. If provided, will be used instead of default execution. */
62
+ onAction?: ActionExecutor;
52
63
  };