1// Product Roadmap (ENG-5890) â entry module for the standalone /roadmap page (layouts/roadmap). 2// Boots Turbo (for the timeline Turbo Frame) and registers one scoped Stimulus controller. 3// The controller: 4// - opens the project detail offcanvas when a card is clicked (title, summary, the full 5// Description document rendered from Markdown, and a "View in Linear" link), 6// - auto-submits the filter form when a checkbox is toggled, so only the timeline frame 7// reloads (no full-page refresh) and the board updates in place, 8// - debounce-submits that same form when the topbar search box changes (mirroring its value 9// into the form's hidden `q` field), so title search composes with the facets and timeframe, 10// - re-opens the active filter dropdown after the frame swaps, so several boxes can be 11// unchecked in a row, and closes dropdowns on an outside click. 12import "@hotwired/turbo-rails"; 13import { Application, Controller } from "@hotwired/stimulus"; 14import { marked } from "marked"; 15import DOMPurify from "dompurify"; 16 17// Render Linear's Markdown Description to sanitized HTML; links open in a new tab. 18const renderContent = (markdown) => { 19 const html = DOMPurify.sanitize(marked.parse(markdown, { gfm: true, breaks: true })); 20 const template = document.createElement("template"); 21 template.innerHTML = html; 22 template.content.querySelectorAll("a[href]").forEach((anchor) => { 23 anchor.target = "_blank"; 24 anchor.rel = "noopener"; 25 }); 26 return template.innerHTML; 27}; 28 29class RoadmapController extends Controller { 30 connect() { 31 this.activeCard = null; 32 this.triggerElement = null; 33 this.openFilter = null; 34 this.searchTimer = null; 35 36 this.onClick = this.onClick.bind(this); 37 this.onChange = this.onChange.bind(this); 38 this.onInput = this.onInput.bind(this); 39 this.onKeydown = this.onKeydown.bind(this); 40 this.onFrameRender = this.onFrameRender.bind(this); 41 42 document.addEventListener("click", this.onClick); 43 document.addEventListener("change", this.onChange); 44 document.addEventListener("input", this.onInput); 45 document.addEventListener("keydown", this.onKeydown); 46 document.addEventListener("turbo:frame-render", this.onFrameRender); 47 } 48 49 disconnect() { 50 document.removeEventListener("click", this.onClick); 51 document.removeEventListener("change", this.onChange); 52 document.removeEventListener("input", this.onInput); 53 document.removeEventListener("keydown", this.onKeydown); 54 document.removeEventListener("turbo:frame-render", this.onFrameRender); 55 clearTimeout(this.searchTimer); 56 this.closeOffcanvas(); 57 } 58 59 // --- Offcanvas ----------------------------------------------------------- 60 61 get offcanvas() { 62 return this.element.querySelector("[data-roadmap-offcanvas]"); 63 } 64 65 get backdrop() { 66 return this.element.querySelector("[data-roadmap-offcanvas-backdrop]"); 67 } 68 69 openOffcanvas(card) { 70 const offcanvas = this.offcanvas; 71 const backdrop = this.backdrop; 72 if (!offcanvas || !backdrop) return; 73 74 if (this.activeCard) this.activeCard.classList.remove("is-active"); 75 this.activeCard = card; 76 77 offcanvas.querySelector("[data-roadmap-offcanvas-title]").textContent = card.dataset.roadmapTitle || ""; 78 79 const summary = (card.dataset.roadmapSummary || "").trim(); 80 const content = (card.dataset.roadmapContent || "").trim(); 81 const summaryEl = offcanvas.querySelector("[data-roadmap-offcanvas-summary]"); 82 const contentEl = offcanvas.querySelector("[data-roadmap-offcanvas-content]"); 83 summaryEl.textContent = summary; 84 summaryEl.hidden = summary.length === 0; 85 if (content) { 86 contentEl.innerHTML = renderContent(content); 87 contentEl.hidden = false; 88 } else if (summary) { 89 contentEl.hidden = true; // summary already stands in for the description 90 } else { 91 contentEl.textContent = "No description"; 92 contentEl.hidden = false; 93 } 94 95 // Defense-in-depth: only http(s) hrefs reach the "View in Linear" link. Unlike the 96 // Markdown body, this href bypasses DOMPurify, so reject any other scheme (javascript:, 97 // data:, â¦) and fall back to "#". The source is Linear's system URL, but it is untyped. 98 const roadmapUrl = (card.dataset.roadmapUrl || "").trim(); 99 offcanvas.querySelector("[data-roadmap-offcanvas-link]").href = 100 /^https?:\/\//i.test(roadmapUrl) ? roadmapUrl : "#"; 101 102 offcanvas.classList.add("is-open"); 103 offcanvas.setAttribute("aria-hidden", "false"); 104 backdrop.hidden = false;
105 card.classList.add("is-active"); 106 document.body.classList.add("roadmap-offcanvas-open"); 107 108 // The panel is role="dialog" aria-modal="true", so move focus into it and remember the 109 // trigger to restore focus on close (keyboard/screen-reader accessibility). 110 this.triggerElement = card; 111 offcanvas.querySelector("[data-roadmap-offcanvas-close]")?.focus(); 112 } 113 114 closeOffcanvas() { 115 const offcanvas = this.offcanvas; 116 const backdrop = this.backdrop; 117 if (offcanvas) { 118 offcanvas.classList.remove("is-open"); 119 offcanvas.setAttribute("aria-hidden", "true"); 120 } 121 if (backdrop) backdrop.hidden = true; 122 if (this.activeCard) this.activeCard.classList.remove("is-active"); 123 this.activeCard = null; 124 document.body.classList.remove("roadmap-offcanvas-open"); 125 126 // Return focus to the card that opened the dialog (no-op if it was removed by a frame swap). 127 this.triggerElement?.focus(); 128 this.triggerElement = null; 129 } 130 131 // --- Delegated event handlers ------------------------------------------- 132 133 onClick(event) { 134 // Close any open filter dropdown when the click lands outside it. 135 this.element.querySelectorAll("details.roadmap-filter[open]").forEach((details) => { 136 if (!details.contains(event.target)) details.open = false; 137 }); 138 139 const card = event.target.closest("[data-roadmap-card]"); 140 if (card) { 141 this.openOffcanvas(card); 142 return; 143 } 144 145 if (event.target.closest("[data-roadmap-offcanvas-close]") || event.target === this.backdrop) { 146 this.closeOffcanvas(); 147 } 148 } 149 150 onChange(event) { 151 const checkbox = event.target; 152 if (!checkbox.matches('[data-roadmap-filters] input[type="checkbox"]')) return; 153 154 // Remember which dropdown is open so it can be re-opened after the frame swaps. 155 const details = checkbox.closest("details[data-roadmap-filter]"); 156 this.openFilter = details ? details.dataset.roadmapFilter : null; 157 158 // Carry any pending search text into the form so toggling a facet keeps the active query, and 159 // cancel the debounced search submit so the facet toggle doesn't fire a second frame reload. 160 if (checkbox.form) { 161 clearTimeout(this.searchTimer); 162 this.syncSearchField(checkbox.form); 163 } 164 165 // requestSubmit() (not submit()) fires a submit event, which Turbo intercepts to 166 // navigate the enclosing frame instead of reloading the page. 167 if (checkbox.form) checkbox.form.requestSubmit(); 168 } 169 170 // Debounced auto-submit when the topbar search box changes, so a burst of keystrokes triggers 171 // one frame reload. The box lives outside the Turbo Frame, so it keeps focus and caret across 172 // the swap â no restore needed (unlike the in-frame filter dropdowns). 173 onInput(event) { 174 if (!event.target.matches("[data-roadmap-search]")) return; 175 176 clearTimeout(this.searchTimer); 177 this.searchTimer = setTimeout(() => this.submitSearch(), 250); 178 } 179 180 submitSearch() { 181 const form = this.element.querySelector("[data-roadmap-filters]"); 182 if (!form) return; // no board rendered (empty mirror) â nothing to filter 183 184 // A search submit aborts any in-flight facet submit (turbo-rails cancels the prior frame 185 // submission), so that submit's turbo:frame-render never fires and never consumes openFilter. 186 // Clear it here, or a dropdown left pending by a just-toggled box pops open over the board 187 // while you're typing in the search field. 188 this.openFilter = null; 189 this.syncSearchField(form); 190 form.requestSubmit(); 191 } 192 193 // Copy the topbar search box's value into the filter form's hidden `q` field so the query is 194 // submitted server-side alongside the facets and the timeframe. 195 syncSearchField(form) { 196 const searchInput = this.element.querySelector("[data-roadmap-search]"); 197 const field = form.querySelector("[data-roadmap-search-field]"); 198 if (searchInput && field) field.value = searchInput.value; 199 } 200 201 onKeydown(event) { 202 // Enter in the search box submits immediately, skipping the debounce. 203 if (event.key === "Enter" && event.target.matches && event.target.matches("[data-roadmap-search]")) { 204 event.preventDefault(); 205 clearTimeout(this.searchTimer); 206 this.submitSearch(); 207 return; 208 } 209 if (event.key === "Escape") { 210 this.closeOffcanvas(); 211 return; 212 } 213 if (event.key === "Enter" || event.key === " ") { 214 const card = event.target.closest && event.target.closest("[data-roadmap-card]"); 215 if (card) { 216 event.preventDefault(); 217 this.openOffcanvas(card); 218 } 219 } 220 } 221
222 onFrameRender() { 223 if (!this.openFilter) return; 224 const details = this.element.querySelector(`details[data-roadmap-filter="${this.openFilter}"]`); 225 if (details) details.open = true; 226 this.openFilter = null; 227 } 228} 229 230const application = Application.start(); 231application.register("roadmap", RoadmapController);
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.