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

@@ -1,5 +1,7 @@
import type { ControlFactory, ControlOption } from './types'
import { keyboardButton } from '../accessibility/button'
import { appendElement } from '../component/dom'
import { entryScope } from '../component/resources'
import { controlEvents } from './resources'
export default function airplay(option: ControlOption): ControlFactory {
@@ -9,6 +11,7 @@ export default function airplay(option: ControlOption): ControlFactory {
mounted: ($control) => {
const { proxy } = controlEvents(art, $control)
const { icons } = art
keyboardButton(entryScope($control), $control)
appendElement($control, icons.airplay)
proxy($control, 'click', () => art.airplay())
},
@@ -1,5 +1,7 @@
import type { ControlFactory, ControlOption } from './types'
import { keyboardButton } from '../accessibility/button'
import { appendElement } from '../component/dom'
import { entryScope } from '../component/resources'
import { setStyle, tooltip } from '../utils'
import { controlEvents } from './resources'
@@ -10,6 +12,7 @@ export default function fullscreen(option: ControlOption): ControlFactory {
mounted: ($control) => {
const { on, proxy } = controlEvents(art, $control)
const { icons, i18n } = art
keyboardButton(entryScope($control), $control)
const $fullscreenOn = appendElement($control, icons.fullscreenOn)
const $fullscreenOff = appendElement($control, icons.fullscreenOff)
@@ -1,5 +1,7 @@
import type { ControlFactory, ControlOption } from './types'
import { keyboardButton } from '../accessibility/button'
import { appendElement } from '../component/dom'
import { entryScope } from '../component/resources'
import { setStyle, tooltip } from '../utils'
import { controlEvents } from './resources'
@@ -10,6 +12,7 @@ export default function fullscreenWeb(option: ControlOption): ControlFactory {
mounted: ($control) => {
const { on, proxy } = controlEvents(art, $control)
const { icons, i18n } = art
keyboardButton(entryScope($control), $control)
const $fullscreenWebOn = appendElement($control, icons.fullscreenWebOn)
const $fullscreenWebOff = appendElement($control, icons.fullscreenWebOff)
+18 -2
View File
@@ -1,5 +1,6 @@
import type { EntryInput, EntryOption, EventCleanup, SelectorItem } from '../component/types'
import type { ControlHost } from './types'
import { focusVisibility } from '../accessibility/focus'
import { ownEntry } from '../component/resources'
import { getScope, isClosing } from '../lifecycle/instance'
import {
@@ -30,8 +31,22 @@ export default class Control extends Component<ControlHost> {
const { constructor } = art
const { $player, $bottom } = this.art.template
ownEntry(art, $bottom)
const { on } = controlEvents(art, $bottom)
const scope = ownEntry(art, $bottom)
const keyboardFocused = focusVisibility(scope, $player, () => {
this.show = true
})
const { on, proxy } = controlEvents(art, $bottom)
proxy($player, 'focusin', (event) => {
art.isFocus = true
art.isInput = (event.target as Element | null)?.tagName === 'INPUT'
})
proxy($player, 'focusout', (event) => {
if (!$player.contains(event.relatedTarget as Node | null)) {
art.isFocus = false
art.isInput = false
}
})
on('mousemove', () => {
if (!isMobile) {
@@ -57,6 +72,7 @@ export default class Control extends Component<ControlHost> {
!art.setting.show
&& !this.isHover
&& !art.isInput
&& !keyboardFocused()
&& art.playing
&& this.show
&& Date.now() - this.timer >= constructor.CONTROL_HIDE_TIME
+3
View File
@@ -1,5 +1,7 @@
import type { ControlFactory, ControlOption } from './types'
import { keyboardButton } from '../accessibility/button'
import { appendElement } from '../component/dom'
import { entryScope } from '../component/resources'
import { tooltip } from '../utils'
import { controlEvents } from './resources'
@@ -10,6 +12,7 @@ export default function pip(option: ControlOption): ControlFactory {
mounted: ($control) => {
const { on, proxy } = controlEvents(art, $control)
const { icons, i18n } = art
keyboardButton(entryScope($control), $control)
appendElement($control, icons.pip)
@@ -1,5 +1,7 @@
import type { ControlFactory, ControlOption } from './types'
import { keyboardButton } from '../accessibility/button'
import { appendElement } from '../component/dom'
import { entryScope } from '../component/resources'
import { setStyle, silencePromise, tooltip } from '../utils'
import { controlEvents } from './resources'
@@ -12,6 +14,7 @@ export default function playAndPause(option: ControlOption): ControlFactory {
const $play = appendElement($control, icons.play)
const $pause = appendElement($control, icons.pause)
keyboardButton(entryScope($control), $control, () => (art.playing ? $pause : $play).click())
tooltip($play, i18n.get('Play'))
tooltip($pause, i18n.get('Pause'))
@@ -24,11 +27,13 @@ export default function playAndPause(option: ControlOption): ControlFactory {
})
function showPlay() {
$control.setAttribute('aria-label', i18n.get('Play'))
setStyle($play, 'display', 'flex')
setStyle($pause, 'display', 'none')
}
function showPause() {
$control.setAttribute('aria-label', i18n.get('Pause'))
setStyle($play, 'display', 'none')
setStyle($pause, 'display', 'flex')
}
@@ -1,5 +1,6 @@
import type { ControlFactory, ControlOption } from './types'
import { installProgressInteractions } from './progress/interactions'
import { installProgressKeyboard } from './progress/keyboard'
import { mountProgressView } from './progress/view'
export { getPosFromEvent, setCurrentTime } from './progress/position'
@@ -20,6 +21,7 @@ export default function progress(options: ControlOption): ControlFactory {
mounted: ($control) => {
mountProgressView(art, $control)
installProgressInteractions(art, $control)
installProgressKeyboard(art, $control)
},
})
}
@@ -0,0 +1,77 @@
import type { ControlHost } from '../types'
import type ResourceScope from '../../lifecycle/scope'
import { keyboardSlider } from '../../accessibility/slider'
import { entryScope } from '../../component/resources'
import { isClosing } from '../../lifecycle/instance'
import { captureSource, getSourceScope } from '../../source/operation'
import { positionRestoration } from '../../source/restore-position'
import { secondToTime } from '../../utils'
import { controlEvents } from '../resources'
export function installProgressKeyboard(art: ControlHost, element: HTMLDivElement): void {
const scope = entryScope(element)
const { on } = controlEvents(art, element)
let revision = 0
let pending: ResourceScope | undefined
const update = keyboardSlider(scope, element, art.i18n.get('Progress'), () => ({
min: 0,
max: art.duration,
value: art.currentTime,
step: art.constructor.SEEK_STEP,
text: value => `${secondToTime(value)} / ${secondToTime(Number.isFinite(art.duration) && art.duration > 0 ? art.duration : 0)}`,
}), (value) => {
const action = ++revision
pending?.dispose()
const operation = scope.child()
pending = operation
const releaseSource = getSourceScope(art).add(() => {
operation.dispose()
})
operation.add(() => {
releaseSource()
})
operation.add(() => {
if (pending === operation)
pending = undefined
})
const sourceActive = captureSource(art)
const active = () => action === revision && !operation.closed && !isClosing(art) && sourceActive()
const position = positionRestoration(art, value, active)
const settle = () => {
if (!active()) {
operation.dispose()
return
}
if (position.ready()) {
operation.dispose()
update()
}
}
for (const name of ['video:seeked', 'video:ended'] as const) {
art.on(name, settle)
operation.add(() => {
art.off(name, settle)
})
}
try {
art.emit('setBar', 'played', value / art.duration)
if (active()) {
position.restore(() => {
art.seek = value
})
}
settle()
}
catch (error) {
operation.dispose()
throw error
}
})
on('video:loadedmetadata', update)
on('video:durationchange', update)
on('video:emptied', update)
on('video:timeupdate', update)
on('video:seeking', update)
on('video:seeked', update)
on('video:ended', update)
}
@@ -1,5 +1,7 @@
import type { ControlFactory, ControlOption } from './types'
import { keyboardButton } from '../accessibility/button'
import { appendElement } from '../component/dom'
import { entryScope } from '../component/resources'
import { silencePromise } from '../utils'
import { controlEvents } from './resources'
@@ -10,6 +12,7 @@ export default function screenshot(option: ControlOption): ControlFactory {
mounted: ($control) => {
const { proxy } = controlEvents(art, $control)
const { icons } = art
keyboardButton(entryScope($control), $control)
appendElement($control, icons.screenshot)
proxy($control, 'click', () => {
@@ -0,0 +1,191 @@
import type ResourceScope from '../lifecycle/scope'
import { keyboardButton } from '../accessibility/button'
import { claimKey, plainKey } from '../accessibility/keyboard'
import { listen } from '../lifecycle/resources'
const interactive = 'button,input,select,textarea,a[href],[tabindex],[contenteditable]'
export function selectorKeyboard(scope: ResourceScope, owner: HTMLElement, value: HTMLElement, list: HTMLElement, label: string): (restore?: boolean) => void {
if (scope.closed)
return () => {}
const items = Array.from(list.children) as HTMLElement[]
const complex = items.some(item => item.querySelector(interactive))
list.setAttribute('role', complex ? 'group' : 'listbox')
list.setAttribute('aria-label', label)
let hovered = false
let opened = false
let dismissed = false
let prefix = ''
let typedAt = 0
const expanded = () => items.length > 0 && (opened || (hovered && !dismissed))
const trigger = () => value.querySelector<HTMLElement>('button,a[href],[role="button"]') || value
const refresh = (restore = false) => {
if (scope.closed)
return
const target = trigger()
if (value.querySelector(interactive)) {
value.removeAttribute('role')
value.removeAttribute('tabindex')
}
else {
value.setAttribute('role', 'button')
value.tabIndex = 0
}
if (target !== value) {
value.removeAttribute('aria-haspopup')
value.removeAttribute('aria-expanded')
value.removeAttribute('aria-disabled')
}
if (complex)
target.removeAttribute('aria-haspopup')
else
target.setAttribute('aria-haspopup', 'listbox')
target.setAttribute('aria-expanded', String(expanded()))
target.setAttribute('aria-disabled', String(items.length === 0))
if (restore && value.isConnected && value.ownerDocument.activeElement === value.ownerDocument.body)
target.focus({ preventScroll: true })
}
const visibility = (state: boolean, hide = false) => {
opened = state
dismissed = hide
owner.classList.toggle('art-selector-open', state)
owner.classList.toggle('art-selector-dismissed', hide)
refresh()
}
const focus = (item?: HTMLElement) => {
if (!item || scope.closed)
return
const target = item.querySelector<HTMLElement>(interactive) || item
target.focus({ preventScroll: true })
if (!scope.closed)
item.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}
const open = (last = false) => {
if (scope.closed || !items.length)
return
visibility(true)
prefix = ''
focus(items.find(item => item.classList.contains('art-current')) || items[last ? items.length - 1 : 0])
}
const close = () => {
if (scope.closed)
return
const restore = list.contains(owner.ownerDocument.activeElement)
visibility(false, true)
prefix = ''
if (restore && value.isConnected)
trigger().focus({ preventScroll: true })
}
keyboardButton(scope, value, () => {
value.click()
if (owner.ownerDocument.activeElement === value)
open()
})
refresh()
for (const item of items) {
const selected = item.classList.contains('art-current')
if (!complex) {
item.setAttribute('role', 'option')
item.setAttribute('aria-selected', String(selected))
}
else {
item.setAttribute('aria-current', String(selected))
}
if (!item.querySelector(interactive)) {
item.tabIndex = -1
keyboardButton(scope, item, () => {
item.click()
close()
})
}
}
listen(scope, value, 'keydown', (input) => {
const event = input as KeyboardEvent
if (!plainKey(event) || event.defaultPrevented || (event.target !== value && event.target !== trigger()))
return
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
claimKey(event)
open(event.key === 'ArrowUp')
}
else if (event.key === 'Escape' && expanded()) {
claimKey(event)
close()
}
})
listen(scope, value, 'click', (event) => {
if (event.isTrusted && (event as MouseEvent).detail === 0 && trigger() !== value)
open()
})
listen(scope, list, 'keydown', (input) => {
const event = input as KeyboardEvent
if (!plainKey(event) || event.defaultPrevented)
return
if (event.key === 'Escape') {
claimKey(event)
close()
return
}
const index = items.indexOf(event.target as HTMLElement)
if (index < 0)
return
if (event.key.length !== 1)
prefix = ''
let next: number | undefined
switch (event.key) {
case 'ArrowDown':
next = Math.min(index + 1, items.length - 1)
break
case 'ArrowUp':
next = Math.max(index - 1, 0)
break
case 'Home':
next = 0
break
case 'End':
next = items.length - 1
break
case 'ArrowLeft':
case 'ArrowRight':
claimKey(event)
return
default: {
if (event.key.length !== 1 || event.key === ' ')
return
const now = Date.now()
prefix = now - typedAt < 500 ? prefix + event.key.toLocaleLowerCase() : event.key.toLocaleLowerCase()
typedAt = now
const query = [...prefix].every(char => char === prefix[0]) ? prefix[0]! : prefix
for (let offset = 1; offset <= items.length; offset++) {
const candidate = (index + offset) % items.length
if (items[candidate]!.textContent?.trim().toLocaleLowerCase().startsWith(query)) {
next = candidate
break
}
}
}
}
claimKey(event)
if (next !== undefined)
focus(items[next])
})
listen(scope, list, 'click', (event) => {
if (event.isTrusted && (event as MouseEvent).detail === 0)
close()
})
listen(scope, owner, 'mouseenter', () => {
hovered = true
visibility(opened)
})
listen(scope, owner, 'mouseleave', () => {
hovered = false
visibility(list.contains(owner.ownerDocument.activeElement))
})
listen(scope, owner, 'focusout', (event) => {
if (!owner.contains((event as FocusEvent).relatedTarget as Node | null))
visibility(false, true)
})
scope.add(() => {
owner.classList.remove('art-selector-open', 'art-selector-dismissed')
})
return refresh
}
+13 -1
View File
@@ -4,12 +4,14 @@ import { appendElement } from '../component/dom'
import { entryScope } from '../component/resources'
import { trackSelection } from '../component/selection'
import { addClass, append, def, errorHandle, getComposedPath, inverseClass } from '../utils'
import { selectorKeyboard } from './selector-keyboard'
interface Binding {
option: EntryOption<ControlHost>
item: HTMLDivElement
value: HTMLDivElement
owner: HTMLDivElement
refresh?: (restore?: boolean) => void
}
const bindings = new WeakMap<SelectorItem, Binding>()
@@ -32,13 +34,17 @@ function setHTML(element: HTMLElement, value: unknown): void {
export function checkSelector(target?: SelectorItem): void {
if (!target)
return
const restore = target.$control_value!.contains(target.$control_value!.ownerDocument.activeElement)
setHTML(target.$control_value!, target.html)
for (let index = 0; index < target.$control_option!.length; index++) {
const item = target.$control_option![index]!
item.default = item === target
const element = item.$control_item!
element.setAttribute(element.getAttribute('role') === 'option' ? 'aria-selected' : 'aria-current', String(item.default))
if (item.default)
inverseClass(item.$control_item!, 'art-current')
}
bindings.get(target)?.refresh?.(restore)
}
export function renderSelector(art: ControlHost, check: (target?: SelectorItem) => void, option: EntryOption<ControlHost>, $ref: HTMLDivElement, events: EventCleanup[]): void {
@@ -81,8 +87,11 @@ export function renderSelector(art: ControlHost, check: (target?: SelectorItem)
return
if (option.onSelect) {
const value = await option.onSelect.call(art, item, item.$control_item!, event)
if (active())
if (active()) {
const restore = $value.contains($value.ownerDocument.activeElement)
setHTML($value, value)
bindings.get(item)?.refresh?.(restore)
}
}
}
catch (error) {
@@ -93,4 +102,7 @@ export function renderSelector(art: ControlHost, check: (target?: SelectorItem)
}
})
events.push(event)
const refresh = selectorKeyboard(scope, $ref, $value, $list, String(option.tooltip || option.name || $value.textContent || art.i18n.get('Open')))
for (const item of selector)
bindings.get(item)!.refresh = refresh
}
@@ -1,5 +1,7 @@
import type { ControlFactory, ControlOption } from './types'
import { keyboardButton } from '../accessibility/button'
import { appendElement } from '../component/dom'
import { entryScope } from '../component/resources'
import { tooltip } from '../utils'
import { controlEvents } from './resources'
@@ -12,6 +14,8 @@ export default function setting(option: ControlOption): ControlFactory {
const { icons, i18n } = art
appendElement($control, icons.setting)
keyboardButton(entryScope($control), $control)
$control.setAttribute('aria-expanded', 'false')
proxy($control, 'click', () => {
art.setting.toggle()
@@ -19,6 +23,7 @@ export default function setting(option: ControlOption): ControlFactory {
})
on('setting', (value) => {
$control.setAttribute('aria-expanded', String(value))
tooltip($control, i18n.get(value ? 'Hide Setting' : 'Show Setting'))
})
},
+8 -1
View File
@@ -9,6 +9,10 @@ export interface UIEvents {
'document:mouseup': [MouseEvent]
'video:timeupdate': [Event]
'video:loadedmetadata': [Event]
'video:durationchange': [Event]
'video:emptied': [Event]
'video:seeking': [Event]
'video:seeked': [Event]
'video:progress': [Event]
'video:ended': [Event]
'video:playing': [Event]
@@ -22,6 +26,7 @@ export interface UIEvents {
'fullscreenWeb': [boolean]
'pip': [boolean]
'setting': [boolean]
'contextmenu': [boolean]
'aspectRatio': [string]
'flip': [string]
'blur': [Event]
@@ -34,6 +39,7 @@ type IconName = 'play' | 'pause' | 'volume' | 'volumeClose' | 'fullscreenOn' | '
export interface ControlHost extends ComponentHost, SubscriptionHost<UIEvents>, PipProperty {
template: {
$video?: { readonly currentTime: number, readonly seeking?: boolean }
$player: HTMLDivElement
$bottom: HTMLDivElement
$controls: HTMLDivElement
@@ -55,9 +61,10 @@ export interface ControlHost extends ComponentHost, SubscriptionHost<UIEvents>,
}
icons: Record<IconName, HTMLElement> & { indicator?: HTMLElement }
i18n: { get: (key: string) => string }
constructor: { CONTROL_HIDE_TIME: number, USE_RAF: boolean }
constructor: { CONTROL_HIDE_TIME: number, USE_RAF: boolean, SEEK_STEP: number, VOLUME_STEP: number }
setting: { show: boolean, toggle: () => void, resize: () => void }
isInput: boolean
isFocus: boolean
isRotate: boolean
playing: boolean
muted: boolean
+32 -2
View File
@@ -1,5 +1,9 @@
import type { ControlFactory, ControlOption } from './types'
import { keyboardButton } from '../accessibility/button'
import { keyboardSlider } from '../accessibility/slider'
import { appendElement } from '../component/dom'
import { entryScope } from '../component/resources'
import { isClosing } from '../lifecycle/instance'
import { getRect, isMobile, setStyle } from '../utils'
import { controlEvents } from './resources'
@@ -8,10 +12,14 @@ export default function volume(option: ControlOption): ControlFactory {
...option,
mounted: ($control) => {
const { on, proxy } = controlEvents(art, $control)
const { icons } = art
const { icons, i18n } = art
const $volume = appendElement($control, icons.volume)
const $close = appendElement($control, icons.volumeClose)
for (const element of [$volume, $close]) {
keyboardButton(entryScope($control), element)
element.setAttribute('aria-label', i18n.get('Mute'))
}
const $panel = appendElement($control, '<div class="art-volume-panel"></div>')
const $inner = appendElement($panel, '<div class="art-volume-inner"></div>')
const $value = appendElement($inner, `<div class="art-volume-val"></div>`)
@@ -19,6 +27,20 @@ export default function volume(option: ControlOption): ControlFactory {
const $handle = appendElement($slider, `<div class="art-volume-handle"></div>`)
const $loaded = appendElement($handle, `<div class="art-volume-loaded"></div>`)
const $indicator = appendElement($slider, `<div class="art-volume-indicator"></div>`)
const scope = entryScope($control)
let revision = 0
const updateSlider = keyboardSlider(scope, $slider, i18n.get('Volume'), () => ({
min: 0,
max: 100,
value: art.muted ? 0 : art.volume * 100,
step: art.constructor.VOLUME_STEP * 100,
text: value => `${Math.round(value)}%`,
}), (value) => {
const action = ++revision
art.muted = false
if (action === revision && !scope.closed && !isClosing(art))
art.volume = value / 100
}, 'vertical')
function getVolumeFromEvent(event: MouseEvent) {
const { top, height } = getRect($slider)
@@ -26,7 +48,13 @@ export default function volume(option: ControlOption): ControlFactory {
}
function update() {
if (art.muted || art.volume === 0) {
updateSlider()
const focused = $control.ownerDocument.activeElement
const muted = art.muted || art.volume === 0
const visible = muted ? $close : $volume
$volume.setAttribute('aria-pressed', String(art.muted))
$close.setAttribute('aria-pressed', String(art.muted))
if (muted) {
setStyle($volume, 'display', 'none')
setStyle($close, 'display', 'flex')
setStyle($indicator, 'top', '100%')
@@ -41,6 +69,8 @@ export default function volume(option: ControlOption): ControlFactory {
setStyle($loaded, 'top', `${100 - percentage}%`)
$value.textContent = String(Math.floor(percentage))
}
if ((focused === $volume || focused === $close) && focused !== visible)
visible.focus({ preventScroll: true })
}
update()