@velocis/dropdown1 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -59,7 +59,9 @@ var DROPDOWN1_ITEM_DEFAULTS = {
59
59
  };
60
60
  var DROPDOWN1_SUBMENU_DEFAULTS = {
61
61
  contentWidth: "w-48",
62
- hoverDelayMs: 100
62
+ hoverDelayMs: 100,
63
+ placement: "auto",
64
+ subMenuGap: 8
63
65
  };
64
66
 
65
67
  // src/Dropdown1.variants.ts
@@ -93,7 +95,7 @@ var dropdown1SubMenuTriggerVariants = (0, import_class_variance_authority.cva)(
93
95
  "flex w-full cursor-default items-center justify-between px-4 py-2 text-sm transition-colors"
94
96
  );
95
97
  var dropdown1SubMenuContentVariants = (0, import_class_variance_authority.cva)(
96
- "absolute top-0 right-full mr-2 w-48 rounded-velocis-md z-[10000]"
98
+ "fixed w-48 rounded-velocis-md z-[10000]"
97
99
  );
98
100
 
99
101
  // src/context/Dropdown1Context.tsx
@@ -131,7 +133,7 @@ function resolveDropdown1Styles(overrides) {
131
133
  content,
132
134
  item: overrides?.item ?? dropdown1Styles.item,
133
135
  subMenuTrigger: overrides?.subMenuTrigger ?? dropdown1Styles.subMenuTrigger,
134
- subMenuContent: overrides?.subMenuContent ?? content,
136
+ subMenuContent: overrides?.subMenuContent ?? dropdown1Styles.subMenuContent,
135
137
  subMenuIcon: overrides?.subMenuIcon ?? dropdown1Styles.subMenuIcon
136
138
  };
137
139
  }
@@ -216,10 +218,18 @@ function Dropdown1Root({
216
218
  const { resolvedTheme } = (0, import_theme.useTheme)();
217
219
  const { open, setOpen } = useControllableOpen(controlledOpen, defaultOpen, onOpenChange);
218
220
  const styles = resolveDropdown1Styles(stylesProp);
219
- const dropdownRef = (0, import_react2.useRef)(null);
221
+ const contentRef = (0, import_react2.useRef)(null);
220
222
  const buttonRef = (0, import_react2.useRef)(null);
221
223
  const [positionStyle, setPositionStyle] = (0, import_react2.useState)({});
222
224
  const closeDropdown = (0, import_react2.useCallback)(() => setOpen(false), [setOpen]);
225
+ const isInsideDropdownPanel = (0, import_react2.useCallback)((node) => {
226
+ if (!node) return false;
227
+ if (contentRef.current?.contains(node)) return true;
228
+ if (node instanceof Element && node.closest("[data-velocis-dropdown1-submenu]")) {
229
+ return true;
230
+ }
231
+ return false;
232
+ }, []);
223
233
  const updatePosition = (0, import_react2.useCallback)(() => {
224
234
  if (!buttonRef.current) return;
225
235
  const rect = buttonRef.current.getBoundingClientRect();
@@ -241,7 +251,7 @@ function Dropdown1Root({
241
251
  (0, import_react2.useEffect)(() => {
242
252
  if (!open) return;
243
253
  const handleClickOutside = (event) => {
244
- if (dropdownRef.current && buttonRef.current && !dropdownRef.current.contains(event.target) && !buttonRef.current.contains(event.target)) {
254
+ if (buttonRef.current && !buttonRef.current.contains(event.target) && !isInsideDropdownPanel(event.target)) {
245
255
  setOpen(false);
246
256
  }
247
257
  };
@@ -253,7 +263,7 @@ function Dropdown1Root({
253
263
  const handleScroll = (event) => {
254
264
  if (!closeOnScroll) return;
255
265
  const target = event.target;
256
- if (dropdownRef.current && target && !dropdownRef.current.contains(target) && !buttonRef.current?.contains(target)) {
266
+ if (target && !isInsideDropdownPanel(target) && !buttonRef.current?.contains(target)) {
257
267
  setOpen(false);
258
268
  }
259
269
  };
@@ -265,7 +275,7 @@ function Dropdown1Root({
265
275
  document.removeEventListener("keydown", handleKeyDown);
266
276
  document.removeEventListener("scroll", handleScroll, true);
267
277
  };
268
- }, [closeOnScroll, open, setOpen]);
278
+ }, [closeOnScroll, isInsideDropdownPanel, open, setOpen]);
269
279
  const contentSurfaceProps = (0, import_theme.applySurface)(surface, resolvedTheme, {
270
280
  className: (0, import_core3.cn)(
271
281
  dropdown1ContentVariants(),
@@ -275,10 +285,10 @@ function Dropdown1Root({
275
285
  contentClassName
276
286
  )
277
287
  });
278
- const dropdownContent = open ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Dropdown1Context.Provider, { value: { closeDropdown, styles }, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
288
+ const dropdownContent = open ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Dropdown1Context.Provider, { value: { closeDropdown, styles, contentRef }, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
279
289
  "div",
280
290
  {
281
- ref: dropdownRef,
291
+ ref: contentRef,
282
292
  role: "menu",
283
293
  "data-testid": contentTestId,
284
294
  dir: direction,
@@ -369,28 +379,101 @@ function Dropdown1Item({
369
379
  }
370
380
 
371
381
  // src/components/Dropdown1SubMenu.tsx
372
- var import_core5 = require("@velocis/core");
382
+ var import_core6 = require("@velocis/core");
383
+ var import_theme2 = require("@velocis/theme");
373
384
  var import_react3 = require("react");
385
+
386
+ // src/positioning/computeSubMenuStyle.ts
387
+ var import_core5 = require("@velocis/core");
388
+ function resolvePlacement(placement, direction) {
389
+ if (placement === "auto") {
390
+ return (0, import_core5.isRTL)(direction) ? "left" : "right";
391
+ }
392
+ return placement;
393
+ }
394
+ function computeSubMenuStyle({
395
+ placement,
396
+ direction,
397
+ contentRect,
398
+ triggerRect,
399
+ subMenuWidth,
400
+ gap = 8
401
+ }) {
402
+ const resolved = resolvePlacement(placement, direction);
403
+ const margin = 8;
404
+ const vw = window.innerWidth;
405
+ const vh = window.innerHeight;
406
+ if (resolved === "center") {
407
+ const top2 = contentRect.bottom + gap;
408
+ const left2 = contentRect.left + (contentRect.width - subMenuWidth) / 2;
409
+ return {
410
+ position: "fixed",
411
+ top: Math.max(margin, Math.min(top2, vh - margin)),
412
+ left: Math.max(margin, Math.min(left2, vw - subMenuWidth - margin)),
413
+ width: subMenuWidth
414
+ };
415
+ }
416
+ const top = triggerRect.top;
417
+ let left;
418
+ if (resolved === "left") {
419
+ left = contentRect.left - subMenuWidth - gap;
420
+ } else {
421
+ left = contentRect.right + gap;
422
+ }
423
+ return {
424
+ position: "fixed",
425
+ top: Math.max(margin, Math.min(top, vh - margin)),
426
+ left: Math.max(margin, Math.min(left, vw - subMenuWidth - margin)),
427
+ width: subMenuWidth
428
+ };
429
+ }
430
+
431
+ // src/components/Dropdown1SubMenu.tsx
374
432
  var import_jsx_runtime4 = require("react/jsx-runtime");
375
433
  function Dropdown1SubMenu({
376
434
  trigger,
377
435
  children,
436
+ placement = DROPDOWN1_SUBMENU_DEFAULTS.placement,
378
437
  contentWidth = DROPDOWN1_SUBMENU_DEFAULTS.contentWidth,
379
438
  styles: stylesProp,
439
+ surface,
380
440
  className
381
441
  }) {
442
+ const direction = (0, import_core6.useDirection)();
443
+ const { resolvedTheme } = (0, import_theme2.useTheme)();
382
444
  const context = useDropdown1Context();
383
445
  const subMenuTriggerStyles = stylesProp?.subMenuTrigger ?? context?.styles.subMenuTrigger;
384
446
  const subMenuContentStyles = stylesProp?.subMenuContent ?? context?.styles.subMenuContent;
385
447
  const subMenuIconStyles = stylesProp?.subMenuIcon ?? context?.styles.subMenuIcon;
386
448
  const [isOpen, setIsOpen] = (0, import_react3.useState)(false);
449
+ const [positionStyle, setPositionStyle] = (0, import_react3.useState)({});
387
450
  const timeoutRef = (0, import_react3.useRef)(null);
388
- const containerRef = (0, import_react3.useRef)(null);
389
- const handleMouseEnter = () => {
451
+ const triggerRef = (0, import_react3.useRef)(null);
452
+ const panelRef = (0, import_react3.useRef)(null);
453
+ const updatePosition = (0, import_react3.useCallback)(() => {
454
+ const contentEl = context?.contentRef.current;
455
+ const triggerEl = triggerRef.current;
456
+ if (!contentEl || !triggerEl) return;
457
+ const subMenuWidth = parseContentWidthClass(contentWidth) ?? panelRef.current?.getBoundingClientRect().width ?? 192;
458
+ setPositionStyle(
459
+ computeSubMenuStyle({
460
+ placement,
461
+ direction,
462
+ contentRect: contentEl.getBoundingClientRect(),
463
+ triggerRect: triggerEl.getBoundingClientRect(),
464
+ subMenuWidth,
465
+ gap: DROPDOWN1_SUBMENU_DEFAULTS.subMenuGap
466
+ })
467
+ );
468
+ }, [contentWidth, context?.contentRef, direction, placement]);
469
+ const clearCloseTimeout = () => {
390
470
  if (timeoutRef.current) {
391
471
  clearTimeout(timeoutRef.current);
392
472
  timeoutRef.current = null;
393
473
  }
474
+ };
475
+ const handleMouseEnter = () => {
476
+ clearCloseTimeout();
394
477
  setIsOpen(true);
395
478
  };
396
479
  const handleMouseLeave = () => {
@@ -398,6 +481,11 @@ function Dropdown1SubMenu({
398
481
  setIsOpen(false);
399
482
  }, DROPDOWN1_SUBMENU_DEFAULTS.hoverDelayMs);
400
483
  };
484
+ (0, import_react3.useEffect)(() => {
485
+ if (isOpen) {
486
+ updatePosition();
487
+ }
488
+ }, [isOpen, updatePosition]);
401
489
  (0, import_react3.useEffect)(() => {
402
490
  return () => {
403
491
  if (timeoutRef.current) {
@@ -405,45 +493,47 @@ function Dropdown1SubMenu({
405
493
  }
406
494
  };
407
495
  }, []);
408
- return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
496
+ const panelSurfaceProps = (0, import_theme2.applySurface)(surface, resolvedTheme, {
497
+ className: (0, import_core6.cn)(
498
+ dropdown1SubMenuContentVariants(),
499
+ subMenuContentStyles,
500
+ contentWidth !== "w-48" && contentWidth,
501
+ className
502
+ )
503
+ });
504
+ const subMenuPanel = isOpen ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
409
505
  "div",
410
506
  {
411
- ref: containerRef,
412
- className: "relative w-full",
507
+ ref: panelRef,
508
+ role: "menu",
509
+ "data-velocis-dropdown1-submenu": "",
510
+ ...panelSurfaceProps,
511
+ style: {
512
+ ...panelSurfaceProps.style,
513
+ ...positionStyle
514
+ },
413
515
  onMouseEnter: handleMouseEnter,
414
516
  onMouseLeave: handleMouseLeave,
415
- children: [
416
- /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
417
- "div",
418
- {
419
- role: "menuitem",
420
- "aria-haspopup": "menu",
421
- "aria-expanded": isOpen,
422
- className: (0, import_core5.cn)(dropdown1SubMenuTriggerVariants(), subMenuTriggerStyles),
423
- children: [
424
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "flex items-center gap-2", children: trigger }),
425
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ChevronSubMenuIcon, { open: isOpen, className: (0, import_core5.cn)("transition-transform", subMenuIconStyles) })
426
- ]
427
- }
428
- ),
429
- isOpen && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
430
- "div",
431
- {
432
- role: "menu",
433
- className: (0, import_core5.cn)(
434
- dropdown1SubMenuContentVariants(),
435
- subMenuContentStyles,
436
- contentWidth !== "w-48" && contentWidth,
437
- className
438
- ),
439
- onMouseEnter: handleMouseEnter,
440
- onMouseLeave: handleMouseLeave,
441
- children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "py-1", children })
442
- }
443
- )
444
- ]
517
+ children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "py-1", children })
445
518
  }
446
- );
519
+ ) : null;
520
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "relative w-full", onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, children: [
521
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
522
+ "div",
523
+ {
524
+ ref: triggerRef,
525
+ role: "menuitem",
526
+ "aria-haspopup": "menu",
527
+ "aria-expanded": isOpen,
528
+ className: (0, import_core6.cn)(dropdown1SubMenuTriggerVariants(), subMenuTriggerStyles),
529
+ children: [
530
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "flex items-center gap-2", children: trigger }),
531
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ChevronSubMenuIcon, { open: isOpen, className: (0, import_core6.cn)("transition-transform", subMenuIconStyles) })
532
+ ]
533
+ }
534
+ ),
535
+ isOpen && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_core6.Portal, { children: subMenuPanel })
536
+ ] });
447
537
  }
448
538
 
449
539
  // src/hooks/useDropdown1.ts
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as react from 'react';
2
- import { ReactNode } from 'react';
2
+ import { ReactNode, RefObject } from 'react';
3
3
  import { VelocisSurfaceConfig } from '@velocis/theme';
4
4
  import * as class_variance_authority_types from 'class-variance-authority/types';
5
5
 
@@ -14,7 +14,7 @@ type Dropdown1Styles = {
14
14
  item?: string;
15
15
  /** Sub-menu row — text + hover bg/text */
16
16
  subMenuTrigger?: string;
17
- /** Sub-menu panel — defaults to `content` when omitted */
17
+ /** Sub-menu panel — independent default from `content` */
18
18
  subMenuContent?: string;
19
19
  /** Chevron on sub-menu row */
20
20
  subMenuIcon?: string;
@@ -63,12 +63,17 @@ type Dropdown1ItemProps = {
63
63
  className?: string;
64
64
  testId?: string;
65
65
  };
66
+ type Dropdown1SubMenuPlacement = 'auto' | 'left' | 'right' | 'center';
66
67
  type Dropdown1SubMenuProps = {
67
68
  trigger: ReactNode;
68
69
  children: ReactNode;
70
+ /** Where the sub-panel opens relative to the main menu panel */
71
+ placement?: Dropdown1SubMenuPlacement;
69
72
  contentWidth?: string;
70
73
  /** Override sub-menu colors (defaults to root styles) */
71
74
  styles?: Pick<Dropdown1Styles, 'subMenuTrigger' | 'subMenuContent' | 'subMenuIcon'>;
75
+ /** Independent background/tokens — separate from root `surface` */
76
+ surface?: VelocisSurfaceConfig;
72
77
  className?: string;
73
78
  };
74
79
 
@@ -76,7 +81,7 @@ declare function Dropdown1Root({ trigger, children, open: controlledOpen, defaul
76
81
 
77
82
  declare function Dropdown1Item({ children, onClick, closeOnSelect, styles: stylesProp, className, testId, }: Dropdown1ItemProps): react.JSX.Element;
78
83
 
79
- declare function Dropdown1SubMenu({ trigger, children, contentWidth, styles: stylesProp, className, }: Dropdown1SubMenuProps): react.JSX.Element;
84
+ declare function Dropdown1SubMenu({ trigger, children, placement, contentWidth, styles: stylesProp, surface, className, }: Dropdown1SubMenuProps): react.JSX.Element;
80
85
 
81
86
  type UseDropdown1Options = {
82
87
  defaultOpen?: boolean;
@@ -91,6 +96,7 @@ declare function useDropdown1(options?: UseDropdown1Options): UseDropdown1Result
91
96
  type Dropdown1ContextValue = {
92
97
  closeDropdown: () => void;
93
98
  styles: ResolvedDropdown1Styles;
99
+ contentRef: RefObject<HTMLDivElement | null>;
94
100
  };
95
101
  declare const Dropdown1Context: react.Context<Dropdown1ContextValue | null>;
96
102
  declare function useDropdown1Context(): Dropdown1ContextValue | null;
@@ -108,6 +114,8 @@ declare const DROPDOWN1_ITEM_DEFAULTS: {
108
114
  declare const DROPDOWN1_SUBMENU_DEFAULTS: {
109
115
  readonly contentWidth: "w-48";
110
116
  readonly hoverDelayMs: 100;
117
+ readonly placement: "auto";
118
+ readonly subMenuGap: 8;
111
119
  };
112
120
 
113
121
  /** Layout/structure only — colors come from `dropdown1Styles` + `styles` prop */
@@ -125,4 +133,4 @@ declare const Dropdown1: typeof Dropdown1Root & {
125
133
  SubMenu: typeof Dropdown1SubMenu;
126
134
  };
127
135
 
128
- export { DROPDOWN1_DEFAULTS, DROPDOWN1_ITEM_DEFAULTS, DROPDOWN1_SUBMENU_DEFAULTS, Dropdown1, Dropdown1Context, type Dropdown1ContextValue, Dropdown1Item, type Dropdown1ItemProps, type Dropdown1Props, Dropdown1Root, type Dropdown1Styles, Dropdown1SubMenu, type Dropdown1SubMenuProps, type ResolvedDropdown1Styles, type UseDropdown1Options, type UseDropdown1Result, dropdown1ContentVariants, dropdown1ItemVariants, dropdown1Styles, dropdown1SubMenuContentVariants, dropdown1SubMenuTriggerVariants, dropdown1TriggerVariants, resolveDropdown1Styles, useDropdown1, useDropdown1Context };
136
+ export { DROPDOWN1_DEFAULTS, DROPDOWN1_ITEM_DEFAULTS, DROPDOWN1_SUBMENU_DEFAULTS, Dropdown1, Dropdown1Context, type Dropdown1ContextValue, Dropdown1Item, type Dropdown1ItemProps, type Dropdown1Props, Dropdown1Root, type Dropdown1Styles, Dropdown1SubMenu, type Dropdown1SubMenuPlacement, type Dropdown1SubMenuProps, type ResolvedDropdown1Styles, type UseDropdown1Options, type UseDropdown1Result, dropdown1ContentVariants, dropdown1ItemVariants, dropdown1Styles, dropdown1SubMenuContentVariants, dropdown1SubMenuTriggerVariants, dropdown1TriggerVariants, resolveDropdown1Styles, useDropdown1, useDropdown1Context };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as react from 'react';
2
- import { ReactNode } from 'react';
2
+ import { ReactNode, RefObject } from 'react';
3
3
  import { VelocisSurfaceConfig } from '@velocis/theme';
4
4
  import * as class_variance_authority_types from 'class-variance-authority/types';
5
5
 
@@ -14,7 +14,7 @@ type Dropdown1Styles = {
14
14
  item?: string;
15
15
  /** Sub-menu row — text + hover bg/text */
16
16
  subMenuTrigger?: string;
17
- /** Sub-menu panel — defaults to `content` when omitted */
17
+ /** Sub-menu panel — independent default from `content` */
18
18
  subMenuContent?: string;
19
19
  /** Chevron on sub-menu row */
20
20
  subMenuIcon?: string;
@@ -63,12 +63,17 @@ type Dropdown1ItemProps = {
63
63
  className?: string;
64
64
  testId?: string;
65
65
  };
66
+ type Dropdown1SubMenuPlacement = 'auto' | 'left' | 'right' | 'center';
66
67
  type Dropdown1SubMenuProps = {
67
68
  trigger: ReactNode;
68
69
  children: ReactNode;
70
+ /** Where the sub-panel opens relative to the main menu panel */
71
+ placement?: Dropdown1SubMenuPlacement;
69
72
  contentWidth?: string;
70
73
  /** Override sub-menu colors (defaults to root styles) */
71
74
  styles?: Pick<Dropdown1Styles, 'subMenuTrigger' | 'subMenuContent' | 'subMenuIcon'>;
75
+ /** Independent background/tokens — separate from root `surface` */
76
+ surface?: VelocisSurfaceConfig;
72
77
  className?: string;
73
78
  };
74
79
 
@@ -76,7 +81,7 @@ declare function Dropdown1Root({ trigger, children, open: controlledOpen, defaul
76
81
 
77
82
  declare function Dropdown1Item({ children, onClick, closeOnSelect, styles: stylesProp, className, testId, }: Dropdown1ItemProps): react.JSX.Element;
78
83
 
79
- declare function Dropdown1SubMenu({ trigger, children, contentWidth, styles: stylesProp, className, }: Dropdown1SubMenuProps): react.JSX.Element;
84
+ declare function Dropdown1SubMenu({ trigger, children, placement, contentWidth, styles: stylesProp, surface, className, }: Dropdown1SubMenuProps): react.JSX.Element;
80
85
 
81
86
  type UseDropdown1Options = {
82
87
  defaultOpen?: boolean;
@@ -91,6 +96,7 @@ declare function useDropdown1(options?: UseDropdown1Options): UseDropdown1Result
91
96
  type Dropdown1ContextValue = {
92
97
  closeDropdown: () => void;
93
98
  styles: ResolvedDropdown1Styles;
99
+ contentRef: RefObject<HTMLDivElement | null>;
94
100
  };
95
101
  declare const Dropdown1Context: react.Context<Dropdown1ContextValue | null>;
96
102
  declare function useDropdown1Context(): Dropdown1ContextValue | null;
@@ -108,6 +114,8 @@ declare const DROPDOWN1_ITEM_DEFAULTS: {
108
114
  declare const DROPDOWN1_SUBMENU_DEFAULTS: {
109
115
  readonly contentWidth: "w-48";
110
116
  readonly hoverDelayMs: 100;
117
+ readonly placement: "auto";
118
+ readonly subMenuGap: 8;
111
119
  };
112
120
 
113
121
  /** Layout/structure only — colors come from `dropdown1Styles` + `styles` prop */
@@ -125,4 +133,4 @@ declare const Dropdown1: typeof Dropdown1Root & {
125
133
  SubMenu: typeof Dropdown1SubMenu;
126
134
  };
127
135
 
128
- export { DROPDOWN1_DEFAULTS, DROPDOWN1_ITEM_DEFAULTS, DROPDOWN1_SUBMENU_DEFAULTS, Dropdown1, Dropdown1Context, type Dropdown1ContextValue, Dropdown1Item, type Dropdown1ItemProps, type Dropdown1Props, Dropdown1Root, type Dropdown1Styles, Dropdown1SubMenu, type Dropdown1SubMenuProps, type ResolvedDropdown1Styles, type UseDropdown1Options, type UseDropdown1Result, dropdown1ContentVariants, dropdown1ItemVariants, dropdown1Styles, dropdown1SubMenuContentVariants, dropdown1SubMenuTriggerVariants, dropdown1TriggerVariants, resolveDropdown1Styles, useDropdown1, useDropdown1Context };
136
+ export { DROPDOWN1_DEFAULTS, DROPDOWN1_ITEM_DEFAULTS, DROPDOWN1_SUBMENU_DEFAULTS, Dropdown1, Dropdown1Context, type Dropdown1ContextValue, Dropdown1Item, type Dropdown1ItemProps, type Dropdown1Props, Dropdown1Root, type Dropdown1Styles, Dropdown1SubMenu, type Dropdown1SubMenuPlacement, type Dropdown1SubMenuProps, type ResolvedDropdown1Styles, type UseDropdown1Options, type UseDropdown1Result, dropdown1ContentVariants, dropdown1ItemVariants, dropdown1Styles, dropdown1SubMenuContentVariants, dropdown1SubMenuTriggerVariants, dropdown1TriggerVariants, resolveDropdown1Styles, useDropdown1, useDropdown1Context };
package/dist/index.js CHANGED
@@ -23,7 +23,9 @@ var DROPDOWN1_ITEM_DEFAULTS = {
23
23
  };
24
24
  var DROPDOWN1_SUBMENU_DEFAULTS = {
25
25
  contentWidth: "w-48",
26
- hoverDelayMs: 100
26
+ hoverDelayMs: 100,
27
+ placement: "auto",
28
+ subMenuGap: 8
27
29
  };
28
30
 
29
31
  // src/Dropdown1.variants.ts
@@ -57,7 +59,7 @@ var dropdown1SubMenuTriggerVariants = cva(
57
59
  "flex w-full cursor-default items-center justify-between px-4 py-2 text-sm transition-colors"
58
60
  );
59
61
  var dropdown1SubMenuContentVariants = cva(
60
- "absolute top-0 right-full mr-2 w-48 rounded-velocis-md z-[10000]"
62
+ "fixed w-48 rounded-velocis-md z-[10000]"
61
63
  );
62
64
 
63
65
  // src/context/Dropdown1Context.tsx
@@ -95,7 +97,7 @@ function resolveDropdown1Styles(overrides) {
95
97
  content,
96
98
  item: overrides?.item ?? dropdown1Styles.item,
97
99
  subMenuTrigger: overrides?.subMenuTrigger ?? dropdown1Styles.subMenuTrigger,
98
- subMenuContent: overrides?.subMenuContent ?? content,
100
+ subMenuContent: overrides?.subMenuContent ?? dropdown1Styles.subMenuContent,
99
101
  subMenuIcon: overrides?.subMenuIcon ?? dropdown1Styles.subMenuIcon
100
102
  };
101
103
  }
@@ -180,10 +182,18 @@ function Dropdown1Root({
180
182
  const { resolvedTheme } = useTheme();
181
183
  const { open, setOpen } = useControllableOpen(controlledOpen, defaultOpen, onOpenChange);
182
184
  const styles = resolveDropdown1Styles(stylesProp);
183
- const dropdownRef = useRef(null);
185
+ const contentRef = useRef(null);
184
186
  const buttonRef = useRef(null);
185
187
  const [positionStyle, setPositionStyle] = useState({});
186
188
  const closeDropdown = useCallback(() => setOpen(false), [setOpen]);
189
+ const isInsideDropdownPanel = useCallback((node) => {
190
+ if (!node) return false;
191
+ if (contentRef.current?.contains(node)) return true;
192
+ if (node instanceof Element && node.closest("[data-velocis-dropdown1-submenu]")) {
193
+ return true;
194
+ }
195
+ return false;
196
+ }, []);
187
197
  const updatePosition = useCallback(() => {
188
198
  if (!buttonRef.current) return;
189
199
  const rect = buttonRef.current.getBoundingClientRect();
@@ -205,7 +215,7 @@ function Dropdown1Root({
205
215
  useEffect(() => {
206
216
  if (!open) return;
207
217
  const handleClickOutside = (event) => {
208
- if (dropdownRef.current && buttonRef.current && !dropdownRef.current.contains(event.target) && !buttonRef.current.contains(event.target)) {
218
+ if (buttonRef.current && !buttonRef.current.contains(event.target) && !isInsideDropdownPanel(event.target)) {
209
219
  setOpen(false);
210
220
  }
211
221
  };
@@ -217,7 +227,7 @@ function Dropdown1Root({
217
227
  const handleScroll = (event) => {
218
228
  if (!closeOnScroll) return;
219
229
  const target = event.target;
220
- if (dropdownRef.current && target && !dropdownRef.current.contains(target) && !buttonRef.current?.contains(target)) {
230
+ if (target && !isInsideDropdownPanel(target) && !buttonRef.current?.contains(target)) {
221
231
  setOpen(false);
222
232
  }
223
233
  };
@@ -229,7 +239,7 @@ function Dropdown1Root({
229
239
  document.removeEventListener("keydown", handleKeyDown);
230
240
  document.removeEventListener("scroll", handleScroll, true);
231
241
  };
232
- }, [closeOnScroll, open, setOpen]);
242
+ }, [closeOnScroll, isInsideDropdownPanel, open, setOpen]);
233
243
  const contentSurfaceProps = applySurface(surface, resolvedTheme, {
234
244
  className: cn2(
235
245
  dropdown1ContentVariants(),
@@ -239,10 +249,10 @@ function Dropdown1Root({
239
249
  contentClassName
240
250
  )
241
251
  });
242
- const dropdownContent = open ? /* @__PURE__ */ jsx2(Dropdown1Context.Provider, { value: { closeDropdown, styles }, children: /* @__PURE__ */ jsx2(
252
+ const dropdownContent = open ? /* @__PURE__ */ jsx2(Dropdown1Context.Provider, { value: { closeDropdown, styles, contentRef }, children: /* @__PURE__ */ jsx2(
243
253
  "div",
244
254
  {
245
- ref: dropdownRef,
255
+ ref: contentRef,
246
256
  role: "menu",
247
257
  "data-testid": contentTestId,
248
258
  dir: direction,
@@ -333,28 +343,101 @@ function Dropdown1Item({
333
343
  }
334
344
 
335
345
  // src/components/Dropdown1SubMenu.tsx
336
- import { cn as cn4 } from "@velocis/core";
337
- import { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
346
+ import { cn as cn4, Portal as Portal2, useDirection as useDirection2 } from "@velocis/core";
347
+ import { applySurface as applySurface2, useTheme as useTheme2 } from "@velocis/theme";
348
+ import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
349
+
350
+ // src/positioning/computeSubMenuStyle.ts
351
+ import { isRTL as isRTL2 } from "@velocis/core";
352
+ function resolvePlacement(placement, direction) {
353
+ if (placement === "auto") {
354
+ return isRTL2(direction) ? "left" : "right";
355
+ }
356
+ return placement;
357
+ }
358
+ function computeSubMenuStyle({
359
+ placement,
360
+ direction,
361
+ contentRect,
362
+ triggerRect,
363
+ subMenuWidth,
364
+ gap = 8
365
+ }) {
366
+ const resolved = resolvePlacement(placement, direction);
367
+ const margin = 8;
368
+ const vw = window.innerWidth;
369
+ const vh = window.innerHeight;
370
+ if (resolved === "center") {
371
+ const top2 = contentRect.bottom + gap;
372
+ const left2 = contentRect.left + (contentRect.width - subMenuWidth) / 2;
373
+ return {
374
+ position: "fixed",
375
+ top: Math.max(margin, Math.min(top2, vh - margin)),
376
+ left: Math.max(margin, Math.min(left2, vw - subMenuWidth - margin)),
377
+ width: subMenuWidth
378
+ };
379
+ }
380
+ const top = triggerRect.top;
381
+ let left;
382
+ if (resolved === "left") {
383
+ left = contentRect.left - subMenuWidth - gap;
384
+ } else {
385
+ left = contentRect.right + gap;
386
+ }
387
+ return {
388
+ position: "fixed",
389
+ top: Math.max(margin, Math.min(top, vh - margin)),
390
+ left: Math.max(margin, Math.min(left, vw - subMenuWidth - margin)),
391
+ width: subMenuWidth
392
+ };
393
+ }
394
+
395
+ // src/components/Dropdown1SubMenu.tsx
338
396
  import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
339
397
  function Dropdown1SubMenu({
340
398
  trigger,
341
399
  children,
400
+ placement = DROPDOWN1_SUBMENU_DEFAULTS.placement,
342
401
  contentWidth = DROPDOWN1_SUBMENU_DEFAULTS.contentWidth,
343
402
  styles: stylesProp,
403
+ surface,
344
404
  className
345
405
  }) {
406
+ const direction = useDirection2();
407
+ const { resolvedTheme } = useTheme2();
346
408
  const context = useDropdown1Context();
347
409
  const subMenuTriggerStyles = stylesProp?.subMenuTrigger ?? context?.styles.subMenuTrigger;
348
410
  const subMenuContentStyles = stylesProp?.subMenuContent ?? context?.styles.subMenuContent;
349
411
  const subMenuIconStyles = stylesProp?.subMenuIcon ?? context?.styles.subMenuIcon;
350
412
  const [isOpen, setIsOpen] = useState2(false);
413
+ const [positionStyle, setPositionStyle] = useState2({});
351
414
  const timeoutRef = useRef2(null);
352
- const containerRef = useRef2(null);
353
- const handleMouseEnter = () => {
415
+ const triggerRef = useRef2(null);
416
+ const panelRef = useRef2(null);
417
+ const updatePosition = useCallback2(() => {
418
+ const contentEl = context?.contentRef.current;
419
+ const triggerEl = triggerRef.current;
420
+ if (!contentEl || !triggerEl) return;
421
+ const subMenuWidth = parseContentWidthClass(contentWidth) ?? panelRef.current?.getBoundingClientRect().width ?? 192;
422
+ setPositionStyle(
423
+ computeSubMenuStyle({
424
+ placement,
425
+ direction,
426
+ contentRect: contentEl.getBoundingClientRect(),
427
+ triggerRect: triggerEl.getBoundingClientRect(),
428
+ subMenuWidth,
429
+ gap: DROPDOWN1_SUBMENU_DEFAULTS.subMenuGap
430
+ })
431
+ );
432
+ }, [contentWidth, context?.contentRef, direction, placement]);
433
+ const clearCloseTimeout = () => {
354
434
  if (timeoutRef.current) {
355
435
  clearTimeout(timeoutRef.current);
356
436
  timeoutRef.current = null;
357
437
  }
438
+ };
439
+ const handleMouseEnter = () => {
440
+ clearCloseTimeout();
358
441
  setIsOpen(true);
359
442
  };
360
443
  const handleMouseLeave = () => {
@@ -362,6 +445,11 @@ function Dropdown1SubMenu({
362
445
  setIsOpen(false);
363
446
  }, DROPDOWN1_SUBMENU_DEFAULTS.hoverDelayMs);
364
447
  };
448
+ useEffect2(() => {
449
+ if (isOpen) {
450
+ updatePosition();
451
+ }
452
+ }, [isOpen, updatePosition]);
365
453
  useEffect2(() => {
366
454
  return () => {
367
455
  if (timeoutRef.current) {
@@ -369,52 +457,54 @@ function Dropdown1SubMenu({
369
457
  }
370
458
  };
371
459
  }, []);
372
- return /* @__PURE__ */ jsxs2(
460
+ const panelSurfaceProps = applySurface2(surface, resolvedTheme, {
461
+ className: cn4(
462
+ dropdown1SubMenuContentVariants(),
463
+ subMenuContentStyles,
464
+ contentWidth !== "w-48" && contentWidth,
465
+ className
466
+ )
467
+ });
468
+ const subMenuPanel = isOpen ? /* @__PURE__ */ jsx4(
373
469
  "div",
374
470
  {
375
- ref: containerRef,
376
- className: "relative w-full",
471
+ ref: panelRef,
472
+ role: "menu",
473
+ "data-velocis-dropdown1-submenu": "",
474
+ ...panelSurfaceProps,
475
+ style: {
476
+ ...panelSurfaceProps.style,
477
+ ...positionStyle
478
+ },
377
479
  onMouseEnter: handleMouseEnter,
378
480
  onMouseLeave: handleMouseLeave,
379
- children: [
380
- /* @__PURE__ */ jsxs2(
381
- "div",
382
- {
383
- role: "menuitem",
384
- "aria-haspopup": "menu",
385
- "aria-expanded": isOpen,
386
- className: cn4(dropdown1SubMenuTriggerVariants(), subMenuTriggerStyles),
387
- children: [
388
- /* @__PURE__ */ jsx4("span", { className: "flex items-center gap-2", children: trigger }),
389
- /* @__PURE__ */ jsx4(ChevronSubMenuIcon, { open: isOpen, className: cn4("transition-transform", subMenuIconStyles) })
390
- ]
391
- }
392
- ),
393
- isOpen && /* @__PURE__ */ jsx4(
394
- "div",
395
- {
396
- role: "menu",
397
- className: cn4(
398
- dropdown1SubMenuContentVariants(),
399
- subMenuContentStyles,
400
- contentWidth !== "w-48" && contentWidth,
401
- className
402
- ),
403
- onMouseEnter: handleMouseEnter,
404
- onMouseLeave: handleMouseLeave,
405
- children: /* @__PURE__ */ jsx4("div", { className: "py-1", children })
406
- }
407
- )
408
- ]
481
+ children: /* @__PURE__ */ jsx4("div", { className: "py-1", children })
409
482
  }
410
- );
483
+ ) : null;
484
+ return /* @__PURE__ */ jsxs2("div", { className: "relative w-full", onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, children: [
485
+ /* @__PURE__ */ jsxs2(
486
+ "div",
487
+ {
488
+ ref: triggerRef,
489
+ role: "menuitem",
490
+ "aria-haspopup": "menu",
491
+ "aria-expanded": isOpen,
492
+ className: cn4(dropdown1SubMenuTriggerVariants(), subMenuTriggerStyles),
493
+ children: [
494
+ /* @__PURE__ */ jsx4("span", { className: "flex items-center gap-2", children: trigger }),
495
+ /* @__PURE__ */ jsx4(ChevronSubMenuIcon, { open: isOpen, className: cn4("transition-transform", subMenuIconStyles) })
496
+ ]
497
+ }
498
+ ),
499
+ isOpen && /* @__PURE__ */ jsx4(Portal2, { children: subMenuPanel })
500
+ ] });
411
501
  }
412
502
 
413
503
  // src/hooks/useDropdown1.ts
414
- import { useCallback as useCallback2, useState as useState3 } from "react";
504
+ import { useCallback as useCallback3, useState as useState3 } from "react";
415
505
  function useDropdown1(options = {}) {
416
506
  const [open, setOpen] = useState3(options.defaultOpen ?? false);
417
- const onOpenChange = useCallback2((next) => {
507
+ const onOpenChange = useCallback3((next) => {
418
508
  setOpen(next);
419
509
  }, []);
420
510
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velocis/dropdown1",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "sideEffects": false,
6
6
  "type": "module",