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,492 @@
|
|
1
|
+
import {controller, target} from '@github/catalyst'
|
2
|
+
import {SegmentedControlElement} from '../alpha/segmented_control'
|
3
|
+
import {TreeViewElement} from './tree_view/tree_view'
|
4
|
+
import {TreeViewSubTreeNodeElement} from './tree_view/tree_view_sub_tree_node_element'
|
5
|
+
import {TreeViewNodeInfo} from '../shared_events'
|
6
|
+
|
7
|
+
// This function is expected to return the following values:
|
8
|
+
// 1. No match - return null
|
9
|
+
// 2. Match but no highlights - empty array (i.e. when showing all selected nodes but empty query string)
|
10
|
+
// 3. Match with highlights - non-empty array of Range objects
|
11
|
+
export type FilterFn = (node: HTMLElement, query: string, filterMode?: string) => Range[] | null
|
12
|
+
|
13
|
+
type NodeState = {
|
14
|
+
checked: boolean
|
15
|
+
disabled: boolean
|
16
|
+
}
|
17
|
+
|
18
|
+
@controller
|
19
|
+
export class FilterableTreeViewElement extends HTMLElement {
|
20
|
+
@target filterInput: HTMLInputElement
|
21
|
+
@target filterModeControlList: HTMLElement
|
22
|
+
@target treeViewList: HTMLElement
|
23
|
+
@target noResultsMessage: HTMLElement
|
24
|
+
@target includeSubItemsCheckBox: HTMLInputElement
|
25
|
+
|
26
|
+
#filterFn?: FilterFn
|
27
|
+
#abortController: AbortController
|
28
|
+
#stateMap: Map<TreeViewSubTreeNodeElement, Map<HTMLElement, NodeState>> = new Map()
|
29
|
+
|
30
|
+
connectedCallback() {
|
31
|
+
const {signal} = (this.#abortController = new AbortController())
|
32
|
+
this.addEventListener('treeViewNodeChecked', this, {signal})
|
33
|
+
this.addEventListener('itemActivated', this, {signal})
|
34
|
+
this.addEventListener('input', this, {signal})
|
35
|
+
}
|
36
|
+
|
37
|
+
disconnectedCallback() {
|
38
|
+
this.#abortController.abort()
|
39
|
+
}
|
40
|
+
|
41
|
+
handleEvent(event: Event) {
|
42
|
+
if (event.target === this.filterModeControl) {
|
43
|
+
this.#handleFilterModeEvent(event)
|
44
|
+
} else if (event.target === this.filterInput) {
|
45
|
+
this.#handleFilterInputEvent(event)
|
46
|
+
} else if (event.target === this.includeSubItemsCheckBox) {
|
47
|
+
this.#handleIncludeSubItemsCheckBoxEvent(event)
|
48
|
+
} else if (event.target instanceof TreeViewElement || event.target instanceof TreeViewSubTreeNodeElement) {
|
49
|
+
this.#handleTreeViewEvent(event)
|
50
|
+
}
|
51
|
+
}
|
52
|
+
|
53
|
+
#handleTreeViewEvent(origEvent: Event) {
|
54
|
+
const event = origEvent as CustomEvent<TreeViewNodeInfo[]>
|
55
|
+
|
56
|
+
// NOTE: This event only fires if someone actually activates the check mark, i.e. does not fire
|
57
|
+
// when calling this.treeView.setNodeCheckedValue.
|
58
|
+
switch (origEvent.type) {
|
59
|
+
case 'treeViewNodeChecked':
|
60
|
+
this.#handleTreeViewNodeChecked(event)
|
61
|
+
break
|
62
|
+
}
|
63
|
+
}
|
64
|
+
|
65
|
+
#handleTreeViewNodeChecked(event: CustomEvent<TreeViewNodeInfo[]>) {
|
66
|
+
if (!this.treeView) return
|
67
|
+
if (!this.includeSubItemsCheckBox.checked) return
|
68
|
+
|
69
|
+
// Although multiple nodes may have been checked (eg. if the TreeView is in descendants mode),
|
70
|
+
// the one that actually received the click, i.e. the local root the user checked, is the first
|
71
|
+
// entry. We only care about sub-tree nodes because checking them affects all leaf nodes, so
|
72
|
+
// there's no need to check or uncheck individual leaves.
|
73
|
+
const nodeInfo = event.detail[0]
|
74
|
+
if (this.treeView.getNodeType(nodeInfo.node) !== 'sub-tree') return
|
75
|
+
|
76
|
+
const subTree = nodeInfo.node.closest('tree-view-sub-tree-node') as TreeViewSubTreeNodeElement
|
77
|
+
|
78
|
+
if (nodeInfo.checkedValue === 'false') {
|
79
|
+
// If the sub-tree has been unchecked, restore whatever state they were in before. We don't
|
80
|
+
// need to explicitly enable the sub-tree because restoring will handle setting the enabled
|
81
|
+
// or disabled state per-node.
|
82
|
+
this.#restoreNodeState(subTree)
|
83
|
+
} else {
|
84
|
+
this.#includeSubItemsUnder(subTree)
|
85
|
+
}
|
86
|
+
}
|
87
|
+
|
88
|
+
#restoreNodeState(subTree: TreeViewSubTreeNodeElement) {
|
89
|
+
if (!this.treeView) return
|
90
|
+
if (!this.#stateMap.has(subTree)) return
|
91
|
+
|
92
|
+
const descendantStates = this.#stateMap.get(subTree)!
|
93
|
+
|
94
|
+
for (const [element, state] of descendantStates.entries()) {
|
95
|
+
let node = element
|
96
|
+
|
97
|
+
if (element instanceof TreeViewSubTreeNodeElement) {
|
98
|
+
node = element.node
|
99
|
+
}
|
100
|
+
|
101
|
+
this.treeView.setNodeCheckedValue(node, state.checked ? 'true' : 'false')
|
102
|
+
this.treeView.setNodeDisabledValue(node, state.disabled)
|
103
|
+
}
|
104
|
+
|
105
|
+
// once node state has been restored, there's no reason to keep it around - it will be saved
|
106
|
+
// again if this sub-tree gets checked
|
107
|
+
this.#stateMap.delete(subTree)
|
108
|
+
}
|
109
|
+
|
110
|
+
get filterModeControl(): SegmentedControlElement | null {
|
111
|
+
return this.filterModeControlList.closest('segmented-control')
|
112
|
+
}
|
113
|
+
|
114
|
+
get treeView(): TreeViewElement | null {
|
115
|
+
return this.treeViewList.closest('tree-view')
|
116
|
+
}
|
117
|
+
|
118
|
+
#handleFilterModeEvent(event: Event) {
|
119
|
+
if (event.type !== 'itemActivated') return
|
120
|
+
|
121
|
+
this.#applyFilterOptions()
|
122
|
+
}
|
123
|
+
|
124
|
+
#handleFilterInputEvent(event: Event) {
|
125
|
+
if (event.type !== 'input') return
|
126
|
+
|
127
|
+
this.#applyFilterOptions()
|
128
|
+
}
|
129
|
+
|
130
|
+
#handleIncludeSubItemsCheckBoxEvent(event: Event) {
|
131
|
+
if (!this.treeView) return
|
132
|
+
if (event.type !== 'input') return
|
133
|
+
|
134
|
+
this.#applyFilterOptions()
|
135
|
+
|
136
|
+
if (this.includeSubItemsCheckBox.checked) {
|
137
|
+
this.#includeSubItems()
|
138
|
+
} else {
|
139
|
+
this.#restoreAllNodeStates()
|
140
|
+
}
|
141
|
+
}
|
142
|
+
|
143
|
+
// Automatically checks all children of checked nodes, including leaf nodes and sub-trees. It does so
|
144
|
+
// by finding the set of shallowest checked sub-tree nodes, i.e. the set of checked sub-tree nodes with
|
145
|
+
// the lowest level value. It then saves their node state, disables them, and checks all their children.
|
146
|
+
// Rather than storing child node information for every checked sub-tree regardless of depth, finding
|
147
|
+
// the set of shallowest sub-tree nodes allows the component to store the minimum amount of node
|
148
|
+
// information and simplifies the process of restoring it later.
|
149
|
+
#includeSubItems() {
|
150
|
+
if (!this.treeView) return
|
151
|
+
|
152
|
+
for (const subTree of this.treeView.rootSubTreeNodes()) {
|
153
|
+
for (const checkedSubTree of this.eachShallowestCheckedSubTree(subTree)) {
|
154
|
+
this.#includeSubItemsUnder(checkedSubTree)
|
155
|
+
}
|
156
|
+
}
|
157
|
+
}
|
158
|
+
|
159
|
+
// Records the state of all the nodes in the given sub-tree. Node state includes whether or not the
|
160
|
+
// node is checked, and whether or not it is disabled. Or at least, that's what it included when this
|
161
|
+
// comment was first written. Check the members of the NodeState type above for up-to-date info.
|
162
|
+
#includeSubItemsUnder(subTree: TreeViewSubTreeNodeElement) {
|
163
|
+
if (!this.treeView) return
|
164
|
+
|
165
|
+
const descendantStates: Map<HTMLElement, NodeState> = new Map()
|
166
|
+
|
167
|
+
for (const node of subTree.eachDescendantNode()) {
|
168
|
+
descendantStates.set(node as HTMLElement, {
|
169
|
+
checked: this.treeView.getNodeCheckedValue(node) === 'true',
|
170
|
+
disabled: this.treeView.getNodeDisabledValue(node),
|
171
|
+
})
|
172
|
+
|
173
|
+
this.treeView.setNodeCheckedValue(node, 'true')
|
174
|
+
this.treeView.setNodeDisabledValue(node, true)
|
175
|
+
}
|
176
|
+
|
177
|
+
this.#stateMap.set(subTree, descendantStates)
|
178
|
+
}
|
179
|
+
|
180
|
+
// Revert all nodes back to their saved state, i.e. from before we automatically checked and disabled
|
181
|
+
// everything.
|
182
|
+
#restoreAllNodeStates() {
|
183
|
+
for (const subTree of this.#stateMap.keys()) {
|
184
|
+
this.#restoreNodeState(subTree)
|
185
|
+
}
|
186
|
+
}
|
187
|
+
|
188
|
+
set filterFn(newFn: FilterFn) {
|
189
|
+
this.#filterFn = newFn
|
190
|
+
}
|
191
|
+
|
192
|
+
get filterFn(): FilterFn {
|
193
|
+
if (this.#filterFn) {
|
194
|
+
return this.#filterFn
|
195
|
+
} else {
|
196
|
+
return this.defaultFilterFn
|
197
|
+
}
|
198
|
+
}
|
199
|
+
|
200
|
+
defaultFilterFn(node: HTMLElement, query: string, filterMode?: string): Range[] | null {
|
201
|
+
const ranges = []
|
202
|
+
|
203
|
+
if (query.length > 0) {
|
204
|
+
const lowercaseQuery = query.toLowerCase()
|
205
|
+
const treeWalker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT)
|
206
|
+
let currentNode = treeWalker.nextNode()
|
207
|
+
|
208
|
+
while (currentNode) {
|
209
|
+
const lowercaseNodeText = currentNode.textContent?.toLocaleLowerCase() || ''
|
210
|
+
let startIndex = 0
|
211
|
+
|
212
|
+
while (startIndex < lowercaseNodeText.length) {
|
213
|
+
const index = lowercaseNodeText.indexOf(lowercaseQuery, startIndex)
|
214
|
+
if (index === -1) break
|
215
|
+
|
216
|
+
const range = new Range()
|
217
|
+
range.setStart(currentNode, index)
|
218
|
+
range.setEnd(currentNode, index + lowercaseQuery.length)
|
219
|
+
ranges.push(range)
|
220
|
+
|
221
|
+
startIndex = index + lowercaseQuery.length
|
222
|
+
}
|
223
|
+
|
224
|
+
currentNode = treeWalker.nextNode()
|
225
|
+
}
|
226
|
+
}
|
227
|
+
|
228
|
+
if (ranges.length === 0 && query.length > 0) {
|
229
|
+
return null
|
230
|
+
}
|
231
|
+
|
232
|
+
switch (filterMode) {
|
233
|
+
case 'selected': {
|
234
|
+
// Only match nodes that have been checked
|
235
|
+
if (this.treeView?.getNodeCheckedValue(node) !== 'false') {
|
236
|
+
return ranges
|
237
|
+
}
|
238
|
+
|
239
|
+
break
|
240
|
+
}
|
241
|
+
|
242
|
+
case 'all': {
|
243
|
+
return ranges
|
244
|
+
}
|
245
|
+
}
|
246
|
+
|
247
|
+
return null
|
248
|
+
}
|
249
|
+
|
250
|
+
get filterMode(): string | null {
|
251
|
+
const current = this.filterModeControl?.current
|
252
|
+
|
253
|
+
if (current) {
|
254
|
+
return current.getAttribute('data-name')
|
255
|
+
} else {
|
256
|
+
return null
|
257
|
+
}
|
258
|
+
}
|
259
|
+
|
260
|
+
get queryString(): string {
|
261
|
+
return this.filterInput.value
|
262
|
+
}
|
263
|
+
|
264
|
+
/* This function does quite a bit. It's responsible for showing and hiding nodes that match the filter
|
265
|
+
* criteria, disabling nodes under certain conditions, and rendering highlights for node text that
|
266
|
+
* matches the query string. The filter criteria are as follows:
|
267
|
+
*
|
268
|
+
* 1. A free-form query string from a text input field.
|
269
|
+
* 2. A SegmentedControl with two options:
|
270
|
+
* 1. The "Selected" option causes the component to only show checked nodes, provided they also
|
271
|
+
* satisfy the other filter criteria described here.
|
272
|
+
* 2. The "All" option causes the component to show all nodes, provided they also satisfy the other
|
273
|
+
* filter criteria described here.
|
274
|
+
*
|
275
|
+
* Whether or not a node matches is determined by a filter function with a `FilterFn` signature. The
|
276
|
+
* component defines a default filter function, but a user-defined one can also be provided. The filter
|
277
|
+
* function is expected to return an array of `Range` objects which #applyFilterOptions uses to highlight
|
278
|
+
* node text that matches the query string. The default filter function identifies matching node text by
|
279
|
+
* looking for an exact substring match, operating on a lowercased version of both the query string and
|
280
|
+
* the node text. For an exact description of the expected return values of the filter function, please
|
281
|
+
* see the FilterFn type above.
|
282
|
+
*
|
283
|
+
* It should be noted that the returned `Range` objects must have starting and ending values that refer
|
284
|
+
* to offsets inside the same text node. Not adhering to this rule may lead to undefined behavior.
|
285
|
+
*
|
286
|
+
* Applying the filter criteria can have the following effects on individual nodes:
|
287
|
+
*
|
288
|
+
* 1. Hidden: Nodes are hidden if:
|
289
|
+
* 1. The filter function returns null.
|
290
|
+
* 2. Disabled: Nodes are disabled if:
|
291
|
+
* 1. The node is a child of a checked parent and the "Include sub-items" check box is checked.
|
292
|
+
* 4. Expanded: Sub-tree nodes are expanded if:
|
293
|
+
* 1. For at least one of the node's children, including descendants, the filter function returns a
|
294
|
+
* truthy value.
|
295
|
+
*/
|
296
|
+
#applyFilterOptions() {
|
297
|
+
if (!this.treeView) return
|
298
|
+
|
299
|
+
this.#removeHighlights()
|
300
|
+
|
301
|
+
const query = this.queryString
|
302
|
+
const mode = this.filterMode || undefined
|
303
|
+
const generation = window.crypto.randomUUID()
|
304
|
+
const filterRangesCache: Map<Element, Range[] | null> = new Map()
|
305
|
+
|
306
|
+
const expandAncestors = (...ancestors: TreeViewSubTreeNodeElement[]) => {
|
307
|
+
for (const ancestor of ancestors) {
|
308
|
+
ancestor.expand()
|
309
|
+
ancestor.removeAttribute('hidden')
|
310
|
+
ancestor.setAttribute('data-generation', generation)
|
311
|
+
|
312
|
+
if (cachedFilterFn(ancestor.node, query, mode)) {
|
313
|
+
ancestor.node.removeAttribute('aria-disabled')
|
314
|
+
} else {
|
315
|
+
ancestor.node.setAttribute('aria-disabled', 'true')
|
316
|
+
}
|
317
|
+
}
|
318
|
+
}
|
319
|
+
|
320
|
+
// This function is called in the loop below for both leaf nodes and sub-tree nodes to determine
|
321
|
+
// if they match, and subsequently whether or not to hide them. However, it serves a secondary purpose
|
322
|
+
// as well in that it remembers the range information returned by the filter function so it can be
|
323
|
+
// used to highlight matching ranges later.
|
324
|
+
const cachedFilterFn = (node: HTMLElement, queryStr: string, filterMode?: string): boolean => {
|
325
|
+
if (!filterRangesCache.has(node)) {
|
326
|
+
filterRangesCache.set(node, this.filterFn(node, queryStr, filterMode))
|
327
|
+
}
|
328
|
+
|
329
|
+
return filterRangesCache.get(node)! !== null
|
330
|
+
}
|
331
|
+
|
332
|
+
/* We iterate depth-first here in order to be able to examine the most deeply nested leaf nodes
|
333
|
+
* before their parents. This enables us to easily hide the parent if none of its children match.
|
334
|
+
* To handle expanding and collapsing ancestors, the algorithm iterates over the provided ancestor
|
335
|
+
* chain, expanding "upwards" to the root.
|
336
|
+
*
|
337
|
+
* Using this technique does mean it's possible to iterate over the same ancestor multiple times.
|
338
|
+
* For example, consider two nodes that share the same ancestor. Node A contains matching children,
|
339
|
+
* but node B does not. The algorithm below will visit node A first and expand it and all its
|
340
|
+
* ancestors. Next, the algorithm will visit node B and collapse all its ancestors. To avoid this,
|
341
|
+
* the algorithm attaches a random "generation ID" to each node visited. If the generation ID
|
342
|
+
* matches when visiting a particular node, we know that node has already been visited and should
|
343
|
+
* not be hidden or collapsed.
|
344
|
+
*/
|
345
|
+
for (const [leafNodes, ancestors] of this.eachDescendantDepthFirst(this.treeViewList, 1, [])) {
|
346
|
+
const parent: TreeViewSubTreeNodeElement | undefined = ancestors[ancestors.length - 1]
|
347
|
+
let atLeastOneLeafMatches = false
|
348
|
+
|
349
|
+
for (const leafNode of leafNodes) {
|
350
|
+
if (cachedFilterFn(leafNode, query, mode)) {
|
351
|
+
leafNode.closest('li')?.removeAttribute('hidden')
|
352
|
+
atLeastOneLeafMatches = true
|
353
|
+
} else {
|
354
|
+
leafNode.closest('li')?.setAttribute('hidden', 'hidden')
|
355
|
+
}
|
356
|
+
}
|
357
|
+
|
358
|
+
if (atLeastOneLeafMatches) {
|
359
|
+
expandAncestors(...ancestors)
|
360
|
+
} else {
|
361
|
+
if (parent) {
|
362
|
+
if (cachedFilterFn(parent.node, query, mode)) {
|
363
|
+
// sub-tree matched, so expand ancestors
|
364
|
+
expandAncestors(...ancestors)
|
365
|
+
} else {
|
366
|
+
// this node has already been marked by the current generation and is therefore
|
367
|
+
// a shared ancestor - don't collapse or hide it
|
368
|
+
if (parent.getAttribute('data-generation') !== generation) {
|
369
|
+
parent.collapse()
|
370
|
+
parent.setAttribute('hidden', 'hidden')
|
371
|
+
}
|
372
|
+
}
|
373
|
+
}
|
374
|
+
}
|
375
|
+
}
|
376
|
+
|
377
|
+
// convert range map into a 1-dimensional array with no nulls so it can be given to
|
378
|
+
// #applyHighlights (and therefore CSS.highlights.set) more easily
|
379
|
+
const allRanges = Array.from(filterRangesCache.values())
|
380
|
+
.flat()
|
381
|
+
.filter(r => r !== null)
|
382
|
+
|
383
|
+
if (allRanges.length === 0 && query.length > 0) {
|
384
|
+
this.treeViewList.setAttribute('hidden', 'hidden')
|
385
|
+
this.noResultsMessage.removeAttribute('hidden')
|
386
|
+
} else {
|
387
|
+
this.treeViewList.removeAttribute('hidden')
|
388
|
+
this.noResultsMessage.setAttribute('hidden', 'hidden')
|
389
|
+
|
390
|
+
this.#applyHighlights(allRanges)
|
391
|
+
}
|
392
|
+
}
|
393
|
+
|
394
|
+
#applyHighlights(ranges: Range[]) {
|
395
|
+
// Attempt to use the new-ish custom highlight API:
|
396
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/CSS_Custom_Highlight_API
|
397
|
+
if (CSS.highlights) {
|
398
|
+
CSS.highlights.set('primer-filterable-tree-view-search-results', new Highlight(...ranges))
|
399
|
+
} else {
|
400
|
+
this.#applyManualHighlights(ranges)
|
401
|
+
}
|
402
|
+
}
|
403
|
+
|
404
|
+
#applyManualHighlights(ranges: Range[]) {
|
405
|
+
const textNode = ranges[0].startContainer
|
406
|
+
const parent = textNode.parentNode!
|
407
|
+
const originalText = textNode.textContent!
|
408
|
+
const fragments = []
|
409
|
+
let lastIndex = 0
|
410
|
+
|
411
|
+
for (const {startOffset, endOffset} of ranges) {
|
412
|
+
// text before the highlight
|
413
|
+
if (startOffset > lastIndex) {
|
414
|
+
fragments.push(document.createTextNode(originalText.slice(lastIndex, startOffset)))
|
415
|
+
}
|
416
|
+
|
417
|
+
// highlighted text
|
418
|
+
const mark = document.createElement('mark')
|
419
|
+
mark.textContent = originalText.slice(startOffset, endOffset)
|
420
|
+
fragments.push(mark)
|
421
|
+
|
422
|
+
lastIndex = endOffset
|
423
|
+
}
|
424
|
+
|
425
|
+
// remaining text after the last highlight
|
426
|
+
if (lastIndex < originalText.length) {
|
427
|
+
fragments.push(document.createTextNode(originalText.slice(lastIndex)))
|
428
|
+
}
|
429
|
+
|
430
|
+
// replace original text node with our text + <mark> elements
|
431
|
+
for (const frag of fragments.reverse()) {
|
432
|
+
parent.insertBefore(frag, textNode.nextSibling)
|
433
|
+
}
|
434
|
+
|
435
|
+
parent.removeChild(textNode)
|
436
|
+
}
|
437
|
+
|
438
|
+
#removeHighlights() {
|
439
|
+
// quick-and-dirty way of ignoring any existing <mark> elements and restoring
|
440
|
+
// the original text
|
441
|
+
for (const mark of this.querySelectorAll('mark')) {
|
442
|
+
if (!mark.parentElement) continue
|
443
|
+
mark.parentElement.replaceChildren(mark.parentElement.textContent!)
|
444
|
+
}
|
445
|
+
}
|
446
|
+
|
447
|
+
// Iterates over the nodes in the given sub-tree in depth-first order, yielding a list of leaf nodes
|
448
|
+
// and an array of ancestor nodes. It uses the aria-level information attached to each node to determine
|
449
|
+
// the next level of the tree to visit.
|
450
|
+
*eachDescendantDepthFirst(
|
451
|
+
node: HTMLElement,
|
452
|
+
level: number,
|
453
|
+
ancestry: TreeViewSubTreeNodeElement[],
|
454
|
+
): Generator<[NodeListOf<HTMLElement>, TreeViewSubTreeNodeElement[]]> {
|
455
|
+
for (const subTreeItem of node.querySelectorAll<HTMLElement>(
|
456
|
+
`[role=treeitem][data-node-type='sub-tree'][aria-level='${level}']`,
|
457
|
+
)) {
|
458
|
+
const subTree = subTreeItem.closest('tree-view-sub-tree-node') as TreeViewSubTreeNodeElement
|
459
|
+
yield* this.eachDescendantDepthFirst(subTree, level + 1, [...ancestry, subTree])
|
460
|
+
}
|
461
|
+
|
462
|
+
const leafNodes = node.querySelectorAll<HTMLElement>(
|
463
|
+
`[role=treeitem][data-node-type='leaf'][aria-level='${level}']`,
|
464
|
+
)
|
465
|
+
|
466
|
+
yield [leafNodes, ancestry]
|
467
|
+
}
|
468
|
+
|
469
|
+
// Yields only the shallowest (i.e. lowest depth) sub-tree nodes that are checked, i.e. does not
|
470
|
+
// visit a sub-tree's children if that sub-tree is checked.
|
471
|
+
*eachShallowestCheckedSubTree(root: TreeViewSubTreeNodeElement): Generator<TreeViewSubTreeNodeElement> {
|
472
|
+
if (this.treeView?.getNodeCheckedValue(root.node) === 'true') {
|
473
|
+
yield root
|
474
|
+
return // do not descend further
|
475
|
+
}
|
476
|
+
|
477
|
+
for (const childSubTree of root.eachDirectDescendantSubTreeNode()) {
|
478
|
+
yield* this.eachShallowestCheckedSubTree(childSubTree)
|
479
|
+
}
|
480
|
+
}
|
481
|
+
}
|
482
|
+
|
483
|
+
if (!window.customElements.get('filterable-tree-view')) {
|
484
|
+
window.FilterableTreeViewElement = FilterableTreeViewElement
|
485
|
+
window.customElements.define('filterable-tree-view', FilterableTreeViewElement)
|
486
|
+
}
|
487
|
+
|
488
|
+
declare global {
|
489
|
+
interface Window {
|
490
|
+
FilterableTreeViewElement: typeof FilterableTreeViewElement
|
491
|
+
}
|
492
|
+
}
|
@@ -1 +1 @@
|
|
1
|
-
.PageHeader{border-bottom:var(--borderWidth-thin) solid var(--borderColor-muted);display:flex;flex-flow:column;margin-bottom:var(--
|
1
|
+
.PageHeader{border-bottom:var(--borderWidth-thin) solid var(--borderColor-muted);display:flex;flex-flow:column;margin-bottom:var(--base-size-16);padding-bottom:var(--base-size-8)}.PageHeader--withTabNav{border-bottom:none;margin-bottom:0;padding-bottom:0}.PageHeader-contextBar{height:var(--control-small-size)}.PageHeader-contextBar,.PageHeader-titleBar{align-items:center;display:flex;flex-flow:row;justify-content:flex-end;margin-bottom:var(--base-size-8)}.PageHeader-title{flex:1 1 auto;font-size:var(--text-title-size-medium)}.PageHeader-title--large{font-size:var(--text-title-size-large)}.PageHeader-description{color:var(--fgColor-muted);flex:1 100%;font-size:var(--text-body-size-medium)}.PageHeader-description--underlined-links a{-webkit-text-decoration:underline;text-decoration:underline}.PageHeader-tabNavBar{overflow:auto}.PageHeader-tabNavBar .PageHeader-tabNav{min-width:max-content}.PageHeader--withTabNav .PageHeader-description{margin-bottom:var(--base-size-16)}.PageHeader-actions{align-items:center;display:flex;justify-content:flex-end}.PageHeader-breadcrumbs{display:block;width:100%}.PageHeader-leadingAction{margin-right:var(--base-size-4);margin-top:var(--base-size-2)}.PageHeader-parentLink{flex:1 1 auto}
|
@@ -8,7 +8,7 @@
|
|
8
8
|
".PageHeader-title",
|
9
9
|
".PageHeader-title--large",
|
10
10
|
".PageHeader-description",
|
11
|
-
".PageHeader-description a",
|
11
|
+
".PageHeader-description--underlined-links a",
|
12
12
|
".PageHeader-tabNavBar",
|
13
13
|
".PageHeader-tabNavBar .PageHeader-tabNav",
|
14
14
|
".PageHeader--withTabNav .PageHeader-description",
|
@@ -1 +1 @@
|
|
1
|
-
{"version":3,"sources":["page_header.pcss"],"names":[],"mappings":"AAEA,YAIE,oEAAqE,CAHrE,YAAa,CAIb,gBAAiB,CAFjB,
|
1
|
+
{"version":3,"sources":["page_header.pcss"],"names":[],"mappings":"AAEA,YAIE,oEAAqE,CAHrE,YAAa,CAIb,gBAAiB,CAFjB,iCAAkC,CADlC,iCAIF,CAEA,wBACE,kBAAmB,CAEnB,eAAgB,CADhB,gBAEF,CAEA,uBAME,gCACF,CAEA,4CALE,kBAAmB,CAHnB,YAAa,CACb,aAAc,CACd,wBAAyB,CAEzB,gCAUF,CAEA,kBAEE,aAAc,CADd,uCAEF,CAEA,yBACE,sCACF,CAGA,wBAEE,0BAA2B,CAC3B,WAAY,CAFZ,sCAGF,CAEA,4CAEE,iCAA0B,CAA1B,yBACF,CAEA,sBACE,aACF,CAEA,yCACE,qBACF,CAEA,gDACE,iCACF,CAEA,oBAGE,kBAAmB,CADnB,YAAa,CADb,wBAGF,CAEA,wBACE,aAAc,CACd,UACF,CAEA,0BAEE,+BAAgC,CADhC,6BAEF,CAEA,uBACE,aACF","file":"page_header.css","sourcesContent":["/* OP PageHeader */\n\n.PageHeader {\n display: flex;\n padding-bottom: var(--base-size-8);\n margin-bottom: var(--base-size-16);\n border-bottom: var(--borderWidth-thin) solid var(--borderColor-muted);\n flex-flow: column;\n}\n\n.PageHeader--withTabNav {\n border-bottom: none;\n padding-bottom: 0;\n margin-bottom: 0;\n}\n\n.PageHeader-contextBar {\n display: flex;\n flex-flow: row;\n justify-content: flex-end;\n align-items: center;\n margin-bottom: var(--base-size-8);\n height: var(--control-small-size);\n}\n\n.PageHeader-titleBar {\n display: flex;\n flex-flow: row;\n justify-content: flex-end;\n align-items: center; /* Keep back button vertically aligned. */\n margin-bottom: var(--base-size-8);\n}\n\n.PageHeader-title {\n font-size: var(--text-title-size-medium);\n flex: 1 1 auto;\n}\n\n.PageHeader-title--large {\n font-size: var(--text-title-size-large);\n}\n\n/* One-liner of supporting text */\n.PageHeader-description {\n font-size: var(--text-body-size-medium);\n color: var(--fgColor-muted);\n flex: 1 100%;\n}\n\n.PageHeader-description--underlined-links a {\n /* Ensure the accessibility is met, given that the description is written in light grey */\n text-decoration: underline;\n}\n\n.PageHeader-tabNavBar {\n overflow: auto;\n}\n\n.PageHeader-tabNavBar .PageHeader-tabNav {\n min-width: max-content;\n}\n\n.PageHeader--withTabNav .PageHeader-description {\n margin-bottom: var(--base-size-16);\n}\n\n.PageHeader-actions {\n justify-content: flex-end;\n display: flex;\n align-items: center;\n}\n\n.PageHeader-breadcrumbs {\n display: block;\n width: 100%;\n}\n\n.PageHeader-leadingAction {\n margin-top: var(--base-size-2); /* to center align with label */\n margin-right: var(--base-size-4);\n}\n\n.PageHeader-parentLink {\n flex: 1 1 auto;\n}\n"]}
|
@@ -2,8 +2,8 @@
|
|
2
2
|
|
3
3
|
.PageHeader {
|
4
4
|
display: flex;
|
5
|
-
padding-bottom: var(--
|
6
|
-
margin-bottom: var(--
|
5
|
+
padding-bottom: var(--base-size-8);
|
6
|
+
margin-bottom: var(--base-size-16);
|
7
7
|
border-bottom: var(--borderWidth-thin) solid var(--borderColor-muted);
|
8
8
|
flex-flow: column;
|
9
9
|
}
|
@@ -47,7 +47,7 @@
|
|
47
47
|
flex: 1 100%;
|
48
48
|
}
|
49
49
|
|
50
|
-
.PageHeader-description a {
|
50
|
+
.PageHeader-description--underlined-links a {
|
51
51
|
/* Ensure the accessibility is met, given that the description is written in light grey */
|
52
52
|
text-decoration: underline;
|
53
53
|
}
|
@@ -76,7 +76,7 @@
|
|
76
76
|
}
|
77
77
|
|
78
78
|
.PageHeader-leadingAction {
|
79
|
-
margin-top:
|
79
|
+
margin-top: var(--base-size-2); /* to center align with label */
|
80
80
|
margin-right: var(--base-size-4);
|
81
81
|
}
|
82
82
|
|
@@ -45,11 +45,15 @@ module Primer
|
|
45
45
|
}
|
46
46
|
|
47
47
|
# Optional description below the title row
|
48
|
-
renders_one :description, lambda {
|
48
|
+
renders_one :description, lambda { |underlined_links: true, **system_arguments|
|
49
49
|
deny_tag_argument(**system_arguments)
|
50
50
|
|
51
51
|
system_arguments[:tag] = :div
|
52
|
-
system_arguments[:classes] = class_names(
|
52
|
+
system_arguments[:classes] = class_names(
|
53
|
+
system_arguments[:classes],
|
54
|
+
"PageHeader-description",
|
55
|
+
("PageHeader-description--underlined-links" if underlined_links)
|
56
|
+
)
|
53
57
|
|
54
58
|
Primer::BaseComponent.new(**system_arguments)
|
55
59
|
}
|
@@ -48,6 +48,12 @@ module Primer
|
|
48
48
|
# @return [Symbol]
|
49
49
|
attr_reader :node_variant
|
50
50
|
|
51
|
+
# Whether or not this node is disabled, i.e. cannot be activated.
|
52
|
+
#
|
53
|
+
# @return [Boolean]
|
54
|
+
attr_reader :disabled
|
55
|
+
alias disabled? disabled
|
56
|
+
|
51
57
|
DEFAULT_SELECT_VARIANT = :none
|
52
58
|
SELECT_VARIANT_OPTIONS = [
|
53
59
|
:multiple,
|
@@ -67,6 +73,8 @@ module Primer
|
|
67
73
|
# @param current [Boolean] Whether or not this node is the current node. The current node is styled differently than regular nodes and is the first element that receives focus when tabbing to the `TreeView` component.
|
68
74
|
# @param select_variant [Symbol] Controls the type of checkbox that appears. <%= one_of(Primer::OpenProject::TreeView::Node::SELECT_VARIANT_OPTIONS) %>
|
69
75
|
# @param checked [Boolean | String] The checked state of the node's checkbox. <%= one_of(Primer::OpenProject::TreeView::Node::CHECKED_STATES) %>
|
76
|
+
# @param disabled [Boolean] Whether or not the node can be activated. Passing `false` here will cause the node to appear visually disabled but it is still keyboard-focusable.
|
77
|
+
# @param value [String] If this node is checked, this value will be sent to the server on form submission.
|
70
78
|
# @param content_arguments [Hash] Arguments attached to the node's content, i.e the `<button>` or `<a>` element. <%= link_to_system_arguments_docs %>
|
71
79
|
def initialize(
|
72
80
|
path:,
|
@@ -75,6 +83,8 @@ module Primer
|
|
75
83
|
current: false,
|
76
84
|
select_variant: DEFAULT_SELECT_VARIANT,
|
77
85
|
checked: DEFAULT_CHECKED_STATE,
|
86
|
+
disabled: false,
|
87
|
+
value: nil,
|
78
88
|
**content_arguments
|
79
89
|
)
|
80
90
|
@system_arguments = {
|
@@ -89,6 +99,7 @@ module Primer
|
|
89
99
|
@current = current
|
90
100
|
@select_variant = fetch_or_fallback(SELECT_VARIANT_OPTIONS, select_variant, DEFAULT_SELECT_VARIANT)
|
91
101
|
@checked = fetch_or_fallback(CHECKED_STATES, checked, DEFAULT_CHECKED_STATE)
|
102
|
+
@disabled = disabled
|
92
103
|
@node_variant = fetch_or_fallback(NODE_VARIANT_TAG_OPTIONS, node_variant, DEFAULT_NODE_VARIANT)
|
93
104
|
|
94
105
|
@content_arguments[:tag] = NODE_VARIANT_TAG_MAP[@node_variant]
|
@@ -107,14 +118,19 @@ module Primer
|
|
107
118
|
level: level,
|
108
119
|
selected: false,
|
109
120
|
checked: checked,
|
110
|
-
labelledby: content_id
|
121
|
+
labelledby: content_id,
|
122
|
+
disabled: disabled?
|
111
123
|
}
|
112
124
|
}
|
113
125
|
)
|
114
126
|
|
115
127
|
@content_arguments[:data] = merge_data(
|
116
|
-
@content_arguments,
|
117
|
-
|
128
|
+
@content_arguments, {
|
129
|
+
data: {
|
130
|
+
value: value,
|
131
|
+
path: @path.to_json
|
132
|
+
}
|
133
|
+
}
|
118
134
|
)
|
119
135
|
|
120
136
|
return unless current?
|
@@ -8,10 +8,11 @@ module Primer
|
|
8
8
|
# This component is part of the <%= link_to_component(Primer::OpenProject::TreeView) %> component and should
|
9
9
|
# not be used directly.
|
10
10
|
class SubTreeNode < Primer::Component
|
11
|
-
DEFAULT_SELECT_STRATEGY = :
|
11
|
+
DEFAULT_SELECT_STRATEGY = :mixed_descendants
|
12
12
|
SELECT_STRATEGIES = [
|
13
13
|
:self,
|
14
|
-
DEFAULT_SELECT_STRATEGY
|
14
|
+
DEFAULT_SELECT_STRATEGY,
|
15
|
+
:descendants
|
15
16
|
]
|
16
17
|
|
17
18
|
# @!parse
|
@@ -108,10 +109,19 @@ module Primer
|
|
108
109
|
# @param label [String] The node's label, i.e. it's textual content.
|
109
110
|
# @param path [Array<String>] The node's "path," i.e. this node's label and the labels of all its ancestors. This node should be reachable by traversing the tree following this path.
|
110
111
|
# @param node_variant [Symbol] The variant to use for this node. <%= one_of(Primer::OpenProject::TreeView::NODE_VARIANT_OPTIONS) %>
|
112
|
+
# @param sub_tree_component_klass [Class] The class to use for the sub-tree instead of the default <%= link_to_component(Primer::OpenProject::TreeView::SubTree) %>
|
111
113
|
# @param expanded [Boolean] Whether or not this sub-tree should be rendered expanded.
|
112
114
|
# @param select_strategy [Symbol] What should happen when this sub-tree node is checked. <%= one_of(Primer::OpenProject::TreeView::SubTreeNode::SELECT_STRATEGIES) %>
|
113
115
|
# @param system_arguments [Hash] The arguments accepted by <%= link_to_component(Primer::OpenProject::TreeView::Node) %>.
|
114
|
-
def initialize(
|
116
|
+
def initialize(
|
117
|
+
label:,
|
118
|
+
path:,
|
119
|
+
node_variant:,
|
120
|
+
sub_tree_component_klass: SubTree,
|
121
|
+
expanded: false,
|
122
|
+
select_strategy: DEFAULT_SELECT_STRATEGY,
|
123
|
+
**system_arguments
|
124
|
+
)
|
115
125
|
@label = label
|
116
126
|
@system_arguments = system_arguments
|
117
127
|
@select_strategy = fetch_or_fallback(SELECT_STRATEGIES, select_strategy, DEFAULT_SELECT_STRATEGY)
|
@@ -132,7 +142,7 @@ module Primer
|
|
132
142
|
|
133
143
|
sub_tree_arguments = @system_arguments.delete(:sub_tree_arguments) || {}
|
134
144
|
|
135
|
-
@sub_tree =
|
145
|
+
@sub_tree = sub_tree_component_klass.new(
|
136
146
|
expanded: expanded,
|
137
147
|
path: path,
|
138
148
|
node_variant: node_variant,
|