mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-26 17:01:20 -08:00
refactor(core): [CORE-23] complete keyboard controls and focus lifecycle
This commit is contained in:
1 parent
faaa3e8c2d
commit
8ed0a5228c
190 files changed
+53981
-489
No files matched your search
@@ -0,0 +1,48 @@
|
||||
import type ResourceScope from '../lifecycle/scope'
|
||||
import { listen } from '../lifecycle/resources'
|
||||
import { claimKey, plainKey } from './keyboard'
|
||||
|
||||
/** Preserve the existing click path and keep owned keys out of player hotkeys. */
|
||||
export function keyboardButton(scope: ResourceScope, element: HTMLElement, activate = () => element.click(), active = () => true): void {
|
||||
if (scope.closed)
|
||||
return
|
||||
if (!element.hasAttribute('role'))
|
||||
element.setAttribute('role', 'button')
|
||||
if (!element.hasAttribute('tabindex'))
|
||||
element.tabIndex = 0
|
||||
let space = false
|
||||
const enabled = () => active() && element.getAttribute('aria-disabled') !== 'true' && !element.hasAttribute('disabled')
|
||||
const own = (event: KeyboardEvent) => (event.composedPath()[0] || event.target) === element
|
||||
listen(scope, element, 'keydown', (value) => {
|
||||
const event = value as KeyboardEvent
|
||||
if (!own(event) || !plainKey(event) || event.defaultPrevented)
|
||||
return
|
||||
if (event.key === 'Enter') {
|
||||
claimKey(event)
|
||||
if (!event.repeat && enabled())
|
||||
activate()
|
||||
}
|
||||
else if (event.key === ' ') {
|
||||
claimKey(event)
|
||||
if (!event.repeat && enabled())
|
||||
space = true
|
||||
}
|
||||
})
|
||||
listen(scope, element, 'keyup', (value) => {
|
||||
const event = value as KeyboardEvent
|
||||
if (event.key !== ' ')
|
||||
return
|
||||
const armed = space
|
||||
space = false
|
||||
if (armed && own(event) && plainKey(event) && !event.defaultPrevented && enabled() && element.ownerDocument.activeElement === element) {
|
||||
claimKey(event)
|
||||
activate()
|
||||
}
|
||||
})
|
||||
listen(scope, element, 'blur', () => {
|
||||
space = false
|
||||
})
|
||||
scope.add(() => {
|
||||
space = false
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type ResourceScope from '../lifecycle/scope'
|
||||
import { listen } from '../lifecycle/resources'
|
||||
|
||||
/** Keep keyboard visibility separate from the existing pointer focus flags. */
|
||||
export function focusVisibility(scope: ResourceScope, root: HTMLElement, show: () => void): () => boolean {
|
||||
let keyboard = true
|
||||
let document: Document | undefined
|
||||
let documentScope: ResourceScope | undefined
|
||||
const focused = () => !scope.closed && keyboard && root.contains(root.ownerDocument.activeElement)
|
||||
const update = () => {
|
||||
const visible = focused()
|
||||
const previous = root.classList.contains('art-keyboard-focus')
|
||||
root.classList.toggle('art-keyboard-focus', visible)
|
||||
if (visible && !previous)
|
||||
show()
|
||||
}
|
||||
const key = () => {
|
||||
keyboard = true
|
||||
update()
|
||||
}
|
||||
const pointer = () => {
|
||||
keyboard = false
|
||||
update()
|
||||
}
|
||||
const bindDocument = () => {
|
||||
if (document === root.ownerDocument)
|
||||
return
|
||||
documentScope?.dispose()
|
||||
document = root.ownerDocument
|
||||
documentScope = scope.child()
|
||||
listen(documentScope, document, 'keydown', key, { capture: true })
|
||||
listen(documentScope, document, 'mousedown', pointer, { capture: true })
|
||||
listen(documentScope, document, 'touchstart', pointer, { capture: true, passive: true })
|
||||
}
|
||||
bindDocument()
|
||||
listen(scope, root, 'keydown', key, { capture: true })
|
||||
listen(scope, root, 'mousedown', pointer, { capture: true })
|
||||
listen(scope, root, 'touchstart', pointer, { capture: true, passive: true })
|
||||
listen(scope, root, 'focusin', () => {
|
||||
bindDocument()
|
||||
update()
|
||||
})
|
||||
listen(scope, root, 'focusout', () => {
|
||||
root.classList.remove('art-keyboard-focus')
|
||||
})
|
||||
scope.add(() => {
|
||||
root.classList.remove('art-keyboard-focus')
|
||||
})
|
||||
return focused
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
const handled = new WeakSet<KeyboardEvent>()
|
||||
|
||||
export function claimKey(event: KeyboardEvent): void {
|
||||
handled.add(event)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
export function isClaimedKey(event: KeyboardEvent): boolean {
|
||||
return handled.has(event)
|
||||
}
|
||||
|
||||
export function plainKey(event: KeyboardEvent): boolean {
|
||||
return !event.altKey && !event.ctrlKey && !event.metaKey && !event.isComposing && event.keyCode !== 229
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Restore the same focused descendant after DOM reparenting. */
|
||||
export function captureMovedFocus(root: HTMLElement): () => void {
|
||||
const focused = root.ownerDocument?.activeElement as HTMLElement | null | undefined
|
||||
const inside = focused && root.contains(focused)
|
||||
return () => {
|
||||
if (inside && focused.isConnected && root.contains(focused) && focused.ownerDocument.activeElement === focused.ownerDocument.body)
|
||||
focused.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
const origins = new WeakMap<HTMLElement, HTMLElement>()
|
||||
|
||||
export function setOverlayOrigin(overlay: HTMLElement, origin?: HTMLElement): void {
|
||||
if (origin)
|
||||
origins.set(overlay, origin)
|
||||
else
|
||||
origins.delete(overlay)
|
||||
}
|
||||
|
||||
export function resolveFocusOrigin(target: HTMLElement): HTMLElement {
|
||||
const visited = new Set<HTMLElement>()
|
||||
let current = target
|
||||
while (!visited.has(current)) {
|
||||
visited.add(current)
|
||||
let element: HTMLElement | null = current
|
||||
let origin: HTMLElement | undefined
|
||||
while (element && !origin) {
|
||||
origin = origins.get(element)
|
||||
element = element.parentElement
|
||||
}
|
||||
if (!origin)
|
||||
return current
|
||||
current = origin
|
||||
}
|
||||
return target
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ComponentHost } from '../component/types'
|
||||
import { getScope, isClosing } from '../lifecycle/instance'
|
||||
|
||||
const owned = new WeakSet<HTMLElement>()
|
||||
|
||||
export function focusPlayer(art: Pick<ComponentHost, 'template'>): void {
|
||||
const player = art.template.$player
|
||||
if (isClosing(art) || !player.isConnected)
|
||||
return
|
||||
if (!player.hasAttribute('tabindex')) {
|
||||
player.setAttribute('tabindex', '-1')
|
||||
if (!owned.has(player)) {
|
||||
owned.add(player)
|
||||
getScope(art).add(() => {
|
||||
owned.delete(player)
|
||||
if (player.getAttribute('tabindex') === '-1')
|
||||
player.removeAttribute('tabindex')
|
||||
})
|
||||
}
|
||||
}
|
||||
player.focus({ preventScroll: true })
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type ResourceScope from '../lifecycle/scope'
|
||||
import { listen } from '../lifecycle/resources'
|
||||
import { claimKey, plainKey } from './keyboard'
|
||||
|
||||
interface Range {
|
||||
min: number
|
||||
max: number
|
||||
value: number
|
||||
step: number
|
||||
text: (value: number) => string
|
||||
}
|
||||
|
||||
const directions = new Map([['ArrowRight', 1], ['ArrowUp', 1], ['ArrowLeft', -1], ['ArrowDown', -1], ['PageUp', 10], ['PageDown', -10]])
|
||||
|
||||
export function keyboardSlider(scope: ResourceScope, element: HTMLElement, label: string, read: () => Range, write: (value: number) => void, orientation = 'horizontal'): () => void {
|
||||
const valid = (range: Range) => [range.min, range.max, range.value, range.step].every(Number.isFinite) && range.max > range.min && range.step > 0
|
||||
const clamp = (value: number, range: Range) => Math.min(range.max, Math.max(range.min, value))
|
||||
const update = () => {
|
||||
if (scope.closed)
|
||||
return
|
||||
const range = read()
|
||||
const enabled = valid(range)
|
||||
const value = enabled ? clamp(range.value, range) : 0
|
||||
element.setAttribute('aria-disabled', String(!enabled))
|
||||
element.setAttribute('aria-valuemin', String(enabled ? range.min : 0))
|
||||
element.setAttribute('aria-valuemax', String(enabled ? range.max : 0))
|
||||
element.setAttribute('aria-valuenow', String(value))
|
||||
element.setAttribute('aria-valuetext', range.text(value))
|
||||
}
|
||||
if (scope.closed)
|
||||
return update
|
||||
element.setAttribute('role', 'slider')
|
||||
element.tabIndex = 0
|
||||
element.setAttribute('aria-label', label)
|
||||
element.setAttribute('aria-orientation', orientation)
|
||||
listen(scope, element, 'keydown', (value) => {
|
||||
const event = value as KeyboardEvent
|
||||
if ((event.composedPath()[0] || event.target) !== element || !plainKey(event) || event.defaultPrevented)
|
||||
return
|
||||
const direction = directions.get(event.key)
|
||||
if (direction === undefined && event.key !== 'Home' && event.key !== 'End')
|
||||
return
|
||||
claimKey(event)
|
||||
const range = read()
|
||||
if (!valid(range)) {
|
||||
update()
|
||||
return
|
||||
}
|
||||
const next = event.key === 'Home' ? range.min : event.key === 'End' ? range.max : clamp(range.value, range) + direction! * range.step
|
||||
write(clamp(next, range))
|
||||
update()
|
||||
})
|
||||
update()
|
||||
return update
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type ResourceScope from '../lifecycle/scope'
|
||||
|
||||
export function suspendFocus(scope: ResourceScope, root: HTMLElement): void {
|
||||
const original = new Map<HTMLElement, string | null>()
|
||||
const inert = root.hasAttribute('inert')
|
||||
const hidden = root.getAttribute('aria-hidden')
|
||||
const restore = (element: HTMLElement, value: string | null) => {
|
||||
if (element.getAttribute('tabindex') !== '-1')
|
||||
return
|
||||
if (value === null)
|
||||
element.removeAttribute('tabindex')
|
||||
else
|
||||
element.setAttribute('tabindex', value)
|
||||
}
|
||||
scope.add(() => {
|
||||
for (const [element, value] of original)
|
||||
restore(element, value)
|
||||
original.clear()
|
||||
if (!inert && root.getAttribute('inert') === '')
|
||||
root.removeAttribute('inert')
|
||||
if (root.getAttribute('aria-hidden') === 'true') {
|
||||
if (hidden === null)
|
||||
root.removeAttribute('aria-hidden')
|
||||
else
|
||||
root.setAttribute('aria-hidden', hidden)
|
||||
}
|
||||
})
|
||||
if (scope.closed)
|
||||
return
|
||||
if (!inert)
|
||||
root.setAttribute('inert', '')
|
||||
root.setAttribute('aria-hidden', 'true')
|
||||
const update = () => {
|
||||
if (scope.closed)
|
||||
return
|
||||
for (const [element, value] of original) {
|
||||
if (!root.contains(element)) {
|
||||
restore(element, value)
|
||||
original.delete(element)
|
||||
}
|
||||
}
|
||||
for (const element of root.querySelectorAll<HTMLElement>('[tabindex],button,input,select,textarea,a[href],[contenteditable]')) {
|
||||
if (!original.has(element)) {
|
||||
original.set(element, element.getAttribute('tabindex'))
|
||||
element.setAttribute('tabindex', '-1')
|
||||
}
|
||||
}
|
||||
}
|
||||
update()
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
const observer = new MutationObserver(update)
|
||||
observer.observe(root, { childList: true, subtree: true })
|
||||
scope.add(() => {
|
||||
observer.disconnect()
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user