refactor(core): [CORE-23] complete keyboard controls and focus lifecycle

This commit is contained in:
Harvey Zhao committed 2026-09-11 21:06:33 +08:00
1 parent faaa3e8c2d
commit 8ed0a5228c
190 files changed
+53981 -489

No files matched your search

@@ -1,6 +1,7 @@
import type { ContextmenuFactory, ContextmenuOption } from './types'
import { controlEvents } from '../control/resources'
import { inverseClass, query } from '../utils'
import { keyboardChoices, updateChoices } from './choices'
export default function aspectRatio(option: ContextmenuOption): ContextmenuFactory {
return (art) => {
@@ -12,10 +13,11 @@ export default function aspectRatio(option: ContextmenuOption): ContextmenuFacto
const html = ASPECT_RATIO.map(
item => `<span data-value="${item}">${item === 'default' ? i18n.get('Default') : item}</span>`,
).join('')
const label = i18n.get('Aspect Ratio')
return {
...option,
html: `${i18n.get('Aspect Ratio')}: ${html}`,
html: `${label}: ${html}`,
click: (contextmenu, event) => {
const value = event.target instanceof HTMLElement ? event.target.dataset.value : undefined
if (value) {
@@ -29,10 +31,12 @@ export default function aspectRatio(option: ContextmenuOption): ContextmenuFacto
if ($default) {
inverseClass($default, 'art-current')
}
keyboardChoices($panel, label)
on('aspectRatio', (value) => {
const $current = Array.from($panel.querySelectorAll('span')).find(item => item.dataset.value === value)
if ($current) {
inverseClass($current, 'art-current')
updateChoices($panel)
}
})
},
@@ -0,0 +1,20 @@
import { keyboardButton } from '../accessibility/button'
import { entryScope } from '../component/resources'
export function updateChoices(panel: HTMLElement): void {
for (const item of panel.querySelectorAll<HTMLElement>('[data-value]'))
item.setAttribute('aria-pressed', String(item.classList.contains('art-current')))
}
export function keyboardChoices(panel: HTMLElement, label: string): void {
const scope = entryScope(panel)
if (scope.closed)
return
panel.setAttribute('role', 'group')
panel.setAttribute('aria-label', label)
for (const item of panel.querySelectorAll<HTMLElement>('[data-value]')) {
keyboardButton(scope, item)
item.setAttribute('aria-label', `${label}: ${item.textContent?.trim() ?? ''}`)
}
updateChoices(panel)
}
+5 -1
View File
@@ -1,6 +1,7 @@
import type { ContextmenuFactory, ContextmenuOption } from './types'
import { controlEvents } from '../control/resources'
import { capitalize, inverseClass, query } from '../utils'
import { keyboardChoices, updateChoices } from './choices'
export default function flip(option: ContextmenuOption): ContextmenuFactory {
return (art) => {
@@ -10,10 +11,11 @@ export default function flip(option: ContextmenuOption): ContextmenuFactory {
} = art
const html = FLIP.map(item => `<span data-value="${item}">${i18n.get(capitalize(item))}</span>`).join('')
const label = i18n.get('Video Flip')
return {
...option,
html: `${i18n.get('Video Flip')}: ${html}`,
html: `${label}: ${html}`,
click: (contextmenu, event) => {
const value = event.target instanceof HTMLElement ? event.target.dataset.value : undefined
if (value) {
@@ -27,10 +29,12 @@ export default function flip(option: ContextmenuOption): ContextmenuFactory {
if ($default) {
inverseClass($default, 'art-current')
}
keyboardChoices($panel, label)
on('flip', (value) => {
const $current = Array.from($panel.querySelectorAll('span')).find(item => item.dataset.value === value)
if ($current) {
inverseClass($current, 'art-current')
updateChoices($panel)
}
})
},
+18 -25
View File
@@ -1,13 +1,16 @@
import type { ContextmenuHost } from './types'
import { ownEntry } from '../component/resources'
import { ownEntry, releaseEntry } from '../component/resources'
import { controlEvents } from '../control/resources'
import { getRect, includeFromEvent, isMobile, setStyles } from '../utils'
import { isClosing } from '../lifecycle/instance'
import { includeFromEvent, isMobile } from '../utils'
import Component from '../utils/component'
import aspectRatio from './aspectRatio'
import close from './close'
import flip from './flip'
import info from './info'
import { contextmenuKeyboard } from './keyboard'
import playbackRate from './playbackRate'
import { positionContextmenu } from './position'
import version from './version'
export default class Contextmenu extends Component<ContextmenuHost> {
@@ -82,32 +85,22 @@ export default class Contextmenu extends Component<ContextmenuHost> {
this.add(option.contextmenu[index]!)
}
if (isClosing(this.art))
return
releaseEntry($contextmenu)
const scope = ownEntry(this.art, $contextmenu)
const open = (x: number, y: number) => {
this.show = true
if (!scope.closed && !isClosing(this.art) && this.show)
positionContextmenu(this.art, x, y)
}
const keyboard = contextmenuKeyboard(this, open)
proxy($player, 'contextmenu', (event) => {
if (!this.art.constructor.CONTEXTMENU)
if (!keyboard.pointer(event))
return
event.preventDefault()
this.show = true
const mouseX = event.clientX
const mouseY = event.clientY
const { height: cHeight, width: cWidth, left: cLeft, top: cTop } = getRect($player)
const { height: mHeight, width: mWidth } = getRect($contextmenu)
let menuLeft = mouseX - cLeft
let menuTop = mouseY - cTop
if (mouseX + mWidth > cLeft + cWidth) {
menuLeft = cWidth - mWidth
}
if (mouseY + mHeight > cTop + cHeight) {
menuTop = cHeight - mHeight
}
setStyles($contextmenu, {
top: `${menuTop}px`,
left: `${menuLeft}px`,
})
open(event.clientX, event.clientY)
})
proxy($player, 'click', (event) => {
@@ -0,0 +1,144 @@
import type Contextmenu from './index'
import { claimKey, plainKey } from '../accessibility/keyboard'
import { resolveFocusOrigin, setOverlayOrigin } from '../accessibility/overlay-focus'
import { focusPlayer } from '../accessibility/player-focus'
import { entryScope } from '../component/resources'
import { isClosing } from '../lifecycle/instance'
import { listen, timeout } from '../lifecycle/resources'
function editable(target: EventTarget | null): boolean {
const element = target as HTMLElement | null
return !!element && (['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName) || element.isContentEditable)
}
function available(element: HTMLElement): boolean {
const visibility = element.ownerDocument.defaultView?.getComputedStyle(element).visibility
return element.isConnected && !element.matches(':disabled') && !element.closest('[inert]') && element.getAttribute('aria-disabled') !== 'true' && element.getClientRects().length > 0 && visibility !== 'hidden' && visibility !== 'collapse'
}
export function contextmenuKeyboard(menu: Contextmenu, open: (x: number, y: number) => void) {
const { art } = menu
const { $player, $contextmenu } = art.template
const scope = entryScope($contextmenu)
const active = () => !scope.closed && !isClosing(art)
let origin: HTMLElement | undefined
let hadFocus = false
let prefix = ''
let typedAt = 0
const capture = () => {
const focused = $player.ownerDocument.activeElement as HTMLElement | null
origin = focused && $player.contains(focused) ? resolveFocusOrigin(focused) : $player
setOverlayOrigin($contextmenu, origin)
}
const targets = () => Array.from($contextmenu.querySelectorAll<HTMLElement>('[tabindex],button,input,select,textarea,a[href],[contenteditable]'))
.filter(element => element.tabIndex >= 0 && available(element))
const label = art.i18n.get('Context Menu')
if (!active())
return { pointer: () => false }
$contextmenu.setAttribute('role', 'group')
$contextmenu.setAttribute('aria-label', label)
const changed = (show: boolean) => {
if (!active())
return
if (show) {
if (!$contextmenu.contains($player.ownerDocument.activeElement))
capture()
return
}
const focused = $player.ownerDocument.activeElement
const restore = $contextmenu.contains(focused) || (hadFocus && focused === $player.ownerDocument.body)
const previous = origin
origin = undefined
hadFocus = false
prefix = ''
setOverlayOrigin($contextmenu)
if (!restore)
return
if (previous && $player.contains(previous) && available(previous))
previous.focus({ preventScroll: true })
if ($player.ownerDocument.activeElement === $player.ownerDocument.body || $contextmenu.contains($player.ownerDocument.activeElement))
focusPlayer(art)
}
art.on('contextmenu', changed)
scope.add(() => {
art.off('contextmenu', changed)
setOverlayOrigin($contextmenu)
})
listen(scope, $contextmenu, 'focusin', () => {
hadFocus = true
})
let pending = false
listen(scope, $contextmenu, 'focusout', (input) => {
if ($contextmenu.contains((input as FocusEvent).relatedTarget as Node | null) || pending)
return
pending = true
timeout(scope, () => {
pending = false
if (active() && menu.show && !$contextmenu.contains($player.ownerDocument.activeElement))
menu.show = false
}, 0)
})
listen(scope, $player, 'keydown', (input) => {
const event = input as KeyboardEvent
if (!active() || !plainKey(event) || event.defaultPrevented)
return
const opening = event.key === 'ContextMenu' || (event.key === 'F10' && event.shiftKey)
if (opening && art.constructor.CONTEXTMENU && !editable(event.target)) {
claimKey(event)
capture()
const rect = (event.target as HTMLElement).getBoundingClientRect()
open(rect.left, rect.bottom)
if (active() && menu.show)
targets()[0]?.focus({ preventScroll: true })
return
}
if (!menu.show || !$contextmenu.contains(event.target as Node | null))
return
if (event.key === 'Escape') {
claimKey(event)
menu.show = false
return
}
if (event.key === 'Tab') {
menu.show = false
return
}
if (editable(event.target))
return
const items = targets()
const index = items.indexOf(event.target as HTMLElement)
let target: HTMLElement | undefined
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
target = items[(index + 1) % items.length]
}
else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
target = items[(index + items.length - 1) % items.length]
}
else if (event.key === 'Home') {
target = items[0]
}
else if (event.key === 'End') {
target = items[items.length - 1]
}
else if (event.key.length === 1 && event.key !== ' ') {
const key = event.key.toLowerCase()
prefix = Date.now() - typedAt > 700 ? key : prefix + key
typedAt = Date.now()
const search = [...prefix].every(letter => letter === key) ? key : prefix
target = [...items.slice(index + 1), ...items.slice(0, index + 1)].find(item => (item.getAttribute('aria-label') || item.textContent || '').trim().toLowerCase().startsWith(search))
}
else {
return
}
claimKey(event)
target?.focus({ preventScroll: true })
})
return {
pointer(event: MouseEvent): boolean {
if (!active() || !art.constructor.CONTEXTMENU || editable(event.target))
return false
capture()
return true
},
}
}
@@ -1,6 +1,7 @@
import type { ContextmenuFactory, ContextmenuOption } from './types'
import { controlEvents } from '../control/resources'
import { inverseClass, query } from '../utils'
import { keyboardChoices, updateChoices } from './choices'
export default function playbackRate(option: ContextmenuOption): ContextmenuFactory {
return (art) => {
@@ -12,10 +13,11 @@ export default function playbackRate(option: ContextmenuOption): ContextmenuFact
const html = PLAYBACK_RATE.map(
item => `<span data-value="${item}">${item === 1 ? i18n.get('Normal') : item.toFixed(1)}</span>`,
).join('')
const label = i18n.get('Play Speed')
return {
...option,
html: `${i18n.get('Play Speed')}: ${html}`,
html: `${label}: ${html}`,
click: (contextmenu, event) => {
const value = event.target instanceof HTMLElement ? event.target.dataset.value : undefined
if (value) {
@@ -28,12 +30,14 @@ export default function playbackRate(option: ContextmenuOption): ContextmenuFact
const $default = query('[data-value="1"]', $panel)
if ($default)
inverseClass($default, 'art-current')
keyboardChoices($panel, label)
on('video:ratechange', () => {
const $current = Array.from($panel.querySelectorAll('span')).find(
item => Number(item.dataset.value) === art.playbackRate,
)
if ($current) {
inverseClass($current, 'art-current')
updateChoices($panel)
}
})
},
@@ -0,0 +1,17 @@
import type { ContextmenuHost } from './types'
import { isClosing } from '../lifecycle/instance'
import { getRect, setStyles } from '../utils'
export function positionContextmenu(art: ContextmenuHost, mouseX: number, mouseY: number): void {
const { $player, $contextmenu } = art.template
const { height: cHeight, width: cWidth, left: cLeft, top: cTop } = getRect($player)
const { height: mHeight, width: mWidth } = getRect($contextmenu)
let menuLeft = mouseX - cLeft
let menuTop = mouseY - cTop
if (mouseX + mWidth > cLeft + cWidth)
menuLeft = cWidth - mWidth
if (mouseY + mHeight > cTop + cHeight)
menuTop = cHeight - mHeight
if (!isClosing(art))
setStyles($contextmenu, { top: `${menuTop}px`, left: `${menuLeft}px` })
}