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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/app/assets/javascripts/components/primer/open_project/sub_header_element.d.ts +1 -1
  4. data/app/assets/javascripts/primer_view_components.js +1 -1
  5. data/app/assets/javascripts/primer_view_components.js.map +1 -1
  6. data/app/assets/styles/primer_view_components.css +1 -1
  7. data/app/assets/styles/primer_view_components.css.map +1 -1
  8. data/app/components/primer/alpha/select_panel_element.js +2 -2
  9. data/app/components/primer/alpha/select_panel_element.ts +2 -2
  10. data/app/components/primer/alpha/tree_view/leaf_node.html.erb +5 -0
  11. data/app/components/primer/alpha/tree_view/leaf_node.rb +18 -0
  12. data/app/components/primer/alpha/tree_view/node.html.erb +3 -0
  13. data/app/components/primer/alpha/tree_view/node.rb +10 -0
  14. data/app/components/primer/alpha/tree_view/sub_tree_node.html.erb +5 -0
  15. data/app/components/primer/alpha/tree_view/sub_tree_node.rb +18 -0
  16. data/app/components/primer/alpha/tree_view/trailing_action.html.erb +3 -0
  17. data/app/components/primer/alpha/tree_view/trailing_action.rb +18 -0
  18. data/app/components/primer/alpha/tree_view.css +1 -1
  19. data/app/components/primer/alpha/tree_view.css.json +4 -1
  20. data/app/components/primer/alpha/tree_view.css.map +1 -1
  21. data/app/components/primer/alpha/tree_view.pcss +22 -6
  22. data/app/components/primer/open_project/filterable_tree_view/sub_tree.rb +6 -6
  23. data/app/components/primer/open_project/filterable_tree_view.css +1 -0
  24. data/app/components/primer/open_project/filterable_tree_view.css.json +14 -0
  25. data/app/components/primer/open_project/filterable_tree_view.css.map +1 -0
  26. data/app/components/primer/open_project/filterable_tree_view.html.erb +26 -14
  27. data/app/components/primer/open_project/filterable_tree_view.js +294 -5
  28. data/app/components/primer/open_project/filterable_tree_view.pcss +57 -0
  29. data/app/components/primer/open_project/filterable_tree_view.rb +58 -10
  30. data/app/components/primer/open_project/filterable_tree_view.ts +316 -4
  31. data/app/components/primer/open_project/sub_header.css +1 -1
  32. data/app/components/primer/open_project/sub_header.css.json +2 -1
  33. data/app/components/primer/open_project/sub_header.css.map +1 -1
  34. data/app/components/primer/open_project/sub_header.html.erb +11 -8
  35. data/app/components/primer/open_project/sub_header.pcss +14 -7
  36. data/app/components/primer/open_project/sub_header.rb +46 -25
  37. data/app/components/primer/open_project/sub_header_element.d.ts +1 -1
  38. data/app/components/primer/open_project/sub_header_element.js +6 -6
  39. data/app/components/primer/open_project/sub_header_element.ts +5 -10
  40. data/app/components/primer/primer.pcss +1 -0
  41. data/app/controllers/primer/view_components/filterable_tree_view_items_controller.rb +192 -0
  42. data/app/views/primer/view_components/filterable_tree_view_items/_node.html.erb +38 -0
  43. data/app/views/primer/view_components/filterable_tree_view_items/async_form_tree.html.erb +9 -0
  44. data/app/views/primer/view_components/filterable_tree_view_items/index.html.erb +6 -0
  45. data/config/routes.rb +4 -0
  46. data/lib/primer/view_components/version.rb +2 -2
  47. data/previews/primer/alpha/select_panel_preview.rb +0 -27
  48. data/previews/primer/alpha/text_area_preview.rb +0 -1
  49. data/previews/primer/alpha/text_field_preview.rb +0 -1
  50. data/previews/primer/alpha/tree_view_preview/leaf_node_playground.html.erb +4 -0
  51. data/previews/primer/alpha/tree_view_preview.rb +3 -0
  52. data/previews/primer/open_project/filterable_tree_view_preview/async.html.erb +3 -0
  53. data/previews/primer/open_project/filterable_tree_view_preview/async_form_input.html.erb +9 -0
  54. data/previews/primer/open_project/filterable_tree_view_preview/link_nodes.html.erb +18 -0
  55. data/previews/primer/open_project/filterable_tree_view_preview.rb +23 -2
  56. data/previews/primer/open_project/sub_header_preview/quick_filters.html.erb +47 -0
  57. data/previews/primer/open_project/sub_header_preview.rb +23 -1
  58. data/static/arguments.json +28 -0
  59. data/static/audited_at.json +1 -0
  60. data/static/classes.json +9 -0
  61. data/static/constants.json +10 -1
  62. data/static/info_arch.json +189 -30
  63. data/static/previews.json +94 -29
  64. data/static/statuses.json +1 -0
  65. 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
- import {TreeViewNodeInfo} from '../shared_events'
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.#applyFilterOptions()
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
- this.#applyFilterOptions()
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
- this.#applyFilterOptions()
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:center;display:grid;grid-template-areas:"left middle right" "bottom bottom bottom";grid-template-columns:auto 1fr auto;margin-bottom:var(--base-size-16)}.SubHeader--expandedSearch{grid-template-areas:"left left left" "bottom bottom bottom"}.SubHeader-rightPane{align-items:center;column-gap:12px;display:flex;grid-area:right}.SubHeader-middlePane{grid-area:middle;text-align:center}.SubHeader-bottomPane{grid-area:bottom}.SubHeader-leftPane{align-items:center;display:flex;grid-area:left;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:543.98px){.SubHeader{grid-template-areas:"left right" "middle middle" "bottom bottom";grid-template-columns:1fr auto}.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(--stack-gap-normal)}}
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,kBAAmB,CAHnB,YAAa,CACb,8DAA+D,CAC/D,mCAAoC,CAEpC,iCACJ,CAEA,2BACI,2DACJ,CAEA,qBAGI,kBAAmB,CACnB,eAAgB,CAFhB,YAAa,CADb,eAIJ,CAEA,sBACI,gBAAiB,CACjB,iBACJ,CAEA,sBACI,gBACJ,CAEA,oBAGI,kBAAmB,CADnB,YAAa,CADb,cAAe,CAGf,UASJ,CAJQ,gGACI,WACJ,CAIR,2BACI,YAAa,CACb,sBAAuB,CAEvB,OAAQ,CADR,UAEJ,CAEA,2EACE,YACF,CAEA,4BACI,WACI,gEAAiE,CACjE,8BACJ,CAEA,0BACI,gEAAiE,CACjE,mCACJ,CAEA,gDAGI,eAAgB,CADhB,sBAAuB,CADvB,kBAGJ,CAEA,sBACI,eACJ,CAEA,8BACI,kCACJ,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: center;\n margin-bottom: var(--base-size-16);\n}\n\n.SubHeader--expandedSearch {\n grid-template-areas: \"left left left\" \"bottom bottom bottom\";\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}\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 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: 543.98px) {\n .SubHeader {\n grid-template-areas: \"left right\" \"middle middle\" \"bottom bottom\";\n grid-template-columns: 1fr auto;\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(--stack-gap-normal);\n }\n}\n"]}
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 @mobile_filter_cancel do
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 @mobile_filter_trigger if @mobile_filter_trigger.present? %>
8
+ <%= render @collapsed_filter_trigger if @collapsed_filter_trigger.present? %>
11
9
 
12
- <%= render(@mobile_filter_button) if @mobile_filter_button.present? %>
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
- <%= filter_button %>
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" data-targets="<%= HIDDEN_FILTER_TARGET_SELECTOR %>">
28
+ <div class="SubHeader-middlePane SubHeader-hiddenOnExpand">
26
29
  <%= text %>
27
30
  </div>
28
31
 
29
- <div class="SubHeader-rightPane" data-targets="<%= HIDDEN_FILTER_TARGET_SELECTOR %>">
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: center;
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: 543.98px) {
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(--stack-gap-normal);
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
- @mobile_filter_trigger = Primer::Beta::IconButton.new(icon: system_arguments[:leading_visual][:icon],
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
- @mobile_filter_cancel = Primer::Beta::Button.new(scheme: :invisible,
112
- display: :none,
113
- data: {
114
- targets: SHOWN_FILTER_TARGET_SELECTOR,
115
- action: "click:sub-header#collapseFilterInput"})
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: DESKTOP_ACTIONS_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[:data] ||= {}
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;