mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-15 09:06:16 -08:00
192 lines
6.1 KiB
TypeScript
192 lines
6.1 KiB
TypeScript
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
|
|
}
|