@grove-dev/starlight 0.1.9

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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +200 -0
  3. package/components/custom/ContainerSection.astro +58 -0
  4. package/components/custom/LinkButton.astro +141 -0
  5. package/components/custom/dropdown/Dropdown.astro +616 -0
  6. package/components/custom/dropdown/DropdownContent.astro +179 -0
  7. package/components/custom/dropdown/DropdownItem.astro +72 -0
  8. package/components/custom/dropdown/DropdownLabel.astro +31 -0
  9. package/components/custom/dropdown/DropdownSeparator.astro +18 -0
  10. package/components/custom/dropdown/DropdownShortcut.astro +24 -0
  11. package/components/custom/dropdown/DropdownTrigger.astro +54 -0
  12. package/components/custom/dropdown/index.ts +29 -0
  13. package/components/overrides/ContentPanel.astro +1 -0
  14. package/components/overrides/Footer.astro +54 -0
  15. package/components/overrides/Header.astro +93 -0
  16. package/components/overrides/Hero.astro +260 -0
  17. package/components/overrides/MarkdownContent.astro +17 -0
  18. package/components/overrides/PageFrame.astro +121 -0
  19. package/components/overrides/PageSidebar.astro +20 -0
  20. package/components/overrides/PageTitle.astro +130 -0
  21. package/components/overrides/Pagination.astro +101 -0
  22. package/components/overrides/Search.astro +549 -0
  23. package/components/overrides/Sidebar.astro +111 -0
  24. package/components/overrides/SiteTitle.astro +65 -0
  25. package/components/overrides/SocialIcons.astro +47 -0
  26. package/components/overrides/TableOfContents.astro +36 -0
  27. package/components/overrides/ThemeSelect.astro +156 -0
  28. package/components/overrides/TwoColumnContent.astro +75 -0
  29. package/components/overrides/parts/Drawer.astro +232 -0
  30. package/components/overrides/parts/MobileMenuToggle.astro +42 -0
  31. package/components/overrides/parts/NavBar.astro +110 -0
  32. package/components/overrides/parts/SidebarSublist.astro +115 -0
  33. package/components/overrides/parts/toc/TableOfContentsList.astro +71 -0
  34. package/components/overrides/parts/toc/starlight-toc.ts +119 -0
  35. package/core/config/constants.ts +1 -0
  36. package/core/config/expresive-code.ts +63 -0
  37. package/core/config/override.ts +48 -0
  38. package/core/config/schemas.ts +57 -0
  39. package/core/config/vite.ts +20 -0
  40. package/core/plugin.ts +56 -0
  41. package/global.d.ts +5 -0
  42. package/index.ts +3 -0
  43. package/package.json +54 -0
  44. package/schema.ts +24 -0
  45. package/styles/base.css +897 -0
  46. package/styles/layers.css +1 -0
  47. package/styles/theme.css +67 -0
  48. package/user-components.ts +3 -0
  49. package/virtual.d.ts +51 -0
@@ -0,0 +1,616 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types';
3
+
4
+ type Props = HTMLAttributes<'div'> & {
5
+ /** When true, the dropdown will open on hover in addition to click */
6
+ openOnHover?: boolean;
7
+ /** Time in milliseconds to wait before closing when hover open is enabled (defaults to 200) */
8
+ closeDelay?: number;
9
+ children: any;
10
+ };
11
+
12
+ const { class: className, openOnHover = false, closeDelay = 200, ...rest } = Astro.props;
13
+ ---
14
+
15
+ <div
16
+ class:list={['starwind-dropdown', className]}
17
+ data-open-on-hover={openOnHover ? 'true' : undefined}
18
+ data-close-delay={closeDelay}
19
+ {...rest}
20
+ data-slot="dropdown"
21
+ >
22
+ <slot />
23
+ </div>
24
+
25
+ <style>
26
+ div[data-slot='dropdown'] {
27
+ position: relative;
28
+ }
29
+ </style>
30
+
31
+ <script>
32
+ class DropdownHandler {
33
+ private dropdown: HTMLElement;
34
+ private trigger: HTMLElement | null;
35
+ private content: HTMLElement | null;
36
+ private items: HTMLElement[] = [];
37
+ private currentFocusIndex: number = -1;
38
+ private isOpen: boolean = false;
39
+ private isClosing: boolean = false;
40
+ private animationDuration = 150;
41
+ private openOnHover: boolean;
42
+ private closeDelay: number;
43
+ private closeTimerRef: number | null = null;
44
+ private lastOpenSource: 'keyboard' | 'mouse' = 'keyboard';
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;
52
+
53
+ constructor(dropdown: HTMLElement, dropdownIdx: number) {
54
+ this.dropdown = dropdown;
55
+ this.isSubmenu = dropdown.classList.contains('starwind-dropdown-sub');
56
+ this.openOnHover =
57
+ dropdown.getAttribute('data-open-on-hover') === 'true' || this.isSubmenu;
58
+ this.closeDelay = parseInt(dropdown.getAttribute('data-close-delay') || '200');
59
+
60
+ // Find the trigger and content that belong to this dropdown, avoiding nested ones
61
+ this.trigger = this.findOwnElement(
62
+ '[data-slot="dropdown-trigger"], [data-sub-trigger]'
63
+ );
64
+
65
+ // if trigger is set with asChild, use the first child element for trigger button
66
+ if (this.trigger?.hasAttribute('data-as-child')) {
67
+ this.trigger = this.trigger.firstElementChild as HTMLElement;
68
+ }
69
+
70
+ this.content = this.findOwnElement('[data-slot="dropdown-content"]');
71
+
72
+ // Resolve root dropdown before any portaling (DOM is in original state)
73
+ this.rootDropdown =
74
+ (this.dropdown.closest('.starwind-dropdown') as HTMLElement) || this.dropdown;
75
+
76
+ // Store handler reference and root dropdown on content element for lookups
77
+ if (this.content) {
78
+ (this.content as any).__dropdownHandler = this;
79
+ (this.content as any).__rootDropdown = this.rootDropdown;
80
+ }
81
+
82
+ if (!this.trigger || !this.content) return;
83
+
84
+ // Get animation duration from inline styles if available
85
+ const animationDurationString = this.content.style.animationDuration;
86
+ if (animationDurationString.endsWith('ms')) {
87
+ this.animationDuration = parseFloat(animationDurationString);
88
+ } else if (animationDurationString.endsWith('s')) {
89
+ this.animationDuration = parseFloat(animationDurationString) * 1000;
90
+ }
91
+
92
+ this.init(dropdownIdx);
93
+ }
94
+
95
+ private findOwnElement(selector: string): HTMLElement | null {
96
+ const elements = this.dropdown.querySelectorAll(selector);
97
+ for (const el of elements) {
98
+ if (el.closest('.starwind-dropdown, .starwind-dropdown-sub') === this.dropdown) {
99
+ return el as HTMLElement;
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+
105
+ private init(dropdownIdx: number) {
106
+ this.setupAccessibility(dropdownIdx);
107
+ this.setupEvents();
108
+ }
109
+
110
+ private setupAccessibility(dropdownIdx: number) {
111
+ if (!this.trigger || !this.content) return;
112
+
113
+ // Generate unique IDs for accessibility
114
+ this.trigger.id = `starwind-dropdown${dropdownIdx}-trigger`;
115
+ this.content.id = `starwind-dropdown${dropdownIdx}-content`;
116
+
117
+ // Set up additional ARIA attributes
118
+ this.trigger.setAttribute('aria-controls', this.content.id);
119
+ this.content.setAttribute('aria-labelledby', this.trigger.id);
120
+ }
121
+
122
+ private setupEvents() {
123
+ if (!this.trigger || !this.content) return;
124
+
125
+ // Handle trigger click
126
+ this.trigger.addEventListener('click', (e) => {
127
+ e.preventDefault();
128
+ this.lastOpenSource = e.detail === 0 ? 'keyboard' : 'mouse';
129
+ this.toggleDropdown();
130
+ });
131
+
132
+ // Handle keyboard navigation on trigger
133
+ this.trigger.addEventListener('keydown', (e) => {
134
+ if (e.key === 'Enter' || e.key === ' ') {
135
+ e.preventDefault();
136
+ e.stopPropagation();
137
+ this.lastOpenSource = 'keyboard';
138
+ if (this.isSubmenu) {
139
+ this.openDropdown();
140
+ } else {
141
+ this.toggleDropdown();
142
+ }
143
+ } else if (e.key === 'Escape' && this.isOpen) {
144
+ e.preventDefault();
145
+ e.stopPropagation();
146
+ this.lastCloseSource = 'keyboard';
147
+ // Close all menus in the group, focus root trigger
148
+ this.rootDropdown.dispatchEvent(
149
+ new CustomEvent('starwind-dropdown:close-all', {
150
+ detail: { focusRoot: true },
151
+ })
152
+ );
153
+ } else if (this.isOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
154
+ e.preventDefault();
155
+ e.stopPropagation();
156
+ this.lastOpenSource = 'keyboard';
157
+ this.updateDropdownItems();
158
+ if (e.key === 'ArrowDown') {
159
+ this.focusItem(0); // Focus first item when opening with arrow down
160
+ } else {
161
+ this.focusItem(this.items.length - 1); // Focus last item when opening with arrow up
162
+ }
163
+ } else if (e.key === 'ArrowRight' && this.isSubmenu) {
164
+ e.preventDefault();
165
+ e.stopPropagation();
166
+ if (!this.isOpen) {
167
+ this.lastOpenSource = 'keyboard';
168
+ this.openDropdown();
169
+ } else {
170
+ this.updateDropdownItems();
171
+ this.focusItem(0);
172
+ }
173
+ }
174
+ });
175
+
176
+ // Listen for close-all event (routed through rootDropdown to work across portaled content)
177
+ this.rootDropdown.addEventListener('starwind-dropdown:close-all', ((e: CustomEvent) => {
178
+ this.closeDropdown({ skipFocus: e.detail?.focusRoot && this.isSubmenu });
179
+ }) as EventListener);
180
+
181
+ // Listen for cancel-close event (routed through rootDropdown)
182
+ this.rootDropdown.addEventListener('starwind-dropdown:cancel-close', () => {
183
+ this.clearCloseTimer();
184
+ });
185
+
186
+ // Close dropdown when clicking outside for mouse
187
+ document.addEventListener('pointerdown', (e) => {
188
+ if (
189
+ this.isOpen &&
190
+ !this.dropdown.contains(e.target as Node) &&
191
+ !this.content?.contains(e.target as Node) &&
192
+ !this.isTargetInGroup(e.target as Node)
193
+ ) {
194
+ if (e.button === 0 && e.ctrlKey === false && e.pointerType === 'mouse') {
195
+ this.closeDropdown();
196
+ }
197
+ }
198
+ });
199
+
200
+ // Handle click outside select content to close for mobile
201
+ document.addEventListener('click', (e) => {
202
+ if (
203
+ this.isOpen &&
204
+ !this.trigger?.contains(e.target as Node) &&
205
+ !this.content?.contains(e.target as Node) &&
206
+ !this.isTargetInGroup(e.target as Node)
207
+ ) {
208
+ this.closeDropdown();
209
+ }
210
+ });
211
+
212
+ // Handle keyboard navigation and item selection within dropdown
213
+ this.content.addEventListener('keydown', (e) => {
214
+ if (e.key === 'Escape') {
215
+ e.preventDefault();
216
+ e.stopPropagation();
217
+ // Close all menus in the group, focus root trigger
218
+ this.rootDropdown.dispatchEvent(
219
+ new CustomEvent('starwind-dropdown:close-all', {
220
+ detail: { focusRoot: true },
221
+ })
222
+ );
223
+ } else if (this.isOpen) {
224
+ this.handleMenuKeydown(e);
225
+ }
226
+ });
227
+
228
+ // Handle item selection
229
+ this.content.addEventListener('click', (e) => {
230
+ const target = e.target as HTMLElement;
231
+ const item = target.closest('[role="menuitem"]');
232
+ if (item && !(item as HTMLElement).hasAttribute('data-disabled')) {
233
+ if (item.hasAttribute('aria-haspopup')) return;
234
+
235
+ // Close the dropdown after item selection
236
+ this.closeDropdown();
237
+
238
+ // Dispatch close-all through root to close all menus in the group
239
+ this.rootDropdown.dispatchEvent(new CustomEvent('starwind-dropdown:close-all'));
240
+ }
241
+ });
242
+
243
+ // Handle hover on dropdown items
244
+ this.content.addEventListener('mouseover', (e) => {
245
+ const target = e.target as HTMLElement;
246
+ const menuItem = target.closest('[role="menuitem"]');
247
+ if (menuItem && menuItem instanceof HTMLElement && this.isOpen === true) {
248
+ // Update items list before focusing to ensure the index is correct
249
+ this.updateDropdownItems();
250
+
251
+ // Focus the item when hovering
252
+ menuItem.focus();
253
+
254
+ // Update the current focus index
255
+ this.currentFocusIndex = this.items.indexOf(menuItem);
256
+ }
257
+ });
258
+
259
+ if (this.openOnHover) {
260
+ this.trigger.addEventListener('pointerenter', (e) => {
261
+ if (e.pointerType !== 'mouse') return;
262
+ if (this.isClosing) return;
263
+ if (!this.isOpen) {
264
+ this.lastOpenSource = 'mouse';
265
+ this.openDropdown();
266
+ } else {
267
+ // If the dropdown is already open, make sure to clear any close timer
268
+ this.clearCloseTimer();
269
+ }
270
+ });
271
+
272
+ this.dropdown.addEventListener('pointerleave', (e) => {
273
+ if (e.pointerType !== 'mouse') return;
274
+ if (this.openChildCount > 0) return;
275
+ if (this.isOpen) {
276
+ this.lastCloseSource = 'mouse';
277
+ this.closeDropdownDelayed();
278
+ }
279
+ });
280
+
281
+ this.content.addEventListener('pointerenter', (e) => {
282
+ if (e.pointerType !== 'mouse') return;
283
+ // If the user moves the mouse to the content, cancel the close timer
284
+ this.clearCloseTimer();
285
+ });
286
+ }
287
+ }
288
+
289
+ private handleMenuKeydown(e: KeyboardEvent) {
290
+ // Make sure we've got an updated list of menu items
291
+ this.updateDropdownItems();
292
+
293
+ // Skip if no items
294
+ if (this.items.length === 0) return;
295
+
296
+ const currentIdx = this.currentFocusIndex;
297
+ const currentItem = this.items[currentIdx];
298
+
299
+ switch (e.key) {
300
+ case 'ArrowDown':
301
+ e.preventDefault();
302
+ e.stopPropagation();
303
+ this.focusItem(currentIdx === -1 ? 0 : currentIdx + 1);
304
+ break;
305
+ case 'ArrowUp':
306
+ e.preventDefault();
307
+ e.stopPropagation();
308
+ this.focusItem(currentIdx === -1 ? this.items.length - 1 : currentIdx - 1);
309
+ break;
310
+ case 'ArrowRight':
311
+ if (currentItem?.getAttribute('aria-haspopup') === 'true') {
312
+ e.preventDefault();
313
+ e.stopPropagation();
314
+ currentItem.click();
315
+ }
316
+ break;
317
+ case 'ArrowLeft':
318
+ if (this.isSubmenu) {
319
+ e.preventDefault();
320
+ e.stopPropagation();
321
+ this.closeDropdown();
322
+ }
323
+ break;
324
+ case 'Home':
325
+ e.preventDefault();
326
+ e.stopPropagation();
327
+ this.focusItem(0);
328
+ break;
329
+ case 'End':
330
+ e.preventDefault();
331
+ e.stopPropagation();
332
+ this.focusItem(this.items.length - 1);
333
+ break;
334
+ case 'Enter':
335
+ case ' ':
336
+ if (currentIdx !== -1) {
337
+ e.preventDefault();
338
+ e.stopPropagation();
339
+ this.items[currentIdx]?.click();
340
+ }
341
+ break;
342
+ }
343
+ }
344
+
345
+ private isTargetInGroup(target: Node): boolean {
346
+ const el = target instanceof HTMLElement ? target : target.parentElement;
347
+ if (!el) return false;
348
+ const contentEl = el.closest('[data-slot="dropdown-content"]');
349
+ return !!contentEl && (contentEl as any).__rootDropdown === this.rootDropdown;
350
+ }
351
+
352
+ private updateDropdownItems() {
353
+ if (!this.content) return;
354
+ // Get all interactive menuitem elements belonging to this menu level only
355
+ this.items = (
356
+ Array.from(
357
+ this.content.querySelectorAll('[role="menuitem"]:not([data-disabled="true"])')
358
+ ) as HTMLElement[]
359
+ ).filter((item) => item.closest('[role="menu"]') === this.content);
360
+ }
361
+
362
+ private focusItem(idx: number) {
363
+ // Ensure the index wraps around properly
364
+ const targetIdx = (idx + this.items.length) % this.items.length;
365
+
366
+ if (this.items[targetIdx]) {
367
+ this.items[targetIdx].focus();
368
+ this.currentFocusIndex = targetIdx;
369
+ }
370
+ }
371
+
372
+ private toggleDropdown() {
373
+ if (this.isOpen) {
374
+ this.closeDropdown();
375
+ } else {
376
+ this.openDropdown();
377
+ }
378
+ }
379
+
380
+ private openDropdown() {
381
+ if (this.isClosing) return;
382
+ if (!this.content || !this.trigger || (this.trigger as any).disabled) return;
383
+
384
+ this.isOpen = true;
385
+ this.trigger.setAttribute('aria-expanded', 'true');
386
+ this.content.style.removeProperty('display');
387
+
388
+ // Portal sub-content to body before triggering animation
389
+ if (this.isSubmenu) {
390
+ this.portalContent();
391
+ }
392
+
393
+ this.content.setAttribute('data-state', 'open');
394
+
395
+ // Update the list of dropdown items
396
+ this.updateDropdownItems();
397
+
398
+ // Reset focus index when opening
399
+ this.currentFocusIndex = -1;
400
+
401
+ this.positionContent();
402
+
403
+ // For submenus opened by keyboard, focus the first item
404
+ if (this.isSubmenu && this.lastOpenSource === 'keyboard') {
405
+ requestAnimationFrame(() => {
406
+ this.focusItem(0);
407
+ });
408
+ }
409
+ }
410
+
411
+ private closeDropdown(options?: { skipFocus?: boolean }) {
412
+ if (!this.isOpen) return;
413
+ if (!this.content || !this.trigger) return;
414
+
415
+ this.isClosing = true;
416
+ this.isOpen = false;
417
+ this.content.setAttribute('data-state', 'closed');
418
+
419
+ // Set focus back on trigger only if opened or closed by keyboard
420
+ // Skip focus if a close-all with focusRoot is closing this submenu (root will handle focus)
421
+ if (
422
+ !options?.skipFocus &&
423
+ (!this.openOnHover ||
424
+ this.lastOpenSource === 'keyboard' ||
425
+ this.lastCloseSource === 'keyboard')
426
+ ) {
427
+ requestAnimationFrame(() => {
428
+ if (!this.trigger) return;
429
+ this.trigger.focus();
430
+ });
431
+ }
432
+
433
+ // Give the content time to animate before hiding
434
+ setTimeout(() => {
435
+ if (!this.content) return;
436
+ if (this.isSubmenu) {
437
+ this.unportalContent();
438
+ }
439
+ this.content.style.display = 'none';
440
+ this.isClosing = false;
441
+ }, this.animationDuration);
442
+
443
+ this.trigger.setAttribute('aria-expanded', 'false');
444
+
445
+ // Reset focus index when closing
446
+ this.currentFocusIndex = -1;
447
+ }
448
+
449
+ private closeDropdownDelayed() {
450
+ if (!this.content || !this.trigger) return;
451
+
452
+ // Clear any existing close timer
453
+ this.clearCloseTimer();
454
+
455
+ // Set a new timer to close the dropdown after the delay
456
+ this.closeTimerRef = window.setTimeout(() => {
457
+ if (this.isOpen) {
458
+ this.closeDropdown();
459
+ }
460
+ this.closeTimerRef = null;
461
+ }, this.closeDelay);
462
+ }
463
+
464
+ private clearCloseTimer() {
465
+ if (this.closeTimerRef !== null) {
466
+ window.clearTimeout(this.closeTimerRef);
467
+ this.closeTimerRef = null;
468
+ }
469
+ }
470
+
471
+ private portalContent() {
472
+ if (!this.content || !this.trigger) return;
473
+
474
+ // Save the original position with a placeholder
475
+ this.contentPlaceholder = document.createComment('dropdown-sub-placeholder');
476
+ this.content.parentNode?.insertBefore(this.contentPlaceholder, this.content);
477
+
478
+ // Move content to body
479
+ document.body.appendChild(this.content);
480
+
481
+ // Apply fixed positioning (overrides the CSS absolute class)
482
+ this.content.style.position = 'fixed';
483
+ this.content.style.zIndex = '50';
484
+
485
+ // Position relative to trigger
486
+ this.positionSubContent();
487
+
488
+ // Set up auto-update for scroll/resize
489
+ const updatePosition = () => this.positionSubContent();
490
+ window.addEventListener('scroll', updatePosition, true);
491
+ window.addEventListener('resize', updatePosition);
492
+
493
+ // Find and link to parent handler for child-tracking
494
+ const parentContent = this.trigger.closest('[data-slot="dropdown-content"]');
495
+ if (parentContent) {
496
+ this.parentHandler = (parentContent as any).__dropdownHandler || null;
497
+ if (this.parentHandler) {
498
+ this.parentHandler.openChildCount++;
499
+ }
500
+ }
501
+
502
+ // Handle hover interactions for portaled content
503
+ const onPointerEnter = (e: PointerEvent) => {
504
+ if (e.pointerType !== 'mouse') return;
505
+ this.clearCloseTimer();
506
+ // Cancel close timers for all handlers in the group (parents included)
507
+ this.rootDropdown.dispatchEvent(new CustomEvent('starwind-dropdown:cancel-close'));
508
+ };
509
+
510
+ const onPointerLeave = (e: PointerEvent) => {
511
+ if (e.pointerType !== 'mouse') return;
512
+ // Don't close if a child submenu is still open
513
+ if (this.openChildCount > 0) return;
514
+ this.lastCloseSource = 'mouse';
515
+ this.closeDropdownDelayed();
516
+ };
517
+
518
+ this.content.addEventListener('pointerenter', onPointerEnter);
519
+ this.content.addEventListener('pointerleave', onPointerLeave);
520
+
521
+ this.cleanupAutoUpdate = () => {
522
+ window.removeEventListener('scroll', updatePosition, true);
523
+ window.removeEventListener('resize', updatePosition);
524
+ this.content?.removeEventListener('pointerenter', onPointerEnter);
525
+ this.content?.removeEventListener('pointerleave', onPointerLeave);
526
+ };
527
+ }
528
+
529
+ private unportalContent() {
530
+ if (!this.content || !this.contentPlaceholder) return;
531
+
532
+ // Decrement parent's open child count
533
+ if (this.parentHandler) {
534
+ this.parentHandler.openChildCount--;
535
+ this.parentHandler = null;
536
+ }
537
+
538
+ // Clean up auto-update and hover listeners
539
+ this.cleanupAutoUpdate?.();
540
+ this.cleanupAutoUpdate = null;
541
+
542
+ // Move content back to its original position
543
+ this.contentPlaceholder.parentNode?.insertBefore(this.content, this.contentPlaceholder);
544
+ this.contentPlaceholder.remove();
545
+ this.contentPlaceholder = null;
546
+
547
+ // Remove fixed positioning styles
548
+ this.content.style.removeProperty('position');
549
+ this.content.style.removeProperty('z-index');
550
+ this.content.style.removeProperty('top');
551
+ this.content.style.removeProperty('left');
552
+ }
553
+
554
+ private positionSubContent() {
555
+ if (!this.content || !this.trigger) return;
556
+
557
+ const triggerRect = this.trigger.getBoundingClientRect();
558
+
559
+ // Position to the right of the trigger by default
560
+ let top = triggerRect.top;
561
+ let left = triggerRect.right;
562
+
563
+ // Measure content dimensions
564
+ const contentWidth = this.content.offsetWidth;
565
+ const contentHeight = this.content.offsetHeight;
566
+ const viewportWidth = window.innerWidth;
567
+ const viewportHeight = window.innerHeight;
568
+
569
+ // If it would overflow on the right, flip to the left
570
+ if (left + contentWidth > viewportWidth) {
571
+ left = triggerRect.left - contentWidth;
572
+ }
573
+
574
+ // If it would overflow on the bottom, shift up
575
+ if (top + contentHeight > viewportHeight) {
576
+ top = Math.max(0, viewportHeight - contentHeight);
577
+ }
578
+
579
+ this.content.style.top = `${top}px`;
580
+ this.content.style.left = `${left}px`;
581
+ }
582
+
583
+ private positionContent() {
584
+ if (!this.content || !this.trigger || this.isSubmenu) return;
585
+
586
+ // // Set content width to match trigger width
587
+ // this.content.style.width = 'var(--starwind-dropdown-trigger-width)';
588
+ // this.content.style.setProperty(
589
+ // '--starwind-dropdown-trigger-width',
590
+ // `${this.trigger.offsetWidth}px`
591
+ // );
592
+ }
593
+ }
594
+
595
+ // Store instances in a WeakMap to avoid memory leaks
596
+ const dropdownInstances = new WeakMap<HTMLElement, DropdownHandler>();
597
+ let dropdownCounter = 0;
598
+
599
+ // Initialize dropdowns
600
+ const initDropdowns = () => {
601
+ document
602
+ .querySelectorAll('.starwind-dropdown, .starwind-dropdown-sub')
603
+ .forEach((dropdown) => {
604
+ if (dropdown instanceof HTMLElement && !dropdownInstances.has(dropdown)) {
605
+ dropdownInstances.set(
606
+ dropdown,
607
+ new DropdownHandler(dropdown, dropdownCounter++)
608
+ );
609
+ }
610
+ });
611
+ };
612
+
613
+ initDropdowns();
614
+ document.addEventListener('astro:after-swap', initDropdowns);
615
+ document.addEventListener('starwind:init', initDropdowns);
616
+ </script>