Skip to content

Commit 2e63eb9

Browse files
committed
♻️ Extract methods (#1907)
1 parent 696e76b commit 2e63eb9

File tree

2 files changed

+188
-92
lines changed

2 files changed

+188
-92
lines changed

src/lib/actions/handle_dropdown.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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+
/**
7+
* A Svelte action that manages dropdown behavior for an HTML element.
8+
*
9+
* This action handles common dropdown interactions such as:
10+
* - Closing when clicking outside the dropdown
11+
* - Closing on page scroll
12+
* - Coordinating with other dropdowns to ensure only one is open at a time
13+
*
14+
* @param node - The HTML element that contains the dropdown
15+
* @param options - Configuration options for the dropdown behavior
16+
* @param options.dropdownId - Unique identifier for this dropdown
17+
* @param options.isOpen - Boolean indicating if the dropdown is currently open
18+
* @param options.closeDropdown - Function to call when the dropdown should close
19+
* @param options.onStatusChange - Optional callback that fires when dropdown open state changes
20+
*
21+
* @returns An object containing update and destroy methods (Svelte action interface)
22+
* @returns.update - Method to update the dropdown options
23+
* @returns.destroy - Cleanup method to remove event listeners
24+
*
25+
* @example
26+
* <div use:handleDropdownBehavior={{
27+
* dropdownId: 'user-menu',
28+
* isOpen: $isMenuOpen,
29+
* closeDropdown: () => isMenuOpen.set(false),
30+
* onStatusChange: (status) => console.log(`Menu is ${status ? 'open' : 'closed'}`)
31+
* }}>
32+
* <!-- Dropdown content -->
33+
* </div>
34+
*/
35+
export function handleDropdownBehavior(
36+
node: HTMLElement,
37+
options: {
38+
dropdownId: string;
39+
isOpen: boolean;
40+
closeDropdown: () => void;
41+
onStatusChange?: (status: boolean) => void;
42+
},
43+
) {
44+
if (!browser) {
45+
return {
46+
update: () => {},
47+
destroy: () => {},
48+
};
49+
}
50+
51+
let ignoreNextClick = false;
52+
53+
// Close the dropdown on scroll.
54+
const handleScroll = () => {
55+
if (options.isOpen) {
56+
options.closeDropdown();
57+
}
58+
};
59+
60+
// Close the dropdown when clicking outside of it.
61+
const handleWindowClick = (event: MouseEvent) => {
62+
if (ignoreNextClick) {
63+
ignoreNextClick = false;
64+
return;
65+
}
66+
67+
if (options.isOpen && !node.contains(event.target as Node)) {
68+
options.closeDropdown();
69+
}
70+
};
71+
72+
// Close the dropdown if another one is opened.
73+
const unsubscribe = activeDropdownId.subscribe((id) => {
74+
if (id && id !== options.dropdownId && options.isOpen) {
75+
options.closeDropdown();
76+
}
77+
});
78+
79+
window.addEventListener('click', handleWindowClick);
80+
window.addEventListener('scroll', handleScroll, { passive: true });
81+
82+
return {
83+
update(newOptions: {
84+
dropdownId: string;
85+
isOpen: boolean;
86+
closeDropdown: () => void;
87+
onStatusChange?: (status: boolean) => void;
88+
}) {
89+
Object.assign(options, newOptions);
90+
},
91+
92+
destroy() {
93+
if (browser) {
94+
window.removeEventListener('scroll', handleScroll);
95+
window.removeEventListener('click', handleWindowClick);
96+
unsubscribe();
97+
}
98+
},
99+
};
100+
}
101+
102+
/**
103+
* Calculates the position for a dropdown menu based on the event's target element.
104+
*
105+
* @param event - The mouse event that triggered the dropdown
106+
* @returns An object containing:
107+
* - x: The horizontal position (right edge of the element)
108+
* - y: The vertical position (bottom edge of the element)
109+
* - isLower: Boolean indicating whether the element is in the lower half of the viewport
110+
*/
111+
export function calculateDropdownPosition(event: MouseEvent): {
112+
x: number;
113+
y: number;
114+
isLower: boolean;
115+
} {
116+
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
117+
118+
return {
119+
x: rect.right,
120+
y: rect.bottom,
121+
isLower: rect.top > window.innerHeight / 2,
122+
};
123+
}
124+
125+
/**
126+
* Toggles a dropdown menu's visibility state and manages its active status.
127+
*
128+
* @param event - The mouse event that triggered the dropdown toggle. If undefined, the dropdown
129+
* is toggled without position calculations or stopping event propagation.
130+
* @param options - Configuration options for the dropdown toggle operation
131+
* @param options.dropdownId - Unique identifier for the dropdown being toggled
132+
* @param options.toggle - Callback function that handles the actual toggling of the dropdown state
133+
* @param options.getPosition - Optional callback to calculate and set dropdown position based on the mouse event
134+
*
135+
* @remarks
136+
* This function handles several aspects of dropdown management:
137+
* - Stops event propagation if an event is provided
138+
* - Optionally positions the dropdown based on mouse coordinates
139+
* - Updates the currently active dropdown ID in the application state
140+
* - Executes the toggle callback to change dropdown visibility
141+
*/
142+
export function toggleDropdown(
143+
event: MouseEvent | undefined,
144+
options: {
145+
dropdownId: string;
146+
toggle: () => void;
147+
getPosition?: (e: MouseEvent) => void;
148+
},
149+
) {
150+
if (event) {
151+
event.preventDefault();
152+
event.stopPropagation();
153+
154+
if (options.getPosition) {
155+
options.getPosition(event);
156+
}
157+
}
158+
159+
activeDropdownId.set(options.dropdownId);
160+
161+
setTimeout(() => {
162+
options.toggle();
163+
}, 10);
164+
}

src/lib/components/SubmissionStatus/UpdatingDropdown.svelte

Lines changed: 24 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,9 @@
2525
2626
<UpdatingDropdown bind:this={updatingDropdown} {taskResult} {isLoggedIn} {onupdate} />
2727
-->
28-
<script module lang="ts">
29-
import { writable } from 'svelte/store';
30-
31-
// Avoid multiple activation of dropdowns.
32-
const activeDropdownId = writable<string | null>(null);
33-
</script>
34-
3528
<script lang="ts">
3629
import { getStores } from '$app/stores';
3730
import { enhance } from '$app/forms';
38-
import { onMount } from 'svelte';
39-
import { browser } from '$app/environment';
4031
4132
import { Dropdown, DropdownUl, DropdownLi, uiHelpers } from 'svelte-5-ui-lib';
4233
import Check from 'lucide-svelte/icons/check';
@@ -45,6 +36,11 @@
4536
4637
import InputFieldWrapper from '$lib/components/InputFieldWrapper.svelte';
4738
39+
import {
40+
handleDropdownBehavior,
41+
calculateDropdownPosition,
42+
toggleDropdown,
43+
} from '$lib/actions/handle_dropdown';
4844
import { submission_statuses } from '$lib/services/submission_status';
4945
import { errorMessageStore } from '$lib/stores/error_message';
5046
@@ -66,19 +62,8 @@
6662
let closeDropdown = dropdown.close;
6763
6864
let dropdownPosition = $state({ x: 0, y: 0, isLower: false });
69-
7065
const componentId = Math.random().toString(36).substring(2);
7166
72-
onMount(() => {
73-
const unsubscribe = activeDropdownId.subscribe((id) => {
74-
if (id && id !== componentId && dropdownStatus) {
75-
closeDropdown();
76-
}
77-
});
78-
79-
return unsubscribe;
80-
});
81-
8267
$effect(() => {
8368
activeUrl = $page.url.pathname;
8469
dropdownStatus = dropdown.isOpen;
@@ -90,28 +75,16 @@
9075
});
9176
9277
export function toggle(event?: MouseEvent): void {
93-
if (event) {
94-
event.stopPropagation();
95-
getDropdownPosition(event);
96-
}
97-
98-
activeDropdownId.set(componentId);
99-
dropdown.toggle();
100-
101-
// Note: Wait until the next event cycle before accepting a click event.
102-
if (!dropdownStatus) {
103-
setTimeout(() => {}, 0);
104-
}
78+
toggleDropdown(event, {
79+
dropdownId: componentId,
80+
toggle: dropdown.toggle,
81+
getPosition: updateDropdownPosition,
82+
});
10583
}
10684
107-
function getDropdownPosition(event: MouseEvent): void {
108-
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
109-
110-
dropdownPosition = {
111-
x: rect.right,
112-
y: rect.bottom,
113-
isLower: rect.top > window.innerHeight / 2,
114-
};
85+
// Required for the dropdown to open at the correct position.
86+
function updateDropdownPosition(event: MouseEvent): void {
87+
dropdownPosition = calculateDropdownPosition(event);
11588
}
11689
11790
function getDropdownClasses(isLower: boolean): string {
@@ -235,60 +208,19 @@
235208
};
236209
return option;
237210
});
238-
239-
function handleDropdown(node: HTMLElement) {
240-
if (!browser) {
241-
return {
242-
destroy: () => {},
243-
};
244-
}
245-
246-
let justOpened = false;
247-
248-
const handleScroll = () => {
249-
if (dropdownStatus) {
250-
closeDropdown();
251-
}
252-
};
253-
254-
const handleWindowClick = (event: MouseEvent) => {
255-
if (justOpened) {
256-
justOpened = false;
257-
return;
258-
}
259-
260-
if (dropdownStatus && !node.contains(event.target as Node)) {
261-
closeDropdown();
262-
}
263-
};
264-
265-
window.addEventListener('scroll', handleScroll, { passive: true });
266-
window.addEventListener('click', handleWindowClick);
267-
268-
return {
269-
update(newStatus: boolean) {
270-
if (newStatus && !dropdownStatus) {
271-
justOpened = true;
272-
273-
setTimeout(() => {
274-
justOpened = false;
275-
}, 0);
276-
}
277-
278-
dropdownStatus = newStatus;
279-
},
280-
281-
destroy() {
282-
if (browser) {
283-
window.removeEventListener('scroll', handleScroll);
284-
window.removeEventListener('click', handleWindowClick);
285-
}
286-
},
287-
};
288-
}
289211
</script>
290212

291-
<div class="fixed inset-0 pointer-events-none z-50 w-full h-full" use:handleDropdown>
213+
<div
214+
class="fixed inset-0 pointer-events-none z-50 w-full h-full"
215+
use:handleDropdownBehavior={{
216+
dropdownId: componentId,
217+
isOpen: dropdownStatus,
218+
closeDropdown,
219+
onStatusChange: (status: boolean) => {
220+
dropdownStatus = status;
221+
},
222+
}}
223+
>
292224
<Dropdown
293225
{activeUrl}
294226
{dropdownStatus}

0 commit comments

Comments
 (0)