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 type { Snippet } from 'svelte'
|
||||||
import Button from './Button.svelte'
|
import Button from './Button.svelte'
|
||||||
import DropdownMenuContainer from './DropdownMenuContainer.svelte'
|
import DropdownMenuContainer from './DropdownMenuContainer.svelte'
|
||||||
|
import { clickOutside } from '$lib/actions/clickOutside'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen?: boolean
|
isOpen?: boolean
|
||||||
|
|
@ -31,26 +32,17 @@
|
||||||
onToggle?.(isOpen)
|
onToggle?.(isOpen)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside() {
|
||||||
const target = event.target as HTMLElement
|
|
||||||
if (!target.closest(`.${className}`) && !target.closest('.dropdown-container')) {
|
|
||||||
isOpen = false
|
isOpen = false
|
||||||
onToggle?.(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>
|
</script>
|
||||||
|
|
||||||
<div class="dropdown-container {className}">
|
<div
|
||||||
|
class="dropdown-container {className}"
|
||||||
|
use:clickOutside={{ enabled: isOpen }}
|
||||||
|
on:clickoutside={handleClickOutside}
|
||||||
|
>
|
||||||
<div class="dropdown-trigger">
|
<div class="dropdown-trigger">
|
||||||
{@render trigger()}
|
{@render trigger()}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import InlineComposerModal from './InlineComposerModal.svelte'
|
import InlineComposerModal from './InlineComposerModal.svelte'
|
||||||
import Button from './Button.svelte'
|
import Button from './Button.svelte'
|
||||||
import ChevronDownIcon from '$icons/chevron-down.svg?raw'
|
import ChevronDownIcon from '$icons/chevron-down.svg?raw'
|
||||||
|
import { clickOutside } from '$lib/actions/clickOutside'
|
||||||
|
|
||||||
let isOpen = $state(false)
|
let isOpen = $state(false)
|
||||||
let buttonRef: HTMLElement
|
let buttonRef: HTMLElement
|
||||||
|
|
@ -37,21 +38,12 @@
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside() {
|
||||||
if (!buttonRef?.contains(event.target as Node)) {
|
|
||||||
isOpen = false
|
isOpen = false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
document.addEventListener('click', handleClickOutside)
|
|
||||||
return () => document.removeEventListener('click', handleClickOutside)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dropdown-container">
|
<div class="dropdown-container" use:clickOutside={{ enabled: isOpen }} on:clickoutside={handleClickOutside}>
|
||||||
<Button
|
<Button
|
||||||
bind:this={buttonRef}
|
bind:this={buttonRef}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue