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.