foliate-js
Version:
Render e-books in the browser
170 lines (159 loc) • 6.08 kB
JavaScript
const createSVGElement = tag =>
document.createElementNS('http://www.w3.org/2000/svg', tag)
const createExpanderIcon = () => {
const svg = createSVGElement('svg')
svg.setAttribute('viewBox', '0 0 13 10')
svg.setAttribute('width', '13')
svg.setAttribute('height', '13')
const polygon = createSVGElement('polygon')
polygon.setAttribute('points', '2 1, 12 1, 7 9')
svg.append(polygon)
return svg
}
const createTOCItemElement = (list, map, onclick) => {
let count = 0
const makeID = () => `toc-element-${count++}`
const createItem = ({ label, href, subitems }, depth = 0) => {
const a = document.createElement(href ? 'a' : 'span')
a.innerText = label
a.setAttribute('role', 'treeitem')
a.tabIndex = -1
a.style.paddingInlineStart = `${(depth + 1) * 24}px`
list.push(a)
if (href) {
if (!map.has(href)) map.set(href, a)
a.href = href
a.onclick = event => {
event.preventDefault()
onclick(href)
}
} else a.onclick = event => a.firstElementChild?.onclick(event)
const li = document.createElement('li')
li.setAttribute('role', 'none')
li.append(a)
if (subitems?.length) {
a.setAttribute('aria-expanded', 'false')
const expander = createExpanderIcon()
expander.onclick = event => {
event.preventDefault()
event.stopPropagation()
const expanded = a.getAttribute('aria-expanded')
a.setAttribute('aria-expanded', expanded === 'true' ? 'false' : 'true')
}
a.prepend(expander)
const ol = document.createElement('ol')
ol.id = makeID()
ol.setAttribute('role', 'group')
a.setAttribute('aria-owns', ol.id)
ol.replaceChildren(...subitems.map(item => createItem(item, depth + 1)))
li.append(ol)
}
return li
}
return createItem
}
// https://www.w3.org/TR/wai-aria-practices-1.2/examples/treeview/treeview-navigation.html
export const createTOCView = (toc, onclick) => {
const $toc = document.createElement('ol')
$toc.setAttribute('role', 'tree')
const list = []
const map = new Map()
const createItem = createTOCItemElement(list, map, onclick)
$toc.replaceChildren(...toc.map(item => createItem(item)))
const isTreeItem = item => item?.getAttribute('role') === 'treeitem'
const getParents = function* (el) {
for (let parent = el.parentNode; parent !== $toc; parent = parent.parentNode) {
const item = parent.previousElementSibling
if (isTreeItem(item)) yield item
}
}
let currentItem, currentVisibleParent
$toc.addEventListener('focusout', () => {
if (!currentItem) return
// reset parent focus from last time
if (currentVisibleParent) currentVisibleParent.tabIndex = -1
// if current item is visible, let it have the focus
if (currentItem.offsetParent) {
currentItem.tabIndex = 0
return
}
// current item is hidden; give focus to the nearest visible parent
for (const item of getParents(currentItem)) {
if (item.offsetParent) {
item.tabIndex = 0
currentVisibleParent = item
break
}
}
})
const setCurrentHref = href => {
if (currentItem) {
currentItem.removeAttribute('aria-current')
currentItem.tabIndex = -1
}
const el = map.get(href)
if (!el) {
currentItem = list[0]
currentItem.tabIndex = 0
return
}
for (const item of getParents(el))
item.setAttribute('aria-expanded', 'true')
el.setAttribute('aria-current', 'page')
el.tabIndex = 0
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
currentItem = el
}
const acceptNode = node => isTreeItem(node) && node.offsetParent
? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
const iter = document.createTreeWalker($toc, 1, { acceptNode })
const getIter = current => (iter.currentNode = current, iter)
for (const el of list) el.addEventListener('keydown', event => {
let stop = false
const { currentTarget, key } = event
switch (key) {
case ' ':
case 'Enter':
currentTarget.click()
stop = true
break
case 'ArrowDown':
getIter(currentTarget).nextNode()?.focus()
stop = true
break
case 'ArrowUp':
getIter(currentTarget).previousNode()?.focus()
stop = true
break
case 'ArrowLeft':
if (currentTarget.getAttribute('aria-expanded') === 'true')
currentTarget.setAttribute('aria-expanded', 'false')
else getParents(currentTarget).next()?.value?.focus()
stop = true
break
case 'ArrowRight':
if (currentTarget.getAttribute('aria-expanded') === 'true')
getIter(currentTarget).nextNode()?.focus()
else if (currentTarget.getAttribute('aria-owns'))
currentTarget.setAttribute('aria-expanded', 'true')
stop = true
break
case 'Home':
list[0].focus()
stop = true
break
case 'End': {
const last = list[list.length - 1]
if (last.offsetParent) last.focus()
else getIter(last).previousNode()?.focus()
stop = true
break
}
}
if (stop) {
event.preventDefault()
event.stopPropagation()
}
})
return { element: $toc, setCurrentHref }
}