@dxos/react-ui-menu 0.8.4-main.d05673bc65 → 0.8.4-main.e00bdcdb52

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 (50) hide show
  1. package/dist/lib/browser/index.mjs +104 -134
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/browser/translations.mjs +14 -0
  5. package/dist/lib/browser/translations.mjs.map +7 -0
  6. package/dist/lib/node-esm/index.mjs +104 -134
  7. package/dist/lib/node-esm/index.mjs.map +4 -4
  8. package/dist/lib/node-esm/meta.json +1 -1
  9. package/dist/lib/node-esm/translations.mjs +16 -0
  10. package/dist/lib/node-esm/translations.mjs.map +7 -0
  11. package/dist/types/src/builder.d.ts +23 -4
  12. package/dist/types/src/builder.d.ts.map +1 -1
  13. package/dist/types/src/builder.test.d.ts +2 -0
  14. package/dist/types/src/builder.test.d.ts.map +1 -0
  15. package/dist/types/src/components/ActionLabel.d.ts.map +1 -1
  16. package/dist/types/src/components/DropdownMenu.d.ts +3 -1
  17. package/dist/types/src/components/DropdownMenu.d.ts.map +1 -1
  18. package/dist/types/src/components/Menu.d.ts +8 -4
  19. package/dist/types/src/components/Menu.d.ts.map +1 -1
  20. package/dist/types/src/components/{MenuItems.stories.d.ts → Menu.stories.d.ts} +1 -1
  21. package/dist/types/src/components/Menu.stories.d.ts.map +1 -0
  22. package/dist/types/src/components/ToolbarMenu.d.ts +4 -4
  23. package/dist/types/src/components/ToolbarMenu.d.ts.map +1 -1
  24. package/dist/types/src/components/ToolbarMenu.stories.d.ts +3 -1
  25. package/dist/types/src/components/ToolbarMenu.stories.d.ts.map +1 -1
  26. package/dist/types/src/hooks/useMenuActions.d.ts.map +1 -1
  27. package/dist/types/src/index.d.ts +1 -1
  28. package/dist/types/src/index.d.ts.map +1 -1
  29. package/dist/types/src/testing/generator.d.ts +8 -4
  30. package/dist/types/src/testing/generator.d.ts.map +1 -1
  31. package/dist/types/src/types.d.ts +2 -1
  32. package/dist/types/src/types.d.ts.map +1 -1
  33. package/dist/types/src/util.d.ts +2 -2
  34. package/dist/types/src/util.d.ts.map +1 -1
  35. package/dist/types/tsconfig.tsbuildinfo +1 -1
  36. package/package.json +27 -21
  37. package/src/builder.test.ts +224 -0
  38. package/src/builder.ts +41 -18
  39. package/src/components/ActionLabel.tsx +2 -1
  40. package/src/components/DropdownMenu.tsx +0 -1
  41. package/src/components/{MenuItems.stories.tsx → Menu.stories.tsx} +2 -1
  42. package/src/components/Menu.test.tsx +0 -1
  43. package/src/components/Menu.tsx +9 -10
  44. package/src/components/ToolbarMenu.stories.tsx +15 -12
  45. package/src/components/ToolbarMenu.tsx +111 -86
  46. package/src/hooks/useMenuActions.ts +6 -7
  47. package/src/index.ts +1 -1
  48. package/src/testing/generator.ts +15 -10
  49. package/src/types.ts +1 -0
  50. package/dist/types/src/components/MenuItems.stories.d.ts.map +0 -1
@@ -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,7 +21,6 @@ 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
26
  import { type MenuScopedProps, useMenuItems, useMenuScoped } from './Menu';
@@ -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 }>) => {
56
+ const ActionToolbarItem = ({ __menuScope, action }: MenuScopedProps<{ action: MenuAction }>) => {
57
57
  const { iconSize, onAction } = useMenuScoped('ActionToolbarItem', __menuScope);
58
58
  const { t } = useTranslation(translationKey);
59
59
 
60
- const { icon, iconOnly = true, disabled, testId, hidden, classNames, variant } = 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 } = 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,23 +69,38 @@ 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={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
+ label={actionLabel(action, t)}
83
92
  />
93
+ ) : (
94
+ <NaturalToolbar.Button key={action.id} {...commonProps}>
95
+ <ActionLabel action={action} />
96
+ </NaturalToolbar.Button>
84
97
  );
85
98
  };
86
99
 
87
100
  const DropdownMenuToolbarItem = ({
101
+ __menuScope,
88
102
  group,
89
103
  items: propsItems,
90
- __menuScope,
91
104
  }: MenuScopedProps<ToolbarMenuActionGroupProps>) => {
92
105
  const { iconOnly, disabled, testId } = group.properties;
93
106
  const { t } = useTranslation(translationKey);
@@ -127,10 +140,11 @@ const DropdownMenuToolbarItem = ({
127
140
  );
128
141
  };
129
142
 
130
- const ToggleGroupItem = ({ group, action, __menuScope }: MenuScopedProps<ToolbarMenuActionProps>) => {
143
+ const ToggleGroupItem = ({ __menuScope, group, action }: MenuScopedProps<ToolbarMenuActionProps>) => {
131
144
  const { iconSize, onAction } = useMenuScoped('ToggleGroupItem', __menuScope);
132
145
  const { t } = useTranslation(translationKey);
133
146
  const { icon, iconOnly = true, disabled, testId, hidden, classNames } = action.properties;
147
+
134
148
  const handleClick = useCallback(() => {
135
149
  if (onAction) {
136
150
  onAction(action, { parent: group });
@@ -138,91 +152,102 @@ const ToggleGroupItem = ({ group, action, __menuScope }: MenuScopedProps<Toolbar
138
152
  void executeMenuAction(action, { parent: group });
139
153
  }
140
154
  }, [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
155
 
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}
156
+ const commonProps = {
157
+ value: action.id,
158
+ disabled,
159
+ variant: 'ghost' as const,
160
+ classNames,
161
+ onClick: handleClick,
162
+ ...(testId && { 'data-testid': testId }),
163
+ };
164
+
165
+ return hidden ? null : icon ? (
166
+ <NaturalToolbar.ToggleGroupIconItem
167
+ {...commonProps}
168
+ icon={icon}
169
+ size={iconSize}
170
+ iconOnly={iconOnly}
171
+ label={actionLabel(action, t)}
156
172
  />
173
+ ) : (
174
+ <NaturalToolbar.ToggleGroupItem {...commonProps}>
175
+ <ActionLabel action={action} />
176
+ </NaturalToolbar.ToggleGroupItem>
157
177
  );
158
178
  };
159
179
 
160
180
  const ToggleGroupToolbarItem = ({
161
- group,
162
- items: propsItems,
163
181
  __menuScope,
182
+ group,
183
+ items: itemsProp,
164
184
  }: 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
- );
185
+ const items = useMenuItems(group, itemsProp, 'ToggleGroupToolbarItem', __menuScope);
186
+ const { selectCardinality } = group.properties;
187
+
188
+ // TODO(thure): Handle other menu item types.
189
+ const children = (items as MenuAction[]).map((action) => (
190
+ <ToggleGroupItem key={action.id} group={group} action={action} />
191
+ ));
192
+
193
+ if (selectCardinality === 'multiple') {
194
+ return (
195
+ <NaturalToolbar.ToggleGroup type='multiple' value={group.properties.value}>
196
+ {children}
197
+ </NaturalToolbar.ToggleGroup>
198
+ );
199
+ } else {
200
+ return (
201
+ <NaturalToolbar.ToggleGroup type='single' value={group.properties.value}>
202
+ {children}
203
+ </NaturalToolbar.ToggleGroup>
204
+ );
205
+ }
180
206
  };
181
207
 
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 } = useMenuScoped('ToolbarMenu', __menuScope);
191
- const { hasAttention } = useAttention(attendableId);
192
- const InnerRoot = textBlockWidthProp ? 'div' : Fragment;
193
- const innerRootProps = textBlockWidthProp
194
- ? { role: 'none', className: mx(textBlockWidth, toolbarLayout, 'h-full') }
195
- : {};
196
-
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} />
208
+ export const ToolbarMenu = composable<HTMLDivElement, MenuScopedProps<ToolbarMenuProps>>(
209
+ ({ __menuScope, children, ...props }, forwardedRef) => {
210
+ const items = useMenuItems(undefined, undefined, 'ToolbarMenu', __menuScope);
211
+ const { attendableId, alwaysActive } = useMenuScoped('ToolbarMenu', __menuScope);
212
+ const { hasAttention } = useAttention(attendableId);
213
+
214
+ return (
215
+ <NaturalToolbar.Root
216
+ {...composableProps(props, { classNames: attendableId })}
217
+ disabled={!alwaysActive && !hasAttention}
218
+ ref={forwardedRef}
219
+ >
220
+ {items?.map((item: MenuItem) => (
221
+ <ToolbarMenuItem key={item.id} __menuScope={__menuScope} item={item} />
207
222
  ))}
208
- </InnerRoot>
209
- </NaturalToolbar.Root>
210
- );
211
- };
223
+ {children}
224
+ </NaturalToolbar.Root>
225
+ );
226
+ },
227
+ );
212
228
 
213
- const ToolbarMenuItem = ({ item }: { item: MenuItem }) => {
229
+ const ToolbarMenuItem = ({ __menuScope, item }: MenuScopedProps<{ item: MenuItem }>) => {
214
230
  if (isSeparator(item)) {
215
231
  return <NaturalToolbar.Separator variant={item.properties.variant} />;
216
232
  }
217
233
 
218
- // TODO(thure): Figure out type narrowing that doesn’t require so much use of `as`.
219
234
  if (isMenuGroup(item)) {
220
235
  if (item.properties.variant === 'dropdownMenu') {
221
- return <DropdownMenuToolbarItem group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>} />;
236
+ return (
237
+ <DropdownMenuToolbarItem
238
+ __menuScope={__menuScope}
239
+ group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>}
240
+ />
241
+ );
222
242
  } else {
223
- return <ToggleGroupToolbarItem group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>} />;
243
+ return (
244
+ <ToggleGroupToolbarItem
245
+ __menuScope={__menuScope}
246
+ group={item as MenuItemGroup<ToolbarMenuActionGroupProperties>}
247
+ />
248
+ );
224
249
  }
225
250
  }
226
251
 
227
- return <ActionToolbarItem action={item as MenuAction} />;
252
+ return <ActionToolbarItem __menuScope={__menuScope} action={item as MenuAction} />;
228
253
  };
@@ -3,7 +3,7 @@
3
3
  //
4
4
 
5
5
  import { type Atom, RegistryContext, useAtomValue } from '@effect-atom/atom-react';
6
- import { useCallback, useContext, useRef } from 'react';
6
+ import { useCallback, useContext, useMemo } from 'react';
7
7
 
8
8
  import { Graph, Node } from '@dxos/app-graph';
9
9
 
@@ -26,14 +26,13 @@ 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
- }
35
-
36
- const graph = graphRef.current.graph;
34
+ return newGraph;
35
+ }, [registry, menuGraphProps]);
37
36
 
38
37
  const items: MenuItemsAccessor = useCallback(
39
38
  (group?: MenuItemGroup) => {
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,11 +2,11 @@
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
12
  import { type MenuItem, type MenuItemGroup, type MenuItemsAccessor } from '../types';
@@ -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
  }),
@@ -70,8 +70,13 @@ 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);
@@ -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
@@ -50,6 +50,7 @@ export type MenuContextValue = {
50
50
  items: MenuItemsAccessor;
51
51
  iconSize: IconButtonProps['size'];
52
52
  attendableId?: string;
53
+ /** TODO(burdon): Remove and assume always active if attendableId is undefined. */
53
54
  /** If true, the menu is always active regardless of attention state. */
54
55
  alwaysActive?: boolean;
55
56
  /** Atom holding the current set of imperatively added menu items. */
@@ -1 +0,0 @@
1
- {"version":3,"file":"MenuItems.stories.d.ts","sourceRoot":"","sources":["../../../../src/components/MenuItems.stories.tsx"],"names":[],"mappings":"AAKA,OAAO,EAAa,KAAK,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACjE,OAAO,KAAuC,MAAM,OAAO,CAAC;AAgB5D,QAAA,MAAM,IAAI;;;;;;;;;;;CAOwB,CAAC;AAEnC,eAAe,IAAI,CAAC;AAEpB,KAAK,KAAK,GAAG,QAAQ,CAAC,OAAO,IAAI,CAAC,CAAC;AA0CnC,eAAO,MAAM,WAAW,EAAE,KAsBzB,CAAC;AAaF,eAAO,MAAM,aAAa,EAAE,KA8C3B,CAAC;AAsBF,eAAO,MAAM,eAAe,EAAE,KAqB7B,CAAC;AA0CF,eAAO,MAAM,gBAAgB,EAAE,KAuB9B,CAAC"}