live_beats/assets/js/app.js
Nolan Darilek 566a574c0a Initial attempt at accessibly announcing route updates.
* Add handler for `phx:page-loading-stop` that focuses either the first `<h1>` child of `<main>`, or `<main>` directly if no child `<h1>` is present. Make this our focus target.
* Cache original `tabindex` of target, if any, and assign it a temporary `tabindex1 of -1.
* Focus the target.
* After a long timeout, either restore the target's original `tabindex` or remove the temporary -1 value. Short timeouts didn't seem to perform the focus step. Neither did nested `requestAnimationFrame` calls.
2021-11-22 15:25:41 -06:00

217 lines
7.1 KiB
JavaScript

import "phoenix_html"
import {Socket} from "phoenix"
// import {LiveSocket} from "./phoenix_live_view"
import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"
let nowSeconds = () => Math.round(Date.now() / 1000)
let execJS = (selector, attr) => {
document.querySelectorAll(selector).forEach(el => liveSocket.execJS(el, el.getAttribute(attr)))
}
let Hooks = {}
Hooks.Flash = {
mounted(){
let hide = () => liveSocket.execJS(this.el, this.el.getAttribute("phx-click"))
this.timer = setTimeout(() => hide(), 8000)
this.el.addEventListener("phx:hide-start", () => clearTimeout(this.timer))
this.el.addEventListener("mouseover", () => {
clearTimeout(this.timer)
this.timer = setTimeout(() => hide(), 8000)
})
},
destroyed(){ clearTimeout(this.timer) }
}
Hooks.Menu = {
getAttr(name){
let val = this.el.getAttribute(name)
if(val === null){ throw(new Error(`no ${name} attribute configured for menu`)) }
return val
},
reset(){
this.enabled = false
this.activeClass = this.getAttr("data-active-class")
this.deactivate(this.menuItems())
this.activeItem = null
window.removeEventListener("keydown", this.handleKeyDown)
},
destroyed(){ this.reset() },
mounted(){
this.menuItemsContainer = document.querySelector(`[aria-labelledby="${this.el.id}"]`)
this.reset()
this.handleKeyDown = (e) => this.onKeyDown(e)
this.el.addEventListener("keydown", e => {
if((e.key === "Enter" || e.key === " ") && e.currentTarget.isSameNode(this.el)){
this.enabled = true
}
})
this.el.addEventListener("click", e => {
if(!e.currentTarget.isSameNode(this.el)){ return }
window.addEventListener("keydown", this.handleKeyDown)
// disable if button clicked and click was not a keyboard event
if(this.enabled){
window.requestAnimationFrame(() => this.activate(0))
}
})
this.menuItemsContainer.addEventListener("phx:hide-start", () => this.reset())
},
activate(index, fallbackIndex){
let menuItems = this.menuItems()
this.activeItem = menuItems[index] || menuItems[fallbackIndex]
this.activeItem.classList.add(this.activeClass)
this.activeItem.focus()
},
deactivate(items){ items.forEach(item => item.classList.remove(this.activeClass)) },
menuItems(){ return Array.from(this.menuItemsContainer.querySelectorAll("[role=menuitem]")) },
onKeyDown(e){
if(e.key === "Escape"){
document.body.click()
this.el.focus()
this.reset()
} else if(e.key === "Enter" && !this.activeItem){
this.activate(0)
} else if(e.key === "Enter"){
this.activeItem.click()
}
if(e.key === "ArrowDown"){
e.preventDefault()
let menuItems = this.menuItems()
this.deactivate(menuItems)
this.activate(menuItems.indexOf(this.activeItem) + 1, 0)
} else if(e.key === "ArrowUp"){
e.preventDefault()
let menuItems = this.menuItems()
this.deactivate(menuItems)
this.activate(menuItems.indexOf(this.activeItem) - 1, menuItems.length - 1)
} else if (e.key === "Tab"){
e.preventDefault()
}
}
}
Hooks.AudioPlayer = {
mounted(){
this.playbackBeganAt = null
this.player = this.el.querySelector("audio")
this.currentTime = this.el.querySelector("#player-time")
this.duration = this.el.querySelector("#player-duration")
this.progress = this.el.querySelector("#player-progress")
let enableAudio = () => {
if(this.player.src){
document.removeEventListener("click", enableAudio)
if(this.player.readyState === 0){
this.player.play().catch(error => null)
this.player.pause()
}
}
}
document.addEventListener("click", enableAudio)
this.el.addEventListener("js:listen_now", () => this.play({sync: true}))
this.el.addEventListener("js:play_pause", () => {
if(this.player.paused){
this.play()
}
})
this.handleEvent("play", ({url, token, elapsed}) => {
this.playbackBeganAt = nowSeconds() - elapsed
let currentSrc = this.player.src.split("?")[0]
if(currentSrc === url && this.player.paused){
this.play({sync: true})
} else if(currentSrc !== url) {
this.player.src = `${url}?token=${token}`
this.play({sync: true})
}
})
this.handleEvent("pause", () => this.pause())
this.handleEvent("stop", () => this.stop())
},
play(opts = {}){
let {sync} = opts
this.player.play().then(() => {
if(sync){ this.player.currentTime = nowSeconds() - this.playbackBeganAt }
this.progressTimer = setInterval(() => this.updateProgress(), 100)
}, error => {
if(error.name === "NotAllowedError"){
execJS("#enable-audio", "data-js-show")
}
})
},
pause(){
clearInterval(this.progressTimer)
this.player.pause()
},
stop(){
clearInterval(this.progressTimer)
this.player.pause()
this.player.currentTime = 0
this.updateProgress()
this.duration.innerText = ""
this.currentTime.innerText = ""
},
updateProgress(){
if(isNaN(this.player.duration)){ return false }
if(this.player.currentTime >= this.player.duration){
this.pushEvent("next_song_auto")
clearInterval(this.progressTimer)
return
}
this.progress.style.width = `${(this.player.currentTime / (this.player.duration) * 100)}%`
this.duration.innerText = this.formatTime(this.player.duration)
this.currentTime.innerText = this.formatTime(this.player.currentTime)
},
formatTime(seconds){ return new Date(1000 * seconds).toISOString().substr(14, 5) }
}
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {
hooks: Hooks,
params: {_csrf_token: csrfToken}
})
let routeUpdated = () => {
let target = document.querySelector("main h1") || document.querySelector("main")
if (target) {
let origTabIndex = target.getAttribute("tabindex")
target.setAttribute("tabindex", "-1")
target.focus()
window.setTimeout(() => {
if (origTabIndex) {
target.setAttribute("tabindex", origTabIndex)
} else {
target.removeAttribute("tabindex")
}
}, 1000)
}
}
// Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", info => topbar.show())
window.addEventListener("phx:page-loading-stop", info => topbar.hide())
// Accessible routing
window.addEventListener("phx:page-loading-stop", () => window.requestAnimationFrame(routeUpdated))
window.addEventListener("js:exec", e => e.target[e.detail.call](...e.detail.args))
// connect if there are any LiveViews on the page
liveSocket.getSocket().onOpen(() => execJS("#connection-status", "js-hide"))
liveSocket.getSocket().onError(() => execJS("#connection-status", "js-show"))
liveSocket.connect()
// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket