mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-25 08: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
@@ -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` })
|
||||
}
|
||||
Reference in new issue
Block a user