openproject-primer_view_components 0.84.5 → 0.86.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 +16 -0
- data/app/assets/javascripts/components/primer/open_project/sub_header_element.d.ts +1 -1
- data/app/assets/javascripts/primer_view_components.js +1 -1
- data/app/assets/javascripts/primer_view_components.js.map +1 -1
- data/app/assets/styles/primer_view_components.css +1 -1
- data/app/assets/styles/primer_view_components.css.map +1 -1
- data/app/components/primer/alpha/select_panel_element.js +2 -2
- data/app/components/primer/alpha/select_panel_element.ts +2 -2
- data/app/components/primer/alpha/tree_view/leaf_node.html.erb +5 -0
- data/app/components/primer/alpha/tree_view/leaf_node.rb +18 -0
- data/app/components/primer/alpha/tree_view/node.html.erb +3 -0
- data/app/components/primer/alpha/tree_view/node.rb +10 -0
- data/app/components/primer/alpha/tree_view/sub_tree_node.html.erb +5 -0
- data/app/components/primer/alpha/tree_view/sub_tree_node.rb +18 -0
- data/app/components/primer/alpha/tree_view/trailing_action.html.erb +3 -0
- data/app/components/primer/alpha/tree_view/trailing_action.rb +18 -0
- data/app/components/primer/alpha/tree_view.css +1 -1
- data/app/components/primer/alpha/tree_view.css.json +4 -1
- data/app/components/primer/alpha/tree_view.css.map +1 -1
- data/app/components/primer/alpha/tree_view.pcss +22 -6
- data/app/components/primer/open_project/filterable_tree_view/sub_tree.rb +6 -6
- data/app/components/primer/open_project/filterable_tree_view.css +1 -0
- data/app/components/primer/open_project/filterable_tree_view.css.json +14 -0
- data/app/components/primer/open_project/filterable_tree_view.css.map +1 -0
- data/app/components/primer/open_project/filterable_tree_view.html.erb +26 -14
- data/app/components/primer/open_project/filterable_tree_view.js +294 -5
- data/app/components/primer/open_project/filterable_tree_view.pcss +57 -0
- data/app/components/primer/open_project/filterable_tree_view.rb +58 -10
- data/app/components/primer/open_project/filterable_tree_view.ts +316 -4
- data/app/components/primer/open_project/sub_header.css +1 -1
- data/app/components/primer/open_project/sub_header.css.json +2 -1
- data/app/components/primer/open_project/sub_header.css.map +1 -1
- data/app/components/primer/open_project/sub_header.html.erb +11 -8
- data/app/components/primer/open_project/sub_header.pcss +14 -7
- data/app/components/primer/open_project/sub_header.rb +46 -25
- data/app/components/primer/open_project/sub_header_element.d.ts +1 -1
- data/app/components/primer/open_project/sub_header_element.js +6 -6
- data/app/components/primer/open_project/sub_header_element.ts +5 -10
- data/app/components/primer/primer.pcss +1 -0
- data/app/controllers/primer/view_components/filterable_tree_view_items_controller.rb +192 -0
- data/app/views/primer/view_components/filterable_tree_view_items/_node.html.erb +38 -0
- data/app/views/primer/view_components/filterable_tree_view_items/async_form_tree.html.erb +9 -0
- data/app/views/primer/view_components/filterable_tree_view_items/index.html.erb +6 -0
- data/config/routes.rb +4 -0
- data/lib/primer/view_components/version.rb +2 -2
- data/previews/primer/alpha/select_panel_preview.rb +0 -27
- data/previews/primer/alpha/text_area_preview.rb +0 -1
- data/previews/primer/alpha/text_field_preview.rb +0 -1
- data/previews/primer/alpha/tree_view_preview/leaf_node_playground.html.erb +4 -0
- data/previews/primer/alpha/tree_view_preview.rb +3 -0
- data/previews/primer/open_project/filterable_tree_view_preview/async.html.erb +3 -0
- data/previews/primer/open_project/filterable_tree_view_preview/async_form_input.html.erb +9 -0
- data/previews/primer/open_project/filterable_tree_view_preview/link_nodes.html.erb +18 -0
- data/previews/primer/open_project/filterable_tree_view_preview.rb +23 -2
- data/previews/primer/open_project/sub_header_preview/quick_filters.html.erb +47 -0
- data/previews/primer/open_project/sub_header_preview.rb +23 -1
- data/static/arguments.json +28 -0
- data/static/audited_at.json +1 -0
- data/static/classes.json +9 -0
- data/static/constants.json +10 -1
- data/static/info_arch.json +189 -30
- data/static/previews.json +94 -29
- data/static/statuses.json +1 -0
- metadata +18 -10
|
@@ -2,7 +2,8 @@ import {controller, target} from '@github/catalyst'
|
|
|
2
2
|
import {SegmentedControlElement} from '../alpha/segmented_control'
|
|
3
3
|
import {TreeViewElement} from '../alpha/tree_view/tree_view'
|
|
4
4
|
import {TreeViewSubTreeNodeElement} from '../alpha/tree_view/tree_view_sub_tree_node_element'
|
|
5
|
-
|
|
5
|
+
// eslint-disable-next-line import/named
|
|
6
|
+
import {TreeViewCheckedValue, TreeViewNodeInfo} from '../shared_events'
|
|
6
7
|
|
|
7
8
|
// This function is expected to return the following values:
|
|
8
9
|
// 1. No match - return null
|
|
@@ -15,6 +16,8 @@ type NodeState = {
|
|
|
15
16
|
disabled: boolean
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
const ASYNC_DEBOUNCE_MS = 300
|
|
20
|
+
|
|
18
21
|
@controller
|
|
19
22
|
export class FilterableTreeViewElement extends HTMLElement {
|
|
20
23
|
@target filterInput: HTMLInputElement
|
|
@@ -27,15 +30,43 @@ export class FilterableTreeViewElement extends HTMLElement {
|
|
|
27
30
|
#abortController: AbortController
|
|
28
31
|
#stateMap: Map<TreeViewSubTreeNodeElement, Map<HTMLElement, NodeState>> = new Map()
|
|
29
32
|
|
|
33
|
+
// Async mode state
|
|
34
|
+
#debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
35
|
+
#fetchAbortController: AbortController | null = null
|
|
36
|
+
// nodeId → wasExpanded: taken once before the first filter query is entered, cleared when filter is removed
|
|
37
|
+
#expansionSnapshot: Map<string, boolean> | null = null
|
|
38
|
+
// nodeId → wasExpanded: taken before entering "selected" mode, cleared when leaving it
|
|
39
|
+
#selectedModeSnapshot: Map<string, boolean> | null = null
|
|
40
|
+
// nodeId → checkedValue: persists across tree replacements, updated on every treeViewNodeChecked event
|
|
41
|
+
#checkedNodeIds: Map<string, TreeViewCheckedValue> = new Map()
|
|
42
|
+
// nodeId → form payload: mirrors #checkedNodeIds but stores the data needed to synthesise a hidden
|
|
43
|
+
// form input for nodes that are checked but not currently in the DOM (e.g. filtered out).
|
|
44
|
+
#checkedNodeFormPayloads: Map<string, {path: string[]; value?: string}> = new Map()
|
|
45
|
+
#isFiltered = false
|
|
46
|
+
|
|
30
47
|
connectedCallback() {
|
|
31
48
|
const {signal} = (this.#abortController = new AbortController())
|
|
32
49
|
this.addEventListener('treeViewNodeChecked', this, {signal})
|
|
33
50
|
this.addEventListener('itemActivated', this, {signal})
|
|
34
51
|
this.addEventListener('input', this, {signal})
|
|
52
|
+
|
|
53
|
+
if (this.#isAsyncMode) {
|
|
54
|
+
void this.#fetchAndReplaceTree()
|
|
55
|
+
}
|
|
35
56
|
}
|
|
36
57
|
|
|
37
58
|
disconnectedCallback() {
|
|
38
59
|
this.#abortController.abort()
|
|
60
|
+
this.#fetchAbortController?.abort()
|
|
61
|
+
if (this.#debounceTimer !== null) clearTimeout(this.#debounceTimer)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get #src(): string | null {
|
|
65
|
+
return this.getAttribute('src')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get #isAsyncMode(): boolean {
|
|
69
|
+
return !!this.#src
|
|
39
70
|
}
|
|
40
71
|
|
|
41
72
|
handleEvent(event: Event) {
|
|
@@ -57,11 +88,48 @@ export class FilterableTreeViewElement extends HTMLElement {
|
|
|
57
88
|
// when calling this.treeView.setNodeCheckedValue.
|
|
58
89
|
switch (origEvent.type) {
|
|
59
90
|
case 'treeViewNodeChecked':
|
|
91
|
+
// Always track checked node IDs before delegating, so async replacements can restore selection.
|
|
92
|
+
this.#updateCheckedNodeIds(event)
|
|
60
93
|
this.#handleTreeViewNodeChecked(event)
|
|
61
94
|
break
|
|
62
95
|
}
|
|
63
96
|
}
|
|
64
97
|
|
|
98
|
+
// Keeps #checkedNodeIds and #checkedNodeFormPayloads in sync with every user-triggered check event
|
|
99
|
+
// so we can restore selection and synthesise form inputs after async tree replacements, even for
|
|
100
|
+
// nodes that are no longer visible (e.g. filtered out by the server).
|
|
101
|
+
#updateCheckedNodeIds(event: CustomEvent<TreeViewNodeInfo[]>) {
|
|
102
|
+
if (!this.#isAsyncMode) return
|
|
103
|
+
|
|
104
|
+
for (const nodeInfo of event.detail) {
|
|
105
|
+
const node = nodeInfo.node as HTMLElement
|
|
106
|
+
const nodeId = node.getAttribute('data-node-id')
|
|
107
|
+
if (nodeId) {
|
|
108
|
+
if (nodeInfo.checkedValue === 'false') {
|
|
109
|
+
this.#checkedNodeIds.delete(nodeId)
|
|
110
|
+
this.#checkedNodeFormPayloads.delete(nodeId)
|
|
111
|
+
} else {
|
|
112
|
+
// In single-select mode, TreeView clears the previous selection internally
|
|
113
|
+
// (via checkOnlyAtPath) but the treeViewNodeChecked event only contains the
|
|
114
|
+
// newly selected node. Clear our tracked state so #restoreSelectionState does
|
|
115
|
+
// not re-check previously selected nodes after a tree replacement.
|
|
116
|
+
if (node.getAttribute('data-select-variant') === 'single') {
|
|
117
|
+
this.#checkedNodeIds.clear()
|
|
118
|
+
this.#checkedNodeFormPayloads.clear()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this.#checkedNodeIds.set(nodeId, nodeInfo.checkedValue)
|
|
122
|
+
const payload: {path: string[]; value?: string} = {path: nodeInfo.path}
|
|
123
|
+
const dataValue = node.getAttribute('data-value')
|
|
124
|
+
if (dataValue) payload.value = dataValue
|
|
125
|
+
this.#checkedNodeFormPayloads.set(nodeId, payload)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
this.#updateRetainedSelections()
|
|
131
|
+
}
|
|
132
|
+
|
|
65
133
|
#handleTreeViewNodeChecked(event: CustomEvent<TreeViewNodeInfo[]>) {
|
|
66
134
|
if (!this.treeView) return
|
|
67
135
|
if (!this.includeSubItemsCheckBox.checked) return
|
|
@@ -118,20 +186,56 @@ export class FilterableTreeViewElement extends HTMLElement {
|
|
|
118
186
|
#handleFilterModeEvent(event: Event) {
|
|
119
187
|
if (event.type !== 'itemActivated') return
|
|
120
188
|
|
|
121
|
-
this.#
|
|
189
|
+
if (this.#isAsyncMode) {
|
|
190
|
+
if (this.filterMode === 'selected') {
|
|
191
|
+
// "selected" mode is client-side: snapshot expansion state before the filter collapses nodes,
|
|
192
|
+
// then apply client-side filter without a server round-trip.
|
|
193
|
+
this.#selectedModeSnapshot = this.#captureExpansionState()
|
|
194
|
+
this.#applyFilterOptions()
|
|
195
|
+
} else if (this.#selectedModeSnapshot !== null && this.queryString.length === 0) {
|
|
196
|
+
// Leaving "selected" mode with no active query: undo client-side filter and restore expansion
|
|
197
|
+
// state without a server round-trip (the full tree is already in the DOM).
|
|
198
|
+
this.#undoClientSideFilter()
|
|
199
|
+
this.#applyExpansionSnapshot(this.#selectedModeSnapshot)
|
|
200
|
+
this.#selectedModeSnapshot = null
|
|
201
|
+
} else {
|
|
202
|
+
// "all" mode with an active query, or switching away from a custom mode: use async fetch.
|
|
203
|
+
this.#selectedModeSnapshot = null
|
|
204
|
+
this.#scheduleAsyncFetch()
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
this.#applyFilterOptions()
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Removes `hidden` attributes added by the client-side filter from leaf list items and sub-tree nodes.
|
|
212
|
+
#undoClientSideFilter() {
|
|
213
|
+
for (const el of this.querySelectorAll<HTMLElement>('tree-view li[hidden], tree-view-sub-tree-node[hidden]')) {
|
|
214
|
+
el.removeAttribute('hidden')
|
|
215
|
+
}
|
|
122
216
|
}
|
|
123
217
|
|
|
124
218
|
#handleFilterInputEvent(event: Event) {
|
|
125
219
|
if (event.type !== 'input') return
|
|
126
220
|
|
|
127
|
-
|
|
221
|
+
// "selected" mode is always client-side – the server doesn't know the selection state.
|
|
222
|
+
if (this.#isAsyncMode && this.filterMode !== 'selected') {
|
|
223
|
+
this.#scheduleAsyncFetch()
|
|
224
|
+
} else {
|
|
225
|
+
this.#applyFilterOptions()
|
|
226
|
+
}
|
|
128
227
|
}
|
|
129
228
|
|
|
130
229
|
#handleIncludeSubItemsCheckBoxEvent(event: Event) {
|
|
131
230
|
if (!this.treeView) return
|
|
132
231
|
if (event.type !== 'input') return
|
|
133
232
|
|
|
134
|
-
|
|
233
|
+
// In async mode, toggling include-sub-items does not require a server round-trip: the client
|
|
234
|
+
// handles the visual state entirely (checking/disabling visible descendants). The flag will be
|
|
235
|
+
// included automatically in the next filter request triggered by a query or filter-mode change.
|
|
236
|
+
if (!this.#isAsyncMode) {
|
|
237
|
+
this.#applyFilterOptions()
|
|
238
|
+
}
|
|
135
239
|
|
|
136
240
|
if (this.includeSubItemsCheckBox.checked) {
|
|
137
241
|
this.#includeSubItems()
|
|
@@ -185,6 +289,214 @@ export class FilterableTreeViewElement extends HTMLElement {
|
|
|
185
289
|
}
|
|
186
290
|
}
|
|
187
291
|
|
|
292
|
+
// ─── Async mode ────────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
#scheduleAsyncFetch() {
|
|
295
|
+
if (this.#debounceTimer !== null) clearTimeout(this.#debounceTimer)
|
|
296
|
+
|
|
297
|
+
this.#debounceTimer = setTimeout(() => {
|
|
298
|
+
this.#debounceTimer = null
|
|
299
|
+
void this.#fetchAndReplaceTree()
|
|
300
|
+
}, ASYNC_DEBOUNCE_MS)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async #fetchAndReplaceTree() {
|
|
304
|
+
const src = this.#src
|
|
305
|
+
if (!src) return
|
|
306
|
+
|
|
307
|
+
const query = this.queryString
|
|
308
|
+
const filterMode = this.filterMode || 'all'
|
|
309
|
+
const includeSubItems = this.includeSubItemsCheckBox?.checked ?? false
|
|
310
|
+
|
|
311
|
+
// Snapshot expansion state the first time the user enters a filter query
|
|
312
|
+
if (!this.#isFiltered && query.length > 0) {
|
|
313
|
+
this.#snapshotExpansionState()
|
|
314
|
+
this.#isFiltered = true
|
|
315
|
+
} else if (this.#isFiltered && query.length === 0) {
|
|
316
|
+
this.#isFiltered = false
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Remember which filter state this particular request was for so we apply
|
|
320
|
+
// the correct post-processing even if the user types quickly.
|
|
321
|
+
const requestWasFiltered = query.length > 0
|
|
322
|
+
|
|
323
|
+
// Abort any in-flight request
|
|
324
|
+
this.#fetchAbortController?.abort()
|
|
325
|
+
const {signal} = (this.#fetchAbortController = new AbortController())
|
|
326
|
+
|
|
327
|
+
const url = new URL(src, window.location.href)
|
|
328
|
+
url.searchParams.set('query', query)
|
|
329
|
+
url.searchParams.set('filter_mode', filterMode)
|
|
330
|
+
url.searchParams.set('include_sub_items', String(includeSubItems))
|
|
331
|
+
|
|
332
|
+
// Send currently-checked node IDs so the server can apply include-sub-items
|
|
333
|
+
// logic even for nodes that are no longer visible due to filtering / pagination.
|
|
334
|
+
for (const nodeId of this.#checkedNodeIds.keys()) {
|
|
335
|
+
url.searchParams.append('checked_ids[]', nodeId)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
this.setAttribute('data-loading', '')
|
|
339
|
+
this.setAttribute('aria-busy', 'true')
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
const response = await fetch(url.toString(), {
|
|
343
|
+
signal,
|
|
344
|
+
headers: {Accept: 'text/html'},
|
|
345
|
+
credentials: 'same-origin',
|
|
346
|
+
method: 'GET',
|
|
347
|
+
})
|
|
348
|
+
if (!response.ok) return
|
|
349
|
+
|
|
350
|
+
const html = await response.text()
|
|
351
|
+
const doc = new DOMParser().parseFromString(html, 'text/html')
|
|
352
|
+
const newTreeView = doc.querySelector('tree-view')
|
|
353
|
+
if (!newTreeView) return
|
|
354
|
+
|
|
355
|
+
const oldTreeView = this.treeViewList?.closest('tree-view')
|
|
356
|
+
if (!oldTreeView) return
|
|
357
|
+
|
|
358
|
+
// Invalidate old stateMap entries – the referenced DOM nodes no longer exist after replacement.
|
|
359
|
+
this.#stateMap.clear()
|
|
360
|
+
|
|
361
|
+
oldTreeView.replaceWith(newTreeView)
|
|
362
|
+
// Catalyst re-resolves @target treeViewList dynamically on next access.
|
|
363
|
+
|
|
364
|
+
// Restore checked state for all nodes that now appear in the new tree.
|
|
365
|
+
this.#restoreSelectionState()
|
|
366
|
+
|
|
367
|
+
// Re-apply include-sub-items visually if the checkbox is still checked.
|
|
368
|
+
if (includeSubItems) {
|
|
369
|
+
this.#includeSubItems()
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (requestWasFiltered) {
|
|
373
|
+
this.#expandAllSubTrees()
|
|
374
|
+
this.#applyAsyncHighlights(query)
|
|
375
|
+
const hasResults = !!this.treeViewList?.querySelector('[role=treeitem]')
|
|
376
|
+
this.noResultsMessage.toggleAttribute('hidden', hasResults)
|
|
377
|
+
this.treeViewList?.toggleAttribute('hidden', !hasResults)
|
|
378
|
+
} else {
|
|
379
|
+
this.#removeHighlights()
|
|
380
|
+
this.#restoreExpansionState()
|
|
381
|
+
this.noResultsMessage.setAttribute('hidden', 'hidden')
|
|
382
|
+
this.treeViewList?.removeAttribute('hidden')
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Synthesise form inputs for nodes that are checked but absent from the current DOM
|
|
386
|
+
// (e.g. filtered out). Must run after restoreSelectionState so we know what is in the DOM.
|
|
387
|
+
this.#updateRetainedSelections()
|
|
388
|
+
} catch (e) {
|
|
389
|
+
if ((e as Error).name === 'AbortError') return
|
|
390
|
+
throw e
|
|
391
|
+
} finally {
|
|
392
|
+
this.removeAttribute('data-loading')
|
|
393
|
+
this.setAttribute('aria-busy', 'false')
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Captures the current expanded/collapsed state of every sub-tree node as a nodeId → boolean map.
|
|
398
|
+
#captureExpansionState(): Map<string, boolean> {
|
|
399
|
+
const snapshot = new Map<string, boolean>()
|
|
400
|
+
for (const treeitem of this.querySelectorAll<HTMLElement>(
|
|
401
|
+
'[role=treeitem][data-node-id][data-node-type=sub-tree]',
|
|
402
|
+
)) {
|
|
403
|
+
snapshot.set(treeitem.getAttribute('data-node-id')!, treeitem.getAttribute('aria-expanded') === 'true')
|
|
404
|
+
}
|
|
405
|
+
return snapshot
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Applies a previously captured expansion snapshot to the current tree.
|
|
409
|
+
#applyExpansionSnapshot(snapshot: Map<string, boolean>) {
|
|
410
|
+
for (const [nodeId, wasExpanded] of snapshot) {
|
|
411
|
+
const treeitem = this.querySelector<HTMLElement>(`[role=treeitem][data-node-id="${CSS.escape(nodeId)}"]`)
|
|
412
|
+
const subTreeNode = treeitem?.closest('tree-view-sub-tree-node') as TreeViewSubTreeNodeElement | null
|
|
413
|
+
if (subTreeNode) {
|
|
414
|
+
if (wasExpanded) {
|
|
415
|
+
subTreeNode.expand()
|
|
416
|
+
} else {
|
|
417
|
+
subTreeNode.collapse()
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Saves the expanded/collapsed state of every sub-tree node before the first filter query is applied.
|
|
424
|
+
#snapshotExpansionState() {
|
|
425
|
+
this.#expansionSnapshot = this.#captureExpansionState()
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Restores the expansion state that was saved before filtering began, then discards the snapshot.
|
|
429
|
+
#restoreExpansionState() {
|
|
430
|
+
if (!this.#expansionSnapshot) return
|
|
431
|
+
this.#applyExpansionSnapshot(this.#expansionSnapshot)
|
|
432
|
+
this.#expansionSnapshot = null
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Re-applies checked values from #checkedNodeIds to every node that exists in the current tree.
|
|
436
|
+
#restoreSelectionState() {
|
|
437
|
+
if (!this.treeView) return
|
|
438
|
+
|
|
439
|
+
for (const treeitem of this.querySelectorAll<HTMLElement>('[role=treeitem][data-node-id]')) {
|
|
440
|
+
const nodeId = treeitem.getAttribute('data-node-id')!
|
|
441
|
+
const savedValue = this.#checkedNodeIds.get(nodeId)
|
|
442
|
+
if (savedValue !== undefined) {
|
|
443
|
+
this.treeView.setNodeCheckedValue(treeitem, savedValue)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Expands every sub-tree node in the current tree – used after a filtered result is rendered so all
|
|
449
|
+
// matches and their ancestors are visible.
|
|
450
|
+
#expandAllSubTrees() {
|
|
451
|
+
for (const subTreeNode of this.querySelectorAll<TreeViewSubTreeNodeElement>('tree-view-sub-tree-node')) {
|
|
452
|
+
subTreeNode.expand()
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Applies highlights based on the current query string to whatever tree is in the DOM. Unlike the
|
|
457
|
+
// client-side path, filtering is already done by the server, so we only need to produce highlights.
|
|
458
|
+
#applyAsyncHighlights(query: string) {
|
|
459
|
+
this.#removeHighlights()
|
|
460
|
+
const ranges: Range[] = []
|
|
461
|
+
|
|
462
|
+
for (const treeitem of this.querySelectorAll<HTMLElement>('[role=treeitem]')) {
|
|
463
|
+
const result = this.defaultFilterFn(treeitem, query, 'all')
|
|
464
|
+
if (result) ranges.push(...result)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (ranges.length > 0) this.#applyHighlights(ranges)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Maintains hidden form inputs (with the same name as the TreeView's own inputs) for nodes that are
|
|
471
|
+
// checked but currently absent from the DOM (e.g. filtered out by the server). These inputs live
|
|
472
|
+
// directly inside <filterable-tree-view> – outside <tree-view> – so they survive tree replacements.
|
|
473
|
+
// The server therefore receives a complete set of checked paths regardless of what is currently visible.
|
|
474
|
+
#updateRetainedSelections() {
|
|
475
|
+
// Only relevant when a form is wired up.
|
|
476
|
+
const prototype = this.treeView?.formInputPrototype
|
|
477
|
+
if (!prototype) return
|
|
478
|
+
|
|
479
|
+
// Remove previously injected retained inputs.
|
|
480
|
+
for (const el of this.querySelectorAll('[data-filterable-tree-view-retained]')) {
|
|
481
|
+
el.remove()
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
for (const [nodeId, payload] of this.#checkedNodeFormPayloads) {
|
|
485
|
+
// Nodes currently in the DOM are already covered by TreeView's updateHiddenFormInputs.
|
|
486
|
+
const inDom = !!this.querySelector(`[role=treeitem][data-node-id="${CSS.escape(nodeId)}"]`)
|
|
487
|
+
if (inDom) continue
|
|
488
|
+
|
|
489
|
+
const input = document.createElement('input')
|
|
490
|
+
input.type = 'hidden'
|
|
491
|
+
input.name = prototype.name
|
|
492
|
+
input.value = JSON.stringify(payload)
|
|
493
|
+
input.setAttribute('data-filterable-tree-view-retained', '')
|
|
494
|
+
this.appendChild(input)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ─── End async mode ─────────────────────────────────────────────────────────
|
|
499
|
+
|
|
188
500
|
set filterFn(newFn: FilterFn) {
|
|
189
501
|
this.#filterFn = newFn
|
|
190
502
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
.SubHeader{align-items:
|
|
1
|
+
.SubHeader{align-items:baseline;display:grid;grid-template-areas:"left middle right" "bottom bottom bottom";grid-template-columns:auto 1fr auto;margin-bottom:var(--base-size-16)}.SubHeader-rightPane{align-items:center;column-gap:12px;display:flex;grid-area:right}.SubHeader-middlePane{grid-area:middle;text-align:center;white-space:nowrap}.SubHeader-bottomPane{grid-area:bottom}.SubHeader-leftPane{align-items:center;display:flex;flex-wrap:wrap;grid-area:left;row-gap:var(--base-size-16);width:100%}:is(.SubHeader-leftPane [class*=FormControl-input-width--]):not(.FormControl-input-width--auto){width:100vw}.SubHeader-filterContainer{display:flex;flex-basis:max-content;gap:8px;width:100%}.SubHeader-filterInput_hiddenClearButton+.FormControl-input-trailingAction{display:none}@media (max-width:767.98px){.SubHeader{grid-template-areas:"left right" "middle middle" "bottom bottom";grid-template-columns:1fr auto}.SubHeader--expandedSearch{grid-template-areas:"left left" "middle middle" "bottom bottom"}.SubHeader--expandedSearch .SubHeader-hiddenOnExpand{display:none!important}.SubHeader--emptyLeftPane{grid-template-areas:"middle middle right" "bottom bottom bottom";grid-template-columns:auto 1fr auto}.SubHeader--emptyLeftPane .SubHeader-middlePane{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.SubHeader-middlePane{text-align:left}.SubHeader-middlePane:has(>*){margin-top:var(--base-size-16)}}
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
"name": "open_project/sub_header",
|
|
3
3
|
"selectors": [
|
|
4
4
|
".SubHeader",
|
|
5
|
-
".SubHeader--expandedSearch",
|
|
6
5
|
".SubHeader-rightPane",
|
|
7
6
|
".SubHeader-middlePane",
|
|
8
7
|
".SubHeader-bottomPane",
|
|
@@ -10,6 +9,8 @@
|
|
|
10
9
|
":is(.SubHeader-leftPane [class*=FormControl-input-width--]):not(.FormControl-input-width--auto)",
|
|
11
10
|
".SubHeader-filterContainer",
|
|
12
11
|
".SubHeader-filterInput_hiddenClearButton+.FormControl-input-trailingAction",
|
|
12
|
+
".SubHeader--expandedSearch",
|
|
13
|
+
".SubHeader--expandedSearch .SubHeader-hiddenOnExpand",
|
|
13
14
|
".SubHeader--emptyLeftPane",
|
|
14
15
|
".SubHeader--emptyLeftPane .SubHeader-middlePane",
|
|
15
16
|
".SubHeader-middlePane:has(>*)"
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["sub_header.pcss"],"names":[],"mappings":"AAEA,WAII,
|
|
1
|
+
{"version":3,"sources":["sub_header.pcss"],"names":[],"mappings":"AAEA,WAII,oBAAqB,CAHrB,YAAa,CACb,8DAA+D,CAC/D,mCAAoC,CAEpC,iCACJ,CAEA,qBAGI,kBAAmB,CACnB,eAAgB,CAFhB,YAAa,CADb,eAIJ,CAEA,sBACI,gBAAiB,CACjB,iBAAkB,CAClB,kBACJ,CAEA,sBACI,gBACJ,CAEA,oBAGI,kBAAmB,CADnB,YAAa,CAEb,cAAe,CAHf,cAAe,CAIf,2BAA4B,CAC5B,UASJ,CAJQ,gGACI,WACJ,CAIR,2BACI,YAAa,CACb,sBAAuB,CAEvB,OAAQ,CADR,UAEJ,CAEA,2EACE,YACF,CAEA,4BACI,WACI,gEAAiE,CACjE,8BACJ,CAEA,2BACI,+DACJ,CAEA,qDACI,sBACJ,CAEA,0BACI,gEAAiE,CACjE,mCACJ,CAEA,gDAGI,eAAgB,CADhB,sBAAuB,CADvB,kBAGJ,CAEA,sBACI,eACJ,CAEA,8BACI,8BACJ,CACJ","file":"sub_header.css","sourcesContent":["/* CSS for SubHeader */\n\n.SubHeader {\n display: grid;\n grid-template-areas: \"left middle right\" \"bottom bottom bottom\";\n grid-template-columns: auto 1fr auto;\n align-items: baseline;\n margin-bottom: var(--base-size-16);\n}\n\n.SubHeader-rightPane {\n grid-area: right;\n display: flex;\n align-items: center;\n column-gap: 12px;\n}\n\n.SubHeader-middlePane {\n grid-area: middle;\n text-align: center;\n white-space: nowrap;\n}\n\n.SubHeader-bottomPane {\n grid-area: bottom;\n}\n\n.SubHeader-leftPane {\n grid-area: left;\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n row-gap: var(--base-size-16);\n width: 100%;\n\n /* Since the container is not full width (due to the grid around it)\n we want it to grow, and then be limited by the max-width of the \"FormControl-input-width--xy\" class */\n & [class*='FormControl-input-width--'] {\n &:not(.FormControl-input-width--auto) {\n width: 100vw;\n }\n }\n}\n\n.SubHeader-filterContainer {\n display: flex;\n flex-basis: max-content;\n width: 100%;\n gap: 8px;\n}\n\n.SubHeader-filterInput_hiddenClearButton + .FormControl-input-trailingAction {\n display: none;\n}\n\n@media (max-width: 767.98px) {\n .SubHeader {\n grid-template-areas: \"left right\" \"middle middle\" \"bottom bottom\";\n grid-template-columns: 1fr auto;\n }\n\n .SubHeader--expandedSearch {\n grid-template-areas: \"left left\" \"middle middle\" \"bottom bottom\";\n }\n\n .SubHeader--expandedSearch .SubHeader-hiddenOnExpand {\n display: none !important;\n }\n\n .SubHeader--emptyLeftPane {\n grid-template-areas: \"middle middle right\" \"bottom bottom bottom\";\n grid-template-columns: auto 1fr auto;\n }\n\n .SubHeader--emptyLeftPane .SubHeader-middlePane {\n white-space: nowrap;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n .SubHeader-middlePane {\n text-align: left;\n }\n\n .SubHeader-middlePane:has(> *) {\n margin-top: var(--base-size-16);\n }\n}\n"]}
|
|
@@ -2,16 +2,19 @@
|
|
|
2
2
|
<div class="SubHeader-leftPane">
|
|
3
3
|
<%= render @filter_container do %>
|
|
4
4
|
<%= filter_input %>
|
|
5
|
-
<%= render @
|
|
6
|
-
I18n.t("button_cancel")
|
|
7
|
-
end if @mobile_filter_cancel.present? %>
|
|
5
|
+
<%= render @collapsed_filter_cancel if @collapsed_filter_cancel.present? %>
|
|
8
6
|
<% end if filter_input.present? %>
|
|
9
7
|
|
|
10
|
-
<%= render @
|
|
8
|
+
<%= render @collapsed_filter_trigger if @collapsed_filter_trigger.present? %>
|
|
11
9
|
|
|
12
|
-
<%= render(
|
|
10
|
+
<%= render(Primer::BaseComponent.new(tag: :div, display: :flex)) do %>
|
|
11
|
+
<% quick_filters.each do |quick_filter| %>
|
|
12
|
+
<%= quick_filter %>
|
|
13
|
+
<% end %>
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
<%= render(@mobile_filter_button) if @mobile_filter_button.present? %>
|
|
16
|
+
<%= filter_button %>
|
|
17
|
+
<% end %>
|
|
15
18
|
|
|
16
19
|
<%= segmented_control %>
|
|
17
20
|
|
|
@@ -22,11 +25,11 @@
|
|
|
22
25
|
<% end %>
|
|
23
26
|
</div>
|
|
24
27
|
|
|
25
|
-
<div class="SubHeader-middlePane
|
|
28
|
+
<div class="SubHeader-middlePane SubHeader-hiddenOnExpand">
|
|
26
29
|
<%= text %>
|
|
27
30
|
</div>
|
|
28
31
|
|
|
29
|
-
<div class="SubHeader-rightPane
|
|
32
|
+
<div class="SubHeader-rightPane SubHeader-hiddenOnExpand">
|
|
30
33
|
<% actions.each do |action| %>
|
|
31
34
|
<%= action %>
|
|
32
35
|
<% end %>
|
|
@@ -4,14 +4,10 @@
|
|
|
4
4
|
display: grid;
|
|
5
5
|
grid-template-areas: "left middle right" "bottom bottom bottom";
|
|
6
6
|
grid-template-columns: auto 1fr auto;
|
|
7
|
-
align-items:
|
|
7
|
+
align-items: baseline;
|
|
8
8
|
margin-bottom: var(--base-size-16);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
.SubHeader--expandedSearch {
|
|
12
|
-
grid-template-areas: "left left left" "bottom bottom bottom";
|
|
13
|
-
}
|
|
14
|
-
|
|
15
11
|
.SubHeader-rightPane {
|
|
16
12
|
grid-area: right;
|
|
17
13
|
display: flex;
|
|
@@ -22,6 +18,7 @@
|
|
|
22
18
|
.SubHeader-middlePane {
|
|
23
19
|
grid-area: middle;
|
|
24
20
|
text-align: center;
|
|
21
|
+
white-space: nowrap;
|
|
25
22
|
}
|
|
26
23
|
|
|
27
24
|
.SubHeader-bottomPane {
|
|
@@ -32,6 +29,8 @@
|
|
|
32
29
|
grid-area: left;
|
|
33
30
|
display: flex;
|
|
34
31
|
align-items: center;
|
|
32
|
+
flex-wrap: wrap;
|
|
33
|
+
row-gap: var(--base-size-16);
|
|
35
34
|
width: 100%;
|
|
36
35
|
|
|
37
36
|
/* Since the container is not full width (due to the grid around it)
|
|
@@ -54,12 +53,20 @@
|
|
|
54
53
|
display: none;
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
@media (max-width:
|
|
56
|
+
@media (max-width: 767.98px) {
|
|
58
57
|
.SubHeader {
|
|
59
58
|
grid-template-areas: "left right" "middle middle" "bottom bottom";
|
|
60
59
|
grid-template-columns: 1fr auto;
|
|
61
60
|
}
|
|
62
61
|
|
|
62
|
+
.SubHeader--expandedSearch {
|
|
63
|
+
grid-template-areas: "left left" "middle middle" "bottom bottom";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.SubHeader--expandedSearch .SubHeader-hiddenOnExpand {
|
|
67
|
+
display: none !important;
|
|
68
|
+
}
|
|
69
|
+
|
|
63
70
|
.SubHeader--emptyLeftPane {
|
|
64
71
|
grid-template-areas: "middle middle right" "bottom bottom bottom";
|
|
65
72
|
grid-template-columns: auto 1fr auto;
|
|
@@ -76,6 +83,6 @@
|
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
.SubHeader-middlePane:has(> *) {
|
|
79
|
-
margin-top: var(--
|
|
86
|
+
margin-top: var(--base-size-16);
|
|
80
87
|
}
|
|
81
88
|
}
|
|
@@ -7,8 +7,8 @@ module Primer
|
|
|
7
7
|
class SubHeader < Primer::Component
|
|
8
8
|
status :open_project
|
|
9
9
|
|
|
10
|
-
HIDDEN_FILTER_TARGET_SELECTOR = "sub-header.hiddenItemsOnExpandedFilter"
|
|
11
10
|
SHOWN_FILTER_TARGET_SELECTOR = "sub-header.shownItemsOnExpandedFilter"
|
|
11
|
+
FILTER_EXPAND_BUTTON_TARGET_SELECTOR = "sub-header.filterExpandButton"
|
|
12
12
|
|
|
13
13
|
MOBILE_ACTIONS_DISPLAY = [:flex, :none].freeze
|
|
14
14
|
DESKTOP_ACTIONS_DISPLAY = [:none, :flex].freeze
|
|
@@ -101,18 +101,23 @@ module Primer
|
|
|
101
101
|
system_arguments[:data][:action] += " input:sub-header#toggleFilterInputClearButton focus:sub-header#toggleFilterInputClearButton"
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
display: [:inline_flex, :none],
|
|
106
|
-
aria: { label: label },
|
|
107
|
-
mr: 2,
|
|
108
|
-
"data-action": "click:sub-header#expandFilterInput",
|
|
109
|
-
"data-targets": HIDDEN_FILTER_TARGET_SELECTOR)
|
|
104
|
+
trigger_display = @collapsed_search ? :inline_flex : [:inline_flex, :none]
|
|
110
105
|
|
|
111
|
-
@
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
106
|
+
@collapsed_filter_trigger = Primer::Beta::IconButton.new(icon: system_arguments[:leading_visual][:icon],
|
|
107
|
+
display: trigger_display,
|
|
108
|
+
aria: { label: label },
|
|
109
|
+
mr: 2,
|
|
110
|
+
"data-action": "click:sub-header#expandFilterInput",
|
|
111
|
+
"data-targets": FILTER_EXPAND_BUTTON_TARGET_SELECTOR)
|
|
112
|
+
|
|
113
|
+
@collapsed_filter_cancel = Primer::Beta::IconButton.new(icon: :x,
|
|
114
|
+
"aria-label": I18n.t(:button_cancel),
|
|
115
|
+
scheme: :invisible,
|
|
116
|
+
display: :none,
|
|
117
|
+
data: {
|
|
118
|
+
targets: SHOWN_FILTER_TARGET_SELECTOR,
|
|
119
|
+
action: "click:sub-header#collapseFilterInput"
|
|
120
|
+
})
|
|
116
121
|
|
|
117
122
|
|
|
118
123
|
Primer::Alpha::TextField.new(name: name, label: label, **system_arguments)
|
|
@@ -165,6 +170,18 @@ module Primer
|
|
|
165
170
|
}
|
|
166
171
|
}
|
|
167
172
|
|
|
173
|
+
# Quick filters shown in the left pane next to the search bar (0–5 items).
|
|
174
|
+
# Hidden on mobile. Requires all_filters_button to be set when used.
|
|
175
|
+
# Supports ActionMenus, Buttons, IconButtons, SelectPanels, and SegmentedControls inside the block.
|
|
176
|
+
renders_many :quick_filters, lambda { |**kwargs|
|
|
177
|
+
deny_tag_argument(**kwargs)
|
|
178
|
+
kwargs[:tag] = :div
|
|
179
|
+
kwargs[:mr] ||= 2
|
|
180
|
+
kwargs[:display] = DESKTOP_ACTIONS_DISPLAY
|
|
181
|
+
|
|
182
|
+
Primer::BaseComponent.new(**kwargs)
|
|
183
|
+
}
|
|
184
|
+
|
|
168
185
|
renders_one :segmented_control, lambda { |**system_arguments, &block|
|
|
169
186
|
deny_tag_argument(**system_arguments)
|
|
170
187
|
system_arguments[:mr] ||= 2
|
|
@@ -182,6 +199,7 @@ module Primer
|
|
|
182
199
|
|
|
183
200
|
renders_one :text, lambda { |**system_arguments|
|
|
184
201
|
system_arguments[:font_weight] ||= :bold
|
|
202
|
+
system_arguments[:mx] ||= 2
|
|
185
203
|
|
|
186
204
|
Primer::Beta::Text.new(**system_arguments)
|
|
187
205
|
}
|
|
@@ -195,16 +213,18 @@ module Primer
|
|
|
195
213
|
Primer::BaseComponent.new(**system_arguments)
|
|
196
214
|
}
|
|
197
215
|
|
|
198
|
-
|
|
216
|
+
# @param collapsed_search [Boolean] When true, the search bar starts collapsed as an icon button on all screen sizes. Clicking expands it.
|
|
199
217
|
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
|
|
200
|
-
def initialize(**system_arguments)
|
|
218
|
+
def initialize(collapsed_search: false, **system_arguments)
|
|
219
|
+
@collapsed_search = collapsed_search
|
|
201
220
|
@system_arguments = system_arguments
|
|
202
221
|
@system_arguments[:tag] = :"sub-header"
|
|
203
222
|
|
|
223
|
+
filter_container_display = collapsed_search ? :none : DESKTOP_ACTIONS_DISPLAY
|
|
224
|
+
|
|
204
225
|
@filter_container = Primer::BaseComponent.new(tag: :div,
|
|
205
226
|
classes: "SubHeader-filterContainer",
|
|
206
|
-
display:
|
|
207
|
-
|
|
227
|
+
display: filter_container_display,
|
|
208
228
|
mr: 2,
|
|
209
229
|
data: { targets: SHOWN_FILTER_TARGET_SELECTOR })
|
|
210
230
|
|
|
@@ -215,21 +235,22 @@ module Primer
|
|
|
215
235
|
end
|
|
216
236
|
|
|
217
237
|
def before_render
|
|
238
|
+
if quick_filters.any? && filter_button.nil?
|
|
239
|
+
raise ArgumentError, "You must provide a filter_button when using quick_filters."
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
if quick_filters.size > 5
|
|
243
|
+
raise ArgumentError, "SubHeader supports a maximum of 5 quick_filters, got #{quick_filters.size}."
|
|
244
|
+
end
|
|
245
|
+
|
|
218
246
|
@system_arguments[:classes] = class_names(
|
|
219
247
|
@system_arguments[:classes],
|
|
220
|
-
"SubHeader--emptyLeftPane" => !segmented_control? && !filter_button && !filter_input
|
|
248
|
+
"SubHeader--emptyLeftPane" => !segmented_control? && !filter_button && !filter_input && quick_filters.empty?
|
|
221
249
|
)
|
|
222
250
|
end
|
|
223
251
|
|
|
224
252
|
def set_as_hidden_filter_target(system_arguments)
|
|
225
|
-
system_arguments[:
|
|
226
|
-
system_arguments[:data] = merge_data(
|
|
227
|
-
system_arguments, {
|
|
228
|
-
data: {
|
|
229
|
-
targets: HIDDEN_FILTER_TARGET_SELECTOR,
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
)
|
|
253
|
+
system_arguments[:classes] = class_names(system_arguments[:classes], "SubHeader-hiddenOnExpand")
|
|
233
254
|
system_arguments
|
|
234
255
|
end
|
|
235
256
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
declare class SubHeaderElement extends HTMLElement {
|
|
2
2
|
filterInput: HTMLInputElement;
|
|
3
|
-
hiddenItemsOnExpandedFilter: HTMLElement[];
|
|
4
3
|
shownItemsOnExpandedFilter: HTMLElement[];
|
|
4
|
+
filterExpandButton: HTMLElement[];
|
|
5
5
|
connectedCallback(): void;
|
|
6
6
|
setupFilterInputClearButton(): void;
|
|
7
7
|
toggleFilterInputClearButton(): void;
|