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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/app/assets/javascripts/components/primer/alpha/segmented_control.d.ts +2 -2
- data/app/assets/javascripts/components/primer/{open_project → alpha}/tree_view/tree_view.d.ts +11 -1
- data/app/assets/javascripts/components/primer/{open_project → alpha}/tree_view/tree_view_sub_tree_node_element.d.ts +5 -1
- data/app/assets/javascripts/components/primer/open_project/filterable_tree_view.d.ts +29 -0
- data/app/assets/javascripts/components/primer/primer.d.ts +5 -4
- 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/{open_project → alpha}/file_tree_view/directory_node.rb +1 -1
- data/app/components/primer/{open_project → alpha}/file_tree_view/file_node.rb +1 -1
- data/app/components/primer/{open_project → alpha}/file_tree_view.rb +1 -1
- 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/{open_project → alpha}/skeleton_box.css.json +1 -1
- data/app/components/primer/{open_project → alpha}/skeleton_box.css.map +1 -1
- data/app/components/primer/{open_project → alpha}/skeleton_box.pcss +1 -1
- data/app/components/primer/{open_project → alpha}/skeleton_box.rb +3 -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 → alpha}/tree_view/icon.rb +2 -2
- data/app/components/primer/{open_project → alpha}/tree_view/icon_pair.rb +4 -4
- data/app/components/primer/{open_project → alpha}/tree_view/leading_action.rb +2 -2
- data/app/components/primer/{open_project → alpha}/tree_view/leaf_node.rb +6 -6
- data/app/components/primer/{open_project → alpha}/tree_view/loading_failure_message.rb +2 -2
- data/app/components/primer/{open_project → alpha}/tree_view/node.rb +30 -14
- data/app/components/primer/{open_project → alpha}/tree_view/skeleton_loader.html.erb +3 -3
- data/app/components/primer/{open_project → alpha}/tree_view/skeleton_loader.rb +4 -4
- data/app/components/primer/{open_project → alpha}/tree_view/spinner_loader.html.erb +2 -2
- data/app/components/primer/{open_project → alpha}/tree_view/spinner_loader.rb +4 -4
- data/app/components/primer/{open_project → alpha}/tree_view/sub_tree.html.erb +1 -1
- data/app/components/primer/{open_project → alpha}/tree_view/sub_tree.rb +10 -10
- data/app/components/primer/{open_project → alpha}/tree_view/sub_tree_container.rb +2 -2
- data/app/components/primer/{open_project → alpha}/tree_view/sub_tree_node.rb +28 -18
- data/app/components/primer/{open_project → alpha}/tree_view/tree_view.d.ts +11 -1
- data/app/components/primer/{open_project → alpha}/tree_view/tree_view.js +120 -20
- data/app/components/primer/{open_project → alpha}/tree_view/tree_view.ts +137 -18
- data/app/components/primer/{open_project → alpha}/tree_view/tree_view_include_fragment_element.js +0 -1
- data/app/components/primer/{open_project → alpha}/tree_view/tree_view_include_fragment_element.ts +0 -1
- data/app/components/primer/{open_project → alpha}/tree_view/tree_view_sub_tree_node_element.d.ts +5 -1
- data/app/components/primer/{open_project → alpha}/tree_view/tree_view_sub_tree_node_element.js +27 -4
- data/app/components/primer/{open_project → alpha}/tree_view/tree_view_sub_tree_node_element.ts +36 -5
- data/app/components/primer/{open_project → alpha}/tree_view/visual.rb +2 -2
- data/app/components/primer/alpha/tree_view.css +1 -0
- data/app/components/primer/{open_project → alpha}/tree_view.css.json +8 -1
- data/app/components/primer/alpha/tree_view.css.map +1 -0
- data/app/components/primer/alpha/tree_view.html.erb +12 -0
- data/app/components/primer/{open_project → alpha}/tree_view.pcss +39 -0
- data/app/components/primer/{open_project → alpha}/tree_view.rb +20 -12
- data/app/components/primer/beta/breadcrumbs.css +1 -1
- data/app/components/primer/beta/breadcrumbs.css.json +0 -1
- data/app/components/primer/beta/breadcrumbs.css.map +1 -1
- data/app/components/primer/beta/breadcrumbs.pcss +2 -8
- data/app/components/primer/beta/progress_bar.css +1 -1
- data/app/components/primer/beta/progress_bar.css.map +1 -1
- data/app/components/primer/beta/progress_bar.pcss +3 -2
- data/app/components/primer/beta/relative_time.rb +3 -0
- 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/primer.d.ts +5 -4
- data/app/components/primer/primer.js +5 -4
- data/app/components/primer/primer.pcss +2 -2
- data/app/components/primer/primer.ts +5 -4
- data/app/controllers/primer/view_components/tree_view_items_controller.rb +1 -1
- data/app/forms/check_box_with_nested_form.rb +10 -10
- data/app/forms/radio_button_with_nested_form.rb +16 -16
- data/app/lib/primer/experimental_slot_helpers.rb +2 -2
- data/app/lib/primer/forms/base_component.rb +1 -1
- data/app/lib/primer/forms/dsl/text_field_input.rb +2 -0
- data/app/views/primer/view_components/tree_view_items/async_alpha.html_fragment.erb +1 -1
- data/app/views/primer/view_components/tree_view_items/index.html_fragment.erb +1 -1
- data/config/locales/en.yml +20 -0
- data/lib/primer/view_components/version.rb +2 -2
- data/previews/primer/{open_project → alpha}/file_tree_view_preview/default.html.erb +1 -1
- data/previews/primer/{open_project → alpha}/file_tree_view_preview/playground.html.erb +1 -1
- data/previews/primer/{open_project → alpha}/file_tree_view_preview.rb +1 -1
- data/previews/primer/{open_project → alpha}/skeleton_box_preview.rb +3 -3
- data/previews/primer/{open_project → alpha}/tree_view_preview/async_alpha.html.erb +1 -1
- data/previews/primer/{open_project → alpha}/tree_view_preview/buttons.html.erb +5 -5
- data/previews/primer/{open_project → alpha}/tree_view_preview/default.html.erb +5 -5
- data/previews/primer/{open_project → alpha}/tree_view_preview/empty.html.erb +1 -1
- data/previews/primer/alpha/tree_view_preview/form_input.html.erb +14 -0
- data/previews/primer/{open_project → alpha}/tree_view_preview/leaf_node_playground.html.erb +2 -2
- data/previews/primer/{open_project → alpha}/tree_view_preview/links.html.erb +5 -5
- data/previews/primer/{open_project → alpha}/tree_view_preview/loading_failure.html.erb +1 -1
- data/previews/primer/{open_project → alpha}/tree_view_preview/loading_skeleton.html.erb +1 -1
- data/previews/primer/{open_project → alpha}/tree_view_preview/loading_spinner.html.erb +1 -1
- data/previews/primer/{open_project → alpha}/tree_view_preview/playground.html.erb +1 -1
- data/previews/primer/{open_project → alpha}/tree_view_preview.rb +34 -15
- 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/stress_test.html.erb +28 -0
- data/previews/primer/open_project/filterable_tree_view_preview.rb +125 -0
- data/static/arguments.json +1685 -1581
- data/static/audited_at.json +19 -17
- data/static/classes.json +5 -5
- data/static/constants.json +137 -98
- data/static/info_arch.json +6396 -6146
- data/static/previews.json +120 -21
- data/static/statuses.json +19 -17
- metadata +102 -84
- data/app/components/primer/open_project/tree_view.css +0 -1
- data/app/components/primer/open_project/tree_view.css.map +0 -1
- data/app/components/primer/open_project/tree_view.html.erb +0 -7
- /data/app/assets/javascripts/components/primer/{open_project → alpha}/tree_view/tree_view_icon_pair_element.d.ts +0 -0
- /data/app/assets/javascripts/components/primer/{open_project → alpha}/tree_view/tree_view_include_fragment_element.d.ts +0 -0
- /data/app/assets/javascripts/components/primer/{open_project → alpha}/tree_view/tree_view_roving_tab_index.d.ts +0 -0
- /data/app/components/primer/{open_project → alpha}/file_tree_view/directory_node.html.erb +0 -0
- /data/app/components/primer/{open_project → alpha}/file_tree_view/file_node.html.erb +0 -0
- /data/app/components/primer/{open_project → alpha}/skeleton_box.css +0 -0
- /data/app/components/primer/{open_project → alpha}/skeleton_box.html.erb +0 -0
- /data/app/components/primer/{open_project → alpha}/tree_view/icon.html.erb +0 -0
- /data/app/components/primer/{open_project → alpha}/tree_view/icon_pair.html.erb +0 -0
- /data/app/components/primer/{open_project → alpha}/tree_view/leading_action.html.erb +0 -0
- /data/app/components/primer/{open_project → alpha}/tree_view/leaf_node.html.erb +0 -0
- /data/app/components/primer/{open_project → alpha}/tree_view/loading_failure_message.html.erb +0 -0
- /data/app/components/primer/{open_project → alpha}/tree_view/node.html.erb +0 -0
- /data/app/components/primer/{open_project → alpha}/tree_view/sub_tree_container.html.erb +0 -0
- /data/app/components/primer/{open_project → alpha}/tree_view/sub_tree_node.html.erb +0 -0
- /data/app/components/primer/{open_project → alpha}/tree_view/tree_view_icon_pair_element.d.ts +0 -0
- /data/app/components/primer/{open_project → alpha}/tree_view/tree_view_icon_pair_element.js +0 -0
- /data/app/components/primer/{open_project → alpha}/tree_view/tree_view_icon_pair_element.ts +0 -0
- /data/app/components/primer/{open_project → alpha}/tree_view/tree_view_include_fragment_element.d.ts +0 -0
- /data/app/components/primer/{open_project → alpha}/tree_view/tree_view_roving_tab_index.d.ts +0 -0
- /data/app/components/primer/{open_project → alpha}/tree_view/tree_view_roving_tab_index.js +0 -0
- /data/app/components/primer/{open_project → alpha}/tree_view/tree_view_roving_tab_index.ts +0 -0
- /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/
|
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/
|
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/
|
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'
|
@@ -1,18 +1,18 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
# :nodoc:
|
4
|
-
class
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
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
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
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
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
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 =
|
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
|
-
|
25
|
+
__vc_set_slot(slot_name, poly_def, **system_arguments, &block)
|
26
26
|
end
|
27
27
|
end
|
28
28
|
end
|