@xsolla/xui-context-menu 0.184.0 → 0.185.1

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.
@@ -87,6 +87,11 @@ interface ContextMenuProps extends ThemeOverrideProps {
87
87
  emptyMessage?: string;
88
88
  empty?: ReactNode;
89
89
  trigger?: ReactNode;
90
+ /**
91
+ * When `true`, the trigger fills the full width of its parent instead of
92
+ * shrinking to its content width. Defaults to `false`.
93
+ */
94
+ triggerFullWidth?: boolean;
90
95
  /**
91
96
  * Menu placement relative to the trigger element. The `*-center` variants
92
97
  * horizontally center the panel on the trigger; every variant auto-flips on
@@ -95,6 +100,13 @@ interface ContextMenuProps extends ThemeOverrideProps {
95
100
  * when `position` is set). Defaults to `"bottom-start"`.
96
101
  */
97
102
  placement?: ContextMenuPlacement;
103
+ /**
104
+ * Gap in pixels between the trigger and the menu. When `withArrow` is set
105
+ * this is measured to the arrow's tip; otherwise to the panel edge. Only
106
+ * applies to trigger-anchored menus (ignored when `position` is set).
107
+ * Defaults to `4`.
108
+ */
109
+ offset?: number;
98
110
  position?: ContextMenuPosition;
99
111
  isOpen?: boolean;
100
112
  onOpenChange?: (open: boolean) => void;
@@ -174,6 +186,7 @@ declare const useContextMenuRequired: () => ContextMenuContextValue;
174
186
  interface UseContextMenuPositionOptions {
175
187
  triggerRef: RefObject<HTMLElement | null>;
176
188
  panelRef: RefObject<HTMLElement | null>;
189
+ containerRef: RefObject<HTMLElement | null>;
177
190
  isOpen: boolean;
178
191
  placement?: ContextMenuPlacement;
179
192
  offset?: number;
@@ -183,6 +196,6 @@ interface ResolvedPosition {
183
196
  left: number;
184
197
  placement: ContextMenuPlacement;
185
198
  }
186
- declare const useContextMenuPosition: ({ triggerRef, panelRef, isOpen, placement, offset, }: UseContextMenuPositionOptions) => ResolvedPosition | undefined;
199
+ declare const useContextMenuPosition: ({ triggerRef, panelRef, containerRef, isOpen, placement, offset, }: UseContextMenuPositionOptions) => ResolvedPosition | undefined;
187
200
 
188
201
  export { ContextMenu, ContextMenuContext, type ContextMenuContextValue, type ContextMenuDividerItemProps, type ContextMenuHeadingItemProps, ContextMenuItem, type ContextMenuItemProps, type ContextMenuOptionItemProps, type ContextMenuPlacement, type ContextMenuPosition, type ContextMenuPresetType, type ContextMenuProps, type ContextMenuSearchItemProps, type ContextMenuSize, type ContextMenuSizing, ContextMenuSubmenu, type ContextMenuSubmenuProps, useContextMenu, useContextMenuPosition, useContextMenuRequired, useKeyboardNavigation };
package/native/index.d.ts CHANGED
@@ -87,6 +87,11 @@ interface ContextMenuProps extends ThemeOverrideProps {
87
87
  emptyMessage?: string;
88
88
  empty?: ReactNode;
89
89
  trigger?: ReactNode;
90
+ /**
91
+ * When `true`, the trigger fills the full width of its parent instead of
92
+ * shrinking to its content width. Defaults to `false`.
93
+ */
94
+ triggerFullWidth?: boolean;
90
95
  /**
91
96
  * Menu placement relative to the trigger element. The `*-center` variants
92
97
  * horizontally center the panel on the trigger; every variant auto-flips on
@@ -95,6 +100,13 @@ interface ContextMenuProps extends ThemeOverrideProps {
95
100
  * when `position` is set). Defaults to `"bottom-start"`.
96
101
  */
97
102
  placement?: ContextMenuPlacement;
103
+ /**
104
+ * Gap in pixels between the trigger and the menu. When `withArrow` is set
105
+ * this is measured to the arrow's tip; otherwise to the panel edge. Only
106
+ * applies to trigger-anchored menus (ignored when `position` is set).
107
+ * Defaults to `4`.
108
+ */
109
+ offset?: number;
98
110
  position?: ContextMenuPosition;
99
111
  isOpen?: boolean;
100
112
  onOpenChange?: (open: boolean) => void;
@@ -174,6 +186,7 @@ declare const useContextMenuRequired: () => ContextMenuContextValue;
174
186
  interface UseContextMenuPositionOptions {
175
187
  triggerRef: RefObject<HTMLElement | null>;
176
188
  panelRef: RefObject<HTMLElement | null>;
189
+ containerRef: RefObject<HTMLElement | null>;
177
190
  isOpen: boolean;
178
191
  placement?: ContextMenuPlacement;
179
192
  offset?: number;
@@ -183,6 +196,6 @@ interface ResolvedPosition {
183
196
  left: number;
184
197
  placement: ContextMenuPlacement;
185
198
  }
186
- declare const useContextMenuPosition: ({ triggerRef, panelRef, isOpen, placement, offset, }: UseContextMenuPositionOptions) => ResolvedPosition | undefined;
199
+ declare const useContextMenuPosition: ({ triggerRef, panelRef, containerRef, isOpen, placement, offset, }: UseContextMenuPositionOptions) => ResolvedPosition | undefined;
187
200
 
188
201
  export { ContextMenu, ContextMenuContext, type ContextMenuContextValue, type ContextMenuDividerItemProps, type ContextMenuHeadingItemProps, ContextMenuItem, type ContextMenuItemProps, type ContextMenuOptionItemProps, type ContextMenuPlacement, type ContextMenuPosition, type ContextMenuPresetType, type ContextMenuProps, type ContextMenuSearchItemProps, type ContextMenuSize, type ContextMenuSizing, ContextMenuSubmenu, type ContextMenuSubmenuProps, useContextMenu, useContextMenuPosition, useContextMenuRequired, useKeyboardNavigation };
package/native/index.js CHANGED
@@ -379,6 +379,7 @@ var OptionCell = ({
379
379
  const closeTimerRef = import_react2.default.useRef(
380
380
  null
381
381
  );
382
+ const ownsSubmenu = Boolean(hasSubmenu && submenu != null);
382
383
  const cancelClose = () => {
383
384
  if (closeTimerRef.current) {
384
385
  clearTimeout(closeTimerRef.current);
@@ -387,9 +388,22 @@ var OptionCell = ({
387
388
  };
388
389
  const scheduleClose = () => {
389
390
  cancelClose();
390
- closeTimerRef.current = setTimeout(() => setSubmenuOpen(false), 120);
391
+ closeTimerRef.current = setTimeout(() => {
392
+ setSubmenuOpen(false);
393
+ if (ownsSubmenu && ctx?.activeSubmenuId === id) {
394
+ ctx.setActiveSubmenuId?.(null);
395
+ }
396
+ }, 120);
391
397
  };
392
398
  (0, import_react2.useEffect)(() => () => cancelClose(), []);
399
+ (0, import_react2.useEffect)(() => {
400
+ if (!ownsSubmenu || !submenuOpen) return;
401
+ const activeId = ctx?.activeSubmenuId;
402
+ if (activeId != null && activeId !== id) {
403
+ cancelClose();
404
+ setSubmenuOpen(false);
405
+ }
406
+ }, [ctx?.activeSubmenuId, ownsSubmenu, submenuOpen, id]);
393
407
  (0, import_react2.useEffect)(() => {
394
408
  if (!hasSubmenu || !submenuOpen) return;
395
409
  const onMouseDown = (e) => {
@@ -480,6 +494,7 @@ var OptionCell = ({
480
494
  if (ctx && index >= 0) ctx.setActiveIndex(index);
481
495
  if (!ctx) setIsHovered(true);
482
496
  if (hasSubmenu) setSubmenuOpen(true);
497
+ if (ownsSubmenu) ctx?.setActiveSubmenuId?.(id);
483
498
  };
484
499
  const handleLeave = () => {
485
500
  if (!ctx) setIsHovered(false);
@@ -1079,12 +1094,13 @@ var joinPlacement = (vertical, horizontal) => `${vertical}-${horizontal}`;
1079
1094
  var useContextMenuPosition = ({
1080
1095
  triggerRef,
1081
1096
  panelRef,
1097
+ containerRef,
1082
1098
  isOpen,
1083
1099
  placement = "bottom-start",
1084
1100
  offset = 4
1085
1101
  }) => {
1086
1102
  const [resolved, setResolved] = (0, import_react4.useState)();
1087
- (0, import_react4.useEffect)(() => {
1103
+ (0, import_react4.useLayoutEffect)(() => {
1088
1104
  if (!isOpen) {
1089
1105
  setResolved(void 0);
1090
1106
  return;
@@ -1092,6 +1108,7 @@ var useContextMenuPosition = ({
1092
1108
  const compute = () => {
1093
1109
  const trigger = triggerRef.current;
1094
1110
  const panel = panelRef.current;
1111
+ const container = containerRef.current;
1095
1112
  if (!trigger || !panel) return;
1096
1113
  const triggerRect = trigger.getBoundingClientRect();
1097
1114
  const panelRect = panel.getBoundingClientRect();
@@ -1131,6 +1148,15 @@ var useContextMenuPosition = ({
1131
1148
  }
1132
1149
  }
1133
1150
  }
1151
+ if (container) {
1152
+ const containerRect = container.getBoundingClientRect();
1153
+ const cOffsetW = container.offsetWidth;
1154
+ const cOffsetH = container.offsetHeight;
1155
+ const scaleX = cOffsetW > 0 ? containerRect.width / cOffsetW : 1;
1156
+ const scaleY = cOffsetH > 0 ? containerRect.height / cOffsetH : 1;
1157
+ left = (left - containerRect.left) / (scaleX || 1);
1158
+ top = (top - containerRect.top) / (scaleY || 1);
1159
+ }
1134
1160
  const next = {
1135
1161
  top,
1136
1162
  left,
@@ -1140,14 +1166,13 @@ var useContextMenuPosition = ({
1140
1166
  (prev) => prev && prev.top === next.top && prev.left === next.left && prev.placement === next.placement ? prev : next
1141
1167
  );
1142
1168
  };
1143
- const rafId = window.requestAnimationFrame(compute);
1169
+ compute();
1144
1170
  const onResize = () => compute();
1145
1171
  window.addEventListener("resize", onResize);
1146
1172
  return () => {
1147
- window.cancelAnimationFrame(rafId);
1148
1173
  window.removeEventListener("resize", onResize);
1149
1174
  };
1150
- }, [isOpen, placement, offset, triggerRef, panelRef]);
1175
+ }, [isOpen, placement, offset, triggerRef, panelRef, containerRef]);
1151
1176
  return resolved;
1152
1177
  };
1153
1178
 
@@ -1279,6 +1304,7 @@ var SEARCH_DEBOUNCE_MS = 200;
1279
1304
  var ARROW_SIZE = 10;
1280
1305
  var ARROW_HALF = ARROW_SIZE / 2;
1281
1306
  var ARROW_EDGE_OFFSET = 12;
1307
+ var ARROW_TIP_PROTRUSION = ARROW_HALF * Math.SQRT2;
1282
1308
  var PANEL_SHADOW = "0 4px 12px rgba(0,0,0,0.15)";
1283
1309
  var textFromNode = (node) => {
1284
1310
  if (node === null || node === void 0 || typeof node === "boolean")
@@ -1328,7 +1354,9 @@ var ContextMenuRoot = (0, import_react6.forwardRef)(
1328
1354
  isOpen: propIsOpen,
1329
1355
  onOpenChange,
1330
1356
  trigger,
1357
+ triggerFullWidth = false,
1331
1358
  placement = "bottom-start",
1359
+ offset = 4,
1332
1360
  position,
1333
1361
  width,
1334
1362
  maxHeight = 300,
@@ -1362,11 +1390,14 @@ var ContextMenuRoot = (0, import_react6.forwardRef)(
1362
1390
  const sizeStyles = xuiTheme.sizing.contextMenu(size);
1363
1391
  const borderRadius = xuiTheme.shape?.contextMenu?.[size]?.borderRadius ?? 8;
1364
1392
  const shouldCloseOnSelect = closeOnSelect ?? (type === "checkbox" ? false : true);
1393
+ const positionOffset = withArrow ? offset + ARROW_TIP_PROTRUSION : offset;
1365
1394
  const positioned = useContextMenuPosition({
1366
1395
  triggerRef,
1367
1396
  panelRef,
1397
+ containerRef,
1368
1398
  isOpen: isOpen && !!trigger && !position,
1369
- placement
1399
+ placement,
1400
+ offset: positionOffset
1370
1401
  });
1371
1402
  const resolvedPlacement = positioned?.placement ?? placement;
1372
1403
  const getArrowStyle = (arrowPlacement) => {
@@ -1622,7 +1653,10 @@ var ContextMenuRoot = (0, import_react6.forwardRef)(
1622
1653
  onClick: (event) => {
1623
1654
  if (!event.defaultPrevented) toggleMenu();
1624
1655
  },
1625
- style: { display: "inline-flex" },
1656
+ style: {
1657
+ display: "inline-flex",
1658
+ width: triggerFullWidth ? "100%" : "auto"
1659
+ },
1626
1660
  children: (0, import_react6.cloneElement)(trigger, {
1627
1661
  "aria-haspopup": "menu",
1628
1662
  "aria-expanded": isOpen ? "true" : "false"
@@ -1644,10 +1678,13 @@ var ContextMenuRoot = (0, import_react6.forwardRef)(
1644
1678
  position: "fixed",
1645
1679
  left: position.x,
1646
1680
  top: position.y
1647
- } : trigger ? {
1648
- position: "fixed",
1649
- left: positioned?.left ?? 0,
1650
- top: positioned?.top ?? 0
1681
+ } : trigger ? positioned ? {
1682
+ position: "absolute",
1683
+ left: positioned.left,
1684
+ top: positioned.top
1685
+ } : {
1686
+ position: "absolute",
1687
+ visibility: "hidden"
1651
1688
  } : void 0;
1652
1689
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ContextMenuContext.Provider, { value: contextValue, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
1653
1690
  "div",