diff options
Diffstat (limited to 'packages/frontend/src/scripts')
| -rw-r--r-- | packages/frontend/src/scripts/focus-trap.ts | 65 | ||||
| -rw-r--r-- | packages/frontend/src/scripts/focus.ts | 94 | ||||
| -rw-r--r-- | packages/frontend/src/scripts/get-dom-node-or-null.ts | 19 | ||||
| -rw-r--r-- | packages/frontend/src/scripts/hotkey.ts | 51 | ||||
| -rw-r--r-- | packages/frontend/src/scripts/scroll.ts | 8 |
5 files changed, 207 insertions, 30 deletions
diff --git a/packages/frontend/src/scripts/focus-trap.ts b/packages/frontend/src/scripts/focus-trap.ts new file mode 100644 index 0000000000..734c73652f --- /dev/null +++ b/packages/frontend/src/scripts/focus-trap.ts @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js'; + +const focusTrapElements = new Set<HTMLElement>(); +const ignoreElements = [ + 'script', + 'style', +]; + +function containsFocusTrappedElements(el: HTMLElement): boolean { + return Array.from(focusTrapElements).some((focusTrapElement) => { + return el.contains(focusTrapElement); + }); +} + +function releaseFocusTrap(el: HTMLElement): void { + focusTrapElements.delete(el); + if (el.parentElement != null && el !== document.body) { + el.parentElement.childNodes.forEach((siblingNode) => { + const siblingEl = getHTMLElementOrNull(siblingNode); + if (!siblingEl) return; + if (siblingEl !== el && (focusTrapElements.has(siblingEl) || containsFocusTrappedElements(siblingEl) || focusTrapElements.size === 0)) { + siblingEl.inert = false; + } else if ( + focusTrapElements.size > 0 && + !containsFocusTrappedElements(siblingEl) && + !focusTrapElements.has(siblingEl) && + !ignoreElements.includes(siblingEl.tagName.toLowerCase()) + ) { + siblingEl.inert = true; + } else { + siblingEl.inert = false; + } + }); + releaseFocusTrap(el.parentElement); + } +} + +export function focusTrap(el: HTMLElement, parent: true): void; +export function focusTrap(el: HTMLElement, parent?: false): { release: () => void; }; +export function focusTrap(el: HTMLElement, parent = false): { release: () => void; } | void { + if (el.parentElement != null && el !== document.body) { + el.parentElement.childNodes.forEach((siblingNode) => { + const siblingEl = getHTMLElementOrNull(siblingNode); + if (!siblingEl) return; + if (siblingEl !== el && !ignoreElements.includes(siblingEl.tagName.toLowerCase())) { + siblingEl.inert = true; + } + }); + focusTrap(el.parentElement, true); + } + + if (!parent) { + focusTrapElements.add(el); + + return { + release: () => { + releaseFocusTrap(el); + }, + }; + } +} diff --git a/packages/frontend/src/scripts/focus.ts b/packages/frontend/src/scripts/focus.ts index ea6ee61c88..eb2da5ad86 100644 --- a/packages/frontend/src/scripts/focus.ts +++ b/packages/frontend/src/scripts/focus.ts @@ -3,30 +3,78 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -export function focusPrev(el: Element | null, self = false, scroll = true) { - if (el == null) return; - if (!self) el = el.previousElementSibling; - if (el) { - if (el.hasAttribute('tabindex')) { - (el as HTMLElement).focus({ - preventScroll: !scroll, - }); - } else { - focusPrev(el.previousElementSibling, true); - } +import { getScrollPosition, getScrollContainer, getStickyBottom, getStickyTop } from '@/scripts/scroll.js'; +import { getElementOrNull, getNodeOrNull } from '@/scripts/get-dom-node-or-null.js'; + +type MaybeHTMLElement = EventTarget | Node | Element | HTMLElement; + +export const isFocusable = (input: MaybeHTMLElement | null | undefined): input is HTMLElement => { + if (input == null || !(input instanceof HTMLElement)) return false; + + if (input.tabIndex < 0) return false; + if ('disabled' in input && input.disabled === true) return false; + if ('readonly' in input && input.readonly === true) return false; + + if (!input.ownerDocument.contains(input)) return false; + + const style = window.getComputedStyle(input); + if (style.display === 'none') return false; + if (style.visibility === 'hidden') return false; + if (style.opacity === '0') return false; + if (style.pointerEvents === 'none') return false; + + return true; +}; + +export const focusPrev = (input: MaybeHTMLElement | null | undefined, self = false, scroll = true) => { + const element = self ? input : getElementOrNull(input)?.previousElementSibling; + if (element == null) return; + if (isFocusable(element)) { + focusOrScroll(element, scroll); + } else { + focusPrev(element, false, scroll); } -} +}; -export function focusNext(el: Element | null, self = false, scroll = true) { - if (el == null) return; - if (!self) el = el.nextElementSibling; - if (el) { - if (el.hasAttribute('tabindex')) { - (el as HTMLElement).focus({ - preventScroll: !scroll, - }); - } else { - focusPrev(el.nextElementSibling, true); +export const focusNext = (input: MaybeHTMLElement | null | undefined, self = false, scroll = true) => { + const element = self ? input : getElementOrNull(input)?.nextElementSibling; + if (element == null) return; + if (isFocusable(element)) { + focusOrScroll(element, scroll); + } else { + focusNext(element, false, scroll); + } +}; + +export const focusParent = (input: MaybeHTMLElement | null | undefined, self = false, scroll = true) => { + const element = self ? input : getNodeOrNull(input)?.parentElement; + if (element == null) return; + if (isFocusable(element)) { + focusOrScroll(element, scroll); + } else { + focusParent(element, false, scroll); + } +}; + +const focusOrScroll = (element: HTMLElement, scroll: boolean) => { + if (scroll) { + const scrollContainer = getScrollContainer(element) ?? document.documentElement; + const scrollContainerTop = getScrollPosition(scrollContainer); + const stickyTop = getStickyTop(element, scrollContainer); + const stickyBottom = getStickyBottom(element, scrollContainer); + const top = element.getBoundingClientRect().top; + const bottom = element.getBoundingClientRect().bottom; + + let scrollTo = scrollContainerTop; + if (top < stickyTop) { + scrollTo += top - stickyTop; + } else if (bottom > window.innerHeight - stickyBottom) { + scrollTo += bottom - window.innerHeight + stickyBottom; } + scrollContainer.scrollTo({ top: scrollTo, behavior: 'instant' }); + } + + if (document.activeElement !== element) { + element.focus({ preventScroll: true }); } -} +}; diff --git a/packages/frontend/src/scripts/get-dom-node-or-null.ts b/packages/frontend/src/scripts/get-dom-node-or-null.ts new file mode 100644 index 0000000000..fbf54675fd --- /dev/null +++ b/packages/frontend/src/scripts/get-dom-node-or-null.ts @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const getNodeOrNull = (input: unknown): Node | null => { + if (input instanceof Node) return input; + return null; +}; + +export const getElementOrNull = (input: unknown): Element | null => { + if (input instanceof Element) return input; + return null; +}; + +export const getHTMLElementOrNull = (input: unknown): HTMLElement | null => { + if (input instanceof HTMLElement) return input; + return null; +}; diff --git a/packages/frontend/src/scripts/hotkey.ts b/packages/frontend/src/scripts/hotkey.ts index fd79baa604..ff3cbe98ac 100644 --- a/packages/frontend/src/scripts/hotkey.ts +++ b/packages/frontend/src/scripts/hotkey.ts @@ -2,6 +2,7 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ +import { getHTMLElementOrNull } from "@/scripts/get-dom-node-or-null.js"; //#region types export type Keymap = Record<string, CallbackFunction | CallbackObject>; @@ -30,8 +31,8 @@ type Action = { //#region consts const KEY_ALIASES = { 'esc': 'Escape', - 'enter': ['Enter', 'NumpadEnter'], - 'space': [' ', 'Spacebar'], + 'enter': 'Enter', + 'space': ' ', 'up': 'ArrowUp', 'down': 'ArrowDown', 'left': 'ArrowLeft', @@ -44,6 +45,10 @@ const MODIFIER_KEYS = ['ctrl', 'alt', 'shift']; const IGNORE_ELEMENTS = ['input', 'textarea']; //#endregion +//#region store +let latestHotkey: Pattern & { callback: CallbackFunction } | null = null; +//#endregion + //#region impl export const makeHotkey = (keymap: Keymap) => { const actions = parseKeymap(keymap); @@ -51,13 +56,14 @@ export const makeHotkey = (keymap: Keymap) => { if ('pswp' in window && window.pswp != null) return; if (document.activeElement != null) { if (IGNORE_ELEMENTS.includes(document.activeElement.tagName.toLowerCase())) return; - if ((document.activeElement as HTMLElement).isContentEditable) return; + if (getHTMLElementOrNull(document.activeElement)?.isContentEditable) return; } - for (const { patterns, callback, options } of actions) { - if (matchPatterns(ev, patterns, options)) { + for (const action of actions) { + if (matchPatterns(ev, action)) { ev.preventDefault(); ev.stopPropagation(); - callback(ev); + action.callback(ev); + storePattern(ev, action.callback); } } }; @@ -102,10 +108,21 @@ const parseOptions = (rawCallback: Keymap[keyof Keymap]) => { return { ...defaultOptions } as const satisfies Action['options']; }; -const matchPatterns = (ev: KeyboardEvent, patterns: Action['patterns'], options: Action['options']) => { +const matchPatterns = (ev: KeyboardEvent, action: Action) => { + const { patterns, options, callback } = action; if (ev.repeat && !options.allowRepeat) return false; const key = ev.key.toLowerCase(); return patterns.some(({ which, ctrl, shift, alt }) => { + if ( + latestHotkey != null && + latestHotkey.which.includes(key) && + latestHotkey.ctrl === ctrl && + latestHotkey.alt === alt && + latestHotkey.shift === shift && + latestHotkey.callback === callback + ) { + return false; + } if (!which.includes(key)) return false; if (ctrl !== (ev.ctrlKey || ev.metaKey)) return false; if (alt !== ev.altKey) return false; @@ -114,6 +131,26 @@ const matchPatterns = (ev: KeyboardEvent, patterns: Action['patterns'], options: }); }; +let lastHotKeyStoreTimer: number | null = null; + +const storePattern = (ev: KeyboardEvent, callback: CallbackFunction) => { + if (lastHotKeyStoreTimer != null) { + clearTimeout(lastHotKeyStoreTimer); + } + + latestHotkey = { + which: [ev.key.toLowerCase()], + ctrl: ev.ctrlKey || ev.metaKey, + alt: ev.altKey, + shift: ev.shiftKey, + callback, + }; + + lastHotKeyStoreTimer = window.setTimeout(() => { + latestHotkey = null; + }, 500); +}; + const parseKeyCode = (input?: string | null) => { if (input == null) return []; const raw = getValueByKey(KEY_ALIASES, input); diff --git a/packages/frontend/src/scripts/scroll.ts b/packages/frontend/src/scripts/scroll.ts index 8edb6fca05..f0274034b5 100644 --- a/packages/frontend/src/scripts/scroll.ts +++ b/packages/frontend/src/scripts/scroll.ts @@ -23,6 +23,14 @@ export function getStickyTop(el: HTMLElement, container: HTMLElement | null = nu return getStickyTop(el.parentElement, container, newTop); } +export function getStickyBottom(el: HTMLElement, container: HTMLElement | null = null, bottom = 0) { + if (!el.parentElement) return bottom; + const data = el.dataset.stickyContainerFooterHeight; + const newBottom = data ? Number(data) + bottom : bottom; + if (el === container) return newBottom; + return getStickyBottom(el.parentElement, container, newBottom); +} + export function getScrollPosition(el: HTMLElement | null): number { const container = getScrollContainer(el); return container == null ? window.scrollY : container.scrollTop; |