PageSourceSearch

https://railsblocks.com/assets/controllers/tree_view_controller-9e…8cac1e6e59fdb2b0bfdc28dd8edadc6a7c11a350.js

js railsblocks.com collected 2026-09-25 20:24:33 UTC 5,885 bytes, 165 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2
3export default class extends Controller {
4  static targets = ["icon", "content"];
5  static values = {
6    animate: { type: Boolean, default: true }, // Whether to animate the tree view
7  };
8
9  connect() {
10    // Initialize any folders that should start open
11    this.element.querySelectorAll('[data-state="open"]').forEach((el) => {
12      const button = el.previousElementSibling;
13      if (button) {
14        const icon = button.querySelector('[data-tree-view-target="icon"]');
15        if (icon) {
16          icon.classList.add("folder-open");
17          icon.innerHTML = this.openFolderSvg;
18        }
19      }
20    });
21
22    // Initialize closed folders with hidden attribute
23    this.element.querySelectorAll('[data-tree-view-target="content"][data-state="closed"]').forEach((el) => {
24      el.setAttribute("hidden", "");
25    });
26
27    this.addKeyboardListeners();
28  }
29
30  disconnect() {
31    this.element.removeEventListener("keydown", this.handleKeydownBound);
32    // Clean up transition listeners
33    this.contentTargets.forEach((content) => {
34      content.removeEventListener("transitionend", content._onTransitionEnd);
35    });
36  }
37
38  addKeyboardListeners() {
39    this.handleKeydownBound = this.handleKeydown.bind(this);
40    this.element.addEventListener("keydown", this.handleKeydownBound);
41  }
42
43  handleKeydown(event) {
44    const triggers = Array.from(
45      this.element.querySelectorAll('button[data-action="click->tree-view#toggle"], [data-tree-view-item]'),
46    );
47    const currentIndex = triggers.indexOf(document.activeElement);
48    if (currentIndex === -1) return;
49
50    switch (event.key) {
51      case "ArrowUp":
52        event.preventDefault();
53        let prevIndex = currentIndex;
54        do {
55          prevIndex = (prevIndex - 1 + triggers.length) % triggers.length;
56        } while (prevIndex !== currentIndex && this.isElementHidden(triggers[prevIndex]));
57        triggers[prevIndex].focus();
58        break;
59      case "ArrowDown":
60        event.preventDefault();
61        let nextIndex = currentIndex;
62        do {
63          nextIndex = (nextIndex + 1) % triggers.length;
64        } while (nextIndex !== currentIndex && this.isElementHidden(triggers[nextIndex]));
65        triggers[nextIndex].focus();
66        break;
67      case "Enter":
68      case " ":
69        event.preventDefault();
70        triggers[currentIndex].click();
71        break;
72    }
73  }
74
75  isElementHidden(element) {
76    let current = element;
77    while (current && current !== this.element) {
78      // Check if inside a closed tree content (grid-rows-[0fr])
79      const content = current.closest('[data-tree-view-target="content"]');
80      if (content && content.getAttribute("data-state") === "closed") {
81        return true;
82      }
83      current = current.parentElement;
84    }
85    return false;
86  }
87
88  toggle(event) {
89    const button = event.currentTarget;
90    const contentId = button.getAttribute("aria-controls");
91    if (!contentId) return;
92
93    // Use getElementById instead of querySelector to support ids that may begin with digits.
94    const content = document.getElementById(contentId);
95    if (!content || !this.element.contains(content)) return;
96
97    const icon = button.querySelector('[data-tree-view-target="icon"]');
98
99    const isOpen = button.getAttribute("aria-expanded") === "true";
100
101    // Toggle aria attributes
102    button.setAttribute("aria-expanded", !isOpen);
103
104    if (isOpen) {
105      // Closing: set state first, then hide after transition
106      content.setAttribute("data-state", "closed");
107
108      // Remove any existing listener
109      if (content._onTransitionEnd) {
110        content.removeEventListener("transitionend", content._onTransitionEnd);
111      }
112
113      // Add hidden after transition completes
114      content._onTransitionEnd = (e) => {
115        if (e.propertyName === "grid-template-rows" && content.getAttribute("data-state") === "closed") {
116          content.setAttribute("hidden", "");
117        }
118        content.removeEventListener("transitionend", content._onTransitionEnd);
119      };
120      content.addEventListener("transitionend", content._onTransitionEnd);
121    } else {
122      // Opening: remove hidden first, then change state
123      content.removeAttribute("hidden");
124      // Force reflow to ensure hidden is removed before animation starts
125      content.offsetHeight;
126      content.setAttribute("data-state", "open");
127    }
128
129    // Update icons
130    if (icon) {
131      if (isOpen) {
132        icon.classList.remove("folder-open");
133        icon.innerHTML = this.closedFolderSvg;
134      } else {
135        icon.classList.add("folder-open");
136        icon.innerHTML = this.openFolderSvg;
137      }
138    }
139
140    // Update button state
141    button.setAttribute("data-state", isOpen ? "closed" : "open");
142  }
143
144  // Toggle the checkbox associated with a file item
145  selectFile(event) {
146    const button = event.currentTarget;
147    const checkbox = button.previousElementSibling;
148    if (checkbox && checkbox.type === "checkbox" && !checkbox.disabled) {
149      checkbox.click();
150    }
151  }
152
153  get openFolderSvg() {
154    return `<g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor">
155      <path d="M5,14.75h-.75c-1.105,0-2-.895-2-2V4.75c0-1.105,.895-2,2-2h1.825c.587,0,1.144,.258,1.524,.705l1.524,1.795h4.626c1.105,0,2,.895,2,2v1"></path>
156      <path d="M16.148,13.27l.843-3.13c.257-.953-.461-1.89-1.448-1.89H6.15c-.678,0-1.272,.455-1.448,1.11l-.942,3.5c-.257,.953,.461,1.89,1.448,1.89H14.217c.904,0,1.696-.607,1.931-1.48Z"></path>
157    </g>`;
158  }
159
160  get closedFolderSvg() {
161    return `<g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor">
162      <path d="M13.75,5.25c1.105,0,2,.895,2,2v5.5c0,1.105-.895,2-2,2H4.25c-1.105,0-2-.895-2-2V4.75c0-1.105,.895-2,2-2h1.825c.587,0,1.144,.258,1.524,.705l1.524,1.795h4.626Z"></path>
163    </g>`;
164  }
165};

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.