diff --git a/packages/library/src/lib/use-dismissable-layer.ts b/packages/library/src/lib/use-dismissable-layer.ts new file mode 100644 index 0000000..b89036f --- /dev/null +++ b/packages/library/src/lib/use-dismissable-layer.ts @@ -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, + active: Ref, + 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) +} diff --git a/packages/library/tests/use-dismissable-layer.spec.ts b/packages/library/tests/use-dismissable-layer.spec.ts new file mode 100644 index 0000000..90551a5 --- /dev/null +++ b/packages/library/tests/use-dismissable-layer.spec.ts @@ -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(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(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(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(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(container) + const active = ref(true) + const onDismiss = vi.fn() + useDismissableLayer(containerRef, active, { + onDismiss, + onPointerDownOutside: (event) => event.preventDefault(), + }) + + firePointerDown(outside) + + expect(onDismiss).not.toHaveBeenCalled() + }) +})