refactor(core): [CORE-23] complete keyboard controls and focus lifecycle

This commit is contained in:
Harvey Zhao committed 2026-09-11 21:06:33 +08:00
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()
})
}
}