mitgliederverwaltung/assets/js/app.js
Simon dfc616257d feat(member): let members tailor overview density and visible columns
The View dropdown drives row density and whether the Member and Address
fields render as composite cells or split into their underlying columns;
the column manager toggles visibility and resets to the curated default.
All choices persist per browser through the URL/session/cookie/global
chain, so a saved layout survives reloads without a per-account store.
2026-07-06 10:45:53 +02:00

653 lines
22 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"
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)
}
}
// 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)
}
}
// 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
}
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 next
// full page load (via the request cookie on the dead render and via connect
// params on the connected mount).
// 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()
}
})
}
})