@xsolla/xui-context-menu 0.181.0 → 0.182.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.
@@ -25,7 +25,7 @@ declare const useKeyboardNavigation: ({ isOpen, cells, activeIndex, setActiveInd
25
25
  };
26
26
 
27
27
  type ContextMenuSize = "sm" | "md" | "lg" | "xl";
28
- type ContextMenuPlacement = "bottom-start" | "top-start" | "bottom-end" | "top-end";
28
+ type ContextMenuPlacement = "bottom-start" | "top-start" | "bottom-end" | "top-end" | "bottom-center" | "top-center";
29
29
  type ContextMenuPresetType = "list" | "loading" | "phone" | "checkbox" | "status" | "brandLogo" | "radio" | "avatar";
30
30
  interface ContextMenuPosition {
31
31
  x: number;
@@ -87,6 +87,13 @@ interface ContextMenuProps extends ThemeOverrideProps {
87
87
  emptyMessage?: string;
88
88
  empty?: ReactNode;
89
89
  trigger?: ReactNode;
90
+ /**
91
+ * Menu placement relative to the trigger element. The `*-center` variants
92
+ * horizontally center the panel on the trigger; every variant auto-flips on
93
+ * the vertical axis (and `start`/`end` flip horizontally) when the menu
94
+ * would clip the viewport. Only applies to trigger-anchored menus (ignored
95
+ * when `position` is set). Defaults to `"bottom-start"`.
96
+ */
90
97
  placement?: ContextMenuPlacement;
91
98
  position?: ContextMenuPosition;
92
99
  isOpen?: boolean;
@@ -94,6 +101,12 @@ interface ContextMenuProps extends ThemeOverrideProps {
94
101
  closeOnSelect?: boolean;
95
102
  width?: number | string;
96
103
  maxHeight?: number;
104
+ /**
105
+ * Render a directional arrow on the panel edge pointing toward the trigger,
106
+ * mirroring the Tooltip arrow. Only applies to trigger-anchored menus.
107
+ * Defaults to `false`.
108
+ */
109
+ withArrow?: boolean;
97
110
  onSelect?: (item: ContextMenuOptionItemProps) => void;
98
111
  "aria-label"?: string;
99
112
  testID?: string;
package/native/index.d.ts CHANGED
@@ -25,7 +25,7 @@ declare const useKeyboardNavigation: ({ isOpen, cells, activeIndex, setActiveInd
25
25
  };
26
26
 
27
27
  type ContextMenuSize = "sm" | "md" | "lg" | "xl";
28
- type ContextMenuPlacement = "bottom-start" | "top-start" | "bottom-end" | "top-end";
28
+ type ContextMenuPlacement = "bottom-start" | "top-start" | "bottom-end" | "top-end" | "bottom-center" | "top-center";
29
29
  type ContextMenuPresetType = "list" | "loading" | "phone" | "checkbox" | "status" | "brandLogo" | "radio" | "avatar";
30
30
  interface ContextMenuPosition {
31
31
  x: number;
@@ -87,6 +87,13 @@ interface ContextMenuProps extends ThemeOverrideProps {
87
87
  emptyMessage?: string;
88
88
  empty?: ReactNode;
89
89
  trigger?: ReactNode;
90
+ /**
91
+ * Menu placement relative to the trigger element. The `*-center` variants
92
+ * horizontally center the panel on the trigger; every variant auto-flips on
93
+ * the vertical axis (and `start`/`end` flip horizontally) when the menu
94
+ * would clip the viewport. Only applies to trigger-anchored menus (ignored
95
+ * when `position` is set). Defaults to `"bottom-start"`.
96
+ */
90
97
  placement?: ContextMenuPlacement;
91
98
  position?: ContextMenuPosition;
92
99
  isOpen?: boolean;
@@ -94,6 +101,12 @@ interface ContextMenuProps extends ThemeOverrideProps {
94
101
  closeOnSelect?: boolean;
95
102
  width?: number | string;
96
103
  maxHeight?: number;
104
+ /**
105
+ * Render a directional arrow on the panel edge pointing toward the trigger,
106
+ * mirroring the Tooltip arrow. Only applies to trigger-anchored menus.
107
+ * Defaults to `false`.
108
+ */
109
+ withArrow?: boolean;
97
110
  onSelect?: (item: ContextMenuOptionItemProps) => void;
98
111
  "aria-label"?: string;
99
112
  testID?: string;
package/native/index.js CHANGED
@@ -1099,7 +1099,11 @@ var useContextMenuPosition = ({
1099
1099
  const viewportHeight = window.innerHeight;
1100
1100
  let { vertical, horizontal } = splitPlacement(placement);
1101
1101
  const computeTop = (v) => v === "bottom" ? triggerRect.bottom + offset : triggerRect.top - panelRect.height - offset;
1102
- const computeLeft = (h) => h === "start" ? triggerRect.left : triggerRect.right - panelRect.width;
1102
+ const computeLeft = (h) => {
1103
+ if (h === "center")
1104
+ return triggerRect.left + triggerRect.width / 2 - panelRect.width / 2;
1105
+ return h === "start" ? triggerRect.left : triggerRect.right - panelRect.width;
1106
+ };
1103
1107
  let top = computeTop(vertical);
1104
1108
  const wantedBottom = top + panelRect.height;
1105
1109
  if (top < 0 || wantedBottom > viewportHeight) {
@@ -1112,14 +1116,19 @@ var useContextMenuPosition = ({
1112
1116
  }
1113
1117
  }
1114
1118
  let left = computeLeft(horizontal);
1115
- const wantedRight = left + panelRect.width;
1116
- if (left < 0 || wantedRight > viewportWidth) {
1117
- const flipped = horizontal === "start" ? "end" : "start";
1118
- const flippedLeft = computeLeft(flipped);
1119
- const flippedRight = flippedLeft + panelRect.width;
1120
- if (flippedLeft >= 0 && flippedRight <= viewportWidth) {
1121
- horizontal = flipped;
1122
- left = flippedLeft;
1119
+ if (horizontal === "center") {
1120
+ const maxLeft = viewportWidth - panelRect.width;
1121
+ left = maxLeft >= 0 ? Math.min(Math.max(left, 0), maxLeft) : 0;
1122
+ } else {
1123
+ const wantedRight = left + panelRect.width;
1124
+ if (left < 0 || wantedRight > viewportWidth) {
1125
+ const flipped = horizontal === "start" ? "end" : "start";
1126
+ const flippedLeft = computeLeft(flipped);
1127
+ const flippedRight = flippedLeft + panelRect.width;
1128
+ if (flippedLeft >= 0 && flippedRight <= viewportWidth) {
1129
+ horizontal = flipped;
1130
+ left = flippedLeft;
1131
+ }
1123
1132
  }
1124
1133
  }
1125
1134
  const next = {
@@ -1267,6 +1276,10 @@ var useKeyboardNavigation = ({
1267
1276
  var import_jsx_runtime4 = require("react/jsx-runtime");
1268
1277
  var DEFAULT_EMPTY_MESSAGE = "No results";
1269
1278
  var SEARCH_DEBOUNCE_MS = 200;
1279
+ var ARROW_SIZE = 10;
1280
+ var ARROW_HALF = ARROW_SIZE / 2;
1281
+ var ARROW_EDGE_OFFSET = 12;
1282
+ var PANEL_SHADOW = "0 4px 12px rgba(0,0,0,0.15)";
1270
1283
  var textFromNode = (node) => {
1271
1284
  if (node === null || node === void 0 || typeof node === "boolean")
1272
1285
  return "";
@@ -1319,6 +1332,7 @@ var ContextMenuRoot = (0, import_react6.forwardRef)(
1319
1332
  position,
1320
1333
  width,
1321
1334
  maxHeight = 300,
1335
+ withArrow = false,
1322
1336
  onSelect,
1323
1337
  closeOnSelect,
1324
1338
  "aria-label": ariaLabel,
@@ -1354,6 +1368,33 @@ var ContextMenuRoot = (0, import_react6.forwardRef)(
1354
1368
  isOpen: isOpen && !!trigger && !position,
1355
1369
  placement
1356
1370
  });
1371
+ const resolvedPlacement = positioned?.placement ?? placement;
1372
+ const getArrowStyle = (arrowPlacement) => {
1373
+ const isTop = arrowPlacement.startsWith("top");
1374
+ const arrowStyle = {
1375
+ position: "absolute",
1376
+ width: ARROW_SIZE,
1377
+ height: ARROW_SIZE,
1378
+ backgroundColor: xuiTheme.colors.background.secondary,
1379
+ borderStyle: "solid",
1380
+ borderColor: xuiTheme.colors.border.secondary,
1381
+ // 1px outline only on the two outward-facing edges of the rotated square
1382
+ borderWidth: isTop ? "0 1px 1px 0" : "1px 0 0 1px",
1383
+ transform: "rotate(45deg)",
1384
+ pointerEvents: "none"
1385
+ };
1386
+ if (isTop) arrowStyle.bottom = -ARROW_HALF;
1387
+ else arrowStyle.top = -ARROW_HALF;
1388
+ if (arrowPlacement.endsWith("center")) {
1389
+ arrowStyle.left = "50%";
1390
+ arrowStyle.marginLeft = -ARROW_HALF;
1391
+ } else if (arrowPlacement.endsWith("end")) {
1392
+ arrowStyle.right = ARROW_EDGE_OFFSET;
1393
+ } else {
1394
+ arrowStyle.left = ARROW_EDGE_OFFSET;
1395
+ }
1396
+ return arrowStyle;
1397
+ };
1357
1398
  const setOpen = (0, import_react6.useCallback)(
1358
1399
  (nextOpen) => {
1359
1400
  if (propIsOpen === void 0) setInternalIsOpen(nextOpen);
@@ -1564,15 +1605,30 @@ var ContextMenuRoot = (0, import_react6.forwardRef)(
1564
1605
  const assignTriggerRef = (node) => {
1565
1606
  triggerRef.current = node;
1566
1607
  };
1567
- const triggerNode = trigger && (0, import_react6.isValidElement)(trigger) ? (0, import_react6.cloneElement)(trigger, {
1568
- ref: assignTriggerRef,
1569
- "aria-haspopup": "menu",
1570
- "aria-expanded": isOpen ? "true" : "false",
1571
- onClick: (event) => {
1572
- trigger.props.onClick?.(event);
1573
- if (!event.defaultPrevented) toggleMenu();
1608
+ const assignTriggerWrapperRef = (node) => {
1609
+ if (!node) {
1610
+ assignTriggerRef(null);
1611
+ return;
1574
1612
  }
1575
- }) : trigger ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1613
+ const firstChild = node.firstElementChild;
1614
+ assignTriggerRef(
1615
+ firstChild instanceof HTMLElement ? firstChild : node
1616
+ );
1617
+ };
1618
+ const triggerNode = trigger && (0, import_react6.isValidElement)(trigger) ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1619
+ "span",
1620
+ {
1621
+ ref: assignTriggerWrapperRef,
1622
+ onClick: (event) => {
1623
+ if (!event.defaultPrevented) toggleMenu();
1624
+ },
1625
+ style: { display: "inline-flex" },
1626
+ children: (0, import_react6.cloneElement)(trigger, {
1627
+ "aria-haspopup": "menu",
1628
+ "aria-expanded": isOpen ? "true" : "false"
1629
+ })
1630
+ }
1631
+ ) : trigger ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1576
1632
  "span",
1577
1633
  {
1578
1634
  ref: assignTriggerRef,
@@ -1610,7 +1666,7 @@ var ContextMenuRoot = (0, import_react6.forwardRef)(
1610
1666
  role: "menu",
1611
1667
  "aria-label": ariaLabel,
1612
1668
  "data-testid": dataTestId ?? testID ?? "context-menu",
1613
- "data-placement": positioned?.placement ?? placement,
1669
+ "data-placement": resolvedPlacement,
1614
1670
  backgroundColor: xuiTheme.colors.background.secondary,
1615
1671
  borderColor: xuiTheme.colors.border.secondary,
1616
1672
  borderWidth: 1,
@@ -1625,12 +1681,26 @@ var ContextMenuRoot = (0, import_react6.forwardRef)(
1625
1681
  ...positionStyle,
1626
1682
  ...style,
1627
1683
  zIndex: 1e3,
1628
- boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
1629
- maxHeight,
1630
- overflowY: "auto",
1684
+ boxShadow: PANEL_SHADOW,
1685
+ ...withArrow ? {
1686
+ overflow: "visible"
1687
+ } : {
1688
+ maxHeight,
1689
+ overflowY: "auto"
1690
+ },
1631
1691
  outline: "none"
1632
1692
  },
1633
- children: renderContent()
1693
+ children: withArrow ? /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_jsx_runtime4.Fragment, { children: [
1694
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1695
+ "span",
1696
+ {
1697
+ "aria-hidden": "true",
1698
+ "data-testid": "context-menu-arrow",
1699
+ style: getArrowStyle(resolvedPlacement)
1700
+ }
1701
+ ),
1702
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { style: { maxHeight, overflowY: "auto", borderRadius }, children: renderContent() })
1703
+ ] }) : renderContent()
1634
1704
  }
1635
1705
  )
1636
1706
  ]