Ini yang menutup dropdown, popover, dan menu ketika orang menekan tempat lain. Terdengar sepele, tapi ada dua hal yang selalu salah kalau ditulis dadakan dengan document.addEventListener('click', …):
- Target yang belum ada. Elemen yang dipantau baru muncul setelah komponennya terpasang. Pendengar yang dipasang lebih dulu akan menganggap setiap klik jatuh "di luar" — termasuk klik yang membuka menunya sendiri. Di sini targetnya boleh berupa
refyang masih kosong, dan elemen yang belum ada dianggap belum siap, bukan dianggap di luar. - Shadow DOM.
event.targetdi dalam shadow root sudah diretarget saat peristiwanya sampai kedocument, jadicontains()selalu bilang tidak. Kalau semua targetnya tinggal di dalam shadow root yang sama, pendengarnya dipasang di root itu, bukan didocument— sama seperti sumbernya.
Installation
pnpm dlx shadcn-vue@latest add @kliping/use-click-away Perlu @kliping terdaftar di components.json project Anda. Lihat cara menyiapkannya.
Ikut terpasang sendiri: is-browser, target.
Salin dulu is-browser, target — masing-masing memuat sumbernya di halamannya sendiri — lalu tempel berkas ini.
ts
import { getCurrentScope, onScopeDispose, watch } from 'vue'
import { getTargetElement } from '@/lib/target'
import { isBrowser } from '@/lib/is-browser'
import type { BasicTarget } from '@/lib/target'
export function useClickAway<T extends Event = Event>(
onClickAway: (event: T) => void,
target: BasicTarget | BasicTarget[],
eventName: string | string[] = 'click',
) {
const eventNames = Array.isArray(eventName) ? eventName : [eventName]
const allTargets = () => {
const targets = Array.isArray(target) ? target : [target]
return targets.map((t) => getTargetElement(t))
}
const listenerRoot = (elements: (Element | Node | undefined)[]): Document | Node => {
const present = elements.filter(Boolean) as Element[]
if (!present.length) return document
const allInShadow = present.every(
(el) => typeof el.getRootNode === 'function' && el.getRootNode() instanceof ShadowRoot,
)
return allInShadow ? present[0].getRootNode() : document
}
let detach: (() => void) | undefined
const teardown = () => {
detach?.()
detach = undefined
}
const stopWatch = watch(
() => allTargets(),
(elements) => {
teardown()
if (!isBrowser) return
const root = listenerRoot(elements as Element[])
const handler = (event: Event) => {
const current = allTargets()
const inside = current.some((el) => {
if (!el) return true
return 'contains' in el && el.contains(event.target as Node)
})
if (!inside) onClickAway(event as T)
}
for (const name of eventNames) root.addEventListener(name, handler)
detach = () => {
for (const name of eventNames) root.removeEventListener(name, handler)
}
},
{ immediate: true, flush: 'post' },
)
const stop = () => {
teardown()
stopWatch()
}
if (getCurrentScope()) onScopeDispose(stop)
return stop
}API
ts
export function useClickAway<T extends Event = Event>(
onClickAway: (event: T) => void,
target: BasicTarget | BasicTarget[],
eventName: string | string[] = 'click',
)