diff options
Diffstat (limited to 'frontend/src/lib/Actions.ts')
-rw-r--r-- | frontend/src/lib/Actions.ts | 109 |
1 files changed, 109 insertions, 0 deletions
diff --git a/frontend/src/lib/Actions.ts b/frontend/src/lib/Actions.ts new file mode 100644 index 0000000..7231c2f --- /dev/null +++ b/frontend/src/lib/Actions.ts @@ -0,0 +1,109 @@ +export function debounce( + node: HTMLInputElement, + { callback, timeout = 500 }: { callback: () => void; timeout?: number } +) { + let timer: NodeJS.Timeout; + + function trigger(event: KeyboardEvent) { + clearTimeout(timer); + if (event.key !== 'Enter') { + timer = setTimeout(callback, timeout); + } else { + callback(); + } + } + + node.addEventListener('keyup', trigger); + + return { + destroy() { + clearTimeout(timer); + node.removeEventListener('keyup', trigger); + } + }; +} + +export function clickOutside( + node: HTMLElement, + { handler, ignore }: { handler: () => void; ignore?: HTMLElement } +) { + const handle = (event: Event) => { + const target = event.target as HTMLElement; + if (!target || target === ignore) return; + + if (node && !node.contains(target) && !event.defaultPrevented) { + handler(); + } + }; + + document.addEventListener('click', handle, true); + + return { + destroy() { + document.removeEventListener('click', handle, true); + } + }; +} + +export const focusableElements = [ + 'a[href]', + 'area[href]', + 'input:not([disabled]):not([type="hidden"]):not([aria-hidden])', + 'select:not([disabled]):not([aria-hidden])', + 'textarea:not([disabled]):not([aria-hidden])', + 'button:not([disabled]):not([aria-hidden])', + 'iframe', + 'object', + 'embed', + '[contenteditable]', + '[tabindex]:not([tabindex^="-"])' +]; + +let trapped: HTMLElement[] = []; + +export function trapFocus(node: HTMLElement) { + function handler(event: KeyboardEvent) { + if (event.target === window) return; + + // return if we're not the topmost node to handle + if (trapped.at(0) !== node) return; + + const focusable = node.querySelectorAll<HTMLElement>(focusableElements.join()); + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (event.key === 'Tab') { + if (!node.contains(document.activeElement)) { + first.focus(); + event.preventDefault(); + } + + if (event.shiftKey && event.target === first) { + last.focus(); + event.preventDefault(); + } else if (!event.shiftKey && event.target === last) { + first.focus(); + event.preventDefault(); + } + } + } + + if (document.activeElement instanceof HTMLElement) { + // if we trap focus, make sure to blur any previously selected external + // item such that focus does not remain outside of the node + if (!node.contains(document.activeElement)) { + document.activeElement.blur(); + } + } + + document.addEventListener('keydown', handler); + trapped.unshift(node); + + return { + destroy() { + document.removeEventListener('keydown', handler); + trapped = trapped.filter((i) => i !== node); + } + }; +} |