openproject-primer_view_components 0.70.5 → 0.71.0

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/app/assets/javascripts/components/primer/alpha/segmented_control.d.ts +2 -2
  4. data/app/assets/javascripts/components/primer/open_project/filterable_tree_view.d.ts +29 -0
  5. data/app/assets/javascripts/components/primer/open_project/tree_view/tree_view.d.ts +11 -1
  6. data/app/assets/javascripts/components/primer/open_project/tree_view/tree_view_sub_tree_node_element.d.ts +5 -1
  7. data/app/assets/javascripts/components/primer/primer.d.ts +1 -0
  8. data/app/assets/javascripts/primer_view_components.js +1 -1
  9. data/app/assets/javascripts/primer_view_components.js.map +1 -1
  10. data/app/assets/styles/primer_view_components.css +1 -1
  11. data/app/assets/styles/primer_view_components.css.map +1 -1
  12. data/app/components/primer/alpha/segmented_control.d.ts +2 -2
  13. data/app/components/primer/alpha/segmented_control.js +12 -0
  14. data/app/components/primer/alpha/segmented_control.ts +16 -1
  15. data/app/components/primer/alpha/stack.css +1 -1
  16. data/app/components/primer/alpha/stack.css.json +5 -1
  17. data/app/components/primer/alpha/stack.css.map +1 -1
  18. data/app/components/primer/alpha/stack.pcss +13 -0
  19. data/app/components/primer/alpha/stack.rb +2 -1
  20. data/app/components/primer/open_project/filterable_tree_view/sub_tree.rb +39 -0
  21. data/app/components/primer/open_project/filterable_tree_view.d.ts +29 -0
  22. data/app/components/primer/open_project/filterable_tree_view.html.erb +28 -0
  23. data/app/components/primer/open_project/filterable_tree_view.js +409 -0
  24. data/app/components/primer/open_project/filterable_tree_view.rb +254 -0
  25. data/app/components/primer/open_project/filterable_tree_view.ts +492 -0
  26. data/app/components/primer/open_project/tree_view/node.rb +19 -3
  27. data/app/components/primer/open_project/tree_view/sub_tree_node.rb +14 -4
  28. data/app/components/primer/open_project/tree_view/tree_view.d.ts +11 -1
  29. data/app/components/primer/open_project/tree_view/tree_view.js +120 -20
  30. data/app/components/primer/open_project/tree_view/tree_view.ts +137 -18
  31. data/app/components/primer/open_project/tree_view/tree_view_sub_tree_node_element.d.ts +5 -1
  32. data/app/components/primer/open_project/tree_view/tree_view_sub_tree_node_element.js +27 -4
  33. data/app/components/primer/open_project/tree_view/tree_view_sub_tree_node_element.ts +36 -5
  34. data/app/components/primer/open_project/tree_view.css +1 -1
  35. data/app/components/primer/open_project/tree_view.css.json +9 -0
  36. data/app/components/primer/open_project/tree_view.css.map +1 -1
  37. data/app/components/primer/open_project/tree_view.html.erb +4 -0
  38. data/app/components/primer/open_project/tree_view.pcss +48 -0
  39. data/app/components/primer/open_project/tree_view.rb +6 -1
  40. data/app/components/primer/primer.d.ts +1 -0
  41. data/app/components/primer/primer.js +1 -0
  42. data/app/components/primer/primer.ts +1 -0
  43. data/app/lib/primer/forms/base_component.rb +1 -1
  44. data/app/lib/primer/forms/dsl/text_field_input.rb +2 -0
  45. data/config/locales/en.yml +20 -0
  46. data/lib/primer/view_components/version.rb +2 -2
  47. data/previews/primer/open_project/filterable_tree_view_preview/_custom_select_js.html.erb +62 -0
  48. data/previews/primer/open_project/filterable_tree_view_preview/custom_checkbox_text.html.erb +26 -0
  49. data/previews/primer/open_project/filterable_tree_view_preview/custom_no_results_text.html.erb +28 -0
  50. data/previews/primer/open_project/filterable_tree_view_preview/custom_segmented_control.html.erb +31 -0
  51. data/previews/primer/open_project/filterable_tree_view_preview/default.html.erb +26 -0
  52. data/previews/primer/open_project/filterable_tree_view_preview/form_input.html.erb +32 -0
  53. data/previews/primer/open_project/filterable_tree_view_preview/playground.html.erb +26 -0
  54. data/previews/primer/open_project/filterable_tree_view_preview.rb +125 -0
  55. data/previews/primer/open_project/tree_view_preview/buttons.html.erb +4 -4
  56. data/previews/primer/open_project/tree_view_preview/default.html.erb +4 -4
  57. data/previews/primer/open_project/tree_view_preview/leaf_node_playground.html.erb +1 -1
  58. data/previews/primer/open_project/tree_view_preview/links.html.erb +4 -4
  59. data/previews/primer/open_project/tree_view_preview.rb +18 -8
  60. data/static/arguments.json +89 -3
  61. data/static/audited_at.json +2 -0
  62. data/static/constants.json +40 -1
  63. data/static/info_arch.json +220 -3
  64. data/static/previews.json +86 -0
  65. data/static/statuses.json +2 -0
  66. metadata +18 -2
@@ -0,0 +1,409 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
8
+ if (kind === "m") throw new TypeError("Private method is not writable");
9
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
10
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
11
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
12
+ };
13
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
14
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
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
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
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;
19
+ import { controller, target } from '@github/catalyst';
20
+ import { TreeViewElement } from './tree_view/tree_view';
21
+ import { TreeViewSubTreeNodeElement } from './tree_view/tree_view_sub_tree_node_element';
22
+ let FilterableTreeViewElement = class FilterableTreeViewElement extends HTMLElement {
23
+ constructor() {
24
+ super(...arguments);
25
+ _FilterableTreeViewElement_instances.add(this);
26
+ _FilterableTreeViewElement_filterFn.set(this, void 0);
27
+ _FilterableTreeViewElement_abortController.set(this, void 0);
28
+ _FilterableTreeViewElement_stateMap.set(this, new Map());
29
+ }
30
+ connectedCallback() {
31
+ const { signal } = (__classPrivateFieldSet(this, _FilterableTreeViewElement_abortController, new AbortController(), "f"));
32
+ this.addEventListener('treeViewNodeChecked', this, { signal });
33
+ this.addEventListener('itemActivated', this, { signal });
34
+ this.addEventListener('input', this, { signal });
35
+ }
36
+ disconnectedCallback() {
37
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_abortController, "f").abort();
38
+ }
39
+ handleEvent(event) {
40
+ if (event.target === this.filterModeControl) {
41
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_handleFilterModeEvent).call(this, event);
42
+ }
43
+ else if (event.target === this.filterInput) {
44
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_handleFilterInputEvent).call(this, event);
45
+ }
46
+ else if (event.target === this.includeSubItemsCheckBox) {
47
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_handleIncludeSubItemsCheckBoxEvent).call(this, event);
48
+ }
49
+ else if (event.target instanceof TreeViewElement || event.target instanceof TreeViewSubTreeNodeElement) {
50
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_handleTreeViewEvent).call(this, event);
51
+ }
52
+ }
53
+ get filterModeControl() {
54
+ return this.filterModeControlList.closest('segmented-control');
55
+ }
56
+ get treeView() {
57
+ return this.treeViewList.closest('tree-view');
58
+ }
59
+ set filterFn(newFn) {
60
+ __classPrivateFieldSet(this, _FilterableTreeViewElement_filterFn, newFn, "f");
61
+ }
62
+ get filterFn() {
63
+ if (__classPrivateFieldGet(this, _FilterableTreeViewElement_filterFn, "f")) {
64
+ return __classPrivateFieldGet(this, _FilterableTreeViewElement_filterFn, "f");
65
+ }
66
+ else {
67
+ return this.defaultFilterFn;
68
+ }
69
+ }
70
+ defaultFilterFn(node, query, filterMode) {
71
+ const ranges = [];
72
+ if (query.length > 0) {
73
+ const lowercaseQuery = query.toLowerCase();
74
+ const treeWalker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
75
+ let currentNode = treeWalker.nextNode();
76
+ while (currentNode) {
77
+ const lowercaseNodeText = currentNode.textContent?.toLocaleLowerCase() || '';
78
+ let startIndex = 0;
79
+ while (startIndex < lowercaseNodeText.length) {
80
+ const index = lowercaseNodeText.indexOf(lowercaseQuery, startIndex);
81
+ if (index === -1)
82
+ break;
83
+ const range = new Range();
84
+ range.setStart(currentNode, index);
85
+ range.setEnd(currentNode, index + lowercaseQuery.length);
86
+ ranges.push(range);
87
+ startIndex = index + lowercaseQuery.length;
88
+ }
89
+ currentNode = treeWalker.nextNode();
90
+ }
91
+ }
92
+ if (ranges.length === 0 && query.length > 0) {
93
+ return null;
94
+ }
95
+ switch (filterMode) {
96
+ case 'selected': {
97
+ // Only match nodes that have been checked
98
+ if (this.treeView?.getNodeCheckedValue(node) !== 'false') {
99
+ return ranges;
100
+ }
101
+ break;
102
+ }
103
+ case 'all': {
104
+ return ranges;
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+ get filterMode() {
110
+ const current = this.filterModeControl?.current;
111
+ if (current) {
112
+ return current.getAttribute('data-name');
113
+ }
114
+ else {
115
+ return null;
116
+ }
117
+ }
118
+ get queryString() {
119
+ return this.filterInput.value;
120
+ }
121
+ // Iterates over the nodes in the given sub-tree in depth-first order, yielding a list of leaf nodes
122
+ // and an array of ancestor nodes. It uses the aria-level information attached to each node to determine
123
+ // the next level of the tree to visit.
124
+ *eachDescendantDepthFirst(node, level, ancestry) {
125
+ for (const subTreeItem of node.querySelectorAll(`[role=treeitem][data-node-type='sub-tree'][aria-level='${level}']`)) {
126
+ const subTree = subTreeItem.closest('tree-view-sub-tree-node');
127
+ yield* this.eachDescendantDepthFirst(subTree, level + 1, [...ancestry, subTree]);
128
+ }
129
+ const leafNodes = node.querySelectorAll(`[role=treeitem][data-node-type='leaf'][aria-level='${level}']`);
130
+ yield [leafNodes, ancestry];
131
+ }
132
+ // Yields only the shallowest (i.e. lowest depth) sub-tree nodes that are checked, i.e. does not
133
+ // visit a sub-tree's children if that sub-tree is checked.
134
+ *eachShallowestCheckedSubTree(root) {
135
+ if (this.treeView?.getNodeCheckedValue(root.node) === 'true') {
136
+ yield root;
137
+ return; // do not descend further
138
+ }
139
+ for (const childSubTree of root.eachDirectDescendantSubTreeNode()) {
140
+ yield* this.eachShallowestCheckedSubTree(childSubTree);
141
+ }
142
+ }
143
+ };
144
+ _FilterableTreeViewElement_filterFn = new WeakMap();
145
+ _FilterableTreeViewElement_abortController = new WeakMap();
146
+ _FilterableTreeViewElement_stateMap = new WeakMap();
147
+ _FilterableTreeViewElement_instances = new WeakSet();
148
+ _FilterableTreeViewElement_handleTreeViewEvent = function _FilterableTreeViewElement_handleTreeViewEvent(origEvent) {
149
+ const event = origEvent;
150
+ // NOTE: This event only fires if someone actually activates the check mark, i.e. does not fire
151
+ // when calling this.treeView.setNodeCheckedValue.
152
+ switch (origEvent.type) {
153
+ case 'treeViewNodeChecked':
154
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_handleTreeViewNodeChecked).call(this, event);
155
+ break;
156
+ }
157
+ };
158
+ _FilterableTreeViewElement_handleTreeViewNodeChecked = function _FilterableTreeViewElement_handleTreeViewNodeChecked(event) {
159
+ if (!this.treeView)
160
+ return;
161
+ if (!this.includeSubItemsCheckBox.checked)
162
+ return;
163
+ // Although multiple nodes may have been checked (eg. if the TreeView is in descendants mode),
164
+ // the one that actually received the click, i.e. the local root the user checked, is the first
165
+ // entry. We only care about sub-tree nodes because checking them affects all leaf nodes, so
166
+ // there's no need to check or uncheck individual leaves.
167
+ const nodeInfo = event.detail[0];
168
+ if (this.treeView.getNodeType(nodeInfo.node) !== 'sub-tree')
169
+ return;
170
+ const subTree = nodeInfo.node.closest('tree-view-sub-tree-node');
171
+ if (nodeInfo.checkedValue === 'false') {
172
+ // If the sub-tree has been unchecked, restore whatever state they were in before. We don't
173
+ // need to explicitly enable the sub-tree because restoring will handle setting the enabled
174
+ // or disabled state per-node.
175
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_restoreNodeState).call(this, subTree);
176
+ }
177
+ else {
178
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_includeSubItemsUnder).call(this, subTree);
179
+ }
180
+ };
181
+ _FilterableTreeViewElement_restoreNodeState = function _FilterableTreeViewElement_restoreNodeState(subTree) {
182
+ if (!this.treeView)
183
+ return;
184
+ if (!__classPrivateFieldGet(this, _FilterableTreeViewElement_stateMap, "f").has(subTree))
185
+ return;
186
+ const descendantStates = __classPrivateFieldGet(this, _FilterableTreeViewElement_stateMap, "f").get(subTree);
187
+ for (const [element, state] of descendantStates.entries()) {
188
+ let node = element;
189
+ if (element instanceof TreeViewSubTreeNodeElement) {
190
+ node = element.node;
191
+ }
192
+ this.treeView.setNodeCheckedValue(node, state.checked ? 'true' : 'false');
193
+ this.treeView.setNodeDisabledValue(node, state.disabled);
194
+ }
195
+ // once node state has been restored, there's no reason to keep it around - it will be saved
196
+ // again if this sub-tree gets checked
197
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_stateMap, "f").delete(subTree);
198
+ };
199
+ _FilterableTreeViewElement_handleFilterModeEvent = function _FilterableTreeViewElement_handleFilterModeEvent(event) {
200
+ if (event.type !== 'itemActivated')
201
+ return;
202
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_applyFilterOptions).call(this);
203
+ };
204
+ _FilterableTreeViewElement_handleFilterInputEvent = function _FilterableTreeViewElement_handleFilterInputEvent(event) {
205
+ if (event.type !== 'input')
206
+ return;
207
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_applyFilterOptions).call(this);
208
+ };
209
+ _FilterableTreeViewElement_handleIncludeSubItemsCheckBoxEvent = function _FilterableTreeViewElement_handleIncludeSubItemsCheckBoxEvent(event) {
210
+ if (!this.treeView)
211
+ return;
212
+ if (event.type !== 'input')
213
+ return;
214
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_applyFilterOptions).call(this);
215
+ if (this.includeSubItemsCheckBox.checked) {
216
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_includeSubItems).call(this);
217
+ }
218
+ else {
219
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_restoreAllNodeStates).call(this);
220
+ }
221
+ };
222
+ _FilterableTreeViewElement_includeSubItems = function _FilterableTreeViewElement_includeSubItems() {
223
+ if (!this.treeView)
224
+ return;
225
+ for (const subTree of this.treeView.rootSubTreeNodes()) {
226
+ for (const checkedSubTree of this.eachShallowestCheckedSubTree(subTree)) {
227
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_includeSubItemsUnder).call(this, checkedSubTree);
228
+ }
229
+ }
230
+ };
231
+ _FilterableTreeViewElement_includeSubItemsUnder = function _FilterableTreeViewElement_includeSubItemsUnder(subTree) {
232
+ if (!this.treeView)
233
+ return;
234
+ const descendantStates = new Map();
235
+ for (const node of subTree.eachDescendantNode()) {
236
+ descendantStates.set(node, {
237
+ checked: this.treeView.getNodeCheckedValue(node) === 'true',
238
+ disabled: this.treeView.getNodeDisabledValue(node),
239
+ });
240
+ this.treeView.setNodeCheckedValue(node, 'true');
241
+ this.treeView.setNodeDisabledValue(node, true);
242
+ }
243
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_stateMap, "f").set(subTree, descendantStates);
244
+ };
245
+ _FilterableTreeViewElement_restoreAllNodeStates = function _FilterableTreeViewElement_restoreAllNodeStates() {
246
+ for (const subTree of __classPrivateFieldGet(this, _FilterableTreeViewElement_stateMap, "f").keys()) {
247
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_restoreNodeState).call(this, subTree);
248
+ }
249
+ };
250
+ _FilterableTreeViewElement_applyFilterOptions = function _FilterableTreeViewElement_applyFilterOptions() {
251
+ if (!this.treeView)
252
+ return;
253
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_removeHighlights).call(this);
254
+ const query = this.queryString;
255
+ const mode = this.filterMode || undefined;
256
+ const generation = window.crypto.randomUUID();
257
+ const filterRangesCache = new Map();
258
+ const expandAncestors = (...ancestors) => {
259
+ for (const ancestor of ancestors) {
260
+ ancestor.expand();
261
+ ancestor.removeAttribute('hidden');
262
+ ancestor.setAttribute('data-generation', generation);
263
+ if (cachedFilterFn(ancestor.node, query, mode)) {
264
+ ancestor.node.removeAttribute('aria-disabled');
265
+ }
266
+ else {
267
+ ancestor.node.setAttribute('aria-disabled', 'true');
268
+ }
269
+ }
270
+ };
271
+ // This function is called in the loop below for both leaf nodes and sub-tree nodes to determine
272
+ // if they match, and subsequently whether or not to hide them. However, it serves a secondary purpose
273
+ // as well in that it remembers the range information returned by the filter function so it can be
274
+ // used to highlight matching ranges later.
275
+ const cachedFilterFn = (node, queryStr, filterMode) => {
276
+ if (!filterRangesCache.has(node)) {
277
+ filterRangesCache.set(node, this.filterFn(node, queryStr, filterMode));
278
+ }
279
+ return filterRangesCache.get(node) !== null;
280
+ };
281
+ /* We iterate depth-first here in order to be able to examine the most deeply nested leaf nodes
282
+ * before their parents. This enables us to easily hide the parent if none of its children match.
283
+ * To handle expanding and collapsing ancestors, the algorithm iterates over the provided ancestor
284
+ * chain, expanding "upwards" to the root.
285
+ *
286
+ * Using this technique does mean it's possible to iterate over the same ancestor multiple times.
287
+ * For example, consider two nodes that share the same ancestor. Node A contains matching children,
288
+ * but node B does not. The algorithm below will visit node A first and expand it and all its
289
+ * ancestors. Next, the algorithm will visit node B and collapse all its ancestors. To avoid this,
290
+ * the algorithm attaches a random "generation ID" to each node visited. If the generation ID
291
+ * matches when visiting a particular node, we know that node has already been visited and should
292
+ * not be hidden or collapsed.
293
+ */
294
+ for (const [leafNodes, ancestors] of this.eachDescendantDepthFirst(this.treeViewList, 1, [])) {
295
+ const parent = ancestors[ancestors.length - 1];
296
+ let atLeastOneLeafMatches = false;
297
+ for (const leafNode of leafNodes) {
298
+ if (cachedFilterFn(leafNode, query, mode)) {
299
+ leafNode.closest('li')?.removeAttribute('hidden');
300
+ atLeastOneLeafMatches = true;
301
+ }
302
+ else {
303
+ leafNode.closest('li')?.setAttribute('hidden', 'hidden');
304
+ }
305
+ }
306
+ if (atLeastOneLeafMatches) {
307
+ expandAncestors(...ancestors);
308
+ }
309
+ else {
310
+ if (parent) {
311
+ if (cachedFilterFn(parent.node, query, mode)) {
312
+ // sub-tree matched, so expand ancestors
313
+ expandAncestors(...ancestors);
314
+ }
315
+ else {
316
+ // this node has already been marked by the current generation and is therefore
317
+ // a shared ancestor - don't collapse or hide it
318
+ if (parent.getAttribute('data-generation') !== generation) {
319
+ parent.collapse();
320
+ parent.setAttribute('hidden', 'hidden');
321
+ }
322
+ }
323
+ }
324
+ }
325
+ }
326
+ // convert range map into a 1-dimensional array with no nulls so it can be given to
327
+ // #applyHighlights (and therefore CSS.highlights.set) more easily
328
+ const allRanges = Array.from(filterRangesCache.values())
329
+ .flat()
330
+ .filter(r => r !== null);
331
+ if (allRanges.length === 0 && query.length > 0) {
332
+ this.treeViewList.setAttribute('hidden', 'hidden');
333
+ this.noResultsMessage.removeAttribute('hidden');
334
+ }
335
+ else {
336
+ this.treeViewList.removeAttribute('hidden');
337
+ this.noResultsMessage.setAttribute('hidden', 'hidden');
338
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_applyHighlights).call(this, allRanges);
339
+ }
340
+ };
341
+ _FilterableTreeViewElement_applyHighlights = function _FilterableTreeViewElement_applyHighlights(ranges) {
342
+ // Attempt to use the new-ish custom highlight API:
343
+ // https://developer.mozilla.org/en-US/docs/Web/API/CSS_Custom_Highlight_API
344
+ if (CSS.highlights) {
345
+ CSS.highlights.set('primer-filterable-tree-view-search-results', new Highlight(...ranges));
346
+ }
347
+ else {
348
+ __classPrivateFieldGet(this, _FilterableTreeViewElement_instances, "m", _FilterableTreeViewElement_applyManualHighlights).call(this, ranges);
349
+ }
350
+ };
351
+ _FilterableTreeViewElement_applyManualHighlights = function _FilterableTreeViewElement_applyManualHighlights(ranges) {
352
+ const textNode = ranges[0].startContainer;
353
+ const parent = textNode.parentNode;
354
+ const originalText = textNode.textContent;
355
+ const fragments = [];
356
+ let lastIndex = 0;
357
+ for (const { startOffset, endOffset } of ranges) {
358
+ // text before the highlight
359
+ if (startOffset > lastIndex) {
360
+ fragments.push(document.createTextNode(originalText.slice(lastIndex, startOffset)));
361
+ }
362
+ // highlighted text
363
+ const mark = document.createElement('mark');
364
+ mark.textContent = originalText.slice(startOffset, endOffset);
365
+ fragments.push(mark);
366
+ lastIndex = endOffset;
367
+ }
368
+ // remaining text after the last highlight
369
+ if (lastIndex < originalText.length) {
370
+ fragments.push(document.createTextNode(originalText.slice(lastIndex)));
371
+ }
372
+ // replace original text node with our text + <mark> elements
373
+ for (const frag of fragments.reverse()) {
374
+ parent.insertBefore(frag, textNode.nextSibling);
375
+ }
376
+ parent.removeChild(textNode);
377
+ };
378
+ _FilterableTreeViewElement_removeHighlights = function _FilterableTreeViewElement_removeHighlights() {
379
+ // quick-and-dirty way of ignoring any existing <mark> elements and restoring
380
+ // the original text
381
+ for (const mark of this.querySelectorAll('mark')) {
382
+ if (!mark.parentElement)
383
+ continue;
384
+ mark.parentElement.replaceChildren(mark.parentElement.textContent);
385
+ }
386
+ };
387
+ __decorate([
388
+ target
389
+ ], FilterableTreeViewElement.prototype, "filterInput", void 0);
390
+ __decorate([
391
+ target
392
+ ], FilterableTreeViewElement.prototype, "filterModeControlList", void 0);
393
+ __decorate([
394
+ target
395
+ ], FilterableTreeViewElement.prototype, "treeViewList", void 0);
396
+ __decorate([
397
+ target
398
+ ], FilterableTreeViewElement.prototype, "noResultsMessage", void 0);
399
+ __decorate([
400
+ target
401
+ ], FilterableTreeViewElement.prototype, "includeSubItemsCheckBox", void 0);
402
+ FilterableTreeViewElement = __decorate([
403
+ controller
404
+ ], FilterableTreeViewElement);
405
+ export { FilterableTreeViewElement };
406
+ if (!window.customElements.get('filterable-tree-view')) {
407
+ window.FilterableTreeViewElement = FilterableTreeViewElement;
408
+ window.customElements.define('filterable-tree-view', FilterableTreeViewElement);
409
+ }
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Primer
4
+ module OpenProject
5
+ # A TreeView and associated filter controls for searching nested hierarchies.
6
+ #
7
+ # ## Filter controls
8
+ #
9
+ # `FilterableTreeView`s can be filtered using two controls, both present in the toolbar above the tree:
10
+ #
11
+ # 1. A free-form query string from a text input field.
12
+ # 2. A `SegmentedControl` with two options (by default):
13
+ # 1. The "Selected" option causes the component to only show checked nodes, provided they also satisfy the other
14
+ # filter criteria described here.
15
+ # 2. The "All" option causes the component to show all nodes, provided they also satisfy the other filter
16
+ # criteria described here.
17
+ #
18
+ # ## Custom filter modes
19
+ #
20
+ # In addition to the default filter modes of `'all'` and `'selected'` described above, `FilterableTreeView` supports
21
+ # adding custom filter modes. Adding a filter mode will cause its label to appear in the `SegmentedControl` in the
22
+ # toolbar, and will be passed as the third argument to the filter function (see below).
23
+ #
24
+ # Here's how to add a custom filter mode in addition to the default ones:
25
+ #
26
+ # ```erb
27
+ # <%= render(Primer::OpenProject::FilterableTreeView.new) do |tree_view| %>
28
+ # <%# remove this line to prevent adding the default modes %>
29
+ # <% tree_view.with_default_filter_modes %>
30
+ # <% tree_view.with_filter_mode(name: "Custom", system_arguments)
31
+ # <% end %>
32
+ # ```
33
+ #
34
+ # ## Filter behavior
35
+ #
36
+ # By default, matching node text is identified by looking for an exact substring match, operating on a lowercased
37
+ # version of both the query string and the node text. For more information, and to provide a customized filter
38
+ # function, please see the section titled "Customizing the filter function" below.
39
+ #
40
+ # Nodes that match the filter appear as normal; nodes that do not match are presented as follows:
41
+ #
42
+ # 1. Leaf nodes are hidden.
43
+ # 2. Sub-tree nodes with no matching children are hidden.
44
+ # 3. Sub-tree nodes with at least one matching child are disabled but still visible.
45
+ #
46
+ # ## Checking behavior
47
+ #
48
+ # By default, checking a node in a `FilterableTreeView` checks only that node (i.e. no child nodes are checked).
49
+ # To aide in checking children in deeply nested or highly populated hierarchies, a third control exists in the
50
+ # toolbar: the "Include sub-items" check box. If this feature is turned on, checking sub-tree nodes causes all
51
+ # children, both leaf and sub-tree nodes, to also be checked recursively. Moreover, turning this feature on will
52
+ # cause the children of any previously checked nodes to be checked recursively. Unchecking a node while in
53
+ # "Include sub-items" mode will restore that sub-tree and all its children to their previously checked state, so as
54
+ # not to permanently override a user's selections. Unchecking the "Include sub-items" check box has a similar effect,
55
+ # i.e. restores all previous user selections under currently checked sub-trees.
56
+ #
57
+ # ## JavaScript API
58
+ #
59
+ # `FilterableTreeView` does not yet have an extensive JavaScript API, but this may change in the future as the
60
+ # component is further developed to fit additional use-cases.
61
+ #
62
+ # ### Customizing the filter function
63
+ #
64
+ # The filter function can be customized by setting the value of the `filterFn` property to a function with the
65
+ # following signature:
66
+ #
67
+ # ```typescript
68
+ # export type FilterFn = (node: HTMLElement, query: string, filterMode?: string) => Range[] | null
69
+ # ```
70
+ #
71
+ # This function will be called once for each node in the tree every time filter controls change (i.e. when the
72
+ # filter mode or query string are altered). The function is called with the following arguments:
73
+ #
74
+ # |Argument |Description |
75
+ # |:-----------|:----------------------------------------------------------------|
76
+ # |`node` |The HTML node element, i.e. the element with `role=treeitem` set.|
77
+ # |`query` |The query string. |
78
+ # |`filterMode`|The filter mode, either `'all'` or `'selected'`. |
79
+ #
80
+ # The component expects the filter function to return specific values depending on the type of match:
81
+ #
82
+ # 1. No match - return `null`
83
+ # 2. Match but no highlights (eg. when the query string is empty) - return an empty array
84
+ # 3. Match with highlights - return a non-empty array of `Range` objects
85
+ #
86
+ # Example:
87
+ #
88
+ # ```javascript
89
+ # const filterableTreeView = document.querySelector('filterable-tree-view')
90
+ # filterableTreeView.filterFn = (node, query, filterMode) => {
91
+ # // custom filter implementation here
92
+ # }
93
+ # ```
94
+ #
95
+ # You can read about `Range` objects here: https://developer.mozilla.org/en-US/docs/Web/API/Range.
96
+ #
97
+ # For a complete example demonstrating how to implement a working filter function complete with range highlighting,
98
+ # see the default filter function available in the `FilterableTreeViewElement` JavaScript class, which is part of
99
+ # the Primer source code.
100
+ #
101
+ # ### Events
102
+ #
103
+ # Currently `FilterableTreeView` does not emit any events aside from the events already emitted by the `TreeView`
104
+ # component.
105
+ class FilterableTreeView < Primer::Component
106
+ delegate :with_leaf, :with_sub_tree, to: :@tree_view
107
+
108
+ DEFAULT_FILTER_INPUT_ARGUMENTS = {
109
+ name: :filter,
110
+ label: I18n.t(:button_filter),
111
+ type: :search,
112
+ leading_visual: { icon: :search },
113
+ visually_hide_label: true,
114
+ show_clear_button: true,
115
+ }
116
+
117
+ DEFAULT_FILTER_INPUT_ARGUMENTS.freeze
118
+
119
+ DEFAULT_FILTER_MODE_CONTROL_ARGUMENTS = {
120
+ aria: {
121
+ label: I18n.t("filterable_tree_view.filter_mode.label")
122
+ }
123
+ }
124
+
125
+ DEFAULT_FILTER_MODE_CONTROL_ARGUMENTS.freeze
126
+
127
+ DEFAULT_INCLUDE_SUB_ITEMS_CHECK_BOX_ARGUMENTS = {
128
+ label: I18n.t("filterable_tree_view.include_sub_items"),
129
+ name: :include_sub_items
130
+ }
131
+
132
+ DEFAULT_INCLUDE_SUB_ITEMS_CHECK_BOX_ARGUMENTS.freeze
133
+
134
+ DEFAULT_FILTER_MODES = {
135
+ all: {
136
+ label: I18n.t("filterable_tree_view.filter_mode.all"),
137
+ selected: true,
138
+ },
139
+
140
+ selected: {
141
+ label: I18n.t("filterable_tree_view.filter_mode.selected"),
142
+ }
143
+ }
144
+
145
+ DEFAULT_FILTER_MODES.freeze
146
+
147
+ DEFAULT_NO_RESULTS_NODE_ARGUMENTS = {
148
+ label: I18n.t("filterable_tree_view.no_results_text")
149
+ }
150
+
151
+ DEFAULT_NO_RESULTS_NODE_ARGUMENTS.freeze
152
+
153
+ # @param tree_view_arguments [Hash] Arguments that will be passed to the underlying <%= link_to_component(Primer::OpenProject::TreeView) %> component.
154
+ # @param form_arguments [Hash] Form arguments that will be passed to the underlying <%= link_to_component(Primer::OpenProject::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.
155
+ # @param filter_input_arguments [Hash] Arguments that will be passed to the <%= link_to_component(Primer::Alpha::TextField) %> component.
156
+ # @param filter_mode_control_arguments [Hash] Arguments that will be passed to the <%= link_to_component(Primer::Alpha::SegmentedControl) %> component.
157
+ # @param include_sub_items_check_box_arguments [Hash] Arguments that will be passed to the <%= link_to_component(Primer::Alpha::CheckBox) %> component.
158
+ # @param no_results_node_arguments [Hash] Arguments that will be passed to a <%= link_to_component(Primer::OpenProject::TreeView::LeafNode) %> component that appears when no items match the filter criteria.
159
+ def initialize(
160
+ tree_view_arguments: {},
161
+ form_arguments: {},
162
+ filter_input_arguments: DEFAULT_FILTER_INPUT_ARGUMENTS.dup,
163
+ filter_mode_control_arguments: DEFAULT_FILTER_MODE_CONTROL_ARGUMENTS.dup,
164
+ include_sub_items_check_box_arguments: DEFAULT_INCLUDE_SUB_ITEMS_CHECK_BOX_ARGUMENTS.dup,
165
+ no_results_node_arguments: DEFAULT_NO_RESULTS_NODE_ARGUMENTS.dup,
166
+ **system_arguments
167
+ )
168
+ tree_view_arguments[:data] = merge_data(
169
+ tree_view_arguments, {
170
+ data: { target: "filterable-tree-view.treeViewList" }
171
+ }
172
+ )
173
+
174
+ @tree_view = Primer::OpenProject::TreeView.new(
175
+ form_arguments: form_arguments,
176
+ **tree_view_arguments
177
+ )
178
+
179
+ filter_input_arguments[:data] = merge_data(
180
+ filter_input_arguments, {
181
+ data: { target: "filterable-tree-view.filterInput" }
182
+ }
183
+ )
184
+
185
+ @filter_input = Primer::Alpha::TextField.new(**filter_input_arguments)
186
+
187
+ filter_mode_control_arguments[:data] = merge_data(
188
+ filter_mode_control_arguments, {
189
+ data: { target: "filterable-tree-view.filterModeControlList" }
190
+ }
191
+ )
192
+
193
+ @filter_mode_control = Primer::Alpha::SegmentedControl.new(**filter_mode_control_arguments)
194
+
195
+ include_sub_items_check_box_arguments[:data] = merge_data(
196
+ include_sub_items_check_box_arguments, {
197
+ data: { target: "filterable-tree-view.includeSubItemsCheckBox" }
198
+ }
199
+ )
200
+
201
+ @include_sub_items_check_box = Primer::Alpha::CheckBox.new(**include_sub_items_check_box_arguments)
202
+
203
+ @system_arguments = deny_tag_argument(**system_arguments)
204
+ @system_arguments[:tag] = :"filterable-tree-view"
205
+
206
+ @no_results_node_arguments = no_results_node_arguments
207
+ end
208
+
209
+ def with_default_filter_modes
210
+ DEFAULT_FILTER_MODES.each do |name, system_arguments|
211
+ with_filter_mode(name: name, **system_arguments)
212
+ end
213
+ end
214
+
215
+ def with_filter_mode(name:, **system_arguments)
216
+ system_arguments[:data] = merge_data(
217
+ system_arguments, {
218
+ data: { name: name }
219
+ }
220
+ )
221
+
222
+ @filter_mode_control.with_item(**system_arguments)
223
+ end
224
+
225
+ def with_sub_tree(**system_arguments, &block)
226
+ @tree_view.with_sub_tree(
227
+ sub_tree_component_klass: SubTree,
228
+ **system_arguments,
229
+ select_variant: :multiple,
230
+ select_strategy: :self,
231
+ &block
232
+ )
233
+ end
234
+
235
+ def with_leaf(**system_arguments, &block)
236
+ @tree_view.with_leaf(
237
+ **system_arguments,
238
+ select_variant: :multiple,
239
+ &block
240
+ )
241
+ end
242
+
243
+ private
244
+
245
+ def before_render
246
+ content
247
+
248
+ if @filter_mode_control.items.empty?
249
+ with_default_filter_modes
250
+ end
251
+ end
252
+ end
253
+ end
254
+ end