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,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
+ }
@@ -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
- { data: { path: @path.to_json } }
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 = :descendants
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(label:, path:, node_variant:, expanded: false, select_strategy: DEFAULT_SELECT_STRATEGY, **system_arguments)
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 = SubTree.new(
145
+ @sub_tree = sub_tree_component_klass.new(
136
146
  expanded: expanded,
137
147
  path: path,
138
148
  node_variant: node_variant,
@@ -1,10 +1,15 @@
1
- import { TreeViewSubTreeNodeElement } from './tree_view_sub_tree_node_element';
1
+ import { SelectStrategy, TreeViewSubTreeNodeElement } from './tree_view_sub_tree_node_element';
2
2
  import type { TreeViewNodeType, TreeViewCheckedValue, TreeViewNodeInfo } from '../../shared_events';
3
3
  export declare class TreeViewElement extends HTMLElement {
4
4
  #private;
5
+ formInputContainer: HTMLElement;
6
+ formInputPrototype: HTMLInputElement;
5
7
  connectedCallback(): void;
8
+ rootLeafNodes(): NodeListOf<HTMLElement>;
9
+ rootSubTreeNodes(): NodeListOf<TreeViewSubTreeNodeElement>;
6
10
  disconnectedCallback(): void;
7
11
  handleEvent(event: Event): void;
12
+ getFormInputValueForNode(node: Element): string | null;
8
13
  getNodePath(node: Element): string[];
9
14
  getNodeType(node: Element): TreeViewNodeType | null;
10
15
  markCurrentAtPath(path: string[]): void;
@@ -16,13 +21,18 @@ export declare class TreeViewElement extends HTMLElement {
16
21
  uncheckAtPath(path: string[]): void;
17
22
  toggleCheckedAtPath(path: string[]): void;
18
23
  checkedValueAtPath(path: string[]): TreeViewCheckedValue;
24
+ disabledValueAtPath(path: string[]): boolean;
19
25
  nodeAtPath(path: string[], selector?: string): Element | null;
20
26
  subTreeAtPath(path: string[]): TreeViewSubTreeNodeElement | null;
21
27
  leafAtPath(path: string[]): HTMLLIElement | null;
28
+ setNodeCheckedValue(node: Element, value: TreeViewCheckedValue): void;
22
29
  getNodeCheckedValue(node: Element): TreeViewCheckedValue;
30
+ getNodeDisabledValue(node: Element): boolean;
31
+ setNodeDisabledValue(node: Element, disabled: boolean): void;
23
32
  nodeHasCheckBox(node: Element): boolean;
24
33
  nodeHasNativeAction(node: Element): boolean;
25
34
  expandAncestorsForNode(node: HTMLElement): void;
35
+ changeSelectStrategy(newStrategy: SelectStrategy): void;
26
36
  infoFromNode(node: Element, newCheckedValue?: TreeViewCheckedValue): TreeViewNodeInfo | null;
27
37
  }
28
38
  declare global {