@dxos/react-ui-menu 0.8.4-main.84f28bd → 0.8.4-main.937b3ca

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 (47) hide show
  1. package/dist/lib/browser/index.mjs +308 -317
  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 +308 -317
  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 +3 -3
  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/MenuContext.d.ts +3 -1
  14. package/dist/types/src/components/MenuContext.d.ts.map +1 -1
  15. package/dist/types/src/components/ToolbarMenu.d.ts +3 -2
  16. package/dist/types/src/components/ToolbarMenu.d.ts.map +1 -1
  17. package/dist/types/src/components/ToolbarMenu.stories.d.ts +20 -0
  18. package/dist/types/src/components/ToolbarMenu.stories.d.ts.map +1 -0
  19. package/dist/types/src/hooks/useMenuActions.d.ts +6 -5
  20. package/dist/types/src/hooks/useMenuActions.d.ts.map +1 -1
  21. package/dist/types/src/index.d.ts +0 -1
  22. package/dist/types/src/index.d.ts.map +1 -1
  23. package/dist/types/src/testing/generator.d.ts +13 -11
  24. package/dist/types/src/testing/generator.d.ts.map +1 -1
  25. package/dist/types/src/translations.d.ts +2 -2
  26. package/dist/types/src/translations.d.ts.map +1 -1
  27. package/dist/types/src/types.d.ts +11 -24
  28. package/dist/types/src/types.d.ts.map +1 -1
  29. package/dist/types/src/util.d.ts +12 -5
  30. package/dist/types/src/util.d.ts.map +1 -1
  31. package/dist/types/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +33 -29
  33. package/src/builder.ts +11 -11
  34. package/src/components/ActionLabel.tsx +5 -5
  35. package/src/components/DropdownMenu.tsx +16 -6
  36. package/src/components/MenuContext.tsx +16 -4
  37. package/src/components/ToolbarMenu.stories.tsx +81 -0
  38. package/src/components/ToolbarMenu.tsx +68 -34
  39. package/src/hooks/useMenuActions.ts +32 -19
  40. package/src/index.ts +0 -2
  41. package/src/testing/generator.ts +63 -58
  42. package/src/translations.ts +1 -1
  43. package/src/types.ts +12 -26
  44. package/src/util.ts +32 -14
  45. package/dist/types/src/components/Menus.stories.d.ts +0 -25
  46. package/dist/types/src/components/Menus.stories.d.ts.map +0 -1
  47. package/src/components/Menus.stories.tsx +0 -69
package/package.json CHANGED
@@ -1,15 +1,20 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-menu",
3
- "version": "0.8.4-main.84f28bd",
3
+ "version": "0.8.4-main.937b3ca",
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
  ".": {
17
+ "source": "./src/index.ts",
13
18
  "types": "./dist/types/src/index.d.ts",
14
19
  "browser": "./dist/lib/browser/index.mjs",
15
20
  "node": "./dist/lib/node-esm/index.mjs"
@@ -24,39 +29,38 @@
24
29
  "src"
25
30
  ],
26
31
  "dependencies": {
27
- "@preact-signals/safe-react": "^0.9.0",
28
- "@preact/signals-react": "^3.2.0",
29
32
  "@radix-ui/react-context": "1.1.1",
30
33
  "@radix-ui/react-use-controllable-state": "1.1.0",
31
- "@dxos/app-graph": "0.8.4-main.84f28bd",
32
- "@dxos/invariant": "0.8.4-main.84f28bd",
33
- "@dxos/keyboard": "0.8.4-main.84f28bd",
34
- "@dxos/react-ui-attention": "0.8.4-main.84f28bd",
35
- "@dxos/util": "0.8.4-main.84f28bd"
34
+ "@dxos/app-graph": "0.8.4-main.937b3ca",
35
+ "@dxos/invariant": "0.8.4-main.937b3ca",
36
+ "@dxos/keyboard": "0.8.4-main.937b3ca",
37
+ "@dxos/effect": "0.8.4-main.937b3ca",
38
+ "@dxos/ui-types": "0.8.4-main.937b3ca",
39
+ "@dxos/react-ui-attention": "0.8.4-main.937b3ca",
40
+ "@dxos/util": "0.8.4-main.937b3ca"
36
41
  },
37
42
  "devDependencies": {
38
- "@effect-rx/rx-react": "0.38.0",
39
- "@effect/platform": "0.88.0",
40
- "@types/react": "~18.2.0",
41
- "@types/react-dom": "~18.2.0",
42
- "effect": "3.16.13",
43
- "react": "~18.2.0",
44
- "react-dom": "~18.2.0",
45
- "vite": "5.4.7",
46
- "@dxos/live-object": "0.8.4-main.84f28bd",
47
- "@dxos/random": "0.8.4-main.84f28bd",
48
- "@dxos/react-ui": "0.8.4-main.84f28bd",
49
- "@dxos/react-ui-theme": "0.8.4-main.84f28bd",
50
- "@dxos/storybook-utils": "0.8.4-main.84f28bd"
43
+ "@effect-atom/atom-react": "^0.4.6",
44
+ "@effect/platform": "0.93.6",
45
+ "@types/react": "~19.2.7",
46
+ "@types/react-dom": "~19.2.3",
47
+ "effect": "3.19.11",
48
+ "react": "~19.2.3",
49
+ "react-dom": "~19.2.3",
50
+ "vite": "7.1.9",
51
+ "@dxos/random": "0.8.4-main.937b3ca",
52
+ "@dxos/react-ui": "0.8.4-main.937b3ca",
53
+ "@dxos/storybook-utils": "0.8.4-main.937b3ca",
54
+ "@dxos/ui-theme": "0.8.4-main.937b3ca"
51
55
  },
52
56
  "peerDependencies": {
53
- "@effect-rx/rx-react": "^0.34.1",
54
- "@effect/platform": "^0.80.12",
55
- "effect": "^3.13.3",
56
- "react": "~18.2.0",
57
- "react-dom": "~18.2.0",
58
- "@dxos/react-ui": "0.8.4-main.84f28bd",
59
- "@dxos/react-ui-theme": "0.8.4-main.84f28bd"
57
+ "@effect-atom/atom-react": "^0.4.6",
58
+ "@effect/platform": "0.93.6",
59
+ "effect": "3.19.11",
60
+ "react": "~19.2.3",
61
+ "react-dom": "~19.2.3",
62
+ "@dxos/ui-theme": "0.8.4-main.937b3ca",
63
+ "@dxos/react-ui": "0.8.4-main.937b3ca"
60
64
  },
61
65
  "publishConfig": {
62
66
  "access": "public"
package/src/builder.ts CHANGED
@@ -3,16 +3,16 @@
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 {
12
- action<P extends {} = {}>(id: string, invoke: () => void, properties: P & MenuActionProperties): this;
12
+ action<P extends {} = {}>(id: string, props: P & MenuActionProperties, invoke: () => void): this;
13
13
  group<P extends {} = {}>(
14
14
  id: string,
15
- properties: P & MenuItemGroupProperties,
15
+ props: P & MenuItemGroupProperties,
16
16
  cb: (builder: ActionGroupBuilder) => void,
17
17
  ): this;
18
18
  }
@@ -32,12 +32,6 @@ class MenuBuilderImpl implements MenuBuilder {
32
32
  private readonly _rootId: string,
33
33
  ) {}
34
34
 
35
- action<P extends {} = {}>(id: string, invoke: () => void, properties: P & MenuActionProperties): this {
36
- this._data.nodes.push(createMenuAction(id, invoke, properties));
37
- this._data.edges.push({ source: this._rootId, target: id });
38
- return this;
39
- }
40
-
41
35
  root(props: MenuItemGroupProperties): this {
42
36
  invariant(this._rootId === 'root', 'Root group can only be at the top level');
43
37
  invariant(this._data.nodes.find((node) => node.id === 'root') === undefined, 'Root group can only be created once');
@@ -45,12 +39,18 @@ class MenuBuilderImpl implements MenuBuilder {
45
39
  return this;
46
40
  }
47
41
 
42
+ action<P extends {} = {}>(id: string, props: P & MenuActionProperties, invoke: () => void): this {
43
+ this._data.nodes.push(createMenuAction(id, invoke, props));
44
+ this._data.edges.push({ source: this._rootId, target: id });
45
+ return this;
46
+ }
47
+
48
48
  group<P extends {} = {}>(
49
49
  id: string,
50
- properties: P & MenuItemGroupProperties,
50
+ props: P & MenuItemGroupProperties,
51
51
  cb: (builder: ActionGroupBuilder) => void,
52
52
  ): this {
53
- this._data.nodes.push(createMenuItemGroup(id, properties));
53
+ this._data.nodes.push(createMenuItemGroup(id, props));
54
54
  this._data.edges.push({ source: this._rootId, target: id });
55
55
  cb(new MenuBuilderImpl(this._data, id));
56
56
  return this;
@@ -4,16 +4,16 @@
4
4
 
5
5
  import React from 'react';
6
6
 
7
- import { type Action as GraphAction, type ActionGroup } from '@dxos/app-graph';
7
+ import { type Node } from '@dxos/app-graph';
8
8
  import { keySymbols } from '@dxos/keyboard';
9
- import { toLocalizedString, useTranslation, type TFunction } from '@dxos/react-ui';
10
- import { mx, descriptionText } from '@dxos/react-ui-theme';
9
+ import { type TFunction, toLocalizedString, useTranslation } from '@dxos/react-ui';
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);
@@ -5,11 +5,13 @@
5
5
  import { useControllableState } from '@radix-ui/react-use-controllable-state';
6
6
  import React, { type MouseEvent, useCallback } from 'react';
7
7
 
8
- import { DropdownMenu as NaturalDropdownMenu, Icon, type DropdownMenuRootProps } from '@dxos/react-ui';
8
+ import { type DropdownMenuRootProps, Icon, DropdownMenu as NaturalDropdownMenu } from '@dxos/react-ui';
9
+
10
+ import { type MenuAction, type MenuItem, type MenuItemGroup } from '../types';
11
+ import { executeMenuAction } from '../util';
9
12
 
10
13
  import { ActionLabel } from './ActionLabel';
11
14
  import { type MenuScopedProps, useMenu, useMenuItems } from './MenuContext';
12
- import { type MenuAction, type MenuItem, type MenuItemGroup } from '../types';
13
15
 
14
16
  export type DropdownMenuProps = DropdownMenuRootProps & {
15
17
  group?: MenuItemGroup;
@@ -59,17 +61,23 @@ const DropdownMenuRoot = ({
59
61
  onChange: onOpenChange,
60
62
  });
61
63
 
64
+ const { onAction } = useMenu('DropdownMenuRoot', __menuScope);
65
+
62
66
  const handleActionClick = useCallback(
63
67
  (action: MenuAction, event: MouseEvent) => {
64
68
  if (action.properties?.disabled) {
65
69
  return;
66
70
  }
67
71
  event.stopPropagation();
68
- // 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?
69
73
  setOptionsMenuOpen(false);
70
- 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
+ }
71
79
  },
72
- [group, caller],
80
+ [group, caller, onAction],
73
81
  );
74
82
 
75
83
  const items = useMenuItems(group, propsItems);
@@ -80,7 +88,9 @@ const DropdownMenuRoot = ({
80
88
  <NaturalDropdownMenu.Portal>
81
89
  <NaturalDropdownMenu.Content>
82
90
  <NaturalDropdownMenu.Viewport>
83
- {items?.map((item) => <DropdownMenuItem key={item.id} item={item} onClick={handleActionClick} />)}
91
+ {items?.map((item) => (
92
+ <DropdownMenuItem key={item.id} item={item} onClick={handleActionClick} />
93
+ ))}
84
94
  </NaturalDropdownMenu.Viewport>
85
95
  <NaturalDropdownMenu.Arrow />
86
96
  </NaturalDropdownMenu.Content>
@@ -2,14 +2,14 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { createContextScope, type Scope } from '@radix-ui/react-context';
5
+ import { type Scope, createContextScope } from '@radix-ui/react-context';
6
6
  import React, { type PropsWithChildren, useMemo } from 'react';
7
7
 
8
8
  import { type MenuContextValue, type MenuItem, type MenuItemGroup } from '../types';
9
9
 
10
10
  export type MenuScopedProps<P> = P & { __menuScope?: Scope };
11
11
 
12
- const MENU_NAME = 'GraphMenu';
12
+ const MENU_NAME = 'Menu';
13
13
 
14
14
  const [createMenuContext, createMenuScope] = createContextScope(MENU_NAME, []);
15
15
 
@@ -18,19 +18,29 @@ 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();
24
25
 
26
+ type MenuProviderProps = PropsWithChildren<Partial<MenuContextValue>>;
27
+
25
28
  const MenuProvider = ({
26
29
  children,
27
30
  useGroupItems = menuContextDefaults.useGroupItems,
28
31
  iconSize = menuContextDefaults.iconSize,
29
32
  attendableId,
30
- }: PropsWithChildren<Partial<MenuContextValue>>) => {
33
+ onAction,
34
+ }: MenuProviderProps) => {
31
35
  const { scope } = useMenuScope(undefined);
32
36
  return (
33
- <MenuContextProvider useGroupItems={useGroupItems} iconSize={iconSize} attendableId={attendableId} scope={scope}>
37
+ <MenuContextProvider
38
+ useGroupItems={useGroupItems}
39
+ iconSize={iconSize}
40
+ attendableId={attendableId}
41
+ onAction={onAction}
42
+ scope={scope}
43
+ >
34
44
  {children}
35
45
  </MenuContextProvider>
36
46
  );
@@ -48,3 +58,5 @@ export const useMenuItems = (
48
58
  };
49
59
 
50
60
  export { useMenu, createMenuScope, MenuProvider };
61
+
62
+ export type { MenuProviderProps };
@@ -0,0 +1,81 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { Atom } from '@effect-atom/atom-react';
6
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
7
+ import React, { useMemo } from 'react';
8
+
9
+ import { faker } from '@dxos/random';
10
+ import { IconButton } from '@dxos/react-ui';
11
+ import { withTheme } from '@dxos/react-ui/testing';
12
+ import { withRegistry } from '@dxos/storybook-utils';
13
+
14
+ import { MenuProvider, DropdownMenu as NaturalDropdownMenu, ToolbarMenu } from '../components';
15
+ import { type ActionGraphProps, useMenuActions } from '../hooks';
16
+ import { createActions, createNestedActions, createNestedActionsResolver, useMutateActions } from '../testing';
17
+ import { translations } from '../translations';
18
+
19
+ faker.seed(1234);
20
+
21
+ const meta = {
22
+ title: 'ui/react-ui-menu/ToolbarMenu',
23
+ component: ToolbarMenu,
24
+ decorators: [withTheme, withRegistry],
25
+ parameters: {
26
+ translations,
27
+ },
28
+ } satisfies Meta<typeof ToolbarMenu>;
29
+
30
+ export default meta;
31
+
32
+ type Story = StoryObj<typeof meta>;
33
+
34
+ export const DropdownMenu: Story = {
35
+ render: () => {
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);
45
+
46
+ return (
47
+ <MenuProvider {...menu}>
48
+ <NaturalDropdownMenu.Root>
49
+ <NaturalDropdownMenu.Trigger asChild>
50
+ <IconButton icon='ph--list-checks--regular' label='Options' />
51
+ </NaturalDropdownMenu.Trigger>
52
+ </NaturalDropdownMenu.Root>
53
+ </MenuProvider>
54
+ );
55
+ },
56
+ };
57
+
58
+ export const Toolbar: Story = {
59
+ render: () => {
60
+ const nestedMenuActions = useMemo(() => createNestedActionsResolver(), []);
61
+
62
+ return (
63
+ <MenuProvider {...nestedMenuActions}>
64
+ <ToolbarMenu />
65
+ </MenuProvider>
66
+ );
67
+ },
68
+ };
69
+
70
+ export const UseMenuActionsToolbar: Story = {
71
+ render: () => {
72
+ useMutateActions(createNestedActions);
73
+ const menu = useMenuActions(createNestedActions);
74
+
75
+ return (
76
+ <MenuProvider {...menu}>
77
+ <ToolbarMenu />
78
+ </MenuProvider>
79
+ );
80
+ },
81
+ };
@@ -6,22 +6,24 @@ 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, toolbarInactive, toolbarLayout } from '@dxos/ui-theme';
10
+ import { type MenuActionProperties } from '@dxos/ui-types';
10
11
 
11
- import { actionLabel, ActionLabel } from './ActionLabel';
12
- import { DropdownMenu } from './DropdownMenu';
13
- import { type MenuScopedProps, useMenu, useMenuItems } from './MenuContext';
14
12
  import { translationKey } from '../translations';
15
13
  import {
16
14
  type MenuAction,
17
15
  type MenuItem,
18
- isMenuGroup,
19
- isSeparator,
20
16
  type MenuItemGroup,
21
- type MenuActionProperties,
22
17
  type MenuMultipleSelectActionGroup,
23
18
  type MenuSingleSelectActionGroup,
19
+ isMenuGroup,
20
+ isSeparator,
24
21
  } from '../types';
22
+ import { executeMenuAction } from '../util';
23
+
24
+ import { ActionLabel, actionLabel } from './ActionLabel';
25
+ import { DropdownMenu } from './DropdownMenu';
26
+ import { type MenuScopedProps, useMenu, useMenuItems } from './MenuContext';
25
27
 
26
28
  export type ToolbarMenuDropdownMenuActionGroup = Omit<MenuActionProperties, 'variant' | 'icon'> & {
27
29
  variant: 'dropdownMenu';
@@ -52,14 +54,23 @@ export type ToolbarMenuActionProps = {
52
54
  };
53
55
 
54
56
  const ActionToolbarItem = ({ action, __menuScope }: MenuScopedProps<{ action: MenuAction }>) => {
55
- const { iconSize } = useMenu('ActionToolbarItem', __menuScope);
57
+ const { iconSize, onAction } = useMenu('ActionToolbarItem', __menuScope);
56
58
  const { t } = useTranslation(translationKey);
57
- const handleClick = useCallback(() => action.data?.(), [action]);
59
+
58
60
  const { icon, iconOnly = true, disabled, testId, hidden, classNames } = action.properties;
59
61
  const Root = icon ? NaturalToolbar.IconButton : NaturalToolbar.Button;
60
62
  const rootProps = icon
61
63
  ? { icon, size: iconSize, iconOnly, label: actionLabel(action, t) }
62
64
  : { children: <ActionLabel action={action} /> };
65
+
66
+ const handleClick = useCallback(() => {
67
+ if (onAction) {
68
+ onAction(action, {});
69
+ } else {
70
+ void executeMenuAction(action);
71
+ }
72
+ }, [action, onAction]);
73
+
63
74
  return hidden ? null : (
64
75
  <Root
65
76
  key={action.id}
@@ -91,7 +102,13 @@ const DropdownMenuToolbarItem = ({
91
102
  const Root = icon ? NaturalToolbar.IconButton : NaturalToolbar.Button;
92
103
  const labelAction = (group.properties as any).applyActive && activeItem ? (activeItem as MenuAction) : group;
93
104
  const rootProps = icon
94
- ? { 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
+ }
95
112
  : {
96
113
  children: (
97
114
  <>
@@ -100,6 +117,7 @@ const DropdownMenuToolbarItem = ({
100
117
  </>
101
118
  ),
102
119
  };
120
+
103
121
  return (
104
122
  <DropdownMenu.Root group={group} items={items}>
105
123
  <DropdownMenu.Trigger asChild>
@@ -110,14 +128,21 @@ const DropdownMenuToolbarItem = ({
110
128
  };
111
129
 
112
130
  const ToggleGroupItem = ({ group, action, __menuScope }: MenuScopedProps<ToolbarMenuActionProps>) => {
113
- const { iconSize } = useMenu('ToggleGroupItem', __menuScope);
131
+ const { iconSize, onAction } = useMenu('ToggleGroupItem', __menuScope);
114
132
  const { t } = useTranslation(translationKey);
115
133
  const { icon, iconOnly = true, disabled, testId, hidden, classNames } = action.properties;
116
- 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]);
117
141
  const Root = icon ? NaturalToolbar.ToggleGroupIconItem : NaturalToolbar.ToggleGroupItem;
118
142
  const rootProps = icon
119
143
  ? { icon, size: iconSize, iconOnly, label: actionLabel(action, t) }
120
144
  : { children: <ActionLabel action={action} /> };
145
+
121
146
  return hidden ? null : (
122
147
  <Root
123
148
  key={action.id}
@@ -139,8 +164,9 @@ const ToggleGroupToolbarItem = ({
139
164
  }: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
140
165
  const items = useMenuItems(group, propsItems, 'ToggleGroupToolbarItem', __menuScope);
141
166
  const { selectCardinality, value } = group.properties;
167
+
142
168
  return (
143
- // TODO(thure): The type here is difficult to manage, what do?
169
+ // TODO(thure): Fix.
144
170
  // @ts-ignore
145
171
  <NaturalToolbar.ToggleGroup type={selectCardinality} value={value}>
146
172
  {
@@ -153,41 +179,49 @@ const ToggleGroupToolbarItem = ({
153
179
  );
154
180
  };
155
181
 
182
+ // TODO(burdon): Reconcile with react-ui/Toolbar (incl. textBlockWidth)
156
183
  export const ToolbarMenu = ({
157
184
  __menuScope,
158
185
  classNames,
159
- textBlockWidth: wrapContents,
186
+ textBlockWidth: textBlockWidthProp,
160
187
  ...props
161
188
  }: MenuScopedProps<ToolbarMenuProps>) => {
162
189
  const items = useMenuItems(undefined, undefined, 'ToolbarMenu', __menuScope);
163
190
  const { attendableId } = useMenu('ToolbarMenu', __menuScope);
164
191
  const { hasAttention } = useAttention(attendableId);
165
- const InnerRoot = wrapContents ? 'div' : Fragment;
166
- 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, 'bs-full') }
195
+ : {};
196
+
167
197
  return (
168
198
  <NaturalToolbar.Root
169
199
  {...props}
170
- layoutManaged={wrapContents}
171
- classNames={[attendableId && !hasAttention && '*:opacity-20 !bg-transparent', classNames]}
200
+ classNames={[attendableId && !hasAttention && toolbarInactive, classNames]}
201
+ layoutManaged={textBlockWidthProp}
172
202
  >
173
203
  <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
- )}
204
+ {items?.map((item: MenuItem, i: number) => (
205
+ <ToolbarMenuItem key={item.id ?? i} item={item} />
206
+ ))}
190
207
  </InnerRoot>
191
208
  </NaturalToolbar.Root>
192
209
  );
193
210
  };
211
+
212
+ const ToolbarMenuItem = ({ item }: { item: MenuItem }) => {
213
+ if (isSeparator(item)) {
214
+ return <NaturalToolbar.Separator variant={item.properties.variant} />;
215
+ }
216
+
217
+ // TODO(thure): Figure out type narrowing that doesn’t require so much use of `as`.
218
+ if (isMenuGroup(item)) {
219
+ if (item.properties.variant === 'dropdownMenu') {
220
+ return <DropdownMenuToolbarItem group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>} />;
221
+ } else {
222
+ return <ToggleGroupToolbarItem group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>} />;
223
+ }
224
+ }
225
+
226
+ return <ActionToolbarItem action={item as MenuAction} />;
227
+ };
@@ -2,14 +2,14 @@
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 NodeArg, type Node, 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> => {
12
+ const edgesArrayToRecord = (edges: Graph.Edge[]): Record<string, Graph.Edges> => {
13
13
  return Object.fromEntries(
14
14
  Object.entries(
15
15
  edges.reduce((acc: Record<string, { inbound: string[]; outbound: string[] }>, { source, target }) => {
@@ -36,30 +36,43 @@ const edgesArrayToRecord = (edges: Edge[]): Record<string, Edges> => {
36
36
  );
37
37
  };
38
38
 
39
- export type ActionGraphNodes = NodeArg<any>[];
40
- export type ActionGraphEdges = Edge[];
41
- export type ActionGraphProps = { nodes: ActionGraphNodes; edges: ActionGraphEdges };
39
+ export type ActionGraphNodes = Node.NodeArg<any>[];
40
+ export type ActionGraphEdges = Graph.Edge[];
41
+ export type ActionGraphProps = {
42
+ nodes: ActionGraphNodes;
43
+ edges: ActionGraphEdges;
44
+ };
45
+
46
+ export type MenuActions = {
47
+ useGroupItems: (sourceNode?: MenuItemGroup) => MenuItem[];
48
+ };
42
49
 
43
- export const useMenuActions = (props: Rx.Rx<ActionGraphProps>) => {
50
+ export const useMenuActions = (props: Atom.Atom<ActionGraphProps>): MenuActions => {
44
51
  const registry = useContext(RegistryContext);
45
- const menuGraphProps = useRxValue(props);
52
+ const menuGraphProps = useAtomValue(props);
46
53
 
47
- const [graph] = useState(
48
- new Graph({
54
+ // Create graph once, then update it when props change.
55
+ const graphRef = useRef<Graph.WritableGraph | null>(null);
56
+ if (!graphRef.current) {
57
+ graphRef.current = Graph.make({
49
58
  registry,
50
- nodes: menuGraphProps.nodes as Node[],
59
+ nodes: menuGraphProps.nodes as Node.Node[],
51
60
  edges: edgesArrayToRecord(menuGraphProps.edges),
52
- }),
53
- );
61
+ });
62
+ }
63
+ const graph = graphRef.current;
54
64
 
55
- useEffect(() => {
56
- graph.addNodes(menuGraphProps.nodes);
57
- graph.addEdges(menuGraphProps.edges);
58
- }, [menuGraphProps]);
65
+ // Update graph nodes synchronously when props change (before render completes).
66
+ // Using a ref to track the previous value to detect changes.
67
+ const prevPropsRef = useRef(menuGraphProps);
68
+ if (prevPropsRef.current !== menuGraphProps) {
69
+ prevPropsRef.current = menuGraphProps;
70
+ graph.pipe(Graph.addNodes(menuGraphProps.nodes), Graph.addEdges(menuGraphProps.edges));
71
+ }
59
72
 
60
73
  const useGroupItems = useCallback(
61
74
  (sourceNode?: MenuItemGroup) => {
62
- const items = useRxValue(graph.connections(sourceNode?.id || ROOT_ID)) as MenuItem[];
75
+ const items = useAtomValue(graph.connections(sourceNode?.id || Node.RootId)) as MenuItem[];
63
76
  return items;
64
77
  },
65
78
  [graph],
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';