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