@versini/ui-menu 7.1.2 → 7.3.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/dist/index.d.ts CHANGED
@@ -38,8 +38,8 @@ export declare const MenuItem: {
38
38
  declare type MenuItemProps = {
39
39
  /**
40
40
  * The label to use for the menu item. Accepts any React node so you can
41
- * compose richer labels (e.g. text plus a `<Badge>`, an icon, or styled
42
- * inline elements). For a plain text label, pass a string.
41
+ * compose richer labels (e.g. text plus a `<Badge>`, an icon, or styled inline
42
+ * elements). For a plain text label, pass a string.
43
43
  */
44
44
  label?: React.ReactNode;
45
45
  /**
@@ -147,15 +147,15 @@ export declare const MenuSeparator: {
147
147
  declare type MenuSeparatorProps = React.HTMLAttributes<HTMLDivElement>;
148
148
 
149
149
  export declare const MenuSub: {
150
- ({ label, icon, children, disabled, sideOffset, }: MenuSubProps): JSX.Element;
150
+ ({ label, icon, children, disabled, sideOffset, selected, }: MenuSubProps): JSX.Element;
151
151
  displayName: string;
152
152
  };
153
153
 
154
154
  declare type MenuSubProps = {
155
155
  /**
156
156
  * The label for the sub-menu trigger. Accepts any React node so you can
157
- * compose richer labels (e.g. text plus a `<Badge>`). For a plain text
158
- * label, pass a string.
157
+ * compose richer labels (e.g. text plus a `<Badge>`). For a plain text label,
158
+ * pass a string.
159
159
  */
160
160
  label: React.ReactNode;
161
161
  /**
@@ -176,6 +176,14 @@ declare type MenuSubProps = {
176
176
  * @default 20
177
177
  */
178
178
  sideOffset?: number;
179
+ /**
180
+ * Forces the "contains a selection" indicator on (true) or off (false),
181
+ * overriding automatic detection. Leave undefined to auto-detect from
182
+ * descendant MenuItems. Use this when MenuItem children are wrapped in a
183
+ * custom component that hides the `selected` prop from auto-detection.
184
+ * @default undefined (auto-detected)
185
+ */
186
+ selected?: boolean;
179
187
  };
180
188
 
181
189
  export { }
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-menu v7.1.2
2
+ @versini/ui-menu v7.3.0
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
@@ -53,18 +53,20 @@ const TYPEAHEAD_TIMEOUT_MS = 500;
53
53
  function getTypeaheadMatchIndex(items, searchString, startIndex) {
54
54
  const count = items.length;
55
55
  const normalized = searchString.toLowerCase();
56
- // When the same single character is repeated (e.g. "aaa"), cycle
57
- // through items starting with that character instead of trying to
58
- // match the full repeated string.
59
- const isRepeatedChar = normalized.length > 1 && [
56
+ /**
57
+ * When the same single character is repeated (e.g. "aaa"), cycle through items
58
+ * starting with that character instead of trying to match the full repeated
59
+ * string.
60
+ */ const isRepeatedChar = normalized.length > 1 && [
60
61
  ...normalized
61
62
  ].every((c)=>c === normalized[0]);
62
63
  const matchStr = isRepeatedChar ? normalized[0] : normalized;
63
- // For a single character (including repeated single-char), start
64
- // searching from the item *after* the current one so we always
65
- // advance to the next match. For multi-character strings, start
66
- // from the current item so the user can refine the match in-place.
67
- const offset = normalized.length === 1 || isRepeatedChar ? 1 : 0;
64
+ /**
65
+ * For a single character (including repeated single-char), start searching
66
+ * from the item *after* the current one so we always advance to the next
67
+ * match. For multi-character strings, start from the current item so the user
68
+ * can refine the match in-place.
69
+ */ const offset = normalized.length === 1 || isRepeatedChar ? 1 : 0;
68
70
  for(let i = 0; i < count; i++){
69
71
  const index = (startIndex + offset + i) % count;
70
72
  if (items[index].disabled) {
@@ -216,9 +218,10 @@ function getLastEnabledIndex(items) {
216
218
  }
217
219
  /* v8 ignore stop */ default:
218
220
  {
219
- // Typeahead: printable single characters (ignore modifier-only
220
- // or functional keys such as Shift, Control, Alt, Meta, etc.).
221
- if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
221
+ /**
222
+ * Typeahead: printable single characters (ignore modifier-only or
223
+ * functional keys such as Shift, Control, Alt, Meta, etc.).
224
+ */ if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
222
225
  event.preventDefault();
223
226
  event.stopPropagation();
224
227
  // Reset the debounce timer.
@@ -264,6 +267,13 @@ function getLastEnabledIndex(items) {
264
267
 
265
268
 
266
269
 
270
+
271
+
272
+ /**
273
+ * Shared classes for the small green "selected" dot, reused by MenuItem (inner
274
+ * dot) and MenuSub (nested-selection indicator) so the success token lives in
275
+ * one place.
276
+ */ const SELECTED_DOT_CLASS = "size-1.5 rounded-full bg-copy-success-light";
267
277
  const getDisplayName = (element)=>{
268
278
  if (typeof element === "string") {
269
279
  return element;
@@ -279,12 +289,41 @@ const getDisplayName = (element)=>{
279
289
  }
280
290
  return "Element";
281
291
  };
292
+ /**
293
+ * Recursively walks a React children tree looking for a selected menu entry.
294
+ * Only `MenuItem`/`MenuSub` elements with `selected === true` count, so an
295
+ * unrelated `selected` prop on other nested content (e.g. a native `<option
296
+ * selected>`) does not produce a false indicator. Traversal still descends
297
+ * through any container (MenuGroup, fragments) to reach those entries.
298
+ *
299
+ * Used by MenuSub to surface a nested selection on its trigger row even while
300
+ * the sub-menu is closed (sub-menu children are conditionally mounted, so they
301
+ * cannot report selection upward via context).
302
+ *
303
+ */ const hasSelectedDescendant = (node)=>{
304
+ let found = false;
305
+ __rspack_external_react.Children.forEach(node, (child)=>{
306
+ if (found || !__rspack_external_react.isValidElement(child)) {
307
+ return;
308
+ }
309
+ const childProps = child.props;
310
+ const name = getDisplayName(child);
311
+ if ((name === "MenuItem" || name === "MenuSub") && childProps.selected === true) {
312
+ found = true;
313
+ return;
314
+ }
315
+ if (childProps.children) {
316
+ found = hasSelectedDescendant(childProps.children);
317
+ }
318
+ });
319
+ return found;
320
+ };
282
321
  const calculatePosition = (triggerRect, menuRect, placement, sideOffset, viewportWidth, viewportHeight)=>{
283
322
  /* v8 ignore start - split always returns non-empty for valid placements */ const side = placement.split("-")[0] || "bottom";
284
323
  /* v8 ignore stop */ const alignment = placement.includes("-") ? placement.split("-")[1] : "center";
285
324
  let top = 0;
286
325
  let left = 0;
287
- // Calculate base position based on side
326
+ // Calculate base position based on side.
288
327
  switch(side){
289
328
  case "bottom":
290
329
  top = triggerRect.bottom + sideOffset;
@@ -299,7 +338,7 @@ const calculatePosition = (triggerRect, menuRect, placement, sideOffset, viewpor
299
338
  left = triggerRect.left - menuRect.width - sideOffset;
300
339
  break;
301
340
  }
302
- // Calculate alignment
341
+ // Calculate alignment.
303
342
  if (side === "bottom" || side === "top") {
304
343
  switch(alignment){
305
344
  case "start":
@@ -325,7 +364,7 @@ const calculatePosition = (triggerRect, menuRect, placement, sideOffset, viewpor
325
364
  break;
326
365
  }
327
366
  }
328
- // Auto-flip if overflowing viewport
367
+ // Auto-flip if overflowing viewport.
329
368
  /* v8 ignore start - auto-flip with fallback when flipped position also overflows */ if (side === "bottom" && top + menuRect.height > viewportHeight) {
330
369
  const flippedTop = triggerRect.top - menuRect.height - sideOffset;
331
370
  if (flippedTop >= 0) {
@@ -347,7 +386,7 @@ const calculatePosition = (triggerRect, menuRect, placement, sideOffset, viewpor
347
386
  left = flippedLeft;
348
387
  }
349
388
  }
350
- /* v8 ignore stop */ // Clamp to viewport bounds
389
+ /* v8 ignore stop */ // Clamp to viewport bounds.
351
390
  left = Math.max(0, Math.min(left, viewportWidth - menuRect.width));
352
391
  top = Math.max(0, Math.min(top, viewportHeight - menuRect.height));
353
392
  return {
@@ -440,13 +479,13 @@ const Menu = ({ trigger, children, label = "Open menu", defaultPlacement = "bott
440
479
  /* v8 ignore stop */ sideOffset,
441
480
  isOpen
442
481
  });
443
- // Show/hide popover after the menu element is mounted/unmounted
482
+ // Show/hide popover after the menu element is mounted/unmounted.
444
483
  /* v8 ignore start - popover API may not be available in test env */ (0,__rspack_external_react.useEffect)(()=>{
445
484
  if (isOpen && menuRef.current && typeof menuRef.current.showPopover === "function") {
446
485
  try {
447
486
  menuRef.current.showPopover();
448
487
  } catch {
449
- // Popover might already be shown
488
+ // Popover might already be shown.
450
489
  }
451
490
  }
452
491
  }, [
@@ -464,7 +503,7 @@ const Menu = ({ trigger, children, label = "Open menu", defaultPlacement = "bott
464
503
  try {
465
504
  menuRef.current.hidePopover();
466
505
  } catch {
467
- // Popover might already be hidden
506
+ // Popover might already be hidden.
468
507
  }
469
508
  }
470
509
  /* v8 ignore stop */ triggerRef.current?.focus();
@@ -472,7 +511,7 @@ const Menu = ({ trigger, children, label = "Open menu", defaultPlacement = "bott
472
511
  isOpen,
473
512
  onOpenChange
474
513
  ]);
475
- // Click-outside detection: only active when menu is open
514
+ // Click-outside detection: only active when menu is open.
476
515
  /* v8 ignore start - click-outside detection requires full browser env */ (0,__rspack_external_react.useEffect)(()=>{
477
516
  if (!isOpen) {
478
517
  return;
@@ -487,7 +526,7 @@ const Menu = ({ trigger, children, label = "Open menu", defaultPlacement = "bott
487
526
  triggerRef.current?.focus();
488
527
  }
489
528
  };
490
- // Use a timeout to avoid catching the opening click itself
529
+ // Use a timeout to avoid catching the opening click itself.
491
530
  const timeoutId = setTimeout(()=>{
492
531
  document.addEventListener("mousedown", handleClickOutside);
493
532
  }, 0);
@@ -501,7 +540,7 @@ const Menu = ({ trigger, children, label = "Open menu", defaultPlacement = "bott
501
540
  ]);
502
541
  /* v8 ignore stop */ const getItems = (0,__rspack_external_react.useCallback)(()=>itemsRef.current, []);
503
542
  const registerItem = (0,__rspack_external_react.useCallback)((element, disabled)=>{
504
- // Maintain DOM order by using compareDocumentPosition
543
+ // Maintain DOM order by using compareDocumentPosition.
505
544
  const existing = itemsRef.current.findIndex((item)=>item.element === element);
506
545
  /* v8 ignore start - re-registration on re-render */ if (existing >= 0) {
507
546
  itemsRef.current[existing].disabled = disabled;
@@ -511,7 +550,7 @@ const Menu = ({ trigger, children, label = "Open menu", defaultPlacement = "bott
511
550
  element,
512
551
  disabled
513
552
  };
514
- // Insert in DOM order
553
+ // Insert in DOM order.
515
554
  const insertIndex = itemsRef.current.findIndex((item)=>item.element.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_PRECEDING);
516
555
  /* v8 ignore start */ if (insertIndex === -1) {
517
556
  itemsRef.current.push(newItem);
@@ -537,7 +576,7 @@ const Menu = ({ trigger, children, label = "Open menu", defaultPlacement = "bott
537
576
  const handleOpen = (0,__rspack_external_react.useCallback)(()=>{
538
577
  setIsOpen(true);
539
578
  onOpenChange?.(true);
540
- // Focus first enabled item after menu renders
579
+ // Focus first enabled item after menu renders.
541
580
  /* v8 ignore start - rAF does not execute in test env */ requestAnimationFrame(()=>{
542
581
  const items = getItems();
543
582
  for(let i = 0; i < items.length; i++){
@@ -563,9 +602,9 @@ const Menu = ({ trigger, children, label = "Open menu", defaultPlacement = "bott
563
602
  } else {
564
603
  /* v8 ignore stop */ handleOpen();
565
604
  /**
566
- * Dispatch a click event to parent elements.
567
- * This ensures that parent components like Tooltip can detect the
568
- * interaction and respond appropriately (e.g., disable tooltip display).
605
+ * Dispatch a click event to parent elements. This ensures that parent
606
+ * components like Tooltip can detect the interaction and respond
607
+ * appropriately (e.g., disable tooltip display).
569
608
  */ /* v8 ignore start */ if (triggerRef.current) {
570
609
  const clickEvent = new MouseEvent("click", {
571
610
  bubbles: true,
@@ -592,7 +631,7 @@ const Menu = ({ trigger, children, label = "Open menu", defaultPlacement = "bott
592
631
  isOpen,
593
632
  handleOpen
594
633
  ]);
595
- /* v8 ignore stop */ // Build trigger element with props
634
+ /* v8 ignore stop */ // Build trigger element with props.
596
635
  const noInternalClick = getDisplayName(trigger) === "Button" || getDisplayName(trigger) === "ButtonIcon";
597
636
  const uiButtonsExtraProps = noInternalClick ? {
598
637
  noInternalClick,
@@ -739,7 +778,7 @@ const MenuItem = ({ label, disabled, icon, raw = false, children, ignoreClick =
739
778
  setActiveIndex(myIndex);
740
779
  itemRef.current?.focus();
741
780
  }
742
- /* v8 ignore stop */ // Close any open sub-menu when hovering a regular item
781
+ /* v8 ignore stop */ // Close any open sub-menu when hovering a regular item.
743
782
  setOpenSubMenuId(null);
744
783
  onMouseEnter?.(event);
745
784
  };
@@ -797,7 +836,7 @@ const MenuItem = ({ label, disabled, icon, raw = false, children, ignoreClick =
797
836
  selected === true && /*#__PURE__*/ jsx("span", {
798
837
  className: "mr-2 flex size-4 shrink-0 items-center justify-center rounded-full border border-copy-medium",
799
838
  children: /*#__PURE__*/ jsx("span", {
800
- className: "size-1.5 rounded-full bg-copy-success-light"
839
+ className: SELECTED_DOT_CLASS
801
840
  })
802
841
  }),
803
842
  selected === false && /*#__PURE__*/ jsx("span", {
@@ -868,7 +907,7 @@ const SUB_CONTENT_CLASS = getMenuClasses({
868
907
  const SUB_TRIGGER_CLASS = getMenuItemClasses({
869
908
  isSub: true
870
909
  });
871
- const MenuSub = ({ label, icon, children, disabled = false, sideOffset = 20 })=>{
910
+ const MenuSub = ({ label, icon, children, disabled = false, sideOffset = 20, selected })=>{
872
911
  const subMenuId = useUniqueId("av-menu-sub-");
873
912
  const [isSubOpen, setIsSubOpen] = useState(false);
874
913
  const [subActiveIndex, setSubActiveIndex] = useState(-1);
@@ -878,7 +917,7 @@ const MenuSub = ({ label, icon, children, disabled = false, sideOffset = 20 })=>
878
917
  const subItemsRef = useRef([]);
879
918
  const parentContext = useContext(MenuContentContext);
880
919
  const rootContext = useContext(MenuRootContext);
881
- // Register as an item in the parent menu
920
+ // Register as an item in the parent menu.
882
921
  useEffect(()=>{
883
922
  const element = triggerItemRef.current;
884
923
  /* v8 ignore start - ref is always set after mount */ if (element) {
@@ -892,8 +931,10 @@ const MenuSub = ({ label, icon, children, disabled = false, sideOffset = 20 })=>
892
931
  parentContext.registerItem,
893
932
  parentContext.unregisterItem
894
933
  ]);
895
- // Close if a sibling sub-menu opens or parent signals close (openSubMenuId set to null)
896
- useEffect(()=>{
934
+ /**
935
+ * Close if a sibling sub-menu opens or parent signals close (openSubMenuId set
936
+ * to null).
937
+ */ useEffect(()=>{
897
938
  if (isSubOpen && parentContext.openSubMenuId !== subMenuId) {
898
939
  setIsSubOpen(false);
899
940
  setSubActiveIndex(-1);
@@ -901,7 +942,7 @@ const MenuSub = ({ label, icon, children, disabled = false, sideOffset = 20 })=>
901
942
  try {
902
943
  subMenuRef.current.hidePopover();
903
944
  } catch {
904
- // Already hidden
945
+ // Already hidden.
905
946
  }
906
947
  }
907
948
  /* v8 ignore stop */ }
@@ -917,13 +958,13 @@ const MenuSub = ({ label, icon, children, disabled = false, sideOffset = 20 })=>
917
958
  sideOffset,
918
959
  isOpen: isSubOpen
919
960
  });
920
- // Show popover after the sub-menu element is mounted
961
+ // Show popover after the sub-menu element is mounted.
921
962
  /* v8 ignore start - popover API may not be available in test env */ useEffect(()=>{
922
963
  if (isSubOpen && subMenuRef.current && typeof subMenuRef.current.showPopover === "function") {
923
964
  try {
924
965
  subMenuRef.current.showPopover();
925
966
  } catch {
926
- // Popover might already be shown
967
+ // Popover might already be shown.
927
968
  }
928
969
  }
929
970
  }, [
@@ -958,7 +999,7 @@ const MenuSub = ({ label, icon, children, disabled = false, sideOffset = 20 })=>
958
999
  try {
959
1000
  subMenuRef.current.hidePopover();
960
1001
  } catch {
961
- // Already hidden
1002
+ // Already hidden.
962
1003
  }
963
1004
  }
964
1005
  triggerItemRef.current?.focus();
@@ -969,7 +1010,7 @@ const MenuSub = ({ label, icon, children, disabled = false, sideOffset = 20 })=>
969
1010
  }
970
1011
  /* v8 ignore stop */ setIsSubOpen(true);
971
1012
  parentContext.setOpenSubMenuId(subMenuId);
972
- // Focus first enabled item after render
1013
+ // Focus first enabled item after render.
973
1014
  /* v8 ignore start - rAF does not execute in test env */ requestAnimationFrame(()=>{
974
1015
  const items = getSubItems();
975
1016
  for(let i = 0; i < items.length; i++){
@@ -1017,14 +1058,24 @@ const MenuSub = ({ label, icon, children, disabled = false, sideOffset = 20 })=>
1017
1058
  parentContext.setActiveIndex(myIndex);
1018
1059
  triggerItemRef.current?.focus();
1019
1060
  }
1020
- // Open sub-menu on hover (WAI-ARIA: hover opens sub-menus when parent menu is already open)
1021
- if (!isSubOpen && !disabled) {
1061
+ /**
1062
+ * Open sub-menu on hover (WAI-ARIA: hover opens sub-menus when parent menu is
1063
+ * already open).
1064
+ */ if (!isSubOpen && !disabled) {
1022
1065
  openSubMenu();
1023
1066
  }
1024
1067
  };
1025
1068
  /* v8 ignore stop */ const myIndex = parentContext.getItems().findIndex((item)=>item.element === triggerItemRef.current);
1026
1069
  const isHighlighted = myIndex >= 0 && parentContext.activeIndex === myIndex;
1027
1070
  const labelSpanClass = icon ? "pl-2" : "";
1071
+ /**
1072
+ * Surface a nested selection on the trigger row. An explicit `selected` prop
1073
+ * wins; otherwise auto-detect from the descendant children tree.
1074
+ */ const showSelectedIndicator = selected ?? hasSelectedDescendant(children);
1075
+ /**
1076
+ * Screen-reader description id. Kept OUTSIDE the menuitem's text content so it
1077
+ * never pollutes typeahead navigation (which reads element.textContent).
1078
+ */ const selectionDescriptionId = `${subMenuId}-selection`;
1028
1079
  const subContentContextValue = {
1029
1080
  registerItem: registerSubItem,
1030
1081
  unregisterItem: unregisterSubItem,
@@ -1044,6 +1095,7 @@ const MenuSub = ({ label, icon, children, disabled = false, sideOffset = 20 })=>
1044
1095
  "aria-haspopup": "menu",
1045
1096
  "aria-expanded": isSubOpen,
1046
1097
  "aria-controls": subMenuId,
1098
+ "aria-describedby": showSelectedIndicator ? selectionDescriptionId : undefined,
1047
1099
  tabIndex: isHighlighted ? 0 : -1,
1048
1100
  className: SUB_TRIGGER_CLASS,
1049
1101
  "data-state": isSubOpen ? "open" : "closed",
@@ -1056,6 +1108,10 @@ const MenuSub = ({ label, icon, children, disabled = false, sideOffset = 20 })=>
1056
1108
  /*#__PURE__*/ jsxs("span", {
1057
1109
  className: "flex items-center",
1058
1110
  children: [
1111
+ showSelectedIndicator && /*#__PURE__*/ jsx("span", {
1112
+ className: `mr-2 shrink-0 ${SELECTED_DOT_CLASS}`,
1113
+ "aria-hidden": "true"
1114
+ }),
1059
1115
  icon,
1060
1116
  /*#__PURE__*/ jsx("span", {
1061
1117
  className: labelSpanClass,
@@ -1070,6 +1126,11 @@ const MenuSub = ({ label, icon, children, disabled = false, sideOffset = 20 })=>
1070
1126
  })
1071
1127
  ]
1072
1128
  }),
1129
+ showSelectedIndicator && /*#__PURE__*/ jsx("span", {
1130
+ id: selectionDescriptionId,
1131
+ className: "sr-only",
1132
+ children: "(contains a selection)"
1133
+ }),
1073
1134
  isSubOpen && /*#__PURE__*/ jsx("div", {
1074
1135
  ref: subMenuRef,
1075
1136
  id: subMenuId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@versini/ui-menu",
3
- "version": "7.1.2",
3
+ "version": "7.3.0",
4
4
  "license": "MIT",
5
5
  "author": "Arno Versini",
6
6
  "publishConfig": {
@@ -38,10 +38,10 @@
38
38
  },
39
39
  "devDependencies": {
40
40
  "@testing-library/jest-dom": "6.9.1",
41
- "@versini/ui-types": "10.0.0"
41
+ "@versini/ui-types": "10.1.0"
42
42
  },
43
43
  "dependencies": {
44
- "@versini/ui-hooks": "6.1.1",
44
+ "@versini/ui-hooks": "6.2.0",
45
45
  "@versini/ui-icons": "4.29.0",
46
46
  "clsx": "2.1.1",
47
47
  "tailwindcss": "4.3.1"
@@ -49,5 +49,5 @@
49
49
  "sideEffects": [
50
50
  "**/*.css"
51
51
  ],
52
- "gitHead": "f25e4b557175f745705c249d10e2e77dc02ed462"
52
+ "gitHead": "fbfcead0c4637fdf2cf9c8f6a701fe5da9508d4b"
53
53
  }