@starwind-ui/core 1.15.4 → 1.16.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.
Files changed (183) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.js +67 -43
  3. package/dist/index.js.map +1 -1
  4. package/dist/src/components/accordion/Accordion.astro +1 -1
  5. package/dist/src/components/accordion/AccordionContent.astro +1 -1
  6. package/dist/src/components/accordion/AccordionItem.astro +1 -1
  7. package/dist/src/components/accordion/AccordionTrigger.astro +1 -1
  8. package/dist/src/components/alert/Alert.astro +1 -1
  9. package/dist/src/components/alert/AlertDescription.astro +1 -1
  10. package/dist/src/components/alert/AlertTitle.astro +1 -1
  11. package/dist/src/components/alert-dialog/AlertDialog.astro +1 -1
  12. package/dist/src/components/alert-dialog/AlertDialogAction.astro +1 -1
  13. package/dist/src/components/alert-dialog/AlertDialogCancel.astro +1 -1
  14. package/dist/src/components/alert-dialog/AlertDialogContent.astro +3 -3
  15. package/dist/src/components/alert-dialog/AlertDialogDescription.astro +1 -1
  16. package/dist/src/components/alert-dialog/AlertDialogFooter.astro +1 -1
  17. package/dist/src/components/alert-dialog/AlertDialogHeader.astro +1 -1
  18. package/dist/src/components/alert-dialog/AlertDialogTitle.astro +1 -1
  19. package/dist/src/components/alert-dialog/AlertDialogTrigger.astro +2 -2
  20. package/dist/src/components/aspect-ratio/AspectRatio.astro +1 -1
  21. package/dist/src/components/avatar/Avatar.astro +1 -1
  22. package/dist/src/components/avatar/AvatarFallback.astro +1 -1
  23. package/dist/src/components/avatar/AvatarImage.astro +2 -2
  24. package/dist/src/components/badge/Badge.astro +1 -1
  25. package/dist/src/components/breadcrumb/Breadcrumb.astro +1 -1
  26. package/dist/src/components/breadcrumb/BreadcrumbEllipsis.astro +1 -1
  27. package/dist/src/components/breadcrumb/BreadcrumbItem.astro +1 -1
  28. package/dist/src/components/breadcrumb/BreadcrumbLink.astro +1 -1
  29. package/dist/src/components/breadcrumb/BreadcrumbList.astro +2 -2
  30. package/dist/src/components/breadcrumb/BreadcrumbPage.astro +1 -1
  31. package/dist/src/components/breadcrumb/BreadcrumbSeparator.astro +1 -1
  32. package/dist/src/components/button/Button.astro +1 -1
  33. package/dist/src/components/button-group/ButtonGroup.astro +1 -1
  34. package/dist/src/components/button-group/ButtonGroupSeparator.astro +1 -1
  35. package/dist/src/components/button-group/ButtonGroupText.astro +1 -1
  36. package/dist/src/components/card/Card.astro +1 -1
  37. package/dist/src/components/card/CardAction.astro +1 -1
  38. package/dist/src/components/card/CardContent.astro +1 -1
  39. package/dist/src/components/card/CardDescription.astro +1 -1
  40. package/dist/src/components/card/CardFooter.astro +1 -1
  41. package/dist/src/components/card/CardHeader.astro +1 -1
  42. package/dist/src/components/card/CardTitle.astro +1 -1
  43. package/dist/src/components/carousel/Carousel.astro +1 -1
  44. package/dist/src/components/carousel/CarouselContent.astro +1 -1
  45. package/dist/src/components/carousel/CarouselItem.astro +1 -1
  46. package/dist/src/components/carousel/CarouselNext.astro +1 -1
  47. package/dist/src/components/carousel/CarouselPrevious.astro +1 -1
  48. package/dist/src/components/checkbox/Checkbox.astro +1 -1
  49. package/dist/src/components/collapsible/Collapsible.astro +1 -1
  50. package/dist/src/components/collapsible/CollapsibleContent.astro +1 -1
  51. package/dist/src/components/collapsible/CollapsibleTrigger.astro +1 -1
  52. package/dist/src/components/dialog/Dialog.astro +1 -1
  53. package/dist/src/components/dialog/DialogClose.astro +1 -1
  54. package/dist/src/components/dialog/DialogContent.astro +2 -2
  55. package/dist/src/components/dialog/DialogDescription.astro +1 -1
  56. package/dist/src/components/dialog/DialogFooter.astro +1 -1
  57. package/dist/src/components/dialog/DialogHeader.astro +1 -1
  58. package/dist/src/components/dialog/DialogTitle.astro +1 -1
  59. package/dist/src/components/dialog/DialogTrigger.astro +1 -1
  60. package/dist/src/components/dropdown/Dropdown.astro +261 -34
  61. package/dist/src/components/dropdown/DropdownContent.astro +41 -5
  62. package/dist/src/components/dropdown/DropdownItem.astro +2 -2
  63. package/dist/src/components/dropdown/DropdownLabel.astro +2 -2
  64. package/dist/src/components/dropdown/DropdownSeparator.astro +1 -1
  65. package/dist/src/components/dropdown/DropdownShortcut.astro +17 -0
  66. package/dist/src/components/dropdown/DropdownSub.astro +15 -0
  67. package/dist/src/components/dropdown/DropdownSubContent.astro +36 -0
  68. package/dist/src/components/dropdown/DropdownSubTrigger.astro +34 -0
  69. package/dist/src/components/dropdown/DropdownTrigger.astro +1 -1
  70. package/dist/src/components/dropdown/index.ts +12 -0
  71. package/dist/src/components/dropzone/DropzoneFilesList.astro +1 -1
  72. package/dist/src/components/image/Image.astro +1 -1
  73. package/dist/src/components/input/Input.astro +1 -1
  74. package/dist/src/components/input-group/InputGroup.astro +28 -0
  75. package/dist/src/components/input-group/InputGroupAddon.astro +79 -0
  76. package/dist/src/components/input-group/InputGroupButton.astro +34 -0
  77. package/dist/src/components/input-group/InputGroupInput.astro +16 -0
  78. package/dist/src/components/input-group/InputGroupText.astro +17 -0
  79. package/dist/src/components/input-group/InputGroupTextarea.astro +20 -0
  80. package/dist/src/components/input-group/index.ts +33 -0
  81. package/dist/src/components/input-otp/InputOtp.astro +1 -1
  82. package/dist/src/components/input-otp/InputOtpGroup.astro +1 -1
  83. package/dist/src/components/input-otp/InputOtpSeparator.astro +1 -1
  84. package/dist/src/components/input-otp/InputOtpSlot.astro +1 -1
  85. package/dist/src/components/item/Item.astro +1 -1
  86. package/dist/src/components/item/ItemActions.astro +1 -1
  87. package/dist/src/components/item/ItemContent.astro +1 -1
  88. package/dist/src/components/item/ItemDescription.astro +1 -1
  89. package/dist/src/components/item/ItemFooter.astro +1 -1
  90. package/dist/src/components/item/ItemGroup.astro +1 -1
  91. package/dist/src/components/item/ItemHeader.astro +1 -1
  92. package/dist/src/components/item/ItemMedia.astro +1 -1
  93. package/dist/src/components/item/ItemSeparator.astro +1 -1
  94. package/dist/src/components/item/ItemTitle.astro +1 -1
  95. package/dist/src/components/kbd/Kbd.astro +1 -1
  96. package/dist/src/components/kbd/KbdGroup.astro +1 -1
  97. package/dist/src/components/label/Label.astro +1 -1
  98. package/dist/src/components/native-select/NativeSelect.astro +64 -0
  99. package/dist/src/components/native-select/NativeSelectOptGroup.astro +15 -0
  100. package/dist/src/components/native-select/NativeSelectOption.astro +15 -0
  101. package/dist/src/components/native-select/index.ts +21 -0
  102. package/dist/src/components/pagination/Pagination.astro +1 -1
  103. package/dist/src/components/pagination/PaginationContent.astro +1 -1
  104. package/dist/src/components/pagination/PaginationEllipsis.astro +1 -1
  105. package/dist/src/components/pagination/PaginationItem.astro +1 -1
  106. package/dist/src/components/pagination/PaginationLink.astro +1 -1
  107. package/dist/src/components/pagination/PaginationNext.astro +1 -1
  108. package/dist/src/components/pagination/PaginationPrevious.astro +1 -1
  109. package/dist/src/components/popover/Popover.astro +717 -0
  110. package/dist/src/components/popover/PopoverContent.astro +102 -0
  111. package/dist/src/components/popover/PopoverDescription.astro +14 -0
  112. package/dist/src/components/popover/PopoverHeader.astro +14 -0
  113. package/dist/src/components/popover/PopoverTitle.astro +14 -0
  114. package/dist/src/components/popover/PopoverTrigger.astro +51 -0
  115. package/dist/src/components/popover/index.ts +34 -0
  116. package/dist/src/components/progress/Progress.astro +1 -1
  117. package/dist/src/components/prose/Prose.astro +1 -1
  118. package/dist/src/components/radio-group/RadioGroup.astro +1 -1
  119. package/dist/src/components/radio-group/RadioGroupItem.astro +1 -1
  120. package/dist/src/components/select/Select.astro +1 -1
  121. package/dist/src/components/select/SelectContent.astro +1 -1
  122. package/dist/src/components/select/SelectItem.astro +1 -1
  123. package/dist/src/components/select/SelectLabel.astro +1 -1
  124. package/dist/src/components/select/SelectSearch.astro +1 -1
  125. package/dist/src/components/select/SelectSeparator.astro +1 -1
  126. package/dist/src/components/select/SelectTrigger.astro +1 -1
  127. package/dist/src/components/select/SelectValue.astro +1 -1
  128. package/dist/src/components/separator/Separator.astro +1 -1
  129. package/dist/src/components/sheet/Sheet.astro +1 -1
  130. package/dist/src/components/sheet/SheetContent.astro +1 -1
  131. package/dist/src/components/sheet/SheetDescription.astro +1 -1
  132. package/dist/src/components/sheet/SheetFooter.astro +1 -1
  133. package/dist/src/components/sheet/SheetHeader.astro +1 -1
  134. package/dist/src/components/sheet/SheetTitle.astro +1 -1
  135. package/dist/src/components/sidebar/Sidebar.astro +19 -2
  136. package/dist/src/components/sidebar/SidebarContent.astro +1 -1
  137. package/dist/src/components/sidebar/SidebarFooter.astro +1 -1
  138. package/dist/src/components/sidebar/SidebarGroup.astro +1 -1
  139. package/dist/src/components/sidebar/SidebarGroupContent.astro +1 -1
  140. package/dist/src/components/sidebar/SidebarGroupLabel.astro +2 -2
  141. package/dist/src/components/sidebar/SidebarHeader.astro +1 -1
  142. package/dist/src/components/sidebar/SidebarInput.astro +1 -1
  143. package/dist/src/components/sidebar/SidebarInset.astro +1 -1
  144. package/dist/src/components/sidebar/SidebarMenu.astro +1 -1
  145. package/dist/src/components/sidebar/SidebarMenuAction.astro +1 -1
  146. package/dist/src/components/sidebar/SidebarMenuBadge.astro +1 -1
  147. package/dist/src/components/sidebar/SidebarMenuButton.astro +2 -14
  148. package/dist/src/components/sidebar/SidebarMenuItem.astro +1 -1
  149. package/dist/src/components/sidebar/SidebarMenuSkeleton.astro +1 -1
  150. package/dist/src/components/sidebar/SidebarMenuSub.astro +1 -1
  151. package/dist/src/components/sidebar/SidebarMenuSubButton.astro +1 -1
  152. package/dist/src/components/sidebar/SidebarMenuSubItem.astro +1 -1
  153. package/dist/src/components/sidebar/SidebarProvider.astro +1 -1
  154. package/dist/src/components/sidebar/SidebarRail.astro +2 -2
  155. package/dist/src/components/sidebar/SidebarSeparator.astro +1 -1
  156. package/dist/src/components/sidebar/SidebarTrigger.astro +1 -1
  157. package/dist/src/components/skeleton/Skeleton.astro +1 -1
  158. package/dist/src/components/slider/Slider.astro +1 -1
  159. package/dist/src/components/spinner/Spinner.astro +1 -1
  160. package/dist/src/components/switch/Switch.astro +1 -1
  161. package/dist/src/components/table/Table.astro +1 -1
  162. package/dist/src/components/table/TableBody.astro +1 -1
  163. package/dist/src/components/table/TableCaption.astro +1 -1
  164. package/dist/src/components/table/TableCell.astro +1 -1
  165. package/dist/src/components/table/TableFoot.astro +1 -1
  166. package/dist/src/components/table/TableHead.astro +1 -1
  167. package/dist/src/components/table/TableHeader.astro +1 -1
  168. package/dist/src/components/table/TableRow.astro +1 -1
  169. package/dist/src/components/tabs/Tabs.astro +1 -1
  170. package/dist/src/components/tabs/TabsContent.astro +1 -1
  171. package/dist/src/components/tabs/TabsList.astro +1 -1
  172. package/dist/src/components/tabs/TabsTrigger.astro +1 -1
  173. package/dist/src/components/textarea/Textarea.astro +1 -1
  174. package/dist/src/components/theme-toggle/ThemeToggle.astro +1 -1
  175. package/dist/src/components/toast/ToastDescription.astro +1 -1
  176. package/dist/src/components/toast/ToastItem.astro +1 -1
  177. package/dist/src/components/toast/Toaster.astro +1 -1
  178. package/dist/src/components/toggle/Toggle.astro +1 -1
  179. package/dist/src/components/tooltip/Tooltip.astro +260 -122
  180. package/dist/src/components/tooltip/TooltipContent.astro +13 -23
  181. package/dist/src/components/video/Video.astro +2 -2
  182. package/dist/src/lib/utils/starwind/positioning.ts +318 -0
  183. package/package.json +1 -1
@@ -22,8 +22,8 @@ const { class: className, openOnHover = false, closeDelay = 200, ...rest } = Ast
22
22
  class:list={["starwind-dropdown", "relative", className]}
23
23
  data-open-on-hover={openOnHover ? "true" : undefined}
24
24
  data-close-delay={closeDelay}
25
- data-slot="dropdown"
26
25
  {...rest}
26
+ data-slot="dropdown"
27
27
  >
28
28
  <slot />
29
29
  </div>
@@ -31,7 +31,7 @@ const { class: className, openOnHover = false, closeDelay = 200, ...rest } = Ast
31
31
  <script>
32
32
  class DropdownHandler {
33
33
  private dropdown: HTMLElement;
34
- private trigger: HTMLButtonElement | null;
34
+ private trigger: HTMLElement | null;
35
35
  private content: HTMLElement | null;
36
36
  private items: HTMLElement[] = [];
37
37
  private currentFocusIndex: number = -1;
@@ -43,23 +43,38 @@ const { class: className, openOnHover = false, closeDelay = 200, ...rest } = Ast
43
43
  private closeTimerRef: number | null = null;
44
44
  private lastOpenSource: "keyboard" | "mouse" = "keyboard";
45
45
  private lastCloseSource: "keyboard" | "mouse" = "keyboard";
46
+ private isSubmenu: boolean;
47
+ private rootDropdown: HTMLElement;
48
+ private contentPlaceholder: Comment | null = null;
49
+ private cleanupAutoUpdate: (() => void) | null = null;
50
+ private openChildCount: number = 0;
51
+ private parentHandler: DropdownHandler | null = null;
46
52
 
47
53
  constructor(dropdown: HTMLElement, dropdownIdx: number) {
48
54
  this.dropdown = dropdown;
49
- this.openOnHover = dropdown.getAttribute("data-open-on-hover") === "true";
55
+ this.isSubmenu = dropdown.classList.contains("starwind-dropdown-sub");
56
+ this.openOnHover = dropdown.getAttribute("data-open-on-hover") === "true" || this.isSubmenu;
50
57
  this.closeDelay = parseInt(dropdown.getAttribute("data-close-delay") || "200");
51
58
 
52
- // Get the temporary trigger element
53
- const tempTrigger = dropdown.querySelector(".starwind-dropdown-trigger") as HTMLElement;
59
+ // Find the trigger and content that belong to this dropdown, avoiding nested ones
60
+ this.trigger = this.findOwnElement('[data-slot="dropdown-trigger"], [data-sub-trigger]');
54
61
 
55
62
  // if trigger is set with asChild, use the first child element for trigger button
56
- if (tempTrigger?.hasAttribute("data-as-child")) {
57
- this.trigger = tempTrigger.firstElementChild as HTMLButtonElement;
58
- } else {
59
- this.trigger = tempTrigger as HTMLButtonElement;
63
+ if (this.trigger?.hasAttribute("data-as-child")) {
64
+ this.trigger = this.trigger.firstElementChild as HTMLElement;
60
65
  }
61
66
 
62
- this.content = dropdown.querySelector(".starwind-dropdown-content");
67
+ this.content = this.findOwnElement('[data-slot="dropdown-content"]');
68
+
69
+ // Resolve root dropdown before any portaling (DOM is in original state)
70
+ this.rootDropdown =
71
+ (this.dropdown.closest(".starwind-dropdown") as HTMLElement) || this.dropdown;
72
+
73
+ // Store handler reference and root dropdown on content element for lookups
74
+ if (this.content) {
75
+ (this.content as any).__dropdownHandler = this;
76
+ (this.content as any).__rootDropdown = this.rootDropdown;
77
+ }
63
78
 
64
79
  if (!this.trigger || !this.content) return;
65
80
 
@@ -74,6 +89,16 @@ const { class: className, openOnHover = false, closeDelay = 200, ...rest } = Ast
74
89
  this.init(dropdownIdx);
75
90
  }
76
91
 
92
+ private findOwnElement(selector: string): HTMLElement | null {
93
+ const elements = this.dropdown.querySelectorAll(selector);
94
+ for (const el of elements) {
95
+ if (el.closest(".starwind-dropdown, .starwind-dropdown-sub") === this.dropdown) {
96
+ return el as HTMLElement;
97
+ }
98
+ }
99
+ return null;
100
+ }
101
+
77
102
  private init(dropdownIdx: number) {
78
103
  this.setupAccessibility(dropdownIdx);
79
104
  this.setupEvents();
@@ -101,18 +126,28 @@ const { class: className, openOnHover = false, closeDelay = 200, ...rest } = Ast
101
126
  this.toggleDropdown();
102
127
  });
103
128
 
104
- // Handle keyboard navigation
129
+ // Handle keyboard navigation on trigger
105
130
  this.trigger.addEventListener("keydown", (e) => {
106
131
  if (e.key === "Enter" || e.key === " ") {
107
132
  e.preventDefault();
133
+ e.stopPropagation();
108
134
  this.lastOpenSource = "keyboard";
109
- this.toggleDropdown();
135
+ if (this.isSubmenu) {
136
+ this.openDropdown();
137
+ } else {
138
+ this.toggleDropdown();
139
+ }
110
140
  } else if (e.key === "Escape" && this.isOpen) {
111
141
  e.preventDefault();
142
+ e.stopPropagation();
112
143
  this.lastCloseSource = "keyboard";
113
- this.closeDropdown();
144
+ // Close all menus in the group, focus root trigger
145
+ this.rootDropdown.dispatchEvent(
146
+ new CustomEvent("starwind-dropdown:close-all", { detail: { focusRoot: true } }),
147
+ );
114
148
  } else if (this.isOpen && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
115
149
  e.preventDefault();
150
+ e.stopPropagation();
116
151
  this.lastOpenSource = "keyboard";
117
152
  this.updateDropdownItems();
118
153
  if (e.key === "ArrowDown") {
@@ -120,15 +155,37 @@ const { class: className, openOnHover = false, closeDelay = 200, ...rest } = Ast
120
155
  } else {
121
156
  this.focusItem(this.items.length - 1); // Focus last item when opening with arrow up
122
157
  }
158
+ } else if (e.key === "ArrowRight" && this.isSubmenu) {
159
+ e.preventDefault();
160
+ e.stopPropagation();
161
+ if (!this.isOpen) {
162
+ this.lastOpenSource = "keyboard";
163
+ this.openDropdown();
164
+ } else {
165
+ this.updateDropdownItems();
166
+ this.focusItem(0);
167
+ }
123
168
  }
124
169
  });
125
170
 
171
+ // Listen for close-all event (routed through rootDropdown to work across portaled content)
172
+ this.rootDropdown.addEventListener("starwind-dropdown:close-all", ((e: CustomEvent) => {
173
+ this.closeDropdown({ skipFocus: e.detail?.focusRoot && this.isSubmenu });
174
+ }) as EventListener);
175
+
176
+ // Listen for cancel-close event (routed through rootDropdown)
177
+ this.rootDropdown.addEventListener("starwind-dropdown:cancel-close", () => {
178
+ this.clearCloseTimer();
179
+ });
180
+
126
181
  // Close dropdown when clicking outside for mouse
127
182
  document.addEventListener("pointerdown", (e) => {
128
- if (this.isOpen && !this.dropdown.contains(e.target as Node)) {
129
- // only call handler if it's the left button (mousedown gets triggered by all mouse buttons)
130
- // but not when the control key is pressed (avoiding MacOS right click); also not for touch
131
- // devices because that would open the menu on scroll. (pen devices behave as touch on iOS).
183
+ if (
184
+ this.isOpen &&
185
+ !this.dropdown.contains(e.target as Node) &&
186
+ !this.content?.contains(e.target as Node) &&
187
+ !this.isTargetInGroup(e.target as Node)
188
+ ) {
132
189
  if (e.button === 0 && e.ctrlKey === false && e.pointerType === "mouse") {
133
190
  this.closeDropdown();
134
191
  }
@@ -138,8 +195,10 @@ const { class: className, openOnHover = false, closeDelay = 200, ...rest } = Ast
138
195
  // Handle click outside select content to close for mobile
139
196
  document.addEventListener("click", (e) => {
140
197
  if (
141
- !(this.trigger?.contains(e.target as Node) || this.content?.contains(e.target as Node)) &&
142
- this.isOpen
198
+ this.isOpen &&
199
+ !this.trigger?.contains(e.target as Node) &&
200
+ !this.content?.contains(e.target as Node) &&
201
+ !this.isTargetInGroup(e.target as Node)
143
202
  ) {
144
203
  this.closeDropdown();
145
204
  }
@@ -149,8 +208,11 @@ const { class: className, openOnHover = false, closeDelay = 200, ...rest } = Ast
149
208
  this.content.addEventListener("keydown", (e) => {
150
209
  if (e.key === "Escape") {
151
210
  e.preventDefault();
152
- this.closeDropdown();
153
- this.trigger?.focus();
211
+ e.stopPropagation();
212
+ // Close all menus in the group, focus root trigger
213
+ this.rootDropdown.dispatchEvent(
214
+ new CustomEvent("starwind-dropdown:close-all", { detail: { focusRoot: true } }),
215
+ );
154
216
  } else if (this.isOpen) {
155
217
  this.handleMenuKeydown(e);
156
218
  }
@@ -161,9 +223,13 @@ const { class: className, openOnHover = false, closeDelay = 200, ...rest } = Ast
161
223
  const target = e.target as HTMLElement;
162
224
  const item = target.closest('[role="menuitem"]');
163
225
  if (item && !(item as HTMLElement).hasAttribute("data-disabled")) {
226
+ if (item.hasAttribute("aria-haspopup")) return;
227
+
164
228
  // Close the dropdown after item selection
165
229
  this.closeDropdown();
166
- // console.log("click closing");
230
+
231
+ // Dispatch close-all through root to close all menus in the group
232
+ this.rootDropdown.dispatchEvent(new CustomEvent("starwind-dropdown:close-all"));
167
233
  }
168
234
  });
169
235
 
@@ -198,6 +264,7 @@ const { class: className, openOnHover = false, closeDelay = 200, ...rest } = Ast
198
264
 
199
265
  this.dropdown.addEventListener("pointerleave", (e) => {
200
266
  if (e.pointerType !== "mouse") return;
267
+ if (this.openChildCount > 0) return;
201
268
  if (this.isOpen) {
202
269
  this.lastCloseSource = "mouse";
203
270
  this.closeDropdownDelayed();
@@ -220,40 +287,69 @@ const { class: className, openOnHover = false, closeDelay = 200, ...rest } = Ast
220
287
  if (this.items.length === 0) return;
221
288
 
222
289
  const currentIdx = this.currentFocusIndex;
290
+ const currentItem = this.items[currentIdx];
223
291
 
224
292
  switch (e.key) {
225
293
  case "ArrowDown":
226
294
  e.preventDefault();
295
+ e.stopPropagation();
227
296
  this.focusItem(currentIdx === -1 ? 0 : currentIdx + 1);
228
297
  break;
229
298
  case "ArrowUp":
230
299
  e.preventDefault();
300
+ e.stopPropagation();
231
301
  this.focusItem(currentIdx === -1 ? this.items.length - 1 : currentIdx - 1);
232
302
  break;
303
+ case "ArrowRight":
304
+ if (currentItem?.getAttribute("aria-haspopup") === "true") {
305
+ e.preventDefault();
306
+ e.stopPropagation();
307
+ currentItem.click();
308
+ }
309
+ break;
310
+ case "ArrowLeft":
311
+ if (this.isSubmenu) {
312
+ e.preventDefault();
313
+ e.stopPropagation();
314
+ this.closeDropdown();
315
+ }
316
+ break;
233
317
  case "Home":
234
318
  e.preventDefault();
319
+ e.stopPropagation();
235
320
  this.focusItem(0);
236
321
  break;
237
322
  case "End":
238
323
  e.preventDefault();
324
+ e.stopPropagation();
239
325
  this.focusItem(this.items.length - 1);
240
326
  break;
241
327
  case "Enter":
242
328
  case " ":
243
329
  if (currentIdx !== -1) {
244
330
  e.preventDefault();
331
+ e.stopPropagation();
245
332
  this.items[currentIdx].click();
246
333
  }
247
334
  break;
248
335
  }
249
336
  }
250
337
 
338
+ private isTargetInGroup(target: Node): boolean {
339
+ const el = target instanceof HTMLElement ? target : target.parentElement;
340
+ if (!el) return false;
341
+ const contentEl = el.closest('[data-slot="dropdown-content"]');
342
+ return !!contentEl && (contentEl as any).__rootDropdown === this.rootDropdown;
343
+ }
344
+
251
345
  private updateDropdownItems() {
252
346
  if (!this.content) return;
253
- // Get all interactive menuitem elements
254
- this.items = Array.from(
255
- this.content.querySelectorAll('[role="menuitem"]:not([data-disabled="true"])'),
256
- ) as HTMLElement[];
347
+ // Get all interactive menuitem elements belonging to this menu level only
348
+ this.items = (
349
+ Array.from(
350
+ this.content.querySelectorAll('[role="menuitem"]:not([data-disabled="true"])'),
351
+ ) as HTMLElement[]
352
+ ).filter((item) => item.closest('[role="menu"]') === this.content);
257
353
  }
258
354
 
259
355
  private focusItem(idx: number) {
@@ -276,13 +372,19 @@ const { class: className, openOnHover = false, closeDelay = 200, ...rest } = Ast
276
372
 
277
373
  private openDropdown() {
278
374
  if (this.isClosing) return;
279
- if (!this.content || !this.trigger || this.trigger.disabled) return;
375
+ if (!this.content || !this.trigger || (this.trigger as any).disabled) return;
280
376
 
281
377
  this.isOpen = true;
282
- this.content.setAttribute("data-state", "open");
283
378
  this.trigger.setAttribute("aria-expanded", "true");
284
379
  this.content.style.removeProperty("display");
285
380
 
381
+ // Portal sub-content to body before triggering animation
382
+ if (this.isSubmenu) {
383
+ this.portalContent();
384
+ }
385
+
386
+ this.content.setAttribute("data-state", "open");
387
+
286
388
  // Update the list of dropdown items
287
389
  this.updateDropdownItems();
288
390
 
@@ -290,9 +392,17 @@ const { class: className, openOnHover = false, closeDelay = 200, ...rest } = Ast
290
392
  this.currentFocusIndex = -1;
291
393
 
292
394
  this.positionContent();
395
+
396
+ // For submenus opened by keyboard, focus the first item
397
+ if (this.isSubmenu && this.lastOpenSource === "keyboard") {
398
+ requestAnimationFrame(() => {
399
+ this.focusItem(0);
400
+ });
401
+ }
293
402
  }
294
403
 
295
- private closeDropdown() {
404
+ private closeDropdown(options?: { skipFocus?: boolean }) {
405
+ if (!this.isOpen) return;
296
406
  if (!this.content || !this.trigger) return;
297
407
 
298
408
  this.isClosing = true;
@@ -300,10 +410,12 @@ const { class: className, openOnHover = false, closeDelay = 200, ...rest } = Ast
300
410
  this.content.setAttribute("data-state", "closed");
301
411
 
302
412
  // Set focus back on trigger only if opened or closed by keyboard
413
+ // Skip focus if a close-all with focusRoot is closing this submenu (root will handle focus)
303
414
  if (
304
- !this.openOnHover ||
305
- this.lastOpenSource === "keyboard" ||
306
- this.lastCloseSource === "keyboard"
415
+ !options?.skipFocus &&
416
+ (!this.openOnHover ||
417
+ this.lastOpenSource === "keyboard" ||
418
+ this.lastCloseSource === "keyboard")
307
419
  ) {
308
420
  requestAnimationFrame(() => {
309
421
  if (!this.trigger) return;
@@ -314,6 +426,9 @@ const { class: className, openOnHover = false, closeDelay = 200, ...rest } = Ast
314
426
  // Give the content time to animate before hiding
315
427
  setTimeout(() => {
316
428
  if (!this.content) return;
429
+ if (this.isSubmenu) {
430
+ this.unportalContent();
431
+ }
317
432
  this.content.style.display = "none";
318
433
  this.isClosing = false;
319
434
  }, this.animationDuration);
@@ -346,9 +461,121 @@ const { class: className, openOnHover = false, closeDelay = 200, ...rest } = Ast
346
461
  }
347
462
  }
348
463
 
349
- private positionContent() {
464
+ private portalContent() {
350
465
  if (!this.content || !this.trigger) return;
351
466
 
467
+ // Save the original position with a placeholder
468
+ this.contentPlaceholder = document.createComment("dropdown-sub-placeholder");
469
+ this.content.parentNode?.insertBefore(this.contentPlaceholder, this.content);
470
+
471
+ // Move content to body
472
+ document.body.appendChild(this.content);
473
+
474
+ // Apply fixed positioning (overrides the CSS absolute class)
475
+ this.content.style.position = "fixed";
476
+ this.content.style.zIndex = "50";
477
+
478
+ // Position relative to trigger
479
+ this.positionSubContent();
480
+
481
+ // Set up auto-update for scroll/resize
482
+ const updatePosition = () => this.positionSubContent();
483
+ window.addEventListener("scroll", updatePosition, true);
484
+ window.addEventListener("resize", updatePosition);
485
+
486
+ // Find and link to parent handler for child-tracking
487
+ const parentContent = this.trigger.closest('[data-slot="dropdown-content"]');
488
+ if (parentContent) {
489
+ this.parentHandler = (parentContent as any).__dropdownHandler || null;
490
+ if (this.parentHandler) {
491
+ this.parentHandler.openChildCount++;
492
+ }
493
+ }
494
+
495
+ // Handle hover interactions for portaled content
496
+ const onPointerEnter = (e: PointerEvent) => {
497
+ if (e.pointerType !== "mouse") return;
498
+ this.clearCloseTimer();
499
+ // Cancel close timers for all handlers in the group (parents included)
500
+ this.rootDropdown.dispatchEvent(new CustomEvent("starwind-dropdown:cancel-close"));
501
+ };
502
+
503
+ const onPointerLeave = (e: PointerEvent) => {
504
+ if (e.pointerType !== "mouse") return;
505
+ // Don't close if a child submenu is still open
506
+ if (this.openChildCount > 0) return;
507
+ this.lastCloseSource = "mouse";
508
+ this.closeDropdownDelayed();
509
+ };
510
+
511
+ this.content.addEventListener("pointerenter", onPointerEnter);
512
+ this.content.addEventListener("pointerleave", onPointerLeave);
513
+
514
+ this.cleanupAutoUpdate = () => {
515
+ window.removeEventListener("scroll", updatePosition, true);
516
+ window.removeEventListener("resize", updatePosition);
517
+ this.content?.removeEventListener("pointerenter", onPointerEnter);
518
+ this.content?.removeEventListener("pointerleave", onPointerLeave);
519
+ };
520
+ }
521
+
522
+ private unportalContent() {
523
+ if (!this.content || !this.contentPlaceholder) return;
524
+
525
+ // Decrement parent's open child count
526
+ if (this.parentHandler) {
527
+ this.parentHandler.openChildCount--;
528
+ this.parentHandler = null;
529
+ }
530
+
531
+ // Clean up auto-update and hover listeners
532
+ this.cleanupAutoUpdate?.();
533
+ this.cleanupAutoUpdate = null;
534
+
535
+ // Move content back to its original position
536
+ this.contentPlaceholder.parentNode?.insertBefore(this.content, this.contentPlaceholder);
537
+ this.contentPlaceholder.remove();
538
+ this.contentPlaceholder = null;
539
+
540
+ // Remove fixed positioning styles
541
+ this.content.style.removeProperty("position");
542
+ this.content.style.removeProperty("z-index");
543
+ this.content.style.removeProperty("top");
544
+ this.content.style.removeProperty("left");
545
+ }
546
+
547
+ private positionSubContent() {
548
+ if (!this.content || !this.trigger) return;
549
+
550
+ const triggerRect = this.trigger.getBoundingClientRect();
551
+
552
+ // Position to the right of the trigger by default
553
+ let top = triggerRect.top;
554
+ let left = triggerRect.right;
555
+
556
+ // Measure content dimensions
557
+ const contentWidth = this.content.offsetWidth;
558
+ const contentHeight = this.content.offsetHeight;
559
+ const viewportWidth = window.innerWidth;
560
+ const viewportHeight = window.innerHeight;
561
+
562
+ // If it would overflow on the right, flip to the left
563
+ if (left + contentWidth > viewportWidth) {
564
+ left = triggerRect.left - contentWidth;
565
+ }
566
+
567
+ // If it would overflow on the bottom, shift up
568
+ if (top + contentHeight > viewportHeight) {
569
+ top = Math.max(0, viewportHeight - contentHeight);
570
+ }
571
+
572
+ this.content.style.top = `${top}px`;
573
+ this.content.style.left = `${left}px`;
574
+ }
575
+
576
+ private positionContent() {
577
+ if (!this.content || !this.trigger || this.isSubmenu) return;
578
+
352
579
  // Set content width to match trigger width
353
580
  this.content.style.width = "var(--starwind-dropdown-trigger-width)";
354
581
  this.content.style.setProperty(
@@ -364,7 +591,7 @@ const { class: className, openOnHover = false, closeDelay = 200, ...rest } = Ast
364
591
 
365
592
  // Initialize dropdowns
366
593
  const initDropdowns = () => {
367
- document.querySelectorAll(".starwind-dropdown").forEach((dropdown) => {
594
+ document.querySelectorAll(".starwind-dropdown, .starwind-dropdown-sub").forEach((dropdown) => {
368
595
  if (dropdown instanceof HTMLElement && !dropdownInstances.has(dropdown)) {
369
596
  dropdownInstances.set(dropdown, new DropdownHandler(dropdown, dropdownCounter++));
370
597
  }
@@ -7,7 +7,7 @@ type Props = HTMLAttributes<"div"> & {
7
7
  * Side of the dropdown
8
8
  * @default bottom
9
9
  */
10
- side?: "top" | "bottom";
10
+ side?: "top" | "bottom" | "left" | "right";
11
11
  /**
12
12
  * Alignment of the dropdown
13
13
  * @default start
@@ -37,13 +37,47 @@ export const dropdownContent = tv({
37
37
  side: {
38
38
  bottom: "slide-in-from-top-2 slide-out-to-top-2 top-full",
39
39
  top: "slide-in-from-bottom-2 slide-out-to-bottom-2 bottom-full",
40
+ right: "slide-in-from-left-2 slide-out-to-left-2 top-0 left-full",
41
+ left: "slide-in-from-right-2 slide-out-to-right-2 top-0 right-full",
40
42
  },
41
43
  align: {
42
- start: "slide-in-from-left-1 slide-out-to-left-1 left-0",
43
- center: "left-1/2 -translate-x-1/2",
44
- end: "slide-in-from-right-1 slide-out-to-right-1 right-0",
44
+ start: "",
45
+ center: "",
46
+ end: "",
45
47
  },
46
48
  },
49
+ compoundVariants: [
50
+ {
51
+ side: ["top", "bottom"],
52
+ align: "start",
53
+ class: "slide-in-from-left-1 slide-out-to-left-1 left-0",
54
+ },
55
+ {
56
+ side: ["top", "bottom"],
57
+ align: "center",
58
+ class: "left-1/2 -translate-x-1/2",
59
+ },
60
+ {
61
+ side: ["top", "bottom"],
62
+ align: "end",
63
+ class: "slide-in-from-right-1 slide-out-to-right-1 right-0",
64
+ },
65
+ {
66
+ side: ["left", "right"],
67
+ align: "start",
68
+ class: "top-0",
69
+ },
70
+ {
71
+ side: ["left", "right"],
72
+ align: "center",
73
+ class: "top-1/2 -translate-y-1/2",
74
+ },
75
+ {
76
+ side: ["left", "right"],
77
+ align: "end",
78
+ class: "bottom-0",
79
+ },
80
+ ],
47
81
  defaultVariants: {
48
82
  side: "bottom",
49
83
  align: "start",
@@ -66,7 +100,6 @@ const {
66
100
  data-side={side}
67
101
  data-align={align}
68
102
  data-state="closed"
69
- data-slot="dropdown-content"
70
103
  tabindex="-1"
71
104
  aria-orientation="vertical"
72
105
  style={{
@@ -74,8 +107,11 @@ const {
74
107
  animationDuration: `${animationDuration}ms`,
75
108
  marginTop: side === "bottom" ? `${sideOffset}px` : undefined,
76
109
  marginBottom: side === "top" ? `${sideOffset}px` : undefined,
110
+ marginLeft: side === "right" ? `${sideOffset}px` : undefined,
111
+ marginRight: side === "left" ? `${sideOffset}px` : undefined,
77
112
  }}
78
113
  {...rest}
114
+ data-slot="dropdown-content"
79
115
  >
80
116
  <slot />
81
117
  </div>
@@ -17,7 +17,7 @@ export const dropdownItem = tv({
17
17
  base: [
18
18
  "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 transition-colors outline-none select-none",
19
19
  "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
20
- "[&>svg]:size-4 [&>svg]:shrink-0",
20
+ "group/dropdown-item [&>svg]:size-4 [&>svg]:shrink-0",
21
21
  ],
22
22
  variants: {
23
23
  inset: {
@@ -41,8 +41,8 @@ const { class: className, inset = false, disabled = false, as: Tag = "div", ...r
41
41
  role="menuitem"
42
42
  tabindex={disabled ? "-1" : "0"}
43
43
  data-disabled={disabled ? "true" : undefined}
44
- data-slot="dropdown-item"
45
44
  {...rest}
45
+ data-slot="dropdown-item"
46
46
  >
47
47
  <slot />
48
48
  </Tag>
@@ -10,7 +10,7 @@ type Props = HTMLAttributes<"div"> & {
10
10
  };
11
11
 
12
12
  export const dropdownLabel = tv({
13
- base: ["px-2 py-1.5 font-semibold"],
13
+ base: ["text-muted-foreground px-2 py-1.5 text-sm font-medium"],
14
14
  variants: {
15
15
  inset: {
16
16
  true: "pl-8",
@@ -24,6 +24,6 @@ export const dropdownLabel = tv({
24
24
  const { class: className, inset = false, ...rest } = Astro.props;
25
25
  ---
26
26
 
27
- <div class={dropdownLabel({ inset, class: className })} data-slot="dropdown-label" {...rest}>
27
+ <div class={dropdownLabel({ inset, class: className })} {...rest} data-slot="dropdown-label">
28
28
  <slot />
29
29
  </div>
@@ -15,7 +15,7 @@ const { class: className, ...rest } = Astro.props;
15
15
  class={dropdownSeparator({ class: className })}
16
16
  role="separator"
17
17
  aria-orientation="horizontal"
18
- data-slot="dropdown-separator"
19
18
  {...rest}
19
+ data-slot="dropdown-separator"
20
20
  >
21
21
  </div>
@@ -0,0 +1,17 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ type Props = HTMLAttributes<"span">;
5
+
6
+ const { class: className, ...rest } = Astro.props;
7
+ ---
8
+
9
+ <span
10
+ class:list={[
11
+ "group-focus/dropdown-item:text-accent-foreground text-muted-foreground ml-auto text-sm tracking-widest transition-colors",
12
+ className,
13
+ ]}
14
+ {...rest}
15
+ >
16
+ <slot />
17
+ </span>
@@ -0,0 +1,15 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ type Props = HTMLAttributes<"div">;
5
+
6
+ const { class: className, ...rest } = Astro.props;
7
+ ---
8
+
9
+ <div
10
+ class:list={["starwind-dropdown-sub", "relative", className]}
11
+ {...rest}
12
+ data-slot="dropdown-sub"
13
+ >
14
+ <slot />
15
+ </div>
@@ -0,0 +1,36 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ import { dropdownContent } from "./DropdownContent.astro";
5
+
6
+ type Props = HTMLAttributes<"div"> & {
7
+ /**
8
+ * Open and close animation duration in milliseconds
9
+ * @default 150
10
+ */
11
+ animationDuration?: number;
12
+ };
13
+
14
+ const { class: className, animationDuration = 150, ...rest } = Astro.props;
15
+ ---
16
+
17
+ <div
18
+ class={dropdownContent({
19
+ side: "right",
20
+ align: "start",
21
+ class: ["starwind-dropdown-sub-content", className],
22
+ })}
23
+ data-sub-content
24
+ role="menu"
25
+ data-state="closed"
26
+ tabindex="-1"
27
+ aria-orientation="vertical"
28
+ style={{
29
+ display: "none",
30
+ animationDuration: `${animationDuration}ms`,
31
+ }}
32
+ {...rest}
33
+ data-slot="dropdown-content"
34
+ >
35
+ <slot />
36
+ </div>