feat: add useDismissableLayer composable
Replaces reka-ui's Escape/click-outside dismissal behavior for Dialog and Select.
This commit is contained in:
parent
cbe9e45af5
commit
57c94e1207
2 changed files with 132 additions and 0 deletions
50
packages/library/src/lib/use-dismissable-layer.ts
Normal file
50
packages/library/src/lib/use-dismissable-layer.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { Ref } from 'vue'
|
||||
import { onUnmounted, watch } from 'vue'
|
||||
|
||||
export interface DismissableLayerOptions {
|
||||
onDismiss: () => void
|
||||
onPointerDownOutside?: (event: PointerEvent) => void
|
||||
}
|
||||
|
||||
export function useDismissableLayer(
|
||||
containerRef: Ref<HTMLElement | null>,
|
||||
active: Ref<boolean>,
|
||||
options: DismissableLayerOptions,
|
||||
) {
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape') return
|
||||
event.preventDefault()
|
||||
options.onDismiss()
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
if (container.contains(event.target as Node)) return
|
||||
|
||||
options.onPointerDownOutside?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
options.onDismiss()
|
||||
}
|
||||
|
||||
function attach() {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
document.addEventListener('pointerdown', handlePointerDown)
|
||||
}
|
||||
|
||||
function detach() {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
document.removeEventListener('pointerdown', handlePointerDown)
|
||||
}
|
||||
|
||||
watch(
|
||||
active,
|
||||
(isActive) => {
|
||||
if (isActive) attach()
|
||||
else detach()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(detach)
|
||||
}
|
||||
82
packages/library/tests/use-dismissable-layer.spec.ts
Normal file
82
packages/library/tests/use-dismissable-layer.spec.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { useDismissableLayer } from '../src/lib/use-dismissable-layer'
|
||||
|
||||
function firePointerDown(target: EventTarget) {
|
||||
const event = new PointerEvent('pointerdown', { bubbles: true, cancelable: true })
|
||||
target.dispatchEvent(event)
|
||||
}
|
||||
|
||||
describe('useDismissableLayer', () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('calls onDismiss on Escape keydown while active', () => {
|
||||
const containerRef = ref<HTMLElement | null>(document.createElement('div'))
|
||||
const active = ref(true)
|
||||
const onDismiss = vi.fn()
|
||||
useDismissableLayer(containerRef, active, { onDismiss })
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not call onDismiss on Escape when inactive', () => {
|
||||
const containerRef = ref<HTMLElement | null>(document.createElement('div'))
|
||||
const active = ref(false)
|
||||
const onDismiss = vi.fn()
|
||||
useDismissableLayer(containerRef, active, { onDismiss })
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onDismiss on pointerdown outside the container', () => {
|
||||
const container = document.createElement('div')
|
||||
const outside = document.createElement('div')
|
||||
document.body.append(container, outside)
|
||||
const containerRef = ref<HTMLElement | null>(container)
|
||||
const active = ref(true)
|
||||
const onDismiss = vi.fn()
|
||||
useDismissableLayer(containerRef, active, { onDismiss })
|
||||
|
||||
firePointerDown(outside)
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not call onDismiss on pointerdown inside the container', () => {
|
||||
const container = document.createElement('div')
|
||||
const inside = document.createElement('span')
|
||||
container.append(inside)
|
||||
document.body.append(container)
|
||||
const containerRef = ref<HTMLElement | null>(container)
|
||||
const active = ref(true)
|
||||
const onDismiss = vi.fn()
|
||||
useDismissableLayer(containerRef, active, { onDismiss })
|
||||
|
||||
firePointerDown(inside)
|
||||
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips onDismiss when onPointerDownOutside calls preventDefault', () => {
|
||||
const container = document.createElement('div')
|
||||
const outside = document.createElement('div')
|
||||
document.body.append(container, outside)
|
||||
const containerRef = ref<HTMLElement | null>(container)
|
||||
const active = ref(true)
|
||||
const onDismiss = vi.fn()
|
||||
useDismissableLayer(containerRef, active, {
|
||||
onDismiss,
|
||||
onPointerDownOutside: (event) => event.preventDefault(),
|
||||
})
|
||||
|
||||
firePointerDown(outside)
|
||||
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue