@dxos/react-ui-menu 0.8.4-main.bc674ce → 0.8.4-main.c351d160a8

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 (48) hide show
  1. package/dist/lib/browser/index.mjs +373 -209
  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 +373 -209
  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 +6 -0
  8. package/dist/types/src/builder.d.ts.map +1 -1
  9. package/dist/types/src/components/DropdownMenu.d.ts +4 -2
  10. package/dist/types/src/components/DropdownMenu.d.ts.map +1 -1
  11. package/dist/types/src/components/Menu.d.ts +48 -0
  12. package/dist/types/src/components/Menu.d.ts.map +1 -0
  13. package/dist/types/src/components/Menu.stories.d.ts +21 -0
  14. package/dist/types/src/components/Menu.stories.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/ToolbarMenu.d.ts +3 -5
  18. package/dist/types/src/components/ToolbarMenu.d.ts.map +1 -1
  19. package/dist/types/src/components/ToolbarMenu.stories.d.ts +1 -1
  20. package/dist/types/src/components/ToolbarMenu.stories.d.ts.map +1 -1
  21. package/dist/types/src/components/index.d.ts +1 -3
  22. package/dist/types/src/components/index.d.ts.map +1 -1
  23. package/dist/types/src/hooks/useMenuActions.d.ts +2 -2
  24. package/dist/types/src/hooks/useMenuActions.d.ts.map +1 -1
  25. package/dist/types/src/testing/generator.d.ts +9 -5
  26. package/dist/types/src/testing/generator.d.ts.map +1 -1
  27. package/dist/types/src/types.d.ts +26 -2
  28. package/dist/types/src/types.d.ts.map +1 -1
  29. package/dist/types/src/util.d.ts +2 -0
  30. package/dist/types/src/util.d.ts.map +1 -1
  31. package/dist/types/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +24 -21
  33. package/src/builder.ts +35 -8
  34. package/src/components/ActionLabel.tsx +2 -2
  35. package/src/components/DropdownMenu.tsx +3 -3
  36. package/src/components/Menu.stories.tsx +265 -0
  37. package/src/components/Menu.test.tsx +448 -0
  38. package/src/components/Menu.tsx +347 -0
  39. package/src/components/ToolbarMenu.stories.tsx +21 -21
  40. package/src/components/ToolbarMenu.tsx +99 -81
  41. package/src/components/index.ts +1 -3
  42. package/src/hooks/useMenuActions.ts +18 -55
  43. package/src/testing/generator.ts +17 -12
  44. package/src/types.ts +28 -2
  45. package/src/util.ts +2 -1
  46. package/dist/types/src/components/MenuContext.d.ts +0 -15
  47. package/dist/types/src/components/MenuContext.d.ts.map +0 -1
  48. package/src/components/MenuContext.tsx +0 -62
@@ -2,11 +2,10 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import React, { Fragment, useCallback } from 'react';
5
+ import React, { useCallback } from 'react';
6
6
 
7
7
  import { Icon, Toolbar as NaturalToolbar, type ToolbarRootProps, useTranslation } from '@dxos/react-ui';
8
8
  import { useAttention } from '@dxos/react-ui-attention';
9
- import { mx, textBlockWidth, toolbarInactive, toolbarLayout } from '@dxos/ui-theme';
10
9
  import { type MenuActionProperties } from '@dxos/ui-types';
11
10
 
12
11
  import { translationKey } from '../translations';
@@ -23,7 +22,7 @@ import { executeMenuAction } from '../util';
23
22
 
24
23
  import { ActionLabel, actionLabel } from './ActionLabel';
25
24
  import { DropdownMenu } from './DropdownMenu';
26
- import { type MenuScopedProps, useMenu, useMenuItems } from './MenuContext';
25
+ import { type MenuScopedProps, useMenuItems, useMenuScoped } from './Menu';
27
26
 
28
27
  export type ToolbarMenuDropdownMenuActionGroup = Omit<MenuActionProperties, 'variant' | 'icon'> & {
29
28
  variant: 'dropdownMenu';
@@ -41,7 +40,7 @@ export type ToolbarMenuActionGroupProperties = (
41
40
  ) &
42
41
  (MenuSingleSelectActionGroup | MenuMultipleSelectActionGroup);
43
42
 
44
- export type ToolbarMenuProps = ToolbarRootProps & { textBlockWidth?: boolean };
43
+ export type ToolbarMenuProps = ToolbarRootProps;
45
44
 
46
45
  export type ToolbarMenuActionGroupProps = {
47
46
  group: MenuItemGroup<ToolbarMenuActionGroupProperties>;
@@ -53,15 +52,13 @@ export type ToolbarMenuActionProps = {
53
52
  action: MenuAction;
54
53
  };
55
54
 
56
- const ActionToolbarItem = ({ action, __menuScope }: MenuScopedProps<{ action: MenuAction }>) => {
57
- const { iconSize, onAction } = useMenu('ActionToolbarItem', __menuScope);
55
+ const ActionToolbarItem = ({ __menuScope, action }: MenuScopedProps<{ action: MenuAction }>) => {
56
+ const { iconSize, onAction } = useMenuScoped('ActionToolbarItem', __menuScope);
58
57
  const { t } = useTranslation(translationKey);
59
58
 
60
59
  const { icon, iconOnly = true, disabled, testId, hidden, classNames } = action.properties;
61
- const Root = icon ? NaturalToolbar.IconButton : NaturalToolbar.Button;
62
- const rootProps = icon
63
- ? { icon, size: iconSize, iconOnly, label: actionLabel(action, t) }
64
- : { children: <ActionLabel action={action} /> };
60
+ const buttonVariant =
61
+ (action.properties as { variant?: string }).variant === 'primary' ? ('primary' as const) : ('ghost' as const);
65
62
 
66
63
  const handleClick = useCallback(() => {
67
64
  if (onAction) {
@@ -71,27 +68,42 @@ const ActionToolbarItem = ({ action, __menuScope }: MenuScopedProps<{ action: Me
71
68
  }
72
69
  }, [action, onAction]);
73
70
 
74
- return hidden ? null : (
75
- <Root
71
+ const commonProps = {
72
+ variant: buttonVariant,
73
+ disabled,
74
+ classNames,
75
+ onClick: handleClick,
76
+ ...(testId && { 'data-testid': testId }),
77
+ };
78
+
79
+ if (hidden) {
80
+ return null;
81
+ }
82
+
83
+ return icon ? (
84
+ <NaturalToolbar.IconButton
76
85
  key={action.id}
77
- disabled={disabled}
78
- onClick={handleClick}
79
- variant='ghost'
80
- {...(testId && { 'data-testid': testId })}
81
- {...(rootProps as any)}
82
- classNames={classNames}
86
+ {...commonProps}
87
+ icon={icon}
88
+ size={iconSize}
89
+ iconOnly={iconOnly}
90
+ label={actionLabel(action, t)}
83
91
  />
92
+ ) : (
93
+ <NaturalToolbar.Button key={action.id} {...commonProps}>
94
+ <ActionLabel action={action} />
95
+ </NaturalToolbar.Button>
84
96
  );
85
97
  };
86
98
 
87
99
  const DropdownMenuToolbarItem = ({
100
+ __menuScope,
88
101
  group,
89
102
  items: propsItems,
90
- __menuScope,
91
103
  }: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
92
104
  const { iconOnly, disabled, testId } = group.properties;
93
105
  const { t } = useTranslation(translationKey);
94
- const { iconSize } = useMenu('DropdownMenuToolbarItem', __menuScope);
106
+ const { iconSize } = useMenuScoped('DropdownMenuToolbarItem', __menuScope);
95
107
  const items = useMenuItems(group, propsItems, 'DropdownMenuToolbarItem', __menuScope);
96
108
  const activeItem = items?.find((item) => !!(item as MenuAction).properties.checked);
97
109
  const icon =
@@ -113,7 +125,7 @@ const DropdownMenuToolbarItem = ({
113
125
  children: (
114
126
  <>
115
127
  <ActionLabel action={labelAction} />
116
- <Icon size={3} icon='ph--caret-down--bold' classNames='mis-1' />
128
+ <Icon size={3} icon='ph--caret-down--bold' classNames='ms-1' />
117
129
  </>
118
130
  ),
119
131
  };
@@ -127,10 +139,11 @@ const DropdownMenuToolbarItem = ({
127
139
  );
128
140
  };
129
141
 
130
- const ToggleGroupItem = ({ group, action, __menuScope }: MenuScopedProps<ToolbarMenuActionProps>) => {
131
- const { iconSize, onAction } = useMenu('ToggleGroupItem', __menuScope);
142
+ const ToggleGroupItem = ({ __menuScope, group, action }: MenuScopedProps<ToolbarMenuActionProps>) => {
143
+ const { iconSize, onAction } = useMenuScoped('ToggleGroupItem', __menuScope);
132
144
  const { t } = useTranslation(translationKey);
133
145
  const { icon, iconOnly = true, disabled, testId, hidden, classNames } = action.properties;
146
+
134
147
  const handleClick = useCallback(() => {
135
148
  if (onAction) {
136
149
  onAction(action, { parent: group });
@@ -138,90 +151,95 @@ const ToggleGroupItem = ({ group, action, __menuScope }: MenuScopedProps<Toolbar
138
151
  void executeMenuAction(action, { parent: group });
139
152
  }
140
153
  }, [action, group, onAction]);
141
- const Root = icon ? NaturalToolbar.ToggleGroupIconItem : NaturalToolbar.ToggleGroupItem;
142
- const rootProps = icon
143
- ? { icon, size: iconSize, iconOnly, label: actionLabel(action, t) }
144
- : { children: <ActionLabel action={action} /> };
145
154
 
146
- return hidden ? null : (
147
- <Root
148
- key={action.id}
149
- value={action.id as any}
150
- disabled={disabled}
151
- onClick={handleClick}
152
- variant='ghost'
153
- {...(testId && { 'data-testid': testId })}
154
- {...(rootProps as any)}
155
- classNames={classNames}
155
+ const commonProps = {
156
+ value: action.id,
157
+ disabled,
158
+ variant: 'ghost' as const,
159
+ classNames,
160
+ onClick: handleClick,
161
+ ...(testId && { 'data-testid': testId }),
162
+ };
163
+
164
+ return hidden ? null : icon ? (
165
+ <NaturalToolbar.ToggleGroupIconItem
166
+ {...commonProps}
167
+ icon={icon}
168
+ size={iconSize}
169
+ iconOnly={iconOnly}
170
+ label={actionLabel(action, t)}
156
171
  />
172
+ ) : (
173
+ <NaturalToolbar.ToggleGroupItem {...commonProps}>
174
+ <ActionLabel action={action} />
175
+ </NaturalToolbar.ToggleGroupItem>
157
176
  );
158
177
  };
159
178
 
160
179
  const ToggleGroupToolbarItem = ({
161
- group,
162
- items: propsItems,
163
180
  __menuScope,
181
+ group,
182
+ items: itemsProp,
164
183
  }: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
165
- const items = useMenuItems(group, propsItems, 'ToggleGroupToolbarItem', __menuScope);
166
- const { selectCardinality, value } = group.properties;
167
-
168
- return (
169
- // TODO(thure): Fix.
170
- // @ts-ignore
171
- <NaturalToolbar.ToggleGroup type={selectCardinality} value={value}>
172
- {
173
- // TODO(thure): Handle other menu item types.
174
- (items as MenuAction[]).map((action) => (
175
- <ToggleGroupItem key={action.id} group={group} action={action} />
176
- ))
177
- }
178
- </NaturalToolbar.ToggleGroup>
179
- );
184
+ const items = useMenuItems(group, itemsProp, 'ToggleGroupToolbarItem', __menuScope);
185
+ const { selectCardinality } = group.properties;
186
+
187
+ // TODO(thure): Handle other menu item types.
188
+ const children = (items as MenuAction[]).map((action) => (
189
+ <ToggleGroupItem key={action.id} group={group} action={action} />
190
+ ));
191
+
192
+ if (selectCardinality === 'multiple') {
193
+ return (
194
+ <NaturalToolbar.ToggleGroup type='multiple' value={group.properties.value}>
195
+ {children}
196
+ </NaturalToolbar.ToggleGroup>
197
+ );
198
+ } else {
199
+ return (
200
+ <NaturalToolbar.ToggleGroup type='single' value={group.properties.value}>
201
+ {children}
202
+ </NaturalToolbar.ToggleGroup>
203
+ );
204
+ }
180
205
  };
181
206
 
182
- // TODO(burdon): Reconcile with react-ui/Toolbar (incl. textBlockWidth)
183
- export const ToolbarMenu = ({
184
- __menuScope,
185
- classNames,
186
- textBlockWidth: textBlockWidthProp,
187
- ...props
188
- }: MenuScopedProps<ToolbarMenuProps>) => {
207
+ export const ToolbarMenu = ({ __menuScope, classNames, ...props }: MenuScopedProps<ToolbarMenuProps>) => {
189
208
  const items = useMenuItems(undefined, undefined, 'ToolbarMenu', __menuScope);
190
- const { attendableId } = useMenu('ToolbarMenu', __menuScope);
209
+ const { attendableId, alwaysActive } = useMenuScoped('ToolbarMenu', __menuScope);
191
210
  const { hasAttention } = useAttention(attendableId);
192
- const InnerRoot = textBlockWidthProp ? 'div' : Fragment;
193
- const innerRootProps = textBlockWidthProp
194
- ? { role: 'none', className: mx(textBlockWidth, toolbarLayout, 'bs-full') }
195
- : {};
196
211
 
197
212
  return (
198
- <NaturalToolbar.Root
199
- {...props}
200
- classNames={[attendableId && !hasAttention && toolbarInactive, classNames]}
201
- layoutManaged={textBlockWidthProp}
202
- >
203
- <InnerRoot {...innerRootProps}>
204
- {items?.map((item: MenuItem, i: number) => (
205
- <ToolbarMenuItem key={item.id ?? i} item={item} />
206
- ))}
207
- </InnerRoot>
213
+ <NaturalToolbar.Root {...props} classNames={[attendableId, classNames]} disabled={!alwaysActive && !hasAttention}>
214
+ {items?.map((item: MenuItem) => (
215
+ <ToolbarMenuItem key={item.id} __menuScope={__menuScope} item={item} />
216
+ ))}
208
217
  </NaturalToolbar.Root>
209
218
  );
210
219
  };
211
220
 
212
- const ToolbarMenuItem = ({ item }: { item: MenuItem }) => {
221
+ const ToolbarMenuItem = ({ __menuScope, item }: MenuScopedProps<{ item: MenuItem }>) => {
213
222
  if (isSeparator(item)) {
214
223
  return <NaturalToolbar.Separator variant={item.properties.variant} />;
215
224
  }
216
225
 
217
- // TODO(thure): Figure out type narrowing that doesn’t require so much use of `as`.
218
226
  if (isMenuGroup(item)) {
219
227
  if (item.properties.variant === 'dropdownMenu') {
220
- return <DropdownMenuToolbarItem group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>} />;
228
+ return (
229
+ <DropdownMenuToolbarItem
230
+ __menuScope={__menuScope}
231
+ group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>}
232
+ />
233
+ );
221
234
  } else {
222
- return <ToggleGroupToolbarItem group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>} />;
235
+ return (
236
+ <ToggleGroupToolbarItem
237
+ __menuScope={__menuScope}
238
+ group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>}
239
+ />
240
+ );
223
241
  }
224
242
  }
225
243
 
226
- return <ActionToolbarItem action={item as MenuAction} />;
244
+ return <ActionToolbarItem __menuScope={__menuScope} action={item as MenuAction} />;
227
245
  };
@@ -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';
@@ -3,38 +3,11 @@
3
3
  //
4
4
 
5
5
  import { type Atom, RegistryContext, useAtomValue } from '@effect-atom/atom-react';
6
- import { useCallback, useContext, useEffect, useRef } from 'react';
6
+ import { useCallback, useContext, useMemo } from 'react';
7
7
 
8
8
  import { Graph, Node } from '@dxos/app-graph';
9
9
 
10
- import { type MenuItem, type MenuItemGroup } from '../types';
11
-
12
- const edgesArrayToRecord = (edges: Graph.Edge[]): Record<string, Graph.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
- };
10
+ import { type MenuItem, type MenuItemGroup, type MenuItemsAccessor } from '../types';
38
11
 
39
12
  export type ActionGraphNodes = Node.NodeArg<any>[];
40
13
  export type ActionGraphEdges = Graph.Edge[];
@@ -44,40 +17,30 @@ export type ActionGraphProps = {
44
17
  };
45
18
 
46
19
  export type MenuActions = {
47
- useGroupItems: (sourceNode?: MenuItemGroup) => MenuItem[];
20
+ items: MenuItemsAccessor;
48
21
  };
49
22
 
50
23
  export const useMenuActions = (props: Atom.Atom<ActionGraphProps>): MenuActions => {
51
24
  const registry = useContext(RegistryContext);
52
25
  const menuGraphProps = useAtomValue(props);
53
26
 
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({
58
- registry,
59
- nodes: menuGraphProps.nodes as Node.Node[],
60
- edges: edgesArrayToRecord(menuGraphProps.edges),
61
- });
62
- }
63
- const graph = graphRef.current;
64
-
65
- // Update graph nodes when props change (after render completes).
66
- const prevPropsRef = useRef(menuGraphProps);
67
- useEffect(() => {
68
- if (prevPropsRef.current !== menuGraphProps) {
69
- prevPropsRef.current = menuGraphProps;
70
- graph.pipe(Graph.addNodes(menuGraphProps.nodes), Graph.addEdges(menuGraphProps.edges));
71
- }
72
- }, [graph, menuGraphProps]);
73
-
74
- const useGroupItems = useCallback(
75
- (sourceNode?: MenuItemGroup) => {
76
- const items = useAtomValue(graph.connections(sourceNode?.id || Node.RootId)) as MenuItem[];
77
- return items;
27
+ // Create a new graph whenever props change to preserve correct order.
28
+ // (Graph.addEdges appends rather than replaces, which breaks ordering on updates.)
29
+ // NOTE: Using useMemo rather than a ref-mutation pattern to avoid calling registry.set during render,
30
+ // which would trigger atom state updates in other components (setState-in-render React warning).
31
+ const graph = useMemo(() => {
32
+ const newGraph = Graph.make({ registry });
33
+ newGraph.pipe(Graph.addNodes(menuGraphProps.nodes as Node.NodeArg<any>[]), Graph.addEdges(menuGraphProps.edges));
34
+ return newGraph;
35
+ }, [registry, menuGraphProps]);
36
+
37
+ const items: MenuItemsAccessor = useCallback(
38
+ (group?: MenuItemGroup) => {
39
+ // TODO(wittjosiah): Migrate to using action relation instead of child.
40
+ return graph.connections(group?.id || Node.RootId, 'child') as Atom.Atom<MenuItem[] | null>;
78
41
  },
79
42
  [graph],
80
43
  );
81
44
 
82
- return { useGroupItems };
45
+ return { items };
83
46
  };
@@ -2,14 +2,14 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { Atom, RegistryContext } from '@effect-atom/atom-react';
5
+ import { Atom, Registry, RegistryContext } from '@effect-atom/atom-react';
6
6
  import { useContext, useEffect } from 'react';
7
7
 
8
8
  import { Graph, Node } from '@dxos/app-graph';
9
9
  import { faker } from '@dxos/random';
10
10
 
11
11
  import { type ActionGraphProps } from '../hooks/useMenuActions';
12
- import { type MenuItem, type MenuItemGroup } from '../types';
12
+ import { type MenuItem, type MenuItemGroup, type MenuItemsAccessor } from '../types';
13
13
 
14
14
  export type CreateActionsProps = Partial<{
15
15
  type?: typeof Node.ActionType | typeof Node.ActionGroupType;
@@ -61,8 +61,8 @@ const buildNestedActions = (): ActionGraphProps => {
61
61
  const actions = createActions();
62
62
  result.nodes.push(group, ...actions);
63
63
  result.edges.push(
64
- { source: 'root', target: group.id },
65
- ...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' })),
66
66
  );
67
67
  });
68
68
  return result;
@@ -70,23 +70,28 @@ const buildNestedActions = (): ActionGraphProps => {
70
70
 
71
71
  export const createNestedActions = Atom.make(buildNestedActions()).pipe(Atom.keepAlive);
72
72
 
73
- export const createNestedActionsResolver = (groupParams?: CreateActionsProps, params?: CreateActionsProps) => {
74
- const graph = Graph.make();
73
+ export const createNestedActionsResolver = (props?: {
74
+ groupParams?: CreateActionsProps;
75
+ params?: CreateActionsProps;
76
+ registry?: Registry.Registry;
77
+ }) => {
78
+ const { groupParams, params, registry } = props ?? {};
79
+ const graph = Graph.make({ ...(registry && { registry }) });
75
80
  const actionGroups = createActions({ type: Node.ActionGroupType, ...groupParams });
76
81
  actionGroups.forEach((group) => {
77
82
  const actions = createActions(params);
78
83
  graph.pipe(
79
84
  Graph.addNodes([group as Node.NodeArg<any>, ...(actions as Node.NodeArg<any>[])]),
80
85
  Graph.addEdges([
81
- { source: 'root', target: group.id },
82
- ...actions.map((action) => ({ source: group.id, target: action.id })),
86
+ { source: 'root', target: group.id, relation: 'child' },
87
+ ...actions.map((action) => ({ source: group.id, target: action.id, relation: 'child' })),
83
88
  ]),
84
- Graph.expand(group.id),
89
+ Graph.expand(group.id, 'child'),
85
90
  );
86
91
  });
87
- const resolveGroupItems = (groupNode?: MenuItemGroup) =>
88
- (Graph.getActions(graph, groupNode?.id ?? Node.RootId) || null) as MenuItem[] | null;
89
- return { resolveGroupItems };
92
+ const items: MenuItemsAccessor = (group?: MenuItemGroup) =>
93
+ graph.connections(group?.id ?? Node.RootId, 'child') as Atom.Atom<MenuItem[] | null>;
94
+ return { items };
90
95
  };
91
96
 
92
97
  /**
package/src/types.ts CHANGED
@@ -2,6 +2,8 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
+ import { type Atom } from '@effect-atom/atom-react';
6
+
5
7
  import { Node } from '@dxos/app-graph';
6
8
  import { type IconButtonProps, type ToolbarSeparatorProps } from '@dxos/react-ui';
7
9
  import { type MenuActionProperties } from '@dxos/ui-types';
@@ -25,14 +27,38 @@ export const isMenuGroup = (node: Node.Node): node is MenuItemGroup => node.type
25
27
 
26
28
  export type MenuItem = MenuSeparator | MenuAction | MenuItemGroup;
27
29
 
28
- 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>;
29
32
 
30
33
  export type ActionExecutor = (action: MenuAction, params: Node.InvokeProps) => void;
31
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
+
32
48
  export type MenuContextValue = {
33
- useGroupItems: MenuItemsResolver;
49
+ /** Atom-family accessor for base menu items, keyed by group (similar to Tree model). */
50
+ items: MenuItemsAccessor;
34
51
  iconSize: IconButtonProps['size'];
35
52
  attendableId?: string;
53
+ /** TODO(burdon): Remove and assume always active if attendableId is undefined. */
54
+ /** If true, the menu is always active regardless of attention state. */
55
+ alwaysActive?: boolean;
56
+ /** Atom holding the current set of imperatively added menu items. */
57
+ menuItemsAtom: Atom.Atom<MenuItemsMap>;
58
+ /** Imperatively add menu items to the nearest MenuProvider. */
59
+ addMenuItems: (props: AddMenuItemsProps) => void;
60
+ /** Remove previously added menu items by id. */
61
+ removeMenuItems: (id: string) => void;
36
62
  /** Optional action executor. If provided, will be used instead of default execution. */
37
63
  onAction?: ActionExecutor;
38
64
  };
package/src/util.ts CHANGED
@@ -72,6 +72,7 @@ export const createGapSeparator = (id: string = 'gap', source: string = 'root')
72
72
  {
73
73
  source,
74
74
  target: id,
75
+ relation: 'child' as const,
75
76
  },
76
77
  ],
77
78
  });
@@ -85,5 +86,5 @@ export const createLineSeparator = (id: string = 'line', source: string = 'root'
85
86
  data: undefined as never,
86
87
  } satisfies MenuSeparator,
87
88
  ],
88
- edges: [{ source, target: id }],
89
+ edges: [{ source, target: id, relation: 'child' as const }],
89
90
  });
@@ -1,15 +0,0 @@
1
- import { type Scope } from '@radix-ui/react-context';
2
- import React, { type PropsWithChildren } from 'react';
3
- import { type MenuContextValue, type MenuItem, type MenuItemGroup } from '../types';
4
- export type MenuScopedProps<P> = P & {
5
- __menuScope?: Scope;
6
- };
7
- declare const createMenuScope: import("@radix-ui/react-context").CreateScope;
8
- declare const useMenu: (consumerName: string, scope: Scope<MenuContextValue | undefined>) => MenuContextValue;
9
- export declare const menuContextDefaults: MenuContextValue;
10
- type MenuProviderProps = PropsWithChildren<Partial<MenuContextValue>>;
11
- declare const MenuProvider: ({ children, useGroupItems, iconSize, attendableId, onAction, }: MenuProviderProps) => React.JSX.Element;
12
- export declare const useMenuItems: (group?: MenuItemGroup, propsItems?: MenuItem[], consumerName?: string, __menuScope?: Scope) => MenuItem[] | undefined;
13
- export { useMenu, createMenuScope, MenuProvider };
14
- export type { MenuProviderProps };
15
- //# sourceMappingURL=MenuContext.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"MenuContext.d.ts","sourceRoot":"","sources":["../../../../src/components/MenuContext.tsx"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,KAAK,EAAsB,MAAM,yBAAyB,CAAC;AACzE,OAAO,KAAK,EAAE,EAAE,KAAK,iBAAiB,EAAW,MAAM,OAAO,CAAC;AAE/D,OAAO,EAAE,KAAK,gBAAgB,EAAE,KAAK,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAEpF,MAAM,MAAM,eAAe,CAAC,CAAC,IAAI,CAAC,GAAG;IAAE,WAAW,CAAC,EAAE,KAAK,CAAA;CAAE,CAAC;AAI7D,QAAA,MAA0B,eAAe,+CAAqC,CAAC;AAE/E,QAAA,MAA4B,OAAO,wFAAkD,CAAC;AAEtF,eAAO,MAAM,mBAAmB,EAAE,gBAIjC,CAAC;AAIF,KAAK,iBAAiB,GAAG,iBAAiB,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAC,CAAC;AAEtE,QAAA,MAAM,YAAY,GAAI,gEAMnB,iBAAiB,sBAanB,CAAC;AAEF,eAAO,MAAM,YAAY,GACvB,QAAQ,aAAa,EACrB,aAAa,QAAQ,EAAE,EACvB,eAAc,MAA8B,EAC5C,cAAc,KAAK,2BAKpB,CAAC;AAEF,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,CAAC;AAElD,YAAY,EAAE,iBAAiB,EAAE,CAAC"}
@@ -1,62 +0,0 @@
1
- //
2
- // Copyright 2025 DXOS.org
3
- //
4
-
5
- import { type Scope, createContextScope } from '@radix-ui/react-context';
6
- import React, { type PropsWithChildren, useMemo } from 'react';
7
-
8
- import { type MenuContextValue, type MenuItem, type MenuItemGroup } from '../types';
9
-
10
- export type MenuScopedProps<P> = P & { __menuScope?: Scope };
11
-
12
- const MENU_NAME = 'Menu';
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
- useGroupItems: () => null,
21
- onAction: undefined,
22
- };
23
-
24
- const useMenuScope = createMenuScope();
25
-
26
- type MenuProviderProps = PropsWithChildren<Partial<MenuContextValue>>;
27
-
28
- const MenuProvider = ({
29
- children,
30
- useGroupItems = menuContextDefaults.useGroupItems,
31
- iconSize = menuContextDefaults.iconSize,
32
- attendableId,
33
- onAction,
34
- }: MenuProviderProps) => {
35
- const { scope } = useMenuScope(undefined);
36
- return (
37
- <MenuContextProvider
38
- useGroupItems={useGroupItems}
39
- iconSize={iconSize}
40
- attendableId={attendableId}
41
- onAction={onAction}
42
- scope={scope}
43
- >
44
- {children}
45
- </MenuContextProvider>
46
- );
47
- };
48
-
49
- export const useMenuItems = (
50
- group?: MenuItemGroup,
51
- propsItems?: MenuItem[],
52
- consumerName: string = 'useMenuItemConsumer',
53
- __menuScope?: Scope,
54
- ) => {
55
- const { useGroupItems } = useMenu(consumerName, __menuScope);
56
- const groupItems = useGroupItems(group);
57
- return useMemo(() => propsItems ?? groupItems ?? undefined, [propsItems, groupItems]);
58
- };
59
-
60
- export { useMenu, createMenuScope, MenuProvider };
61
-
62
- export type { MenuProviderProps };