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

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 (60) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/browser/index.mjs +396 -217
  3. package/dist/lib/browser/index.mjs.map +4 -4
  4. package/dist/lib/browser/meta.json +1 -1
  5. package/dist/lib/browser/translations.mjs +14 -0
  6. package/dist/lib/browser/translations.mjs.map +7 -0
  7. package/dist/lib/node-esm/index.mjs +396 -217
  8. package/dist/lib/node-esm/index.mjs.map +4 -4
  9. package/dist/lib/node-esm/meta.json +1 -1
  10. package/dist/lib/node-esm/translations.mjs +16 -0
  11. package/dist/lib/node-esm/translations.mjs.map +7 -0
  12. package/dist/types/src/builder.d.ts +26 -1
  13. package/dist/types/src/builder.d.ts.map +1 -1
  14. package/dist/types/src/builder.test.d.ts +2 -0
  15. package/dist/types/src/builder.test.d.ts.map +1 -0
  16. package/dist/types/src/components/ActionLabel.d.ts.map +1 -1
  17. package/dist/types/src/components/DropdownMenu.d.ts +4 -2
  18. package/dist/types/src/components/DropdownMenu.d.ts.map +1 -1
  19. package/dist/types/src/components/Menu.d.ts +50 -0
  20. package/dist/types/src/components/Menu.d.ts.map +1 -0
  21. package/dist/types/src/components/Menu.stories.d.ts +21 -0
  22. package/dist/types/src/components/Menu.stories.d.ts.map +1 -0
  23. package/dist/types/src/components/Menu.test.d.ts +2 -0
  24. package/dist/types/src/components/Menu.test.d.ts.map +1 -0
  25. package/dist/types/src/components/ToolbarMenu.d.ts +5 -5
  26. package/dist/types/src/components/ToolbarMenu.d.ts.map +1 -1
  27. package/dist/types/src/components/ToolbarMenu.stories.d.ts +15 -1
  28. package/dist/types/src/components/ToolbarMenu.stories.d.ts.map +1 -1
  29. package/dist/types/src/components/index.d.ts +1 -3
  30. package/dist/types/src/components/index.d.ts.map +1 -1
  31. package/dist/types/src/hooks/useMenuActions.d.ts +11 -3
  32. package/dist/types/src/hooks/useMenuActions.d.ts.map +1 -1
  33. package/dist/types/src/index.d.ts +1 -1
  34. package/dist/types/src/index.d.ts.map +1 -1
  35. package/dist/types/src/testing/generator.d.ts +10 -6
  36. package/dist/types/src/testing/generator.d.ts.map +1 -1
  37. package/dist/types/src/types.d.ts +25 -3
  38. package/dist/types/src/types.d.ts.map +1 -1
  39. package/dist/types/src/util.d.ts +2 -0
  40. package/dist/types/src/util.d.ts.map +1 -1
  41. package/dist/types/tsconfig.tsbuildinfo +1 -1
  42. package/package.json +30 -21
  43. package/src/builder.test.ts +224 -0
  44. package/src/builder.ts +68 -18
  45. package/src/components/ActionLabel.tsx +4 -3
  46. package/src/components/DropdownMenu.tsx +3 -4
  47. package/src/components/Menu.stories.tsx +266 -0
  48. package/src/components/Menu.test.tsx +447 -0
  49. package/src/components/Menu.tsx +348 -0
  50. package/src/components/ToolbarMenu.stories.tsx +41 -27
  51. package/src/components/ToolbarMenu.tsx +123 -90
  52. package/src/components/index.ts +1 -3
  53. package/src/hooks/useMenuActions.ts +26 -15
  54. package/src/index.ts +1 -1
  55. package/src/testing/generator.ts +24 -19
  56. package/src/types.ts +26 -2
  57. package/src/util.ts +2 -1
  58. package/dist/types/src/components/MenuContext.d.ts +0 -15
  59. package/dist/types/src/components/MenuContext.d.ts.map +0 -1
  60. package/src/components/MenuContext.tsx +0 -64
@@ -2,14 +2,15 @@
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, toolbarLayout } from '@dxos/ui-theme';
9
+ import { composable, composableProps } from '@dxos/ui-theme';
10
10
  import { type MenuActionProperties } from '@dxos/ui-types';
11
11
 
12
- import { translationKey } from '../translations';
12
+ import { translationKey } from '#translations';
13
+
13
14
  import {
14
15
  type MenuAction,
15
16
  type MenuItem,
@@ -20,10 +21,9 @@ import {
20
21
  isSeparator,
21
22
  } from '../types';
22
23
  import { executeMenuAction } from '../util';
23
-
24
24
  import { ActionLabel, actionLabel } from './ActionLabel';
25
25
  import { DropdownMenu } from './DropdownMenu';
26
- import { type MenuScopedProps, useMenu, useMenuItems } from './MenuContext';
26
+ import { type MenuScopedProps, useMenuItems, useMenuScoped } from './Menu';
27
27
 
28
28
  export type ToolbarMenuDropdownMenuActionGroup = Omit<MenuActionProperties, 'variant' | 'icon'> & {
29
29
  variant: 'dropdownMenu';
@@ -41,7 +41,7 @@ export type ToolbarMenuActionGroupProperties = (
41
41
  ) &
42
42
  (MenuSingleSelectActionGroup | MenuMultipleSelectActionGroup);
43
43
 
44
- export type ToolbarMenuProps = ToolbarRootProps & { textBlockWidth?: boolean };
44
+ export type ToolbarMenuProps = ToolbarRootProps;
45
45
 
46
46
  export type ToolbarMenuActionGroupProps = {
47
47
  group: MenuItemGroup<ToolbarMenuActionGroupProperties>;
@@ -53,15 +53,13 @@ export type ToolbarMenuActionProps = {
53
53
  action: MenuAction;
54
54
  };
55
55
 
56
- const ActionToolbarItem = ({ action, __menuScope }: MenuScopedProps<{ action: MenuAction }>) => {
57
- const { iconSize, onAction } = useMenu('ActionToolbarItem', __menuScope);
56
+ const ActionToolbarItem = ({ __menuScope, action }: MenuScopedProps<{ action: MenuAction }>) => {
57
+ const { iconSize, onAction } = useMenuScoped('ActionToolbarItem', __menuScope);
58
58
  const { t } = useTranslation(translationKey);
59
59
 
60
- 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 { icon, iconOnly = true, disabled, testId, hidden, classNames, iconClassNames } = action.properties;
61
+ const buttonVariant =
62
+ (action.properties as { variant?: string }).variant === 'primary' ? ('primary' as const) : ('ghost' as const);
65
63
 
66
64
  const handleClick = useCallback(() => {
67
65
  if (onAction) {
@@ -71,27 +69,43 @@ const ActionToolbarItem = ({ action, __menuScope }: MenuScopedProps<{ action: Me
71
69
  }
72
70
  }, [action, onAction]);
73
71
 
74
- return hidden ? null : (
75
- <Root
72
+ const commonProps = {
73
+ variant: buttonVariant,
74
+ disabled,
75
+ classNames,
76
+ onClick: handleClick,
77
+ ...(testId && { 'data-testid': testId }),
78
+ };
79
+
80
+ if (hidden) {
81
+ return null;
82
+ }
83
+
84
+ return icon ? (
85
+ <NaturalToolbar.IconButton
76
86
  key={action.id}
77
- disabled={disabled}
78
- onClick={handleClick}
79
- variant='ghost'
80
- {...(testId && { 'data-testid': testId })}
81
- {...(rootProps as any)}
82
- classNames={classNames}
87
+ {...commonProps}
88
+ icon={icon}
89
+ size={iconSize}
90
+ iconOnly={iconOnly}
91
+ iconClassNames={iconClassNames}
92
+ label={actionLabel(action, t)}
83
93
  />
94
+ ) : (
95
+ <NaturalToolbar.Button key={action.id} {...commonProps}>
96
+ <ActionLabel action={action} />
97
+ </NaturalToolbar.Button>
84
98
  );
85
99
  };
86
100
 
87
101
  const DropdownMenuToolbarItem = ({
102
+ __menuScope,
88
103
  group,
89
104
  items: propsItems,
90
- __menuScope,
91
105
  }: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
92
106
  const { iconOnly, disabled, testId } = group.properties;
93
107
  const { t } = useTranslation(translationKey);
94
- const { iconSize } = useMenu('DropdownMenuToolbarItem', __menuScope);
108
+ const { iconSize } = useMenuScoped('DropdownMenuToolbarItem', __menuScope);
95
109
  const items = useMenuItems(group, propsItems, 'DropdownMenuToolbarItem', __menuScope);
96
110
  const activeItem = items?.find((item) => !!(item as MenuAction).properties.checked);
97
111
  const icon =
@@ -99,6 +113,11 @@ const DropdownMenuToolbarItem = ({
99
113
  // TODO(thure): Handle other menu item types.
100
114
  (activeItem as MenuAction)?.properties.icon) ||
101
115
  group.properties.icon;
116
+ // Follow the same `applyActive` rule for `iconClassNames` so a per-item
117
+ // accent (e.g. tag colour) tracks the displayed icon.
118
+ const iconClassNames =
119
+ ((group.properties as any).applyActive && (activeItem as MenuAction)?.properties.iconClassNames) ||
120
+ (group.properties as { iconClassNames?: any }).iconClassNames;
102
121
  const Root = icon ? NaturalToolbar.IconButton : NaturalToolbar.Button;
103
122
  const labelAction = (group.properties as any).applyActive && activeItem ? (activeItem as MenuAction) : group;
104
123
  const rootProps = icon
@@ -106,6 +125,7 @@ const DropdownMenuToolbarItem = ({
106
125
  icon,
107
126
  size: iconSize,
108
127
  iconOnly,
128
+ iconClassNames,
109
129
  label: actionLabel(labelAction, t),
110
130
  caretDown: true,
111
131
  }
@@ -113,7 +133,7 @@ const DropdownMenuToolbarItem = ({
113
133
  children: (
114
134
  <>
115
135
  <ActionLabel action={labelAction} />
116
- <Icon size={3} icon='ph--caret-down--bold' classNames='mis-1' />
136
+ <Icon size={3} icon='ph--caret-down--bold' classNames='ms-1' />
117
137
  </>
118
138
  ),
119
139
  };
@@ -127,10 +147,11 @@ const DropdownMenuToolbarItem = ({
127
147
  );
128
148
  };
129
149
 
130
- const ToggleGroupItem = ({ group, action, __menuScope }: MenuScopedProps<ToolbarMenuActionProps>) => {
131
- const { iconSize, onAction } = useMenu('ToggleGroupItem', __menuScope);
150
+ const ToggleGroupItem = ({ __menuScope, group, action }: MenuScopedProps<ToolbarMenuActionProps>) => {
151
+ const { iconSize, onAction } = useMenuScoped('ToggleGroupItem', __menuScope);
132
152
  const { t } = useTranslation(translationKey);
133
- const { icon, iconOnly = true, disabled, testId, hidden, classNames } = action.properties;
153
+ const { icon, iconOnly = true, disabled, testId, hidden, classNames, iconClassNames } = action.properties;
154
+
134
155
  const handleClick = useCallback(() => {
135
156
  if (onAction) {
136
157
  onAction(action, { parent: group });
@@ -138,91 +159,103 @@ const ToggleGroupItem = ({ group, action, __menuScope }: MenuScopedProps<Toolbar
138
159
  void executeMenuAction(action, { parent: group });
139
160
  }
140
161
  }, [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
162
 
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}
163
+ const commonProps = {
164
+ value: action.id,
165
+ disabled,
166
+ variant: 'ghost' as const,
167
+ classNames,
168
+ onClick: handleClick,
169
+ ...(testId && { 'data-testid': testId }),
170
+ };
171
+
172
+ return hidden ? null : icon ? (
173
+ <NaturalToolbar.ToggleGroupIconItem
174
+ {...commonProps}
175
+ icon={icon}
176
+ size={iconSize}
177
+ iconOnly={iconOnly}
178
+ iconClassNames={iconClassNames}
179
+ label={actionLabel(action, t)}
156
180
  />
181
+ ) : (
182
+ <NaturalToolbar.ToggleGroupItem {...commonProps}>
183
+ <ActionLabel action={action} />
184
+ </NaturalToolbar.ToggleGroupItem>
157
185
  );
158
186
  };
159
187
 
160
188
  const ToggleGroupToolbarItem = ({
161
- group,
162
- items: propsItems,
163
189
  __menuScope,
190
+ group,
191
+ items: itemsProp,
164
192
  }: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
165
- const items = useMenuItems(group, propsItems, 'ToggleGroupToolbarItem', __menuScope);
166
- const { selectCardinality, value } = group.properties;
193
+ const items = useMenuItems(group, itemsProp, 'ToggleGroupToolbarItem', __menuScope);
194
+ const { selectCardinality } = group.properties;
167
195
 
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
- );
196
+ // TODO(thure): Handle other menu item types.
197
+ const children = (items as MenuAction[]).map((action) => (
198
+ <ToggleGroupItem key={action.id} group={group} action={action} />
199
+ ));
200
+
201
+ if (selectCardinality === 'multiple') {
202
+ return (
203
+ <NaturalToolbar.ToggleGroup type='multiple' value={group.properties.value}>
204
+ {children}
205
+ </NaturalToolbar.ToggleGroup>
206
+ );
207
+ } else {
208
+ return (
209
+ <NaturalToolbar.ToggleGroup type='single' value={group.properties.value}>
210
+ {children}
211
+ </NaturalToolbar.ToggleGroup>
212
+ );
213
+ }
180
214
  };
181
215
 
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>) => {
189
- const items = useMenuItems(undefined, undefined, 'ToolbarMenu', __menuScope);
190
- const { attendableId, alwaysActive } = useMenu('ToolbarMenu', __menuScope);
191
- const { hasAttention } = useAttention(attendableId);
192
- const InnerRoot = textBlockWidthProp ? 'div' : Fragment;
193
- const innerRootProps = textBlockWidthProp
194
- ? { role: 'none', className: mx(textBlockWidth, toolbarLayout, 'bs-full') }
195
- : {};
216
+ export const ToolbarMenu = composable<HTMLDivElement, MenuScopedProps<ToolbarMenuProps>>(
217
+ ({ __menuScope, children, ...props }, forwardedRef) => {
218
+ const items = useMenuItems(undefined, undefined, 'ToolbarMenu', __menuScope);
219
+ const { attendableId, alwaysActive } = useMenuScoped('ToolbarMenu', __menuScope);
220
+ const { hasAttention } = useAttention(attendableId);
196
221
 
197
- return (
198
- <NaturalToolbar.Root
199
- {...props}
200
- classNames={[attendableId, classNames]}
201
- disabled={!alwaysActive && !hasAttention}
202
- layoutManaged={textBlockWidthProp}
203
- >
204
- <InnerRoot {...innerRootProps}>
205
- {items?.map((item: MenuItem, i: number) => (
206
- <ToolbarMenuItem key={item.id ?? i} item={item} />
222
+ return (
223
+ <NaturalToolbar.Root
224
+ {...composableProps(props, { classNames: attendableId })}
225
+ disabled={!alwaysActive && !hasAttention}
226
+ ref={forwardedRef}
227
+ >
228
+ {items?.map((item: MenuItem) => (
229
+ <ToolbarMenuItem key={item.id} __menuScope={__menuScope} item={item} />
207
230
  ))}
208
- </InnerRoot>
209
- </NaturalToolbar.Root>
210
- );
211
- };
231
+ {children}
232
+ </NaturalToolbar.Root>
233
+ );
234
+ },
235
+ );
212
236
 
213
- const ToolbarMenuItem = ({ item }: { item: MenuItem }) => {
237
+ const ToolbarMenuItem = ({ __menuScope, item }: MenuScopedProps<{ item: MenuItem }>) => {
214
238
  if (isSeparator(item)) {
215
239
  return <NaturalToolbar.Separator variant={item.properties.variant} />;
216
240
  }
217
241
 
218
- // TODO(thure): Figure out type narrowing that doesn’t require so much use of `as`.
219
242
  if (isMenuGroup(item)) {
220
243
  if (item.properties.variant === 'dropdownMenu') {
221
- return <DropdownMenuToolbarItem group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>} />;
244
+ return (
245
+ <DropdownMenuToolbarItem
246
+ __menuScope={__menuScope}
247
+ group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>}
248
+ />
249
+ );
222
250
  } else {
223
- return <ToggleGroupToolbarItem group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>} />;
251
+ return (
252
+ <ToggleGroupToolbarItem
253
+ __menuScope={__menuScope}
254
+ group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>}
255
+ />
256
+ );
224
257
  }
225
258
  }
226
259
 
227
- return <ActionToolbarItem action={item as MenuAction} />;
260
+ return <ActionToolbarItem __menuScope={__menuScope} action={item as MenuAction} />;
228
261
  };
@@ -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,12 +2,12 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { type Atom, RegistryContext, useAtomValue } from '@effect-atom/atom-react';
6
- import { useCallback, useContext, useRef } from 'react';
5
+ import { Atom, RegistryContext, useAtomValue } from '@effect-atom/atom-react';
6
+ import { type DependencyList, 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';
10
+ import { type MenuItem, type MenuItemGroup, type MenuItemsAccessor } from '../types';
11
11
 
12
12
  export type ActionGraphNodes = Node.NodeArg<any>[];
13
13
  export type ActionGraphEdges = Graph.Edge[];
@@ -17,7 +17,7 @@ export type ActionGraphProps = {
17
17
  };
18
18
 
19
19
  export type MenuActions = {
20
- useGroupItems: (sourceNode?: MenuItemGroup) => MenuItem[];
20
+ items: MenuItemsAccessor;
21
21
  };
22
22
 
23
23
  export const useMenuActions = (props: Atom.Atom<ActionGraphProps>): MenuActions => {
@@ -26,22 +26,33 @@ export const useMenuActions = (props: Atom.Atom<ActionGraphProps>): MenuActions
26
26
 
27
27
  // Create a new graph whenever props change to preserve correct order.
28
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) {
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(() => {
31
32
  const newGraph = Graph.make({ registry });
32
33
  newGraph.pipe(Graph.addNodes(menuGraphProps.nodes as Node.NodeArg<any>[]), Graph.addEdges(menuGraphProps.edges));
33
- graphRef.current = { graph: newGraph, props: menuGraphProps };
34
- }
34
+ return newGraph;
35
+ }, [registry, menuGraphProps]);
35
36
 
36
- const graph = graphRef.current.graph;
37
-
38
- const useGroupItems = useCallback(
39
- (sourceNode?: MenuItemGroup) => {
40
- const items = useAtomValue(graph.connections(sourceNode?.id || Node.RootId)) as MenuItem[];
41
- return items;
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>;
42
41
  },
43
42
  [graph],
44
43
  );
45
44
 
46
- return { useGroupItems };
45
+ return { items };
46
+ };
47
+
48
+ /**
49
+ * Convenience wrapper around `useMenuActions` that creates the backing atom inline.
50
+ * Pass a builder thunk and a dependency list — the hook memoizes `Atom.make(build)` and threads
51
+ * it through `useMenuActions`. Saves the `useMemo(() => Atom.make(...), deps)` boilerplate when
52
+ * the action graph is composed from local state (e.g. a toolbar driven by component state).
53
+ */
54
+ export const useMenuBuilder = (build: (get: Atom.Context) => ActionGraphProps, deps: DependencyList): MenuActions => {
55
+ // eslint-disable-next-line react-hooks/exhaustive-deps
56
+ const atom = useMemo(() => Atom.make(build), deps);
57
+ return useMenuActions(atom);
47
58
  };
package/src/index.ts CHANGED
@@ -6,4 +6,4 @@ export * from './components';
6
6
  export * from './hooks';
7
7
  export * from './types';
8
8
  export { createMenuAction, createMenuItemGroup, createLineSeparator, createGapSeparator } from './util';
9
- export { MenuBuilder } from './builder';
9
+ export { type ActionGroupBuilder, type ActionGroupBuilderFn, MenuBuilder } from './builder';
@@ -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
- import { faker } from '@dxos/random';
9
+ import { random } 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;
@@ -38,15 +38,15 @@ const icons = {
38
38
 
39
39
  export const createActions = (params?: CreateActionsProps) => {
40
40
  const { callback = () => console.log('invoke'), count = 12, type = Node.ActionType } = params ?? {};
41
- return faker.helpers.multiple(
41
+ return random.helpers.multiple(
42
42
  () => ({
43
- id: faker.string.uuid(),
43
+ id: random.string.uuid(),
44
44
  type,
45
45
  data: type === Node.ActionGroupType ? Node.actionGroupSymbol : callback,
46
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]),
47
+ label: random.lorem.words(2),
48
+ icon: random.helpers.arrayElement(icons[random.helpers.arrayElement(Object.keys(icons)) as keyof typeof icons]),
49
+ disabled: random.helpers.arrayElement([true, false]),
50
50
  ...(type === Node.ActionGroupType && { variant: 'dropdownMenu' }),
51
51
  },
52
52
  }),
@@ -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
  /**
@@ -107,7 +112,7 @@ export const useMutateActions = (actionsAtom: Atom.Writable<ActionGraphProps>) =
107
112
  ...node,
108
113
  properties: {
109
114
  ...node.properties,
110
- icon: faker.helpers.arrayElement(node.properties?.icon?.endsWith('regular') ? icons.fill : icons.regular),
115
+ icon: random.helpers.arrayElement(node.properties?.icon?.endsWith('regular') ? icons.fill : icons.regular),
111
116
  disabled: !node.properties?.disabled,
112
117
  },
113
118
  };
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,16 +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. */
36
54
  /** If true, the menu is always active regardless of attention state. */
37
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;
38
62
  /** Optional action executor. If provided, will be used instead of default execution. */
39
63
  onAction?: ActionExecutor;
40
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, alwaysActive, 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,8EAOnB,iBAAiB,sBAcnB,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,64 +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
- alwaysActive,
34
- onAction,
35
- }: MenuProviderProps) => {
36
- const { scope } = useMenuScope(undefined);
37
- return (
38
- <MenuContextProvider
39
- useGroupItems={useGroupItems}
40
- iconSize={iconSize}
41
- attendableId={attendableId}
42
- alwaysActive={alwaysActive}
43
- onAction={onAction}
44
- scope={scope}
45
- >
46
- {children}
47
- </MenuContextProvider>
48
- );
49
- };
50
-
51
- export const useMenuItems = (
52
- group?: MenuItemGroup,
53
- propsItems?: MenuItem[],
54
- consumerName: string = 'useMenuItemConsumer',
55
- __menuScope?: Scope,
56
- ) => {
57
- const { useGroupItems } = useMenu(consumerName, __menuScope);
58
- const groupItems = useGroupItems(group);
59
- return useMemo(() => propsItems ?? groupItems ?? undefined, [propsItems, groupItems]);
60
- };
61
-
62
- export { useMenu, createMenuScope, MenuProvider };
63
-
64
- export type { MenuProviderProps };