mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-26 17:01:20 -08:00
refactor(core): [CORE-23] complete keyboard controls and focus lifecycle
This commit is contained in:
1 parent
faaa3e8c2d
commit
8ed0a5228c
190 files changed
+53981
-489
No files matched your search
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'))
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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': 'فشل تحميل الفيديو',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'بارگذاری ناموفق',
|
||||
|
||||
@@ -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é',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Ошибка загрузки видео',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '加载失败',
|
||||
|
||||
@@ -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': '載入失敗',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -6,6 +6,8 @@ import Component from '../utils/component'
|
||||
import aspectRatio from './aspectRatio'
|
||||
import { installSettingEvents } from './events'
|
||||
import flip from './flip'
|
||||
import { installSettingKeyboard } from './keyboard'
|
||||
import { captureSettingFocus } from './keyboard-focus'
|
||||
import { resizeSetting } from './layout'
|
||||
import { findItem, formatTree, registerTreeOwner, releaseTreeOwner, traverseTree } from './model'
|
||||
import playbackRate from './playbackRate'
|
||||
@@ -53,6 +55,7 @@ export default class Setting extends SettingBase implements SettingManager {
|
||||
|
||||
installSettingEvents(this)
|
||||
}
|
||||
installSettingKeyboard(this)
|
||||
}
|
||||
|
||||
get builtin(): SettingItem[] {
|
||||
@@ -103,6 +106,7 @@ export default class Setting extends SettingBase implements SettingManager {
|
||||
}
|
||||
|
||||
remove(name: string): void {
|
||||
const restoreFocus = captureSettingFocus(this)
|
||||
const item = this.find(name)!
|
||||
errorHandle(item, `Can't find [${name}] in the [setting]`)
|
||||
this.traverse((item) => {
|
||||
@@ -126,6 +130,7 @@ export default class Setting extends SettingBase implements SettingManager {
|
||||
}
|
||||
if (failures.length)
|
||||
throw new ResourceCleanupError(failures)
|
||||
restoreFocus()
|
||||
}
|
||||
|
||||
update(target: SettingItem): SettingItem {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { SettingManager } from './types'
|
||||
import { focusPlayer } from '../accessibility/player-focus'
|
||||
import { isClosing } from '../lifecycle/instance'
|
||||
import { settingScopeActive } from './activity'
|
||||
import { settingPanelScope } from './panels'
|
||||
|
||||
function available(element: HTMLElement): boolean {
|
||||
const visibility = element.ownerDocument.defaultView?.getComputedStyle(element).visibility
|
||||
return element.isConnected && !element.matches(':disabled') && !element.closest('[inert]') && element.getAttribute('aria-disabled') !== 'true' && element.getClientRects().length > 0 && visibility !== 'hidden' && visibility !== 'collapse'
|
||||
}
|
||||
|
||||
export function settingFocusTargets(panel: HTMLElement): HTMLElement[] {
|
||||
return Array.from(panel.querySelectorAll<HTMLElement>('[tabindex="0"],button,input,select,textarea,a[href],[contenteditable]'))
|
||||
.filter(element => element.getAttribute('tabindex') !== '-1' && available(element))
|
||||
}
|
||||
|
||||
export function returnSettingFocus(setting: SettingManager): void {
|
||||
const { art, $parent } = setting
|
||||
const player = art.template.$player
|
||||
const doc = $parent.ownerDocument
|
||||
const button = art.controls.setting
|
||||
if (isClosing(art) || !$parent.contains(doc.activeElement))
|
||||
return
|
||||
if (button && player.contains(button) && available(button))
|
||||
button.focus({ preventScroll: true })
|
||||
if (doc.activeElement === doc.body || $parent.contains(doc.activeElement))
|
||||
focusPlayer(art)
|
||||
}
|
||||
|
||||
export function focusSettingPanel(setting: SettingManager, preferred?: HTMLElement, last = false): void {
|
||||
if (!setting.show || isClosing(setting.art) || !setting.active)
|
||||
return
|
||||
const panel = setting.cache.get(setting.active)
|
||||
if (!panel || !settingScopeActive(settingPanelScope(panel)))
|
||||
return
|
||||
const targets = settingFocusTargets(panel)
|
||||
const selected = panel.querySelector('.art-setting-item.art-current')
|
||||
const target = targets.find(element => preferred === element || preferred?.contains(element))
|
||||
|| (last ? targets[targets.length - 1] : targets.find(element => selected?.contains(element)) || targets.find(element => !element.classList.contains('art-setting-item-back')) || targets[0])
|
||||
target?.focus({ preventScroll: true })
|
||||
if (!isClosing(setting.art))
|
||||
target?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
}
|
||||
|
||||
export function captureSettingFocus(setting: SettingManager): (preferred?: HTMLElement) => void {
|
||||
const doc = setting.$parent.ownerDocument
|
||||
const focused = doc.activeElement as HTMLElement | null
|
||||
const inside = setting.$parent.contains(focused)
|
||||
return (preferred) => {
|
||||
if (!inside || isClosing(setting.art))
|
||||
return
|
||||
if (doc.activeElement === doc.body || (doc.activeElement === focused && !focused?.getClientRects().length))
|
||||
focusSettingPanel(setting, preferred)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type ResourceScope from '../lifecycle/scope'
|
||||
import type { SettingKind } from './selection'
|
||||
import type { SettingItem, SettingManager } from './types'
|
||||
import { keyboardButton } from '../accessibility/button'
|
||||
import { isClosing } from '../lifecycle/instance'
|
||||
import { settingScopeActive } from './activity'
|
||||
|
||||
export function nameSettingRange(item: SettingItem): void {
|
||||
const input = item.$item?.querySelector<HTMLInputElement>('.art-setting-range')
|
||||
if (input)
|
||||
input.setAttribute('aria-label', item.$html?.textContent || '')
|
||||
}
|
||||
|
||||
export function settingItemKeyboard(setting: SettingManager, item: SettingItem, element: HTMLDivElement, kind: SettingKind, scope: ResourceScope): void {
|
||||
if (kind === 'range') {
|
||||
nameSettingRange(item)
|
||||
return
|
||||
}
|
||||
if (element.querySelector('button,input,select,textarea,a[href],[tabindex],[contenteditable]'))
|
||||
return
|
||||
if (kind === 'switch') {
|
||||
element.setAttribute('role', 'switch')
|
||||
element.setAttribute('aria-checked', String(Boolean(item.switch)))
|
||||
element.setAttribute('aria-disabled', String(!item.onSwitch))
|
||||
}
|
||||
else if (kind === 'selector' && !item.selector?.length) {
|
||||
element.setAttribute('aria-current', String(Boolean(item.default)))
|
||||
}
|
||||
keyboardButton(scope, element, () => element.click(), () => settingScopeActive(scope) && !isClosing(setting.art))
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { SettingManager } from './types'
|
||||
import { claimKey, plainKey } from '../accessibility/keyboard'
|
||||
import { getScope, isClosing } from '../lifecycle/instance'
|
||||
import { listen, timeout } from '../lifecycle/resources'
|
||||
import { settingScopeActive } from './activity'
|
||||
import { focusSettingPanel, returnSettingFocus, settingFocusTargets } from './keyboard-focus'
|
||||
import { settingPanelScope } from './panels'
|
||||
import { settingScope } from './resources'
|
||||
|
||||
export function installSettingKeyboard(setting: SettingManager): void {
|
||||
const { art } = setting
|
||||
const scope = getScope(art).child()
|
||||
if (scope.closed)
|
||||
return
|
||||
const doc = () => setting.$parent.ownerDocument
|
||||
let enterLast = false
|
||||
const close = () => {
|
||||
setting.show = false
|
||||
if (!scope.closed && !isClosing(art))
|
||||
setting.render()
|
||||
}
|
||||
const changed = (show: boolean) => {
|
||||
if (scope.closed || isClosing(art))
|
||||
return
|
||||
if (show) {
|
||||
if (doc().activeElement === art.controls.setting && art.template.$player.classList.contains('art-keyboard-focus'))
|
||||
focusSettingPanel(setting, undefined, enterLast)
|
||||
}
|
||||
else if (setting.$parent.contains(doc().activeElement)) {
|
||||
returnSettingFocus(setting)
|
||||
}
|
||||
}
|
||||
art.on('setting', changed)
|
||||
scope.add(() => {
|
||||
art.off('setting', changed)
|
||||
})
|
||||
listen(scope, art.template.$player, 'keydown', (input) => {
|
||||
const event = input as KeyboardEvent
|
||||
if (!plainKey(event) || event.defaultPrevented || isClosing(art))
|
||||
return
|
||||
const target = event.target as HTMLElement
|
||||
if (target === art.controls.setting) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
claimKey(event)
|
||||
if (!setting.show) {
|
||||
enterLast = event.key === 'ArrowUp'
|
||||
try {
|
||||
setting.show = true
|
||||
}
|
||||
finally {
|
||||
enterLast = false
|
||||
}
|
||||
}
|
||||
else {
|
||||
focusSettingPanel(setting, undefined, event.key === 'ArrowUp')
|
||||
}
|
||||
}
|
||||
else if (event.key === 'Escape' && setting.show) {
|
||||
claimKey(event)
|
||||
close()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!setting.show || !setting.$parent.contains(target))
|
||||
return
|
||||
if (event.key === 'Escape') {
|
||||
claimKey(event)
|
||||
close()
|
||||
return
|
||||
}
|
||||
const row = target.closest<HTMLElement>('.art-setting-item')
|
||||
if (!row || (target !== row && !target.matches('button,a[href],[role="button"]')))
|
||||
return
|
||||
const panel = setting.active && setting.cache.get(setting.active)
|
||||
if (!panel)
|
||||
return
|
||||
const currentItem = setting.active?.find(item => item.$item === row)
|
||||
if (!settingScopeActive(settingPanelScope(panel)) || (currentItem && !settingScopeActive(settingScope(currentItem)))) {
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key))
|
||||
claimKey(event)
|
||||
return
|
||||
}
|
||||
const targets = settingFocusTargets(panel)
|
||||
const index = targets.indexOf(target)
|
||||
if (index < 0)
|
||||
return
|
||||
let next: number | undefined
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
next = Math.min(index + 1, targets.length - 1)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
next = Math.max(index - 1, 0)
|
||||
break
|
||||
case 'Home':
|
||||
next = 0
|
||||
break
|
||||
case 'End':
|
||||
next = targets.length - 1
|
||||
break
|
||||
case 'ArrowLeft': {
|
||||
const back = panel.querySelector<HTMLElement>('.art-setting-item-back')
|
||||
claimKey(event)
|
||||
back?.click()
|
||||
return
|
||||
}
|
||||
case 'ArrowRight': {
|
||||
claimKey(event)
|
||||
if (currentItem?.selector?.length)
|
||||
target.click()
|
||||
return
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
claimKey(event)
|
||||
if (next !== undefined)
|
||||
focusSettingPanel(setting, targets[next])
|
||||
})
|
||||
let pending = false
|
||||
listen(scope, setting.$parent, 'focusout', (event) => {
|
||||
const next = (event as FocusEvent).relatedTarget as Node | null
|
||||
if (next && (setting.$parent.contains(next) || next === art.controls.setting))
|
||||
return
|
||||
if (pending)
|
||||
return
|
||||
pending = true
|
||||
timeout(scope, () => {
|
||||
pending = false
|
||||
if (!scope.closed && !isClosing(art) && setting.show && !setting.$parent.contains(doc().activeElement) && doc().activeElement !== art.controls.setting)
|
||||
close()
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { SettingKind } from './selection'
|
||||
import type { SettingItem, SettingManager, SettingRange } from './types'
|
||||
import { keyboardButton } from '../accessibility/button'
|
||||
import { appendElement } from '../component/dom'
|
||||
import { isClosing } from '../lifecycle/instance'
|
||||
import { timeout } from '../lifecycle/resources'
|
||||
import { addClass, append, def, has, inverseClass, setStyle } from '../utils'
|
||||
import { settingScopeActive } from './activity'
|
||||
import { captureSettingItem, rememberSettingState } from './checkpoint'
|
||||
import { captureSettingFocus } from './keyboard-focus'
|
||||
import { nameSettingRange, settingItemKeyboard } from './keyboard-item'
|
||||
import { ownSettingPanel, releaseSettingPanel, settingPanelScope } from './panels'
|
||||
import { ownSettingItem, proxySetting, settingItemOwner } from './resources'
|
||||
import { bindSettingActions } from './selection'
|
||||
@@ -21,6 +24,8 @@ function bindContent(item: SettingItem, key: 'icon' | 'html' | 'tooltip', elemen
|
||||
set(value: unknown) {
|
||||
element.innerHTML = ''
|
||||
append(element, value)
|
||||
if (key === 'html')
|
||||
nameSettingRange(item)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -46,6 +51,10 @@ export function createSettingHeader(setting: SettingManager, item: SettingItem):
|
||||
append($left, $icon)
|
||||
append($left, item.$parent!.html)
|
||||
proxySetting(setting.art, item.$parent!, $item, 'click', () => setting.render(item.$parents), settingPanelScope($panel))
|
||||
const scope = settingPanelScope($panel)
|
||||
if (!$item.querySelector('button,input,select,textarea,a[href],[tabindex],[contenteditable]'))
|
||||
keyboardButton(scope, $item, () => $item.click(), () => settingScopeActive(scope) && !isClosing(setting.art))
|
||||
$item.setAttribute('aria-label', `${setting.art.i18n.get('Back')}: ${$item.textContent?.trim() || ''}`)
|
||||
append($panel, $item)
|
||||
}
|
||||
|
||||
@@ -177,6 +186,7 @@ export function createSettingItem(setting: SettingManager, item: SettingItem, is
|
||||
get: () => $switchValue,
|
||||
set(value) {
|
||||
$switchValue = value
|
||||
$item.setAttribute('aria-checked', String(Boolean(value)))
|
||||
if (value) {
|
||||
setStyle($switchOff, 'display', 'none')
|
||||
setStyle($switchOn, 'display', null)
|
||||
@@ -258,6 +268,7 @@ export function createSettingItem(setting: SettingManager, item: SettingItem, is
|
||||
configurable: true,
|
||||
get: () => $item,
|
||||
})
|
||||
settingItemKeyboard(setting, item, $item, type, scope)
|
||||
|
||||
if (isUpdate) {
|
||||
if (oldItem?.parentNode)
|
||||
@@ -298,6 +309,7 @@ export function createSettingItem(setting: SettingManager, item: SettingItem, is
|
||||
export function renderSetting(setting: SettingManager, option = setting.option): void {
|
||||
if (isClosing(setting.art))
|
||||
return
|
||||
const restoreFocus = captureSettingFocus(setting)
|
||||
const cached = setting.cache.get(option)
|
||||
if (cached && !settingScopeActive(settingPanelScope(cached)))
|
||||
return
|
||||
@@ -335,6 +347,8 @@ export function renderSetting(setting: SettingManager, option = setting.option):
|
||||
if (!current())
|
||||
return
|
||||
addClass($panel, 'art-setting-panel')
|
||||
$panel.setAttribute('role', 'group')
|
||||
$panel.setAttribute('aria-label', option[0]?.$parent?.$html?.textContent || setting.art.i18n.get('Settings'))
|
||||
append(setting.$parent, $panel)
|
||||
inverseClass($panel, 'art-current')
|
||||
|
||||
@@ -376,4 +390,6 @@ export function renderSetting(setting: SettingManager, option = setting.option):
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (currentNavigation())
|
||||
restoreFocus(previous?.[0]?.$parent?.$item)
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export function checkSetting(setting: SettingManager, target?: SettingItem | nul
|
||||
target.$parent.tooltip = target.html
|
||||
setting.traverse((item) => {
|
||||
item.default = item === target
|
||||
if (item.$item)
|
||||
item.$item.setAttribute('aria-current', String(item.default))
|
||||
if (item.default && item.$item)
|
||||
inverseClass(item.$item, 'art-current')
|
||||
}, target.$option)
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface SettingItem extends TreeItem {
|
||||
}
|
||||
|
||||
export interface SettingEvents {
|
||||
'setting': [boolean]
|
||||
'blur': [Event]
|
||||
'focus': [event: Event]
|
||||
'resize': []
|
||||
|
||||
@@ -5,6 +5,7 @@ import captureTemplate from '../lifecycle/template-rollback'
|
||||
import { inverseClass } from '../utils/dom'
|
||||
import { pauseSettingScope } from './activity'
|
||||
import { captureSettingItem } from './checkpoint'
|
||||
import { captureSettingFocus } from './keyboard-focus'
|
||||
import { releaseSettingPanel, settingPanelScope } from './panels'
|
||||
import { cancelSettingAdd } from './registration'
|
||||
import { suspendSettingItem } from './resources'
|
||||
@@ -112,6 +113,7 @@ export function updateSetting(setting: SettingManager, target: SettingItem): Set
|
||||
return item || target
|
||||
if (!item)
|
||||
return setting.add(target)
|
||||
const restoreFocus = captureSettingFocus(setting)
|
||||
cancelSettingAdd(item)
|
||||
cancelSettingUpdate(item)
|
||||
const restoreItem = captureSettingItem(item)
|
||||
@@ -161,6 +163,7 @@ export function updateSetting(setting: SettingManager, target: SettingItem): Set
|
||||
}
|
||||
},
|
||||
])
|
||||
restoreFocus(element)
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('ArtPlayer setting restore failed:', error)
|
||||
@@ -197,6 +200,7 @@ export function updateSetting(setting: SettingManager, target: SettingItem): Set
|
||||
pending.delete(affected)
|
||||
}
|
||||
tree.dispose()
|
||||
restoreFocus(item.$item)
|
||||
return item
|
||||
}
|
||||
catch (error) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,3 +28,4 @@
|
||||
@import './selector';
|
||||
@import './hint';
|
||||
@import './mobile';
|
||||
@import './accessibility';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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']>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user