// 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" import Sortable from "../vendor/sortable" let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") function getBrowserTimezone() { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || null } catch (_e) { return null } } // 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) } } // TableRowKeydown hook: WCAG 2.1.1 — when a table row cell has data-row-clickable, // Enter and Space trigger a click so row_click tables are keyboard activatable Hooks.TableRowKeydown = { mounted() { this.handleKeydown = (e) => { if ( e.target.getAttribute("data-row-clickable") === "true" && (e.key === "Enter" || e.key === " ") ) { e.preventDefault() e.target.click() } } this.el.addEventListener("keydown", this.handleKeydown) }, destroyed() { this.el.removeEventListener("keydown", this.handleKeydown) } } // FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button) Hooks.FocusRestore = { mounted() { this.handleEvent("focus_restore", ({id}) => { const el = document.getElementById(id) if (el) el.focus() }) } } // FlashAutoDismiss: after a delay, clear the flash so the toast hides without user clicking X (e.g. success toasts) Hooks.FlashAutoDismiss = { mounted() { const ms = this.el.dataset.autoClearMs if (!ms) return const delay = parseInt(ms, 10) if (delay > 0) { this.timer = setTimeout(() => { const key = this.el.dataset.clearFlashKey || "success" this.pushEvent("lv:clear-flash", {key}) }, delay) } }, destroyed() { if (this.timer) clearTimeout(this.timer) } } // TabListKeydown hook: WCAG tab pattern — prevent default for ArrowLeft/ArrowRight so the server can handle tab switch (roving tabindex) Hooks.TabListKeydown = { mounted() { this.handleKeydown = (e) => { if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault() } } this.el.addEventListener('keydown', this.handleKeydown) }, destroyed() { this.el.removeEventListener('keydown', this.handleKeydown) } } // SortableList hook: Accessible reorderable table/list. // Mouse drag: SortableJS (smooth animation, ghost row, items push apart). // Keyboard: Space = grab/drop, Arrow up/down = move, Escape = cancel, matching the Salesforce a11y pattern. // Container must have data-reorder-event and data-list-id. // Each row (tr) must have data-row-index; locked rows have data-locked="true". // Pushes event with { from_index, to_index } (both integers) on reorder. Hooks.SortableList = { mounted() { this.reorderEvent = this.el.dataset.reorderEvent this.listId = this.el.dataset.listId // Keyboard state: store grabbed row id so it survives LiveView re-renders this.grabbedRowId = null this.announcementEl = this.listId ? document.getElementById(this.listId + "-announcement") : null const announce = (msg) => { if (!this.announcementEl) return // Clear then re-set to force screen reader re-read this.announcementEl.textContent = "" setTimeout(() => { if (this.announcementEl) this.announcementEl.textContent = msg }, 50) } const tbody = this.el.querySelector("tbody") if (!tbody) return this.getRows = () => Array.from(tbody.querySelectorAll("tr")) this.getRowIndex = (tr) => { const idx = tr.getAttribute("data-row-index") return idx != null ? parseInt(idx, 10) : -1 } this.isLocked = (tr) => tr.getAttribute("data-locked") === "true" // SortableJS for mouse drag-and-drop with animation this.sortable = new Sortable(tbody, { animation: 150, handle: "[data-sortable-handle]", // Disable sorting for locked rows (first row = email) filter: "[data-locked='true']", preventOnFilter: true, // Ghost (placeholder showing where the item will land) ghostClass: "sortable-ghost", // The item being dragged chosenClass: "sortable-chosen", // Cursor while dragging dragClass: "sortable-drag", // Don't trigger on handle area clicks (only actual drag) delay: 0, onEnd: (e) => { if (e.oldIndex === e.newIndex) return this.pushEvent(this.reorderEvent, { from_index: e.oldIndex, to_index: e.newIndex }) announce(`Dropped. Position ${e.newIndex + 1} of ${this.getRows().length}.`) // LiveView will reconcile the DOM order after re-render } }) // Keyboard handler (Salesforce a11y pattern: Space=grab/drop, Arrows=move, Escape=cancel) this.handleKeyDown = (e) => { // Don't intercept Space on interactive elements (checkboxes, buttons, inputs) const tag = e.target.tagName if (tag === "INPUT" || tag === "BUTTON" || tag === "SELECT" || tag === "TEXTAREA") return const tr = e.target.closest("tr") if (!tr || this.isLocked(tr)) return const rows = this.getRows() const idx = this.getRowIndex(tr) if (idx < 0) return const total = rows.length if (e.key === " ") { e.preventDefault() const rowId = tr.id if (this.grabbedRowId === rowId) { // Drop this.grabbedRowId = null tr.style.outline = "" announce(`Dropped. Position ${idx + 1} of ${total}.`) } else { // Grab this.grabbedRowId = rowId tr.style.outline = "2px solid var(--color-primary)" tr.style.outlineOffset = "-2px" announce(`Grabbed. Position ${idx + 1} of ${total}. Use arrow keys to move, Space to drop, Escape to cancel.`) } return } if (e.key === "Escape") { if (this.grabbedRowId != null) { e.preventDefault() const grabbedTr = document.getElementById(this.grabbedRowId) if (grabbedTr) { grabbedTr.style.outline = ""; grabbedTr.style.outlineOffset = "" } this.grabbedRowId = null announce("Reorder cancelled.") } return } if (this.grabbedRowId == null) return // Do not move into a locked row (e.g. email always first) if (e.key === "ArrowUp" && idx > 0) { const targetRow = rows[idx - 1] if (!this.isLocked(targetRow)) { e.preventDefault() this.pushEvent(this.reorderEvent, { from_index: idx, to_index: idx - 1 }) announce(`Position ${idx} of ${total}.`) } } else if (e.key === "ArrowDown" && idx < total - 1) { const targetRow = rows[idx + 1] if (!this.isLocked(targetRow)) { e.preventDefault() this.pushEvent(this.reorderEvent, { from_index: idx, to_index: idx + 1 }) announce(`Position ${idx + 2} of ${total}.`) } } } this.el.addEventListener("keydown", this.handleKeyDown, true) }, updated() { // Re-apply keyboard outline and restore focus after LiveView re-render. // LiveView DOM patching loses focus; without explicit re-focus the next keypress // goes to document.body (Space scrolls the page instead of triggering our handler). if (this.grabbedRowId) { const tr = document.getElementById(this.grabbedRowId) if (tr) { tr.style.outline = "2px solid var(--color-primary)" tr.style.outlineOffset = "-2px" tr.focus() } else { // Row no longer exists (removed while grabbed), clear state this.grabbedRowId = null } } }, destroyed() { if (this.sortable) this.sortable.destroy() this.el.removeEventListener("keydown", this.handleKeyDown, true) } } // SidebarState hook: Manages sidebar expanded/collapsed state Hooks.SidebarState = { mounted() { // Restore state from localStorage const expanded = localStorage.getItem('sidebar-expanded') !== 'false' this.setSidebarState(expanded) // Expose toggle function globally window.toggleSidebar = () => { const current = this.el.dataset.sidebarExpanded === 'true' this.setSidebarState(!current) } }, updated() { // LiveView patches data-sidebar-expanded back to the template default ("true") // on every DOM update. Re-apply the stored state from localStorage after each patch. const expanded = localStorage.getItem('sidebar-expanded') !== 'false' const current = this.el.dataset.sidebarExpanded === 'true' if (current !== expanded) { this.setSidebarState(expanded) } }, setSidebarState(expanded) { // Convert boolean to string for consistency const expandedStr = expanded ? 'true' : 'false' // Update data-attribute (CSS reacts to this) this.el.dataset.sidebarExpanded = expandedStr // Persist to localStorage localStorage.setItem('sidebar-expanded', expandedStr) // Update ARIA for accessibility const toggleBtn = document.getElementById('sidebar-toggle') if (toggleBtn) { toggleBtn.setAttribute('aria-expanded', expandedStr) } }, destroyed() { // Cleanup delete window.toggleSidebar } } let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: { _csrf_token: csrfToken, timezone: getBrowserTimezone() }, 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("mobile-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", () => { // On desktop (lg:drawer-open), the mobile drawer must never open. // The hamburger label is lg:hidden, but guard here as a safety net // against any accidental toggles (e.g. from overlapping elements or JS). if (drawerToggle.checked && window.innerWidth >= 1024) { drawerToggle.checked = false return } 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() } }) } })