@@ -3,6 +3,10 @@ import { browser } from '$app/environment';
33
44const activeDropdownId : Writable < string | null > = writable < string | null > ( null ) ;
55
6+ let lastTriggerElement : HTMLElement | null = null ;
7+ // Bounce timer for resizing event.
8+ let resizeTimeout : ReturnType < typeof setTimeout > ;
9+
610/**
711 * A Svelte action that manages dropdown behavior for an HTML element.
812 *
@@ -17,6 +21,7 @@ const activeDropdownId: Writable<string | null> = writable<string | null>(null);
1721 * @param options.isOpen - Boolean indicating if the dropdown is currently open
1822 * @param options.closeDropdown - Function to call when the dropdown should close
1923 * @param options.onStatusChange - Optional callback that fires when dropdown open state changes
24+ * @param options.updatePosition - Optional function to update the dropdown position
2025 *
2126 * @returns An object containing update and destroy methods (Svelte action interface)
2227 * @returns .update - Method to update the dropdown options
@@ -39,6 +44,7 @@ export function handleDropdownBehavior(
3944 isOpen : boolean ;
4045 closeDropdown : ( ) => void ;
4146 onStatusChange ?: ( status : boolean ) => void ;
47+ updatePosition ?: ( x : number , y : number , isLower : boolean ) => void ;
4248 } ,
4349) {
4450 if ( ! browser ) {
@@ -69,30 +75,53 @@ export function handleDropdownBehavior(
6975 }
7076 } ;
7177
78+ // Recalculate the dropdown position on resize.
79+ const handleResize = ( ) => {
80+ if ( options . isOpen ) {
81+ options . closeDropdown ( ) ;
82+ }
83+
84+ clearTimeout ( resizeTimeout ) ;
85+
86+ resizeTimeout = setTimeout ( ( ) => {
87+ recalculateDropdownPosition ( {
88+ updatePosition : options . updatePosition || ( ( ) => { } ) ,
89+ dropdownIsOpen : options . isOpen ,
90+ } ) ;
91+ } , 100 ) ;
92+ } ;
93+
7294 // Close the dropdown if another one is opened.
7395 const unsubscribe = activeDropdownId . subscribe ( ( id ) => {
7496 if ( id && id !== options . dropdownId && options . isOpen ) {
7597 options . closeDropdown ( ) ;
7698 }
7799 } ) ;
78100
101+ // Add event listeners.
79102 window . addEventListener ( 'click' , handleWindowClick ) ;
80103 window . addEventListener ( 'scroll' , handleScroll , { passive : true } ) ;
104+ window . addEventListener ( 'resize' , handleResize , { passive : true } ) ;
81105
82106 return {
83107 update ( newOptions : {
84108 dropdownId : string ;
85109 isOpen : boolean ;
86110 closeDropdown : ( ) => void ;
87111 onStatusChange ?: ( status : boolean ) => void ;
112+ updatePosition ?: ( x : number , y : number , isLower : boolean ) => void ;
88113 } ) {
89114 Object . assign ( options , newOptions ) ;
90115 } ,
91116
92117 destroy ( ) {
93118 if ( browser ) {
119+ // Remove event listeners to prevent memory leaks.
94120 window . removeEventListener ( 'scroll' , handleScroll ) ;
95121 window . removeEventListener ( 'click' , handleWindowClick ) ;
122+ window . removeEventListener ( 'resize' , handleResize ) ;
123+
124+ clearTimeout ( resizeTimeout ) ;
96125 unsubscribe ( ) ;
97126 }
98127 } ,
@@ -113,7 +142,8 @@ export function calculateDropdownPosition(event: MouseEvent): {
113142 y : number ;
114143 isLower : boolean ;
115144} {
116- const rect = ( event . currentTarget as HTMLElement ) . getBoundingClientRect ( ) ;
145+ lastTriggerElement = event . currentTarget as HTMLElement ;
146+ const rect = ( lastTriggerElement as HTMLElement ) . getBoundingClientRect ( ) ;
117147
118148 return {
119149 x : rect . right ,
@@ -122,6 +152,37 @@ export function calculateDropdownPosition(event: MouseEvent): {
122152 } ;
123153}
124154
155+ /**
156+ * Recalculates the position of a dropdown menu relative to its trigger element.
157+ *
158+ * This function uses the position of the last trigger element to determine where
159+ * the dropdown should be placed. It also determines whether the dropdown should
160+ * appear above or below the trigger based on the trigger's position on the screen.
161+ *
162+ * @param options - Configuration options for positioning
163+ * @param options.updatePosition - Callback function to update the dropdown position
164+ * @param options.dropdownIsOpen - Boolean indicating if the dropdown is currently open
165+ *
166+ * @remarks
167+ * This function depends on global variables `browser` and `lastTriggerElement`,
168+ * and will do nothing if either is undefined or if the dropdown is not open.
169+ *
170+ * The dropdown will be positioned at the bottom-right corner of the trigger element.
171+ * The `isLower` parameter passed to `updatePosition` will be true if the trigger is
172+ * in the top half of the screen, suggesting the dropdown should expand downward.
173+ */
174+ export function recalculateDropdownPosition ( options : {
175+ updatePosition : ( x : number , y : number , isLower : boolean ) => void ;
176+ dropdownIsOpen : boolean ;
177+ } ) : void {
178+ if ( ! browser || ! lastTriggerElement || ! options . dropdownIsOpen ) {
179+ return ;
180+ }
181+
182+ const rect = lastTriggerElement . getBoundingClientRect ( ) ;
183+ options . updatePosition ( rect . right , rect . bottom , rect . top > window . innerHeight / 2 ) ;
184+ }
185+
125186/**
126187 * Toggles a dropdown menu's visibility state and manages its active status.
127188 *
@@ -151,6 +212,9 @@ export function toggleDropdown(
151212 event . preventDefault ( ) ;
152213 event . stopPropagation ( ) ;
153214
215+ // Save the last trigger element for position calculations.
216+ lastTriggerElement = event . currentTarget as HTMLElement ;
217+
154218 if ( options . getPosition ) {
155219 options . getPosition ( event ) ;
156220 }
0 commit comments