// 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 = {} // IndeterminateCheckbox: the `indeterminate` state of a checkbox is a JS // property, not an HTML attribute, so it cannot be set from the server render. // Mirror it from the data-indeterminate attribute on mount and every update // (e.g. select-all reflecting a partial selection — WCAG select-all semantics). Hooks.IndeterminateCheckbox = { updateIndeterminate() { this.el.indeterminate = this.el.dataset.indeterminate === "true" }, mounted() { this.updateIndeterminate() }, updated() { this.updateIndeterminate() } } // 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) } } // RowSelectionGuard: distinguish drag-to-select-text from a plain click on the members table. // LiveView fires the row navigation push (select_row_and_navigate) on any click. When the user // drags across a cell to select text (e.g. an email to copy) and releases, the mouseup produces a // non-empty text selection; in that case we swallow the click in the capture phase so navigation is // suppressed. A plain click leaves the selection collapsed and navigates as before. Hooks.RowSelectionGuard = { mounted() { this.handleClickCapture = (e) => { const selection = window.getSelection() if (selection && !selection.isCollapsed && selection.toString().trim() !== "") { e.preventDefault() e.stopPropagation() } } // Capture phase so this runs before LiveView's bubbling phx-click handler. this.el.addEventListener("click", this.handleClickCapture, true) }, destroyed() { this.el.removeEventListener("click", this.handleClickCapture, true) } } // StickyViewportWidth: size a `position: sticky; left: 0` element to the visible width // (clientWidth) of its horizontally-scrolling container. The infinite-scroll loading bar // lives in a full-width table row that is wider than the viewport when the table scrolls // horizontally; matching the sticky element to the container's visible width keeps its // centered content pinned at the bottom-center of the visible area instead of drifting off // with the scrolled table. The data-scroll-container attribute names the container element id. Hooks.StickyViewportWidth = { mounted() { this.container = document.getElementById(this.el.dataset.scrollContainer) this.sync = () => { if (this.container) this.el.style.width = this.container.clientWidth + "px" } this.sync() window.addEventListener("resize", this.sync) }, updated() { this.sync() }, destroyed() { window.removeEventListener("resize", this.sync) } } // 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) } } // SortTooltip hook: shows the sort-header hint in the browser TOP LAYER via the // native Popover API, so it escapes the members table's overflow clipping // (overflow-x:auto forces overflow-y:auto, which clips a CSS tooltip below the // header) and paints above the sticky header, neighbor cells, and the pinned // checkbox column's fade — no z-index fight. The popover is shown on hover AND // keyboard focus and positioned next to the header with getBoundingClientRect // (portable; CSS anchor-positioning is not yet supported in Safari/Firefox). Hooks.SortTooltip = { mounted() { this.tip = document.getElementById(this.el.dataset.tooltipId) // Feature-detect: without Popover API support fall back to no visual tooltip // (the button's aria-label still conveys the hint to assistive tech). if (!this.tip || typeof this.tip.showPopover !== "function") return this.position = () => { const btn = this.el.getBoundingClientRect() const tip = this.tip.getBoundingClientRect() const gap = 6 // Prefer just below the header; flip above when there is no room below. let top = btn.bottom + gap if (top + tip.height > window.innerHeight && btn.top - gap - tip.height >= 0) { top = btn.top - gap - tip.height } // Left-align to the header, but keep the whole bubble on-screen. let left = btn.left const maxLeft = window.innerWidth - tip.width - gap if (left > maxLeft) left = maxLeft if (left < gap) left = gap this.tip.style.top = `${Math.round(top)}px` this.tip.style.left = `${Math.round(left)}px` } this.show = () => { try { if (!this.tip.matches(":popover-open")) this.tip.showPopover() // showPopover() makes it measurable; positioning in the same tick avoids // a visible flash (JS runs before the browser paints). this.position() } catch (_e) {} } this.hide = () => { try { if (this.tip.matches(":popover-open")) this.tip.hidePopover() } catch (_e) {} } this.onKey = (e) => { if (e.key === "Escape") this.hide() } this.el.addEventListener("mouseenter", this.show) this.el.addEventListener("focus", this.show) this.el.addEventListener("mouseleave", this.hide) this.el.addEventListener("blur", this.hide) this.el.addEventListener("keydown", this.onKey) }, destroyed() { if (this.hide) this.hide() this.el.removeEventListener("mouseenter", this.show) this.el.removeEventListener("focus", this.show) this.el.removeEventListener("mouseleave", this.hide) this.el.removeEventListener("blur", this.hide) this.el.removeEventListener("keydown", this.onKey) } } // 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 } } // LoadMorePrefetch: fires an infinite-scroll load event as the sentinel // approaches the viewport, rather than only once it is fully scrolled into view. // An IntersectionObserver rooted at the scroll container with a positive bottom // rootMargin treats the sentinel as visible while it is still that many pixels // below the fold, so the next page loads ahead of time. It complements the // built-in phx-viewport-bottom binding (which stays as a reliable backstop); // duplicate loads are harmless because the server guards on "more?" and the // stream de-duplicates rows by id. Hooks.LoadMorePrefetch = { mounted() { const container = document.getElementById(this.el.dataset.scrollContainer) const rootMargin = `0px 0px ${this.el.dataset.margin || "600px"} 0px` const event = this.el.dataset.event || "load_more" let lastPush = 0 this.observer = new IntersectionObserver((entries) => { if (!entries.some((entry) => entry.isIntersecting)) return const now = Date.now() if (now - lastPush < 400) return lastPush = now this.pushEvent(event) }, {root: container || null, rootMargin, threshold: 0}) this.observer.observe(this.el) }, destroyed() { if (this.observer) this.observer.disconnect() } } // Reads a browser cookie value by name (used to echo the persisted member // view-settings to the server on connect, since the live socket's connect-info // does not expose cookies). function getCookie(name) { const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)")) return match ? decodeURIComponent(match[1]) : null } // `params` is a function so it is re-evaluated on every channel join, not just // at socket construction. This matters for the member view settings: a live // navigation back to the overview re-mounts the LiveView on the same socket, so // the cookie must be re-read at that point — a static params object would echo // the stale value captured at initial page load (view settings would appear to // reset until a hard reload). let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: () => ({ _csrf_token: csrfToken, timezone: getBrowserTimezone(), view_settings: getCookie("member_view_settings") }), hooks: Hooks }) // Persist the member-overview view settings per browser/device (§1.5/§1.20). The // LiveView pushes "store-view-settings" (density + compact field toggles) on // change; we write a long-lived cookie that the LiveView reads back on the dead // render (via the request cookie) and on every connected mount/join (via the // connect params, re-evaluated per join — see the LiveSocket params function). // Return the members list to the top after a sort or filter change. The server // resets the keyset stream to page 1 on those changes and pushes this event; a // user scrolled down would otherwise be stranded past the shorter content with // infinite scroll not re-arming. No-op if the scroll container is absent. window.addEventListener("phx:members:scroll-top", () => { const el = document.getElementById("members-table-guard") if (el) el.scrollTop = 0 }) window.addEventListener("phx:store-view-settings", (e) => { const json = e.detail && e.detail.view_settings if (typeof json === "string" && json.length > 0) { const maxAge = 365 * 24 * 60 * 60 document.cookie = `member_view_settings=${encodeURIComponent(json)};path=/;max-age=${maxAge};samesite=lax` } }) // 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() } }) } })