openproject-primer_view_components 0.70.5 → 0.72.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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/app/assets/javascripts/components/primer/alpha/segmented_control.d.ts +2 -2
  4. data/app/assets/javascripts/components/primer/{open_project → alpha}/tree_view/tree_view.d.ts +11 -1
  5. data/app/assets/javascripts/components/primer/{open_project → alpha}/tree_view/tree_view_sub_tree_node_element.d.ts +5 -1
  6. data/app/assets/javascripts/components/primer/open_project/filterable_tree_view.d.ts +29 -0
  7. data/app/assets/javascripts/components/primer/primer.d.ts +5 -4
  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/{open_project → alpha}/file_tree_view/directory_node.rb +1 -1
  13. data/app/components/primer/{open_project → alpha}/file_tree_view/file_node.rb +1 -1
  14. data/app/components/primer/{open_project → alpha}/file_tree_view.rb +1 -1
  15. data/app/components/primer/alpha/segmented_control.d.ts +2 -2
  16. data/app/components/primer/alpha/segmented_control.js +12 -0
  17. data/app/components/primer/alpha/segmented_control.ts +16 -1
  18. data/app/components/primer/{open_project → alpha}/skeleton_box.css.json +1 -1
  19. data/app/components/primer/{open_project → alpha}/skeleton_box.css.map +1 -1
  20. data/app/components/primer/{open_project → alpha}/skeleton_box.pcss +1 -1
  21. data/app/components/primer/{open_project → alpha}/skeleton_box.rb +3 -1
  22. data/app/components/primer/alpha/stack.css +1 -1
  23. data/app/components/primer/alpha/stack.css.json +5 -1
  24. data/app/components/primer/alpha/stack.css.map +1 -1
  25. data/app/components/primer/alpha/stack.pcss +13 -0
  26. data/app/components/primer/alpha/stack.rb +2 -1
  27. data/app/components/primer/{open_project → alpha}/tree_view/icon.rb +2 -2
  28. data/app/components/primer/{open_project → alpha}/tree_view/icon_pair.rb +4 -4
  29. data/app/components/primer/{open_project → alpha}/tree_view/leading_action.rb +2 -2
  30. data/app/components/primer/{open_project → alpha}/tree_view/leaf_node.rb +6 -6
  31. data/app/components/primer/{open_project → alpha}/tree_view/loading_failure_message.rb +2 -2
  32. data/app/components/primer/{open_project → alpha}/tree_view/node.rb +30 -14
  33. data/app/components/primer/{open_project → alpha}/tree_view/skeleton_loader.html.erb +3 -3
  34. data/app/components/primer/{open_project → alpha}/tree_view/skeleton_loader.rb +4 -4
  35. data/app/components/primer/{open_project → alpha}/tree_view/spinner_loader.html.erb +2 -2
  36. data/app/components/primer/{open_project → alpha}/tree_view/spinner_loader.rb +4 -4
  37. data/app/components/primer/{open_project → alpha}/tree_view/sub_tree.html.erb +1 -1
  38. data/app/components/primer/{open_project → alpha}/tree_view/sub_tree.rb +10 -10
  39. data/app/components/primer/{open_project → alpha}/tree_view/sub_tree_container.rb +2 -2
  40. data/app/components/primer/{open_project → alpha}/tree_view/sub_tree_node.rb +28 -18
  41. data/app/components/primer/{open_project → alpha}/tree_view/tree_view.d.ts +11 -1
  42. data/app/components/primer/{open_project → alpha}/tree_view/tree_view.js +120 -20
  43. data/app/components/primer/{open_project → alpha}/tree_view/tree_view.ts +137 -18
  44. data/app/components/primer/{open_project → alpha}/tree_view/tree_view_include_fragment_element.js +0 -1
  45. data/app/components/primer/{open_project → alpha}/tree_view/tree_view_include_fragment_element.ts +0 -1
  46. data/app/components/primer/{open_project → alpha}/tree_view/tree_view_sub_tree_node_element.d.ts +5 -1
  47. data/app/components/primer/{open_project → alpha}/tree_view/tree_view_sub_tree_node_element.js +27 -4
  48. data/app/components/primer/{open_project → alpha}/tree_view/tree_view_sub_tree_node_element.ts +36 -5
  49. data/app/components/primer/{open_project → alpha}/tree_view/visual.rb +2 -2
  50. data/app/components/primer/alpha/tree_view.css +1 -0
  51. data/app/components/primer/{open_project → alpha}/tree_view.css.json +8 -1
  52. data/app/components/primer/alpha/tree_view.css.map +1 -0
  53. data/app/components/primer/alpha/tree_view.html.erb +12 -0
  54. data/app/components/primer/{open_project → alpha}/tree_view.pcss +39 -0
  55. data/app/components/primer/{open_project → alpha}/tree_view.rb +20 -12
  56. data/app/components/primer/beta/breadcrumbs.css +1 -1
  57. data/app/components/primer/beta/breadcrumbs.css.json +0 -1
  58. data/app/components/primer/beta/breadcrumbs.css.map +1 -1
  59. data/app/components/primer/beta/breadcrumbs.pcss +2 -8
  60. data/app/components/primer/beta/progress_bar.css +1 -1
  61. data/app/components/primer/beta/progress_bar.css.map +1 -1
  62. data/app/components/primer/beta/progress_bar.pcss +3 -2
  63. data/app/components/primer/beta/relative_time.rb +3 -0
  64. data/app/components/primer/open_project/filterable_tree_view/sub_tree.rb +39 -0
  65. data/app/components/primer/open_project/filterable_tree_view.d.ts +29 -0
  66. data/app/components/primer/open_project/filterable_tree_view.html.erb +28 -0
  67. data/app/components/primer/open_project/filterable_tree_view.js +409 -0
  68. data/app/components/primer/open_project/filterable_tree_view.rb +254 -0
  69. data/app/components/primer/open_project/filterable_tree_view.ts +492 -0
  70. data/app/components/primer/primer.d.ts +5 -4
  71. data/app/components/primer/primer.js +5 -4
  72. data/app/components/primer/primer.pcss +2 -2
  73. data/app/components/primer/primer.ts +5 -4
  74. data/app/controllers/primer/view_components/tree_view_items_controller.rb +1 -1
  75. data/app/forms/check_box_with_nested_form.rb +10 -10
  76. data/app/forms/radio_button_with_nested_form.rb +16 -16
  77. data/app/lib/primer/experimental_slot_helpers.rb +2 -2
  78. data/app/lib/primer/forms/base_component.rb +1 -1
  79. data/app/lib/primer/forms/dsl/text_field_input.rb +2 -0
  80. data/app/views/primer/view_components/tree_view_items/async_alpha.html_fragment.erb +1 -1
  81. data/app/views/primer/view_components/tree_view_items/index.html_fragment.erb +1 -1
  82. data/config/locales/en.yml +20 -0
  83. data/lib/primer/view_components/version.rb +2 -2
  84. data/previews/primer/{open_project → alpha}/file_tree_view_preview/default.html.erb +1 -1
  85. data/previews/primer/{open_project → alpha}/file_tree_view_preview/playground.html.erb +1 -1
  86. data/previews/primer/{open_project → alpha}/file_tree_view_preview.rb +1 -1
  87. data/previews/primer/{open_project → alpha}/skeleton_box_preview.rb +3 -3
  88. data/previews/primer/{open_project → alpha}/tree_view_preview/async_alpha.html.erb +1 -1
  89. data/previews/primer/{open_project → alpha}/tree_view_preview/buttons.html.erb +5 -5
  90. data/previews/primer/{open_project → alpha}/tree_view_preview/default.html.erb +5 -5
  91. data/previews/primer/{open_project → alpha}/tree_view_preview/empty.html.erb +1 -1
  92. data/previews/primer/alpha/tree_view_preview/form_input.html.erb +14 -0
  93. data/previews/primer/{open_project → alpha}/tree_view_preview/leaf_node_playground.html.erb +2 -2
  94. data/previews/primer/{open_project → alpha}/tree_view_preview/links.html.erb +5 -5
  95. data/previews/primer/{open_project → alpha}/tree_view_preview/loading_failure.html.erb +1 -1
  96. data/previews/primer/{open_project → alpha}/tree_view_preview/loading_skeleton.html.erb +1 -1
  97. data/previews/primer/{open_project → alpha}/tree_view_preview/loading_spinner.html.erb +1 -1
  98. data/previews/primer/{open_project → alpha}/tree_view_preview/playground.html.erb +1 -1
  99. data/previews/primer/{open_project → alpha}/tree_view_preview.rb +34 -15
  100. data/previews/primer/open_project/filterable_tree_view_preview/_custom_select_js.html.erb +62 -0
  101. data/previews/primer/open_project/filterable_tree_view_preview/custom_checkbox_text.html.erb +26 -0
  102. data/previews/primer/open_project/filterable_tree_view_preview/custom_no_results_text.html.erb +28 -0
  103. data/previews/primer/open_project/filterable_tree_view_preview/custom_segmented_control.html.erb +31 -0
  104. data/previews/primer/open_project/filterable_tree_view_preview/default.html.erb +26 -0
  105. data/previews/primer/open_project/filterable_tree_view_preview/form_input.html.erb +32 -0
  106. data/previews/primer/open_project/filterable_tree_view_preview/playground.html.erb +26 -0
  107. data/previews/primer/open_project/filterable_tree_view_preview/stress_test.html.erb +28 -0
  108. data/previews/primer/open_project/filterable_tree_view_preview.rb +125 -0
  109. data/static/arguments.json +1685 -1581
  110. data/static/audited_at.json +19 -17
  111. data/static/classes.json +5 -5
  112. data/static/constants.json +137 -98
  113. data/static/info_arch.json +6396 -6146
  114. data/static/previews.json +120 -21
  115. data/static/statuses.json +19 -17
  116. metadata +102 -84
  117. data/app/components/primer/open_project/tree_view.css +0 -1
  118. data/app/components/primer/open_project/tree_view.css.map +0 -1
  119. data/app/components/primer/open_project/tree_view.html.erb +0 -7
  120. /data/app/assets/javascripts/components/primer/{open_project → alpha}/tree_view/tree_view_icon_pair_element.d.ts +0 -0
  121. /data/app/assets/javascripts/components/primer/{open_project → alpha}/tree_view/tree_view_include_fragment_element.d.ts +0 -0
  122. /data/app/assets/javascripts/components/primer/{open_project → alpha}/tree_view/tree_view_roving_tab_index.d.ts +0 -0
  123. /data/app/components/primer/{open_project → alpha}/file_tree_view/directory_node.html.erb +0 -0
  124. /data/app/components/primer/{open_project → alpha}/file_tree_view/file_node.html.erb +0 -0
  125. /data/app/components/primer/{open_project → alpha}/skeleton_box.css +0 -0
  126. /data/app/components/primer/{open_project → alpha}/skeleton_box.html.erb +0 -0
  127. /data/app/components/primer/{open_project → alpha}/tree_view/icon.html.erb +0 -0
  128. /data/app/components/primer/{open_project → alpha}/tree_view/icon_pair.html.erb +0 -0
  129. /data/app/components/primer/{open_project → alpha}/tree_view/leading_action.html.erb +0 -0
  130. /data/app/components/primer/{open_project → alpha}/tree_view/leaf_node.html.erb +0 -0
  131. /data/app/components/primer/{open_project → alpha}/tree_view/loading_failure_message.html.erb +0 -0
  132. /data/app/components/primer/{open_project → alpha}/tree_view/node.html.erb +0 -0
  133. /data/app/components/primer/{open_project → alpha}/tree_view/sub_tree_container.html.erb +0 -0
  134. /data/app/components/primer/{open_project → alpha}/tree_view/sub_tree_node.html.erb +0 -0
  135. /data/app/components/primer/{open_project → alpha}/tree_view/tree_view_icon_pair_element.d.ts +0 -0
  136. /data/app/components/primer/{open_project → alpha}/tree_view/tree_view_icon_pair_element.js +0 -0
  137. /data/app/components/primer/{open_project → alpha}/tree_view/tree_view_icon_pair_element.ts +0 -0
  138. /data/app/components/primer/{open_project → alpha}/tree_view/tree_view_include_fragment_element.d.ts +0 -0
  139. /data/app/components/primer/{open_project → alpha}/tree_view/tree_view_roving_tab_index.d.ts +0 -0
  140. /data/app/components/primer/{open_project → alpha}/tree_view/tree_view_roving_tab_index.js +0 -0
  141. /data/app/components/primer/{open_project → alpha}/tree_view/tree_view_roving_tab_index.ts +0 -0
  142. /data/app/components/primer/{open_project → alpha}/tree_view/visual.html.erb +0 -0
@@ -0,0 +1,492 @@
1
+ import {controller, target} from '@github/catalyst'
2
+ import {SegmentedControlElement} from '../alpha/segmented_control'
3
+ import {TreeViewElement} from '../alpha/tree_view/tree_view'
4
+ import {TreeViewSubTreeNodeElement} from '../alpha/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
+ }
@@ -26,6 +26,10 @@ import '../../lib/primer/forms/toggle_switch_input';
26
26
  import './alpha/action_menu/action_menu_element';
27
27
  import './alpha/select_panel_element';
28
28
  import './beta/details_toggle_element';
29
+ import './alpha/tree_view/tree_view';
30
+ import './alpha/tree_view/tree_view_icon_pair_element';
31
+ import './alpha/tree_view/tree_view_sub_tree_node_element';
32
+ import './alpha/tree_view/tree_view_include_fragment_element';
29
33
  import './open_project/page_header_element';
30
34
  import './open_project/zen_mode_button';
31
35
  import './open_project/sub_header_element';
@@ -33,7 +37,4 @@ import './open_project/danger_dialog_form_helper';
33
37
  import './open_project/collapsible';
34
38
  import './open_project/border_box/collapsible_header';
35
39
  import './open_project/collapsible_section';
36
- import './open_project/tree_view/tree_view';
37
- import './open_project/tree_view/tree_view_icon_pair_element';
38
- import './open_project/tree_view/tree_view_sub_tree_node_element';
39
- import './open_project/tree_view/tree_view_include_fragment_element';
40
+ import './open_project/filterable_tree_view';
@@ -26,6 +26,10 @@ import '../../lib/primer/forms/toggle_switch_input';
26
26
  import './alpha/action_menu/action_menu_element';
27
27
  import './alpha/select_panel_element';
28
28
  import './beta/details_toggle_element';
29
+ import './alpha/tree_view/tree_view';
30
+ import './alpha/tree_view/tree_view_icon_pair_element';
31
+ import './alpha/tree_view/tree_view_sub_tree_node_element';
32
+ import './alpha/tree_view/tree_view_include_fragment_element';
29
33
  import './open_project/page_header_element';
30
34
  import './open_project/zen_mode_button';
31
35
  import './open_project/sub_header_element';
@@ -33,7 +37,4 @@ import './open_project/danger_dialog_form_helper';
33
37
  import './open_project/collapsible';
34
38
  import './open_project/border_box/collapsible_header';
35
39
  import './open_project/collapsible_section';
36
- import './open_project/tree_view/tree_view';
37
- import './open_project/tree_view/tree_view_icon_pair_element';
38
- import './open_project/tree_view/tree_view_sub_tree_node_element';
39
- import './open_project/tree_view/tree_view_include_fragment_element';
40
+ import './open_project/filterable_tree_view';
@@ -17,6 +17,8 @@
17
17
  @import "./alpha/text_field.pcss";
18
18
  @import "./alpha/toggle_switch.pcss";
19
19
  @import "./alpha/underline_nav.pcss";
20
+ @import "./alpha/skeleton_box.pcss";
21
+ @import "./alpha/tree_view.pcss";
20
22
 
21
23
  /* beta */
22
24
  @import "./beta/avatar.pcss";
@@ -51,5 +53,3 @@
51
53
  @import "./open_project/side_panel/section.pcss";
52
54
  @import "./open_project/border_box/collapsible_header.pcss";
53
55
  @import "./open_project/collapsible_section.pcss";
54
- @import "./open_project/skeleton_box.pcss";
55
- @import "./open_project/tree_view.pcss";
@@ -26,6 +26,10 @@ import '../../lib/primer/forms/toggle_switch_input'
26
26
  import './alpha/action_menu/action_menu_element'
27
27
  import './alpha/select_panel_element'
28
28
  import './beta/details_toggle_element'
29
+ import './alpha/tree_view/tree_view'
30
+ import './alpha/tree_view/tree_view_icon_pair_element'
31
+ import './alpha/tree_view/tree_view_sub_tree_node_element'
32
+ import './alpha/tree_view/tree_view_include_fragment_element'
29
33
  import './open_project/page_header_element'
30
34
  import './open_project/zen_mode_button'
31
35
  import './open_project/sub_header_element'
@@ -33,7 +37,4 @@ import './open_project/danger_dialog_form_helper'
33
37
  import './open_project/collapsible'
34
38
  import './open_project/border_box/collapsible_header'
35
39
  import './open_project/collapsible_section'
36
- import './open_project/tree_view/tree_view'
37
- import './open_project/tree_view/tree_view_icon_pair_element'
38
- import './open_project/tree_view/tree_view_sub_tree_node_element'
39
- import './open_project/tree_view/tree_view_include_fragment_element'
40
+ import './open_project/filterable_tree_view'
@@ -18,7 +18,7 @@ module Primer
18
18
  head :internal_server_error and return
19
19
  end
20
20
 
21
- path = JSON.parse(params[:path])
21
+ path = JSON.parse(params[:path])
22
22
  node = path.inject(TREE) do |current, segment|
23
23
  current["children"][segment]
24
24
  end
@@ -1,18 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # :nodoc:
4
- class CustomCitiesForm < ApplicationForm
5
- form do |custom_cities_form|
6
- custom_cities_form.text_field(
7
- name: :custom_cities,
8
- label: "Custom cities",
9
- description: "A space-separated list of cities"
10
- )
4
+ class CheckBoxWithNestedForm < ApplicationForm
5
+ # :nodoc:
6
+ class CustomCitiesForm < ApplicationForm
7
+ form do |custom_cities_form|
8
+ custom_cities_form.text_field(
9
+ name: :custom_cities,
10
+ label: "Custom cities",
11
+ description: "A space-separated list of cities"
12
+ )
13
+ end
11
14
  end
12
- end
13
15
 
14
- # :nodoc:
15
- class CheckBoxWithNestedForm < ApplicationForm
16
16
  form do |check_form|
17
17
  check_form.check_box_group(name: :city_categories) do |check_group|
18
18
  check_group.check_box(
@@ -1,27 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # :nodoc:
4
- class FriendForm < ApplicationForm
5
- form do |friend_form|
6
- friend_form.group(layout: :horizontal) do |name_group|
7
- name_group.text_field(name: "first_name", label: "First Name")
8
- name_group.text_field(name: "last_name", label: "Last Name")
4
+ class RadioButtonWithNestedForm < ApplicationForm
5
+ # :nodoc:
6
+ class FriendForm < ApplicationForm
7
+ form do |friend_form|
8
+ friend_form.group(layout: :horizontal) do |name_group|
9
+ name_group.text_field(name: "first_name", label: "First Name")
10
+ name_group.text_field(name: "last_name", label: "Last Name")
11
+ end
9
12
  end
10
13
  end
11
- end
12
14
 
13
- # :nodoc:
14
- class FriendTextAreaForm < ApplicationForm
15
- form do |friend_text_area_form|
16
- friend_text_area_form.text_area(
17
- name: "description",
18
- label: "Describe this wonderful person in loving detail"
19
- )
15
+ # :nodoc:
16
+ class FriendTextAreaForm < ApplicationForm
17
+ form do |friend_text_area_form|
18
+ friend_text_area_form.text_area(
19
+ name: "description",
20
+ label: "Describe this wonderful person in loving detail"
21
+ )
22
+ end
20
23
  end
21
- end
22
24
 
23
- # :nodoc:
24
- class RadioButtonWithNestedForm < ApplicationForm
25
25
  form do |radio_form|
26
26
  radio_form.radio_button_group(name: "channel") do |radio_group|
27
27
  radio_group.radio_button(value: "online", label: "Online advertisement", caption: "Facebook maybe?")
@@ -13,7 +13,7 @@ module Primer
13
13
  slot_def = registered_slots[slot_name]
14
14
  raise "Unknown slot '#{slot_name}'" unless slot_def
15
15
 
16
- poly_def = define_slot(
16
+ poly_def = __vc_define_slot(
17
17
  type,
18
18
  collection: slot_def[:collection],
19
19
  callable: callable
@@ -22,7 +22,7 @@ module Primer
22
22
  registered_slots[slot_name][:renderable_hash][type] = poly_def
23
23
 
24
24
  define_method(:"with_#{type}") do |**system_arguments, &block|
25
- set_slot(slot_name, poly_def, **system_arguments, &block)
25
+ __vc_set_slot(slot_name, poly_def, **system_arguments, &block)
26
26
  end
27
27
  end
28
28
  end
@@ -14,7 +14,7 @@ module Primer
14
14
  )
15
15
  end
16
16
 
17
- delegate :required?, :disabled?, :hidden?, to: :@input
17
+ delegate :required?, :disabled?, :hidden?, :merge_input_arguments!, to: :@input
18
18
 
19
19
  def perform_render(&block)
20
20
  return "" unless render?