@versini/ui-menu 6.3.0 → 6.4.0

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.
package/README.md CHANGED
@@ -38,8 +38,10 @@ npm install @versini/ui-menu
38
38
 
39
39
  ### Basic Menu
40
40
 
41
+ > **Important**: Every `MenuItem` must be wrapped in a `MenuGroup`. Rendering a `MenuItem` outside of a `MenuGroup` will throw an error.
42
+
41
43
  ```tsx
42
- import { Menu, MenuItem } from "@versini/ui-menu";
44
+ import { Menu, MenuGroup, MenuItem } from "@versini/ui-menu";
43
45
  import { ButtonIcon } from "@versini/ui-button";
44
46
  import { IconMenu } from "@versini/ui-icons";
45
47
 
@@ -52,9 +54,11 @@ function App() {
52
54
  </ButtonIcon>
53
55
  }
54
56
  >
55
- <MenuItem label="Profile" onSelect={() => console.info("Profile")} />
56
- <MenuItem label="Settings" onSelect={() => console.info("Settings")} />
57
- <MenuItem label="Logout" onSelect={() => console.info("Logout")} />
57
+ <MenuGroup>
58
+ <MenuItem label="Profile" onSelect={() => console.info("Profile")} />
59
+ <MenuItem label="Settings" onSelect={() => console.info("Settings")} />
60
+ <MenuItem label="Logout" onSelect={() => console.info("Logout")} />
61
+ </MenuGroup>
58
62
  </Menu>
59
63
  );
60
64
  }
@@ -65,7 +69,7 @@ function App() {
65
69
  ### Menu with Icons & Selection
66
70
 
67
71
  ```tsx
68
- import { Menu, MenuItem, MenuSeparator } from "@versini/ui-menu";
72
+ import { Menu, MenuGroup, MenuItem, MenuSeparator } from "@versini/ui-menu";
69
73
  import { ButtonIcon } from "@versini/ui-button";
70
74
  import {
71
75
  IconMenu,
@@ -86,22 +90,24 @@ function AccountMenu() {
86
90
  }
87
91
  onOpenChange={(o) => console.info("open?", o)}
88
92
  >
89
- <MenuItem
90
- label="Profile"
91
- icon={<IconUser />}
92
- onSelect={() => setLast("profile")}
93
- />
94
- <MenuItem
95
- label="Settings"
96
- icon={<IconSettings />}
97
- onSelect={() => setLast("settings")}
98
- />
99
- <MenuSeparator />
100
- <MenuItem
101
- label="Logout"
102
- icon={<IconLogout />}
103
- onSelect={() => setLast("logout")}
104
- />
93
+ <MenuGroup>
94
+ <MenuItem
95
+ label="Profile"
96
+ icon={<IconUser />}
97
+ onSelect={() => setLast("profile")}
98
+ />
99
+ <MenuItem
100
+ label="Settings"
101
+ icon={<IconSettings />}
102
+ onSelect={() => setLast("settings")}
103
+ />
104
+ <MenuSeparator />
105
+ <MenuItem
106
+ label="Logout"
107
+ icon={<IconLogout />}
108
+ onSelect={() => setLast("logout")}
109
+ />
110
+ </MenuGroup>
105
111
  </Menu>
106
112
  );
107
113
  }
@@ -147,7 +153,9 @@ function SettingsMenu() {
147
153
  <MenuItem label="French Teacher" selected={selected === 4} />
148
154
  </MenuGroup>
149
155
 
150
- <MenuItem label="About" />
156
+ <MenuGroup className="mt-2">
157
+ <MenuItem label="About" />
158
+ </MenuGroup>
151
159
  </Menu>
152
160
  );
153
161
  }
@@ -158,7 +166,7 @@ function SettingsMenu() {
158
166
  Use `MenuLabel` to add a non-interactive heading inside a menu:
159
167
 
160
168
  ```tsx
161
- import { Menu, MenuItem, MenuLabel } from "@versini/ui-menu";
169
+ import { Menu, MenuGroup, MenuItem, MenuLabel } from "@versini/ui-menu";
162
170
  import { ButtonIcon } from "@versini/ui-button";
163
171
  import { IconBookSparkles, IconMagic, IconProofread } from "@versini/ui-icons";
164
172
 
@@ -171,9 +179,11 @@ function PromptsMenu() {
171
179
  </ButtonIcon>
172
180
  }
173
181
  >
174
- <MenuLabel>Prompts</MenuLabel>
175
- <MenuItem label="Summarize..." icon={<IconMagic />} />
176
- <MenuItem label="Proofread..." icon={<IconProofread />} />
182
+ <MenuGroup>
183
+ <MenuLabel>Prompts</MenuLabel>
184
+ <MenuItem label="Summarize..." icon={<IconMagic />} />
185
+ <MenuItem label="Proofread..." icon={<IconProofread />} />
186
+ </MenuGroup>
177
187
  </Menu>
178
188
  );
179
189
  }
@@ -205,8 +215,10 @@ function SettingsMenu() {
205
215
  </ButtonIcon>
206
216
  }
207
217
  >
208
- <MenuItem label="Profile" />
209
- <MenuItem label="Statistics" />
218
+ <MenuGroup>
219
+ <MenuItem label="Profile" />
220
+ <MenuItem label="Statistics" />
221
+ </MenuGroup>
210
222
  <MenuSeparator />
211
223
 
212
224
  <MenuSub label="Engines and Personas" icon={<IconSettings />}>
@@ -240,7 +252,9 @@ function SettingsMenu() {
240
252
  </MenuGroup>
241
253
  </MenuSub>
242
254
 
243
- <MenuItem label="About" />
255
+ <MenuGroup>
256
+ <MenuItem label="About" />
257
+ </MenuGroup>
244
258
  </Menu>
245
259
  );
246
260
  }
@@ -293,7 +307,7 @@ function SettingsMenu() {
293
307
  | `icon` | `React.ReactNode` | - | Icon to display on the left of the label. |
294
308
  | `children` | `React.ReactNode` | - | Items to render inside sub-menu. |
295
309
  | `disabled` | `boolean` | `false` | Whether the sub-menu is disabled. |
296
- | `sideOffset` | `number` | `14` | Offset from sub-menu trigger. |
310
+ | `sideOffset` | `number` | `22` | Offset from sub-menu trigger. |
297
311
 
298
312
  ### MenuGroup Props
299
313
 
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ declare type MenuGroupProps = {
14
14
  /**
15
15
  * The label for the menu group.
16
16
  */
17
- label: string;
17
+ label?: string;
18
18
  /**
19
19
  * The children to render inside the menu group.
20
20
  */
@@ -168,7 +168,7 @@ declare type MenuSubProps = {
168
168
  disabled?: boolean;
169
169
  /**
170
170
  * The offset distance from the sub-menu trigger.
171
- * @default 14
171
+ * @default 22
172
172
  */
173
173
  sideOffset?: number;
174
174
  };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-menu v6.3.0
2
+ @versini/ui-menu v6.4.0
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
@@ -14,6 +14,7 @@ import { IconNext } from "@versini/ui-icons";
14
14
 
15
15
 
16
16
 
17
+ const MenuGroupContext = createContext(false);
17
18
  /* v8 ignore start - default context values are fallbacks, never used directly */ const MenuRootContext = createContext({
18
19
  closeAll: ()=>{}
19
20
  });
@@ -131,6 +132,7 @@ function getLastEnabledIndex(items) {
131
132
  /* v8 ignore stop */ case "ArrowDown":
132
133
  {
133
134
  event.preventDefault();
135
+ event.stopPropagation();
134
136
  const nextIndex = getNextEnabledIndex(items, activeIndex, 1);
135
137
  setActiveIndex(nextIndex);
136
138
  focusItem(nextIndex);
@@ -139,6 +141,7 @@ function getLastEnabledIndex(items) {
139
141
  case "ArrowUp":
140
142
  {
141
143
  event.preventDefault();
144
+ event.stopPropagation();
142
145
  const prevIndex = getNextEnabledIndex(items, activeIndex, -1);
143
146
  setActiveIndex(prevIndex);
144
147
  focusItem(prevIndex);
@@ -147,6 +150,7 @@ function getLastEnabledIndex(items) {
147
150
  case "Home":
148
151
  {
149
152
  event.preventDefault();
153
+ event.stopPropagation();
150
154
  const firstIndex = getFirstEnabledIndex(items);
151
155
  setActiveIndex(firstIndex);
152
156
  focusItem(firstIndex);
@@ -155,6 +159,7 @@ function getLastEnabledIndex(items) {
155
159
  case "End":
156
160
  {
157
161
  event.preventDefault();
162
+ event.stopPropagation();
158
163
  const lastIndex = getLastEnabledIndex(items);
159
164
  setActiveIndex(lastIndex);
160
165
  focusItem(lastIndex);
@@ -164,6 +169,7 @@ function getLastEnabledIndex(items) {
164
169
  case " ":
165
170
  {
166
171
  event.preventDefault();
172
+ event.stopPropagation();
167
173
  /* v8 ignore start - activeIndex bounds and disabled guard */ if (activeIndex >= 0 && activeIndex < items.length && !items[activeIndex].disabled) {
168
174
  items[activeIndex].element.click();
169
175
  }
@@ -172,6 +178,7 @@ function getLastEnabledIndex(items) {
172
178
  case "Escape":
173
179
  {
174
180
  event.preventDefault();
181
+ event.stopPropagation();
175
182
  onClose();
176
183
  break;
177
184
  }
@@ -181,6 +188,7 @@ function getLastEnabledIndex(items) {
181
188
  const item = items[activeIndex].element;
182
189
  if (item.getAttribute("aria-haspopup") === "menu") {
183
190
  event.preventDefault();
191
+ event.stopPropagation();
184
192
  onOpenSubMenu(item);
185
193
  }
186
194
  }
@@ -190,6 +198,7 @@ function getLastEnabledIndex(items) {
190
198
  {
191
199
  if (isSubMenu && onCloseToParent) {
192
200
  event.preventDefault();
201
+ event.stopPropagation();
193
202
  onCloseToParent();
194
203
  }
195
204
  break;
@@ -197,6 +206,7 @@ function getLastEnabledIndex(items) {
197
206
  case "Tab":
198
207
  {
199
208
  event.preventDefault();
209
+ event.stopPropagation();
200
210
  onClose();
201
211
  break;
202
212
  }
@@ -206,6 +216,7 @@ function getLastEnabledIndex(items) {
206
216
  // or functional keys such as Shift, Control, Alt, Meta, etc.).
207
217
  if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
208
218
  event.preventDefault();
219
+ event.stopPropagation();
209
220
  // Reset the debounce timer.
210
221
  if (searchTimeoutRef.current) {
211
222
  clearTimeout(searchTimeoutRef.current);
@@ -339,8 +350,8 @@ const calculatePosition = (triggerRect, menuRect, placement, sideOffset, viewpor
339
350
  };
340
351
  };
341
352
  const getMenuItemClasses = ({ isSub })=>{
342
- return clsx("flex flex-row items-center", "w-full", "m-0 first:mt-0 mt-2 sm:mt-1 px-2 py-1", "rounded-md border border-transparent", "text-left text-base select-none cursor-pointer", "outline-hidden", "disabled:cursor-not-allowed disabled:text-copy-medium", "data-highlighted:bg-surface-dark", "data-highlighted:text-copy-light", "in-data-menu-group:data-highlighted:bg-surface-dark", "in-data-menu-group:data-highlighted:border-border-medium", {
343
- "data-[state=open]:bg-surface-darker data-[state=open]:text-copy-light": isSub,
353
+ return clsx("flex flex-row items-center", "w-full", "m-0 first:mt-0 mt-2 sm:mt-1 px-2 py-1", "rounded-md border border-transparent", "text-left text-base select-none cursor-pointer", "outline-hidden", "disabled:cursor-not-allowed disabled:text-copy-medium", "text-copy-dark", "data-highlighted:bg-surface-darker", "data-highlighted:text-copy-light", "in-data-menu-group:text-copy-light", "in-data-menu-group:data-highlighted:bg-surface-darker", "in-data-menu-group:data-highlighted:border-border-medium", {
354
+ "data-[state=open]:bg-surface-darker data-[state=open]:text-copy-light justify-between": isSub,
344
355
  "data-disabled:cursor-not-allowed data-disabled:text-copy-medium": !isSub
345
356
  });
346
357
  };
@@ -619,26 +630,31 @@ Menu.displayName = "Menu";
619
630
 
620
631
 
621
632
 
633
+
622
634
  const MenuGroup = ({ children, label, className, icon })=>{
623
- const groupClass = clsx_0("rounded-md", "p-2", "bg-surface-dark", "text-copy-light", className);
635
+ const groupClass = clsx_0("rounded-md", "p-2", "first:mt-0 mt-2", "bg-surface-dark", "text-copy-light", className);
624
636
  const labelContainerClass = clsx_0("pt-2 pb-2", "px-3 sm:px-2", "-mx-1 -mt-1", "flex items-center justify-between", "text-xs text-copy-medium uppercase font-bold", "rounded-t-md");
625
- return /*#__PURE__*/ jsxs("div", {
626
- role: "group",
627
- className: groupClass,
628
- "data-menu-group": true,
629
- children: [
630
- label && /*#__PURE__*/ jsx("div", {
631
- className: labelContainerClass,
632
- children: /*#__PURE__*/ jsxs("span", {
633
- className: "flex items-center gap-1",
634
- children: [
635
- icon,
636
- label
637
- ]
638
- })
639
- }),
640
- children
641
- ]
637
+ return /*#__PURE__*/ jsx(MenuGroupContext.Provider, {
638
+ value: true,
639
+ children: /*#__PURE__*/ jsxs("div", {
640
+ role: label ? "group" : undefined,
641
+ className: groupClass,
642
+ "data-menu-group": true,
643
+ "aria-label": label || undefined,
644
+ children: [
645
+ label && /*#__PURE__*/ jsx("div", {
646
+ className: labelContainerClass,
647
+ children: /*#__PURE__*/ jsxs("span", {
648
+ className: "flex items-center gap-1",
649
+ children: [
650
+ icon,
651
+ label
652
+ ]
653
+ })
654
+ }),
655
+ children
656
+ ]
657
+ })
642
658
  });
643
659
  };
644
660
  MenuGroup.displayName = "MenuGroup";
@@ -652,6 +668,7 @@ const ITEM_CLASS = getMenuItemClasses({
652
668
  isSub: false
653
669
  });
654
670
  const MenuItem = ({ label, disabled, icon, raw = false, children, ignoreClick = false, selected, onSelect, onClick, onFocus, onMouseEnter, ...props })=>{
671
+ const isInsideMenuGroup = useContext(MenuGroupContext);
655
672
  const itemRef = useRef(null);
656
673
  const { closeAll } = useContext(MenuRootContext);
657
674
  const { registerItem, unregisterItem, getItems, activeIndex, setActiveIndex, setOpenSubMenuId } = useContext(MenuContentContext);
@@ -668,6 +685,9 @@ const MenuItem = ({ label, disabled, icon, raw = false, children, ignoreClick =
668
685
  registerItem,
669
686
  unregisterItem
670
687
  ]);
688
+ if (!isInsideMenuGroup) {
689
+ throw new Error("MenuItem must be used within a MenuGroup.");
690
+ }
671
691
  const getMyIndex = ()=>{
672
692
  const items = getItems();
673
693
  return items.findIndex((item)=>item.element === itemRef.current);
@@ -821,7 +841,7 @@ const SUB_CONTENT_CLASS = clsx_0("bg-surface-light", "z-60 rounded-md text-copy-
821
841
  const SUB_TRIGGER_CLASS = getMenuItemClasses({
822
842
  isSub: true
823
843
  });
824
- const MenuSub = ({ label, icon, children, disabled = false, sideOffset = 14 })=>{
844
+ const MenuSub = ({ label, icon, children, disabled = false, sideOffset = 22 })=>{
825
845
  const subMenuId = useUniqueId("av-menu-sub-");
826
846
  const [isSubOpen, setIsSubOpen] = useState(false);
827
847
  const [subActiveIndex, setSubActiveIndex] = useState(-1);
@@ -1029,9 +1049,12 @@ const MenuSub = ({ label, icon, children, disabled = false, sideOffset = 14 })=>
1029
1049
  popover: "manual",
1030
1050
  role: "menu",
1031
1051
  className: SUB_CONTENT_CLASS,
1032
- children: /*#__PURE__*/ jsx(MenuContentContext.Provider, {
1033
- value: subContentContextValue,
1034
- children: children
1052
+ children: /*#__PURE__*/ jsx(MenuGroupContext.Provider, {
1053
+ value: false,
1054
+ children: /*#__PURE__*/ jsx(MenuContentContext.Provider, {
1055
+ value: subContentContextValue,
1056
+ children: children
1057
+ })
1035
1058
  })
1036
1059
  })
1037
1060
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@versini/ui-menu",
3
- "version": "6.3.0",
3
+ "version": "6.4.0",
4
4
  "license": "MIT",
5
5
  "author": "Arno Versini",
6
6
  "publishConfig": {
@@ -49,5 +49,5 @@
49
49
  "sideEffects": [
50
50
  "**/*.css"
51
51
  ],
52
- "gitHead": "6ec1140bfd6c5575eb034224f51149093e8d8683"
52
+ "gitHead": "3ab8ea89787f8ff5c6e101bff69e19621d31cf6a"
53
53
  }