PageSourceSearch

https://my.pressable.com/assets/roadmap-805d71f2.js

js pressable.com collected 2026-09-24 15:21:44 UTC 9,422 bytes, 231 lines download raw bytes

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.