PageSourceSearch

https://a4b.ai/assets/controllers/modal_controller-3ee49bb0.js

js a4b.ai collected 2026-10-05 14:32:05 UTC 5,195 bytes, 159 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2
3/**
4 * Modal Controller
5 *
6 * Provides accessible modal dialog functionality with:
7 * - Focus trapping within the modal
8 * - Focus restoration when closed
9 * - ARIA attributes management
10 * - Keyboard navigation (Escape to close, Tab trapping)
11 */
12export default class extends Controller {
13  connect() {
14    this.$trigger = this.element;
15    this.modalId = this.$trigger.dataset.target;
16    this.$target = document.getElementById(this.modalId);
17    this.previousActiveElement = null;
18
19    this.addEventListeners();
20  }
21
22  disconnect() {
23    document.removeEventListener("keydown", this.boundHandleKeyDown);
24  }
25
26  addEventListeners() {
27    this.$trigger.addEventListener("click", this.handleTriggerClick.bind(this));
28    this.addCloseEventListeners();
29    this.boundHandleKeyDown = this.handleKeyDown.bind(this);
30    document.addEventListener("keydown", this.boundHandleKeyDown);
31  }
32
33  handleTriggerClick() {
34    this.openModal(this.$target);
35  }
36
37  addCloseEventListeners() {
38    const closeElements = this.$target.querySelectorAll(
39      ".modal-background, .modal-close, .modal-card-head .delete"
40    );
41    closeElements.forEach(($close) => {
42      const $modal = $close.closest(".modal");
43      $close.addEventListener("click", () => this.closeModal($modal));
44    });
45  }
46
47  handleKeyDown(event) {
48    // Scoped to this controller's OWN dialog, not a document-wide
49    // `.modal.is-active` lookup. The page can hold dialogs belonging to other
50    // controllers (app--assets--force-delete-modal, and the .modal rendered by
51    // App::CapacityLimitModalComponent); with a global lookup, whichever
52    // instance connected first would close someone else's dialog using its own
53    // state — leaving focus unrestored and resetting aria-expanded on the wrong
54    // trigger, while the owning controller's close() never ran.
55    if (!this.$target || !this.$target.classList.contains("is-active")) return;
56
57    if (event.key === "Escape") {
58      this.closeModal(this.$target);
59      event.preventDefault();
60    } else if (event.key === "Tab") {
61      this.trapFocus(this.$target, event);
62    }
63  }
64
65  /**
66   * Trap focus within the modal for keyboard navigation
67   */
68  trapFocus($modal, event) {
69    const focusableElements = this.getFocusableElements($modal);
70
71    if (focusableElements.length === 0) return;
72
73    const firstFocusable = focusableElements[0];
74    const lastFocusable = focusableElements[focusableElements.length - 1];
75
76    if (event.shiftKey) {
77      // Shift + Tab: if on first element, go to last
78      if (document.activeElement === firstFocusable) {
79        lastFocusable.focus();
80        event.preventDefault();
81      }
82    } else {
83      // Tab: if on last element, go to first
84      if (document.activeElement === lastFocusable) {
85        firstFocusable.focus();
86        event.preventDefault();
87      }
88    }
89  }
90
91  /**
92   * Get all focusable elements in the modal
93   */
94  getFocusableElements($modal) {
95    const focusableSelector =
96      'a[href]:not([tabindex="-1"]), button:not([disabled]):not([tabindex="-1"]), textarea:not([disabled]):not([tabindex="-1"]), input:not([disabled]):not([type="hidden"]):not([tabindex="-1"]), select:not([disabled]):not([tabindex="-1"]), [tabindex]:not([tabindex="-1"])';
97
98    return Array.from($modal.querySelectorAll(focusableSelector)).filter(
99      (el) => {
100        // Exclude elements within nested aria-hidden containers (like modal-background)
101        // but not the modal root itself which may have aria-hidden when closed
102        const hiddenAncestor = el.closest('[aria-hidden="true"]');
103        return !hiddenAncestor || hiddenAncestor === $modal;
104      }
105    );
106  }
107
108  /**
109   * Get the first focusable element in the modal
110   */
111  getFirstFocusable($modal) {
112    return this.getFocusableElements($modal)[0];
113  }
114
115  openModal($el) {
116    // Store the element that had focus before opening
117    this.previousActiveElement = document.activeElement;
118
119    // Open modal
120    $el.classList.add("is-active");
121
122    // Set ARIA attributes
123    $el.setAttribute("aria-hidden", "false");
124    this.$trigger.setAttribute("aria-expanded", "true");
125
126    // Prevent body scroll
127    document.documentElement.classList.add("is-clipped");
128
129    // Focus the first focusable element in the modal
130    requestAnimationFrame(() => {
131      const firstFocusable = this.getFirstFocusable($el);
132      if (firstFocusable) {
133        firstFocusable.focus();
134      }
135    });
136  }
137
138  closeModal($el) {
139    $el.classList.remove("is-active");
140
141    // Set ARIA attributes
142    $el.setAttribute("aria-hidden", "true");
143    if (this.$trigger) {
144      this.$trigger.setAttribute("aria-expanded", "false");
145    }
146
147    // Restore body scroll
148    document.documentElement.classList.remove("is-clipped");
149
150    // Restore focus to the trigger element. isConnected: the element that had
151    // focus may have been removed while the dialog was open (a Turbo Frame
152    // elsewhere being replaced), in which case focus() silently does nothing.
153    if (this.previousActiveElement && this.previousActiveElement.isConnected) {
154      this.previousActiveElement.focus();
155    }
156    this.previousActiveElement = null;
157  }
158
159}

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.