PageSourceSearch

https://resources.better.org.uk/assets/gll_component_library/controllers/open_toggle_controller-b5ea6a31.js

js better.org.uk collected 2026-09-24 09:04:32 UTC 2,668 bytes, 75 lines download raw bytes

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.