@dxos/react-ui-menu 0.7.5-main.937ce75 → 0.7.5-main.b19bfc8

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 (58) hide show
  1. package/dist/lib/browser/index.mjs +322 -115
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node/index.cjs +320 -103
  5. package/dist/lib/node/index.cjs.map +4 -4
  6. package/dist/lib/node/meta.json +1 -1
  7. package/dist/lib/node-esm/index.mjs +322 -115
  8. package/dist/lib/node-esm/index.mjs.map +4 -4
  9. package/dist/lib/node-esm/meta.json +1 -1
  10. package/dist/types/src/components/ActionLabel.d.ts +1 -2
  11. package/dist/types/src/components/ActionLabel.d.ts.map +1 -1
  12. package/dist/types/src/components/DropdownMenu.d.ts +10 -9
  13. package/dist/types/src/components/DropdownMenu.d.ts.map +1 -1
  14. package/dist/types/src/components/MenuContext.d.ts +13 -0
  15. package/dist/types/src/components/MenuContext.d.ts.map +1 -0
  16. package/dist/types/src/components/ToolbarMenu.d.ts +19 -0
  17. package/dist/types/src/components/ToolbarMenu.d.ts.map +1 -0
  18. package/dist/types/src/components/index.d.ts +2 -1
  19. package/dist/types/src/components/index.d.ts.map +1 -1
  20. package/dist/types/src/defs.d.ts +34 -7
  21. package/dist/types/src/defs.d.ts.map +1 -1
  22. package/dist/types/src/hooks.d.ts +17 -0
  23. package/dist/types/src/hooks.d.ts.map +1 -0
  24. package/dist/types/src/index.d.ts +2 -0
  25. package/dist/types/src/index.d.ts.map +1 -1
  26. package/dist/types/src/testing/Menus.stories.d.ts +7 -5
  27. package/dist/types/src/testing/Menus.stories.d.ts.map +1 -1
  28. package/dist/types/src/testing/index.d.ts +6 -6
  29. package/dist/types/src/testing/index.d.ts.map +1 -1
  30. package/dist/types/src/util.d.ts +42 -1
  31. package/dist/types/src/util.d.ts.map +1 -1
  32. package/dist/types/tsconfig.tsbuildinfo +1 -1
  33. package/package.json +16 -12
  34. package/src/components/DropdownMenu.tsx +35 -27
  35. package/src/components/MenuContext.tsx +57 -0
  36. package/src/components/ToolbarMenu.tsx +185 -0
  37. package/src/components/index.ts +2 -1
  38. package/src/defs.ts +38 -7
  39. package/src/hooks.ts +72 -0
  40. package/src/index.ts +2 -0
  41. package/src/testing/Menus.stories.tsx +30 -18
  42. package/src/testing/index.ts +24 -5
  43. package/src/util.ts +58 -1
  44. package/dist/types/src/components/Toolbar/Toolbar.d.ts +0 -4
  45. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +0 -1
  46. package/dist/types/src/components/Toolbar/ToolbarDropdownMenu.d.ts +0 -4
  47. package/dist/types/src/components/Toolbar/ToolbarDropdownMenu.d.ts.map +0 -1
  48. package/dist/types/src/components/Toolbar/ToolbarToggleGroup.d.ts +0 -4
  49. package/dist/types/src/components/Toolbar/ToolbarToggleGroup.d.ts.map +0 -1
  50. package/dist/types/src/components/Toolbar/defs.d.ts +0 -30
  51. package/dist/types/src/components/Toolbar/defs.d.ts.map +0 -1
  52. package/dist/types/src/components/Toolbar/index.d.ts +0 -3
  53. package/dist/types/src/components/Toolbar/index.d.ts.map +0 -1
  54. package/src/components/Toolbar/Toolbar.tsx +0 -71
  55. package/src/components/Toolbar/ToolbarDropdownMenu.tsx +0 -36
  56. package/src/components/Toolbar/ToolbarToggleGroup.tsx +0 -39
  57. package/src/components/Toolbar/defs.ts +0 -36
  58. package/src/components/Toolbar/index.ts +0 -6
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-menu",
3
- "version": "0.7.5-main.937ce75",
3
+ "version": "0.7.5-main.b19bfc8",
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
7
  "license": "MIT",
8
8
  "author": "DXOS.org",
9
9
  "sideEffects": true,
10
+ "type": "module",
10
11
  "exports": {
11
12
  ".": {
12
13
  "types": "./dist/types/src/index.d.ts",
@@ -23,10 +24,13 @@
23
24
  "src"
24
25
  ],
25
26
  "dependencies": {
26
- "@radix-ui/react-use-controllable-state": "^1.0.0",
27
- "@dxos/keyboard": "0.7.5-main.937ce75",
28
- "@dxos/util": "0.7.5-main.937ce75",
29
- "@dxos/app-graph": "0.7.5-main.937ce75"
27
+ "@preact/signals-react": "^1.3.8",
28
+ "@radix-ui/react-context": "1.1.1",
29
+ "@radix-ui/react-use-controllable-state": "1.1.0",
30
+ "@dxos/keyboard": "0.7.5-main.b19bfc8",
31
+ "@dxos/app-graph": "0.7.5-main.b19bfc8",
32
+ "@dxos/react-ui-attention": "0.7.5-main.b19bfc8",
33
+ "@dxos/util": "0.7.5-main.b19bfc8"
30
34
  },
31
35
  "devDependencies": {
32
36
  "@types/react": "~18.2.0",
@@ -34,17 +38,17 @@
34
38
  "react": "~18.2.0",
35
39
  "react-dom": "~18.2.0",
36
40
  "vite": "5.4.7",
37
- "@dxos/live-object": "0.7.5-main.937ce75",
38
- "@dxos/random": "0.7.5-main.937ce75",
39
- "@dxos/react-ui-theme": "0.7.5-main.937ce75",
40
- "@dxos/react-ui": "0.7.5-main.937ce75",
41
- "@dxos/storybook-utils": "0.7.5-main.937ce75"
41
+ "@dxos/random": "0.7.5-main.b19bfc8",
42
+ "@dxos/react-ui": "0.7.5-main.b19bfc8",
43
+ "@dxos/storybook-utils": "0.7.5-main.b19bfc8",
44
+ "@dxos/react-ui-theme": "0.7.5-main.b19bfc8",
45
+ "@dxos/live-object": "0.7.5-main.b19bfc8"
42
46
  },
43
47
  "peerDependencies": {
44
48
  "react": "~18.2.0",
45
49
  "react-dom": "~18.2.0",
46
- "@dxos/react-ui": "0.7.5-main.937ce75",
47
- "@dxos/react-ui-theme": "0.7.5-main.937ce75"
50
+ "@dxos/react-ui": "0.7.5-main.b19bfc8",
51
+ "@dxos/react-ui-theme": "0.7.5-main.b19bfc8"
48
52
  },
49
53
  "publishConfig": {
50
54
  "access": "public"
@@ -3,31 +3,32 @@
3
3
  //
4
4
 
5
5
  import { useControllableState } from '@radix-ui/react-use-controllable-state';
6
- import React, { type MouseEvent, type MutableRefObject, type PropsWithChildren, useCallback } from 'react';
6
+ import React, { type MouseEvent, type MutableRefObject, useCallback } from 'react';
7
7
 
8
- import { DropdownMenu as NaturalDropdownMenu, Icon } from '@dxos/react-ui';
8
+ import { DropdownMenu as NaturalDropdownMenu, Icon, type DropdownMenuRootProps } from '@dxos/react-ui';
9
9
 
10
10
  import { ActionLabel } from './ActionLabel';
11
- import { type MenuAction, type MenuProps } from '../defs';
11
+ import { type MenuScopedProps, useMenu, useMenuItems } from './MenuContext';
12
+ import { type MenuAction, type MenuItem, type MenuItemGroup } from '../defs';
12
13
 
13
- export type DropdownMenuProps = MenuProps &
14
- PropsWithChildren<
15
- Partial<{
16
- defaultMenuOpen: boolean;
17
- menuOpen: boolean;
18
- onMenuOpenChange: (nextOpen: boolean) => void;
19
- suppressNextTooltip?: MutableRefObject<boolean>;
20
- }>
21
- >;
14
+ export type DropdownMenuProps = DropdownMenuRootProps & {
15
+ group?: MenuItemGroup;
16
+ items?: MenuItem[];
17
+ suppressNextTooltip?: MutableRefObject<boolean>;
18
+ };
22
19
 
23
20
  const DropdownMenuItem = ({
24
- action,
21
+ item,
25
22
  onClick,
26
- }: {
27
- action: MenuAction;
23
+ __menuScope,
24
+ }: MenuScopedProps<{
25
+ item: MenuItem;
28
26
  onClick: (action: MenuAction, event: MouseEvent) => void;
29
- }) => {
27
+ }>) => {
28
+ // TODO(thure): handle other items.
29
+ const action = item as MenuAction;
30
30
  const handleClick = useCallback((event: MouseEvent) => onClick(action, event), [action, onClick]);
31
+ const { iconSize } = useMenu('DropdownMenuItem', __menuScope);
31
32
  return (
32
33
  <NaturalDropdownMenu.Item
33
34
  onClick={handleClick}
@@ -35,27 +36,31 @@ const DropdownMenuItem = ({
35
36
  disabled={action.properties?.disabled}
36
37
  {...(action.properties?.testId && { 'data-testid': action.properties.testId })}
37
38
  >
38
- {action.properties?.icon && <Icon icon={action.properties!.icon} size={4} />}
39
+ {action.properties?.icon && <Icon icon={action.properties!.icon} size={iconSize} />}
39
40
  <ActionLabel action={action} />
40
41
  </NaturalDropdownMenu.Item>
41
42
  );
42
43
  };
43
44
 
44
45
  const DropdownMenuRoot = ({
45
- defaultMenuOpen,
46
- actions,
47
- menuOpen,
46
+ items: propsItems,
47
+ group,
48
+ open,
49
+ defaultOpen,
50
+ onOpenChange,
48
51
  suppressNextTooltip,
49
- onMenuOpenChange,
50
- onAction,
51
52
  children,
52
- }: DropdownMenuProps) => {
53
+ __menuScope,
54
+ ...naturalProps
55
+ }: MenuScopedProps<DropdownMenuProps>) => {
53
56
  const [optionsMenuOpen, setOptionsMenuOpen] = useControllableState({
54
- prop: menuOpen,
55
- defaultProp: defaultMenuOpen,
56
- onChange: onMenuOpenChange,
57
+ prop: open,
58
+ defaultProp: defaultOpen,
59
+ onChange: onOpenChange,
57
60
  });
58
61
 
62
+ const { onAction } = useMenu('DropdownMenuRoot', __menuScope);
63
+
59
64
  const handleActionClick = useCallback(
60
65
  (action: MenuAction, event: MouseEvent) => {
61
66
  if (action.properties?.disabled) {
@@ -72,6 +77,8 @@ const DropdownMenuRoot = ({
72
77
  [onAction],
73
78
  );
74
79
 
80
+ const items = useMenuItems(group, propsItems);
81
+
75
82
  return (
76
83
  <NaturalDropdownMenu.Root
77
84
  {...{
@@ -83,12 +90,13 @@ const DropdownMenuRoot = ({
83
90
  return setOptionsMenuOpen(nextOpen);
84
91
  },
85
92
  }}
93
+ {...naturalProps}
86
94
  >
87
95
  {children}
88
96
  <NaturalDropdownMenu.Portal>
89
97
  <NaturalDropdownMenu.Content>
90
98
  <NaturalDropdownMenu.Viewport>
91
- {actions?.map((action) => <DropdownMenuItem key={action.id} action={action} onClick={handleActionClick} />)}
99
+ {items?.map((item) => <DropdownMenuItem key={item.id} item={item} onClick={handleActionClick} />)}
92
100
  </NaturalDropdownMenu.Viewport>
93
101
  <NaturalDropdownMenu.Arrow />
94
102
  </NaturalDropdownMenu.Content>
@@ -0,0 +1,57 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { createContextScope, type Scope } from '@radix-ui/react-context';
6
+ import React, { type PropsWithChildren, useMemo } from 'react';
7
+
8
+ import { type MenuContextValue, type MenuItem, type MenuItemGroup } from '../defs';
9
+
10
+ export type MenuScopedProps<P> = P & { __menuScope?: Scope };
11
+
12
+ const MENU_NAME = 'GraphMenu';
13
+
14
+ const [createMenuContext, createMenuScope] = createContextScope(MENU_NAME, []);
15
+
16
+ const [MenuContextProvider, useMenu] = createMenuContext<MenuContextValue>(MENU_NAME);
17
+
18
+ export const menuContextDefaults: MenuContextValue = {
19
+ iconSize: 5,
20
+ onAction: () => {},
21
+ resolveGroupItems: () => null,
22
+ };
23
+
24
+ const useMenuScope = createMenuScope();
25
+
26
+ const MenuProvider = ({
27
+ children,
28
+ resolveGroupItems = menuContextDefaults.resolveGroupItems,
29
+ onAction = menuContextDefaults.onAction,
30
+ iconSize = menuContextDefaults.iconSize,
31
+ attendableId,
32
+ }: PropsWithChildren<Partial<MenuContextValue>>) => {
33
+ const { scope } = useMenuScope(undefined);
34
+ return (
35
+ <MenuContextProvider
36
+ resolveGroupItems={resolveGroupItems}
37
+ onAction={onAction}
38
+ iconSize={iconSize}
39
+ attendableId={attendableId}
40
+ scope={scope}
41
+ >
42
+ {children}
43
+ </MenuContextProvider>
44
+ );
45
+ };
46
+
47
+ export const useMenuItems = (
48
+ group?: MenuItemGroup,
49
+ propsItems?: MenuItem[],
50
+ consumerName: string = 'useMenuItemConsumer',
51
+ __menuScope?: Scope,
52
+ ) => {
53
+ const { resolveGroupItems } = useMenu(consumerName, __menuScope);
54
+ return useMemo(() => propsItems ?? resolveGroupItems?.(group) ?? undefined, [propsItems, group, resolveGroupItems]);
55
+ };
56
+
57
+ export { useMenu, createMenuScope, MenuProvider };
@@ -0,0 +1,185 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React, { useCallback, useRef } from 'react';
6
+
7
+ import { Icon, type IconButtonProps, Toolbar as NaturalToolbar, type ToolbarRootProps } from '@dxos/react-ui';
8
+ import { useAttention } from '@dxos/react-ui-attention';
9
+
10
+ import { ActionLabel } from './ActionLabel';
11
+ import { DropdownMenu } from './DropdownMenu';
12
+ import { type MenuScopedProps, useMenu, useMenuItems } from './MenuContext';
13
+ import {
14
+ type MenuAction,
15
+ type MenuItem,
16
+ isMenuGroup,
17
+ isSeparator,
18
+ type MenuItemGroup,
19
+ type MenuActionProperties,
20
+ type MenuMultipleSelectActionGroup,
21
+ type MenuSingleSelectActionGroup,
22
+ } from '../defs';
23
+
24
+ export type ToolbarMenuDropdownMenuActionGroup = Omit<MenuActionProperties, 'variant' | 'icon'> & {
25
+ variant: 'dropdownMenu';
26
+ icon: string;
27
+ applyActive?: boolean;
28
+ };
29
+
30
+ export type ToolbarMenuToggleGroupActionGroup = Omit<MenuActionProperties, 'variant'> & {
31
+ variant: 'toggleGroup';
32
+ };
33
+
34
+ export type ToolbarMenuActionGroupProperties = (
35
+ | ToolbarMenuDropdownMenuActionGroup
36
+ | ToolbarMenuToggleGroupActionGroup
37
+ ) &
38
+ (MenuSingleSelectActionGroup | MenuMultipleSelectActionGroup);
39
+
40
+ export type ToolbarMenuProps = ToolbarRootProps;
41
+
42
+ export type ToolbarMenuActionGroupProps = {
43
+ group: MenuItemGroup<ToolbarMenuActionGroupProperties>;
44
+ items?: MenuItem[];
45
+ };
46
+
47
+ const ActionToolbarItem = ({ action, __menuScope }: MenuScopedProps<{ action: MenuAction }>) => {
48
+ const { onAction, iconSize } = useMenu('ActionToolbarItem', __menuScope);
49
+ const handleClick = useCallback(() => {
50
+ onAction?.(action);
51
+ }, [action, onAction]);
52
+ const { icon, iconOnly = true, disabled, testId, hidden, classNames } = action.properties;
53
+ const Root = icon ? NaturalToolbar.IconButton : NaturalToolbar.Button;
54
+ const rootProps = icon
55
+ ? { icon, size: iconSize, iconOnly, label: <ActionLabel action={action} /> }
56
+ : { children: <ActionLabel action={action} /> };
57
+ return hidden ? null : (
58
+ <Root
59
+ key={action.id}
60
+ disabled={disabled}
61
+ onClick={handleClick}
62
+ variant='ghost'
63
+ {...(testId && { 'data-testid': testId })}
64
+ {...(rootProps as IconButtonProps)}
65
+ classNames={classNames}
66
+ />
67
+ );
68
+ };
69
+
70
+ const DropdownMenuToolbarItem = ({
71
+ group,
72
+ items: propsItems,
73
+ __menuScope,
74
+ }: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
75
+ const { iconOnly, disabled, testId } = group.properties;
76
+ const suppressNextTooltip = useRef(false);
77
+ const { iconSize } = useMenu('DropdownMenuToolbarItem', __menuScope);
78
+ const items = useMenuItems(group, propsItems, 'DropdownMenuToolbarItem', __menuScope);
79
+ const activeItem = items?.find((item) => !!(item as MenuAction).properties.checked);
80
+ const icon =
81
+ ((group.properties as any).applyActive &&
82
+ // TODO(thure): Handle other menu item types.
83
+ (activeItem as MenuAction)?.properties.icon) ||
84
+ group.properties.icon;
85
+ const Root = icon ? NaturalToolbar.IconButton : NaturalToolbar.Button;
86
+ const label = (
87
+ <ActionLabel action={(group.properties as any).applyActive && activeItem ? (activeItem as MenuAction) : group} />
88
+ );
89
+ const rootProps = icon
90
+ ? { icon, size: iconSize, iconOnly, label, caretDown: true, suppressNextTooltip }
91
+ : {
92
+ children: (
93
+ <>
94
+ {label}
95
+ <Icon size={3} icon='ph--caret-down--bold' classNames='mis-1' />
96
+ </>
97
+ ),
98
+ };
99
+ return (
100
+ <DropdownMenu.Root group={group} items={items} suppressNextTooltip={suppressNextTooltip}>
101
+ <DropdownMenu.Trigger asChild>
102
+ <Root
103
+ variant='ghost'
104
+ disabled={disabled}
105
+ {...(rootProps as IconButtonProps)}
106
+ {...(testId && { 'data-testid': testId })}
107
+ />
108
+ </DropdownMenu.Trigger>
109
+ </DropdownMenu.Root>
110
+ );
111
+ };
112
+
113
+ const ToggleGroupItem = ({ action, __menuScope }: MenuScopedProps<{ action: MenuAction }>) => {
114
+ const { iconSize, onAction } = useMenu('ToggleGroupItem', __menuScope);
115
+ const { icon, iconOnly = true, disabled, testId, hidden, classNames } = action.properties;
116
+ const handleClick = useCallback(() => {
117
+ onAction?.(action);
118
+ }, [action, onAction]);
119
+ const Root = icon ? NaturalToolbar.ToggleGroupIconItem : NaturalToolbar.ToggleGroupItem;
120
+ const rootProps = icon
121
+ ? { icon, size: iconSize, iconOnly, label: <ActionLabel action={action} /> }
122
+ : { children: <ActionLabel action={action} /> };
123
+ return hidden ? null : (
124
+ <Root
125
+ key={action.id}
126
+ value={action.id as any}
127
+ disabled={disabled}
128
+ onClick={handleClick}
129
+ variant='ghost'
130
+ {...(testId && { 'data-testid': testId })}
131
+ {...(rootProps as IconButtonProps)}
132
+ classNames={classNames}
133
+ />
134
+ );
135
+ };
136
+
137
+ const ToggleGroupToolbarItem = ({
138
+ group,
139
+ items: propsItems,
140
+ __menuScope,
141
+ }: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
142
+ const items = useMenuItems(group, propsItems, 'ToggleGroupToolbarItem', __menuScope);
143
+ const { selectCardinality, value } = group.properties;
144
+ return (
145
+ // TODO(thure): The type here is difficult to manage, what do?
146
+ // @ts-ignore
147
+ <NaturalToolbar.ToggleGroup type={selectCardinality} value={value}>
148
+ {
149
+ // TODO(thure): Handle other menu item types.
150
+ (items as MenuAction[]).map((action) => (
151
+ <ToggleGroupItem key={action.id} action={action} />
152
+ ))
153
+ }
154
+ </NaturalToolbar.ToggleGroup>
155
+ );
156
+ };
157
+
158
+ export const ToolbarMenu = ({ __menuScope, classNames, ...props }: MenuScopedProps<ToolbarMenuProps>) => {
159
+ const items = useMenuItems(undefined, undefined, 'ToolbarMenu', __menuScope);
160
+ const { attendableId } = useMenu('ToolbarMenu', __menuScope);
161
+ const { hasAttention } = useAttention(attendableId);
162
+ return (
163
+ <NaturalToolbar.Root
164
+ {...props}
165
+ classNames={['p-0 pointer-fine:p-1 attention-surface', !hasAttention && 'opacity-20', classNames]}
166
+ >
167
+ {items?.map((item: MenuItem, i: number) =>
168
+ isSeparator(item) ? (
169
+ <NaturalToolbar.Separator key={i} variant={item.properties.variant} />
170
+ ) : isMenuGroup(item) ? (
171
+ item.properties.variant === 'dropdownMenu' ? (
172
+ // TODO(thure): Figure out type narrowing that doesn’t require so much use of `as`.
173
+ <DropdownMenuToolbarItem key={item.id} group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>} />
174
+ ) : (
175
+ // TODO(thure): Figure out type narrowing that doesn’t require so much use of `as`.
176
+ <ToggleGroupToolbarItem key={item.id} group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>} />
177
+ )
178
+ ) : (
179
+ // TODO(thure): Figure out type narrowing that doesn’t require so much use of `as`.
180
+ <ActionToolbarItem key={item.id} action={item as MenuAction} />
181
+ ),
182
+ )}
183
+ </NaturalToolbar.Root>
184
+ );
185
+ };
@@ -3,4 +3,5 @@
3
3
  //
4
4
 
5
5
  export * from './DropdownMenu';
6
- export * from './Toolbar';
6
+ export * from './ToolbarMenu';
7
+ export * from './MenuContext';
package/src/defs.ts CHANGED
@@ -1,23 +1,54 @@
1
1
  //
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
- import { type Node, type Action } from '@dxos/app-graph';
5
- import { type Label } from '@dxos/react-ui';
4
+ import { type Node, type Action, type ActionGroup, ACTION_GROUP_TYPE } from '@dxos/app-graph';
5
+ import { type ToolbarSeparatorProps, type Label, type IconButtonProps, type ClassNameValue } from '@dxos/react-ui';
6
6
 
7
7
  export type MenuActionProperties = {
8
8
  label: Label;
9
- icon: string;
9
+ icon?: string;
10
10
  value?: string;
11
11
  disabled?: boolean;
12
+ hidden?: boolean;
12
13
  iconOnly?: boolean;
13
14
  testId?: string;
14
15
  variant?: 'action' | 'toggle';
15
16
  checked?: boolean;
17
+ classNames?: ClassNameValue;
16
18
  };
17
19
 
18
- export type MenuAction = Action<MenuActionProperties>;
20
+ export type MenuItemGroupProperties = {
21
+ label: Label;
22
+ icon?: string;
23
+ iconOnly?: boolean;
24
+ };
25
+
26
+ export type MenuAction<P extends {} = {}> = Action<P & MenuActionProperties>;
27
+
28
+ export type MenuActionHandler<A extends Node = MenuAction> = (action: A) => void;
29
+
30
+ export const MenuSeparatorType = '@dxos/react-ui-toolbar/separator' as const;
31
+
32
+ export type MenuSeparator = Node<never, Pick<ToolbarSeparatorProps, 'variant'>> & {
33
+ type: typeof MenuSeparatorType;
34
+ };
35
+
36
+ export const isSeparator = (node: Node): node is MenuSeparator => node.type === MenuSeparatorType;
37
+
38
+ export type MenuSingleSelectActionGroup = { selectCardinality: 'single'; value: string };
39
+ export type MenuMultipleSelectActionGroup = { selectCardinality: 'multiple'; value: string[] };
40
+
41
+ export type MenuItemGroup<P extends Record<string, any> = Record<string, any>> = ActionGroup<P>;
42
+
43
+ export const isMenuGroup = (node: Node): node is MenuItemGroup => node.type === ACTION_GROUP_TYPE;
44
+
45
+ export type MenuItem = MenuSeparator | MenuAction | MenuItemGroup;
46
+
47
+ export type MenuItemsResolver = (group?: MenuItemGroup) => MenuItem[] | null;
19
48
 
20
- export type MenuProps<A extends Node = Action<MenuActionProperties>, P extends Node = A> = {
21
- actions?: A[];
22
- onAction?: (action: P) => void;
49
+ export type MenuContextValue<A extends Node = MenuAction> = {
50
+ resolveGroupItems: MenuItemsResolver;
51
+ iconSize: IconButtonProps['size'];
52
+ onAction: MenuActionHandler<A>;
53
+ attendableId?: string;
23
54
  };
package/src/hooks.ts ADDED
@@ -0,0 +1,72 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { useSignalEffect } from '@preact/signals-react';
6
+ import { useCallback, useState } from 'react';
7
+
8
+ import { Graph, type NodeArg, type Node } from '@dxos/app-graph';
9
+
10
+ import { type MenuItem, type MenuItemGroup } from './defs';
11
+
12
+ type Edge = { source: string; target: string };
13
+
14
+ const edgesArrayToRecord = (edges: { source: string; target: string }[]): Record<string, string[]> => {
15
+ return Object.fromEntries(
16
+ Object.entries(
17
+ edges.reduce((acc: Record<string, { inbound: string[]; outbound: string[] }>, { source, target }) => {
18
+ if (!acc[source]) {
19
+ acc[source] = { inbound: [], outbound: [] };
20
+ }
21
+ if (!acc[target]) {
22
+ acc[target] = { inbound: [], outbound: [] };
23
+ }
24
+
25
+ const sourceEdges = acc[source];
26
+ if (!sourceEdges.outbound.includes(target)) {
27
+ sourceEdges.outbound.push(target);
28
+ }
29
+
30
+ const targetEdges = acc[target];
31
+ if (!targetEdges.inbound.includes(source)) {
32
+ targetEdges.inbound.push(source);
33
+ }
34
+
35
+ return acc;
36
+ }, {}),
37
+ ).map(([id, { outbound }]): [string, string[]] => [id, outbound]),
38
+ );
39
+ };
40
+
41
+ export type ActionGraphNodes = NodeArg<any>[];
42
+ export type ActionGraphEdges = Edge[];
43
+ export type ActionGraphProps = { nodes: ActionGraphNodes; edges: ActionGraphEdges };
44
+
45
+ export const useMenuActions = (actionCreator: () => ActionGraphProps) => {
46
+ const initialMenuGraphProps = actionCreator();
47
+
48
+ const [graph] = useState(
49
+ new Graph({ nodes: initialMenuGraphProps.nodes as Node[], edges: edgesArrayToRecord(initialMenuGraphProps.edges) }),
50
+ );
51
+
52
+ useSignalEffect(() => {
53
+ const menuGraphProps = actionCreator();
54
+ // @ts-ignore
55
+ graph._addNodes(menuGraphProps.nodes);
56
+ // @ts-ignore
57
+ graph._addEdges(menuGraphProps.edges);
58
+ });
59
+
60
+ const resolveGroupItems = useCallback(
61
+ (sourceNode: MenuItemGroup = graph.root as MenuItemGroup) => {
62
+ if (graph) {
63
+ return (graph.nodes(sourceNode, { filter: (n): n is any => true }) || null) as MenuItem[] | null;
64
+ } else {
65
+ return null;
66
+ }
67
+ },
68
+ [graph],
69
+ );
70
+
71
+ return { resolveGroupItems };
72
+ };
package/src/index.ts CHANGED
@@ -4,3 +4,5 @@
4
4
 
5
5
  export * from './components';
6
6
  export * from './defs';
7
+ export * from './hooks';
8
+ export { createMenuAction, createMenuItemGroup, createLineSeparator, createGapSeparator } from './util';
@@ -3,41 +3,44 @@
3
3
  //
4
4
 
5
5
  import '@dxos-theme';
6
-
7
- import React from 'react';
6
+ import React, { useCallback } from 'react';
8
7
 
9
8
  import { faker } from '@dxos/random';
10
9
  import { IconButton } from '@dxos/react-ui';
11
- import { withTheme, withLayout, withSignals } from '@dxos/storybook-utils';
10
+ import { withLayout, withSignals, withTheme } from '@dxos/storybook-utils';
12
11
 
13
- import { createActions, createNestedActionGraph, useMutateActions } from './index';
14
- import { Toolbar as NaturalToolbar, DropdownMenu as NaturalDropdownMenu } from '../components';
15
- import { type MenuAction } from '../defs';
12
+ import { createActions, createNestedActions, createNestedActionsResolver, useMutateActions } from './index';
13
+ import { DropdownMenu as NaturalDropdownMenu, ToolbarMenu, MenuProvider } from '../components';
14
+ import { type MenuAction, type MenuActionHandler } from '../defs';
15
+ import { useMenuActions } from '../hooks';
16
16
  import translations from '../translations';
17
17
 
18
18
  faker.seed(1234);
19
19
 
20
20
  export default {
21
21
  title: 'ui/react-ui-menu/Menus',
22
- component: NaturalToolbar,
22
+ component: ToolbarMenu,
23
23
  decorators: [withTheme, withLayout({ tooltips: true }), withSignals],
24
24
  parameters: { translations },
25
25
  };
26
26
 
27
27
  const menuActions = createActions() as MenuAction[];
28
- const nestedMenuActions = createNestedActionGraph();
28
+ const nestedMenuActions = createNestedActionsResolver();
29
29
 
30
30
  const handleAction = (action: MenuAction) => console.log('[on action]', action);
31
31
 
32
32
  export const DropdownMenu = {
33
33
  render: () => {
34
34
  useMutateActions(menuActions);
35
+ const resolveGroupItems = useCallback(() => menuActions, []);
35
36
  return (
36
- <NaturalDropdownMenu.Root actions={menuActions} onAction={handleAction}>
37
- <NaturalDropdownMenu.Trigger asChild>
38
- <IconButton icon='ph--list-checks--regular' size={5} label='Options' />
39
- </NaturalDropdownMenu.Trigger>
40
- </NaturalDropdownMenu.Root>
37
+ <MenuProvider resolveGroupItems={resolveGroupItems} onAction={handleAction as MenuActionHandler}>
38
+ <NaturalDropdownMenu.Root>
39
+ <NaturalDropdownMenu.Trigger asChild>
40
+ <IconButton icon='ph--list-checks--regular' size={5} label='Options' />
41
+ </NaturalDropdownMenu.Trigger>
42
+ </NaturalDropdownMenu.Root>
43
+ </MenuProvider>
41
44
  );
42
45
  },
43
46
  };
@@ -45,11 +48,20 @@ export const DropdownMenu = {
45
48
  export const Toolbar = {
46
49
  render: () => {
47
50
  return (
48
- <NaturalToolbar
49
- actions={nestedMenuActions.topLevelActions}
50
- onAction={handleAction}
51
- graph={nestedMenuActions.graph}
52
- />
51
+ <MenuProvider onAction={handleAction as MenuActionHandler} {...nestedMenuActions}>
52
+ <ToolbarMenu />
53
+ </MenuProvider>
54
+ );
55
+ },
56
+ };
57
+
58
+ export const UseMenuActionsToolbar = {
59
+ render: () => {
60
+ const menu = useMenuActions(createNestedActions);
61
+ return (
62
+ <MenuProvider onAction={handleAction as MenuActionHandler} {...menu}>
63
+ <ToolbarMenu />
64
+ </MenuProvider>
53
65
  );
54
66
  },
55
67
  };