PageSourceSearch

https://railsblocks.com/assets/controllers/accordion_controller-68…974f8241d23da31b691fe7a85fd105ef6bacd3e3.js

js railsblocks.com collected 2026-09-25 20:22:31 UTC 12,359 bytes, 398 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2
3export default class extends Controller {
4  static targets = ["item", "trigger", "content", "icon"];
5  static values = {
6    allowMultiple: { type: Boolean, default: false }, // Allow multiple items to be open at once
7  };
8
9  // Get only the direct child targets (not nested accordion targets)
10  get ownItemTargets() {
11    return this.itemTargets.filter((item) => item.closest('[data-controller="accordion"]') === this.element);
12  }
13
14  get ownTriggerTargets() {
15    return this.triggerTargets.filter((trigger) => trigger.closest('[data-controller="accordion"]') === this.element);
16  }
17
18  get ownContentTargets() {
19    return this.contentTargets.filter((content) => content.closest('[data-controller="accordion"]') === this.element);
20  }
21
22  get ownIconTargets() {
23    return this.iconTargets.filter((icon) => icon.closest('[data-controller="accordion"]') === this.element);
24  }
25
26  _applyOpenVisuals(index) {
27    const items = this.ownItemTargets;
28    const triggers = this.ownTriggerTargets;
29    const contents = this.ownContentTargets;
30    const icons = this.ownIconTargets;
31
32    if (!items[index] || !triggers[index] || !contents[index]) return;
33
34    const item = items[index];
35    const trigger = triggers[index];
36    const content = contents[index];
37    const icon = icons[index] || null;
38
39    // Remove hidden first to allow measurement
40    content.removeAttribute("hidden");
41    // Force reflow to ensure hidden is removed before animation starts
42    content.offsetHeight;
43
44    item.dataset.state = "open";
45    trigger.setAttribute("aria-expanded", "true");
46    trigger.dataset.state = "open";
47    content.dataset.state = "open";
48
49    // Update inner wrapper and body states for opacity transition
50    this._updateInnerStates(content, "open");
51
52    if (icon) {
53      const isPlusMinus = icon.querySelector('path[d*="M5 12h14"]');
54      const isLeftChevron = icon.classList.contains("-rotate-90");
55
56      if (isPlusMinus) {
57        // For plus/minus icons, CSS handles the animation via aria-expanded
58      } else if (isLeftChevron) {
59        icon.classList.add("rotate-0");
60        icon.classList.remove("-rotate-90");
61      } else {
62        icon.classList.add("rotate-180");
63      }
64    }
65  }
66
67  _applyClosedVisuals(index, skipHidden = false) {
68    const items = this.ownItemTargets;
69    const triggers = this.ownTriggerTargets;
70    const contents = this.ownContentTargets;
71    const icons = this.ownIconTargets;
72
73    if (!items[index] || !triggers[index] || !contents[index]) return;
74
75    const item = items[index];
76    const trigger = triggers[index];
77    const content = contents[index];
78    const icon = icons[index] || null;
79
80    item.dataset.state = "closed";
81    trigger.setAttribute("aria-expanded", "false");
82    trigger.dataset.state = "closed";
83    content.dataset.state = "closed";
84
85    // Update inner wrapper and body states for opacity transition
86    this._updateInnerStates(content, "closed");
87
88    // For initial setup, set hidden immediately; for animations, it's handled in close()
89    if (!skipHidden) {
90      content.setAttribute("hidden", "");
91    }
92
93    if (icon) {
94      const isPlusMinus = icon.classList.contains("scale-0");
95      const isLeftChevron = icon.classList.contains("rotate-0");
96
97      if (isPlusMinus) {
98        // For plus/minus icons, CSS handles the animation
99      } else if (isLeftChevron) {
100        icon.classList.remove("rotate-0");
101        icon.classList.add("-rotate-90");
102      } else {
103        icon.classList.remove("rotate-180");
104      }
105    }
106  }
107
108  connect() {
109    this.addKeyboardListeners();
110    this.activeIndices = new Set();
111
112    // Store bound function references for proper cleanup
113    this.boundHandleTriggerKeydown = this.handleTriggerKeydown.bind(this);
114    this.boundHandleKeydown = this.handleKeydown.bind(this);
115
116    const ownTriggers = this.ownTriggerTargets;
117    const ownItems = this.ownItemTargets;
118
119    // Ensure all trigger buttons are focusable and have keyboard listeners
120    ownTriggers.forEach((trigger) => {
121      if (!trigger.hasAttribute("tabindex")) {
122        trigger.setAttribute("tabindex", "0");
123      }
124      trigger.addEventListener("keydown", this.boundHandleTriggerKeydown);
125    });
126
127    const initiallyOpenIndexesFromDOM = [];
128    ownItems.forEach((item, index) => {
129      if (ownTriggers[index]?.getAttribute("aria-expanded") === "true") {
130        initiallyOpenIndexesFromDOM.push(index);
131      } else if (item.dataset.state === "open" && !initiallyOpenIndexesFromDOM.includes(index)) {
132        initiallyOpenIndexesFromDOM.push(index);
133      }
134    });
135
136    if (!this.allowMultipleValue) {
137      if (initiallyOpenIndexesFromDOM.length > 0) {
138        const indexToKeepOpen = initiallyOpenIndexesFromDOM[0];
139        this.activeIndices.add(indexToKeepOpen);
140        this._applyOpenVisuals(indexToKeepOpen);
141
142        for (let i = 0; i < ownItems.length; i++) {
143          if (i !== indexToKeepOpen) {
144            this._applyClosedVisuals(i);
145          }
146        }
147      } else {
148        ownItems.forEach((_, index) => this._applyClosedVisuals(index));
149      }
150    } else {
151      initiallyOpenIndexesFromDOM.forEach((index) => {
152        this.activeIndices.add(index);
153        this._applyOpenVisuals(index);
154      });
155      ownItems.forEach((_, index) => {
156        if (!this.activeIndices.has(index)) {
157          this._applyClosedVisuals(index);
158        }
159      });
160    }
161  }
162
163  disconnect() {
164    this.element.removeEventListener("keydown", this.boundHandleKeydown);
165
166    // Remove individual trigger listeners
167    this.ownTriggerTargets.forEach((trigger) => {
168      trigger.removeEventListener("keydown", this.boundHandleTriggerKeydown);
169    });
170
171    // Clean up transition listeners
172    this.ownContentTargets.forEach((content) => {
173      if (content._onTransitionEnd) {
174        content.removeEventListener("transitionend", content._onTransitionEnd);
175      }
176    });
177  }
178
179  addKeyboardListeners() {
180    this.element.addEventListener("keydown", this.boundHandleKeydown);
181  }
182
183  // Safari-compatible trigger-specific keydown handler
184  handleTriggerKeydown(event) {
185    const currentTrigger = event.currentTarget;
186    const ownTriggers = this.ownTriggerTargets;
187    const currentIndex = ownTriggers.indexOf(currentTrigger);
188
189    if (currentIndex === -1) return;
190
191    switch (event.key) {
192      case "ArrowUp":
193        event.preventDefault();
194        this.focusPreviousItem(currentIndex);
195        break;
196      case "ArrowDown":
197        event.preventDefault();
198        this.focusNextItem(currentIndex);
199        break;
200      case "Home":
201        event.preventDefault();
202        this.focusFirstItem();
203        break;
204      case "End":
205        event.preventDefault();
206        this.focusLastItem();
207        break;
208      case "Enter":
209      case " ": // Space key
210        event.preventDefault();
211        this.toggle(event);
212        break;
213    }
214  }
215
216  handleKeydown(event) {
217    const ownTriggers = this.ownTriggerTargets;
218    let currentIndex = -1;
219
220    ownTriggers.forEach((trigger, index) => {
221      if (trigger === document.activeElement || trigger.contains(document.activeElement)) {
222        currentIndex = index;
223      }
224    });
225
226    if (currentIndex === -1) return;
227
228    switch (event.key) {
229      case "ArrowUp":
230        event.preventDefault();
231        this.focusPreviousItem(currentIndex);
232        break;
233      case "ArrowDown":
234        event.preventDefault();
235        this.focusNextItem(currentIndex);
236        break;
237      case "Home":
238        event.preventDefault();
239        this.focusFirstItem();
240        break;
241      case "End":
242        event.preventDefault();
243        this.focusLastItem();
244        break;
245    }
246  }
247
248  focusPreviousItem(currentIndex) {
249    const ownTriggers = this.ownTriggerTargets;
250    const previousIndex = (currentIndex - 1 + ownTriggers.length) % ownTriggers.length;
251    ownTriggers[previousIndex].focus();
252  }
253
254  focusNextItem(currentIndex) {
255    const ownTriggers = this.ownTriggerTargets;
256    const nextIndex = (currentIndex + 1) % ownTriggers.length;
257    ownTriggers[nextIndex].focus();
258  }
259
260  focusFirstItem() {
261    this.ownTriggerTargets[0]?.focus();
262  }
263
264  focusLastItem() {
265    const ownTriggers = this.ownTriggerTargets;
266    ownTriggers[ownTriggers.length - 1]?.focus();
267  }
268
269  toggle(event) {
270    const ownTriggers = this.ownTriggerTargets;
271    const index = ownTriggers.indexOf(event.currentTarget);
272
273    if (index === -1) return;
274
275    if (this.activeIndices.has(index)) {
276      this.close(index);
277    } else {
278      if (!this.allowMultipleValue) {
279        this.activeIndices.forEach((i) => this.close(i));
280      }
281      this.open(index);
282    }
283  }
284
285  open(index) {
286    const items = this.ownItemTargets;
287    const triggers = this.ownTriggerTargets;
288    const contents = this.ownContentTargets;
289    const icons = this.ownIconTargets;
290
291    const item = items[index];
292    const trigger = triggers[index];
293    const content = contents[index];
294    const icon = icons[index];
295
296    if (!item || !trigger || !content) return;
297
298    // Remove hidden first to allow CSS Grid animation
299    content.removeAttribute("hidden");
300    // Force reflow to ensure hidden is removed before animation starts
301    content.offsetHeight;
302
303    // Set the open state - CSS Grid handles the animation
304    item.dataset.state = "open";
305    trigger.setAttribute("aria-expanded", "true");
306    trigger.dataset.state = "open";
307    content.dataset.state = "open";
308
309    // Update inner wrapper and body states for opacity transition
310    this._updateInnerStates(content, "open");
311
312    // Handle icon animation
313    if (icon) {
314      const isPlusMinus = icon.querySelector('path[d*="M5 12h14"]');
315      const isLeftChevron = icon.classList.contains("-rotate-90");
316
317      if (isPlusMinus) {
318        // CSS handles plus/minus via aria-expanded
319      } else if (isLeftChevron) {
320        icon.classList.add("rotate-0");
321        icon.classList.remove("-rotate-90");
322      } else {
323        icon.classList.add("rotate-180");
324      }
325    }
326
327    this.activeIndices.add(index);
328  }
329
330  close(index) {
331    const items = this.ownItemTargets;
332    const triggers = this.ownTriggerTargets;
333    const contents = this.ownContentTargets;
334    const icons = this.ownIconTargets;
335
336    const item = items[index];
337    const trigger = triggers[index];
338    const content = contents[index];
339    const icon = icons[index];
340
341    if (!item || !trigger || !content) return;
342
343    // Set closed state - CSS Grid handles the animation
344    item.dataset.state = "closed";
345    trigger.setAttribute("aria-expanded", "false");
346    trigger.dataset.state = "closed";
347    content.dataset.state = "closed";
348
349    // Update inner wrapper and body states for opacity transition
350    this._updateInnerStates(content, "closed");
351
352    // Handle icon animation
353    if (icon) {
354      const isPlusMinus = icon.querySelector('path[d*="M5 12h14"]');
355      const isLeftChevron = icon.classList.contains("rotate-0");
356
357      if (isPlusMinus) {
358        // CSS handles plus/minus via aria-expanded
359      } else if (isLeftChevron) {
360        icon.classList.remove("rotate-0");
361        icon.classList.add("-rotate-90");
362      } else {
363        icon.classList.remove("rotate-180");
364      }
365    }
366
367    // Remove any existing listener
368    if (content._onTransitionEnd) {
369      content.removeEventListener("transitionend", content._onTransitionEnd);
370    }
371
372    // Add hidden after transition completes for accessibility
373    content._onTransitionEnd = (e) => {
374      if (e.propertyName === "grid-template-rows" && content.dataset.state === "closed") {
375        content.setAttribute("hidden", "");
376      }
377      content.removeEventListener("transitionend", content._onTransitionEnd);
378    };
379    content.addEventListener("transitionend", content._onTransitionEnd);
380
381    this.activeIndices.delete(index);
382  }
383
384  // Update data-state on inner wrapper elements (for opacity transitions)
385  _updateInnerStates(content, state) {
386    // Find direct children with data-state attribute and update them
387    const innerElements = content.querySelectorAll(":scope > [data-state], :scope > * > [data-state]");
388    innerElements.forEach((el) => {
389      // Only update if it's not a nested accordion element
390      if (
391        !el.closest('[data-controller="accordion"]') ||
392        el.closest('[data-controller="accordion"]') === this.element
393      ) {
394        el.dataset.state = state;
395      }
396    });
397  }
398};

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.