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:
parent
128a24ccde
commit
97a80d9c3e
3 changed files with 107 additions and 29 deletions
94
src/lib/actions/clickOutside.ts
Normal file
94
src/lib/actions/clickOutside.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue