feat(admin): add clickOutside action and update dropdowns

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 <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-10-07 21:33:33 -07:00
parent 128a24ccde
commit 97a80d9c3e
3 changed files with 107 additions and 29 deletions

View file

@ -0,0 +1,94 @@
/**
* Svelte action that dispatches a 'clickoutside' event when the user clicks outside the element.
*
* @example
* ```svelte
* <div use:clickOutside on:clickoutside={() => isOpen = false}>
* Dropdown content
* </div>
* ```
*
* @example With options
* ```svelte
* <div use:clickOutside={{ enabled: isOpen }} on:clickoutside={handleClose}>
* Dropdown content
* </div>
* ```
*/
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)
}
}
}

View file

@ -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)
}
})
</script>
<div class="dropdown-container {className}">
<div
class="dropdown-container {className}"
use:clickOutside={{ enabled: isOpen }}
on:clickoutside={handleClickOutside}
>
<div class="dropdown-trigger">
{@render trigger()}

View file

@ -3,6 +3,7 @@
import InlineComposerModal from './InlineComposerModal.svelte'
import Button from './Button.svelte'
import ChevronDownIcon from '$icons/chevron-down.svg?raw'
import { clickOutside } from '$lib/actions/clickOutside'
let isOpen = $state(false)
let buttonRef: HTMLElement
@ -37,21 +38,12 @@
window.location.reload()
}
function handleClickOutside(event: MouseEvent) {
if (!buttonRef?.contains(event.target as Node)) {
isOpen = false
}
function handleClickOutside() {
isOpen = false
}
$effect(() => {
if (isOpen) {
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}
})
</script>
<div class="dropdown-container">
<div class="dropdown-container" use:clickOutside={{ enabled: isOpen }} on:clickoutside={handleClickOutside}>
<Button
bind:this={buttonRef}
variant="primary"