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()
})
}
}
+50
View File
@@ -0,0 +1,50 @@
import type { ComponentHost } from './types'
import { focusPlayer } from '../accessibility/player-focus'
import { isClosing } from '../lifecycle/instance'
interface FocusOwner {
name?: string
art: ComponentHost
cache: Map<string, { $ref: HTMLDivElement }>
}
const pending = new WeakSet<object>()
const focusable = '[tabindex],button,input,select,textarea,a[href],[contenteditable]'
function targetIn(element: HTMLElement): HTMLElement | undefined {
const targets = [element, ...element.querySelectorAll<HTMLElement>(focusable)]
return targets.find((target) => {
if (!target.matches(focusable) || target.tabIndex < 0 || target.matches(':disabled') || target.closest('[inert]') || target.getAttribute('aria-disabled') === 'true' || !target.getClientRects().length)
return false
const style = target.ownerDocument.defaultView?.getComputedStyle(target)
return style?.visibility !== 'hidden' && style?.visibility !== 'collapse'
})
}
export function captureComponentFocus(owner: FocusOwner, element?: HTMLElement, replacement?: string): () => void {
const doc = element?.ownerDocument
const focused = doc?.activeElement
if (!['control', 'contextmenu'].includes(owner.name ?? '') || pending.has(owner) || !element || !doc || !focused || !element.contains(focused))
return () => {}
const player = owner.art.template.$player
const controls = Array.from(player.querySelectorAll<HTMLElement>(`.art-${owner.name}`))
const index = controls.indexOf(element)
const neighbors = [...controls.slice(index + 1), ...controls.slice(0, index).reverse()]
pending.add(owner)
return () => {
pending.delete(owner)
if (isClosing(owner.art) || !player.isConnected || doc.activeElement !== doc.body)
return
const next = replacement ? owner.cache.get(replacement)?.$ref : undefined
for (const candidate of [...(next ? [next] : []), ...neighbors]) {
if (!player.contains(candidate))
continue
const target = targetIn(candidate)
if (target) {
target.focus({ preventScroll: true })
return
}
}
focusPlayer(owner.art)
}
}
@@ -1,6 +1,7 @@
import type { ContextmenuFactory, ContextmenuOption } from './types'
import { controlEvents } from '../control/resources'
import { inverseClass, query } from '../utils'
import { keyboardChoices, updateChoices } from './choices'
export default function aspectRatio(option: ContextmenuOption): ContextmenuFactory {
return (art) => {
@@ -12,10 +13,11 @@ export default function aspectRatio(option: ContextmenuOption): ContextmenuFacto
const html = ASPECT_RATIO.map(
item => `<span data-value="${item}">${item === 'default' ? i18n.get('Default') : item}</span>`,
).join('')
const label = i18n.get('Aspect Ratio')
return {
...option,
html: `${i18n.get('Aspect Ratio')}: ${html}`,
html: `${label}: ${html}`,
click: (contextmenu, event) => {
const value = event.target instanceof HTMLElement ? event.target.dataset.value : undefined
if (value) {
@@ -29,10 +31,12 @@ export default function aspectRatio(option: ContextmenuOption): ContextmenuFacto
if ($default) {
inverseClass($default, 'art-current')
}
keyboardChoices($panel, label)
on('aspectRatio', (value) => {
const $current = Array.from($panel.querySelectorAll('span')).find(item => item.dataset.value === value)
if ($current) {
inverseClass($current, 'art-current')
updateChoices($panel)
}
})
},
@@ -0,0 +1,20 @@
import { keyboardButton } from '../accessibility/button'
import { entryScope } from '../component/resources'
export function updateChoices(panel: HTMLElement): void {
for (const item of panel.querySelectorAll<HTMLElement>('[data-value]'))
item.setAttribute('aria-pressed', String(item.classList.contains('art-current')))
}
export function keyboardChoices(panel: HTMLElement, label: string): void {
const scope = entryScope(panel)
if (scope.closed)
return
panel.setAttribute('role', 'group')
panel.setAttribute('aria-label', label)
for (const item of panel.querySelectorAll<HTMLElement>('[data-value]')) {
keyboardButton(scope, item)
item.setAttribute('aria-label', `${label}: ${item.textContent?.trim() ?? ''}`)
}
updateChoices(panel)
}
+5 -1
View File
@@ -1,6 +1,7 @@
import type { ContextmenuFactory, ContextmenuOption } from './types'
import { controlEvents } from '../control/resources'
import { capitalize, inverseClass, query } from '../utils'
import { keyboardChoices, updateChoices } from './choices'
export default function flip(option: ContextmenuOption): ContextmenuFactory {
return (art) => {
@@ -10,10 +11,11 @@ export default function flip(option: ContextmenuOption): ContextmenuFactory {
} = art
const html = FLIP.map(item => `<span data-value="${item}">${i18n.get(capitalize(item))}</span>`).join('')
const label = i18n.get('Video Flip')
return {
...option,
html: `${i18n.get('Video Flip')}: ${html}`,
html: `${label}: ${html}`,
click: (contextmenu, event) => {
const value = event.target instanceof HTMLElement ? event.target.dataset.value : undefined
if (value) {
@@ -27,10 +29,12 @@ export default function flip(option: ContextmenuOption): ContextmenuFactory {
if ($default) {
inverseClass($default, 'art-current')
}
keyboardChoices($panel, label)
on('flip', (value) => {
const $current = Array.from($panel.querySelectorAll('span')).find(item => item.dataset.value === value)
if ($current) {
inverseClass($current, 'art-current')
updateChoices($panel)
}
})
},
+18 -25
View File
@@ -1,13 +1,16 @@
import type { ContextmenuHost } from './types'
import { ownEntry } from '../component/resources'
import { ownEntry, releaseEntry } from '../component/resources'
import { controlEvents } from '../control/resources'
import { getRect, includeFromEvent, isMobile, setStyles } from '../utils'
import { isClosing } from '../lifecycle/instance'
import { includeFromEvent, isMobile } from '../utils'
import Component from '../utils/component'
import aspectRatio from './aspectRatio'
import close from './close'
import flip from './flip'
import info from './info'
import { contextmenuKeyboard } from './keyboard'
import playbackRate from './playbackRate'
import { positionContextmenu } from './position'
import version from './version'
export default class Contextmenu extends Component<ContextmenuHost> {
@@ -82,32 +85,22 @@ export default class Contextmenu extends Component<ContextmenuHost> {
this.add(option.contextmenu[index]!)
}
if (isClosing(this.art))
return
releaseEntry($contextmenu)
const scope = ownEntry(this.art, $contextmenu)
const open = (x: number, y: number) => {
this.show = true
if (!scope.closed && !isClosing(this.art) && this.show)
positionContextmenu(this.art, x, y)
}
const keyboard = contextmenuKeyboard(this, open)
proxy($player, 'contextmenu', (event) => {
if (!this.art.constructor.CONTEXTMENU)
if (!keyboard.pointer(event))
return
event.preventDefault()
this.show = true
const mouseX = event.clientX
const mouseY = event.clientY
const { height: cHeight, width: cWidth, left: cLeft, top: cTop } = getRect($player)
const { height: mHeight, width: mWidth } = getRect($contextmenu)
let menuLeft = mouseX - cLeft
let menuTop = mouseY - cTop
if (mouseX + mWidth > cLeft + cWidth) {
menuLeft = cWidth - mWidth
}
if (mouseY + mHeight > cTop + cHeight) {
menuTop = cHeight - mHeight
}
setStyles($contextmenu, {
top: `${menuTop}px`,
left: `${menuLeft}px`,
})
open(event.clientX, event.clientY)
})
proxy($player, 'click', (event) => {
@@ -0,0 +1,144 @@
import type Contextmenu from './index'
import { claimKey, plainKey } from '../accessibility/keyboard'
import { resolveFocusOrigin, setOverlayOrigin } from '../accessibility/overlay-focus'
import { focusPlayer } from '../accessibility/player-focus'
import { entryScope } from '../component/resources'
import { isClosing } from '../lifecycle/instance'
import { listen, timeout } from '../lifecycle/resources'
function editable(target: EventTarget | null): boolean {
const element = target as HTMLElement | null
return !!element && (['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName) || element.isContentEditable)
}
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 contextmenuKeyboard(menu: Contextmenu, open: (x: number, y: number) => void) {
const { art } = menu
const { $player, $contextmenu } = art.template
const scope = entryScope($contextmenu)
const active = () => !scope.closed && !isClosing(art)
let origin: HTMLElement | undefined
let hadFocus = false
let prefix = ''
let typedAt = 0
const capture = () => {
const focused = $player.ownerDocument.activeElement as HTMLElement | null
origin = focused && $player.contains(focused) ? resolveFocusOrigin(focused) : $player
setOverlayOrigin($contextmenu, origin)
}
const targets = () => Array.from($contextmenu.querySelectorAll<HTMLElement>('[tabindex],button,input,select,textarea,a[href],[contenteditable]'))
.filter(element => element.tabIndex >= 0 && available(element))
const label = art.i18n.get('Context Menu')
if (!active())
return { pointer: () => false }
$contextmenu.setAttribute('role', 'group')
$contextmenu.setAttribute('aria-label', label)
const changed = (show: boolean) => {
if (!active())
return
if (show) {
if (!$contextmenu.contains($player.ownerDocument.activeElement))
capture()
return
}
const focused = $player.ownerDocument.activeElement
const restore = $contextmenu.contains(focused) || (hadFocus && focused === $player.ownerDocument.body)
const previous = origin
origin = undefined
hadFocus = false
prefix = ''
setOverlayOrigin($contextmenu)
if (!restore)
return
if (previous && $player.contains(previous) && available(previous))
previous.focus({ preventScroll: true })
if ($player.ownerDocument.activeElement === $player.ownerDocument.body || $contextmenu.contains($player.ownerDocument.activeElement))
focusPlayer(art)
}
art.on('contextmenu', changed)
scope.add(() => {
art.off('contextmenu', changed)
setOverlayOrigin($contextmenu)
})
listen(scope, $contextmenu, 'focusin', () => {
hadFocus = true
})
let pending = false
listen(scope, $contextmenu, 'focusout', (input) => {
if ($contextmenu.contains((input as FocusEvent).relatedTarget as Node | null) || pending)
return
pending = true
timeout(scope, () => {
pending = false
if (active() && menu.show && !$contextmenu.contains($player.ownerDocument.activeElement))
menu.show = false
}, 0)
})
listen(scope, $player, 'keydown', (input) => {
const event = input as KeyboardEvent
if (!active() || !plainKey(event) || event.defaultPrevented)
return
const opening = event.key === 'ContextMenu' || (event.key === 'F10' && event.shiftKey)
if (opening && art.constructor.CONTEXTMENU && !editable(event.target)) {
claimKey(event)
capture()
const rect = (event.target as HTMLElement).getBoundingClientRect()
open(rect.left, rect.bottom)
if (active() && menu.show)
targets()[0]?.focus({ preventScroll: true })
return
}
if (!menu.show || !$contextmenu.contains(event.target as Node | null))
return
if (event.key === 'Escape') {
claimKey(event)
menu.show = false
return
}
if (event.key === 'Tab') {
menu.show = false
return
}
if (editable(event.target))
return
const items = targets()
const index = items.indexOf(event.target as HTMLElement)
let target: HTMLElement | undefined
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
target = items[(index + 1) % items.length]
}
else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
target = items[(index + items.length - 1) % items.length]
}
else if (event.key === 'Home') {
target = items[0]
}
else if (event.key === 'End') {
target = items[items.length - 1]
}
else if (event.key.length === 1 && event.key !== ' ') {
const key = event.key.toLowerCase()
prefix = Date.now() - typedAt > 700 ? key : prefix + key
typedAt = Date.now()
const search = [...prefix].every(letter => letter === key) ? key : prefix
target = [...items.slice(index + 1), ...items.slice(0, index + 1)].find(item => (item.getAttribute('aria-label') || item.textContent || '').trim().toLowerCase().startsWith(search))
}
else {
return
}
claimKey(event)
target?.focus({ preventScroll: true })
})
return {
pointer(event: MouseEvent): boolean {
if (!active() || !art.constructor.CONTEXTMENU || editable(event.target))
return false
capture()
return true
},
}
}
@@ -1,6 +1,7 @@
import type { ContextmenuFactory, ContextmenuOption } from './types'
import { controlEvents } from '../control/resources'
import { inverseClass, query } from '../utils'
import { keyboardChoices, updateChoices } from './choices'
export default function playbackRate(option: ContextmenuOption): ContextmenuFactory {
return (art) => {
@@ -12,10 +13,11 @@ export default function playbackRate(option: ContextmenuOption): ContextmenuFact
const html = PLAYBACK_RATE.map(
item => `<span data-value="${item}">${item === 1 ? i18n.get('Normal') : item.toFixed(1)}</span>`,
).join('')
const label = i18n.get('Play Speed')
return {
...option,
html: `${i18n.get('Play Speed')}: ${html}`,
html: `${label}: ${html}`,
click: (contextmenu, event) => {
const value = event.target instanceof HTMLElement ? event.target.dataset.value : undefined
if (value) {
@@ -28,12 +30,14 @@ export default function playbackRate(option: ContextmenuOption): ContextmenuFact
const $default = query('[data-value="1"]', $panel)
if ($default)
inverseClass($default, 'art-current')
keyboardChoices($panel, label)
on('video:ratechange', () => {
const $current = Array.from($panel.querySelectorAll('span')).find(
item => Number(item.dataset.value) === art.playbackRate,
)
if ($current) {
inverseClass($current, 'art-current')
updateChoices($panel)
}
})
},
@@ -0,0 +1,17 @@
import type { ContextmenuHost } from './types'
import { isClosing } from '../lifecycle/instance'
import { getRect, setStyles } from '../utils'
export function positionContextmenu(art: ContextmenuHost, mouseX: number, mouseY: number): void {
const { $player, $contextmenu } = art.template
const { height: cHeight, width: cWidth, left: cLeft, top: cTop } = getRect($player)
const { height: mHeight, width: mWidth } = getRect($contextmenu)
let menuLeft = mouseX - cLeft
let menuTop = mouseY - cTop
if (mouseX + mWidth > cLeft + cWidth)
menuLeft = cWidth - mWidth
if (mouseY + mHeight > cTop + cHeight)
menuTop = cHeight - mHeight
if (!isClosing(art))
setStyles($contextmenu, { top: `${menuTop}px`, left: `${menuLeft}px` })
}
@@ -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()
@@ -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)
+5
View File
@@ -2,6 +2,11 @@ import type { Dictionary } from './publish'
import { publishLanguage } from './publish'
const ar = {
'Context Menu': 'قائمة السياق',
'Lock': 'قفل',
'Back': 'رجوع',
'Settings': 'الإعدادات',
'Progress': 'تقدم التشغيل',
'Video Info': 'معلومات الفيديو',
'Close': 'إغلاق',
'Video Load Failed': 'فشل تحميل الفيديو',
+5
View File
@@ -2,6 +2,11 @@ import type { Dictionary } from './publish'
import { publishLanguage } from './publish'
const cs = {
'Context Menu': 'Kontextová nabídka',
'Lock': 'Zamknout',
'Back': 'Zpět',
'Settings': 'Nastavení',
'Progress': 'Průběh přehrávání',
'Video Info': 'Info o videu',
'Close': 'Zavřít',
'Video Load Failed': 'Nahrání videa selhalo',
+5
View File
@@ -2,6 +2,11 @@ import type { Dictionary } from './publish'
import { publishLanguage } from './publish'
const es = {
'Context Menu': 'Menú contextual',
'Lock': 'Bloquear',
'Back': 'Volver',
'Settings': 'Configuración',
'Progress': 'Progreso de reproducción',
'Video Info': 'Información del video',
'Close': 'Cerrar',
'Video Load Failed': 'Falló carga de video',
+5
View File
@@ -2,6 +2,11 @@ import type { Dictionary } from './publish'
import { publishLanguage } from './publish'
const fa = {
'Context Menu': 'منوی زمینه',
'Lock': 'قفل',
'Back': 'بازگشت',
'Settings': 'تنظیمات',
'Progress': 'پیشرفت پخش',
'Video Info': 'اطلاعات ویدیو',
'Close': 'بستن',
'Video Load Failed': 'بارگذاری ناموفق',
+5
View File
@@ -2,6 +2,11 @@ import type { Dictionary } from './publish'
import { publishLanguage } from './publish'
const fr = {
'Context Menu': 'Menu contextuel',
'Lock': 'Verrouiller',
'Back': 'Retour',
'Settings': 'Paramètres',
'Progress': 'Progression de lecture',
'Video Info': 'Informations de la vidéo',
'Close': 'Fermer',
'Video Load Failed': 'Téléchargement de la vidéo échoué',
+5
View File
@@ -2,6 +2,11 @@ import type { Dictionary } from './publish'
import { publishLanguage } from './publish'
const id = {
'Context Menu': 'Menu konteks',
'Lock': 'Kunci',
'Back': 'Kembali',
'Settings': 'Pengaturan',
'Progress': 'Progres pemutaran',
'Video Info': 'Informasi Video',
'Close': 'Tutup',
'Video Load Failed': 'Gagal Memuat Video',
+5
View File
@@ -2,6 +2,11 @@ import type { Dictionary } from './publish'
import { publishLanguage } from './publish'
const pl = {
'Context Menu': 'Menu kontekstowe',
'Lock': 'Zablokuj',
'Back': 'Wstecz',
'Settings': 'Ustawienia',
'Progress': 'Postęp odtwarzania',
'Video Info': 'Informacje o wideo',
'Close': 'Zamknij',
'Video Load Failed': 'Błąd ładowania wideo',
+5
View File
@@ -2,6 +2,11 @@ import type { Dictionary } from './publish'
import { publishLanguage } from './publish'
const ru = {
'Context Menu': 'Контекстное меню',
'Lock': 'Заблокировать',
'Back': 'Назад',
'Settings': 'Настройки',
'Progress': 'Ход воспроизведения',
'Video Info': 'Информация',
'Close': 'Закрыть',
'Video Load Failed': 'Ошибка загрузки видео',
+5
View File
@@ -2,6 +2,11 @@ import type { Dictionary } from './publish'
import { publishLanguage } from './publish'
const tr = {
'Context Menu': 'Bağlam menüsü',
'Lock': 'Kilitle',
'Back': 'Geri',
'Settings': 'Ayarlar',
'Progress': 'Oynatma ilerlemesi',
'Video Info': 'Video Bilgisi',
'Close': 'Kapat',
'Video Load Failed': 'Video Yüklenemedi',
+5
View File
@@ -2,6 +2,11 @@ import type { Dictionary } from './publish'
import { publishLanguage } from './publish'
const vi = {
'Context Menu': 'Menu ngữ cảnh',
'Lock': 'Khóa',
'Back': 'Quay lại',
'Settings': 'Cài đặt',
'Progress': 'Tiến trình phát',
'Video Info': 'Thông tin video',
'Close': 'Đóng',
'Video Load Failed': 'Tải video thất bại',
+5
View File
@@ -2,6 +2,11 @@ import type { Dictionary } from './publish'
import { publishLanguage } from './publish'
const zhCn = {
'Context Menu': '快捷菜单',
'Lock': '锁定',
'Back': '返回',
'Settings': '设置',
'Progress': '播放进度',
'Video Info': '统计信息',
'Close': '关闭',
'Video Load Failed': '加载失败',
+5
View File
@@ -2,6 +2,11 @@ import type { Dictionary } from './publish'
import { publishLanguage } from './publish'
const zhTw = {
'Context Menu': '快捷選單',
'Lock': '鎖定',
'Back': '返回',
'Settings': '設定',
'Progress': '播放進度',
'Video Info': '統計訊息',
'Close': '關閉',
'Video Load Failed': '載入失敗',
+66
View File
@@ -0,0 +1,66 @@
import type ResourceScope from '../lifecycle/scope'
import type { InfoHost } from './poll'
import { keyboardButton } from '../accessibility/button'
import { claimKey, plainKey } from '../accessibility/keyboard'
import { resolveFocusOrigin } from '../accessibility/overlay-focus'
import { focusPlayer } from '../accessibility/player-focus'
import { isClosing } from '../lifecycle/instance'
import { listen } from '../lifecycle/resources'
const origins = new WeakMap<InfoHost, HTMLElement>()
export function infoKeyboard(art: InfoHost, scope: ResourceScope, close: () => void): void {
const { $player, $info, $infoClose } = art.template
if (!$info)
return
const active = () => !scope.closed && !isClosing(art)
const visible = () => $player.classList.contains('art-info-show')
const closeLabel = art.i18n.get('Close')
if (!active())
return
const groupLabel = art.i18n.get('Video Info')
if (!active())
return
$infoClose.setAttribute('aria-label', closeLabel)
$info.setAttribute('role', 'group')
$info.setAttribute('aria-label', groupLabel)
keyboardButton(scope, $infoClose, () => $infoClose.click(), visible)
listen(scope, $info, 'keydown', (input) => {
const event = input as KeyboardEvent
if (active() && visible() && plainKey(event) && !event.defaultPrevented && event.key === 'Escape') {
claimKey(event)
close()
}
})
const changed = (show: boolean) => {
if (!active())
return
const doc = $player.ownerDocument
const focused = doc.activeElement as HTMLElement | null
if (show) {
if (!$info.contains(focused)) {
origins.delete(art)
if (focused && $player.contains(focused)) {
origins.set(art, resolveFocusOrigin(focused))
if ($player.classList.contains('art-keyboard-focus'))
$infoClose.focus({ preventScroll: true })
}
}
}
else {
const origin = origins.get(art)
origins.delete(art)
if (!$info.contains(focused))
return
const visibility = origin && doc.defaultView?.getComputedStyle(origin).visibility
if (origin?.isConnected && $player.contains(origin) && origin.getClientRects().length && visibility !== 'hidden' && visibility !== 'collapse' && !origin.matches(':disabled') && !origin.closest('[inert]') && origin.getAttribute('aria-disabled') !== 'true')
origin.focus({ preventScroll: true })
else
focusPlayer(art)
}
}
art.on('info', changed)
scope.add(() => {
art.off('info', changed)
})
}
+5 -2
View File
@@ -3,9 +3,11 @@ import type { ComponentHost } from '../component/types'
import type ResourceScope from '../lifecycle/scope'
import { isClosing } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
import { infoKeyboard } from './keyboard'
export interface InfoHost extends ComponentHost, SubscriptionHost<{ destroy: [] }> {
template: { $player: HTMLElement, $infoPanel: HTMLElement, $infoClose: HTMLElement, $video: object }
export interface InfoHost extends ComponentHost, SubscriptionHost<{ destroy: [], info: [boolean] }> {
template: { $player: HTMLElement, $info: HTMLElement | null, $infoPanel: HTMLElement, $infoClose: HTMLElement, $video: object }
i18n: { get: (key: string) => string }
constructor: { INFO_LOOP_TIME: number }
proxy: ComponentHost['events']['proxy']
}
@@ -36,6 +38,7 @@ export function pollInfo(art: InfoHost, scope: ResourceScope, close: () => void)
scope.add(() => {
art.off('destroy', destroy)
})
infoKeyboard(art, scope, close)
const loop = () => {
try {
for (const item of items) {
+10 -1
View File
@@ -1,3 +1,5 @@
import { isClaimedKey } from '../accessibility/keyboard'
function asElement(target: EventTarget | null | undefined): HTMLElement | undefined {
if (target && (target as Node).nodeType === 1)
return target as HTMLElement
@@ -16,7 +18,7 @@ function editable(target: HTMLElement): boolean {
}
export function acceptsHotkey(event: KeyboardEvent, fallback: Document): boolean {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.isComposing || event.keyCode === 229)
if (isClaimedKey(event) || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.isComposing || event.keyCode === 229)
return false
const doc = eventDocument(event, fallback)
let active = asElement(doc.activeElement)
@@ -26,5 +28,12 @@ export function acceptsHotkey(event: KeyboardEvent, fallback: Document): boolean
return false
// The composed path exposes an editable descendant behind a retargeted shadow host.
const first = asElement(event.composedPath?.()[0]) || asElement(event.target)
const enter = event.key === 'Enter' || event.code === 'Enter' || event.code === 'NumpadEnter'
const activation = enter || event.key === ' ' || event.code === 'Space'
if (activation) {
const selector = `button,summary,[role="button"],[role="switch"]${enter ? ',a[href]' : ''}`
if (first?.closest?.(selector) || active?.closest?.(selector))
return false
}
return !first || !editable(first)
}
@@ -0,0 +1,44 @@
import type ResourceScope from '../lifecycle/scope'
import type { LockHost } from './lock'
import { keyboardButton } from '../accessibility/button'
import { claimKey, plainKey } from '../accessibility/keyboard'
import { suspendFocus } from '../accessibility/suspend-focus'
import { isClosing } from '../lifecycle/instance'
import { listen } from '../lifecycle/resources'
export function lockKeyboard(art: LockHost, element: HTMLElement, scope: ResourceScope, locked: () => boolean): (state: boolean) => void {
let suspension: ResourceScope | undefined
const active = () => !scope.closed && !isClosing(art)
const label = art.i18n.get('Lock')
if (!active())
return () => {}
element.setAttribute('aria-label', label)
keyboardButton(scope, element)
listen(scope, element, 'keydown', (input) => {
const event = input as KeyboardEvent
if (active() && locked() && plainKey(event) && !event.defaultPrevented && event.key === 'Escape') {
claimKey(event)
element.click()
}
})
const update = (state: boolean) => {
if (!active())
return
element.setAttribute('aria-pressed', String(state))
if (state && !suspension) {
const { $player, $bottom } = art.template
const hiddenFocus = $bottom.contains($bottom.ownerDocument.activeElement) && $player.classList.contains('art-keyboard-focus')
suspension = scope.child()
suspendFocus(suspension, $bottom)
if (active() && hiddenFocus)
element.focus({ preventScroll: true })
}
else if (!state && suspension) {
const previous = suspension
suspension = undefined
previous.dispose()
}
}
update(locked())
return update
}
+16 -2
View File
@@ -3,9 +3,11 @@ import { appendElement } from '../component/dom'
import { entryScope, subscribeEntry } from '../component/resources'
import { isClosing } from '../lifecycle/instance'
import { addClass, hasClass, removeClass, setStyle } from '../utils'
import { lockKeyboard } from './lock-keyboard'
export interface LockHost extends SubscriptionHost<{ lock: [boolean] }> {
template: { $player: HTMLElement }
template: { $player: HTMLElement, $bottom: HTMLElement }
i18n: { get: (key: string) => string }
icons: { lock: string | Element, unlock: string | Element }
isLock: boolean
emit: (name: 'lock', state: boolean) => unknown
@@ -57,7 +59,17 @@ export default function lock(art: LockHost): { name: string, state: boolean } {
const $unlock = appendElement($el, icons.unlock)
if (scope.closed || isClosing(art))
return
setStyle($lock, 'display', 'none')
const updateKeyboard = lockKeyboard(art, $el, scope, getState)
if (scope.closed || isClosing(art))
return
if (getState()) {
setStyle($lock, 'display', 'inline-flex')
if (!scope.closed && !isClosing(art))
setStyle($unlock, 'display', 'none')
}
else {
setStyle($lock, 'display', 'none')
}
subscribeEntry<{ lock: [boolean] }, 'lock'>(art, $el, 'lock', (state) => {
if (isClosing(art))
@@ -65,6 +77,8 @@ export default function lock(art: LockHost): { name: string, state: boolean } {
setStyle($lock, 'display', state ? 'inline-flex' : 'none')
if (!scope.closed && !isClosing(art))
setStyle($unlock, 'display', state ? 'none' : 'inline-flex')
if (!scope.closed && !isClosing(art))
updateKeyboard(state)
})
},
click() {
+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) {
@@ -13,11 +13,13 @@ export function positionRestoration(art: Pick<SwitchHost, 'currentTime' | 'templ
const manualPosition = () => manual || positionRevision(art) !== revision
const seeking = () => !!art.template?.$video?.seeking
return {
restore(): void {
restore(write = () => {
art.currentTime = target
}): void {
if (!active() || manualPosition())
return
const previousRevision = positionRevision(art)
art.currentTime = target
write()
revision = positionRevision(art)
// Nested public writes supersede the restoration's own setter call.
if (revision > previousRevision + 1)
@@ -0,0 +1,22 @@
.art-video-player {
[tabindex]:focus-visible,
input:focus-visible,
button:focus-visible,
a:focus-visible {
outline: 2px solid currentColor;
outline-offset: -2px;
}
&.art-keyboard-focus :focus {
outline: 2px solid currentColor;
outline-offset: -2px;
}
&.art-keyboard-focus:not(.art-lock) .art-bottom {
opacity: 1;
.art-controls,
.art-progress {
transform: translateY(0);
}
}
}
+1
View File
@@ -28,3 +28,4 @@
@import './selector';
@import './hint';
@import './mobile';
@import './accessibility';
+7 -1
View File
@@ -56,10 +56,16 @@
opacity: 0.9;
}
&:hover {
&:hover,
&:focus-within {
.art-mini-close,
.art-mini-state {
opacity: 1;
}
}
[role='button']:focus-visible {
outline: 2px solid #fff;
outline-offset: -2px;
}
}
+14
View File
@@ -41,6 +41,20 @@
}
&.art-lock {
.art-layer-lock {
display: flex;
}
&:not(.art-control-show) .art-layer-lock {
opacity: 0;
pointer-events: none;
&:focus-within {
opacity: 1;
pointer-events: auto;
}
}
.art-subtitle {
bottom: var(--art-subtitle-bottom) !important;
}
+5 -2
View File
@@ -13,12 +13,13 @@
overflow-y: auto;
overflow-x: hidden;
opacity: 0;
visibility: hidden;
transform: translateY(10px);
pointer-events: none;
bottom: var(--art-control-height);
max-height: var(--art-selector-max-height);
background-color: var(--art-widget-background);
transition: all var(--art-transition-duration) ease;
transition: opacity var(--art-transition-duration) ease, transform var(--art-transition-duration) ease;
.art-selector-item {
.art-center();
@@ -38,9 +39,11 @@
}
}
&:hover {
&:hover:not(.art-selector-dismissed),
&.art-selector-open {
.art-selector-list {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
+5 -2
View File
@@ -11,12 +11,13 @@
text-align: center;
cursor: default;
opacity: 0;
visibility: hidden;
transform: translateY(10px);
pointer-events: none;
bottom: var(--art-control-height);
width: var(--art-control-height);
height: var(--art-volume-height);
transition: all var(--art-transition-duration) ease;
transition: opacity var(--art-transition-duration) ease, transform var(--art-transition-duration) ease;
.art-volume-inner {
display: flex;
@@ -73,9 +74,11 @@
}
}
&:hover {
&:hover,
&:focus-within {
.art-volume-panel {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
+19 -6
View File
@@ -1,6 +1,8 @@
import type { ComponentHost, Entry, EntryInput, EntryOption, EventCleanup } from '../component/types'
import validator from 'option-validator'
import { keyboardButton } from '../accessibility/button'
import { renderEntry } from '../component/dom'
import { captureComponentFocus } from '../component/focus'
import { ownEntry, releaseEntry } from '../component/resources'
import { isClosing } from '../lifecycle/instance'
import { ComponentOption } from '../scheme'
@@ -74,6 +76,9 @@ export default class Component<Host extends ComponentHost = ComponentHost> {
if (scope.closed)
return
if (option.click) {
const actionable = (this.name === 'control' && !option.selector) || (this.name === 'contextmenu' && !$ref.querySelector('[data-value]'))
if (actionable && !$ref.querySelector('button,input,select,textarea,a[href],[tabindex],[contenteditable]'))
keyboardButton(scope, $ref)
const cleanup = this.art.events.proxy($ref, 'click', (event) => {
if (scope.closed)
return
@@ -118,6 +123,7 @@ export default class Component<Host extends ComponentHost = ComponentHost> {
const item = this.cache.get(name)!
if (removing.has(item))
return
const restoreFocus = captureComponentFocus(this, item.$ref)
removing.add(item)
try {
if (item.option.beforeUnmount)
@@ -135,17 +141,24 @@ export default class Component<Host extends ComponentHost = ComponentHost> {
}
finally {
removing.delete(item)
restoreFocus()
}
}
update(option: EntryOption<Host>): ReturnType<this['add']> {
if (this.cache.has(option.name!)) {
const item = this.cache.get(option.name!)!
option = Object.assign(item.option, option)
this.remove(option.name!)
const restoreFocus = captureComponentFocus(this, this.cache.get(option.name!)?.$ref, option.name)
try {
if (this.cache.has(option.name!)) {
const item = this.cache.get(option.name!)!
option = Object.assign(item.option, option)
this.remove(option.name!)
}
// add is polymorphic: Control intentionally returns undefined.
return this.add(option) as ReturnType<this['add']>
}
finally {
restoreFocus()
}
// add is polymorphic: Control intentionally returns undefined.
return this.add(option) as ReturnType<this['add']>
}
}