Skip to content

Commit 2bfec24

Browse files
authored
Merge pull request #1910 from AtCoder-NoviSteps/#1907
🐛 Avoid multiple activation of dropdowns (#1907)
2 parents 2908a95 + 8fbd554 commit 2bfec24

File tree

2 files changed

+297
-19
lines changed

2 files changed

+297
-19
lines changed

src/lib/actions/handle_dropdown.ts

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import { writable, type Writable } from 'svelte/store';
2+
import { browser } from '$app/environment';
3+
4+
const activeDropdownId: Writable<string | null> = writable<string | null>(null);
5+
6+
let lastTriggerElement: HTMLElement | null = null;
7+
// Bounce timer for resizing event.
8+
let resizeTimeout: ReturnType<typeof setTimeout>;
9+
10+
/**
11+
* A Svelte action that manages dropdown behavior for an HTML element.
12+
*
13+
* This action handles common dropdown interactions such as:
14+
* - Closing when clicking outside the dropdown
15+
* - Closing on page scroll
16+
* - Coordinating with other dropdowns to ensure only one is open at a time
17+
* - Recalculating the dropdown position on window resize
18+
*
19+
* @param node - The HTML element that contains the dropdown
20+
* @param options - Configuration options for the dropdown behavior
21+
* @param options.dropdownId - Unique identifier for this dropdown
22+
* @param options.isOpen - Boolean indicating if the dropdown is currently open
23+
* @param options.closeDropdown - Function to call when the dropdown should close
24+
* @param options.onStatusChange - Optional callback that fires when dropdown open state changes
25+
* @param options.updatePosition - Optional function to update the dropdown position
26+
*
27+
* @returns An object containing update and destroy methods (Svelte action interface)
28+
* @returns.update - Method to update the dropdown options
29+
* @returns.destroy - Cleanup method to remove event listeners
30+
*
31+
* @example
32+
* <div use:handleDropdownBehavior={{
33+
* dropdownId: 'user-menu',
34+
* isOpen: $isMenuOpen,
35+
* closeDropdown: () => isMenuOpen.set(false),
36+
* onStatusChange: (status) => console.log(`Menu is ${status ? 'open' : 'closed'}`)
37+
* updatePosition: (x, y, isInBottomHalf) => {updateDropdownPosition(x, y, isInBottomHalf)},
38+
* }}>
39+
* <!-- Dropdown content -->
40+
* </div>
41+
*/
42+
export function handleDropdownBehavior(
43+
node: HTMLElement,
44+
options: {
45+
dropdownId: string;
46+
isOpen: boolean;
47+
closeDropdown: () => void;
48+
onStatusChange?: (status: boolean) => void;
49+
updatePosition?: (x: number, y: number, isInBottomHalf: boolean) => void;
50+
},
51+
) {
52+
if (!browser) {
53+
return {
54+
update: () => {},
55+
destroy: () => {},
56+
};
57+
}
58+
59+
let ignoreNextClick = false;
60+
61+
// Close the dropdown on scroll.
62+
const handleScroll = () => {
63+
if (options.isOpen) {
64+
options.closeDropdown();
65+
}
66+
};
67+
68+
// Close the dropdown when clicking outside of it.
69+
const handleWindowClick = (event: MouseEvent) => {
70+
if (ignoreNextClick) {
71+
ignoreNextClick = false;
72+
return;
73+
}
74+
75+
if (options.isOpen && !node.contains(event.target as Node)) {
76+
options.closeDropdown();
77+
}
78+
};
79+
80+
// Recalculate the dropdown position on resize.
81+
const handleWindowResize = () => {
82+
clearTimeout(resizeTimeout);
83+
84+
resizeTimeout = setTimeout(() => {
85+
recalculateDropdownPosition({
86+
updatePosition: options.updatePosition || (() => {}),
87+
dropdownIsOpen: options.isOpen,
88+
});
89+
}, 100);
90+
};
91+
92+
// Close the dropdown if another one is opened.
93+
const unsubscribe = activeDropdownId.subscribe((id) => {
94+
if (id && id !== options.dropdownId && options.isOpen) {
95+
options.closeDropdown();
96+
}
97+
});
98+
99+
// Add event listeners.
100+
window.addEventListener('click', handleWindowClick);
101+
window.addEventListener('scroll', handleScroll, { passive: true });
102+
window.addEventListener('resize', handleWindowResize, { passive: true });
103+
104+
return {
105+
update(newOptions: {
106+
dropdownId: string;
107+
isOpen: boolean;
108+
closeDropdown: () => void;
109+
onStatusChange?: (status: boolean) => void;
110+
updatePosition?: (x: number, y: number, isInBottomHalf: boolean) => void;
111+
}) {
112+
Object.assign(options, newOptions);
113+
},
114+
115+
destroy() {
116+
if (browser) {
117+
// Remove event listeners to prevent memory leaks.
118+
window.removeEventListener('scroll', handleScroll);
119+
window.removeEventListener('click', handleWindowClick);
120+
window.removeEventListener('resize', handleWindowResize);
121+
122+
clearTimeout(resizeTimeout);
123+
unsubscribe();
124+
}
125+
},
126+
};
127+
}
128+
129+
/**
130+
* Calculates the position for a dropdown menu based on the event's target element.
131+
*
132+
* @param event - The mouse event that triggered the dropdown
133+
* @returns An object containing:
134+
* - x: The horizontal position (right edge of the element)
135+
* - y: The vertical position (bottom edge of the element)
136+
* - isInBottomHalf: Boolean indicating whether the element is in the lower half of the viewport
137+
*/
138+
export function calculateDropdownPosition(event: MouseEvent): {
139+
x: number;
140+
y: number;
141+
isInBottomHalf: boolean;
142+
} {
143+
lastTriggerElement = event.currentTarget as HTMLElement;
144+
const rect = (lastTriggerElement as HTMLElement).getBoundingClientRect();
145+
const { x, y } = preventDropdownOverflowWhenNearViewportEdge(rect.right, rect.bottom);
146+
147+
return {
148+
x: x,
149+
y: y,
150+
isInBottomHalf: rect.top > window.innerHeight / 2,
151+
};
152+
}
153+
154+
/**
155+
* Recalculates the position of a dropdown menu relative to its trigger element.
156+
*
157+
* This function uses the position of the last trigger element to determine where
158+
* the dropdown should be placed. It also determines whether the dropdown should
159+
* appear above or below the trigger based on the trigger's position on the screen.
160+
*
161+
* @param options - Configuration options for positioning
162+
* @param options.updatePosition - Callback function to update the dropdown position
163+
* @param options.dropdownIsOpen - Boolean indicating if the dropdown is currently open
164+
*
165+
* @remarks
166+
* This function depends on global variables `browser` and `lastTriggerElement`,
167+
* and will do nothing if either is undefined or if the dropdown is not open.
168+
*
169+
* The dropdown will be positioned at the bottom-right corner of the trigger element.
170+
* The `isInBottomHalf` parameter passed to `updatePosition` will be true if the trigger is
171+
* in the top half of the screen, suggesting the dropdown should expand downward.
172+
*/
173+
export function recalculateDropdownPosition(options: {
174+
updatePosition: (x: number, y: number, isInBottomHalf: boolean) => void;
175+
dropdownIsOpen: boolean;
176+
}): void {
177+
if (!browser || !lastTriggerElement || !options.dropdownIsOpen) {
178+
return;
179+
}
180+
181+
const rect = lastTriggerElement.getBoundingClientRect();
182+
const { x, y } = preventDropdownOverflowWhenNearViewportEdge(rect.right, rect.bottom);
183+
options.updatePosition(x, y, rect.top > window.innerHeight / 2);
184+
}
185+
186+
/**
187+
* Adjusts coordinates to prevent a dropdown from overflowing the viewport edge.
188+
*
189+
* @param x - The horizontal coordinate to adjust
190+
* @param y - The vertical coordinate to adjust
191+
* @returns An object containing adjusted x and y coordinates that ensure the dropdown
192+
* will have at least a 10px margin from the viewport edge
193+
*
194+
* @example
195+
* // Adjust coordinates for dropdown positioning
196+
* const adjustedPosition = preventDropdownOverflowWhenNearViewportEdge(mouseX, mouseY);
197+
*/
198+
function preventDropdownOverflowWhenNearViewportEdge(
199+
x: number,
200+
y: number,
201+
): { x: number; y: number } {
202+
const margin = 10; // minimal margin from viewport edge
203+
204+
if (x > window.innerWidth - margin) {
205+
x = window.innerWidth - margin;
206+
}
207+
if (y > window.innerHeight - margin) {
208+
y = window.innerHeight - margin;
209+
}
210+
211+
return { x, y };
212+
}
213+
214+
/**
215+
* Toggles a dropdown menu's visibility state and manages its active status.
216+
*
217+
* @param event - The mouse event that triggered the dropdown toggle. If undefined, the dropdown
218+
* is toggled without position calculations or stopping event propagation.
219+
* @param options - Configuration options for the dropdown toggle operation
220+
* @param options.dropdownId - Unique identifier for the dropdown being toggled
221+
* @param options.toggle - Callback function that handles the actual toggling of the dropdown state
222+
* @param options.getPosition - Optional callback to calculate and set dropdown position based on the mouse event
223+
*
224+
* @remarks
225+
* This function handles several aspects of dropdown management:
226+
* - Stops event propagation if an event is provided
227+
* - Optionally positions the dropdown based on mouse coordinates
228+
* - Updates the currently active dropdown ID in the application state
229+
* - Executes the toggle callback to change dropdown visibility
230+
*/
231+
export function toggleDropdown(
232+
event: MouseEvent | undefined,
233+
options: {
234+
dropdownId: string;
235+
toggle: () => void;
236+
getPosition?: (e: MouseEvent) => void;
237+
},
238+
) {
239+
if (event) {
240+
event.preventDefault();
241+
event.stopPropagation();
242+
243+
// Save the last trigger element for position calculations.
244+
lastTriggerElement = event.currentTarget as HTMLElement;
245+
246+
if (options.getPosition) {
247+
options.getPosition(event);
248+
}
249+
}
250+
251+
activeDropdownId.set(options.dropdownId);
252+
253+
setTimeout(() => {
254+
options.toggle();
255+
}, 10);
256+
}

src/lib/components/SubmissionStatus/UpdatingDropdown.svelte

Lines changed: 41 additions & 19 deletions
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';
@@ -36,6 +37,11 @@
3637
3738
import InputFieldWrapper from '$lib/components/InputFieldWrapper.svelte';
3839
40+
import {
41+
handleDropdownBehavior,
42+
calculateDropdownPosition,
43+
toggleDropdown,
44+
} from '$lib/actions/handle_dropdown';
3945
import { submission_statuses } from '$lib/services/submission_status';
4046
import { errorMessageStore } from '$lib/stores/error_message';
4147
@@ -56,42 +62,47 @@
5662
let dropdownStatus = $state(false);
5763
let closeDropdown = dropdown.close;
5864
59-
let dropdownX = $state(0);
60-
let dropdownY = $state(0);
61-
let isLowerHalfInScreen = $state(false);
65+
let dropdownPosition = $state({ x: 0, y: 0, isInBottomHalf: false });
66+
const componentId = Math.random().toString(36).substring(2);
6267
6368
$effect(() => {
6469
activeUrl = $page.url.pathname;
6570
dropdownStatus = dropdown.isOpen;
6671
6772
if (dropdownStatus) {
68-
document.documentElement.style.setProperty('--dropdown-x', `${dropdownX}px`);
69-
document.documentElement.style.setProperty('--dropdown-y', `${dropdownY}px`);
73+
document.documentElement.style.setProperty('--dropdown-x', `${dropdownPosition.x}px`);
74+
document.documentElement.style.setProperty('--dropdown-y', `${dropdownPosition.y}px`);
7075
}
7176
});
7277
7378
export function toggle(event?: MouseEvent): void {
74-
if (event) {
75-
getDropdownPosition(event);
76-
}
77-
78-
dropdown.toggle();
79+
toggleDropdown(event, {
80+
dropdownId: componentId,
81+
toggle: dropdown.toggle,
82+
getPosition: updateDropdownPosition,
83+
});
7984
}
8085
81-
function getDropdownPosition(event: MouseEvent): void {
82-
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
86+
// Required for the dropdown to open at the correct position.
87+
function updateDropdownPosition(event: MouseEvent): void {
88+
const position = calculateDropdownPosition(event);
89+
updatePositionInComponent(position.x, position.y, position.isInBottomHalf);
90+
}
8391
84-
dropdownX = rect.right;
85-
dropdownY = rect.bottom;
92+
function updatePositionInComponent(x: number, y: number, isInBottomHalf: boolean) {
93+
dropdownPosition = { x, y, isInBottomHalf };
8694
87-
isLowerHalfInScreen = rect.top > window.innerHeight / 2;
95+
if (browser) {
96+
document.documentElement.style.setProperty('--dropdown-x', `${x}px`);
97+
document.documentElement.style.setProperty('--dropdown-y', `${y}px`);
98+
}
8899
}
89100
90-
function getDropdownClasses(isLower: boolean): string {
101+
function getDropdownClasses(isInBottomHalf: boolean): string {
91102
let classes =
92103
'absolute w-32 z-[999] shadow-lg pointer-events-auto left-[var(--dropdown-x)] transform -translate-x-full ';
93104
94-
if (isLower) {
105+
if (isInBottomHalf) {
95106
classes += 'bottom-[calc(100vh-var(--dropdown-y))] mb-5';
96107
} else {
97108
classes += 'top-[var(--dropdown-y)] mt-1';
@@ -210,12 +221,23 @@
210221
});
211222
</script>
212223

213-
<div class="fixed inset-0 pointer-events-none z-50 w-full h-full">
224+
<div
225+
class="fixed inset-0 pointer-events-none z-50 w-full h-full"
226+
use:handleDropdownBehavior={{
227+
dropdownId: componentId,
228+
isOpen: dropdownStatus,
229+
closeDropdown,
230+
onStatusChange: (status: boolean) => {
231+
dropdownStatus = status;
232+
},
233+
updatePosition: updatePositionInComponent,
234+
}}
235+
>
214236
<Dropdown
215237
{activeUrl}
216238
{dropdownStatus}
217239
{closeDropdown}
218-
class={getDropdownClasses(isLowerHalfInScreen)}
240+
class={getDropdownClasses(dropdownPosition.isInBottomHalf)}
219241
>
220242
<DropdownUl class="border rounded-lg shadow">
221243
{#if isLoggedIn}

0 commit comments

Comments
 (0)