openproject-primer_view_components 0.85.0 → 0.86.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/app/assets/javascripts/primer_view_components.js +1 -1
  4. data/app/assets/javascripts/primer_view_components.js.map +1 -1
  5. data/app/assets/styles/primer_view_components.css +1 -1
  6. data/app/assets/styles/primer_view_components.css.map +1 -1
  7. data/app/components/primer/alpha/tree_view/leaf_node.html.erb +5 -0
  8. data/app/components/primer/alpha/tree_view/leaf_node.rb +18 -0
  9. data/app/components/primer/alpha/tree_view/node.html.erb +3 -0
  10. data/app/components/primer/alpha/tree_view/node.rb +10 -0
  11. data/app/components/primer/alpha/tree_view/sub_tree_node.html.erb +5 -0
  12. data/app/components/primer/alpha/tree_view/sub_tree_node.rb +18 -0
  13. data/app/components/primer/alpha/tree_view/trailing_action.html.erb +3 -0
  14. data/app/components/primer/alpha/tree_view/trailing_action.rb +18 -0
  15. data/app/components/primer/alpha/tree_view.css +1 -1
  16. data/app/components/primer/alpha/tree_view.css.json +4 -1
  17. data/app/components/primer/alpha/tree_view.css.map +1 -1
  18. data/app/components/primer/alpha/tree_view.pcss +22 -6
  19. data/app/components/primer/open_project/filterable_tree_view/sub_tree.rb +6 -6
  20. data/app/components/primer/open_project/filterable_tree_view.css +1 -0
  21. data/app/components/primer/open_project/filterable_tree_view.css.json +14 -0
  22. data/app/components/primer/open_project/filterable_tree_view.css.map +1 -0
  23. data/app/components/primer/open_project/filterable_tree_view.html.erb +26 -14
  24. data/app/components/primer/open_project/filterable_tree_view.js +294 -5
  25. data/app/components/primer/open_project/filterable_tree_view.pcss +57 -0
  26. data/app/components/primer/open_project/filterable_tree_view.rb +58 -10
  27. data/app/components/primer/open_project/filterable_tree_view.ts +316 -4
  28. data/app/components/primer/primer.pcss +1 -0
  29. data/app/controllers/primer/view_components/filterable_tree_view_items_controller.rb +192 -0
  30. data/app/views/primer/view_components/filterable_tree_view_items/_node.html.erb +38 -0
  31. data/app/views/primer/view_components/filterable_tree_view_items/async_form_tree.html.erb +9 -0
  32. data/app/views/primer/view_components/filterable_tree_view_items/index.html.erb +6 -0
  33. data/config/routes.rb +4 -0
  34. data/lib/primer/view_components/version.rb +2 -2
  35. data/previews/primer/alpha/tree_view_preview/leaf_node_playground.html.erb +4 -0
  36. data/previews/primer/alpha/tree_view_preview.rb +3 -0
  37. data/previews/primer/open_project/filterable_tree_view_preview/async.html.erb +3 -0
  38. data/previews/primer/open_project/filterable_tree_view_preview/async_form_input.html.erb +9 -0
  39. data/previews/primer/open_project/filterable_tree_view_preview/link_nodes.html.erb +18 -0
  40. data/previews/primer/open_project/filterable_tree_view_preview.rb +23 -2
  41. data/static/arguments.json +22 -0
  42. data/static/audited_at.json +1 -0
  43. data/static/classes.json +9 -0
  44. data/static/constants.json +9 -0
  45. data/static/info_arch.json +123 -1
  46. data/static/previews.json +39 -0
  47. data/static/statuses.json +1 -0
  48. metadata +17 -10
@@ -15,26 +15,57 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
15
15
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
16
16
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
17
17
  };
18
- var _FilterableTreeViewElement_instances, _FilterableTreeViewElement_filterFn, _FilterableTreeViewElement_abortController, _FilterableTreeViewElement_stateMap, _FilterableTreeViewElement_handleTreeViewEvent, _FilterableTreeViewElement_handleTreeViewNodeChecked, _FilterableTreeViewElement_restoreNodeState, _FilterableTreeViewElement_handleFilterModeEvent, _FilterableTreeViewElement_handleFilterInputEvent, _FilterableTreeViewElement_handleIncludeSubItemsCheckBoxEvent, _FilterableTreeViewElement_includeSubItems, _FilterableTreeViewElement_includeSubItemsUnder, _FilterableTreeViewElement_restoreAllNodeStates, _FilterableTreeViewElement_applyFilterOptions, _FilterableTreeViewElement_applyHighlights, _FilterableTreeViewElement_applyManualHighlights, _FilterableTreeViewElement_removeHighlights;
18
+ var _FilterableTreeViewElement_instances, _FilterableTreeViewElement_filterFn, _FilterableTreeViewElement_abortController, _FilterableTreeViewElement_stateMap, _FilterableTreeViewElement_debounceTimer, _FilterableTreeViewElement_fetchAbortController, _FilterableTreeViewElement_expansionSnapshot, _FilterableTreeViewElement_selectedModeSnapshot, _FilterableTreeViewElement_checkedNodeIds, _FilterableTreeViewElement_checkedNodeFormPayloads, _FilterableTreeViewElement_isFiltered, _FilterableTreeViewElement_src_get, _FilterableTreeViewElement_isAsyncMode_get, _FilterableTreeViewElement_handleTreeViewEvent, _FilterableTreeViewElement_updateCheckedNodeIds, _FilterableTreeViewElement_handleTreeViewNodeChecked, _FilterableTreeViewElement_restoreNodeState, _FilterableTreeViewElement_handleFilterModeEvent, _FilterableTreeViewElement_undoClientSideFilter, _FilterableTreeViewElement_handleFilterInputEvent, _FilterableTreeViewElement_handleIncludeSubItemsCheckBoxEvent, _FilterableTreeViewElement_includeSubItems, _FilterableTreeViewElement_includeSubItemsUnder, _FilterableTreeViewElement_restoreAllNodeStates, _FilterableTreeViewElement_scheduleAsyncFetch, _FilterableTreeViewElement_fetchAndReplaceTree, _FilterableTreeViewElement_captureExpansionState, _FilterableTreeViewElement_applyExpansionSnapshot, _FilterableTreeViewElement_snapshotExpansionState, _FilterableTreeViewElement_restoreExpansionState, _FilterableTreeViewElement_restoreSelectionState, _FilterableTreeViewElement_expandAllSubTrees, _FilterableTreeViewElement_applyAsyncHighlights, _FilterableTreeViewElement_updateRetainedSelections, _FilterableTreeViewElement_applyFilterOptions, _FilterableTreeViewElement_applyHighlights, _FilterableTreeViewElement_applyManualHighlights, _FilterableTreeViewElement_removeHighlights;
19
19
  import { controller, target } from '@github/catalyst';
20
20
  import { TreeViewElement } from '../alpha/tree_view/tree_view';
21
21
  import { TreeViewSubTreeNodeElement } from '../alpha/tree_view/tree_view_sub_tree_node_element';
22
+ const ASYNC_DEBOUNCE_MS = 300;
22
23
  let FilterableTreeViewElement = class FilterableTreeViewElement extends HTMLElement {
23
24
  constructor() {
24
25
  super(...arguments);
25
26
  _FilterableTreeViewElement_instances.add(this);
26
27
  _FilterableTreeViewElement_filterFn.set(this, void 0);
27
28
  _FilterableTreeViewElement_abortController.set(this, void 0);
28
- _FilterableTreeViewElement_stateMap.set(this, new Map());
29
+ _FilterableTreeViewElement_stateMap.set(this, new Map()
30
+ // Async mode state
31
+ );
32
+ // Async mode state
33
+ _FilterableTreeViewElement_debounceTimer.set(this, null);
34
+ _FilterableTreeViewElement_fetchAbortController.set(this, null
35
+ // nodeId → wasExpanded: taken once before the first filter query is entered, cleared when filter is removed
36
+ );
37
+ // nodeId → wasExpanded: taken once before the first filter query is entered, cleared when filter is removed
38
+ _FilterableTreeViewElement_expansionSnapshot.set(this, null
39
+ // nodeId → wasExpanded: taken before entering "selected" mode, cleared when leaving it
40
+ );
41
+ // nodeId → wasExpanded: taken before entering "selected" mode, cleared when leaving it
42
+ _FilterableTreeViewElement_selectedModeSnapshot.set(this, null
43
+ // nodeId → checkedValue: persists across tree replacements, updated on every treeViewNodeChecked event
44
+ );
45
+ // nodeId → checkedValue: persists across tree replacements, updated on every treeViewNodeChecked event
46
+ _FilterableTreeViewElement_checkedNodeIds.set(this, new Map()
47
+ // nodeId → form payload: mirrors #checkedNodeIds but stores the data needed to synthesise a hidden
48
+ // form input for nodes that are checked but not currently in the DOM (e.g. filtered out).
49
+ );
50
+ // nodeId → form payload: mirrors #checkedNodeIds but stores the data needed to synthesise a hidden
51
+ // form input for nodes that are checked but not currently in the DOM (e.g. filtered out).
52
+ _FilterableTreeViewElement_checkedNodeFormPayloads.set(this, new Map());
53
+ _FilterableTreeViewElement_isFiltered.set(this, false);
29
54
  }
30
55
  connectedCallback() {
31
56
  const { signal } = (__classPrivateFieldSet(this, _FilterableTreeViewElement_abortController, new AbortController(), "f"));
32
57
  this.addEventListener('treeViewNodeChecked', this, { signal });
33
58
  this.addEventListener('itemActivated', this, { signal });
34
59
  this.addEventListener('input', this, { signal });
60
+ if (__classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "a", _FilterableTreeViewElement_isAsyncMode_get)) {
61
+ void __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_fetchAndReplaceTree).call(this);
62
+ }
35
63
  }
36
64
  disconnectedCallback() {
37
65
  __classPrivateFieldGet(this, _FilterableTreeViewElement_abortController, "f").abort();
66
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_fetchAbortController, "f")?.abort();
67
+ if (__classPrivateFieldGet(this, _FilterableTreeViewElement_debounceTimer, "f") !== null)
68
+ clearTimeout(__classPrivateFieldGet(this, _FilterableTreeViewElement_debounceTimer, "f"));
38
69
  }
39
70
  handleEvent(event) {
40
71
  if (event.target === this.filterModeControl) {
@@ -56,6 +87,7 @@ let FilterableTreeViewElement = class FilterableTreeViewElement extends HTMLElem
56
87
  get treeView() {
57
88
  return this.treeViewList.closest('tree-view');
58
89
  }
90
+ // ─── End async mode ─────────────────────────────────────────────────────────
59
91
  set filterFn(newFn) {
60
92
  __classPrivateFieldSet(this, _FilterableTreeViewElement_filterFn, newFn, "f");
61
93
  }
@@ -144,17 +176,63 @@ let FilterableTreeViewElement = class FilterableTreeViewElement extends HTMLElem
144
176
  _FilterableTreeViewElement_filterFn = new WeakMap();
145
177
  _FilterableTreeViewElement_abortController = new WeakMap();
146
178
  _FilterableTreeViewElement_stateMap = new WeakMap();
179
+ _FilterableTreeViewElement_debounceTimer = new WeakMap();
180
+ _FilterableTreeViewElement_fetchAbortController = new WeakMap();
181
+ _FilterableTreeViewElement_expansionSnapshot = new WeakMap();
182
+ _FilterableTreeViewElement_selectedModeSnapshot = new WeakMap();
183
+ _FilterableTreeViewElement_checkedNodeIds = new WeakMap();
184
+ _FilterableTreeViewElement_checkedNodeFormPayloads = new WeakMap();
185
+ _FilterableTreeViewElement_isFiltered = new WeakMap();
147
186
  _FilterableTreeViewElement_instances = new WeakSet();
187
+ _FilterableTreeViewElement_src_get = function _FilterableTreeViewElement_src_get() {
188
+ return this.getAttribute('src');
189
+ };
190
+ _FilterableTreeViewElement_isAsyncMode_get = function _FilterableTreeViewElement_isAsyncMode_get() {
191
+ return !!__classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "a", _FilterableTreeViewElement_src_get);
192
+ };
148
193
  _FilterableTreeViewElement_handleTreeViewEvent = function _FilterableTreeViewElement_handleTreeViewEvent(origEvent) {
149
194
  const event = origEvent;
150
195
  // NOTE: This event only fires if someone actually activates the check mark, i.e. does not fire
151
196
  // when calling this.treeView.setNodeCheckedValue.
152
197
  switch (origEvent.type) {
153
198
  case 'treeViewNodeChecked':
199
+ // Always track checked node IDs before delegating, so async replacements can restore selection.
200
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_updateCheckedNodeIds).call(this, event);
154
201
  __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_handleTreeViewNodeChecked).call(this, event);
155
202
  break;
156
203
  }
157
204
  };
205
+ _FilterableTreeViewElement_updateCheckedNodeIds = function _FilterableTreeViewElement_updateCheckedNodeIds(event) {
206
+ if (!__classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "a", _FilterableTreeViewElement_isAsyncMode_get))
207
+ return;
208
+ for (const nodeInfo of event.detail) {
209
+ const node = nodeInfo.node;
210
+ const nodeId = node.getAttribute('data-node-id');
211
+ if (nodeId) {
212
+ if (nodeInfo.checkedValue === 'false') {
213
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_checkedNodeIds, "f").delete(nodeId);
214
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_checkedNodeFormPayloads, "f").delete(nodeId);
215
+ }
216
+ else {
217
+ // In single-select mode, TreeView clears the previous selection internally
218
+ // (via checkOnlyAtPath) but the treeViewNodeChecked event only contains the
219
+ // newly selected node. Clear our tracked state so #restoreSelectionState does
220
+ // not re-check previously selected nodes after a tree replacement.
221
+ if (node.getAttribute('data-select-variant') === 'single') {
222
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_checkedNodeIds, "f").clear();
223
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_checkedNodeFormPayloads, "f").clear();
224
+ }
225
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_checkedNodeIds, "f").set(nodeId, nodeInfo.checkedValue);
226
+ const payload = { path: nodeInfo.path };
227
+ const dataValue = node.getAttribute('data-value');
228
+ if (dataValue)
229
+ payload.value = dataValue;
230
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_checkedNodeFormPayloads, "f").set(nodeId, payload);
231
+ }
232
+ }
233
+ }
234
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_updateRetainedSelections).call(this);
235
+ };
158
236
  _FilterableTreeViewElement_handleTreeViewNodeChecked = function _FilterableTreeViewElement_handleTreeViewNodeChecked(event) {
159
237
  if (!this.treeView)
160
238
  return;
@@ -199,19 +277,57 @@ _FilterableTreeViewElement_restoreNodeState = function _FilterableTreeViewElemen
199
277
  _FilterableTreeViewElement_handleFilterModeEvent = function _FilterableTreeViewElement_handleFilterModeEvent(event) {
200
278
  if (event.type !== 'itemActivated')
201
279
  return;
202
- __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_applyFilterOptions).call(this);
280
+ if (__classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "a", _FilterableTreeViewElement_isAsyncMode_get)) {
281
+ if (this.filterMode === 'selected') {
282
+ // "selected" mode is client-side: snapshot expansion state before the filter collapses nodes,
283
+ // then apply client-side filter without a server round-trip.
284
+ __classPrivateFieldSet(this, _FilterableTreeViewElement_selectedModeSnapshot, __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_captureExpansionState).call(this), "f");
285
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_applyFilterOptions).call(this);
286
+ }
287
+ else if (__classPrivateFieldGet(this, _FilterableTreeViewElement_selectedModeSnapshot, "f") !== null && this.queryString.length === 0) {
288
+ // Leaving "selected" mode with no active query: undo client-side filter and restore expansion
289
+ // state without a server round-trip (the full tree is already in the DOM).
290
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_undoClientSideFilter).call(this);
291
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_applyExpansionSnapshot).call(this, __classPrivateFieldGet(this, _FilterableTreeViewElement_selectedModeSnapshot, "f"));
292
+ __classPrivateFieldSet(this, _FilterableTreeViewElement_selectedModeSnapshot, null, "f");
293
+ }
294
+ else {
295
+ // "all" mode with an active query, or switching away from a custom mode: use async fetch.
296
+ __classPrivateFieldSet(this, _FilterableTreeViewElement_selectedModeSnapshot, null, "f");
297
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_scheduleAsyncFetch).call(this);
298
+ }
299
+ }
300
+ else {
301
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_applyFilterOptions).call(this);
302
+ }
303
+ };
304
+ _FilterableTreeViewElement_undoClientSideFilter = function _FilterableTreeViewElement_undoClientSideFilter() {
305
+ for (const el of this.querySelectorAll('tree-view li[hidden], tree-view-sub-tree-node[hidden]')) {
306
+ el.removeAttribute('hidden');
307
+ }
203
308
  };
204
309
  _FilterableTreeViewElement_handleFilterInputEvent = function _FilterableTreeViewElement_handleFilterInputEvent(event) {
205
310
  if (event.type !== 'input')
206
311
  return;
207
- __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_applyFilterOptions).call(this);
312
+ // "selected" mode is always client-side – the server doesn't know the selection state.
313
+ if (__classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "a", _FilterableTreeViewElement_isAsyncMode_get) && this.filterMode !== 'selected') {
314
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_scheduleAsyncFetch).call(this);
315
+ }
316
+ else {
317
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_applyFilterOptions).call(this);
318
+ }
208
319
  };
209
320
  _FilterableTreeViewElement_handleIncludeSubItemsCheckBoxEvent = function _FilterableTreeViewElement_handleIncludeSubItemsCheckBoxEvent(event) {
210
321
  if (!this.treeView)
211
322
  return;
212
323
  if (event.type !== 'input')
213
324
  return;
214
- __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_applyFilterOptions).call(this);
325
+ // In async mode, toggling include-sub-items does not require a server round-trip: the client
326
+ // handles the visual state entirely (checking/disabling visible descendants). The flag will be
327
+ // included automatically in the next filter request triggered by a query or filter-mode change.
328
+ if (!__classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "a", _FilterableTreeViewElement_isAsyncMode_get)) {
329
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_applyFilterOptions).call(this);
330
+ }
215
331
  if (this.includeSubItemsCheckBox.checked) {
216
332
  __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_includeSubItems).call(this);
217
333
  }
@@ -247,6 +363,179 @@ _FilterableTreeViewElement_restoreAllNodeStates = function _FilterableTreeViewEl
247
363
  __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_restoreNodeState).call(this, subTree);
248
364
  }
249
365
  };
366
+ _FilterableTreeViewElement_scheduleAsyncFetch = function _FilterableTreeViewElement_scheduleAsyncFetch() {
367
+ if (__classPrivateFieldGet(this, _FilterableTreeViewElement_debounceTimer, "f") !== null)
368
+ clearTimeout(__classPrivateFieldGet(this, _FilterableTreeViewElement_debounceTimer, "f"));
369
+ __classPrivateFieldSet(this, _FilterableTreeViewElement_debounceTimer, setTimeout(() => {
370
+ __classPrivateFieldSet(this, _FilterableTreeViewElement_debounceTimer, null, "f");
371
+ void __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_fetchAndReplaceTree).call(this);
372
+ }, ASYNC_DEBOUNCE_MS), "f");
373
+ };
374
+ _FilterableTreeViewElement_fetchAndReplaceTree = async function _FilterableTreeViewElement_fetchAndReplaceTree() {
375
+ const src = __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "a", _FilterableTreeViewElement_src_get);
376
+ if (!src)
377
+ return;
378
+ const query = this.queryString;
379
+ const filterMode = this.filterMode || 'all';
380
+ const includeSubItems = this.includeSubItemsCheckBox?.checked ?? false;
381
+ // Snapshot expansion state the first time the user enters a filter query
382
+ if (!__classPrivateFieldGet(this, _FilterableTreeViewElement_isFiltered, "f") && query.length > 0) {
383
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_snapshotExpansionState).call(this);
384
+ __classPrivateFieldSet(this, _FilterableTreeViewElement_isFiltered, true, "f");
385
+ }
386
+ else if (__classPrivateFieldGet(this, _FilterableTreeViewElement_isFiltered, "f") && query.length === 0) {
387
+ __classPrivateFieldSet(this, _FilterableTreeViewElement_isFiltered, false, "f");
388
+ }
389
+ // Remember which filter state this particular request was for so we apply
390
+ // the correct post-processing even if the user types quickly.
391
+ const requestWasFiltered = query.length > 0;
392
+ // Abort any in-flight request
393
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_fetchAbortController, "f")?.abort();
394
+ const { signal } = (__classPrivateFieldSet(this, _FilterableTreeViewElement_fetchAbortController, new AbortController(), "f"));
395
+ const url = new URL(src, window.location.href);
396
+ url.searchParams.set('query', query);
397
+ url.searchParams.set('filter_mode', filterMode);
398
+ url.searchParams.set('include_sub_items', String(includeSubItems));
399
+ // Send currently-checked node IDs so the server can apply include-sub-items
400
+ // logic even for nodes that are no longer visible due to filtering / pagination.
401
+ for (const nodeId of __classPrivateFieldGet(this, _FilterableTreeViewElement_checkedNodeIds, "f").keys()) {
402
+ url.searchParams.append('checked_ids[]', nodeId);
403
+ }
404
+ this.setAttribute('data-loading', '');
405
+ this.setAttribute('aria-busy', 'true');
406
+ try {
407
+ const response = await fetch(url.toString(), {
408
+ signal,
409
+ headers: { Accept: 'text/html' },
410
+ credentials: 'same-origin',
411
+ method: 'GET',
412
+ });
413
+ if (!response.ok)
414
+ return;
415
+ const html = await response.text();
416
+ const doc = new DOMParser().parseFromString(html, 'text/html');
417
+ const newTreeView = doc.querySelector('tree-view');
418
+ if (!newTreeView)
419
+ return;
420
+ const oldTreeView = this.treeViewList?.closest('tree-view');
421
+ if (!oldTreeView)
422
+ return;
423
+ // Invalidate old stateMap entries – the referenced DOM nodes no longer exist after replacement.
424
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_stateMap, "f").clear();
425
+ oldTreeView.replaceWith(newTreeView);
426
+ // Catalyst re-resolves @target treeViewList dynamically on next access.
427
+ // Restore checked state for all nodes that now appear in the new tree.
428
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_restoreSelectionState).call(this);
429
+ // Re-apply include-sub-items visually if the checkbox is still checked.
430
+ if (includeSubItems) {
431
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_includeSubItems).call(this);
432
+ }
433
+ if (requestWasFiltered) {
434
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_expandAllSubTrees).call(this);
435
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_applyAsyncHighlights).call(this, query);
436
+ const hasResults = !!this.treeViewList?.querySelector('[role=treeitem]');
437
+ this.noResultsMessage.toggleAttribute('hidden', hasResults);
438
+ this.treeViewList?.toggleAttribute('hidden', !hasResults);
439
+ }
440
+ else {
441
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_removeHighlights).call(this);
442
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_restoreExpansionState).call(this);
443
+ this.noResultsMessage.setAttribute('hidden', 'hidden');
444
+ this.treeViewList?.removeAttribute('hidden');
445
+ }
446
+ // Synthesise form inputs for nodes that are checked but absent from the current DOM
447
+ // (e.g. filtered out). Must run after restoreSelectionState so we know what is in the DOM.
448
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_updateRetainedSelections).call(this);
449
+ }
450
+ catch (e) {
451
+ if (e.name === 'AbortError')
452
+ return;
453
+ throw e;
454
+ }
455
+ finally {
456
+ this.removeAttribute('data-loading');
457
+ this.setAttribute('aria-busy', 'false');
458
+ }
459
+ };
460
+ _FilterableTreeViewElement_captureExpansionState = function _FilterableTreeViewElement_captureExpansionState() {
461
+ const snapshot = new Map();
462
+ for (const treeitem of this.querySelectorAll('[role=treeitem][data-node-id][data-node-type=sub-tree]')) {
463
+ snapshot.set(treeitem.getAttribute('data-node-id'), treeitem.getAttribute('aria-expanded') === 'true');
464
+ }
465
+ return snapshot;
466
+ };
467
+ _FilterableTreeViewElement_applyExpansionSnapshot = function _FilterableTreeViewElement_applyExpansionSnapshot(snapshot) {
468
+ for (const [nodeId, wasExpanded] of snapshot) {
469
+ const treeitem = this.querySelector(`[role=treeitem][data-node-id="${CSS.escape(nodeId)}"]`);
470
+ const subTreeNode = treeitem?.closest('tree-view-sub-tree-node');
471
+ if (subTreeNode) {
472
+ if (wasExpanded) {
473
+ subTreeNode.expand();
474
+ }
475
+ else {
476
+ subTreeNode.collapse();
477
+ }
478
+ }
479
+ }
480
+ };
481
+ _FilterableTreeViewElement_snapshotExpansionState = function _FilterableTreeViewElement_snapshotExpansionState() {
482
+ __classPrivateFieldSet(this, _FilterableTreeViewElement_expansionSnapshot, __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_captureExpansionState).call(this), "f");
483
+ };
484
+ _FilterableTreeViewElement_restoreExpansionState = function _FilterableTreeViewElement_restoreExpansionState() {
485
+ if (!__classPrivateFieldGet(this, _FilterableTreeViewElement_expansionSnapshot, "f"))
486
+ return;
487
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_applyExpansionSnapshot).call(this, __classPrivateFieldGet(this, _FilterableTreeViewElement_expansionSnapshot, "f"));
488
+ __classPrivateFieldSet(this, _FilterableTreeViewElement_expansionSnapshot, null, "f");
489
+ };
490
+ _FilterableTreeViewElement_restoreSelectionState = function _FilterableTreeViewElement_restoreSelectionState() {
491
+ if (!this.treeView)
492
+ return;
493
+ for (const treeitem of this.querySelectorAll('[role=treeitem][data-node-id]')) {
494
+ const nodeId = treeitem.getAttribute('data-node-id');
495
+ const savedValue = __classPrivateFieldGet(this, _FilterableTreeViewElement_checkedNodeIds, "f").get(nodeId);
496
+ if (savedValue !== undefined) {
497
+ this.treeView.setNodeCheckedValue(treeitem, savedValue);
498
+ }
499
+ }
500
+ };
501
+ _FilterableTreeViewElement_expandAllSubTrees = function _FilterableTreeViewElement_expandAllSubTrees() {
502
+ for (const subTreeNode of this.querySelectorAll('tree-view-sub-tree-node')) {
503
+ subTreeNode.expand();
504
+ }
505
+ };
506
+ _FilterableTreeViewElement_applyAsyncHighlights = function _FilterableTreeViewElement_applyAsyncHighlights(query) {
507
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_removeHighlights).call(this);
508
+ const ranges = [];
509
+ for (const treeitem of this.querySelectorAll('[role=treeitem]')) {
510
+ const result = this.defaultFilterFn(treeitem, query, 'all');
511
+ if (result)
512
+ ranges.push(...result);
513
+ }
514
+ if (ranges.length > 0)
515
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_applyHighlights).call(this, ranges);
516
+ };
517
+ _FilterableTreeViewElement_updateRetainedSelections = function _FilterableTreeViewElement_updateRetainedSelections() {
518
+ // Only relevant when a form is wired up.
519
+ const prototype = this.treeView?.formInputPrototype;
520
+ if (!prototype)
521
+ return;
522
+ // Remove previously injected retained inputs.
523
+ for (const el of this.querySelectorAll('[data-filterable-tree-view-retained]')) {
524
+ el.remove();
525
+ }
526
+ for (const [nodeId, payload] of __classPrivateFieldGet(this, _FilterableTreeViewElement_checkedNodeFormPayloads, "f")) {
527
+ // Nodes currently in the DOM are already covered by TreeView's updateHiddenFormInputs.
528
+ const inDom = !!this.querySelector(`[role=treeitem][data-node-id="${CSS.escape(nodeId)}"]`);
529
+ if (inDom)
530
+ continue;
531
+ const input = document.createElement('input');
532
+ input.type = 'hidden';
533
+ input.name = prototype.name;
534
+ input.value = JSON.stringify(payload);
535
+ input.setAttribute('data-filterable-tree-view-retained', '');
536
+ this.appendChild(input);
537
+ }
538
+ };
250
539
  _FilterableTreeViewElement_applyFilterOptions = function _FilterableTreeViewElement_applyFilterOptions() {
251
540
  if (!this.treeView)
252
541
  return;
@@ -0,0 +1,57 @@
1
+ /* CSS for FilterableTreeView */
2
+
3
+ /* Scroll layout
4
+ *
5
+ * The filterable-tree-view element itself acts as a flex column container.
6
+ * The toolbar (input, segmented control, checkbox) is fixed at the top.
7
+ * Only the tree area scrolls. The consumer is responsible for setting a
8
+ * height or max-height on the filterable-tree-view element (or its parent)
9
+ * to activate scrolling.
10
+ */
11
+
12
+ /* stylelint-disable-next-line selector-max-type */
13
+ filterable-tree-view {
14
+ display: flex;
15
+ flex-direction: column;
16
+ overflow: hidden;
17
+ max-height: inherit;
18
+ }
19
+
20
+ .FilterableTreeViewLayout {
21
+ flex: 1;
22
+ min-height: 0;
23
+ }
24
+
25
+ .FilterableTreeViewTreeContainer {
26
+ flex: 1;
27
+ min-height: 0;
28
+ overflow-y: auto;
29
+ }
30
+
31
+ /* Highlight style for CSS Custom Highlight API */
32
+ ::highlight(primer-filterable-tree-view-search-results) {
33
+ background-color: var(--bgColor-attention-muted);
34
+ }
35
+
36
+ /* Fallback: <mark> elements used when CSS Custom Highlight API is unavailable */
37
+ /* stylelint-disable-next-line selector-max-type */
38
+ filterable-tree-view mark {
39
+ background-color: var(--bgColor-attention-muted);
40
+ color: inherit;
41
+ }
42
+
43
+ /* Loading skeleton */
44
+ .FilterableTreeViewLoadingSkeleton {
45
+ display: none;
46
+ }
47
+
48
+ /* stylelint-disable selector-no-qualifying-type */
49
+ /* stylelint-disable selector-max-type */
50
+ filterable-tree-view[data-loading] .FilterableTreeViewLoadingSkeleton {
51
+ display: block;
52
+ }
53
+
54
+ filterable-tree-view[data-loading] tree-view,
55
+ filterable-tree-view[data-loading] [data-target~="filterable-tree-view.noResultsMessage"] {
56
+ display: none;
57
+ }
@@ -102,9 +102,44 @@ module Primer
102
102
  #
103
103
  # Currently `FilterableTreeView` does not emit any events aside from the events already emitted by the `TreeView`
104
104
  # component.
105
+ #
106
+ # ## Async loading strategy
107
+ #
108
+ # When `src` is set on the component, all filter interactions (text input, filter mode changes) trigger a debounced
109
+ # server request instead of client-side filtering. The server is responsible for returning a filtered `<tree-view>`
110
+ # HTML fragment that replaces the current tree.
111
+ #
112
+ # ### Behavior
113
+ #
114
+ # - The full tree is loaded initially from the server via `src`.
115
+ # - Each filter input event triggers a debounced (300 ms) request to the server.
116
+ # - The server returns a filtered `<tree-view>` element which replaces the existing one.
117
+ # - All matching results and their full ancestor hierarchy are expanded automatically.
118
+ # - Matching text is highlighted using the CSS Custom Highlight API (or `<mark>` fallback).
119
+ # - When the filter is cleared, the tree is replaced with the full (unfiltered) result from
120
+ # the server and the expansion state from before the search is restored.
121
+ # - Checked nodes are preserved across tree replacements using `data-node-id` attributes.
122
+ # - When "include sub-items" is active and the tree is filtered, clicking a parent node
123
+ # selects ALL its descendants (not just the visible filtered ones). Therefore, "include_sub_items" is passed
124
+ # to the server, since it holds the only truth about the data.
125
+ #
126
+ # ### Server endpoint
127
+ #
128
+ # The server endpoint must return a `<tree-view>` HTML fragment. Each node must have a stable
129
+ # `data-node-id` on its `[role=treeitem]` element.
130
+ #
131
+ # ### Usage
132
+ #
133
+ # ```erb
134
+ # <%= render(Primer::OpenProject::FilterableTreeView.new(
135
+ # src: my_path
136
+ # )) %>
137
+ # ```
105
138
  class FilterableTreeView < Primer::Component
106
139
  delegate :with_leaf, :with_sub_tree, to: :@tree_view
107
140
 
141
+ SUPPORTED_SELECT_VARIANTS = %i[multiple single none].freeze
142
+
108
143
  DEFAULT_FILTER_INPUT_ARGUMENTS = {
109
144
  name: :filter,
110
145
  label: I18n.t(:button_filter),
@@ -153,6 +188,7 @@ module Primer
153
188
 
154
189
  DEFAULT_NO_RESULTS_NODE_ARGUMENTS.freeze
155
190
 
191
+ # @param src [String] URL of the server endpoint that returns a filtered `<tree-view>` HTML fragment. When set, activates async (server-side) filtering mode. See "Async loading strategy" above.
156
192
  # @param tree_view_arguments [Hash] Arguments that will be passed to the underlying <%= link_to_component(Primer::Alpha::TreeView) %> component.
157
193
  # @param form_arguments [Hash] Form arguments that will be passed to the underlying <%= link_to_component(Primer::Alpha::TreeView) %> component. These arguments allow the selections made within a `FilterableTreeView` to be submitted to the server as part of a Rails form. Pass the `builder:` and `name:` options to this hash. `builder:` should be an instance of `ActionView::Helpers::FormBuilder`, which are created by the standard Rails `#form_with` and `#form_for` helpers. The `name:` option is the desired name of the field that will be included in the params sent to the server on form submission.
158
194
  # @param filter_input_arguments [Hash] Arguments that will be passed to the <%= link_to_component(Primer::Alpha::TextField) %> component.
@@ -160,6 +196,7 @@ module Primer
160
196
  # @param include_sub_items_check_box_arguments [Hash] Arguments that will be passed to the <%= link_to_component(Primer::Alpha::CheckBox) %> component.
161
197
  # @param no_results_node_arguments [Hash] Arguments that will be passed to a <%= link_to_component(Primer::Alpha::TreeView::LeafNode) %> component that appears when no items match the filter criteria.
162
198
  def initialize(
199
+ src: nil,
163
200
  tree_view_arguments: {},
164
201
  form_arguments: {},
165
202
  filter_input_arguments: DEFAULT_FILTER_INPUT_ARGUMENTS.dup,
@@ -168,6 +205,8 @@ module Primer
168
205
  no_results_node_arguments: DEFAULT_NO_RESULTS_NODE_ARGUMENTS.dup,
169
206
  **system_arguments
170
207
  )
208
+ @tree_view_arguments = tree_view_arguments.dup
209
+
171
210
  tree_view_arguments[:data] = merge_data(
172
211
  tree_view_arguments, {
173
212
  data: { target: "filterable-tree-view.treeViewList" }
@@ -209,6 +248,7 @@ module Primer
209
248
 
210
249
  @system_arguments = deny_tag_argument(**system_arguments)
211
250
  @system_arguments[:tag] = :"filterable-tree-view"
251
+ @system_arguments[:src] = src if src
212
252
 
213
253
  @no_results_node_arguments = no_results_node_arguments
214
254
  end
@@ -232,15 +272,14 @@ module Primer
232
272
  def with_sub_tree(**system_arguments, &block)
233
273
  system_arguments[:select_variant] ||= :multiple
234
274
 
235
- if system_arguments[:select_variant] != :multiple && system_arguments[:select_variant] != :single
236
- raise ArgumentError, "FilterableTreeView only supports `:multiple` or `:single` as select_variant"
275
+ unless SUPPORTED_SELECT_VARIANTS.include?(system_arguments[:select_variant])
276
+ raise ArgumentError, "FilterableTreeView only supports #{SUPPORTED_SELECT_VARIANTS.map(&:inspect).to_sentence} as select_variant"
237
277
  end
238
278
 
239
- if system_arguments[:select_variant] == :single
240
- # In single selection, the include sub-items checkbox and the SegmentedControl make no sense
279
+ if system_arguments[:select_variant] != :multiple
280
+ # In single/none selection, the include sub-items checkbox makes no sense
241
281
  @include_sub_items_check_box_arguments[:hidden] = true
242
282
  @include_sub_items_check_box_arguments[:checked] = false
243
- @filter_mode_control_arguments[:hidden] = true
244
283
  end
245
284
 
246
285
  @tree_view.with_sub_tree(
@@ -254,15 +293,14 @@ module Primer
254
293
  def with_leaf(**system_arguments, &block)
255
294
  system_arguments[:select_variant] ||= :multiple
256
295
 
257
- if system_arguments[:select_variant] != :multiple && system_arguments[:select_variant] != :single
258
- raise ArgumentError, "FilterableTreeView only supports `:multiple` or `:single` as select_variant"
296
+ unless SUPPORTED_SELECT_VARIANTS.include?(system_arguments[:select_variant])
297
+ raise ArgumentError, "FilterableTreeView only supports #{SUPPORTED_SELECT_VARIANTS.map(&:inspect).to_sentence} as select_variant"
259
298
  end
260
299
 
261
- if system_arguments[:select_variant] == :single
262
- # In single selection, the include sub-items checkbox and the SegmentedControl make no sense
300
+ if system_arguments[:select_variant] != :multiple
301
+ # In single/none selection, the include sub-items checkbox makes no sense
263
302
  @include_sub_items_check_box_arguments[:hidden] = true
264
303
  @include_sub_items_check_box_arguments[:checked] = false
265
- @filter_mode_control_arguments[:hidden] = true
266
304
  end
267
305
 
268
306
  @tree_view.with_leaf(
@@ -271,9 +309,19 @@ module Primer
271
309
  )
272
310
  end
273
311
 
312
+ def async?
313
+ @system_arguments.key?(:src)
314
+ end
315
+
274
316
  private
275
317
 
276
318
  def before_render
319
+ if @system_arguments[:src] && @tree_view_arguments.any?
320
+ raise ArgumentError, "tree_view_arguments are not supported when src: is provided. " \
321
+ "The initial tree shell is replaced on the first async fetch, so any " \
322
+ "tree_view_arguments would be lost. Configure the tree in your server endpoint instead."
323
+ end
324
+
277
325
  content
278
326
 
279
327
  if @filter_mode_control.present? && @filter_mode_control.items.empty?