@starwind-ui/core 1.6.2 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/dist/index.js +8 -4
  2. package/dist/index.js.map +1 -1
  3. package/dist/src/components/accordion/Accordion.astro +224 -224
  4. package/dist/src/components/accordion/AccordionContent.astro +13 -13
  5. package/dist/src/components/accordion/AccordionItem.astro +6 -6
  6. package/dist/src/components/accordion/AccordionTrigger.astro +13 -13
  7. package/dist/src/components/accordion/index.ts +4 -4
  8. package/dist/src/components/alert/Alert.astro +15 -15
  9. package/dist/src/components/alert/AlertDescription.astro +1 -1
  10. package/dist/src/components/alert/AlertTitle.astro +2 -2
  11. package/dist/src/components/alert/index.ts +3 -3
  12. package/dist/src/components/avatar/Avatar.astro +16 -16
  13. package/dist/src/components/avatar/AvatarFallback.astro +3 -3
  14. package/dist/src/components/avatar/AvatarImage.astro +12 -12
  15. package/dist/src/components/avatar/index.ts +4 -4
  16. package/dist/src/components/badge/Badge.astro +33 -33
  17. package/dist/src/components/breadcrumb/Breadcrumb.astro +1 -1
  18. package/dist/src/components/breadcrumb/BreadcrumbEllipsis.astro +6 -6
  19. package/dist/src/components/breadcrumb/BreadcrumbItem.astro +1 -1
  20. package/dist/src/components/breadcrumb/BreadcrumbLink.astro +8 -8
  21. package/dist/src/components/breadcrumb/BreadcrumbList.astro +2 -2
  22. package/dist/src/components/breadcrumb/BreadcrumbPage.astro +6 -6
  23. package/dist/src/components/breadcrumb/BreadcrumbSeparator.astro +7 -7
  24. package/dist/src/components/breadcrumb/index.ts +14 -14
  25. package/dist/src/components/button/Button.astro +38 -38
  26. package/dist/src/components/card/Card.astro +1 -1
  27. package/dist/src/components/card/CardContent.astro +1 -1
  28. package/dist/src/components/card/CardDescription.astro +1 -1
  29. package/dist/src/components/card/CardFooter.astro +1 -1
  30. package/dist/src/components/card/CardHeader.astro +1 -1
  31. package/dist/src/components/card/CardTitle.astro +1 -1
  32. package/dist/src/components/card/index.ts +7 -7
  33. package/dist/src/components/checkbox/Checkbox.astro +89 -89
  34. package/dist/src/components/dialog/Dialog.astro +237 -178
  35. package/dist/src/components/dialog/DialogClose.astro +14 -14
  36. package/dist/src/components/dialog/DialogContent.astro +32 -32
  37. package/dist/src/components/dialog/DialogDescription.astro +1 -1
  38. package/dist/src/components/dialog/DialogFooter.astro +1 -1
  39. package/dist/src/components/dialog/DialogHeader.astro +1 -1
  40. package/dist/src/components/dialog/DialogTitle.astro +6 -6
  41. package/dist/src/components/dialog/DialogTrigger.astro +26 -20
  42. package/dist/src/components/dialog/index.ts +16 -16
  43. package/dist/src/components/dropdown/Dropdown.astro +359 -359
  44. package/dist/src/components/dropdown/DropdownContent.astro +63 -63
  45. package/dist/src/components/dropdown/DropdownItem.astro +31 -31
  46. package/dist/src/components/dropdown/DropdownLabel.astro +14 -14
  47. package/dist/src/components/dropdown/DropdownSeparator.astro +5 -5
  48. package/dist/src/components/dropdown/DropdownTrigger.astro +26 -26
  49. package/dist/src/components/dropdown/index.ts +12 -12
  50. package/dist/src/components/dropzone/Dropzone.astro +232 -0
  51. package/dist/src/components/dropzone/DropzoneFilesList.astro +25 -0
  52. package/dist/src/components/dropzone/DropzoneLoadingIndicator.astro +10 -0
  53. package/dist/src/components/dropzone/DropzoneUploadIndicator.astro +10 -0
  54. package/dist/src/components/dropzone/index.ts +13 -0
  55. package/dist/src/components/input/Input.astro +12 -12
  56. package/dist/src/components/label/Label.astro +8 -8
  57. package/dist/src/components/pagination/Pagination.astro +1 -1
  58. package/dist/src/components/pagination/PaginationContent.astro +3 -3
  59. package/dist/src/components/pagination/PaginationEllipsis.astro +2 -2
  60. package/dist/src/components/pagination/PaginationItem.astro +3 -3
  61. package/dist/src/components/pagination/PaginationLink.astro +27 -27
  62. package/dist/src/components/pagination/PaginationNext.astro +7 -6
  63. package/dist/src/components/pagination/PaginationPrevious.astro +7 -6
  64. package/dist/src/components/pagination/index.ts +14 -14
  65. package/dist/src/components/progress/Progress.astro +151 -0
  66. package/dist/src/components/progress/index.ts +5 -0
  67. package/dist/src/components/radio-group/RadioGroup.astro +156 -0
  68. package/dist/src/components/radio-group/RadioGroupItem.astro +125 -0
  69. package/dist/src/components/radio-group/RadioGroupTypes.ts +6 -0
  70. package/dist/src/components/radio-group/index.ts +10 -0
  71. package/dist/src/components/select/Select.astro +515 -475
  72. package/dist/src/components/select/SelectContent.astro +62 -62
  73. package/dist/src/components/select/SelectItem.astro +27 -27
  74. package/dist/src/components/select/SelectLabel.astro +1 -1
  75. package/dist/src/components/select/SelectTrigger.astro +28 -28
  76. package/dist/src/components/select/SelectTypes.ts +11 -5
  77. package/dist/src/components/select/SelectValue.astro +5 -5
  78. package/dist/src/components/select/index.ts +16 -16
  79. package/dist/src/components/skeleton/Skeleton.astro +14 -0
  80. package/dist/src/components/skeleton/index.ts +5 -0
  81. package/dist/src/components/switch/Switch.astro +150 -150
  82. package/dist/src/components/switch/SwitchTypes.ts +4 -4
  83. package/dist/src/components/table/Table.astro +5 -5
  84. package/dist/src/components/table/TableBody.astro +3 -3
  85. package/dist/src/components/table/TableCaption.astro +3 -3
  86. package/dist/src/components/table/TableCell.astro +3 -3
  87. package/dist/src/components/table/TableFoot.astro +3 -3
  88. package/dist/src/components/table/TableHead.astro +3 -3
  89. package/dist/src/components/table/TableHeader.astro +3 -3
  90. package/dist/src/components/table/TableRow.astro +3 -3
  91. package/dist/src/components/table/index.ts +8 -8
  92. package/dist/src/components/tabs/Tabs.astro +250 -250
  93. package/dist/src/components/tabs/TabsContent.astro +10 -10
  94. package/dist/src/components/tabs/TabsList.astro +2 -2
  95. package/dist/src/components/tabs/TabsTrigger.astro +15 -15
  96. package/dist/src/components/tabs/index.ts +4 -4
  97. package/dist/src/components/textarea/Textarea.astro +16 -16
  98. package/dist/src/components/tooltip/Tooltip.astro +217 -217
  99. package/dist/src/components/tooltip/TooltipContent.astro +81 -81
  100. package/dist/src/components/tooltip/index.ts +3 -3
  101. package/package.json +6 -6
@@ -2,373 +2,373 @@
2
2
  import type { HTMLAttributes } from "astro/types";
3
3
 
4
4
  type Props = HTMLAttributes<"div"> & {
5
- /**
6
- * When true, the dropdown will open on hover in addition to click
7
- */
8
- openOnHover?: boolean;
9
- /**
10
- * Time in milliseconds to wait before closing when hover open is enabled
11
- * @default 200
12
- */
13
- closeDelay?: number;
14
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
- children: any;
5
+ /**
6
+ * When true, the dropdown will open on hover in addition to click
7
+ */
8
+ openOnHover?: boolean;
9
+ /**
10
+ * Time in milliseconds to wait before closing when hover open is enabled
11
+ * @default 200
12
+ */
13
+ closeDelay?: number;
14
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
+ children: any;
16
16
  };
17
17
 
18
18
  const { class: className, openOnHover = false, closeDelay = 200, ...rest } = Astro.props;
19
19
  ---
20
20
 
21
21
  <div
22
- class:list={["starwind-dropdown", "relative", className]}
23
- data-open-on-hover={openOnHover ? "true" : undefined}
24
- data-close-delay={closeDelay}
25
- {...rest}
22
+ class:list={["starwind-dropdown", "relative", className]}
23
+ data-open-on-hover={openOnHover ? "true" : undefined}
24
+ data-close-delay={closeDelay}
25
+ {...rest}
26
26
  >
27
- <slot />
27
+ <slot />
28
28
  </div>
29
29
 
30
30
  <script>
31
- class DropdownHandler {
32
- private dropdown: HTMLElement;
33
- private trigger: HTMLButtonElement | null;
34
- private content: HTMLElement | null;
35
- private items: HTMLElement[] = [];
36
- private currentFocusIndex: number = -1;
37
- private isOpen: boolean = false;
38
- private isClosing: boolean = false;
39
- private animationDuration = 150;
40
- private openOnHover: boolean;
41
- private closeDelay: number;
42
- private closeTimerRef: number | null = null;
43
- private lastOpenSource: "keyboard" | "mouse" = "keyboard";
44
- private lastCloseSource: "keyboard" | "mouse" = "keyboard";
45
-
46
- constructor(dropdown: HTMLElement, dropdownIdx: number) {
47
- this.dropdown = dropdown;
48
- this.openOnHover = dropdown.getAttribute("data-open-on-hover") === "true";
49
- this.closeDelay = parseInt(dropdown.getAttribute("data-close-delay") || "200");
50
-
51
- // Get the temporary trigger element
52
- const tempTrigger = dropdown.querySelector(".starwind-dropdown-trigger") as HTMLElement;
53
-
54
- // if trigger is set with asChild, use the first child element for trigger button
55
- if (tempTrigger?.hasAttribute("data-as-child")) {
56
- this.trigger = tempTrigger.firstElementChild as HTMLButtonElement;
57
- } else {
58
- this.trigger = tempTrigger as HTMLButtonElement;
59
- }
60
-
61
- this.content = dropdown.querySelector(".starwind-dropdown-content");
62
-
63
- if (!this.trigger || !this.content) return;
64
-
65
- // Get animation duration from inline styles if available
66
- const animationDurationString = this.content.style.animationDuration;
67
- if (animationDurationString.endsWith("ms")) {
68
- this.animationDuration = parseFloat(animationDurationString);
69
- } else if (animationDurationString.endsWith("s")) {
70
- this.animationDuration = parseFloat(animationDurationString) * 1000;
71
- }
72
-
73
- this.init(dropdownIdx);
74
- }
75
-
76
- private init(dropdownIdx: number) {
77
- this.setupAccessibility(dropdownIdx);
78
- this.setupEvents();
79
- }
80
-
81
- private setupAccessibility(dropdownIdx: number) {
82
- if (!this.trigger || !this.content) return;
83
-
84
- // Generate unique IDs for accessibility
85
- this.trigger.id = `starwind-dropdown${dropdownIdx}-trigger`;
86
- this.content.id = `starwind-dropdown${dropdownIdx}-content`;
87
-
88
- // Set up additional ARIA attributes
89
- this.trigger.setAttribute("aria-controls", this.content.id);
90
- this.content.setAttribute("aria-labelledby", this.trigger.id);
91
- }
92
-
93
- private setupEvents() {
94
- if (!this.trigger || !this.content) return;
95
-
96
- // Handle trigger click
97
- this.trigger.addEventListener("click", (e) => {
98
- e.preventDefault();
99
- this.lastOpenSource = e.detail === 0 ? "keyboard" : "mouse";
100
- this.toggleDropdown();
101
- });
102
-
103
- // Handle keyboard navigation
104
- this.trigger.addEventListener("keydown", (e) => {
105
- if (e.key === "Enter" || e.key === " ") {
106
- e.preventDefault();
107
- this.lastOpenSource = "keyboard";
108
- this.toggleDropdown();
109
- } else if (e.key === "Escape" && this.isOpen) {
110
- e.preventDefault();
111
- this.lastCloseSource = "keyboard";
112
- this.closeDropdown();
113
- } else if (this.isOpen && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
114
- e.preventDefault();
115
- this.lastOpenSource = "keyboard";
116
- this.updateDropdownItems();
117
- if (e.key === "ArrowDown") {
118
- this.focusItem(0); // Focus first item when opening with arrow down
119
- } else {
120
- this.focusItem(this.items.length - 1); // Focus last item when opening with arrow up
121
- }
122
- }
123
- });
124
-
125
- // Close dropdown when clicking outside for mouse
126
- document.addEventListener("pointerdown", (e) => {
127
- if (this.isOpen && !this.dropdown.contains(e.target as Node)) {
128
- // only call handler if it's the left button (mousedown gets triggered by all mouse buttons)
129
- // but not when the control key is pressed (avoiding MacOS right click); also not for touch
130
- // devices because that would open the menu on scroll. (pen devices behave as touch on iOS).
131
- if (e.button === 0 && e.ctrlKey === false && e.pointerType === "mouse") {
132
- this.closeDropdown();
133
- }
134
- }
135
- });
136
-
137
- // Handle click outside select content to close for mobile
138
- document.addEventListener("click", (e) => {
139
- if (
140
- !(this.trigger?.contains(e.target as Node) || this.content?.contains(e.target as Node)) &&
141
- this.isOpen
142
- ) {
143
- this.closeDropdown();
144
- }
145
- });
146
-
147
- // Handle keyboard navigation and item selection within dropdown
148
- this.content.addEventListener("keydown", (e) => {
149
- if (e.key === "Escape") {
150
- e.preventDefault();
151
- this.closeDropdown();
152
- this.trigger?.focus();
153
- } else if (this.isOpen) {
154
- this.handleMenuKeydown(e);
155
- }
156
- });
157
-
158
- // Handle item selection
159
- this.content.addEventListener("click", (e) => {
160
- const target = e.target as HTMLElement;
161
- const item = target.closest('[role="menuitem"]');
162
- if (item && !(item as HTMLElement).hasAttribute("data-disabled")) {
163
- // Close the dropdown after item selection
164
- this.closeDropdown();
165
- console.log("click closing");
166
- }
167
- });
168
-
169
- // Handle hover on dropdown items
170
- this.content.addEventListener("mouseover", (e) => {
171
- const target = e.target as HTMLElement;
172
- const menuItem = target.closest('[role="menuitem"]');
173
- if (menuItem && menuItem instanceof HTMLElement && this.isOpen === true) {
174
- // Update items list before focusing to ensure the index is correct
175
- this.updateDropdownItems();
176
-
177
- // Focus the item when hovering
178
- menuItem.focus();
179
-
180
- // Update the current focus index
181
- this.currentFocusIndex = this.items.indexOf(menuItem);
182
- }
183
- });
184
-
185
- if (this.openOnHover) {
186
- this.trigger.addEventListener("pointerenter", (e) => {
187
- if (e.pointerType !== "mouse") return;
188
- if (this.isClosing) return;
189
- if (!this.isOpen) {
190
- this.lastOpenSource = "mouse";
191
- this.openDropdown();
192
- } else {
193
- // If the dropdown is already open, make sure to clear any close timer
194
- this.clearCloseTimer();
195
- }
196
- });
197
-
198
- this.dropdown.addEventListener("pointerleave", (e) => {
199
- if (e.pointerType !== "mouse") return;
200
- if (this.isOpen) {
201
- this.lastCloseSource = "mouse";
202
- this.closeDropdownDelayed();
203
- }
204
- });
205
-
206
- this.content.addEventListener("pointerenter", (e) => {
207
- if (e.pointerType !== "mouse") return;
208
- // If the user moves the mouse to the content, cancel the close timer
209
- this.clearCloseTimer();
210
- });
211
- }
212
- }
213
-
214
- private handleMenuKeydown(e: KeyboardEvent) {
215
- // Make sure we've got an updated list of menu items
216
- this.updateDropdownItems();
217
-
218
- // Skip if no items
219
- if (this.items.length === 0) return;
220
-
221
- const currentIdx = this.currentFocusIndex;
222
-
223
- switch (e.key) {
224
- case "ArrowDown":
225
- e.preventDefault();
226
- this.focusItem(currentIdx === -1 ? 0 : currentIdx + 1);
227
- break;
228
- case "ArrowUp":
229
- e.preventDefault();
230
- this.focusItem(currentIdx === -1 ? this.items.length - 1 : currentIdx - 1);
231
- break;
232
- case "Home":
233
- e.preventDefault();
234
- this.focusItem(0);
235
- break;
236
- case "End":
237
- e.preventDefault();
238
- this.focusItem(this.items.length - 1);
239
- break;
240
- case "Enter":
241
- case " ":
242
- if (currentIdx !== -1) {
243
- e.preventDefault();
244
- this.items[currentIdx].click();
245
- }
246
- break;
247
- }
248
- }
249
-
250
- private updateDropdownItems() {
251
- if (!this.content) return;
252
- // Get all interactive menuitem elements
253
- this.items = Array.from(
254
- this.content.querySelectorAll('[role="menuitem"]:not([data-disabled="true"])'),
255
- ) as HTMLElement[];
256
- }
257
-
258
- private focusItem(idx: number) {
259
- // Ensure the index wraps around properly
260
- const targetIdx = (idx + this.items.length) % this.items.length;
261
-
262
- if (this.items[targetIdx]) {
263
- this.items[targetIdx].focus();
264
- this.currentFocusIndex = targetIdx;
265
- }
266
- }
267
-
268
- private toggleDropdown() {
269
- if (this.isOpen) {
270
- this.closeDropdown();
271
- } else {
272
- this.openDropdown();
273
- }
274
- }
275
-
276
- private openDropdown() {
277
- if (this.isClosing) return;
278
- if (!this.content || !this.trigger || this.trigger.disabled) return;
279
-
280
- this.isOpen = true;
281
- this.content.setAttribute("data-state", "open");
282
- this.trigger.setAttribute("aria-expanded", "true");
283
- this.content.style.removeProperty("display");
284
-
285
- // Update the list of dropdown items
286
- this.updateDropdownItems();
287
-
288
- // Reset focus index when opening
289
- this.currentFocusIndex = -1;
290
-
291
- this.positionContent();
292
- }
293
-
294
- private closeDropdown() {
295
- if (!this.content || !this.trigger) return;
296
-
297
- this.isClosing = true;
298
- this.isOpen = false;
299
- this.content.setAttribute("data-state", "closed");
300
-
301
- // Set focus back on trigger only if opened or closed by keyboard
302
- if (
303
- !this.openOnHover ||
304
- this.lastOpenSource === "keyboard" ||
305
- this.lastCloseSource === "keyboard"
306
- ) {
307
- requestAnimationFrame(() => {
308
- if (!this.trigger) return;
309
- this.trigger.focus();
310
- });
311
- }
312
-
313
- // Give the content time to animate before hiding
314
- setTimeout(() => {
315
- if (!this.content) return;
316
- this.content.style.display = "none";
317
- this.isClosing = false;
318
- }, this.animationDuration);
319
-
320
- this.trigger.setAttribute("aria-expanded", "false");
321
-
322
- // Reset focus index when closing
323
- this.currentFocusIndex = -1;
324
- }
325
-
326
- private closeDropdownDelayed() {
327
- if (!this.content || !this.trigger) return;
328
-
329
- // Clear any existing close timer
330
- this.clearCloseTimer();
331
-
332
- // Set a new timer to close the dropdown after the delay
333
- this.closeTimerRef = window.setTimeout(() => {
334
- if (this.isOpen) {
335
- this.closeDropdown();
336
- }
337
- this.closeTimerRef = null;
338
- }, this.closeDelay);
339
- }
340
-
341
- private clearCloseTimer() {
342
- if (this.closeTimerRef !== null) {
343
- window.clearTimeout(this.closeTimerRef);
344
- this.closeTimerRef = null;
345
- }
346
- }
347
-
348
- private positionContent() {
349
- if (!this.content || !this.trigger) return;
350
-
351
- // Set content width to match trigger width
352
- this.content.style.width = "var(--starwind-dropdown-trigger-width)";
353
- this.content.style.setProperty(
354
- "--starwind-dropdown-trigger-width",
355
- `${this.trigger.offsetWidth}px`,
356
- );
357
- }
358
- }
359
-
360
- // Store instances in a WeakMap to avoid memory leaks
361
- const dropdownInstances = new WeakMap<HTMLElement, DropdownHandler>();
362
-
363
- // Initialize dropdowns
364
- const initDropdowns = () => {
365
- document.querySelectorAll(".starwind-dropdown").forEach((dropdown, idx) => {
366
- if (dropdown instanceof HTMLElement && !dropdownInstances.has(dropdown)) {
367
- dropdownInstances.set(dropdown, new DropdownHandler(dropdown, idx));
368
- }
369
- });
370
- };
371
-
372
- initDropdowns();
373
- document.addEventListener("astro:after-swap", initDropdowns);
31
+ class DropdownHandler {
32
+ private dropdown: HTMLElement;
33
+ private trigger: HTMLButtonElement | null;
34
+ private content: HTMLElement | null;
35
+ private items: HTMLElement[] = [];
36
+ private currentFocusIndex: number = -1;
37
+ private isOpen: boolean = false;
38
+ private isClosing: boolean = false;
39
+ private animationDuration = 150;
40
+ private openOnHover: boolean;
41
+ private closeDelay: number;
42
+ private closeTimerRef: number | null = null;
43
+ private lastOpenSource: "keyboard" | "mouse" = "keyboard";
44
+ private lastCloseSource: "keyboard" | "mouse" = "keyboard";
45
+
46
+ constructor(dropdown: HTMLElement, dropdownIdx: number) {
47
+ this.dropdown = dropdown;
48
+ this.openOnHover = dropdown.getAttribute("data-open-on-hover") === "true";
49
+ this.closeDelay = parseInt(dropdown.getAttribute("data-close-delay") || "200");
50
+
51
+ // Get the temporary trigger element
52
+ const tempTrigger = dropdown.querySelector(".starwind-dropdown-trigger") as HTMLElement;
53
+
54
+ // if trigger is set with asChild, use the first child element for trigger button
55
+ if (tempTrigger?.hasAttribute("data-as-child")) {
56
+ this.trigger = tempTrigger.firstElementChild as HTMLButtonElement;
57
+ } else {
58
+ this.trigger = tempTrigger as HTMLButtonElement;
59
+ }
60
+
61
+ this.content = dropdown.querySelector(".starwind-dropdown-content");
62
+
63
+ if (!this.trigger || !this.content) return;
64
+
65
+ // Get animation duration from inline styles if available
66
+ const animationDurationString = this.content.style.animationDuration;
67
+ if (animationDurationString.endsWith("ms")) {
68
+ this.animationDuration = parseFloat(animationDurationString);
69
+ } else if (animationDurationString.endsWith("s")) {
70
+ this.animationDuration = parseFloat(animationDurationString) * 1000;
71
+ }
72
+
73
+ this.init(dropdownIdx);
74
+ }
75
+
76
+ private init(dropdownIdx: number) {
77
+ this.setupAccessibility(dropdownIdx);
78
+ this.setupEvents();
79
+ }
80
+
81
+ private setupAccessibility(dropdownIdx: number) {
82
+ if (!this.trigger || !this.content) return;
83
+
84
+ // Generate unique IDs for accessibility
85
+ this.trigger.id = `starwind-dropdown${dropdownIdx}-trigger`;
86
+ this.content.id = `starwind-dropdown${dropdownIdx}-content`;
87
+
88
+ // Set up additional ARIA attributes
89
+ this.trigger.setAttribute("aria-controls", this.content.id);
90
+ this.content.setAttribute("aria-labelledby", this.trigger.id);
91
+ }
92
+
93
+ private setupEvents() {
94
+ if (!this.trigger || !this.content) return;
95
+
96
+ // Handle trigger click
97
+ this.trigger.addEventListener("click", (e) => {
98
+ e.preventDefault();
99
+ this.lastOpenSource = e.detail === 0 ? "keyboard" : "mouse";
100
+ this.toggleDropdown();
101
+ });
102
+
103
+ // Handle keyboard navigation
104
+ this.trigger.addEventListener("keydown", (e) => {
105
+ if (e.key === "Enter" || e.key === " ") {
106
+ e.preventDefault();
107
+ this.lastOpenSource = "keyboard";
108
+ this.toggleDropdown();
109
+ } else if (e.key === "Escape" && this.isOpen) {
110
+ e.preventDefault();
111
+ this.lastCloseSource = "keyboard";
112
+ this.closeDropdown();
113
+ } else if (this.isOpen && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
114
+ e.preventDefault();
115
+ this.lastOpenSource = "keyboard";
116
+ this.updateDropdownItems();
117
+ if (e.key === "ArrowDown") {
118
+ this.focusItem(0); // Focus first item when opening with arrow down
119
+ } else {
120
+ this.focusItem(this.items.length - 1); // Focus last item when opening with arrow up
121
+ }
122
+ }
123
+ });
124
+
125
+ // Close dropdown when clicking outside for mouse
126
+ document.addEventListener("pointerdown", (e) => {
127
+ if (this.isOpen && !this.dropdown.contains(e.target as Node)) {
128
+ // only call handler if it's the left button (mousedown gets triggered by all mouse buttons)
129
+ // but not when the control key is pressed (avoiding MacOS right click); also not for touch
130
+ // devices because that would open the menu on scroll. (pen devices behave as touch on iOS).
131
+ if (e.button === 0 && e.ctrlKey === false && e.pointerType === "mouse") {
132
+ this.closeDropdown();
133
+ }
134
+ }
135
+ });
136
+
137
+ // Handle click outside select content to close for mobile
138
+ document.addEventListener("click", (e) => {
139
+ if (
140
+ !(this.trigger?.contains(e.target as Node) || this.content?.contains(e.target as Node)) &&
141
+ this.isOpen
142
+ ) {
143
+ this.closeDropdown();
144
+ }
145
+ });
146
+
147
+ // Handle keyboard navigation and item selection within dropdown
148
+ this.content.addEventListener("keydown", (e) => {
149
+ if (e.key === "Escape") {
150
+ e.preventDefault();
151
+ this.closeDropdown();
152
+ this.trigger?.focus();
153
+ } else if (this.isOpen) {
154
+ this.handleMenuKeydown(e);
155
+ }
156
+ });
157
+
158
+ // Handle item selection
159
+ this.content.addEventListener("click", (e) => {
160
+ const target = e.target as HTMLElement;
161
+ const item = target.closest('[role="menuitem"]');
162
+ if (item && !(item as HTMLElement).hasAttribute("data-disabled")) {
163
+ // Close the dropdown after item selection
164
+ this.closeDropdown();
165
+ console.log("click closing");
166
+ }
167
+ });
168
+
169
+ // Handle hover on dropdown items
170
+ this.content.addEventListener("mouseover", (e) => {
171
+ const target = e.target as HTMLElement;
172
+ const menuItem = target.closest('[role="menuitem"]');
173
+ if (menuItem && menuItem instanceof HTMLElement && this.isOpen === true) {
174
+ // Update items list before focusing to ensure the index is correct
175
+ this.updateDropdownItems();
176
+
177
+ // Focus the item when hovering
178
+ menuItem.focus();
179
+
180
+ // Update the current focus index
181
+ this.currentFocusIndex = this.items.indexOf(menuItem);
182
+ }
183
+ });
184
+
185
+ if (this.openOnHover) {
186
+ this.trigger.addEventListener("pointerenter", (e) => {
187
+ if (e.pointerType !== "mouse") return;
188
+ if (this.isClosing) return;
189
+ if (!this.isOpen) {
190
+ this.lastOpenSource = "mouse";
191
+ this.openDropdown();
192
+ } else {
193
+ // If the dropdown is already open, make sure to clear any close timer
194
+ this.clearCloseTimer();
195
+ }
196
+ });
197
+
198
+ this.dropdown.addEventListener("pointerleave", (e) => {
199
+ if (e.pointerType !== "mouse") return;
200
+ if (this.isOpen) {
201
+ this.lastCloseSource = "mouse";
202
+ this.closeDropdownDelayed();
203
+ }
204
+ });
205
+
206
+ this.content.addEventListener("pointerenter", (e) => {
207
+ if (e.pointerType !== "mouse") return;
208
+ // If the user moves the mouse to the content, cancel the close timer
209
+ this.clearCloseTimer();
210
+ });
211
+ }
212
+ }
213
+
214
+ private handleMenuKeydown(e: KeyboardEvent) {
215
+ // Make sure we've got an updated list of menu items
216
+ this.updateDropdownItems();
217
+
218
+ // Skip if no items
219
+ if (this.items.length === 0) return;
220
+
221
+ const currentIdx = this.currentFocusIndex;
222
+
223
+ switch (e.key) {
224
+ case "ArrowDown":
225
+ e.preventDefault();
226
+ this.focusItem(currentIdx === -1 ? 0 : currentIdx + 1);
227
+ break;
228
+ case "ArrowUp":
229
+ e.preventDefault();
230
+ this.focusItem(currentIdx === -1 ? this.items.length - 1 : currentIdx - 1);
231
+ break;
232
+ case "Home":
233
+ e.preventDefault();
234
+ this.focusItem(0);
235
+ break;
236
+ case "End":
237
+ e.preventDefault();
238
+ this.focusItem(this.items.length - 1);
239
+ break;
240
+ case "Enter":
241
+ case " ":
242
+ if (currentIdx !== -1) {
243
+ e.preventDefault();
244
+ this.items[currentIdx].click();
245
+ }
246
+ break;
247
+ }
248
+ }
249
+
250
+ private updateDropdownItems() {
251
+ if (!this.content) return;
252
+ // Get all interactive menuitem elements
253
+ this.items = Array.from(
254
+ this.content.querySelectorAll('[role="menuitem"]:not([data-disabled="true"])'),
255
+ ) as HTMLElement[];
256
+ }
257
+
258
+ private focusItem(idx: number) {
259
+ // Ensure the index wraps around properly
260
+ const targetIdx = (idx + this.items.length) % this.items.length;
261
+
262
+ if (this.items[targetIdx]) {
263
+ this.items[targetIdx].focus();
264
+ this.currentFocusIndex = targetIdx;
265
+ }
266
+ }
267
+
268
+ private toggleDropdown() {
269
+ if (this.isOpen) {
270
+ this.closeDropdown();
271
+ } else {
272
+ this.openDropdown();
273
+ }
274
+ }
275
+
276
+ private openDropdown() {
277
+ if (this.isClosing) return;
278
+ if (!this.content || !this.trigger || this.trigger.disabled) return;
279
+
280
+ this.isOpen = true;
281
+ this.content.setAttribute("data-state", "open");
282
+ this.trigger.setAttribute("aria-expanded", "true");
283
+ this.content.style.removeProperty("display");
284
+
285
+ // Update the list of dropdown items
286
+ this.updateDropdownItems();
287
+
288
+ // Reset focus index when opening
289
+ this.currentFocusIndex = -1;
290
+
291
+ this.positionContent();
292
+ }
293
+
294
+ private closeDropdown() {
295
+ if (!this.content || !this.trigger) return;
296
+
297
+ this.isClosing = true;
298
+ this.isOpen = false;
299
+ this.content.setAttribute("data-state", "closed");
300
+
301
+ // Set focus back on trigger only if opened or closed by keyboard
302
+ if (
303
+ !this.openOnHover ||
304
+ this.lastOpenSource === "keyboard" ||
305
+ this.lastCloseSource === "keyboard"
306
+ ) {
307
+ requestAnimationFrame(() => {
308
+ if (!this.trigger) return;
309
+ this.trigger.focus();
310
+ });
311
+ }
312
+
313
+ // Give the content time to animate before hiding
314
+ setTimeout(() => {
315
+ if (!this.content) return;
316
+ this.content.style.display = "none";
317
+ this.isClosing = false;
318
+ }, this.animationDuration);
319
+
320
+ this.trigger.setAttribute("aria-expanded", "false");
321
+
322
+ // Reset focus index when closing
323
+ this.currentFocusIndex = -1;
324
+ }
325
+
326
+ private closeDropdownDelayed() {
327
+ if (!this.content || !this.trigger) return;
328
+
329
+ // Clear any existing close timer
330
+ this.clearCloseTimer();
331
+
332
+ // Set a new timer to close the dropdown after the delay
333
+ this.closeTimerRef = window.setTimeout(() => {
334
+ if (this.isOpen) {
335
+ this.closeDropdown();
336
+ }
337
+ this.closeTimerRef = null;
338
+ }, this.closeDelay);
339
+ }
340
+
341
+ private clearCloseTimer() {
342
+ if (this.closeTimerRef !== null) {
343
+ window.clearTimeout(this.closeTimerRef);
344
+ this.closeTimerRef = null;
345
+ }
346
+ }
347
+
348
+ private positionContent() {
349
+ if (!this.content || !this.trigger) return;
350
+
351
+ // Set content width to match trigger width
352
+ this.content.style.width = "var(--starwind-dropdown-trigger-width)";
353
+ this.content.style.setProperty(
354
+ "--starwind-dropdown-trigger-width",
355
+ `${this.trigger.offsetWidth}px`,
356
+ );
357
+ }
358
+ }
359
+
360
+ // Store instances in a WeakMap to avoid memory leaks
361
+ const dropdownInstances = new WeakMap<HTMLElement, DropdownHandler>();
362
+
363
+ // Initialize dropdowns
364
+ const initDropdowns = () => {
365
+ document.querySelectorAll(".starwind-dropdown").forEach((dropdown, idx) => {
366
+ if (dropdown instanceof HTMLElement && !dropdownInstances.has(dropdown)) {
367
+ dropdownInstances.set(dropdown, new DropdownHandler(dropdown, idx));
368
+ }
369
+ });
370
+ };
371
+
372
+ initDropdowns();
373
+ document.addEventListener("astro:after-swap", initDropdowns);
374
374
  </script>