openproject-primer_view_components 0.70.4 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/app/assets/javascripts/components/primer/alpha/segmented_control.d.ts +2 -2
- data/app/assets/javascripts/components/primer/open_project/filterable_tree_view.d.ts +29 -0
- data/app/assets/javascripts/components/primer/open_project/tree_view/tree_view.d.ts +11 -1
- data/app/assets/javascripts/components/primer/open_project/tree_view/tree_view_sub_tree_node_element.d.ts +5 -1
- data/app/assets/javascripts/components/primer/primer.d.ts +1 -0
- data/app/assets/javascripts/primer_view_components.js +1 -1
- data/app/assets/javascripts/primer_view_components.js.map +1 -1
- data/app/assets/styles/primer_view_components.css +1 -1
- data/app/assets/styles/primer_view_components.css.map +1 -1
- data/app/components/primer/alpha/action_menu/sub_menu_item.rb +4 -0
- data/app/components/primer/alpha/segmented_control.d.ts +2 -2
- data/app/components/primer/alpha/segmented_control.js +12 -0
- data/app/components/primer/alpha/segmented_control.ts +16 -1
- data/app/components/primer/alpha/stack.css +1 -1
- data/app/components/primer/alpha/stack.css.json +5 -1
- data/app/components/primer/alpha/stack.css.map +1 -1
- data/app/components/primer/alpha/stack.pcss +13 -0
- data/app/components/primer/alpha/stack.rb +2 -1
- data/app/components/primer/open_project/filterable_tree_view/sub_tree.rb +39 -0
- data/app/components/primer/open_project/filterable_tree_view.d.ts +29 -0
- data/app/components/primer/open_project/filterable_tree_view.html.erb +28 -0
- data/app/components/primer/open_project/filterable_tree_view.js +409 -0
- data/app/components/primer/open_project/filterable_tree_view.rb +254 -0
- data/app/components/primer/open_project/filterable_tree_view.ts +492 -0
- data/app/components/primer/open_project/page_header.css +1 -1
- data/app/components/primer/open_project/page_header.css.json +1 -1
- data/app/components/primer/open_project/page_header.css.map +1 -1
- data/app/components/primer/open_project/page_header.pcss +4 -4
- data/app/components/primer/open_project/page_header.rb +6 -2
- data/app/components/primer/open_project/tree_view/node.rb +19 -3
- data/app/components/primer/open_project/tree_view/sub_tree_node.rb +14 -4
- data/app/components/primer/open_project/tree_view/tree_view.d.ts +11 -1
- data/app/components/primer/open_project/tree_view/tree_view.js +120 -20
- data/app/components/primer/open_project/tree_view/tree_view.ts +137 -18
- data/app/components/primer/open_project/tree_view/tree_view_sub_tree_node_element.d.ts +5 -1
- data/app/components/primer/open_project/tree_view/tree_view_sub_tree_node_element.js +27 -4
- data/app/components/primer/open_project/tree_view/tree_view_sub_tree_node_element.ts +36 -5
- data/app/components/primer/open_project/tree_view.css +1 -1
- data/app/components/primer/open_project/tree_view.css.json +9 -0
- data/app/components/primer/open_project/tree_view.css.map +1 -1
- data/app/components/primer/open_project/tree_view.html.erb +4 -0
- data/app/components/primer/open_project/tree_view.pcss +48 -0
- data/app/components/primer/open_project/tree_view.rb +6 -1
- data/app/components/primer/primer.d.ts +1 -0
- data/app/components/primer/primer.js +1 -0
- data/app/components/primer/primer.ts +1 -0
- data/app/lib/primer/forms/base_component.rb +1 -1
- data/app/lib/primer/forms/dsl/text_field_input.rb +2 -0
- data/config/locales/en.yml +20 -0
- data/lib/primer/view_components/version.rb +2 -2
- data/previews/primer/alpha/action_menu_preview/sub_menus.html.erb +1 -0
- data/previews/primer/open_project/filterable_tree_view_preview/_custom_select_js.html.erb +62 -0
- data/previews/primer/open_project/filterable_tree_view_preview/custom_checkbox_text.html.erb +26 -0
- data/previews/primer/open_project/filterable_tree_view_preview/custom_no_results_text.html.erb +28 -0
- data/previews/primer/open_project/filterable_tree_view_preview/custom_segmented_control.html.erb +31 -0
- data/previews/primer/open_project/filterable_tree_view_preview/default.html.erb +26 -0
- data/previews/primer/open_project/filterable_tree_view_preview/form_input.html.erb +32 -0
- data/previews/primer/open_project/filterable_tree_view_preview/playground.html.erb +26 -0
- data/previews/primer/open_project/filterable_tree_view_preview.rb +125 -0
- data/previews/primer/open_project/tree_view_preview/buttons.html.erb +4 -4
- data/previews/primer/open_project/tree_view_preview/default.html.erb +4 -4
- data/previews/primer/open_project/tree_view_preview/leaf_node_playground.html.erb +1 -1
- data/previews/primer/open_project/tree_view_preview/links.html.erb +4 -4
- data/previews/primer/open_project/tree_view_preview.rb +18 -8
- data/static/arguments.json +89 -3
- data/static/audited_at.json +2 -0
- data/static/classes.json +3 -0
- data/static/constants.json +40 -1
- data/static/info_arch.json +220 -3
- data/static/previews.json +86 -0
- data/static/statuses.json +2 -0
- 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
|