@xsolla/xui-context-menu 0.172.2 → 0.173.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.
package/native/index.js CHANGED
@@ -33,6 +33,7 @@ __export(index_exports, {
33
33
  ContextMenu: () => ContextMenu,
34
34
  ContextMenuContext: () => ContextMenuContext,
35
35
  ContextMenuItem: () => ContextMenuItem,
36
+ ContextMenuSubmenu: () => ContextMenuSubmenu,
36
37
  useContextMenu: () => useContextMenu,
37
38
  useContextMenuPosition: () => useContextMenuPosition,
38
39
  useContextMenuRequired: () => useContextMenuRequired,
@@ -41,9 +42,184 @@ __export(index_exports, {
41
42
  module.exports = __toCommonJS(index_exports);
42
43
 
43
44
  // src/ContextMenu.tsx
44
- var import_react5 = __toESM(require("react"));
45
- var import_react_dom2 = require("react-dom");
46
- var import_xui_core2 = require("@xsolla/xui-core");
45
+ var import_react6 = require("react");
46
+
47
+ // ../../foundation/primitives-native/src/Box.tsx
48
+ var import_react_native = require("react-native");
49
+ var import_jsx_runtime = require("react/jsx-runtime");
50
+ var Box = ({
51
+ children,
52
+ onPress,
53
+ onLayout,
54
+ onMoveShouldSetResponder,
55
+ onResponderGrant,
56
+ onResponderMove,
57
+ onResponderRelease,
58
+ onResponderTerminate,
59
+ backgroundColor,
60
+ borderColor,
61
+ borderWidth,
62
+ borderBottomWidth,
63
+ borderBottomColor,
64
+ borderTopWidth,
65
+ borderTopColor,
66
+ borderLeftWidth,
67
+ borderLeftColor,
68
+ borderRightWidth,
69
+ borderRightColor,
70
+ borderRadius,
71
+ borderStyle,
72
+ height,
73
+ padding,
74
+ paddingHorizontal,
75
+ paddingVertical,
76
+ margin,
77
+ marginTop,
78
+ marginBottom,
79
+ marginLeft,
80
+ marginRight,
81
+ flexDirection,
82
+ alignItems,
83
+ justifyContent,
84
+ position,
85
+ top,
86
+ bottom,
87
+ left,
88
+ right,
89
+ width,
90
+ minWidth,
91
+ minHeight,
92
+ maxWidth,
93
+ maxHeight,
94
+ flex,
95
+ overflow,
96
+ zIndex,
97
+ hoverStyle,
98
+ pressStyle,
99
+ style,
100
+ "data-testid": dataTestId,
101
+ testID,
102
+ as,
103
+ src,
104
+ alt,
105
+ ...rest
106
+ }) => {
107
+ const getContainerStyle = (pressed) => ({
108
+ backgroundColor: pressed && pressStyle?.backgroundColor ? pressStyle.backgroundColor : backgroundColor,
109
+ borderColor,
110
+ borderWidth,
111
+ borderBottomWidth,
112
+ borderBottomColor,
113
+ borderTopWidth,
114
+ borderTopColor,
115
+ borderLeftWidth,
116
+ borderLeftColor,
117
+ borderRightWidth,
118
+ borderRightColor,
119
+ borderRadius,
120
+ borderStyle,
121
+ overflow,
122
+ zIndex,
123
+ height,
124
+ width,
125
+ minWidth,
126
+ minHeight,
127
+ maxWidth,
128
+ maxHeight,
129
+ padding,
130
+ paddingHorizontal,
131
+ paddingVertical,
132
+ margin,
133
+ marginTop,
134
+ marginBottom,
135
+ marginLeft,
136
+ marginRight,
137
+ flexDirection,
138
+ alignItems,
139
+ justifyContent,
140
+ position,
141
+ top,
142
+ bottom,
143
+ left,
144
+ right,
145
+ flex,
146
+ ...style
147
+ });
148
+ const finalTestID = dataTestId || testID;
149
+ const {
150
+ role,
151
+ tabIndex,
152
+ onKeyDown,
153
+ onKeyUp,
154
+ "aria-label": _ariaLabel,
155
+ "aria-labelledby": _ariaLabelledBy,
156
+ "aria-current": _ariaCurrent,
157
+ "aria-disabled": _ariaDisabled,
158
+ "aria-live": _ariaLive,
159
+ className,
160
+ "data-testid": _dataTestId,
161
+ ...nativeRest
162
+ } = rest;
163
+ if (as === "img" && src) {
164
+ const imageStyle = {
165
+ width,
166
+ height,
167
+ borderRadius,
168
+ position,
169
+ top,
170
+ bottom,
171
+ left,
172
+ right,
173
+ ...style
174
+ };
175
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
176
+ import_react_native.Image,
177
+ {
178
+ source: { uri: src },
179
+ style: imageStyle,
180
+ testID: finalTestID,
181
+ resizeMode: "cover",
182
+ ...nativeRest
183
+ }
184
+ );
185
+ }
186
+ if (onPress) {
187
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
188
+ import_react_native.Pressable,
189
+ {
190
+ onPress,
191
+ onLayout,
192
+ onMoveShouldSetResponder,
193
+ onResponderGrant,
194
+ onResponderMove,
195
+ onResponderRelease,
196
+ onResponderTerminate,
197
+ style: ({ pressed }) => getContainerStyle(pressed),
198
+ testID: finalTestID,
199
+ ...nativeRest,
200
+ children
201
+ }
202
+ );
203
+ }
204
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
205
+ import_react_native.View,
206
+ {
207
+ style: getContainerStyle(),
208
+ testID: finalTestID,
209
+ onLayout,
210
+ onMoveShouldSetResponder,
211
+ onResponderGrant,
212
+ onResponderMove,
213
+ onResponderRelease,
214
+ onResponderTerminate,
215
+ ...nativeRest,
216
+ children
217
+ }
218
+ );
219
+ };
220
+
221
+ // src/ContextMenu.tsx
222
+ var import_xui_core3 = require("@xsolla/xui-core");
47
223
  var import_xui_spinner = require("@xsolla/xui-spinner");
48
224
 
49
225
  // src/ContextMenuContext.tsx
@@ -71,7 +247,7 @@ var import_xui_typography = require("@xsolla/xui-typography");
71
247
  var import_xui_checkbox = require("@xsolla/xui-checkbox");
72
248
  var import_xui_radio = require("@xsolla/xui-radio");
73
249
  var import_xui_icons_base = require("@xsolla/xui-icons-base");
74
- var import_jsx_runtime = require("react/jsx-runtime");
250
+ var import_jsx_runtime2 = require("react/jsx-runtime");
75
251
  var sizeToVariants = {
76
252
  xl: { label: "bodyLg", description: "bodyLg", headingAccent: "bodyLgAccent" },
77
253
  lg: { label: "bodyLg", description: "bodyMd", headingAccent: "bodyMdAccent" },
@@ -81,18 +257,51 @@ var sizeToVariants = {
81
257
  var sizeLabelOverride = {
82
258
  xl: { fontSize: 20, lineHeight: "26px" }
83
259
  };
260
+ var SUBMENU_GAP = 8;
261
+ var SUBMENU_VIEWPORT_PADDING = 8;
262
+ var clipsOverflow = (style) => {
263
+ return /(auto|scroll|hidden|clip)/.test(
264
+ `${style.overflow}${style.overflowX}${style.overflowY}`
265
+ );
266
+ };
267
+ var getSubmenuBoundary = (node) => {
268
+ const viewport = {
269
+ top: SUBMENU_VIEWPORT_PADDING,
270
+ right: window.innerWidth - SUBMENU_VIEWPORT_PADDING,
271
+ bottom: window.innerHeight - SUBMENU_VIEWPORT_PADDING,
272
+ left: SUBMENU_VIEWPORT_PADDING
273
+ };
274
+ let current = node.parentElement;
275
+ while (current && current !== document.body) {
276
+ const style = window.getComputedStyle(current);
277
+ if (clipsOverflow(style) && current.getAttribute("role") !== "menu") {
278
+ const rect = current.getBoundingClientRect();
279
+ return {
280
+ top: Math.max(viewport.top, rect.top + SUBMENU_VIEWPORT_PADDING),
281
+ right: Math.min(viewport.right, rect.right - SUBMENU_VIEWPORT_PADDING),
282
+ bottom: Math.min(
283
+ viewport.bottom,
284
+ rect.bottom - SUBMENU_VIEWPORT_PADDING
285
+ ),
286
+ left: Math.max(viewport.left, rect.left + SUBMENU_VIEWPORT_PADDING)
287
+ };
288
+ }
289
+ current = current.parentElement;
290
+ }
291
+ return viewport;
292
+ };
84
293
  var ContextMenuItem = (props) => {
85
- if (props.type === "option") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(OptionCell, { ...props });
86
- if (props.type === "heading") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(HeadingCell, { ...props });
87
- if (props.type === "divider") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DividerCell, { ...props });
88
- if (props.type === "search") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SearchCell, { ...props });
294
+ if (props.type === "option") return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(OptionCell, { ...props });
295
+ if (props.type === "heading") return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(HeadingCell, { ...props });
296
+ if (props.type === "divider") return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(DividerCell, { ...props });
297
+ if (props.type === "search") return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(SearchCell, { ...props });
89
298
  return null;
90
299
  };
91
300
  ContextMenuItem.displayName = "ContextMenuItem";
92
301
  var SubmenuChevron = ({
93
302
  color,
94
303
  size
95
- }) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
304
+ }) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
96
305
  "span",
97
306
  {
98
307
  "data-testid": "ctxmenu-submenu-chevron",
@@ -104,7 +313,7 @@ var SubmenuChevron = ({
104
313
  width: size,
105
314
  height: size
106
315
  },
107
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
316
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
108
317
  "svg",
109
318
  {
110
319
  width: size,
@@ -112,7 +321,7 @@ var SubmenuChevron = ({
112
321
  viewBox: "0 0 24 24",
113
322
  fill: "none",
114
323
  xmlns: "http://www.w3.org/2000/svg",
115
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
324
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
116
325
  "path",
117
326
  {
118
327
  d: "M17.0605 11.6464C17.2558 11.8417 17.2558 12.1583 17.0605 12.3536L9.70703 19.707L8.29297 18.293L14.5859 12L8.29297 5.70703L9.70703 4.29297L17.0605 11.6464Z",
@@ -134,6 +343,7 @@ var OptionCell = ({
134
343
  leadingIcon,
135
344
  status,
136
345
  iconWrapper,
346
+ slot,
137
347
  slotContent,
138
348
  value,
139
349
  hint,
@@ -142,12 +352,17 @@ var OptionCell = ({
142
352
  hasSubmenu,
143
353
  submenu,
144
354
  onSelect,
355
+ onCheckedChange,
145
356
  testID,
146
357
  themeMode,
147
358
  themeProductContext,
148
359
  "data-testid": testId
149
360
  }) => {
150
- const { theme } = (0, import_xui_core.useResolvedTheme)({ themeMode, themeProductContext });
361
+ const { theme: rawTheme } = (0, import_xui_core.useResolvedTheme)({
362
+ themeMode,
363
+ themeProductContext
364
+ });
365
+ const theme = rawTheme;
151
366
  const ctx = useContextMenu();
152
367
  const size = propSize ?? ctx?.size ?? "md";
153
368
  const sizing = theme.sizing.contextMenu(size);
@@ -204,7 +419,32 @@ var OptionCell = ({
204
419
  const node = optionRef.current;
205
420
  if (!node) return;
206
421
  const rect = node.getBoundingClientRect();
207
- setSubmenuPos({ top: rect.top, left: rect.right });
422
+ const submenuRect = submenuWrapperRef.current?.getBoundingClientRect();
423
+ const submenuWidth = submenuRect?.width ?? 0;
424
+ const submenuHeight = submenuRect?.height ?? 0;
425
+ const boundary = getSubmenuBoundary(node);
426
+ const rightSideLeft = rect.right + SUBMENU_GAP;
427
+ const leftSideLeft = rect.left - SUBMENU_GAP - submenuWidth;
428
+ const opensLeft = submenuWidth > 0 && rightSideLeft + submenuWidth > boundary.right;
429
+ let left = opensLeft ? leftSideLeft : rightSideLeft;
430
+ if (submenuWidth > 0) {
431
+ if (opensLeft) {
432
+ left = Math.max(SUBMENU_VIEWPORT_PADDING, left);
433
+ } else {
434
+ left = Math.min(
435
+ Math.max(boundary.left, left),
436
+ Math.max(boundary.left, boundary.right - submenuWidth)
437
+ );
438
+ }
439
+ }
440
+ let top = rect.top;
441
+ if (submenuHeight > 0 && top + submenuHeight > boundary.bottom) {
442
+ top = boundary.bottom - submenuHeight;
443
+ }
444
+ top = Math.max(boundary.top, top);
445
+ setSubmenuPos(
446
+ (prev) => prev?.top === top && prev.left === left ? prev : { top, left }
447
+ );
208
448
  };
209
449
  update();
210
450
  window.addEventListener("scroll", update, true);
@@ -213,7 +453,7 @@ var OptionCell = ({
213
453
  window.removeEventListener("scroll", update, true);
214
454
  window.removeEventListener("resize", update);
215
455
  };
216
- }, [hasSubmenu, submenuOpen]);
456
+ });
217
457
  const onSelectRef = import_react2.default.useRef(onSelect);
218
458
  onSelectRef.current = onSelect;
219
459
  (0, import_react2.useEffect)(() => {
@@ -247,7 +487,7 @@ var OptionCell = ({
247
487
  };
248
488
  const labelColor = disabled ? theme.colors.control.input.textDisable : destructive ? theme.colors.content.alert.primary : theme.colors.content.primary;
249
489
  const bg = inHoverState ? theme.colors.control.input.bgHover : "transparent";
250
- const role = !hasSubmenu && checked !== void 0 ? "menuitemcheckbox" : "menuitem";
490
+ const role = !hasSubmenu && checked !== void 0 ? leadingControl === "radio" ? "menuitemradio" : "menuitemcheckbox" : "menuitem";
251
491
  const ariaChecked = !hasSubmenu && checked !== void 0 ? checked ? "true" : "false" : void 0;
252
492
  const handleClick = () => {
253
493
  if (disabled) return;
@@ -255,6 +495,9 @@ var OptionCell = ({
255
495
  setSubmenuOpen(true);
256
496
  return;
257
497
  }
498
+ if (checked !== void 0) {
499
+ onCheckedChange?.(!checked);
500
+ }
258
501
  onSelect?.();
259
502
  };
260
503
  const closeSubmenuAndFocus = () => {
@@ -284,10 +527,13 @@ var OptionCell = ({
284
527
  }
285
528
  if (e.key === "Enter" || e.key === " ") {
286
529
  e.preventDefault();
530
+ if (checked !== void 0) {
531
+ onCheckedChange?.(!checked);
532
+ }
287
533
  onSelect?.();
288
534
  }
289
535
  };
290
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
536
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
291
537
  "div",
292
538
  {
293
539
  ref: optionRef,
@@ -327,13 +573,13 @@ var OptionCell = ({
327
573
  outline: "none"
328
574
  },
329
575
  children: [
330
- leadingControl === "checkbox" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
576
+ leadingControl === "checkbox" && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
331
577
  "span",
332
578
  {
333
579
  "data-testid": "ctxmenu-leading-checkbox",
334
580
  "aria-hidden": "true",
335
581
  style: { pointerEvents: "none", display: "inline-flex" },
336
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
582
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
337
583
  import_xui_checkbox.Checkbox,
338
584
  {
339
585
  size,
@@ -345,13 +591,13 @@ var OptionCell = ({
345
591
  )
346
592
  }
347
593
  ),
348
- leadingControl === "radio" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
594
+ leadingControl === "radio" && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
349
595
  "span",
350
596
  {
351
597
  "data-testid": "ctxmenu-leading-radio",
352
598
  "aria-hidden": "true",
353
599
  style: { pointerEvents: "none", display: "inline-flex" },
354
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
600
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
355
601
  import_xui_radio.Radio,
356
602
  {
357
603
  size,
@@ -366,8 +612,9 @@ var OptionCell = ({
366
612
  leadingIcon,
367
613
  status,
368
614
  iconWrapper,
615
+ slot,
369
616
  slotContent,
370
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
617
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
371
618
  "span",
372
619
  {
373
620
  style: {
@@ -378,7 +625,7 @@ var OptionCell = ({
378
625
  minWidth: 0
379
626
  },
380
627
  children: [
381
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
628
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
382
629
  import_xui_typography.Typography,
383
630
  {
384
631
  variant: variants.label,
@@ -391,7 +638,7 @@ var OptionCell = ({
391
638
  children: label
392
639
  }
393
640
  ),
394
- description !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
641
+ description !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
395
642
  import_xui_typography.Typography,
396
643
  {
397
644
  variant: variants.description,
@@ -402,7 +649,7 @@ var OptionCell = ({
402
649
  ]
403
650
  }
404
651
  ),
405
- (value !== void 0 || hint !== void 0) && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
652
+ (value !== void 0 || hint !== void 0) && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
406
653
  "span",
407
654
  {
408
655
  style: {
@@ -411,7 +658,7 @@ var OptionCell = ({
411
658
  alignItems: "flex-end"
412
659
  },
413
660
  children: [
414
- value !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
661
+ value !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
415
662
  import_xui_typography.Typography,
416
663
  {
417
664
  variant: variants.label,
@@ -420,7 +667,7 @@ var OptionCell = ({
420
667
  children: value
421
668
  }
422
669
  ),
423
- hint !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
670
+ hint !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
424
671
  import_xui_typography.Typography,
425
672
  {
426
673
  variant: variants.description,
@@ -431,7 +678,7 @@ var OptionCell = ({
431
678
  ]
432
679
  }
433
680
  ),
434
- keyboardShortcut && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
681
+ keyboardShortcut && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
435
682
  import_xui_typography.Typography,
436
683
  {
437
684
  as: "kbd",
@@ -440,7 +687,7 @@ var OptionCell = ({
440
687
  children: keyboardShortcut
441
688
  }
442
689
  ),
443
- hasSubmenu && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
690
+ hasSubmenu && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
444
691
  SubmenuChevron,
445
692
  {
446
693
  color: theme.colors.content.tertiary,
@@ -449,7 +696,7 @@ var OptionCell = ({
449
696
  ),
450
697
  trailingIcon,
451
698
  hasSubmenu && submenuOpen && submenu && submenuPos && typeof document !== "undefined" && (0, import_react_dom.createPortal)(
452
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
699
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
453
700
  "div",
454
701
  {
455
702
  ref: submenuWrapperRef,
@@ -480,7 +727,11 @@ var HeadingCell = ({
480
727
  themeProductContext,
481
728
  "data-testid": testId
482
729
  }) => {
483
- const { theme } = (0, import_xui_core.useResolvedTheme)({ themeMode, themeProductContext });
730
+ const { theme: rawTheme } = (0, import_xui_core.useResolvedTheme)({
731
+ themeMode,
732
+ themeProductContext
733
+ });
734
+ const theme = rawTheme;
484
735
  const ctx = useContextMenu();
485
736
  const size = propSize ?? ctx?.size ?? "md";
486
737
  const sizing = theme.sizing.contextMenu(size);
@@ -493,7 +744,7 @@ var HeadingCell = ({
493
744
  registerCell(id, { type: "heading" });
494
745
  return () => unregisterCell(id);
495
746
  }, [registerCell, unregisterCell, id]);
496
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
747
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
497
748
  "div",
498
749
  {
499
750
  role: "presentation",
@@ -508,7 +759,7 @@ var HeadingCell = ({
508
759
  paddingBottom: sizing.itemPaddingVertical
509
760
  },
510
761
  children: [
511
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
762
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
512
763
  import_xui_typography.Typography,
513
764
  {
514
765
  variant: variants.headingAccent,
@@ -517,7 +768,7 @@ var HeadingCell = ({
517
768
  children: label
518
769
  }
519
770
  ),
520
- description !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
771
+ description !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
521
772
  import_xui_typography.Typography,
522
773
  {
523
774
  variant: variants.description,
@@ -532,6 +783,7 @@ var HeadingCell = ({
532
783
  var SearchCell = ({
533
784
  size: propSize,
534
785
  value,
786
+ onChange,
535
787
  onValueChange,
536
788
  placeholder = "Search",
537
789
  autoFocus,
@@ -541,7 +793,11 @@ var SearchCell = ({
541
793
  themeMode,
542
794
  themeProductContext
543
795
  }) => {
544
- const { theme } = (0, import_xui_core.useResolvedTheme)({ themeMode, themeProductContext });
796
+ const { theme: rawTheme } = (0, import_xui_core.useResolvedTheme)({
797
+ themeMode,
798
+ themeProductContext
799
+ });
800
+ const theme = rawTheme;
545
801
  const ctx = useContextMenu();
546
802
  const size = propSize ?? ctx?.size ?? "md";
547
803
  const sizing = theme.sizing.contextMenu(size);
@@ -553,7 +809,7 @@ var SearchCell = ({
553
809
  registerCell(id, { type: "search" });
554
810
  return () => unregisterCell(id);
555
811
  }, [registerCell, unregisterCell, id]);
556
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
812
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
557
813
  "div",
558
814
  {
559
815
  style: {
@@ -563,7 +819,7 @@ var SearchCell = ({
563
819
  paddingTop: sizing.searchPaddingVertical,
564
820
  paddingBottom: sizing.searchPaddingVertical
565
821
  },
566
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
822
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
567
823
  "div",
568
824
  {
569
825
  style: {
@@ -574,7 +830,7 @@ var SearchCell = ({
574
830
  borderBottom: `1px solid ${theme.colors.border.secondary}`
575
831
  },
576
832
  children: [
577
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
833
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
578
834
  import_xui_icons_base.Search,
579
835
  {
580
836
  variant: "line",
@@ -583,16 +839,19 @@ var SearchCell = ({
583
839
  "aria-hidden": true
584
840
  }
585
841
  ),
586
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
842
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
587
843
  "input",
588
844
  {
589
845
  type: "search",
590
846
  role: "searchbox",
591
847
  "aria-label": ariaLabel,
592
848
  placeholder,
593
- value,
849
+ value: value ?? "",
594
850
  autoFocus,
595
- onChange: (e) => onValueChange(e.target.value),
851
+ onChange: (e) => {
852
+ onChange?.(e);
853
+ onValueChange?.(e.target.value);
854
+ },
596
855
  "data-testid": testId || testID,
597
856
  style: {
598
857
  flex: 1,
@@ -615,7 +874,11 @@ var SearchCell = ({
615
874
  );
616
875
  };
617
876
  var DividerCell = ({ themeMode, themeProductContext, "data-testid": testId }) => {
618
- const { theme } = (0, import_xui_core.useResolvedTheme)({ themeMode, themeProductContext });
877
+ const { theme: rawTheme } = (0, import_xui_core.useResolvedTheme)({
878
+ themeMode,
879
+ themeProductContext
880
+ });
881
+ const theme = rawTheme;
619
882
  const ctx = useContextMenu();
620
883
  const id = (0, import_xui_core.useId)();
621
884
  const registerCell = ctx?.registerCell;
@@ -625,7 +888,7 @@ var DividerCell = ({ themeMode, themeProductContext, "data-testid": testId }) =>
625
888
  registerCell(id, { type: "divider" });
626
889
  return () => unregisterCell(id);
627
890
  }, [registerCell, unregisterCell, id]);
628
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
891
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
629
892
  "div",
630
893
  {
631
894
  role: "separator",
@@ -639,8 +902,150 @@ var DividerCell = ({ themeMode, themeProductContext, "data-testid": testId }) =>
639
902
  );
640
903
  };
641
904
 
642
- // src/hooks/useContextMenuPosition.ts
905
+ // src/ContextMenuSubmenu.tsx
643
906
  var import_react3 = require("react");
907
+ var import_xui_core2 = require("@xsolla/xui-core");
908
+ var import_jsx_runtime3 = require("react/jsx-runtime");
909
+ var SUBMENU_GAP2 = 4;
910
+ var OPEN_DELAY_MS = 200;
911
+ var CLOSE_GRACE_MS = 100;
912
+ var ContextMenuSubmenu = ({
913
+ label,
914
+ icon,
915
+ disabled,
916
+ children,
917
+ size: propSize,
918
+ "data-testid": testId = "context-menu-submenu"
919
+ }) => {
920
+ const { theme } = (0, import_xui_core2.useDesignSystem)();
921
+ const xuiTheme = theme;
922
+ const context = useContextMenu();
923
+ const size = propSize || context?.size || "md";
924
+ const sizeStyles = xuiTheme.sizing.contextMenu(size);
925
+ const borderRadius = xuiTheme.shape?.contextMenu?.[size]?.borderRadius ?? xuiTheme.radius?.button ?? 8;
926
+ const [isOpen, setIsOpen] = (0, import_react3.useState)(false);
927
+ const [visible, setVisible] = (0, import_react3.useState)(false);
928
+ const [openLeft, setOpenLeft] = (0, import_react3.useState)(false);
929
+ const [topOffset, setTopOffset] = (0, import_react3.useState)(0);
930
+ const triggerRef = (0, import_react3.useRef)(null);
931
+ const submenuRef = (0, import_react3.useRef)(null);
932
+ const openTimerRef = (0, import_react3.useRef)(null);
933
+ const closeTimerRef = (0, import_react3.useRef)(null);
934
+ const clearTimers = (0, import_react3.useCallback)(() => {
935
+ if (openTimerRef.current) clearTimeout(openTimerRef.current);
936
+ if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
937
+ }, []);
938
+ const calculatePlacement = (0, import_react3.useCallback)(() => {
939
+ if (!triggerRef.current) return;
940
+ const triggerRect = triggerRef.current.getBoundingClientRect();
941
+ const estimatedWidth = sizeStyles.minWidth + 32;
942
+ const wouldOverflowRight = triggerRect.right + estimatedWidth + SUBMENU_GAP2 > window.innerWidth - 8;
943
+ setOpenLeft(wouldOverflowRight);
944
+ setTopOffset(0);
945
+ }, [sizeStyles.minWidth]);
946
+ (0, import_react3.useLayoutEffect)(() => {
947
+ if (!isOpen || !submenuRef.current || !triggerRef.current) return;
948
+ const submenuRect = submenuRef.current.getBoundingClientRect();
949
+ const triggerRect = triggerRef.current.getBoundingClientRect();
950
+ const wouldOverflowRight = triggerRect.right + submenuRect.width + SUBMENU_GAP2 > window.innerWidth - 8;
951
+ setOpenLeft(wouldOverflowRight);
952
+ const overflowBottom = triggerRect.top + submenuRect.height - (window.innerHeight - 8);
953
+ if (overflowBottom > 0) {
954
+ setTopOffset(-Math.min(overflowBottom, triggerRect.top - 8));
955
+ } else {
956
+ setTopOffset(0);
957
+ }
958
+ }, [isOpen]);
959
+ (0, import_react3.useEffect)(() => {
960
+ if (!isOpen) {
961
+ setVisible(false);
962
+ return;
963
+ }
964
+ const raf = requestAnimationFrame(() => setVisible(true));
965
+ return () => cancelAnimationFrame(raf);
966
+ }, [isOpen]);
967
+ (0, import_react3.useEffect)(() => () => clearTimers(), [clearTimers]);
968
+ const handleTriggerEnter = () => {
969
+ if (disabled) return;
970
+ clearTimers();
971
+ openTimerRef.current = setTimeout(() => {
972
+ calculatePlacement();
973
+ setIsOpen(true);
974
+ }, OPEN_DELAY_MS);
975
+ };
976
+ const handleTriggerLeave = () => {
977
+ clearTimers();
978
+ closeTimerRef.current = setTimeout(() => setIsOpen(false), CLOSE_GRACE_MS);
979
+ };
980
+ const handleSubmenuEnter = () => clearTimers();
981
+ const handleSubmenuLeave = () => {
982
+ clearTimers();
983
+ closeTimerRef.current = setTimeout(() => setIsOpen(false), CLOSE_GRACE_MS);
984
+ };
985
+ const submenuPositionStyle = openLeft ? { right: `calc(100% + ${SUBMENU_GAP2}px)` } : { left: `calc(100% + ${SUBMENU_GAP2}px)` };
986
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
987
+ "div",
988
+ {
989
+ ref: triggerRef,
990
+ style: { position: "relative" },
991
+ onMouseEnter: handleTriggerEnter,
992
+ onMouseLeave: handleTriggerLeave,
993
+ "data-testid": testId,
994
+ children: [
995
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
996
+ ContextMenuItem,
997
+ {
998
+ type: "option",
999
+ label,
1000
+ leadingIcon: icon,
1001
+ disabled,
1002
+ hasSubmenu: true,
1003
+ size,
1004
+ "data-testid": `${testId}-trigger`
1005
+ }
1006
+ ),
1007
+ isOpen && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1008
+ "div",
1009
+ {
1010
+ ref: submenuRef,
1011
+ onMouseEnter: handleSubmenuEnter,
1012
+ onMouseLeave: handleSubmenuLeave,
1013
+ style: {
1014
+ position: "absolute",
1015
+ top: topOffset,
1016
+ ...submenuPositionStyle,
1017
+ zIndex: 1001,
1018
+ opacity: visible ? 1 : 0,
1019
+ transform: visible ? "translateX(0)" : openLeft ? "translateX(4px)" : "translateX(-4px)",
1020
+ transition: "opacity 100ms ease, transform 100ms ease"
1021
+ },
1022
+ "data-testid": `${testId}-content`,
1023
+ children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1024
+ Box,
1025
+ {
1026
+ role: "menu",
1027
+ backgroundColor: xuiTheme.colors.background.secondary,
1028
+ borderColor: xuiTheme.colors.border.secondary,
1029
+ borderWidth: 1,
1030
+ borderRadius,
1031
+ paddingVertical: sizeStyles.paddingVertical,
1032
+ style: {
1033
+ boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
1034
+ minWidth: sizeStyles.minWidth
1035
+ },
1036
+ children
1037
+ }
1038
+ )
1039
+ }
1040
+ )
1041
+ ]
1042
+ }
1043
+ );
1044
+ };
1045
+ ContextMenuSubmenu.displayName = "ContextMenuSubmenu";
1046
+
1047
+ // src/hooks/useContextMenuPosition.ts
1048
+ var import_react4 = require("react");
644
1049
  var splitPlacement = (placement) => {
645
1050
  const [vertical, horizontal] = placement.split("-");
646
1051
  return { vertical, horizontal };
@@ -653,8 +1058,8 @@ var useContextMenuPosition = ({
653
1058
  placement = "bottom-start",
654
1059
  offset = 4
655
1060
  }) => {
656
- const [resolved, setResolved] = (0, import_react3.useState)();
657
- (0, import_react3.useEffect)(() => {
1061
+ const [resolved, setResolved] = (0, import_react4.useState)();
1062
+ (0, import_react4.useEffect)(() => {
658
1063
  if (!isOpen) {
659
1064
  setResolved(void 0);
660
1065
  return;
@@ -701,30 +1106,19 @@ var useContextMenuPosition = ({
701
1106
  (prev) => prev && prev.top === next.top && prev.left === next.left && prev.placement === next.placement ? prev : next
702
1107
  );
703
1108
  };
704
- let rafId = window.requestAnimationFrame(compute);
1109
+ const rafId = window.requestAnimationFrame(compute);
705
1110
  const onResize = () => compute();
706
- let scrollRafPending = false;
707
- const onScroll = () => {
708
- if (scrollRafPending) return;
709
- scrollRafPending = true;
710
- rafId = window.requestAnimationFrame(() => {
711
- scrollRafPending = false;
712
- compute();
713
- });
714
- };
715
1111
  window.addEventListener("resize", onResize);
716
- window.addEventListener("scroll", onScroll, true);
717
1112
  return () => {
718
1113
  window.cancelAnimationFrame(rafId);
719
1114
  window.removeEventListener("resize", onResize);
720
- window.removeEventListener("scroll", onScroll, true);
721
1115
  };
722
1116
  }, [isOpen, placement, offset, triggerRef, panelRef]);
723
1117
  return resolved;
724
1118
  };
725
1119
 
726
1120
  // src/hooks/useKeyboardNavigation.ts
727
- var import_react4 = require("react");
1121
+ var import_react5 = require("react");
728
1122
  var isNavigableOption = (meta) => meta.type === "option" && !meta.disabled;
729
1123
  var isTextInputTarget = (target) => {
730
1124
  if (!(target instanceof HTMLElement)) return false;
@@ -739,19 +1133,19 @@ var useKeyboardNavigation = ({
739
1133
  onClose,
740
1134
  triggerRef
741
1135
  }) => {
742
- const findFirstOption = (0, import_react4.useCallback)(() => {
1136
+ const findFirstOption = (0, import_react5.useCallback)(() => {
743
1137
  for (let i = 0; i < cells.length; i += 1) {
744
1138
  if (isNavigableOption(cells[i].meta)) return i;
745
1139
  }
746
1140
  return -1;
747
1141
  }, [cells]);
748
- const findLastOption = (0, import_react4.useCallback)(() => {
1142
+ const findLastOption = (0, import_react5.useCallback)(() => {
749
1143
  for (let i = cells.length - 1; i >= 0; i -= 1) {
750
1144
  if (isNavigableOption(cells[i].meta)) return i;
751
1145
  }
752
1146
  return -1;
753
1147
  }, [cells]);
754
- const findNextOption = (0, import_react4.useCallback)(
1148
+ const findNextOption = (0, import_react5.useCallback)(
755
1149
  (from) => {
756
1150
  const len = cells.length;
757
1151
  if (len === 0) return -1;
@@ -763,7 +1157,7 @@ var useKeyboardNavigation = ({
763
1157
  },
764
1158
  [cells]
765
1159
  );
766
- const findPrevOption = (0, import_react4.useCallback)(
1160
+ const findPrevOption = (0, import_react5.useCallback)(
767
1161
  (from) => {
768
1162
  const len = cells.length;
769
1163
  if (len === 0) return -1;
@@ -775,7 +1169,7 @@ var useKeyboardNavigation = ({
775
1169
  },
776
1170
  [cells]
777
1171
  );
778
- const handleKeyDown = (0, import_react4.useCallback)(
1172
+ const handleKeyDown = (0, import_react5.useCallback)(
779
1173
  (event) => {
780
1174
  if (!isOpen) return;
781
1175
  switch (event.key) {
@@ -845,459 +1239,383 @@ var useKeyboardNavigation = ({
845
1239
  };
846
1240
 
847
1241
  // src/ContextMenu.tsx
848
- var import_jsx_runtime2 = require("react/jsx-runtime");
1242
+ var import_jsx_runtime4 = require("react/jsx-runtime");
1243
+ var DEFAULT_EMPTY_MESSAGE = "No results";
849
1244
  var SEARCH_DEBOUNCE_MS = 200;
850
- var EmptyMessage = ({
851
- children,
852
- color
853
- }) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
854
- "div",
855
- {
856
- style: {
857
- padding: 12,
858
- color,
859
- fontSize: 14,
860
- textAlign: "center"
861
- },
862
- children
1245
+ var textFromNode = (node) => {
1246
+ if (node === null || node === void 0 || typeof node === "boolean")
1247
+ return "";
1248
+ if (typeof node === "string" || typeof node === "number") return String(node);
1249
+ if (Array.isArray(node)) return node.map(textFromNode).join(" ");
1250
+ if ((0, import_react6.isValidElement)(node)) return textFromNode(node.props.children);
1251
+ return "";
1252
+ };
1253
+ var filterItems = (items, query) => {
1254
+ const normalized = query.trim().toLowerCase();
1255
+ if (!normalized) return [...items];
1256
+ const result = [];
1257
+ let pendingStructural = [];
1258
+ for (const item of items) {
1259
+ if (item.type === "option") {
1260
+ const label = textFromNode(item.label).toLowerCase();
1261
+ if (label.includes(normalized)) {
1262
+ result.push(...pendingStructural, item);
1263
+ pendingStructural = [];
1264
+ }
1265
+ continue;
1266
+ }
1267
+ if (item.type === "heading") {
1268
+ pendingStructural = [item];
1269
+ continue;
1270
+ }
1271
+ if (item.type === "divider") {
1272
+ if (result.length > 0) pendingStructural.push(item);
1273
+ continue;
1274
+ }
863
1275
  }
864
- );
865
- var ContextMenu = (props) => {
866
- const {
867
- type,
868
- items,
1276
+ return result;
1277
+ };
1278
+ var isOption = (item) => item.type === "option";
1279
+ var ContextMenuRoot = (0, import_react6.forwardRef)(
1280
+ ({
869
1281
  children,
1282
+ type = "list",
1283
+ items = [],
870
1284
  size = "md",
871
1285
  searchable,
872
1286
  loading,
873
- emptyMessage,
1287
+ isLoading,
1288
+ emptyMessage = DEFAULT_EMPTY_MESSAGE,
874
1289
  empty,
875
- trigger,
876
- isOpen,
1290
+ isOpen: propIsOpen,
877
1291
  onOpenChange,
878
- closeOnSelect,
879
- width,
880
- maxHeight,
1292
+ trigger,
881
1293
  placement = "bottom-start",
1294
+ position,
1295
+ width,
1296
+ maxHeight = 300,
882
1297
  onSelect,
1298
+ closeOnSelect,
883
1299
  "aria-label": ariaLabel,
884
- "data-testid": testId,
885
1300
  testID,
1301
+ "data-testid": dataTestId,
886
1302
  themeMode,
887
- themeProductContext
888
- } = props;
889
- const { theme } = (0, import_xui_core2.useResolvedTheme)({ themeMode, themeProductContext });
890
- const isControlled = isOpen !== void 0;
891
- const [internalOpen, setInternalOpen] = (0, import_react5.useState)(false);
892
- const open = isControlled ? !!isOpen : internalOpen;
893
- const setOpen = (0, import_react5.useCallback)(
894
- (next) => {
895
- if (!isControlled) setInternalOpen(next);
896
- onOpenChange?.(next);
897
- },
898
- [isControlled, onOpenChange]
899
- );
900
- const [activeIndex, setActiveIndex] = (0, import_react5.useState)(-1);
901
- const cellsRef = (0, import_react5.useRef)([]);
902
- const [cellsVersion, setCellsVersion] = (0, import_react5.useState)(0);
903
- const triggerRef = (0, import_react5.useRef)(null);
904
- const panelRef = (0, import_react5.useRef)(null);
905
- const menuId = (0, import_xui_core2.useId)();
906
- const [query, setQuery] = (0, import_react5.useState)("");
907
- const [debouncedQuery, setDebouncedQuery] = (0, import_react5.useState)("");
908
- const debounceTimerRef = (0, import_react5.useRef)(null);
909
- (0, import_react5.useEffect)(() => {
910
- if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
911
- debounceTimerRef.current = setTimeout(() => {
912
- setDebouncedQuery(query);
913
- }, SEARCH_DEBOUNCE_MS);
914
- return () => {
915
- if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
916
- };
917
- }, [query]);
918
- const closeMenu = (0, import_react5.useCallback)(() => {
919
- setOpen(false);
920
- setActiveIndex(-1);
921
- }, [setOpen]);
922
- const registerCell = (0, import_react5.useCallback)((id, meta) => {
923
- const existing = cellsRef.current.findIndex((c) => c.id === id);
924
- if (existing === -1) {
1303
+ themeProductContext,
1304
+ style
1305
+ }, ref) => {
1306
+ const { theme } = (0, import_xui_core3.useDesignSystem)();
1307
+ const xuiTheme = theme;
1308
+ const menuId = (0, import_xui_core3.useId)();
1309
+ const [internalIsOpen, setInternalIsOpen] = (0, import_react6.useState)(false);
1310
+ const [activeIndex, setActiveIndex] = (0, import_react6.useState)(-1);
1311
+ const [cellsVersion, setCellsVersion] = (0, import_react6.useState)(0);
1312
+ const [query, setQuery] = (0, import_react6.useState)("");
1313
+ const [debouncedQuery, setDebouncedQuery] = (0, import_react6.useState)("");
1314
+ const containerRef = (0, import_react6.useRef)(null);
1315
+ const triggerRef = (0, import_react6.useRef)(null);
1316
+ const panelRef = (0, import_react6.useRef)(null);
1317
+ const cellsRef = (0, import_react6.useRef)([]);
1318
+ const isOpen = propIsOpen !== void 0 ? propIsOpen : internalIsOpen;
1319
+ const sizeStyles = xuiTheme.sizing.contextMenu(size);
1320
+ const borderRadius = xuiTheme.shape?.contextMenu?.[size]?.borderRadius ?? xuiTheme.radius?.button ?? 8;
1321
+ const shouldCloseOnSelect = closeOnSelect ?? (type === "checkbox" ? false : true);
1322
+ const positioned = useContextMenuPosition({
1323
+ triggerRef,
1324
+ panelRef,
1325
+ isOpen: isOpen && !!trigger && !position,
1326
+ placement
1327
+ });
1328
+ const setOpen = (0, import_react6.useCallback)(
1329
+ (nextOpen) => {
1330
+ if (propIsOpen === void 0) setInternalIsOpen(nextOpen);
1331
+ onOpenChange?.(nextOpen);
1332
+ if (!nextOpen) setActiveIndex(-1);
1333
+ },
1334
+ [propIsOpen, onOpenChange]
1335
+ );
1336
+ const closeMenu = (0, import_react6.useCallback)(() => {
1337
+ setOpen(false);
1338
+ }, [setOpen]);
1339
+ const toggleMenu = (0, import_react6.useCallback)(() => {
1340
+ setOpen(!isOpen);
1341
+ }, [isOpen, setOpen]);
1342
+ const registerCell = (0, import_react6.useCallback)((id, meta) => {
1343
+ const existingIndex = cellsRef.current.findIndex(
1344
+ (cell) => cell.id === id
1345
+ );
1346
+ if (existingIndex >= 0) {
1347
+ cellsRef.current[existingIndex] = { id, meta };
1348
+ setCellsVersion((version) => version + 1);
1349
+ return existingIndex;
1350
+ }
925
1351
  cellsRef.current.push({ id, meta });
926
- setCellsVersion((v) => v + 1);
1352
+ setCellsVersion((version) => version + 1);
927
1353
  return cellsRef.current.length - 1;
928
- }
929
- const prev = cellsRef.current[existing].meta;
930
- cellsRef.current[existing] = { id, meta };
931
- if (prev.disabled !== meta.disabled || prev.type !== meta.type) {
932
- setCellsVersion((v) => v + 1);
933
- }
934
- return existing;
935
- }, []);
936
- const unregisterCell = (0, import_react5.useCallback)((id) => {
937
- const idx = cellsRef.current.findIndex((c) => c.id === id);
938
- if (idx !== -1) {
939
- cellsRef.current.splice(idx, 1);
940
- setCellsVersion((v) => v + 1);
941
- }
942
- }, []);
943
- const getCellIndex = (0, import_react5.useCallback)(
944
- (id) => cellsRef.current.findIndex((c) => c.id === id),
945
- []
946
- );
947
- const ctx = (0, import_react5.useMemo)(
948
- () => ({
949
- size,
950
- menuId,
951
- closeMenu,
952
- registerCell,
953
- unregisterCell,
954
- getCellIndex,
955
- cellsVersion,
1354
+ }, []);
1355
+ const unregisterCell = (0, import_react6.useCallback)((id) => {
1356
+ const index = cellsRef.current.findIndex((cell) => cell.id === id);
1357
+ if (index >= 0) {
1358
+ cellsRef.current.splice(index, 1);
1359
+ setCellsVersion((version) => version + 1);
1360
+ }
1361
+ }, []);
1362
+ const getCellIndex = (0, import_react6.useCallback)((id) => {
1363
+ return cellsRef.current.findIndex((cell) => cell.id === id);
1364
+ }, []);
1365
+ const cells = (0, import_react6.useMemo)(() => [...cellsRef.current], [cellsVersion]);
1366
+ const keyboard = useKeyboardNavigation({
1367
+ isOpen,
1368
+ cells,
956
1369
  activeIndex,
957
1370
  setActiveIndex,
958
- query,
959
- setQuery
960
- }),
961
- [
962
- size,
963
- menuId,
964
- closeMenu,
965
- registerCell,
966
- unregisterCell,
967
- getCellIndex,
968
- cellsVersion,
969
- activeIndex,
970
- query
971
- ]
972
- );
973
- const triggerNode = (0, import_react5.useMemo)(() => {
974
- if (!trigger) return null;
975
- const inner = (0, import_react5.isValidElement)(trigger) ? (0, import_react5.cloneElement)(
976
- trigger,
977
- {
978
- "aria-haspopup": "menu",
979
- "aria-expanded": open ? "true" : "false"
980
- }
981
- ) : trigger;
982
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
983
- "span",
984
- {
985
- ref: (node) => {
986
- if (!node) {
987
- triggerRef.current = null;
988
- return;
989
- }
990
- const focusable = node.querySelector(
991
- "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])"
992
- );
993
- triggerRef.current = focusable ?? node;
994
- },
995
- onClick: () => setOpen(!open),
996
- style: { display: "inline-flex" },
997
- children: inner
998
- }
999
- );
1000
- }, [trigger, open, setOpen]);
1001
- const usePortal = !!trigger && typeof document !== "undefined";
1002
- const position = useContextMenuPosition({
1003
- triggerRef,
1004
- panelRef,
1005
- isOpen: open && usePortal,
1006
- placement
1007
- });
1008
- const cellsForNav = (0, import_react5.useMemo)(
1009
- () => cellsRef.current.map((c) => ({ id: c.id, meta: c.meta })),
1010
- // eslint-disable-next-line react-hooks/exhaustive-deps
1011
- [cellsVersion]
1012
- );
1013
- const { handleKeyDown } = useKeyboardNavigation({
1014
- isOpen: open,
1015
- cells: cellsForNav,
1016
- activeIndex,
1017
- setActiveIndex,
1018
- onClose: closeMenu,
1019
- triggerRef
1020
- });
1021
- const sizingFn = theme.sizing.contextMenu;
1022
- const sizing = sizingFn ? sizingFn(size) : {};
1023
- const radiusObj = theme.radius;
1024
- const radiusVal = sizing.borderRadius ?? radiusObj?.contextMenu ?? 8;
1025
- const shadowObj = theme.shadow;
1026
- const shadowVal = shadowObj?.popover ?? "";
1027
- const panelPaddingVertical = sizing.paddingVertical ?? 8;
1028
- const glassBackground = theme.colors.layer?.float ?? theme.colors.background.primary;
1029
- const panelStyle = {
1030
- background: glassBackground,
1031
- backdropFilter: "blur(12px)",
1032
- WebkitBackdropFilter: "blur(12px)",
1033
- border: `1px solid ${theme.colors.border.secondary}`,
1034
- borderRadius: radiusVal,
1035
- boxShadow: shadowVal,
1036
- width: width ?? sizing.panelWidth,
1037
- maxHeight,
1038
- overflow: "hidden",
1039
- display: open ? "flex" : "none",
1040
- flexDirection: "column",
1041
- outline: "none",
1042
- fontFamily: theme.fonts.body,
1043
- paddingTop: panelPaddingVertical,
1044
- paddingBottom: panelPaddingVertical
1045
- };
1046
- if (usePortal) {
1047
- panelStyle.position = "fixed";
1048
- panelStyle.top = position?.top ?? 0;
1049
- panelStyle.left = position?.left ?? 0;
1050
- }
1051
- const filteredItems = (0, import_react5.useMemo)(() => {
1052
- if (!items) return void 0;
1053
- if (!searchable || !debouncedQuery) return items.slice();
1054
- const q = debouncedQuery.toLowerCase();
1055
- const matchedFlags = items.map((item) => {
1056
- if (item.type === "option") {
1057
- return String(item.label ?? "").toLowerCase().includes(q);
1058
- }
1059
- return false;
1371
+ onClose: closeMenu,
1372
+ triggerRef
1060
1373
  });
1061
- const result = [];
1062
- let pendingHeading = null;
1063
- let lastEmittedWasContent = false;
1064
- let groupHasOption = false;
1065
- for (let i = 0; i < items.length; i += 1) {
1066
- const item = items[i];
1067
- if (item.type === "heading") {
1068
- pendingHeading = { item, idx: i };
1069
- groupHasOption = false;
1070
- } else if (item.type === "divider") {
1071
- if (lastEmittedWasContent) {
1072
- result.push(item);
1073
- lastEmittedWasContent = false;
1074
- }
1075
- pendingHeading = null;
1076
- groupHasOption = false;
1077
- } else if (item.type === "option") {
1078
- if (matchedFlags[i]) {
1079
- if (pendingHeading) {
1080
- result.push(pendingHeading.item);
1081
- pendingHeading = null;
1082
- }
1083
- result.push(item);
1084
- lastEmittedWasContent = true;
1085
- groupHasOption = true;
1086
- }
1374
+ (0, import_react6.useEffect)(() => {
1375
+ if (!isOpen) {
1376
+ cellsRef.current = [];
1377
+ setCellsVersion((version) => version + 1);
1378
+ setQuery("");
1379
+ setDebouncedQuery("");
1087
1380
  }
1088
- }
1089
- while (result.length > 0 && result[result.length - 1].type === "divider") {
1090
- result.pop();
1091
- }
1092
- void groupHasOption;
1093
- return result;
1094
- }, [items, searchable, debouncedQuery]);
1095
- const effectiveCloseOnSelect = closeOnSelect !== void 0 ? closeOnSelect : type !== "checkbox";
1096
- const renderPresetItem = (item, key) => {
1097
- if (item.type === "heading") {
1098
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1099
- ContextMenuItem,
1100
- {
1101
- ...item,
1102
- size: item.size ?? size
1103
- },
1104
- `h-${key}`
1381
+ }, [isOpen]);
1382
+ (0, import_react6.useEffect)(() => {
1383
+ const timer = setTimeout(
1384
+ () => setDebouncedQuery(query),
1385
+ SEARCH_DEBOUNCE_MS
1105
1386
  );
1106
- }
1107
- if (item.type === "divider") {
1108
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1387
+ return () => clearTimeout(timer);
1388
+ }, [query]);
1389
+ (0, import_react6.useEffect)(() => {
1390
+ if (!isOpen || !trigger) return;
1391
+ const onMouseDown = (event) => {
1392
+ const target = event.target;
1393
+ if (!target || containerRef.current?.contains(target)) return;
1394
+ closeMenu();
1395
+ };
1396
+ const onScroll = () => closeMenu();
1397
+ document.addEventListener("mousedown", onMouseDown);
1398
+ window.addEventListener("scroll", onScroll);
1399
+ return () => {
1400
+ document.removeEventListener("mousedown", onMouseDown);
1401
+ window.removeEventListener("scroll", onScroll);
1402
+ };
1403
+ }, [isOpen, trigger, closeMenu]);
1404
+ (0, import_react6.useLayoutEffect)(() => {
1405
+ if (!isOpen || !panelRef.current) return;
1406
+ const searchbox = panelRef.current.querySelector('[role="searchbox"]');
1407
+ const firstOption = panelRef.current.querySelector(
1408
+ '[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"]'
1409
+ );
1410
+ (searchbox ?? firstOption)?.focus();
1411
+ }, [isOpen]);
1412
+ (0, import_react6.useLayoutEffect)(() => {
1413
+ if (!isOpen || !panelRef.current) return;
1414
+ const activeElement = document.activeElement;
1415
+ if (activeElement && panelRef.current.contains(activeElement)) return;
1416
+ panelRef.current.focus();
1417
+ }, [isOpen, cellsVersion]);
1418
+ (0, import_react6.useEffect)(() => {
1419
+ if (isOpen) return;
1420
+ triggerRef.current?.focus();
1421
+ }, [isOpen]);
1422
+ const contextValue = (0, import_react6.useMemo)(
1423
+ () => ({
1424
+ size,
1425
+ menuId,
1426
+ closeMenu,
1427
+ activeIndex,
1428
+ setActiveIndex,
1429
+ registerCell,
1430
+ unregisterCell,
1431
+ getCellIndex,
1432
+ cellsVersion,
1433
+ query,
1434
+ setQuery
1435
+ }),
1436
+ [
1437
+ size,
1438
+ menuId,
1439
+ closeMenu,
1440
+ activeIndex,
1441
+ registerCell,
1442
+ unregisterCell,
1443
+ getCellIndex,
1444
+ cellsVersion,
1445
+ query
1446
+ ]
1447
+ );
1448
+ const renderPresetItem = (item, index) => {
1449
+ if (!isOption(item)) {
1450
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1451
+ ContextMenuItem,
1452
+ {
1453
+ ...item,
1454
+ themeMode,
1455
+ themeProductContext
1456
+ },
1457
+ `context-menu-item-${index}`
1458
+ );
1459
+ }
1460
+ const leadingControl = item.leadingControl ?? (type === "checkbox" ? "checkbox" : type === "radio" ? "radio" : void 0);
1461
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1109
1462
  ContextMenuItem,
1110
1463
  {
1111
1464
  ...item,
1112
- size: item.size ?? size
1465
+ leadingControl,
1466
+ themeMode,
1467
+ themeProductContext,
1468
+ onSelect: () => {
1469
+ item.onSelect?.();
1470
+ onSelect?.(item);
1471
+ if (shouldCloseOnSelect) closeMenu();
1472
+ }
1113
1473
  },
1114
- `d-${key}`
1474
+ `context-menu-item-${index}`
1115
1475
  );
1116
- }
1117
- const composed = composeItemForPreset(type, item);
1118
- const originalSelect = composed.onSelect;
1119
- const wrappedSelect = () => {
1120
- originalSelect?.();
1121
- onSelect?.(item);
1122
- if (effectiveCloseOnSelect) closeMenu();
1123
1476
  };
1124
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1125
- ContextMenuItem,
1477
+ const renderedItems = searchable ? filterItems(items, debouncedQuery) : [...items];
1478
+ const renderContent = () => {
1479
+ if (loading || isLoading || type === "loading") {
1480
+ const brandColor = xuiTheme.colors.control.brand.primary.bg;
1481
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1482
+ Box,
1483
+ {
1484
+ padding: 16,
1485
+ alignItems: "center",
1486
+ justifyContent: "center",
1487
+ minHeight: 60,
1488
+ children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_xui_spinner.Spinner, { size: "md", color: brandColor })
1489
+ }
1490
+ );
1491
+ }
1492
+ if (children) return children;
1493
+ const content = renderedItems.map(renderPresetItem);
1494
+ if (searchable) {
1495
+ content.unshift(
1496
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1497
+ "div",
1498
+ {
1499
+ "data-sticky": "top",
1500
+ style: {
1501
+ position: "sticky",
1502
+ top: 0,
1503
+ zIndex: 1,
1504
+ backgroundColor: xuiTheme.colors.background.secondary
1505
+ },
1506
+ children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1507
+ ContextMenuItem,
1508
+ {
1509
+ type: "search",
1510
+ value: query,
1511
+ onValueChange: setQuery,
1512
+ placeholder: "Search",
1513
+ autoFocus: true,
1514
+ themeMode,
1515
+ themeProductContext
1516
+ }
1517
+ )
1518
+ },
1519
+ "context-menu-search"
1520
+ )
1521
+ );
1522
+ }
1523
+ if (content.length > (searchable ? 1 : 0)) return content;
1524
+ return empty ?? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { style: { padding: 16 }, children: emptyMessage });
1525
+ };
1526
+ const assignPanelRef = (node) => {
1527
+ panelRef.current = node;
1528
+ if (typeof ref === "function") ref(node);
1529
+ else if (ref) ref.current = node;
1530
+ };
1531
+ const assignTriggerRef = (node) => {
1532
+ triggerRef.current = node;
1533
+ };
1534
+ const triggerNode = trigger && (0, import_react6.isValidElement)(trigger) ? (0, import_react6.cloneElement)(trigger, {
1535
+ ref: assignTriggerRef,
1536
+ "aria-haspopup": "menu",
1537
+ "aria-expanded": isOpen ? "true" : "false",
1538
+ onClick: (event) => {
1539
+ trigger.props.onClick?.(event);
1540
+ if (!event.defaultPrevented) toggleMenu();
1541
+ }
1542
+ }) : trigger ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1543
+ "span",
1126
1544
  {
1127
- ...composed,
1128
- size: composed.size ?? size,
1129
- onSelect: wrappedSelect
1130
- },
1131
- `o-${key}`
1132
- );
1133
- };
1134
- const isLoadingState = loading;
1135
- let bodyContent = null;
1136
- let isBodyEmpty = false;
1137
- let searchNode = null;
1138
- if (isLoadingState) {
1139
- bodyContent = /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1545
+ ref: assignTriggerRef,
1546
+ role: "button",
1547
+ tabIndex: 0,
1548
+ "aria-haspopup": "menu",
1549
+ "aria-expanded": isOpen ? "true" : "false",
1550
+ onClick: toggleMenu,
1551
+ children: trigger
1552
+ }
1553
+ ) : null;
1554
+ const positionStyle = position ? {
1555
+ position: "fixed",
1556
+ left: position.x,
1557
+ top: position.y
1558
+ } : trigger ? {
1559
+ position: "fixed",
1560
+ left: positioned?.left ?? 0,
1561
+ top: positioned?.top ?? 0
1562
+ } : void 0;
1563
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ContextMenuContext.Provider, { value: contextValue, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
1140
1564
  "div",
1141
1565
  {
1566
+ ref: containerRef,
1142
1567
  style: {
1143
- display: "flex",
1144
- alignItems: "center",
1145
- justifyContent: "center",
1146
- padding: 16
1568
+ position: trigger || position ? "relative" : void 0,
1569
+ display: trigger ? "inline-block" : void 0
1147
1570
  },
1148
- children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_xui_spinner.Spinner, { size: size === "xl" ? "lg" : size === "lg" ? "md" : "sm" })
1149
- }
1150
- );
1151
- } else if (children !== void 0 && children !== null) {
1152
- const childArr = import_react5.default.Children.toArray(children);
1153
- if (childArr.length === 0) {
1154
- isBodyEmpty = true;
1155
- } else {
1156
- bodyContent = children;
1157
- }
1158
- } else if (type && items) {
1159
- if (searchable) {
1160
- searchNode = /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1161
- ContextMenuItem,
1162
- {
1163
- type: "search",
1164
- value: query,
1165
- onValueChange: setQuery,
1166
- size
1167
- }
1168
- );
1169
- }
1170
- const visible = filteredItems ?? [];
1171
- const optionCount = visible.filter((i) => i.type === "option").length;
1172
- if (optionCount === 0) {
1173
- isBodyEmpty = true;
1174
- } else {
1175
- bodyContent = visible.map((it, idx) => renderPresetItem(it, idx));
1176
- }
1177
- } else {
1178
- isBodyEmpty = true;
1179
- }
1180
- if (isBodyEmpty) {
1181
- bodyContent = empty ?? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(EmptyMessage, { color: theme.colors.content.tertiary, children: emptyMessage ?? "No results" });
1182
- }
1183
- const hasStickySearch = !!searchNode;
1184
- const prevOpenRef = (0, import_react5.useRef)(false);
1185
- const skipFocusRestoreRef = (0, import_react5.useRef)(false);
1186
- (0, import_react5.useEffect)(() => {
1187
- const wasOpen = prevOpenRef.current;
1188
- prevOpenRef.current = open;
1189
- if (!wasOpen && open) {
1190
- const timer = setTimeout(() => {
1191
- const panel2 = panelRef.current;
1192
- if (!panel2) return;
1193
- const search = panel2.querySelector("[role='searchbox']");
1194
- if (search) {
1195
- search.focus();
1196
- return;
1197
- }
1198
- const firstOption = panel2.querySelector(
1199
- "[role='menuitem'], [role='menuitemcheckbox'], [role='menuitemradio']"
1200
- );
1201
- if (firstOption) {
1202
- firstOption.focus();
1203
- setActiveIndex(-1);
1204
- } else {
1205
- panel2.focus();
1206
- }
1207
- }, 0);
1208
- return () => clearTimeout(timer);
1209
- }
1210
- if (wasOpen && !open) {
1211
- if (!skipFocusRestoreRef.current) {
1212
- triggerRef.current?.focus();
1213
- }
1214
- skipFocusRestoreRef.current = false;
1215
- }
1216
- }, [open]);
1217
- (0, import_react5.useEffect)(() => {
1218
- if (!open || !usePortal || typeof document === "undefined") return;
1219
- const handlePointerDown = (event) => {
1220
- const target = event.target;
1221
- if (!target) return;
1222
- if (panelRef.current?.contains(target)) return;
1223
- if (triggerRef.current?.contains(target)) return;
1224
- if (target instanceof Element) {
1225
- const portals = document.querySelectorAll(
1226
- "[data-xui-context-menu-portal]"
1227
- );
1228
- for (let i = 0; i < portals.length; i += 1) {
1229
- const portal = portals[i];
1230
- if (portal.getAttribute("data-xui-context-menu-portal") === menuId && portal.contains(target)) {
1231
- return;
1232
- }
1233
- }
1571
+ children: [
1572
+ triggerNode,
1573
+ isOpen && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1574
+ Box,
1575
+ {
1576
+ ref: assignPanelRef,
1577
+ role: "menu",
1578
+ "aria-label": ariaLabel,
1579
+ "data-testid": dataTestId ?? testID ?? "context-menu",
1580
+ "data-placement": positioned?.placement ?? placement,
1581
+ backgroundColor: xuiTheme.colors.background.secondary,
1582
+ borderColor: xuiTheme.colors.border.secondary,
1583
+ borderWidth: 1,
1584
+ borderRadius,
1585
+ paddingVertical: sizeStyles.paddingVertical,
1586
+ width,
1587
+ minWidth: sizeStyles.minWidth,
1588
+ tabIndex: -1,
1589
+ onKeyDown: keyboard.handleKeyDown,
1590
+ onMouseLeave: () => setActiveIndex(-1),
1591
+ style: {
1592
+ ...positionStyle,
1593
+ ...style,
1594
+ zIndex: 1e3,
1595
+ boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
1596
+ maxHeight,
1597
+ overflowY: "auto",
1598
+ outline: "none"
1599
+ },
1600
+ children: renderContent()
1601
+ }
1602
+ )
1603
+ ]
1234
1604
  }
1235
- skipFocusRestoreRef.current = true;
1236
- closeMenu();
1237
- };
1238
- document.addEventListener("mousedown", handlePointerDown);
1239
- return () => document.removeEventListener("mousedown", handlePointerDown);
1240
- }, [open, usePortal, closeMenu, menuId]);
1241
- const resolvedPlacement = position?.placement ?? placement;
1242
- const scrollContainerStyle = {
1243
- overflowY: "auto",
1244
- flex: 1,
1245
- minHeight: 0
1246
- };
1247
- const stickyHeaderStyle = {
1248
- position: "sticky",
1249
- top: 0,
1250
- zIndex: 1,
1251
- // Match the glass panel so options scrolling underneath blur instead of
1252
- // showing through the translucent header.
1253
- background: glassBackground,
1254
- backdropFilter: "blur(12px)",
1255
- WebkitBackdropFilter: "blur(12px)"
1256
- };
1257
- const panel = open ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(ContextMenuContext.Provider, { value: ctx, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
1258
- "div",
1259
- {
1260
- ref: panelRef,
1261
- role: "menu",
1262
- "aria-label": ariaLabel,
1263
- "data-testid": testId || testID,
1264
- "data-placement": usePortal ? resolvedPlacement : void 0,
1265
- tabIndex: -1,
1266
- onKeyDown: handleKeyDown,
1267
- onMouseLeave: () => setActiveIndex(-1),
1268
- style: panelStyle,
1269
- children: [
1270
- hasStickySearch && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { "data-sticky": "top", style: stickyHeaderStyle, children: searchNode }),
1271
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: scrollContainerStyle, children: bodyContent })
1272
- ]
1273
- }
1274
- ) }) : null;
1275
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
1276
- triggerNode,
1277
- usePortal ? panel && (0, import_react_dom2.createPortal)(panel, document.body) : panel
1278
- ] });
1279
- };
1280
- ContextMenu.displayName = "ContextMenu";
1281
- function composeItemForPreset(type, item) {
1282
- switch (type) {
1283
- case "checkbox":
1284
- return { ...item, leadingControl: "checkbox" };
1285
- case "radio":
1286
- return { ...item, leadingControl: "radio" };
1287
- case "list":
1288
- case "phone":
1289
- case "status":
1290
- case "brandLogo":
1291
- case "avatar":
1292
- default:
1293
- return { ...item };
1605
+ ) });
1294
1606
  }
1295
- }
1607
+ );
1608
+ ContextMenuRoot.displayName = "ContextMenu";
1609
+ var ContextMenu = Object.assign(ContextMenuRoot, {
1610
+ Item: ContextMenuItem,
1611
+ Submenu: ContextMenuSubmenu
1612
+ });
1296
1613
  // Annotate the CommonJS export names for ESM import in node:
1297
1614
  0 && (module.exports = {
1298
1615
  ContextMenu,
1299
1616
  ContextMenuContext,
1300
1617
  ContextMenuItem,
1618
+ ContextMenuSubmenu,
1301
1619
  useContextMenu,
1302
1620
  useContextMenuPosition,
1303
1621
  useContextMenuRequired,