From 97a80d9c3eabdb7cb7102afe7753a97215a30d09 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Tue, 7 Oct 2025 21:33:33 -0700 Subject: [PATCH] feat(admin): add clickOutside action and update dropdowns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created a reusable clickOutside Svelte action that dispatches a custom event when users click outside an element. This replaces manual document.addEventListener patterns. Features: - TypeScript support with generic event types - Configurable enabled/disabled state - Optional callback parameter - Proper cleanup on destroy - setTimeout to avoid immediate triggering Updated components to use the new action: - BaseDropdown.svelte: Removed $effect with manual listeners - PostDropdown.svelte: Replaced manual click handling Part of Task 5 - Click-Outside Primitives 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/lib/actions/clickOutside.ts | 94 ++++++++++++++++++++ src/lib/components/admin/BaseDropdown.svelte | 26 ++---- src/lib/components/admin/PostDropdown.svelte | 16 +--- 3 files changed, 107 insertions(+), 29 deletions(-) create mode 100644 src/lib/actions/clickOutside.ts diff --git a/src/lib/actions/clickOutside.ts b/src/lib/actions/clickOutside.ts new file mode 100644 index 0000000..26ffbce --- /dev/null +++ b/src/lib/actions/clickOutside.ts @@ -0,0 +1,94 @@ +/** + * Svelte action that dispatches a 'clickoutside' event when the user clicks outside the element. + * + * @example + * ```svelte + *
isOpen = false}> + * Dropdown content + *
+ * ``` + * + * @example With options + * ```svelte + *
+ * Dropdown content + *
+ * ``` + */ + +export interface ClickOutsideOptions { + /** Whether the action is enabled. Defaults to true. */ + enabled?: boolean + /** Optional callback to execute on click outside. */ + callback?: () => void +} + +export function clickOutside( + element: HTMLElement, + options: ClickOutsideOptions | (() => void) = {} +) { + let enabled = true + let callback: (() => void) | undefined + + // Normalize options + if (typeof options === 'function') { + callback = options + } else { + enabled = options.enabled !== false + callback = options.callback + } + + function handleClick(event: MouseEvent) { + if (!enabled) return + + const target = event.target as Node + + // Check if click is outside the element + if (element && !element.contains(target)) { + // Dispatch custom event + element.dispatchEvent( + new CustomEvent('clickoutside', { + detail: { target } + }) + ) + + // Call callback if provided + if (callback) { + callback() + } + } + } + + // Add listener on next tick to avoid immediate triggering + setTimeout(() => { + if (enabled) { + document.addEventListener('click', handleClick, true) + } + }, 0) + + return { + update(newOptions: ClickOutsideOptions | (() => void)) { + // Remove old listener + document.removeEventListener('click', handleClick, true) + + // Normalize new options + if (typeof newOptions === 'function') { + enabled = true + callback = newOptions + } else { + enabled = newOptions.enabled !== false + callback = newOptions.callback + } + + // Add new listener if enabled + if (enabled) { + setTimeout(() => { + document.addEventListener('click', handleClick, true) + }, 0) + } + }, + destroy() { + document.removeEventListener('click', handleClick, true) + } + } +} diff --git a/src/lib/components/admin/BaseDropdown.svelte b/src/lib/components/admin/BaseDropdown.svelte index 3024a22..26fe0fc 100644 --- a/src/lib/components/admin/BaseDropdown.svelte +++ b/src/lib/components/admin/BaseDropdown.svelte @@ -2,6 +2,7 @@ import type { Snippet } from 'svelte' import Button from './Button.svelte' import DropdownMenuContainer from './DropdownMenuContainer.svelte' + import { clickOutside } from '$lib/actions/clickOutside' interface Props { isOpen?: boolean @@ -31,26 +32,17 @@ onToggle?.(isOpen) } - function handleClickOutside(event: MouseEvent) { - const target = event.target as HTMLElement - if (!target.closest(`.${className}`) && !target.closest('.dropdown-container')) { - isOpen = false - onToggle?.(false) - } + function handleClickOutside() { + isOpen = false + onToggle?.(false) } - - $effect(() => { - if (isOpen) { - // Use setTimeout to avoid immediate closing when clicking the trigger - setTimeout(() => { - document.addEventListener('click', handleClickOutside) - }, 0) - return () => document.removeEventListener('click', handleClickOutside) - } - }) -