// 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
menu. Handles click / keyboard open, roving arrow-key focus,
// Escape / click-away close, and closes after a selection (the LiveView
// re-renders on sort_composite anyway). The menu and its hint are placed via CSS
// anchor positioning (see .popover-menu / .popover-tooltip in app.css), so this
// hook does no coordinate math — it only toggles visibility, focus and keyboard.
Hooks.SubfieldMenu = {
mounted() {
this.menu = document.getElementById(this.el.dataset.menuId)
if (!this.menu || typeof this.menu.showPopover !== "function") return
// Hover/focus hint on the trigger, shown while the menu is closed. Same
// .popover-tooltip look as the sort-header tooltips; placement is CSS anchor
// positioning (no coordinate math).
this.tip = document.getElementById(this.el.dataset.tooltipId)
// Only hint while the menu is closed.
this.showTip = () => {
if (!this.menu.matches(":popover-open")) openPopover(this.tip)
}
this.hideTip = () => closePopover(this.tip)
this.items = () => [...this.menu.querySelectorAll('[role="menuitemradio"]')]
// Placement is pure CSS anchor positioning (see `.popover-menu` in app.css);
// this hook only toggles visibility, focus and keyboard interaction.
this.open = ({focusFirst = true} = {}) => {
this.hideTip()
if (!this.menu.matches(":popover-open")) this.menu.showPopover()
this.el.setAttribute("aria-expanded", "true")
if (focusFirst) {
const active = this.menu.querySelector('[aria-checked="true"]') || this.items()[0]
if (active) active.focus()
}
}
this.close = ({focusTrigger = false} = {}) => {
if (this.menu.matches(":popover-open")) this.menu.hidePopover()
this.el.setAttribute("aria-expanded", "false")
if (focusTrigger) this.el.focus()
}
this.onTriggerClick = (e) => {
e.preventDefault()
e.stopPropagation()
this.menu.matches(":popover-open") ? this.close() : this.open()
}
this.onTriggerKey = (e) => {
if (["ArrowDown", "Enter", " "].includes(e.key)) {
e.preventDefault()
this.open()
} else if (e.key === "ArrowUp") {
e.preventDefault()
this.open({focusFirst: false})
const items = this.items()
if (items.length) items[items.length - 1].focus()
}
}
this.onMenuKey = (e) => {
const items = this.items()
const i = items.indexOf(document.activeElement)
if (e.key === "ArrowDown") {
e.preventDefault()
;(items[i + 1] || items[0]).focus()
} else if (e.key === "ArrowUp") {
e.preventDefault()
;(items[i - 1] || items[items.length - 1]).focus()
} else if (e.key === "Escape") {
e.preventDefault()
this.close({focusTrigger: true})
} else if (e.key === "Tab") {
this.close()
}
}
// A selection fires the LiveView sort; drop the menu.
this.onMenuClick = () => this.close()
this.onDocPointer = (e) => {
if (!this.el.contains(e.target) && !this.menu.contains(e.target)) this.close()
}
this.el.addEventListener("click", this.onTriggerClick)
this.el.addEventListener("keydown", this.onTriggerKey)
this.el.addEventListener("mouseenter", this.showTip)
this.el.addEventListener("focus", this.showTip)
this.el.addEventListener("mouseleave", this.hideTip)
this.el.addEventListener("blur", this.hideTip)
this.menu.addEventListener("keydown", this.onMenuKey)
this.menu.addEventListener("click", this.onMenuClick)
document.addEventListener("pointerdown", this.onDocPointer, true)
},
destroyed() {
this.el.removeEventListener("click", this.onTriggerClick)
this.el.removeEventListener("keydown", this.onTriggerKey)
this.el.removeEventListener("mouseenter", this.showTip)
this.el.removeEventListener("focus", this.showTip)
this.el.removeEventListener("mouseleave", this.hideTip)
this.el.removeEventListener("blur", this.hideTip)
this.hideTip()
if (this.menu) {
this.menu.removeEventListener("keydown", this.onMenuKey)
this.menu.removeEventListener("click", this.onMenuClick)
if (this.menu.matches(":popover-open")) this.menu.hidePopover()
}
document.removeEventListener("pointerdown", this.onDocPointer, 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. This is the SOLE load
// trigger: the table deliberately carries no phx-viewport-bottom binding, whose
// built-in InfiniteScroll would scroll the sentinel row back into view after each
// load and cascade into loading every page. Duplicate loads are still harmless —
// 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()
}
})
}
})