mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-25 16:11: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
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface SettingItem extends TreeItem {
|
||||
}
|
||||
|
||||
export interface SettingEvents {
|
||||
'setting': [boolean]
|
||||
'blur': [Event]
|
||||
'focus': [event: Event]
|
||||
'resize': []
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user