All checks were successful
continuous-integration/drone/push Build is passing
## Description of the implemented changes The changes were: - [x] Bugfixing - [x] New Feature - [ ] Breaking Change - [x] Refactoring **OIDC-only mode improvements and UX tweaks (success toasts, unauthenticated redirect).** ## What has been changed? ### OIDC-only mode (new feature) - **Admin settings:** "Only OIDC sign-in" is an immediate toggle at the top of the OIDC section (no save button). Enabling it also turns off "Allow direct registration". When OIDC-only is on, the registration checkbox is disabled and shows a tooltip (DaisyUI `<.tooltip>`). - **Backend:** Password sign-in is forbidden via Ash policy (`OidcOnlyActive` check). Password registration is blocked via validation `OidcOnlyBlocksPasswordRegistration`. New plug `OidcOnlySignInRedirect`: when OIDC-only and OIDC are configured, GET `/sign-in` redirects to the OIDC flow; GET `/auth/user/password/sign_in_with_token` is rejected with redirect + flash. `AuthController.success/4` also rejects password sign-in when OIDC-only. - **Tests:** GlobalSettingsLive (OIDC-only UI), AuthController (redirect and password sign-in rejection), User authentication (register_with_password blocked when OIDC-only). ### UX / behaviour (no new feature flag) - **Success toasts:** Success flash messages auto-dismiss after 5 seconds via JS hook `FlashAutoDismiss` and optional `auto_clear_ms` on `<.flash>` (used for success in root layout and `flash_group`). - **Unauthenticated users:** Redirect to sign-in without the "You don't have permission to access this page" flash; that message is only shown to logged-in users who lack access. Logic in `LiveHelpers` and `CheckPagePermission` plug; test updated accordingly. ### Other - Layouts: comment about unprocessed join-request count no longer uses "TODO" (Credo). - Gettext: German translation for "Home" (Startseite); POT/PO kept in sync. - CHANGELOG: Unreleased section updated with the above. ## Definition of Done ### Code Quality - [x] No new technical depths - [x] Linting passed - [x] Documentation is added where needed (module docs, comments where non-obvious) ### Accessibility - [x] New elements are properly defined with html-tags (labels, aria-label on checkboxes) - [x] Colour contrast follows WCAG criteria (unchanged) - [x] Aria labels are added when needed (e.g. oidc-only and registration checkboxes) - [x] Everything is accessible by keyboard (toggles and buttons unchanged) - [x] Tab-Order is comprehensible - [x] All interactive elements have a visible focus (existing patterns) ### Testing - [x] Tests for new code are written (OIDC-only UI, auth controller, user auth; SMTP config builder and mailer) - [x] All tests pass - [ ] axe-core dev tools show no critical or major issues (not re-run for this PR; suggest spot-check on settings and sign-in) ## Additional Notes - **OIDC-only:** When the `OIDC_ONLY` env var is set, the toggle is read-only and shows "(From OIDC_ONLY)". When OIDC is not configured, the toggle is disabled. - **Invalidation:** Enabling OIDC-only sets `registration_enabled: false` in one update; disabling OIDC-only only updates `oidc_only` (registration left as-is). - **Review focus:** Plug order in router (OidcOnlySignInRedirect), policy/validation order in User, and that all OIDC-only paths (form, plug, controller) stay consistent. Reviewed-on: #474 Co-authored-by: Simon <s.thiessen@local-it.org> Co-committed-by: Simon <s.thiessen@local-it.org>
545 lines
18 KiB
JavaScript
545 lines
18 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)
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|