271 lines
8.7 KiB
JavaScript
271 lines
8.7 KiB
JavaScript
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
|
|
// to get started and then uncomment the line below.
|
|
// import "./user_socket.js"
|
|
|
|
// You can include dependencies in two ways.
|
|
//
|
|
// The simplest option is to put them in assets/vendor and
|
|
// import them using relative paths:
|
|
//
|
|
// import "../vendor/some-package.js"
|
|
//
|
|
// Alternatively, you can `npm install some-package --prefix assets` and import
|
|
// them using a path starting with the package name:
|
|
//
|
|
// import "some-package"
|
|
//
|
|
|
|
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
|
|
import "phoenix_html"
|
|
// Establish Phoenix Socket and LiveView configuration.
|
|
import {Socket} from "phoenix"
|
|
import {LiveSocket} from "phoenix_live_view"
|
|
import topbar from "../vendor/topbar"
|
|
|
|
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
|
|
|
// Hooks for LiveView components
|
|
let Hooks = {}
|
|
|
|
// CopyToClipboard hook: Copies text to clipboard when triggered by server event
|
|
Hooks.CopyToClipboard = {
|
|
mounted() {
|
|
this.handleEvent("copy_to_clipboard", ({text}) => {
|
|
if (navigator.clipboard) {
|
|
navigator.clipboard.writeText(text).catch(err => {
|
|
console.error("Clipboard write failed:", err)
|
|
})
|
|
} else {
|
|
// Fallback for older browsers
|
|
const textArea = document.createElement("textarea")
|
|
textArea.value = text
|
|
textArea.style.position = "fixed"
|
|
textArea.style.left = "-999999px"
|
|
document.body.appendChild(textArea)
|
|
textArea.select()
|
|
try {
|
|
document.execCommand("copy")
|
|
} catch (err) {
|
|
console.error("Fallback clipboard copy failed:", err)
|
|
}
|
|
document.body.removeChild(textArea)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ComboBox hook: Prevents form submission when Enter is pressed in dropdown
|
|
Hooks.ComboBox = {
|
|
mounted() {
|
|
this.handleKeyDown = (e) => {
|
|
const isDropdownOpen = this.el.getAttribute("aria-expanded") === "true"
|
|
|
|
if (e.key === "Enter" && isDropdownOpen) {
|
|
e.preventDefault()
|
|
}
|
|
}
|
|
|
|
this.el.addEventListener("keydown", this.handleKeyDown)
|
|
},
|
|
|
|
destroyed() {
|
|
this.el.removeEventListener("keydown", this.handleKeyDown)
|
|
}
|
|
}
|
|
|
|
let liveSocket = new LiveSocket("/live", Socket, {
|
|
longPollFallbackMs: 2500,
|
|
params: {_csrf_token: csrfToken},
|
|
hooks: Hooks
|
|
})
|
|
|
|
// Listen for custom events from LiveView
|
|
window.addEventListener("phx:set-input-value", (e) => {
|
|
const {id, value} = e.detail
|
|
const input = document.getElementById(id)
|
|
if (input) {
|
|
input.value = value
|
|
}
|
|
})
|
|
|
|
// 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(300))
|
|
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
|
|
|
|
// connect if there are any LiveViews on the page
|
|
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
|
|
|
|
// Sidebar accessibility improvements
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
const drawerToggle = document.getElementById("main-drawer")
|
|
const sidebarToggle = document.getElementById("sidebar-toggle")
|
|
const sidebar = document.getElementById("main-sidebar")
|
|
|
|
if (!drawerToggle || !sidebarToggle || !sidebar) return
|
|
|
|
// Manage tabindex for sidebar elements based on open/closed state
|
|
const updateSidebarTabIndex = (isOpen) => {
|
|
// Find all potentially focusable elements (including those with tabindex="-1")
|
|
const allFocusableElements = sidebar.querySelectorAll(
|
|
'a[href], button, select, input:not([type="hidden"]), [tabindex]'
|
|
)
|
|
|
|
allFocusableElements.forEach(el => {
|
|
// Skip the overlay button
|
|
if (el.closest('.drawer-overlay')) return
|
|
|
|
if (isOpen) {
|
|
// Remove tabindex="-1" to make focusable when open
|
|
if (el.hasAttribute('tabindex') && el.getAttribute('tabindex') === '-1') {
|
|
el.removeAttribute('tabindex')
|
|
}
|
|
} else {
|
|
// Set tabindex="-1" to remove from tab order when closed
|
|
if (!el.hasAttribute('tabindex')) {
|
|
el.setAttribute('tabindex', '-1')
|
|
} else if (el.getAttribute('tabindex') !== '-1') {
|
|
// Store original tabindex in data attribute before setting to -1
|
|
if (!el.hasAttribute('data-original-tabindex')) {
|
|
el.setAttribute('data-original-tabindex', el.getAttribute('tabindex'))
|
|
}
|
|
el.setAttribute('tabindex', '-1')
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// Find first focusable element in sidebar
|
|
// Priority: first navigation link (menuitem) > other links > other focusable elements
|
|
const getFirstFocusableElement = () => {
|
|
// First, try to find the first navigation link (menuitem)
|
|
const firstNavLink = sidebar.querySelector('a[href][role="menuitem"]:not([tabindex="-1"])')
|
|
if (firstNavLink && !firstNavLink.closest('.drawer-overlay')) {
|
|
return firstNavLink
|
|
}
|
|
|
|
// Fallback: any navigation link
|
|
const firstLink = sidebar.querySelector('a[href]:not([tabindex="-1"])')
|
|
if (firstLink && !firstLink.closest('.drawer-overlay')) {
|
|
return firstLink
|
|
}
|
|
|
|
// Last resort: any other focusable element
|
|
const focusableSelectors = [
|
|
'button:not([tabindex="-1"]):not([disabled])',
|
|
'select:not([tabindex="-1"]):not([disabled])',
|
|
'input:not([tabindex="-1"]):not([disabled]):not([type="hidden"])',
|
|
'[tabindex]:not([tabindex="-1"])'
|
|
]
|
|
|
|
for (const selector of focusableSelectors) {
|
|
const element = sidebar.querySelector(selector)
|
|
if (element && !element.closest('.drawer-overlay')) {
|
|
return element
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
// Update aria-expanded when drawer state changes
|
|
const updateAriaExpanded = () => {
|
|
const isOpen = drawerToggle.checked
|
|
sidebarToggle.setAttribute("aria-expanded", isOpen.toString())
|
|
|
|
// Update dropdown aria-expanded if present
|
|
const userMenuButton = sidebar.querySelector('button[aria-haspopup="true"]')
|
|
if (userMenuButton) {
|
|
const dropdown = userMenuButton.closest('.dropdown')
|
|
const isDropdownOpen = dropdown?.classList.contains('dropdown-open')
|
|
if (userMenuButton) {
|
|
userMenuButton.setAttribute("aria-expanded", (isDropdownOpen || false).toString())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Listen for changes to the drawer checkbox
|
|
drawerToggle.addEventListener("change", () => {
|
|
const isOpen = drawerToggle.checked
|
|
updateAriaExpanded()
|
|
updateSidebarTabIndex(isOpen)
|
|
if (!isOpen) {
|
|
// When closing, return focus to toggle button
|
|
sidebarToggle.focus()
|
|
}
|
|
})
|
|
|
|
// Update on initial load
|
|
updateAriaExpanded()
|
|
updateSidebarTabIndex(drawerToggle.checked)
|
|
|
|
// Close sidebar with ESC key
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape" && drawerToggle.checked) {
|
|
drawerToggle.checked = false
|
|
updateAriaExpanded()
|
|
updateSidebarTabIndex(false)
|
|
// Return focus to toggle button
|
|
sidebarToggle.focus()
|
|
}
|
|
})
|
|
|
|
// Improve keyboard navigation for sidebar toggle
|
|
sidebarToggle.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault()
|
|
const wasOpen = drawerToggle.checked
|
|
drawerToggle.checked = !drawerToggle.checked
|
|
updateAriaExpanded()
|
|
|
|
// If opening, move focus to first element in sidebar
|
|
if (!wasOpen && drawerToggle.checked) {
|
|
updateSidebarTabIndex(true)
|
|
// Use setTimeout to ensure DOM is updated
|
|
setTimeout(() => {
|
|
const firstElement = getFirstFocusableElement()
|
|
if (firstElement) {
|
|
firstElement.focus()
|
|
}
|
|
}, 50)
|
|
} else if (wasOpen && !drawerToggle.checked) {
|
|
updateSidebarTabIndex(false)
|
|
}
|
|
}
|
|
})
|
|
|
|
// Also handle click events to update tabindex and focus
|
|
sidebarToggle.addEventListener("click", () => {
|
|
setTimeout(() => {
|
|
const isOpen = drawerToggle.checked
|
|
updateSidebarTabIndex(isOpen)
|
|
if (isOpen) {
|
|
const firstElement = getFirstFocusableElement()
|
|
if (firstElement) {
|
|
firstElement.focus()
|
|
}
|
|
}
|
|
}, 50)
|
|
})
|
|
|
|
// Handle dropdown keyboard navigation
|
|
const userMenuButton = sidebar?.querySelector('button[aria-haspopup="true"]')
|
|
if (userMenuButton) {
|
|
userMenuButton.addEventListener("click", () => {
|
|
setTimeout(updateAriaExpanded, 0)
|
|
})
|
|
|
|
userMenuButton.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault()
|
|
userMenuButton.click()
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|