From c55703ea3841a34acb6d97e67154d87aafeb0c98 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 30 Dec 2023 20:12:58 -0500 Subject: [PATCH] fix: escape keydown --- src/lib/internal/escape-keydown.ts | 99 +++++++++++++++++++++++++++++ src/lib/internal/helpers/index.ts | 3 + src/lib/internal/helpers/is.ts | 10 +++ src/lib/internal/helpers/noop.ts | 3 + src/lib/internal/prevent-scroll.ts | 2 +- src/lib/internal/vaul.ts | 36 +++++++++-- src/lib/vaul/components/root.svelte | 11 ++-- 7 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 src/lib/internal/escape-keydown.ts create mode 100644 src/lib/internal/helpers/is.ts create mode 100644 src/lib/internal/helpers/noop.ts diff --git a/src/lib/internal/escape-keydown.ts b/src/lib/internal/escape-keydown.ts new file mode 100644 index 0000000..4b39914 --- /dev/null +++ b/src/lib/internal/escape-keydown.ts @@ -0,0 +1,99 @@ +import { readable } from 'svelte/store'; +import { addEventListener } from './helpers/event.js'; +import { chain } from './prevent-scroll.js'; +import { isFunction, isHTMLElement, noop } from '$lib/internal/helpers/index.js'; + +/** + * Creates a readable store that tracks the latest Escape Keydown that occurred on the document. + * + * @returns A function to unsubscribe from the event listener and stop tracking keydown events. + */ +const documentEscapeKeyStore = readable( + undefined, + (set): (() => void) => { + /** + * Event handler for keydown events on the document. + * Updates the store's value with the latest Escape Keydown event and then resets it to undefined. + */ + function keydown(event: KeyboardEvent | undefined) { + if (event && event.key === 'Escape') { + set(event); + } + + // New subscriptions will not trigger immediately + set(undefined); + } + + // Adds a keydown event listener to the document, calling the keydown function when triggered. + const unsubscribe = addEventListener(document, 'keydown', keydown, { + passive: false + }); + + // Returns a function to unsubscribe from the event listener and stop tracking keydown events. + return unsubscribe; + } +); + +export const useEscapeKeydown = (node: HTMLElement, config: EscapeKeydownConfig = {}) => { + let unsub = noop; + function update(config: EscapeKeydownConfig = {}) { + unsub(); + + unsub = chain( + // Handle escape keydowns + documentEscapeKeyStore.subscribe((e) => { + if (!e) return; + const target = e.target; + + if (!isHTMLElement(target) || target.closest('[data-escapee]') !== node) { + return; + } + + e.preventDefault(); + + // If an ignore function is passed, check if it returns true + if (config.ignore) { + if (isFunction(config.ignore)) { + if (config.ignore(e)) return; + } + // If an ignore array is passed, check if any elements in the array match the target + else if (Array.isArray(config.ignore)) { + if ( + config.ignore.length > 0 && + config.ignore.some((ignoreEl) => { + return ignoreEl && target === ignoreEl; + }) + ) + return; + } + } + + // If none of the above conditions are met, call the handler + config.handler?.(e); + }), + (node.dataset.escapee = '') + ); + } + + update(config); + + return { + update, + destroy() { + node.removeAttribute('data-escapee'); + unsub(); + } + }; +}; + +export type EscapeKeydownConfig = { + /** + * Callback when user presses the escape key element. + */ + handler?: (evt: KeyboardEvent) => void; + + /** + * A predicate function or a list of elements that should not trigger the event. + */ + ignore?: ((e: KeyboardEvent) => boolean) | Element[]; +}; diff --git a/src/lib/internal/helpers/index.ts b/src/lib/internal/helpers/index.ts index 1e5bdfb..9b1c33a 100644 --- a/src/lib/internal/helpers/index.ts +++ b/src/lib/internal/helpers/index.ts @@ -1,3 +1,6 @@ export * from './store.js'; export * from './object.js'; export * from './style.js'; +export * from './noop.js'; +export * from './event.js'; +export * from './is.js'; diff --git a/src/lib/internal/helpers/is.ts b/src/lib/internal/helpers/is.ts new file mode 100644 index 0000000..955d931 --- /dev/null +++ b/src/lib/internal/helpers/is.ts @@ -0,0 +1,10 @@ +export function isHTMLElement(el: unknown): el is HTMLElement { + return el instanceof HTMLElement; +} + +export const isBrowser = typeof document !== 'undefined'; + +// eslint-disable-next-line @typescript-eslint/ban-types +export function isFunction(v: unknown): v is Function { + return typeof v === 'function'; +} diff --git a/src/lib/internal/helpers/noop.ts b/src/lib/internal/helpers/noop.ts new file mode 100644 index 0000000..bd0cbd1 --- /dev/null +++ b/src/lib/internal/helpers/noop.ts @@ -0,0 +1,3 @@ +export function noop() { + // do nothing; +} diff --git a/src/lib/internal/prevent-scroll.ts b/src/lib/internal/prevent-scroll.ts index 13a6601..fc4459d 100644 --- a/src/lib/internal/prevent-scroll.ts +++ b/src/lib/internal/prevent-scroll.ts @@ -3,7 +3,7 @@ import { addEventListener } from './helpers/event.js'; // eslint-disable-next-line @typescript-eslint/no-explicit-any -function chain(...callbacks: any[]): (...args: any[]) => void { +export function chain(...callbacks: any[]): (...args: any[]) => void { // eslint-disable-next-line @typescript-eslint/no-explicit-any return (...args: any[]) => { for (const callback of callbacks) { diff --git a/src/lib/internal/vaul.ts b/src/lib/internal/vaul.ts index 777bbc7..8916dc0 100644 --- a/src/lib/internal/vaul.ts +++ b/src/lib/internal/vaul.ts @@ -18,6 +18,8 @@ import { usePositionFixed } from './position-fixed.js'; import { onMount } from 'svelte'; import { TRANSITIONS, VELOCITY_THRESHOLD } from './constants.js'; import { addEventListener } from './helpers/event.js'; +import { noop } from './helpers/noop.js'; +import { useEscapeKeydown } from './escape-keydown.js'; const CLOSE_THRESHOLD = 0.25; @@ -140,6 +142,7 @@ export function createVaul(props: CreateVaulProps) { const drawerRef = writable(undefined); const drawerHeightRef = writable(get(drawerRef)?.getBoundingClientRect().height || 0); const initialDrawerHeight = writable(0); + let isClosing = false; function getDefaultActiveSnapPoint() { if (withDefaults.defaultActiveSnapPoint) { @@ -221,6 +224,29 @@ export function createVaul(props: CreateVaulProps) { const { restorePositionSetting } = usePositionFixed({ isOpen, modal, nested, hasBeenOpened }); + effect([drawerRef], ([$drawerRef]) => { + let unsub = noop; + + if ($drawerRef) { + const { destroy } = useEscapeKeydown($drawerRef, { + handler: () => { + closeDrawer(); + } + }); + unsub = destroy; + } + + return () => { + unsub(); + }; + }); + + function openDrawer() { + if (isClosing) return; + hasBeenOpened.set(true); + isOpen.set(true); + } + function getScale() { return (window.innerWidth - WINDOW_TOP_OFFSET) / window.innerWidth; } @@ -527,6 +553,7 @@ export function createVaul(props: CreateVaulProps) { ); function closeDrawer() { + if (isClosing) return; const $drawerRef = get(drawerRef); if (!$drawerRef) return; const $snapPoints = get(snapPoints); @@ -544,9 +571,11 @@ export function createVaul(props: CreateVaulProps) { scaleBackground(false); + isClosing = true; setTimeout(() => { visible.set(false); isOpen.set(false); + isClosing = false; }, 300); setTimeout(() => { @@ -793,7 +822,8 @@ export function createVaul(props: CreateVaulProps) { onNestedDrag, onNestedOpenChange, onNestedRelease, - restorePositionSetting + restorePositionSetting, + openDrawer }, refs: { drawerRef, @@ -806,7 +836,3 @@ export function createVaul(props: CreateVaulProps) { export function dampenValue(v: number) { return 8 * (Math.log(v + 1) - 2); } - -function noop() { - // do nothing; -} diff --git a/src/lib/vaul/components/root.svelte b/src/lib/vaul/components/root.svelte index 418d6ea..e336fd6 100644 --- a/src/lib/vaul/components/root.svelte +++ b/src/lib/vaul/components/root.svelte @@ -18,8 +18,8 @@ export let shouldScaleBackground: $$Props['shouldScaleBackground'] = false; const { - states: { isOpen, hasBeenOpened, keyboardIsOpen }, - methods: { closeDrawer }, + states: { keyboardIsOpen }, + methods: { closeDrawer, openDrawer }, refs: { drawerRef }, options: { dismissible }, updateOption @@ -54,17 +54,16 @@ { onOpenChange?.(o); - if (!o) { closeDrawer(); - } else { - hasBeenOpened.set(true); - isOpen.set(true); + } else if (o) { + openDrawer(); } }} onOutsideClick={(e) => {