Skip to content

Commit 902304b

Browse files
committed
🐛 Recalculate dropdown position on resize window (#1907)
1 parent b3800d7 commit 902304b

File tree

2 files changed

+78
-2
lines changed

2 files changed

+78
-2
lines changed

src/lib/actions/handle_dropdown.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { browser } from '$app/environment';
33

44
const 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
}

src/lib/components/SubmissionStatus/UpdatingDropdown.svelte

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
<script lang="ts">
2929
import { getStores } from '$app/stores';
3030
import { enhance } from '$app/forms';
31+
import { browser } from '$app/environment';
3132
3233
import { Dropdown, DropdownUl, DropdownLi, uiHelpers } from 'svelte-5-ui-lib';
3334
import Check from 'lucide-svelte/icons/check';
@@ -84,7 +85,17 @@
8485
8586
// Required for the dropdown to open at the correct position.
8687
function updateDropdownPosition(event: MouseEvent): void {
87-
dropdownPosition = calculateDropdownPosition(event);
88+
const position = calculateDropdownPosition(event);
89+
updatePositionInComponent(position.x, position.y, position.isLower);
90+
}
91+
92+
function updatePositionInComponent(x: number, y: number, isLower: boolean) {
93+
dropdownPosition = { x, y, isLower };
94+
95+
if (browser) {
96+
document.documentElement.style.setProperty('--dropdown-x', `${x}px`);
97+
document.documentElement.style.setProperty('--dropdown-y', `${y}px`);
98+
}
8899
}
89100
90101
function getDropdownClasses(isLower: boolean): string {
@@ -219,6 +230,7 @@
219230
onStatusChange: (status: boolean) => {
220231
dropdownStatus = status;
221232
},
233+
updatePosition: updatePositionInComponent,
222234
}}
223235
>
224236
<Dropdown

0 commit comments

Comments
 (0)