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,36 @@
import type { MiniHost } from './types'
import { resolveFocusOrigin } from '../accessibility/overlay-focus'
import { focusPlayer } from '../accessibility/player-focus'
import { isClosing } from '../lifecycle/instance'
export function miniFocus(art: MiniHost) {
const player = art.template.$player
let origin: HTMLElement | undefined
return {
entering(): (focus?: () => void) => void {
const doc = player.ownerDocument
const focused = doc.activeElement as HTMLElement | null
const inside = focused && player.contains(focused)
if (!art.template.$mini?.contains(focused))
origin = inside ? resolveFocusOrigin(focused) : undefined
const keyboard = inside && player.classList.contains('art-keyboard-focus')
return (focus) => {
if (keyboard && !isClosing(art) && (doc.activeElement === focused || doc.activeElement === doc.body))
focus?.()
}
},
leaving(popup?: HTMLElement): () => void {
const doc = player.ownerDocument
const inside = popup?.contains(doc.activeElement)
return () => {
if (!inside || isClosing(art) || (doc.activeElement !== doc.body && !popup?.contains(doc.activeElement)))
return
const visible = origin && doc.defaultView?.getComputedStyle(origin).visibility
if (origin?.isConnected && origin.getClientRects().length && visible !== 'hidden' && visible !== 'collapse' && origin.getAttribute('aria-disabled') !== 'true' && !origin.matches(':disabled') && !origin.closest('[inert]'))
origin.focus({ preventScroll: true })
else
focusPlayer(art)
}
},
}
}
+27 -1
View File
@@ -1,5 +1,8 @@
import type ResourceScope from '../lifecycle/scope'
import type { MiniHost } from './types'
import { keyboardButton } from '../accessibility/button'
import { claimKey, plainKey } from '../accessibility/keyboard'
import { isClosing } from '../lifecycle/instance'
import { listen } from '../lifecycle/resources'
import { silencePromise } from '../utils/error'
import { miniDrag } from './mini-drag'
@@ -8,6 +11,7 @@ export interface MiniView {
element: HTMLElement
cancelDrag: () => void
fresh: boolean
focus?: () => void
}
export function createMiniView(art: MiniHost, parent: ResourceScope, active: () => boolean, hide: () => void): MiniView | undefined {
@@ -42,6 +46,24 @@ export function createMiniView(art: MiniHost, parent: ResourceScope, active: ()
return
state.append(play, pause)
element.append(close, state)
keyboardButton(scope, close, () => close.click(), active)
keyboardButton(scope, state, () => (art.playing ? pause : play).click(), active)
const closeLabel = art.i18n.get('Close')
if (scope.closed || isClosing(art))
return
close.setAttribute('aria-label', closeLabel)
const groupLabel = art.i18n.get('Mini Player')
if (scope.closed || isClosing(art))
return
element.setAttribute('role', 'group')
element.setAttribute('aria-label', groupLabel)
listen(scope, element, 'keydown', (input) => {
const event = input as KeyboardEvent
if (active() && plainKey(event) && !event.defaultPrevented && event.key === 'Escape') {
claimKey(event)
hide()
}
})
listen(scope, close, 'click', hide)
listen(scope, play, 'click', () => {
silencePromise(art.play())
@@ -52,6 +74,10 @@ export function createMiniView(art: MiniHost, parent: ResourceScope, active: ()
const update = () => {
if (scope.closed)
return
const label = art.i18n.get(art.playing ? 'Pause' : 'Play')
if (scope.closed || isClosing(art))
return
state.setAttribute('aria-label', label)
play.style.display = art.playing ? 'none' : 'flex'
pause.style.display = art.playing ? 'flex' : 'none'
}
@@ -68,7 +94,7 @@ export function createMiniView(art: MiniHost, parent: ResourceScope, active: ()
art.template.$mini = element
document.body.append(element)
if (!scope.closed)
return { element, cancelDrag, fresh: true }
return { element, cancelDrag, fresh: true, focus: () => close.focus({ preventScroll: true }) }
}
catch (error) {
scope.dispose()
+9 -1
View File
@@ -2,6 +2,7 @@ import type { MiniView } from './mini-view'
import type { Placement } from './placement'
import type { MiniHost } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { miniFocus } from './mini-focus'
import { miniGeometry, miniPosition } from './mini-layout'
import { createMiniView } from './mini-view'
import { capturePlacement, restorePlacement } from './placement'
@@ -15,6 +16,7 @@ export function mini(art: MiniHost): PropertyDescriptor {
let creating = false
let queued = false
const active = () => !isClosing(art) && $player.classList.contains('art-mini')
const focus = miniFocus(art)
function restore() {
if (placement) {
@@ -37,6 +39,7 @@ export function mini(art: MiniHost): PropertyDescriptor {
return
const current = ++revision
queued = false
const restoreFocus = focus.leaving(art.template.$mini)
restore()
if (isClosing(art) || current !== revision)
return
@@ -44,7 +47,9 @@ export function mini(art: MiniHost): PropertyDescriptor {
$player.classList.remove('art-mini')
if (art.template.$mini) {
art.template.$mini.style.display = 'none'
art.emit('mini', false)
restoreFocus()
if (!isClosing(art) && current === revision)
art.emit('mini', false)
}
}
@@ -67,6 +72,7 @@ export function mini(art: MiniHost): PropertyDescriptor {
queued = true
return
}
const enterFocus = focus.entering()
art.state = 'mini'
if (isClosing(art) || current !== revision)
return
@@ -115,6 +121,8 @@ export function mini(art: MiniHost): PropertyDescriptor {
return
art.storage.set('left', position.left)
}
if (!isClosing(art) && current === revision)
enterFocus(view.focus)
if (!isClosing(art) && current === revision)
art.emit('mini', true)
}
+1
View File
@@ -57,6 +57,7 @@ export interface MiniEvents {
export interface MiniHost extends SubscriptionHost<MiniEvents> {
template: { $player: HTMLElement, $video: HTMLElement, $mini?: HTMLElement }
i18n: { get: (key: string) => string }
icons: { close: HTMLElement, play: HTMLElement, pause: HTMLElement }
storage: { get: (key: string) => unknown, set: (key: string, value: number) => unknown }
state: string
@@ -1,5 +1,6 @@
import type { Placement } from './placement'
import type { WebFullscreenHost } from './types'
import { captureMovedFocus } from '../accessibility/moved-focus'
import { getScope, isClosing } from '../lifecycle/instance'
import { ResourceCleanupError } from '../lifecycle/scope'
import { capturePlacement, restorePlacement } from './placement'
@@ -55,6 +56,7 @@ export function webFullscreen(art: WebFullscreenHost): (value: boolean) => void
return
const current = ++revision
const active = () => current === revision && !isClosing(art)
const restoreFocus = captureMovedFocus($player)
if (value) {
if (!saved)
saved = { placement: capturePlacement($player), style: $player.getAttribute('style') }
@@ -72,10 +74,15 @@ export function webFullscreen(art: WebFullscreenHost): (value: boolean) => void
$player.classList.add('art-fullscreen-web')
}
catch (error) {
if (active() && restore(snapshot, active))
if (active() && restore(snapshot, active)) {
saved = undefined
restoreFocus()
}
throw error
}
if (!active())
return
restoreFocus()
if (!active())
return
art.emit('fullscreenWeb', true)
@@ -89,6 +96,9 @@ export function webFullscreen(art: WebFullscreenHost): (value: boolean) => void
else {
$player.classList.remove('art-fullscreen-web')
}
if (!active())
return
restoreFocus()
if (!active())
return
art.emit('fullscreenWeb', false)