// 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) } } // Guarded native Popover API toggles: showPopover()/hidePopover() throw on a // detached element or a redundant open/close, so the popover hooks below share // these wrappers instead of repeating the try/catch + :popover-open check. function openPopover(el) { try { if (el && !el.matches(":popover-open")) el.showPopover() } catch (_e) {} } function closePopover(el) { try { if (el && el.matches(":popover-open")) el.hidePopover() } catch (_e) {} } // PopoverTooltip hook: shows a hint (referenced by data-tooltip-id) 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. Shown on hover // AND keyboard focus, positioned next to the trigger with getBoundingClientRect // (portable; CSS anchor-positioning is not yet supported in Safari/Firefox). // Used by the sortable column headers and the column-manager reset button. Hooks.PopoverTooltip = { 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 = () => { openPopover(this.tip) // Popover is measurable once open; positioning in the same tick avoids a // visible flash (JS runs before the browser paints). try { this.position() } catch (_e) {} } this.hide = () => closePopover(this.tip) 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) // Hide on activation: a click (sort/reset) triggers a LiveView re-render that // re-emits the tooltip element without the JS-set inline top/left, which would // otherwise leave the still-open popover stuck at the default (0,0) position, // covering the header. Closing it here keeps it out of the re-render; the next // hover repositions it cleanly. this.el.addEventListener("click", this.hide) }, 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) this.el.removeEventListener("click", this.hide) } } // SubfieldMenu hook: toggles a native Popover menu (top layer, so it escapes the // overview table's overflow clipping) for choosing the composite Member column's // sort sub-field. Attached to the "⋮" trigger; data-menu-id names the