1import { Controller } from "@hotwired/stimulus" 2 3// Connects to data-controller="open-toggle" 4export default class extends Controller { 5 static targets = ["target", "control"] 6 static values = { trap: Boolean } 7 8 toggle() { 9 const open = this.targetTarget.classList.toggle('open') 10 if (this.hasControlTarget) { 11 this.controlTarget.setAttribute('aria-expanded', open) 12 } 13 if (this.trapValue) { 14 open ? this._enterTrap() : this._exitTrap() 15 } 16 } 17 18 toggleSelf() { 19 this.element.classList.toggle('open') 20 } 21 22 disconnect() { 23 this._exitTrap() 24 } 25 26 // BOU-2541 (WCAG 2.1.2): while the full-screen mobile menu is open, focus 27 // moves into it, Tab/Shift+Tab cycle through it (plus the trigger, which 28 // doubles as the close button), and Escape closes it and hands focus back. 29 // Opt-in via data-open-toggle-trap-value so the plain disclosure uses of 30 // this controller (text cards, desktop nav) keep their default tab order. 31 _enterTrap() { 32 this._trapKeydown = (event) => this._handleTrapKeydown(event) 33 this.element.addEventListener('keydown', this._trapKeydown) 34 const first = this._focusables().find((el) => this.targetTarget.contains(el)) 35 if (first) first.focus() 36 } 37 38 _exitTrap() { 39 if (!this._trapKeydown) return 40 this.element.removeEventListener('keydown', this._trapKeydown) 41 this._trapKeydown = null 42 if (this.hasControlTarget) this.controlTarget.focus() 43 } 44 45 _handleTrapKeydown(event) { 46 if (event.key === 'Escape') { 47 event.preventDefault() 48 this.toggle() 49 return 50 } 51 if (event.key !== 'Tab') return 52 const focusables = this._focusables() 53 if (focusables.length === 0) return 54 const first = focusables[0] 55 const last = focusables[focusables.length - 1] 56 if (event.shiftKey && document.activeElement === first) { 57 event.preventDefault() 58 last.focus() 59 } else if (!event.shiftKey && document.activeElement === last) { 60 event.preventDefault() 61 first.focus() 62 } 63 } 64 65 _focusables() { 66 const selector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' 67 const candidates = this.hasControlTarget 68 ? [this.controlTarget, ...this.targetTarget.querySelectorAll(selector)] 69 : [...this.targetTarget.querySelectorAll(selector)] 70 // checkVisibility (not offsetParent, which is null for every descendant 71 // of the position:fixed menu) skips anything display:none â closed 72 // sub-menus, desktop-only items â so the cycle only visits what's visible. 73 return candidates.filter((el) => el.checkVisibility()) 74 } 75}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.