@velocis/dropdown1 0.2.1 → 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
@@ -43,6 +43,7 @@ module.exports = __toCommonJS(index_exports);
43
43
 
44
44
  // src/Dropdown1.tsx
45
45
  var import_core3 = require("@velocis/core");
46
+ var import_theme = require("@velocis/theme");
46
47
  var import_react2 = require("react");
47
48
 
48
49
  // src/Dropdown1.defaults.ts
@@ -58,7 +59,9 @@ var DROPDOWN1_ITEM_DEFAULTS = {
58
59
  };
59
60
  var DROPDOWN1_SUBMENU_DEFAULTS = {
60
61
  contentWidth: "w-48",
61
- hoverDelayMs: 100
62
+ hoverDelayMs: 100,
63
+ placement: "auto",
64
+ subMenuGap: 8
62
65
  };
63
66
 
64
67
  // src/Dropdown1.variants.ts
@@ -92,7 +95,7 @@ var dropdown1SubMenuTriggerVariants = (0, import_class_variance_authority.cva)(
92
95
  "flex w-full cursor-default items-center justify-between px-4 py-2 text-sm transition-colors"
93
96
  );
94
97
  var dropdown1SubMenuContentVariants = (0, import_class_variance_authority.cva)(
95
- "absolute top-0 right-full mr-2 w-48 rounded-velocis-md z-[10000]"
98
+ "fixed w-48 rounded-velocis-md z-[10000]"
96
99
  );
97
100
 
98
101
  // src/context/Dropdown1Context.tsx
@@ -130,7 +133,7 @@ function resolveDropdown1Styles(overrides) {
130
133
  content,
131
134
  item: overrides?.item ?? dropdown1Styles.item,
132
135
  subMenuTrigger: overrides?.subMenuTrigger ?? dropdown1Styles.subMenuTrigger,
133
- subMenuContent: overrides?.subMenuContent ?? content,
136
+ subMenuContent: overrides?.subMenuContent ?? dropdown1Styles.subMenuContent,
134
137
  subMenuIcon: overrides?.subMenuIcon ?? dropdown1Styles.subMenuIcon
135
138
  };
136
139
  }
@@ -198,6 +201,8 @@ function Dropdown1Root({
198
201
  defaultOpen = false,
199
202
  onOpenChange,
200
203
  styles: stylesProp,
204
+ surface,
205
+ triggerSurface,
201
206
  triggerClassName,
202
207
  contentClassName,
203
208
  contentWidth = DROPDOWN1_DEFAULTS.contentWidth,
@@ -210,12 +215,21 @@ function Dropdown1Root({
210
215
  contentTestId
211
216
  }) {
212
217
  const direction = (0, import_core3.useDirection)();
218
+ const { resolvedTheme } = (0, import_theme.useTheme)();
213
219
  const { open, setOpen } = useControllableOpen(controlledOpen, defaultOpen, onOpenChange);
214
220
  const styles = resolveDropdown1Styles(stylesProp);
215
- const dropdownRef = (0, import_react2.useRef)(null);
221
+ const contentRef = (0, import_react2.useRef)(null);
216
222
  const buttonRef = (0, import_react2.useRef)(null);
217
223
  const [positionStyle, setPositionStyle] = (0, import_react2.useState)({});
218
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
+ }, []);
219
233
  const updatePosition = (0, import_react2.useCallback)(() => {
220
234
  if (!buttonRef.current) return;
221
235
  const rect = buttonRef.current.getBoundingClientRect();
@@ -237,7 +251,7 @@ function Dropdown1Root({
237
251
  (0, import_react2.useEffect)(() => {
238
252
  if (!open) return;
239
253
  const handleClickOutside = (event) => {
240
- 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)) {
241
255
  setOpen(false);
242
256
  }
243
257
  };
@@ -249,7 +263,7 @@ function Dropdown1Root({
249
263
  const handleScroll = (event) => {
250
264
  if (!closeOnScroll) return;
251
265
  const target = event.target;
252
- if (dropdownRef.current && target && !dropdownRef.current.contains(target) && !buttonRef.current?.contains(target)) {
266
+ if (target && !isInsideDropdownPanel(target) && !buttonRef.current?.contains(target)) {
253
267
  setOpen(false);
254
268
  }
255
269
  };
@@ -261,22 +275,26 @@ function Dropdown1Root({
261
275
  document.removeEventListener("keydown", handleKeyDown);
262
276
  document.removeEventListener("scroll", handleScroll, true);
263
277
  };
264
- }, [closeOnScroll, open, setOpen]);
265
- const dropdownContent = open ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Dropdown1Context.Provider, { value: { closeDropdown, styles }, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
278
+ }, [closeOnScroll, isInsideDropdownPanel, open, setOpen]);
279
+ const contentSurfaceProps = (0, import_theme.applySurface)(surface, resolvedTheme, {
280
+ className: (0, import_core3.cn)(
281
+ dropdown1ContentVariants(),
282
+ styles.content,
283
+ !fullWidth && contentWidth,
284
+ maxHeight && "overflow-y-auto",
285
+ contentClassName
286
+ )
287
+ });
288
+ const dropdownContent = open ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Dropdown1Context.Provider, { value: { closeDropdown, styles, contentRef }, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
266
289
  "div",
267
290
  {
268
- ref: dropdownRef,
291
+ ref: contentRef,
269
292
  role: "menu",
270
293
  "data-testid": contentTestId,
271
294
  dir: direction,
272
- className: (0, import_core3.cn)(
273
- dropdown1ContentVariants(),
274
- styles.content,
275
- !fullWidth && contentWidth,
276
- maxHeight && "overflow-y-auto",
277
- contentClassName
278
- ),
295
+ ...contentSurfaceProps,
279
296
  style: {
297
+ ...contentSurfaceProps.style,
280
298
  ...positionStyle,
281
299
  zIndex: contentZIndex,
282
300
  maxHeight: maxHeight || void 0
@@ -284,6 +302,13 @@ function Dropdown1Root({
284
302
  children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "py-1", children })
285
303
  }
286
304
  ) }) : null;
305
+ const triggerSurfaceProps = (0, import_theme.applySurface)(triggerSurface, resolvedTheme, {
306
+ className: (0, import_core3.cn)(
307
+ dropdown1TriggerVariants({ fullWidth, disabled }),
308
+ styles.trigger,
309
+ triggerClassName
310
+ )
311
+ });
287
312
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
288
313
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
289
314
  "div",
@@ -302,11 +327,7 @@ function Dropdown1Root({
302
327
  "aria-haspopup": "menu",
303
328
  onClick: () => !disabled && setOpen(!open),
304
329
  "data-testid": testId,
305
- className: (0, import_core3.cn)(
306
- dropdown1TriggerVariants({ fullWidth, disabled }),
307
- styles.trigger,
308
- triggerClassName
309
- ),
330
+ ...triggerSurfaceProps,
310
331
  children: [
311
332
  trigger,
312
333
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(ChevronDownIcon, { className: styles.triggerIcon })
@@ -358,28 +379,101 @@ function Dropdown1Item({
358
379
  }
359
380
 
360
381
  // src/components/Dropdown1SubMenu.tsx
361
- var import_core5 = require("@velocis/core");
382
+ var import_core6 = require("@velocis/core");
383
+ var import_theme2 = require("@velocis/theme");
362
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
363
432
  var import_jsx_runtime4 = require("react/jsx-runtime");
364
433
  function Dropdown1SubMenu({
365
434
  trigger,
366
435
  children,
436
+ placement = DROPDOWN1_SUBMENU_DEFAULTS.placement,
367
437
  contentWidth = DROPDOWN1_SUBMENU_DEFAULTS.contentWidth,
368
438
  styles: stylesProp,
439
+ surface,
369
440
  className
370
441
  }) {
442
+ const direction = (0, import_core6.useDirection)();
443
+ const { resolvedTheme } = (0, import_theme2.useTheme)();
371
444
  const context = useDropdown1Context();
372
445
  const subMenuTriggerStyles = stylesProp?.subMenuTrigger ?? context?.styles.subMenuTrigger;
373
446
  const subMenuContentStyles = stylesProp?.subMenuContent ?? context?.styles.subMenuContent;
374
447
  const subMenuIconStyles = stylesProp?.subMenuIcon ?? context?.styles.subMenuIcon;
375
448
  const [isOpen, setIsOpen] = (0, import_react3.useState)(false);
449
+ const [positionStyle, setPositionStyle] = (0, import_react3.useState)({});
376
450
  const timeoutRef = (0, import_react3.useRef)(null);
377
- const containerRef = (0, import_react3.useRef)(null);
378
- 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 = () => {
379
470
  if (timeoutRef.current) {
380
471
  clearTimeout(timeoutRef.current);
381
472
  timeoutRef.current = null;
382
473
  }
474
+ };
475
+ const handleMouseEnter = () => {
476
+ clearCloseTimeout();
383
477
  setIsOpen(true);
384
478
  };
385
479
  const handleMouseLeave = () => {
@@ -387,6 +481,11 @@ function Dropdown1SubMenu({
387
481
  setIsOpen(false);
388
482
  }, DROPDOWN1_SUBMENU_DEFAULTS.hoverDelayMs);
389
483
  };
484
+ (0, import_react3.useEffect)(() => {
485
+ if (isOpen) {
486
+ updatePosition();
487
+ }
488
+ }, [isOpen, updatePosition]);
390
489
  (0, import_react3.useEffect)(() => {
391
490
  return () => {
392
491
  if (timeoutRef.current) {
@@ -394,45 +493,47 @@ function Dropdown1SubMenu({
394
493
  }
395
494
  };
396
495
  }, []);
397
- 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)(
398
505
  "div",
399
506
  {
400
- ref: containerRef,
401
- 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
+ },
402
515
  onMouseEnter: handleMouseEnter,
403
516
  onMouseLeave: handleMouseLeave,
404
- children: [
405
- /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
406
- "div",
407
- {
408
- role: "menuitem",
409
- "aria-haspopup": "menu",
410
- "aria-expanded": isOpen,
411
- className: (0, import_core5.cn)(dropdown1SubMenuTriggerVariants(), subMenuTriggerStyles),
412
- children: [
413
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "flex items-center gap-2", children: trigger }),
414
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ChevronSubMenuIcon, { open: isOpen, className: (0, import_core5.cn)("transition-transform", subMenuIconStyles) })
415
- ]
416
- }
417
- ),
418
- isOpen && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
419
- "div",
420
- {
421
- role: "menu",
422
- className: (0, import_core5.cn)(
423
- dropdown1SubMenuContentVariants(),
424
- subMenuContentStyles,
425
- contentWidth !== "w-48" && contentWidth,
426
- className
427
- ),
428
- onMouseEnter: handleMouseEnter,
429
- onMouseLeave: handleMouseLeave,
430
- children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "py-1", children })
431
- }
432
- )
433
- ]
517
+ children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "py-1", children })
434
518
  }
435
- );
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
+ ] });
436
537
  }
437
538
 
438
539
  // src/hooks/useDropdown1.ts
package/dist/index.d.cts CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as react from 'react';
2
- import { ReactNode } from 'react';
2
+ import { ReactNode, RefObject } from 'react';
3
+ import { VelocisSurfaceConfig } from '@velocis/theme';
3
4
  import * as class_variance_authority_types from 'class-variance-authority/types';
4
5
 
5
6
  type Dropdown1Styles = {
@@ -13,7 +14,7 @@ type Dropdown1Styles = {
13
14
  item?: string;
14
15
  /** Sub-menu row — text + hover bg/text */
15
16
  subMenuTrigger?: string;
16
- /** Sub-menu panel — defaults to `content` when omitted */
17
+ /** Sub-menu panel — independent default from `content` */
17
18
  subMenuContent?: string;
18
19
  /** Chevron on sub-menu row */
19
20
  subMenuIcon?: string;
@@ -38,6 +39,10 @@ type Dropdown1Props = {
38
39
  onOpenChange?: (open: boolean) => void;
39
40
  /** Override coordinated bg/text/hover classes per layer */
40
41
  styles?: Partial<Dropdown1Styles>;
42
+ /** Menu panel surface — background, tokens, light/dark lock */
43
+ surface?: VelocisSurfaceConfig;
44
+ /** Trigger button surface */
45
+ triggerSurface?: VelocisSurfaceConfig;
41
46
  triggerClassName?: string;
42
47
  contentClassName?: string;
43
48
  contentWidth?: string;
@@ -58,20 +63,25 @@ type Dropdown1ItemProps = {
58
63
  className?: string;
59
64
  testId?: string;
60
65
  };
66
+ type Dropdown1SubMenuPlacement = 'auto' | 'left' | 'right' | 'center';
61
67
  type Dropdown1SubMenuProps = {
62
68
  trigger: ReactNode;
63
69
  children: ReactNode;
70
+ /** Where the sub-panel opens relative to the main menu panel */
71
+ placement?: Dropdown1SubMenuPlacement;
64
72
  contentWidth?: string;
65
73
  /** Override sub-menu colors (defaults to root styles) */
66
74
  styles?: Pick<Dropdown1Styles, 'subMenuTrigger' | 'subMenuContent' | 'subMenuIcon'>;
75
+ /** Independent background/tokens — separate from root `surface` */
76
+ surface?: VelocisSurfaceConfig;
67
77
  className?: string;
68
78
  };
69
79
 
70
- declare function Dropdown1Root({ trigger, children, open: controlledOpen, defaultOpen, onOpenChange, styles: stylesProp, triggerClassName, contentClassName, contentWidth, maxHeight, fullWidth, disabled, contentZIndex, closeOnScroll, testId, contentTestId, }: Dropdown1Props): react.JSX.Element;
80
+ declare function Dropdown1Root({ trigger, children, open: controlledOpen, defaultOpen, onOpenChange, styles: stylesProp, surface, triggerSurface, triggerClassName, contentClassName, contentWidth, maxHeight, fullWidth, disabled, contentZIndex, closeOnScroll, testId, contentTestId, }: Dropdown1Props): react.JSX.Element;
71
81
 
72
82
  declare function Dropdown1Item({ children, onClick, closeOnSelect, styles: stylesProp, className, testId, }: Dropdown1ItemProps): react.JSX.Element;
73
83
 
74
- 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;
75
85
 
76
86
  type UseDropdown1Options = {
77
87
  defaultOpen?: boolean;
@@ -86,6 +96,7 @@ declare function useDropdown1(options?: UseDropdown1Options): UseDropdown1Result
86
96
  type Dropdown1ContextValue = {
87
97
  closeDropdown: () => void;
88
98
  styles: ResolvedDropdown1Styles;
99
+ contentRef: RefObject<HTMLDivElement | null>;
89
100
  };
90
101
  declare const Dropdown1Context: react.Context<Dropdown1ContextValue | null>;
91
102
  declare function useDropdown1Context(): Dropdown1ContextValue | null;
@@ -103,6 +114,8 @@ declare const DROPDOWN1_ITEM_DEFAULTS: {
103
114
  declare const DROPDOWN1_SUBMENU_DEFAULTS: {
104
115
  readonly contentWidth: "w-48";
105
116
  readonly hoverDelayMs: 100;
117
+ readonly placement: "auto";
118
+ readonly subMenuGap: 8;
106
119
  };
107
120
 
108
121
  /** Layout/structure only — colors come from `dropdown1Styles` + `styles` prop */
@@ -120,4 +133,4 @@ declare const Dropdown1: typeof Dropdown1Root & {
120
133
  SubMenu: typeof Dropdown1SubMenu;
121
134
  };
122
135
 
123
- 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,6 @@
1
1
  import * as react from 'react';
2
- import { ReactNode } from 'react';
2
+ import { ReactNode, RefObject } from 'react';
3
+ import { VelocisSurfaceConfig } from '@velocis/theme';
3
4
  import * as class_variance_authority_types from 'class-variance-authority/types';
4
5
 
5
6
  type Dropdown1Styles = {
@@ -13,7 +14,7 @@ type Dropdown1Styles = {
13
14
  item?: string;
14
15
  /** Sub-menu row — text + hover bg/text */
15
16
  subMenuTrigger?: string;
16
- /** Sub-menu panel — defaults to `content` when omitted */
17
+ /** Sub-menu panel — independent default from `content` */
17
18
  subMenuContent?: string;
18
19
  /** Chevron on sub-menu row */
19
20
  subMenuIcon?: string;
@@ -38,6 +39,10 @@ type Dropdown1Props = {
38
39
  onOpenChange?: (open: boolean) => void;
39
40
  /** Override coordinated bg/text/hover classes per layer */
40
41
  styles?: Partial<Dropdown1Styles>;
42
+ /** Menu panel surface — background, tokens, light/dark lock */
43
+ surface?: VelocisSurfaceConfig;
44
+ /** Trigger button surface */
45
+ triggerSurface?: VelocisSurfaceConfig;
41
46
  triggerClassName?: string;
42
47
  contentClassName?: string;
43
48
  contentWidth?: string;
@@ -58,20 +63,25 @@ type Dropdown1ItemProps = {
58
63
  className?: string;
59
64
  testId?: string;
60
65
  };
66
+ type Dropdown1SubMenuPlacement = 'auto' | 'left' | 'right' | 'center';
61
67
  type Dropdown1SubMenuProps = {
62
68
  trigger: ReactNode;
63
69
  children: ReactNode;
70
+ /** Where the sub-panel opens relative to the main menu panel */
71
+ placement?: Dropdown1SubMenuPlacement;
64
72
  contentWidth?: string;
65
73
  /** Override sub-menu colors (defaults to root styles) */
66
74
  styles?: Pick<Dropdown1Styles, 'subMenuTrigger' | 'subMenuContent' | 'subMenuIcon'>;
75
+ /** Independent background/tokens — separate from root `surface` */
76
+ surface?: VelocisSurfaceConfig;
67
77
  className?: string;
68
78
  };
69
79
 
70
- declare function Dropdown1Root({ trigger, children, open: controlledOpen, defaultOpen, onOpenChange, styles: stylesProp, triggerClassName, contentClassName, contentWidth, maxHeight, fullWidth, disabled, contentZIndex, closeOnScroll, testId, contentTestId, }: Dropdown1Props): react.JSX.Element;
80
+ declare function Dropdown1Root({ trigger, children, open: controlledOpen, defaultOpen, onOpenChange, styles: stylesProp, surface, triggerSurface, triggerClassName, contentClassName, contentWidth, maxHeight, fullWidth, disabled, contentZIndex, closeOnScroll, testId, contentTestId, }: Dropdown1Props): react.JSX.Element;
71
81
 
72
82
  declare function Dropdown1Item({ children, onClick, closeOnSelect, styles: stylesProp, className, testId, }: Dropdown1ItemProps): react.JSX.Element;
73
83
 
74
- 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;
75
85
 
76
86
  type UseDropdown1Options = {
77
87
  defaultOpen?: boolean;
@@ -86,6 +96,7 @@ declare function useDropdown1(options?: UseDropdown1Options): UseDropdown1Result
86
96
  type Dropdown1ContextValue = {
87
97
  closeDropdown: () => void;
88
98
  styles: ResolvedDropdown1Styles;
99
+ contentRef: RefObject<HTMLDivElement | null>;
89
100
  };
90
101
  declare const Dropdown1Context: react.Context<Dropdown1ContextValue | null>;
91
102
  declare function useDropdown1Context(): Dropdown1ContextValue | null;
@@ -103,6 +114,8 @@ declare const DROPDOWN1_ITEM_DEFAULTS: {
103
114
  declare const DROPDOWN1_SUBMENU_DEFAULTS: {
104
115
  readonly contentWidth: "w-48";
105
116
  readonly hoverDelayMs: 100;
117
+ readonly placement: "auto";
118
+ readonly subMenuGap: 8;
106
119
  };
107
120
 
108
121
  /** Layout/structure only — colors come from `dropdown1Styles` + `styles` prop */
@@ -120,4 +133,4 @@ declare const Dropdown1: typeof Dropdown1Root & {
120
133
  SubMenu: typeof Dropdown1SubMenu;
121
134
  };
122
135
 
123
- 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
@@ -2,6 +2,7 @@
2
2
 
3
3
  // src/Dropdown1.tsx
4
4
  import { cn as cn2, Portal, useDirection } from "@velocis/core";
5
+ import { applySurface, useTheme } from "@velocis/theme";
5
6
  import {
6
7
  useCallback,
7
8
  useEffect,
@@ -22,7 +23,9 @@ var DROPDOWN1_ITEM_DEFAULTS = {
22
23
  };
23
24
  var DROPDOWN1_SUBMENU_DEFAULTS = {
24
25
  contentWidth: "w-48",
25
- hoverDelayMs: 100
26
+ hoverDelayMs: 100,
27
+ placement: "auto",
28
+ subMenuGap: 8
26
29
  };
27
30
 
28
31
  // src/Dropdown1.variants.ts
@@ -56,7 +59,7 @@ var dropdown1SubMenuTriggerVariants = cva(
56
59
  "flex w-full cursor-default items-center justify-between px-4 py-2 text-sm transition-colors"
57
60
  );
58
61
  var dropdown1SubMenuContentVariants = cva(
59
- "absolute top-0 right-full mr-2 w-48 rounded-velocis-md z-[10000]"
62
+ "fixed w-48 rounded-velocis-md z-[10000]"
60
63
  );
61
64
 
62
65
  // src/context/Dropdown1Context.tsx
@@ -94,7 +97,7 @@ function resolveDropdown1Styles(overrides) {
94
97
  content,
95
98
  item: overrides?.item ?? dropdown1Styles.item,
96
99
  subMenuTrigger: overrides?.subMenuTrigger ?? dropdown1Styles.subMenuTrigger,
97
- subMenuContent: overrides?.subMenuContent ?? content,
100
+ subMenuContent: overrides?.subMenuContent ?? dropdown1Styles.subMenuContent,
98
101
  subMenuIcon: overrides?.subMenuIcon ?? dropdown1Styles.subMenuIcon
99
102
  };
100
103
  }
@@ -162,6 +165,8 @@ function Dropdown1Root({
162
165
  defaultOpen = false,
163
166
  onOpenChange,
164
167
  styles: stylesProp,
168
+ surface,
169
+ triggerSurface,
165
170
  triggerClassName,
166
171
  contentClassName,
167
172
  contentWidth = DROPDOWN1_DEFAULTS.contentWidth,
@@ -174,12 +179,21 @@ function Dropdown1Root({
174
179
  contentTestId
175
180
  }) {
176
181
  const direction = useDirection();
182
+ const { resolvedTheme } = useTheme();
177
183
  const { open, setOpen } = useControllableOpen(controlledOpen, defaultOpen, onOpenChange);
178
184
  const styles = resolveDropdown1Styles(stylesProp);
179
- const dropdownRef = useRef(null);
185
+ const contentRef = useRef(null);
180
186
  const buttonRef = useRef(null);
181
187
  const [positionStyle, setPositionStyle] = useState({});
182
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
+ }, []);
183
197
  const updatePosition = useCallback(() => {
184
198
  if (!buttonRef.current) return;
185
199
  const rect = buttonRef.current.getBoundingClientRect();
@@ -201,7 +215,7 @@ function Dropdown1Root({
201
215
  useEffect(() => {
202
216
  if (!open) return;
203
217
  const handleClickOutside = (event) => {
204
- 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)) {
205
219
  setOpen(false);
206
220
  }
207
221
  };
@@ -213,7 +227,7 @@ function Dropdown1Root({
213
227
  const handleScroll = (event) => {
214
228
  if (!closeOnScroll) return;
215
229
  const target = event.target;
216
- if (dropdownRef.current && target && !dropdownRef.current.contains(target) && !buttonRef.current?.contains(target)) {
230
+ if (target && !isInsideDropdownPanel(target) && !buttonRef.current?.contains(target)) {
217
231
  setOpen(false);
218
232
  }
219
233
  };
@@ -225,22 +239,26 @@ function Dropdown1Root({
225
239
  document.removeEventListener("keydown", handleKeyDown);
226
240
  document.removeEventListener("scroll", handleScroll, true);
227
241
  };
228
- }, [closeOnScroll, open, setOpen]);
229
- const dropdownContent = open ? /* @__PURE__ */ jsx2(Dropdown1Context.Provider, { value: { closeDropdown, styles }, children: /* @__PURE__ */ jsx2(
242
+ }, [closeOnScroll, isInsideDropdownPanel, open, setOpen]);
243
+ const contentSurfaceProps = applySurface(surface, resolvedTheme, {
244
+ className: cn2(
245
+ dropdown1ContentVariants(),
246
+ styles.content,
247
+ !fullWidth && contentWidth,
248
+ maxHeight && "overflow-y-auto",
249
+ contentClassName
250
+ )
251
+ });
252
+ const dropdownContent = open ? /* @__PURE__ */ jsx2(Dropdown1Context.Provider, { value: { closeDropdown, styles, contentRef }, children: /* @__PURE__ */ jsx2(
230
253
  "div",
231
254
  {
232
- ref: dropdownRef,
255
+ ref: contentRef,
233
256
  role: "menu",
234
257
  "data-testid": contentTestId,
235
258
  dir: direction,
236
- className: cn2(
237
- dropdown1ContentVariants(),
238
- styles.content,
239
- !fullWidth && contentWidth,
240
- maxHeight && "overflow-y-auto",
241
- contentClassName
242
- ),
259
+ ...contentSurfaceProps,
243
260
  style: {
261
+ ...contentSurfaceProps.style,
244
262
  ...positionStyle,
245
263
  zIndex: contentZIndex,
246
264
  maxHeight: maxHeight || void 0
@@ -248,6 +266,13 @@ function Dropdown1Root({
248
266
  children: /* @__PURE__ */ jsx2("div", { className: "py-1", children })
249
267
  }
250
268
  ) }) : null;
269
+ const triggerSurfaceProps = applySurface(triggerSurface, resolvedTheme, {
270
+ className: cn2(
271
+ dropdown1TriggerVariants({ fullWidth, disabled }),
272
+ styles.trigger,
273
+ triggerClassName
274
+ )
275
+ });
251
276
  return /* @__PURE__ */ jsxs(Fragment, { children: [
252
277
  /* @__PURE__ */ jsx2(
253
278
  "div",
@@ -266,11 +291,7 @@ function Dropdown1Root({
266
291
  "aria-haspopup": "menu",
267
292
  onClick: () => !disabled && setOpen(!open),
268
293
  "data-testid": testId,
269
- className: cn2(
270
- dropdown1TriggerVariants({ fullWidth, disabled }),
271
- styles.trigger,
272
- triggerClassName
273
- ),
294
+ ...triggerSurfaceProps,
274
295
  children: [
275
296
  trigger,
276
297
  /* @__PURE__ */ jsx2(ChevronDownIcon, { className: styles.triggerIcon })
@@ -322,28 +343,101 @@ function Dropdown1Item({
322
343
  }
323
344
 
324
345
  // src/components/Dropdown1SubMenu.tsx
325
- import { cn as cn4 } from "@velocis/core";
326
- 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
327
396
  import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
328
397
  function Dropdown1SubMenu({
329
398
  trigger,
330
399
  children,
400
+ placement = DROPDOWN1_SUBMENU_DEFAULTS.placement,
331
401
  contentWidth = DROPDOWN1_SUBMENU_DEFAULTS.contentWidth,
332
402
  styles: stylesProp,
403
+ surface,
333
404
  className
334
405
  }) {
406
+ const direction = useDirection2();
407
+ const { resolvedTheme } = useTheme2();
335
408
  const context = useDropdown1Context();
336
409
  const subMenuTriggerStyles = stylesProp?.subMenuTrigger ?? context?.styles.subMenuTrigger;
337
410
  const subMenuContentStyles = stylesProp?.subMenuContent ?? context?.styles.subMenuContent;
338
411
  const subMenuIconStyles = stylesProp?.subMenuIcon ?? context?.styles.subMenuIcon;
339
412
  const [isOpen, setIsOpen] = useState2(false);
413
+ const [positionStyle, setPositionStyle] = useState2({});
340
414
  const timeoutRef = useRef2(null);
341
- const containerRef = useRef2(null);
342
- 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 = () => {
343
434
  if (timeoutRef.current) {
344
435
  clearTimeout(timeoutRef.current);
345
436
  timeoutRef.current = null;
346
437
  }
438
+ };
439
+ const handleMouseEnter = () => {
440
+ clearCloseTimeout();
347
441
  setIsOpen(true);
348
442
  };
349
443
  const handleMouseLeave = () => {
@@ -351,6 +445,11 @@ function Dropdown1SubMenu({
351
445
  setIsOpen(false);
352
446
  }, DROPDOWN1_SUBMENU_DEFAULTS.hoverDelayMs);
353
447
  };
448
+ useEffect2(() => {
449
+ if (isOpen) {
450
+ updatePosition();
451
+ }
452
+ }, [isOpen, updatePosition]);
354
453
  useEffect2(() => {
355
454
  return () => {
356
455
  if (timeoutRef.current) {
@@ -358,52 +457,54 @@ function Dropdown1SubMenu({
358
457
  }
359
458
  };
360
459
  }, []);
361
- 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(
362
469
  "div",
363
470
  {
364
- ref: containerRef,
365
- 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
+ },
366
479
  onMouseEnter: handleMouseEnter,
367
480
  onMouseLeave: handleMouseLeave,
368
- children: [
369
- /* @__PURE__ */ jsxs2(
370
- "div",
371
- {
372
- role: "menuitem",
373
- "aria-haspopup": "menu",
374
- "aria-expanded": isOpen,
375
- className: cn4(dropdown1SubMenuTriggerVariants(), subMenuTriggerStyles),
376
- children: [
377
- /* @__PURE__ */ jsx4("span", { className: "flex items-center gap-2", children: trigger }),
378
- /* @__PURE__ */ jsx4(ChevronSubMenuIcon, { open: isOpen, className: cn4("transition-transform", subMenuIconStyles) })
379
- ]
380
- }
381
- ),
382
- isOpen && /* @__PURE__ */ jsx4(
383
- "div",
384
- {
385
- role: "menu",
386
- className: cn4(
387
- dropdown1SubMenuContentVariants(),
388
- subMenuContentStyles,
389
- contentWidth !== "w-48" && contentWidth,
390
- className
391
- ),
392
- onMouseEnter: handleMouseEnter,
393
- onMouseLeave: handleMouseLeave,
394
- children: /* @__PURE__ */ jsx4("div", { className: "py-1", children })
395
- }
396
- )
397
- ]
481
+ children: /* @__PURE__ */ jsx4("div", { className: "py-1", children })
398
482
  }
399
- );
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
+ ] });
400
501
  }
401
502
 
402
503
  // src/hooks/useDropdown1.ts
403
- import { useCallback as useCallback2, useState as useState3 } from "react";
504
+ import { useCallback as useCallback3, useState as useState3 } from "react";
404
505
  function useDropdown1(options = {}) {
405
506
  const [open, setOpen] = useState3(options.defaultOpen ?? false);
406
- const onOpenChange = useCallback2((next) => {
507
+ const onOpenChange = useCallback3((next) => {
407
508
  setOpen(next);
408
509
  }, []);
409
510
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velocis/dropdown1",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "sideEffects": false,
6
6
  "type": "module",
@@ -24,7 +24,7 @@
24
24
  "dependencies": {
25
25
  "class-variance-authority": "^0.7.1",
26
26
  "@velocis/core": "0.1.0",
27
- "@velocis/theme": "0.1.1"
27
+ "@velocis/theme": "0.2.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/react": "^19.0.2",