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

+5
View File
@@ -6,6 +6,8 @@ import Component from '../utils/component'
import aspectRatio from './aspectRatio'
import { installSettingEvents } from './events'
import flip from './flip'
import { installSettingKeyboard } from './keyboard'
import { captureSettingFocus } from './keyboard-focus'
import { resizeSetting } from './layout'
import { findItem, formatTree, registerTreeOwner, releaseTreeOwner, traverseTree } from './model'
import playbackRate from './playbackRate'
@@ -53,6 +55,7 @@ export default class Setting extends SettingBase implements SettingManager {
installSettingEvents(this)
}
installSettingKeyboard(this)
}
get builtin(): SettingItem[] {
@@ -103,6 +106,7 @@ export default class Setting extends SettingBase implements SettingManager {
}
remove(name: string): void {
const restoreFocus = captureSettingFocus(this)
const item = this.find(name)!
errorHandle(item, `Can't find [${name}] in the [setting]`)
this.traverse((item) => {
@@ -126,6 +130,7 @@ export default class Setting extends SettingBase implements SettingManager {
}
if (failures.length)
throw new ResourceCleanupError(failures)
restoreFocus()
}
update(target: SettingItem): SettingItem {
@@ -0,0 +1,55 @@
import type { SettingManager } from './types'
import { focusPlayer } from '../accessibility/player-focus'
import { isClosing } from '../lifecycle/instance'
import { settingScopeActive } from './activity'
import { settingPanelScope } from './panels'
function available(element: HTMLElement): boolean {
const visibility = element.ownerDocument.defaultView?.getComputedStyle(element).visibility
return element.isConnected && !element.matches(':disabled') && !element.closest('[inert]') && element.getAttribute('aria-disabled') !== 'true' && element.getClientRects().length > 0 && visibility !== 'hidden' && visibility !== 'collapse'
}
export function settingFocusTargets(panel: HTMLElement): HTMLElement[] {
return Array.from(panel.querySelectorAll<HTMLElement>('[tabindex="0"],button,input,select,textarea,a[href],[contenteditable]'))
.filter(element => element.getAttribute('tabindex') !== '-1' && available(element))
}
export function returnSettingFocus(setting: SettingManager): void {
const { art, $parent } = setting
const player = art.template.$player
const doc = $parent.ownerDocument
const button = art.controls.setting
if (isClosing(art) || !$parent.contains(doc.activeElement))
return
if (button && player.contains(button) && available(button))
button.focus({ preventScroll: true })
if (doc.activeElement === doc.body || $parent.contains(doc.activeElement))
focusPlayer(art)
}
export function focusSettingPanel(setting: SettingManager, preferred?: HTMLElement, last = false): void {
if (!setting.show || isClosing(setting.art) || !setting.active)
return
const panel = setting.cache.get(setting.active)
if (!panel || !settingScopeActive(settingPanelScope(panel)))
return
const targets = settingFocusTargets(panel)
const selected = panel.querySelector('.art-setting-item.art-current')
const target = targets.find(element => preferred === element || preferred?.contains(element))
|| (last ? targets[targets.length - 1] : targets.find(element => selected?.contains(element)) || targets.find(element => !element.classList.contains('art-setting-item-back')) || targets[0])
target?.focus({ preventScroll: true })
if (!isClosing(setting.art))
target?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}
export function captureSettingFocus(setting: SettingManager): (preferred?: HTMLElement) => void {
const doc = setting.$parent.ownerDocument
const focused = doc.activeElement as HTMLElement | null
const inside = setting.$parent.contains(focused)
return (preferred) => {
if (!inside || isClosing(setting.art))
return
if (doc.activeElement === doc.body || (doc.activeElement === focused && !focused?.getClientRects().length))
focusSettingPanel(setting, preferred)
}
}
@@ -0,0 +1,30 @@
import type ResourceScope from '../lifecycle/scope'
import type { SettingKind } from './selection'
import type { SettingItem, SettingManager } from './types'
import { keyboardButton } from '../accessibility/button'
import { isClosing } from '../lifecycle/instance'
import { settingScopeActive } from './activity'
export function nameSettingRange(item: SettingItem): void {
const input = item.$item?.querySelector<HTMLInputElement>('.art-setting-range')
if (input)
input.setAttribute('aria-label', item.$html?.textContent || '')
}
export function settingItemKeyboard(setting: SettingManager, item: SettingItem, element: HTMLDivElement, kind: SettingKind, scope: ResourceScope): void {
if (kind === 'range') {
nameSettingRange(item)
return
}
if (element.querySelector('button,input,select,textarea,a[href],[tabindex],[contenteditable]'))
return
if (kind === 'switch') {
element.setAttribute('role', 'switch')
element.setAttribute('aria-checked', String(Boolean(item.switch)))
element.setAttribute('aria-disabled', String(!item.onSwitch))
}
else if (kind === 'selector' && !item.selector?.length) {
element.setAttribute('aria-current', String(Boolean(item.default)))
}
keyboardButton(scope, element, () => element.click(), () => settingScopeActive(scope) && !isClosing(setting.art))
}
+134
View File
@@ -0,0 +1,134 @@
import type { SettingManager } from './types'
import { claimKey, plainKey } from '../accessibility/keyboard'
import { getScope, isClosing } from '../lifecycle/instance'
import { listen, timeout } from '../lifecycle/resources'
import { settingScopeActive } from './activity'
import { focusSettingPanel, returnSettingFocus, settingFocusTargets } from './keyboard-focus'
import { settingPanelScope } from './panels'
import { settingScope } from './resources'
export function installSettingKeyboard(setting: SettingManager): void {
const { art } = setting
const scope = getScope(art).child()
if (scope.closed)
return
const doc = () => setting.$parent.ownerDocument
let enterLast = false
const close = () => {
setting.show = false
if (!scope.closed && !isClosing(art))
setting.render()
}
const changed = (show: boolean) => {
if (scope.closed || isClosing(art))
return
if (show) {
if (doc().activeElement === art.controls.setting && art.template.$player.classList.contains('art-keyboard-focus'))
focusSettingPanel(setting, undefined, enterLast)
}
else if (setting.$parent.contains(doc().activeElement)) {
returnSettingFocus(setting)
}
}
art.on('setting', changed)
scope.add(() => {
art.off('setting', changed)
})
listen(scope, art.template.$player, 'keydown', (input) => {
const event = input as KeyboardEvent
if (!plainKey(event) || event.defaultPrevented || isClosing(art))
return
const target = event.target as HTMLElement
if (target === art.controls.setting) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
claimKey(event)
if (!setting.show) {
enterLast = event.key === 'ArrowUp'
try {
setting.show = true
}
finally {
enterLast = false
}
}
else {
focusSettingPanel(setting, undefined, event.key === 'ArrowUp')
}
}
else if (event.key === 'Escape' && setting.show) {
claimKey(event)
close()
}
return
}
if (!setting.show || !setting.$parent.contains(target))
return
if (event.key === 'Escape') {
claimKey(event)
close()
return
}
const row = target.closest<HTMLElement>('.art-setting-item')
if (!row || (target !== row && !target.matches('button,a[href],[role="button"]')))
return
const panel = setting.active && setting.cache.get(setting.active)
if (!panel)
return
const currentItem = setting.active?.find(item => item.$item === row)
if (!settingScopeActive(settingPanelScope(panel)) || (currentItem && !settingScopeActive(settingScope(currentItem)))) {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key))
claimKey(event)
return
}
const targets = settingFocusTargets(panel)
const index = targets.indexOf(target)
if (index < 0)
return
let next: number | undefined
switch (event.key) {
case 'ArrowDown':
next = Math.min(index + 1, targets.length - 1)
break
case 'ArrowUp':
next = Math.max(index - 1, 0)
break
case 'Home':
next = 0
break
case 'End':
next = targets.length - 1
break
case 'ArrowLeft': {
const back = panel.querySelector<HTMLElement>('.art-setting-item-back')
claimKey(event)
back?.click()
return
}
case 'ArrowRight': {
claimKey(event)
if (currentItem?.selector?.length)
target.click()
return
}
default:
return
}
claimKey(event)
if (next !== undefined)
focusSettingPanel(setting, targets[next])
})
let pending = false
listen(scope, setting.$parent, 'focusout', (event) => {
const next = (event as FocusEvent).relatedTarget as Node | null
if (next && (setting.$parent.contains(next) || next === art.controls.setting))
return
if (pending)
return
pending = true
timeout(scope, () => {
pending = false
if (!scope.closed && !isClosing(art) && setting.show && !setting.$parent.contains(doc().activeElement) && doc().activeElement !== art.controls.setting)
close()
}, 0)
})
}
+16
View File
@@ -1,11 +1,14 @@
import type { SettingKind } from './selection'
import type { SettingItem, SettingManager, SettingRange } from './types'
import { keyboardButton } from '../accessibility/button'
import { appendElement } from '../component/dom'
import { isClosing } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
import { addClass, append, def, has, inverseClass, setStyle } from '../utils'
import { settingScopeActive } from './activity'
import { captureSettingItem, rememberSettingState } from './checkpoint'
import { captureSettingFocus } from './keyboard-focus'
import { nameSettingRange, settingItemKeyboard } from './keyboard-item'
import { ownSettingPanel, releaseSettingPanel, settingPanelScope } from './panels'
import { ownSettingItem, proxySetting, settingItemOwner } from './resources'
import { bindSettingActions } from './selection'
@@ -21,6 +24,8 @@ function bindContent(item: SettingItem, key: 'icon' | 'html' | 'tooltip', elemen
set(value: unknown) {
element.innerHTML = ''
append(element, value)
if (key === 'html')
nameSettingRange(item)
},
})
}
@@ -46,6 +51,10 @@ export function createSettingHeader(setting: SettingManager, item: SettingItem):
append($left, $icon)
append($left, item.$parent!.html)
proxySetting(setting.art, item.$parent!, $item, 'click', () => setting.render(item.$parents), settingPanelScope($panel))
const scope = settingPanelScope($panel)
if (!$item.querySelector('button,input,select,textarea,a[href],[tabindex],[contenteditable]'))
keyboardButton(scope, $item, () => $item.click(), () => settingScopeActive(scope) && !isClosing(setting.art))
$item.setAttribute('aria-label', `${setting.art.i18n.get('Back')}: ${$item.textContent?.trim() || ''}`)
append($panel, $item)
}
@@ -177,6 +186,7 @@ export function createSettingItem(setting: SettingManager, item: SettingItem, is
get: () => $switchValue,
set(value) {
$switchValue = value
$item.setAttribute('aria-checked', String(Boolean(value)))
if (value) {
setStyle($switchOff, 'display', 'none')
setStyle($switchOn, 'display', null)
@@ -258,6 +268,7 @@ export function createSettingItem(setting: SettingManager, item: SettingItem, is
configurable: true,
get: () => $item,
})
settingItemKeyboard(setting, item, $item, type, scope)
if (isUpdate) {
if (oldItem?.parentNode)
@@ -298,6 +309,7 @@ export function createSettingItem(setting: SettingManager, item: SettingItem, is
export function renderSetting(setting: SettingManager, option = setting.option): void {
if (isClosing(setting.art))
return
const restoreFocus = captureSettingFocus(setting)
const cached = setting.cache.get(option)
if (cached && !settingScopeActive(settingPanelScope(cached)))
return
@@ -335,6 +347,8 @@ export function renderSetting(setting: SettingManager, option = setting.option):
if (!current())
return
addClass($panel, 'art-setting-panel')
$panel.setAttribute('role', 'group')
$panel.setAttribute('aria-label', option[0]?.$parent?.$html?.textContent || setting.art.i18n.get('Settings'))
append(setting.$parent, $panel)
inverseClass($panel, 'art-current')
@@ -376,4 +390,6 @@ export function renderSetting(setting: SettingManager, option = setting.option):
throw error
}
}
if (currentNavigation())
restoreFocus(previous?.[0]?.$parent?.$item)
}
@@ -13,6 +13,8 @@ export function checkSetting(setting: SettingManager, target?: SettingItem | nul
target.$parent.tooltip = target.html
setting.traverse((item) => {
item.default = item === target
if (item.$item)
item.$item.setAttribute('aria-current', String(item.default))
if (item.default && item.$item)
inverseClass(item.$item, 'art-current')
}, target.$option)
+1
View File
@@ -34,6 +34,7 @@ export interface SettingItem extends TreeItem {
}
export interface SettingEvents {
'setting': [boolean]
'blur': [Event]
'focus': [event: Event]
'resize': []
+4
View File
@@ -5,6 +5,7 @@ import captureTemplate from '../lifecycle/template-rollback'
import { inverseClass } from '../utils/dom'
import { pauseSettingScope } from './activity'
import { captureSettingItem } from './checkpoint'
import { captureSettingFocus } from './keyboard-focus'
import { releaseSettingPanel, settingPanelScope } from './panels'
import { cancelSettingAdd } from './registration'
import { suspendSettingItem } from './resources'
@@ -112,6 +113,7 @@ export function updateSetting(setting: SettingManager, target: SettingItem): Set
return item || target
if (!item)
return setting.add(target)
const restoreFocus = captureSettingFocus(setting)
cancelSettingAdd(item)
cancelSettingUpdate(item)
const restoreItem = captureSettingItem(item)
@@ -161,6 +163,7 @@ export function updateSetting(setting: SettingManager, target: SettingItem): Set
}
},
])
restoreFocus(element)
}
catch (error) {
console.warn('ArtPlayer setting restore failed:', error)
@@ -197,6 +200,7 @@ export function updateSetting(setting: SettingManager, target: SettingItem): Set
pending.delete(affected)
}
tree.dispose()
restoreFocus(item.$item)
return item
}
catch (error) {