Packages

Ready-made Phoenix components, built with Pyro.

Current section

Files

Jump to
pyro_components assets js components.js
Raw

assets/js/components.js

// #############################################################################
// #### C U S T O M E V E N T H A N D L E R S
// #############################################################################
window.addEventListener('pyro:clear', (e) => {
if (e?.target?.value != '') {
e.target.value = ''
}
})
window.addEventListener(`pyro:scroll-top`, (e) => {
if (e && e.detail && e.detail.id) {
let el = document.getElementById(e.detail.id)
if (el) {
maybeScrollParent(el)
} else {
console.error(
`pyro:scroll-top requested for DOM ID #${e.detail.id} doesn't exist!`,
)
}
} else if (e && e.detail && e.detail.selector) {
let el = document.querySelector(e.detail.selector)
if (el) {
maybeScrollParent(el)
} else {
console.error(
`pyro:scroll-top requested for DOM selector #${e.detail.selector} doesn't exist!`,
)
}
} else {
console.error(
`pyro:scroll-top event doesn't contain e.detail.id or e.detail.selector!`,
)
}
})
// Scroll element or parents if already scrolled
export function maybeScrollParent(el, depth = 0) {
if (el.scrollTop == 0 && el.parentElement && el.parentElement.scrollTop > 0) {
el.parentElement.scrollTop = 0
} else if (
el.scrollTop == 0 &&
el.parentElement &&
el.parentElement.scrollTop == 0 &&
depth < 5
) {
maybeScrollParent(el.parentElement, depth + 1)
} else {
el.scrollTop = 0
}
}
// #############################################################################
// #### T I M E Z O N E T O O L I N G
// #############################################################################
export function getTimezone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone
}
export async function sendTimezoneToServer() {
const timezone = getTimezone()
let csrfToken = document
.querySelector("meta[name='csrf-token']")
.getAttribute('content')
// Skip if we sent the timezone already or the timezone changed since last time we sent
if (
typeof window.localStorage != 'undefined' &&
(!localStorage['timezone'] || localStorage['timezone'] != timezone)
) {
const response = await fetch('/session/set-timezone', {
method: 'POST',
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken,
},
referrerPolicy: 'no-referrer',
body: JSON.stringify({ timezone: timezone }),
})
if (response.status === 200) {
localStorage['timezone'] = timezone
}
}
}
// #############################################################################
// #### H O O K S
// #############################################################################
export const PyroColorSchemeHook = {
mounted() {
this.init(this.el.getAttribute('data-scheme'))
},
updated() {
this.init()
},
init(scheme) {
initScheme(scheme)
this.el.addEventListener('click', window.toggleScheme)
},
}
export const PyroFlashComponent = {
mounted() {
this.oldMessageHTML = document.querySelector(
`#${this.el.id}-message`,
).innerHTML
if (this.el.dataset?.autoshow !== undefined) {
window.liveSocket.execJS(
this.el,
this.el.getAttribute('data-show-exec-js'),
)
}
resetHideTTL(this)
},
updated() {
newMessageHTML = document.querySelector(`#${this.el.id}-message`).innerHTML
if (newMessageHTML !== this.oldMessageHTML) {
this.oldEl = this.el
this.oldMessageHTML = newMessageHTML
resetHideTTL(this)
} else {
el.value = this.countdown
}
},
destroyed() {
clearInterval(this.ttlInterval)
},
}
export const PyroNudgeIntoView = {
mounted() {
nudge(this.el)
},
updated() {
nudge(this.el)
},
}
export const PyroAutocompleteComponent = {
mounted() {
this.lastValueSent = null
const expanded = () => {
return booleanDataset(this.el.getAttribute('aria-expanded'))
}
const updateSearchThrottled = throttle(() => {
if (this.lastValueSent !== this.el.value || !expanded()) {
this.lastValueSent = this.el.value
this.pushEventTo(this.el.dataset.myself, 'search', this.el.value)
}
}, this.el.dataset.throttleTime)
if (this.el.dataset.autofocus) {
focusAndSelect(this.el)
}
const selectedIndex = () => {
return parseInt(this.el.dataset.selectedIndex)
}
const options = () => {
const listbox = document.getElementById(
this.el.getAttribute('aria-controls'),
)
if (listbox?.children) {
return Array.from(listbox.children)
} else {
return []
}
}
const setSelectedIndex = (selectedIndex) => {
// Loop selectedIndex back to first or last result if out of bounds
const rc = parseInt(this.el.dataset.resultsCount)
selectedIndex = ((selectedIndex % rc) + rc) % rc
this.el.dataset.selectedIndex = selectedIndex
options().forEach((option, i) => {
if (i === selectedIndex) {
option.setAttribute('aria-selected', true)
} else {
option.removeAttribute('aria-selected')
}
})
}
const pick = (e) => {
const i = selectedIndex()
const input_el = document.getElementById(this.el.dataset.inputId)
let label = ''
let value = ''
if (i > -1) {
const option = options()[i]
label = option.dataset.label
value = option.dataset.value
}
e.preventDefault()
e.stopPropagation()
this.el.value = label
this.el.focus()
selectValue(this.el)
this.pushEventTo(this.el.dataset.myself, 'pick', {
label,
value,
})
input_el.value = value
input_el.dispatchEvent(new Event('input', { bubbles: true }))
return false
}
this.el.addEventListener('keydown', (e) => {
switch (e.key) {
case 'Tab':
if (expanded()) {
return pick(e)
} else {
return true
}
case 'Esc': // IE/Edge
case 'Escape':
if (this.el.value !== '' || !expanded()) {
e.preventDefault()
e.stopPropagation()
this.el.value = ''
this.el.dataset.selectedIndex = -1
options().forEach((option, i) => {
option.removeAttribute('aria-selected')
})
updateSearchThrottled()
return false
} else if (expanded()) {
e.preventDefault()
e.stopPropagation()
this.el.value = this.el.dataset.savedLabel || ''
selectValue(this.el)
this.pushEventTo(this.el.dataset.myself, 'cancel')
this.lastValueSent = null
return false
} else {
return true
}
case 'Enter':
if (expanded()) {
return pick(e)
} else {
this.el.value = ''
e.preventDefault()
e.stopPropagation()
updateSearchThrottled()
return false
}
case 'Up': // IE/Edge
case 'Down': // IE/Edge
case 'ArrowUp':
case 'ArrowDown':
if (expanded()) {
e.preventDefault()
e.stopPropagation()
let i = selectedIndex()
i = e.key === 'ArrowUp' || e.key === 'Up' ? i - 1 : i + 1
setSelectedIndex(i)
return false
} else {
return true
}
default:
return true
}
})
this.el.addEventListener('focus', (e) => {
selectValue(this.el)
})
this.el.addEventListener('input', (e) => {
switch (e.inputType) {
case 'insertText':
case 'deleteContentBackward':
case 'deleteContentForward':
updateSearchThrottled()
return true
default:
return false
}
})
this.el.addEventListener('pick', (e) => {
setSelectedIndex(e.detail.dispatcher.dataset.index)
return pick(e)
})
},
}
export const PyroCopyToClipboard = {
mounted() {
this.content = this.el.innerHTML
let { value, message, ttl } = this.el.dataset
this.el.addEventListener('click', (e) => {
e.preventDefault()
navigator.clipboard.writeText(value)
this.el.innerHTML = message || 'Copied to clipboard!'
this.timeout = window.setTimeout(() => {
this.el.innerHTML = this.content
}, ttl)
})
},
updated() {
window.clearTimeout(this.timeout)
},
destroyed() {
window.clearTimeout(this.timeout)
},
}
export const hooks = {
PyroColorSchemeHook,
PyroFlashComponent,
PyroNudgeIntoView,
PyroAutocompleteComponent,
PyroCopyToClipboard,
}
export function nudge(el) {
let width = window.innerWidth
let height = window.innerHeight
let rect = el.getBoundingClientRect()
hOffset = el.dataset?.horizontalOffset || 0
vOffset = el.dataset?.verticalOffset || 0
// Nudge left if offscreen
if (rect.right + 24 > width) {
el.style.right = hOffset
el.style.left = null
} else {
el.style.left = hOffset
el.style.right = null
}
// Nudge up if offscreen
if (rect.bottom + 24 > height) {
el.style.bottom = vOffset
el.style.top = null
} else {
el.style.top = vOffset
el.style.bottom = null
}
}
export function resetHideTTL(self) {
clearInterval(self.ttlInterval)
if (self.el.dataset?.ttl > 0) {
self.countdown = self.el.dataset?.ttl
self.ttlInterval = setInterval(() => {
self.countdown = self.countdown - 16.7
if (self.countdown <= 0) {
window.liveSocket.execJS(
self.el,
self.el.getAttribute('data-hide-exec-js'),
)
} else {
el = document.querySelector(`#${self.el.id}>section>progress`)
if (el) {
el.value = self.countdown
} else {
clearInterval(self.ttlInterval)
}
}
}, 16.7)
}
}
export function throttle(callback, limit) {
let waiting = false
return function () {
if (!waiting) {
callback.apply(this, arguments)
waiting = true
setTimeout(function () {
callback.apply(this, arguments)
waiting = false
}, limit)
}
}
}
export function selectValue(el) {
if (typeof el.select === 'function') {
el.select()
}
}
export function focusAndSelect(el) {
el.focus()
selectValue(el)
}
export function booleanDataset(value) {
return ![null, undefined, false, 'false'].includes(value)
}