mirror of
https://github.com/fly-apps/live_beats.git
synced 2024-10-31 21:48:49 +00:00
7461256268
The focus_wrap function component (and hook) can be used to focus wrap any content. The focus and focus_closest JS functions were added to programmtaically focus an element on the client or find the next element or previous element sibling when an action is taken that requires moving focus to cloest item. Co-authored-by: Nolan Darilek <nolan@thewordnerd.info>
329 lines
11 KiB
JavaScript
329 lines
11 KiB
JavaScript
import "phoenix_html"
|
|
import {Socket} from "phoenix"
|
|
import {LiveSocket} from "phoenix_live_view"
|
|
import topbar from "../vendor/topbar"
|
|
|
|
let nowSeconds = () => Math.round(Date.now() / 1000)
|
|
let rand = (min, max) => Math.floor(Math.random() * (max - min) + min)
|
|
let isVisible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length > 0)
|
|
|
|
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())
|
|
},
|
|
|
|
clearNextTimer(){
|
|
clearTimeout(this.nextTimer)
|
|
this.nextTimer = null
|
|
},
|
|
|
|
play(opts = {}){
|
|
let {sync} = opts
|
|
this.clearNextTimer()
|
|
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.nextTimer && this.player.currentTime >= this.player.duration){
|
|
clearInterval(this.progressTimer)
|
|
this.nextTimer = setTimeout(() => this.pushEvent("next_song_auto"), rand(0, 1500))
|
|
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) }
|
|
}
|
|
|
|
Hooks.Ping = {
|
|
mounted(){
|
|
this.handleEvent("pong", () => {
|
|
let rtt = Date.now() - this.nowMs
|
|
this.el.innerText = `ping: ${rtt}ms`
|
|
this.timer = setTimeout(() => this.ping(rtt), 1000)
|
|
})
|
|
this.ping(null)
|
|
},
|
|
destroyed(){ clearTimeout(this.timer) },
|
|
ping(rtt){
|
|
this.nowMs = Date.now()
|
|
this.pushEvent("ping", {rtt: rtt})
|
|
}
|
|
}
|
|
|
|
// Accessible focus handling
|
|
let Focus = {
|
|
focusMain(){
|
|
let target = document.querySelector("main h1") || document.querySelector("main")
|
|
if(target){
|
|
let origTabIndex = target.tabIndex
|
|
target.tabIndex = -1
|
|
target.focus()
|
|
target.tabIndex = origTabIndex
|
|
}
|
|
},
|
|
// Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
|
|
isFocusable(el){
|
|
if(el.tabIndex > 0 || (el.tabIndex === 0 && el.getAttribute("tabIndex") !== null)){ return true }
|
|
if(el.disabled){ return false }
|
|
|
|
switch(el.nodeName) {
|
|
case "A":
|
|
return !!el.href && el.rel !== "ignore"
|
|
case "INPUT":
|
|
return el.type != "hidden" && el.type !== "file"
|
|
case "BUTTON":
|
|
case "SELECT":
|
|
case "TEXTAREA":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
},
|
|
// Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
|
|
attemptFocus(el){
|
|
if(!el){ return }
|
|
if(!this.isFocusable(el)){ return false }
|
|
try {
|
|
el.focus()
|
|
} catch(e){}
|
|
|
|
return document.activeElement === el
|
|
},
|
|
// Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
|
|
focusFirstDescendant(el){
|
|
for(let i = 0; i < el.childNodes.length; i++){
|
|
let child = el.childNodes[i]
|
|
if(this.attemptFocus(child) || this.focusFirstDescendant(child)){
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
},
|
|
// Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
|
|
focusLastDescendant(element){
|
|
for(let i = element.childNodes.length - 1; i >= 0; i--){
|
|
let child = element.childNodes[i]
|
|
if(this.attemptFocus(child) || this.focusLastDescendant(child)){
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
},
|
|
}
|
|
|
|
// Accessible focus wrapping
|
|
Hooks.FocusWrap = {
|
|
mounted(){
|
|
this.content = document.querySelector(this.el.getAttribute("data-content"))
|
|
this.focusStart = this.el.querySelector(`#${this.el.id}-start`)
|
|
this.focusEnd = this.el.querySelector(`#${this.el.id}-end`)
|
|
this.focusStart.addEventListener("focus", () => Focus.focusLastDescendant(this.content))
|
|
this.focusEnd.addEventListener("focus", () => Focus.focusFirstDescendant(this.content))
|
|
this.content.addEventListener("phx:show-end", () => this.content.focus())
|
|
if(window.getComputedStyle(this.content).display !== "none"){
|
|
Focus.focusFirstDescendant(this.content)
|
|
}
|
|
},
|
|
}
|
|
|
|
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
|
let liveSocket = new LiveSocket("/live", Socket, {
|
|
hooks: Hooks,
|
|
params: {_csrf_token: csrfToken},
|
|
dom: {
|
|
onNodeAdded(node){
|
|
if(node instanceof HTMLElement && node.autofocus){
|
|
node.focus()
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
let routeUpdated = ({kind}) => {
|
|
Focus.focusMain()
|
|
}
|
|
|
|
// Show progress bar on live navigation and form submits
|
|
topbar.config({barColors: {0: "rgba(147, 51, 234, 1)"}, 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", e => routeUpdated(e.detail))
|
|
|
|
window.addEventListener("js:exec", e => e.target[e.detail.call](...e.detail.args))
|
|
window.addEventListener("js:focus", e => {
|
|
let parent = document.querySelector(e.detail.parent)
|
|
if(parent && isVisible(parent)){ e.target.focus() }
|
|
})
|
|
window.addEventListener("js:focus-closest", e => {
|
|
let el = e.target
|
|
let sibling = el.nextElementSibling
|
|
while(sibling){
|
|
if(isVisible(sibling) && Focus.attemptFocus(sibling)){ return }
|
|
sibling = sibling.nextElementSibling
|
|
}
|
|
sibling = el.previousElementSibling
|
|
while(sibling){
|
|
if(isVisible(sibling) && Focus.attemptFocus(sibling)){ return }
|
|
sibling = sibling.previousElementSibling
|
|
}
|
|
Focus.attemptFocus(el.parent) || Focus.focusMain()
|
|
})
|
|
window.addEventListener("phx:remove-el", e => document.getElementById(e.detail.id).remove())
|
|
|
|
// 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
|
|
|