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.