openproject-primer_view_components 0.37.1 → 0.38.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/app/assets/javascripts/app/components/primer/alpha/action_menu/action_menu_element.d.ts +0 -9
  4. data/app/assets/javascripts/app/components/primer/alpha/select_panel_element.d.ts +64 -0
  5. data/app/assets/javascripts/app/components/primer/aria_live.d.ts +8 -0
  6. data/app/assets/javascripts/app/components/primer/primer.d.ts +4 -0
  7. data/app/assets/javascripts/app/components/primer/shared_events.d.ts +9 -0
  8. data/app/assets/javascripts/primer_view_components.js +1 -1
  9. data/app/assets/javascripts/primer_view_components.js.map +1 -1
  10. data/app/assets/styles/primer_view_components.css +1 -1
  11. data/app/assets/styles/primer_view_components.css.map +1 -1
  12. data/app/components/primer/alpha/action_list.css +1 -1
  13. data/app/components/primer/alpha/action_list.css.map +1 -1
  14. data/app/components/primer/alpha/action_list.pcss +1 -0
  15. data/app/components/primer/alpha/action_menu/action_menu_element.d.ts +0 -9
  16. data/app/components/primer/alpha/action_menu/action_menu_element.ts +0 -11
  17. data/app/components/primer/alpha/action_menu.rb +13 -6
  18. data/app/components/primer/alpha/select_panel.html.erb +100 -0
  19. data/app/components/primer/alpha/select_panel.rb +473 -0
  20. data/app/components/primer/alpha/select_panel_element.d.ts +64 -0
  21. data/app/components/primer/alpha/select_panel_element.js +924 -0
  22. data/app/components/primer/alpha/select_panel_element.ts +1045 -0
  23. data/app/components/primer/aria_live.d.ts +8 -0
  24. data/app/components/primer/aria_live.js +38 -0
  25. data/app/components/primer/aria_live.ts +41 -0
  26. data/app/components/primer/base_component.rb +1 -1
  27. data/app/components/primer/primer.d.ts +4 -0
  28. data/app/components/primer/primer.js +4 -0
  29. data/app/components/primer/primer.ts +4 -0
  30. data/app/components/primer/shared_events.d.ts +9 -0
  31. data/app/components/primer/shared_events.js +1 -0
  32. data/app/components/primer/shared_events.ts +10 -0
  33. data/app/forms/example_toggle_switch_form/example_field_caption.html.erb +1 -1
  34. data/lib/primer/forms/toggle_switch.html.erb +1 -2
  35. data/lib/primer/static/generate_info_arch.rb +3 -2
  36. data/lib/primer/view_components/version.rb +2 -2
  37. data/lib/primer/yard/component_manifest.rb +2 -0
  38. data/previews/primer/alpha/select_panel_preview/_interaction_subject_js.html.erb +25 -0
  39. data/previews/primer/alpha/select_panel_preview/eventually_local_fetch.html.erb +16 -0
  40. data/previews/primer/alpha/select_panel_preview/eventually_local_fetch_initial_failure.html.erb +12 -0
  41. data/previews/primer/alpha/select_panel_preview/eventually_local_fetch_no_results.html.erb +16 -0
  42. data/previews/primer/alpha/select_panel_preview/footer_buttons.html.erb +23 -0
  43. data/previews/primer/alpha/select_panel_preview/local_fetch.html.erb +19 -0
  44. data/previews/primer/alpha/select_panel_preview/local_fetch_no_results.html.erb +15 -0
  45. data/previews/primer/alpha/select_panel_preview/multiselect.html.erb +17 -0
  46. data/previews/primer/alpha/select_panel_preview/multiselect_form.html.erb +31 -0
  47. data/previews/primer/alpha/select_panel_preview/playground.html.erb +19 -0
  48. data/previews/primer/alpha/select_panel_preview/remote_fetch.html.erb +16 -0
  49. data/previews/primer/alpha/select_panel_preview/remote_fetch_filter_failure.html.erb +13 -0
  50. data/previews/primer/alpha/select_panel_preview/remote_fetch_initial_failure.html.erb +12 -0
  51. data/previews/primer/alpha/select_panel_preview/remote_fetch_no_results.html.erb +16 -0
  52. data/previews/primer/alpha/select_panel_preview/single_select.html.erb +20 -0
  53. data/previews/primer/alpha/select_panel_preview/single_select_form.html.erb +33 -0
  54. data/previews/primer/alpha/select_panel_preview/with_avatar_items.html.erb +19 -0
  55. data/previews/primer/alpha/select_panel_preview/with_dynamic_label.html.erb +23 -0
  56. data/previews/primer/alpha/select_panel_preview/with_dynamic_label_and_aria_prefix.html.erb +24 -0
  57. data/previews/primer/alpha/select_panel_preview/with_leading_icons.html.erb +31 -0
  58. data/previews/primer/alpha/select_panel_preview/with_subtitle.html.erb +25 -0
  59. data/previews/primer/alpha/select_panel_preview/with_trailing_icons.html.erb +19 -0
  60. data/previews/primer/alpha/select_panel_preview.rb +239 -0
  61. data/static/arguments.json +118 -0
  62. data/static/audited_at.json +1 -0
  63. data/static/constants.json +16 -0
  64. data/static/info_arch.json +902 -112
  65. data/static/previews.json +294 -0
  66. data/static/statuses.json +1 -0
  67. metadata +41 -4
@@ -0,0 +1,1045 @@
1
+ import {getAnchoredPosition} from '@primer/behaviors'
2
+ import {controller, target} from '@github/catalyst'
3
+ import {announceFromElement, announce} from '../aria_live'
4
+ import {IncludeFragmentElement} from '@github/include-fragment-element'
5
+ import type {PrimerTextFieldElement} from 'lib/primer/forms/primer_text_field'
6
+ import type {AnchorAlignment, AnchorSide} from '@primer/behaviors'
7
+ import '@oddbird/popover-polyfill'
8
+
9
+ type SelectVariant = 'none' | 'single' | 'multiple' | null
10
+ type SelectedItem = {
11
+ label: string | null | undefined
12
+ value: string | null | undefined
13
+ inputName: string | null | undefined
14
+ element: SelectPanelItem
15
+ }
16
+
17
+ const validSelectors = ['[role="option"]']
18
+ const menuItemSelectors = validSelectors.join(',')
19
+ const visibleMenuItemSelectors = validSelectors.map(selector => `:not([hidden]) > ${selector}`).join(',')
20
+
21
+ export type SelectPanelItem = HTMLLIElement
22
+
23
+ enum FetchStrategy {
24
+ REMOTE,
25
+ EVENTUALLY_LOCAL,
26
+ LOCAL,
27
+ }
28
+
29
+ enum ErrorStateType {
30
+ BODY,
31
+ BANNER,
32
+ }
33
+
34
+ export type FilterFn = (item: SelectPanelItem, query: string) => boolean
35
+
36
+ const updateWhenVisible = (() => {
37
+ const anchors = new Set<SelectPanelElement>()
38
+ let resizeObserver: ResizeObserver | null = null
39
+ function updateVisibleAnchors() {
40
+ for (const anchor of anchors) {
41
+ anchor.updateAnchorPosition()
42
+ }
43
+ }
44
+ return (el: SelectPanelElement) => {
45
+ // eslint-disable-next-line github/prefer-observers
46
+ window.addEventListener('resize', updateVisibleAnchors)
47
+ // eslint-disable-next-line github/prefer-observers
48
+ window.addEventListener('scroll', updateVisibleAnchors)
49
+
50
+ resizeObserver ||= new ResizeObserver(() => {
51
+ for (const anchor of anchors) {
52
+ anchor.updateAnchorPosition()
53
+ }
54
+ })
55
+ resizeObserver.observe(el.ownerDocument.documentElement)
56
+ el.addEventListener('dialog:close', () => {
57
+ anchors.delete(el)
58
+ })
59
+ el.addEventListener('dialog:open', () => {
60
+ anchors.add(el)
61
+ })
62
+ }
63
+ })()
64
+
65
+ @controller
66
+ export class SelectPanelElement extends HTMLElement {
67
+ @target includeFragment: IncludeFragmentElement
68
+ @target dialog: HTMLDialogElement
69
+ @target filterInputTextField: HTMLInputElement
70
+ @target remoteInput: HTMLElement
71
+ @target list: HTMLElement
72
+ @target ariaLiveContainer: HTMLElement
73
+ @target noResults: HTMLElement
74
+ @target fragmentErrorElement: HTMLElement
75
+ @target bannerErrorElement: HTMLElement
76
+ @target bodySpinner: HTMLElement
77
+
78
+ filterFn?: FilterFn
79
+
80
+ #dialogIntersectionObserver: IntersectionObserver
81
+ #abortController: AbortController
82
+ #originalLabel = ''
83
+ #inputName = ''
84
+ #selectedItems: Map<string, SelectedItem> = new Map()
85
+ #loadingDelayTimeoutId: number | null = null
86
+ #loadingAnnouncementTimeoutId: number | null = null
87
+
88
+ get open(): boolean {
89
+ return this.dialog.open
90
+ }
91
+
92
+ get selectVariant(): SelectVariant {
93
+ return this.getAttribute('data-select-variant') as SelectVariant
94
+ }
95
+
96
+ get ariaSelectionType(): string {
97
+ return this.selectVariant === 'multiple' ? 'aria-checked' : 'aria-selected'
98
+ }
99
+
100
+ set selectVariant(variant: SelectVariant) {
101
+ if (variant) {
102
+ this.setAttribute('data-select-variant', variant)
103
+ } else {
104
+ this.removeAttribute('variant')
105
+ }
106
+ }
107
+
108
+ get dynamicLabelPrefix(): string {
109
+ const prefix = this.getAttribute('data-dynamic-label-prefix')
110
+ if (!prefix) return ''
111
+ return `${prefix}:`
112
+ }
113
+
114
+ get dynamicAriaLabelPrefix(): string {
115
+ const prefix = this.getAttribute('data-dynamic-aria-label-prefix')
116
+ if (!prefix) return ''
117
+ return `${prefix}:`
118
+ }
119
+
120
+ set dynamicLabelPrefix(value: string) {
121
+ this.setAttribute('data-dynamic-label', value)
122
+ }
123
+
124
+ get dynamicLabel(): boolean {
125
+ return this.hasAttribute('data-dynamic-label')
126
+ }
127
+
128
+ set dynamicLabel(value: boolean) {
129
+ this.toggleAttribute('data-dynamic-label', value)
130
+ }
131
+
132
+ get invokerElement(): HTMLButtonElement | null {
133
+ const id = this.querySelector('dialog')?.id
134
+ if (!id) return null
135
+ for (const el of this.querySelectorAll(`[aria-controls]`)) {
136
+ if (el.getAttribute('aria-controls') === id) {
137
+ return el as HTMLButtonElement
138
+ }
139
+ }
140
+ return null
141
+ }
142
+
143
+ get closeButton(): HTMLButtonElement | null {
144
+ return this.querySelector('button[data-close-dialog-id]')
145
+ }
146
+
147
+ get invokerLabel(): HTMLElement | null {
148
+ if (!this.invokerElement) return null
149
+ return this.invokerElement.querySelector('.Button-label')
150
+ }
151
+
152
+ get selectedItems(): SelectedItem[] {
153
+ return Array.from(this.#selectedItems.values())
154
+ }
155
+
156
+ get align(): AnchorAlignment {
157
+ return (this.getAttribute('anchor-align') || 'start') as AnchorAlignment
158
+ }
159
+
160
+ get side(): AnchorSide {
161
+ return (this.getAttribute('anchor-side') || 'outside-bottom') as AnchorSide
162
+ }
163
+
164
+ updateAnchorPosition() {
165
+ // If the selectPanel is removed from the screen on resize close the dialog
166
+ if (this && this.offsetParent === null) {
167
+ this.dialog.close()
168
+ }
169
+
170
+ if (this.invokerElement) {
171
+ const {top, left} = getAnchoredPosition(this.dialog, this.invokerElement, {
172
+ align: this.align,
173
+ side: this.side,
174
+ anchorOffset: 4,
175
+ })
176
+ this.dialog.style.top = `${top}px`
177
+ this.dialog.style.left = `${left}px`
178
+ this.dialog.style.bottom = 'auto'
179
+ this.dialog.style.right = 'auto'
180
+ }
181
+ }
182
+
183
+ connectedCallback() {
184
+ const {signal} = (this.#abortController = new AbortController())
185
+ this.addEventListener('keydown', this, {signal})
186
+ this.addEventListener('click', this, {signal})
187
+ this.addEventListener('mousedown', this, {signal})
188
+ this.addEventListener('input', this, {signal})
189
+ this.addEventListener('remote-input-success', this, {signal})
190
+ this.addEventListener('remote-input-error', this, {signal})
191
+ this.addEventListener('loadstart', this, {signal})
192
+ this.#setDynamicLabel()
193
+ this.#updateInput()
194
+ this.#softDisableItems()
195
+ updateWhenVisible(this)
196
+
197
+ this.#waitForCondition(
198
+ () => Boolean(this.remoteInput),
199
+ () => {
200
+ this.remoteInput.addEventListener('loadstart', this, {signal})
201
+ this.remoteInput.addEventListener('loadend', this, {signal})
202
+ },
203
+ )
204
+
205
+ this.#waitForCondition(
206
+ () => Boolean(this.includeFragment),
207
+ () => {
208
+ this.includeFragment.addEventListener('include-fragment-replaced', this, {signal})
209
+ this.includeFragment.addEventListener('error', this, {signal})
210
+ this.includeFragment.addEventListener('loadend', this, {signal})
211
+ },
212
+ )
213
+
214
+ this.#dialogIntersectionObserver = new IntersectionObserver(entries => {
215
+ for (const entry of entries) {
216
+ const elem = entry.target
217
+ if (entry.isIntersecting && elem === this.dialog) {
218
+ this.updateAnchorPosition()
219
+ }
220
+ }
221
+ })
222
+
223
+ this.#waitForCondition(
224
+ () => Boolean(this.dialog),
225
+ () => {
226
+ if (this.getAttribute('data-open-on-load') === 'true') {
227
+ this.show()
228
+ }
229
+
230
+ this.#dialogIntersectionObserver.observe(this.dialog)
231
+ this.dialog.addEventListener('close', this, {signal})
232
+ },
233
+ )
234
+
235
+ if (this.#fetchStrategy === FetchStrategy.LOCAL) {
236
+ this.#waitForCondition(
237
+ () => this.items.length > 0,
238
+ () => {
239
+ this.#updateItemVisibility()
240
+ this.#updateInput()
241
+ },
242
+ )
243
+ }
244
+ }
245
+
246
+ // Waits for condition to return true. If it returns false initially, this function creates a
247
+ // MutationObserver that calls body() whenever the contents of the component change.
248
+ #waitForCondition(condition: () => boolean, body: () => void) {
249
+ if (condition()) {
250
+ body()
251
+ } else {
252
+ const mutationObserver = new MutationObserver(() => {
253
+ if (condition()) {
254
+ body()
255
+ mutationObserver.disconnect()
256
+ }
257
+ })
258
+
259
+ mutationObserver.observe(this, {childList: true, subtree: true})
260
+ }
261
+ }
262
+
263
+ disconnectedCallback() {
264
+ this.#abortController.abort()
265
+ }
266
+
267
+ #softDisableItems() {
268
+ const {signal} = this.#abortController
269
+
270
+ for (const item of this.querySelectorAll(validSelectors.join(','))) {
271
+ item.addEventListener('click', this.#potentiallyDisallowActivation.bind(this), {signal})
272
+ item.addEventListener('keydown', this.#potentiallyDisallowActivation.bind(this), {signal})
273
+ }
274
+ }
275
+
276
+ // If there is an active item in single-select mode, set its tabindex to 0. Otherwise, set the
277
+ // first visible item's tabindex to 0. All other items should have a tabindex of -1.
278
+ #updateTabIndices() {
279
+ let setZeroTabIndex = false
280
+
281
+ if (this.selectVariant === 'single') {
282
+ for (const item of this.items) {
283
+ const itemContent = this.#getItemContent(item)
284
+ if (!itemContent) continue
285
+
286
+ if (!this.isItemHidden(item) && this.isItemChecked(item) && !setZeroTabIndex) {
287
+ itemContent.setAttribute('tabindex', '0')
288
+ setZeroTabIndex = true
289
+ } else {
290
+ itemContent.setAttribute('tabindex', '-1')
291
+ }
292
+
293
+ // <li> elements should not themselves be tabbable
294
+ item.setAttribute('tabindex', '-1')
295
+ }
296
+ } else {
297
+ for (const item of this.items) {
298
+ const itemContent = this.#getItemContent(item)
299
+ if (!itemContent) continue
300
+
301
+ if (!this.isItemHidden(item) && !setZeroTabIndex) {
302
+ setZeroTabIndex = true
303
+ } else {
304
+ itemContent.setAttribute('tabindex', '-1')
305
+ }
306
+
307
+ // <li> elements should not themselves be tabbable
308
+ item.setAttribute('tabindex', '-1')
309
+ }
310
+ }
311
+
312
+ if (!setZeroTabIndex && this.#firstItem) {
313
+ this.#getItemContent(this.#firstItem)?.setAttribute('tabindex', '0')
314
+ }
315
+ }
316
+
317
+ // returns true if activation was prevented
318
+ #potentiallyDisallowActivation(event: Event): boolean {
319
+ if (!this.#isActivation(event)) return false
320
+
321
+ const item = (event.target as HTMLElement).closest(visibleMenuItemSelectors)
322
+ if (!item) return false
323
+
324
+ if (item.getAttribute('aria-disabled')) {
325
+ event.preventDefault()
326
+
327
+ // eslint-disable-next-line no-restricted-syntax
328
+ event.stopPropagation()
329
+
330
+ // eslint-disable-next-line no-restricted-syntax
331
+ event.stopImmediatePropagation()
332
+ return true
333
+ }
334
+
335
+ return false
336
+ }
337
+
338
+ #isAnchorActivationViaSpace(event: Event): boolean {
339
+ return (
340
+ event.target instanceof HTMLAnchorElement &&
341
+ event instanceof KeyboardEvent &&
342
+ event.type === 'keydown' &&
343
+ !(event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) &&
344
+ event.key === ' '
345
+ )
346
+ }
347
+
348
+ #isActivation(event: Event): boolean {
349
+ // Some browsers fire MouseEvents (Firefox) and others fire PointerEvents (Chrome). Activating an item via
350
+ // enter or space counterintuitively fires one of these rather than a KeyboardEvent. Since PointerEvent
351
+ // inherits from MouseEvent, it is enough to check for MouseEvent here.
352
+ return (event instanceof MouseEvent && event.type === 'click') || this.#isAnchorActivationViaSpace(event)
353
+ }
354
+
355
+ #checkSelectedItems() {
356
+ for (const item of this.items) {
357
+ const itemContent = this.#getItemContent(item)
358
+ if (!itemContent) continue
359
+
360
+ const value = itemContent.getAttribute('data-value')
361
+
362
+ if (value) {
363
+ if (this.#selectedItems.has(value)) {
364
+ itemContent.setAttribute(this.ariaSelectionType, 'true')
365
+ }
366
+ }
367
+ }
368
+ this.#updateInput()
369
+ }
370
+
371
+ #addSelectedItem(item: SelectPanelItem) {
372
+ const itemContent = this.#getItemContent(item)
373
+ if (!itemContent) return
374
+
375
+ const value = itemContent.getAttribute('data-value')
376
+
377
+ if (value) {
378
+ this.#selectedItems.set(value, {
379
+ value,
380
+ label: itemContent.querySelector('.ActionListItem-label')?.textContent?.trim(),
381
+ inputName: itemContent.getAttribute('data-input-name'),
382
+ element: item,
383
+ })
384
+ }
385
+ }
386
+
387
+ #removeSelectedItem(item: Element) {
388
+ const value = item.getAttribute('data-value')
389
+
390
+ if (value) {
391
+ this.#selectedItems.delete(value)
392
+ }
393
+ }
394
+
395
+ #setTextFieldLoadingSpinnerTimer() {
396
+ if (this.#loadingDelayTimeoutId) clearTimeout(this.#loadingDelayTimeoutId)
397
+ if (this.#loadingAnnouncementTimeoutId) clearTimeout(this.#loadingAnnouncementTimeoutId)
398
+
399
+ this.#loadingAnnouncementTimeoutId = setTimeout(() => {
400
+ announce('Loading', {element: this.ariaLiveContainer})
401
+ }, 2000) as unknown as number
402
+
403
+ this.#loadingDelayTimeoutId = setTimeout(() => {
404
+ this.#filterInputTextFieldElement.showLeadingSpinner()
405
+ }, 1000) as unknown as number
406
+ }
407
+
408
+ handleEvent(event: Event) {
409
+ if (event.target === this.filterInputTextField) {
410
+ this.#handleSearchFieldEvent(event)
411
+ return
412
+ }
413
+
414
+ if (event.target === this.remoteInput) {
415
+ this.#handleRemoteInputEvent(event)
416
+ return
417
+ }
418
+
419
+ const targetIsInvoker = this.invokerElement?.contains(event.target as HTMLElement)
420
+ const targetIsCloseButton = this.closeButton?.contains(event.target as HTMLElement)
421
+ const eventIsActivation = this.#isActivation(event)
422
+
423
+ if (targetIsInvoker && event.type === 'mousedown') {
424
+ return
425
+ }
426
+
427
+ if (event.type === 'mousedown' && event.target instanceof HTMLInputElement) {
428
+ return
429
+ }
430
+
431
+ // Prevent safari bug that dismisses menu on mousedown instead of allowing
432
+ // the click event to propagate to the button
433
+ if (event.type === 'mousedown') {
434
+ event.preventDefault()
435
+ return
436
+ }
437
+
438
+ if (targetIsInvoker && eventIsActivation) {
439
+ this.#handleInvokerActivated(event)
440
+ return
441
+ }
442
+
443
+ if (targetIsCloseButton && eventIsActivation) {
444
+ // hide() will automatically be called by dialog event triggered from `data-close-dialog-id`
445
+ return
446
+ }
447
+
448
+ if (event.target === this.dialog && event.type === 'close') {
449
+ this.dispatchEvent(
450
+ new CustomEvent('panelClosed', {
451
+ detail: {panel: this},
452
+ bubbles: true,
453
+ }),
454
+ )
455
+
456
+ return
457
+ }
458
+
459
+ const item = (event.target as Element).closest(visibleMenuItemSelectors)?.parentElement as
460
+ | SelectPanelItem
461
+ | null
462
+ | undefined
463
+
464
+ const targetIsItem = item !== null && item !== undefined
465
+
466
+ if (targetIsItem && eventIsActivation) {
467
+ if (this.#potentiallyDisallowActivation(event)) return
468
+
469
+ const dialogInvoker = item.closest('[data-show-dialog-id]')
470
+
471
+ if (dialogInvoker) {
472
+ const dialog = this.ownerDocument.getElementById(dialogInvoker.getAttribute('data-show-dialog-id') || '')
473
+
474
+ if (dialog && this.contains(dialogInvoker) && this.contains(dialog)) {
475
+ this.#handleDialogItemActivated(event, dialog)
476
+ return
477
+ }
478
+ }
479
+
480
+ // Pressing the space key on a link will cause the page to scroll unless preventDefault() is called.
481
+ // We then click it manually to navigate.
482
+ if (this.#isAnchorActivationViaSpace(event)) {
483
+ event.preventDefault()
484
+ this.#getItemContent(item)?.click()
485
+ }
486
+
487
+ this.#handleItemActivated(item)
488
+
489
+ return
490
+ }
491
+
492
+ if (event.type === 'click') {
493
+ const rect = this.dialog.getBoundingClientRect()
494
+
495
+ const clickWasInsideDialog =
496
+ rect.top <= (event as MouseEvent).clientY &&
497
+ (event as MouseEvent).clientY <= rect.top + rect.height &&
498
+ rect.left <= (event as MouseEvent).clientX &&
499
+ (event as MouseEvent).clientX <= rect.left + rect.width
500
+
501
+ if (!clickWasInsideDialog) {
502
+ this.hide()
503
+ }
504
+ }
505
+
506
+ // The include fragment will have been removed from the DOM by the time
507
+ // the include-fragment-replaced event has been dispatched, so we have to
508
+ // check for the type of the event target manually, since this.includeFragment
509
+ // will be null.
510
+ if (event.target instanceof IncludeFragmentElement) {
511
+ this.#handleIncludeFragmentEvent(event)
512
+ }
513
+ }
514
+
515
+ #handleIncludeFragmentEvent(event: Event) {
516
+ switch (event.type) {
517
+ case 'include-fragment-replaced': {
518
+ this.#updateItemVisibility()
519
+ break
520
+ }
521
+
522
+ case 'loadstart': {
523
+ this.#toggleIncludeFragmentElements(false)
524
+ break
525
+ }
526
+
527
+ case 'loadend': {
528
+ this.dispatchEvent(new CustomEvent('loadend'))
529
+ break
530
+ }
531
+
532
+ case 'error': {
533
+ this.#toggleIncludeFragmentElements(true)
534
+
535
+ const errorElement = this.fragmentErrorElement
536
+ // check if the errorElement is visible in the dom
537
+ if (errorElement && !errorElement.hasAttribute('hidden')) {
538
+ announceFromElement(errorElement, {element: this.ariaLiveContainer, assertive: true})
539
+ return
540
+ }
541
+
542
+ break
543
+ }
544
+ }
545
+ }
546
+
547
+ #toggleIncludeFragmentElements(showError: boolean) {
548
+ for (const el of this.includeFragment.querySelectorAll('[data-show-on-error]')) {
549
+ if (el instanceof HTMLElement) el.hidden = !showError
550
+ }
551
+ for (const el of this.includeFragment.querySelectorAll('[data-hide-on-error]')) {
552
+ if (el instanceof HTMLElement) el.hidden = showError
553
+ }
554
+ }
555
+
556
+ #handleRemoteInputEvent(event: Event) {
557
+ switch (event.type) {
558
+ case 'remote-input-success': {
559
+ this.#clearErrorState()
560
+ this.#updateItemVisibility()
561
+ this.#checkSelectedItems()
562
+ break
563
+ }
564
+
565
+ case 'remote-input-error': {
566
+ this.bodySpinner?.setAttribute('hidden', '')
567
+
568
+ if (this.includeFragment || this.visibleItems.length === 0) {
569
+ this.#setErrorState(ErrorStateType.BODY)
570
+ } else {
571
+ this.#setErrorState(ErrorStateType.BANNER)
572
+ }
573
+
574
+ break
575
+ }
576
+
577
+ case 'loadstart': {
578
+ if (!this.#performFilteringLocally()) {
579
+ this.#clearErrorState()
580
+ this.bodySpinner?.removeAttribute('hidden')
581
+
582
+ if (this.bodySpinner) break
583
+ this.#setTextFieldLoadingSpinnerTimer()
584
+ }
585
+
586
+ break
587
+ }
588
+
589
+ case 'loadend': {
590
+ this.#filterInputTextFieldElement.hideLeadingSpinner()
591
+ if (this.#loadingAnnouncementTimeoutId) clearTimeout(this.#loadingAnnouncementTimeoutId)
592
+ if (this.#loadingDelayTimeoutId) clearTimeout(this.#loadingDelayTimeoutId)
593
+ this.dispatchEvent(new CustomEvent('loadend'))
594
+ break
595
+ }
596
+ }
597
+ }
598
+
599
+ #defaultFilterFn(item: HTMLElement, query: string) {
600
+ const text = (item.getAttribute('data-filter-string') || item.textContent || '').toLowerCase()
601
+ return text.indexOf(query.toLowerCase()) > -1
602
+ }
603
+
604
+ #handleSearchFieldEvent(event: Event) {
605
+ if (event.type === 'keydown' && (event as KeyboardEvent).key === 'ArrowDown') {
606
+ if (this.focusableItem) {
607
+ this.focusableItem.focus()
608
+ event.preventDefault()
609
+ }
610
+ }
611
+ if (event.type !== 'input') return
612
+
613
+ // remote-input-element does not trigger another loadstart event if a request is
614
+ // already in-flight, so we use the input event on the text field to reset the
615
+ // loading spinner timer instead
616
+ if (!this.bodySpinner && !this.#performFilteringLocally()) {
617
+ this.#setTextFieldLoadingSpinnerTimer()
618
+ }
619
+
620
+ if (this.#fetchStrategy === FetchStrategy.LOCAL || this.#fetchStrategy === FetchStrategy.EVENTUALLY_LOCAL) {
621
+ if (this.includeFragment) {
622
+ this.includeFragment.refetch()
623
+ return
624
+ }
625
+
626
+ this.#updateItemVisibility()
627
+ }
628
+ }
629
+
630
+ #updateItemVisibility() {
631
+ if (!this.list) return
632
+
633
+ let atLeastOneResult = false
634
+
635
+ if (this.#performFilteringLocally()) {
636
+ const query = this.filterInputTextField?.value ?? ''
637
+ const filter = this.filterFn || this.#defaultFilterFn
638
+
639
+ for (const item of this.items) {
640
+ if (filter(item, query)) {
641
+ this.#showItem(item)
642
+ atLeastOneResult = true
643
+ } else {
644
+ this.#hideItem(item)
645
+ }
646
+ }
647
+ } else {
648
+ atLeastOneResult = this.items.length > 0
649
+ }
650
+
651
+ this.#updateTabIndices()
652
+ this.#maybeAnnounce()
653
+
654
+ for (const item of this.items) {
655
+ const itemContent = this.#getItemContent(item)
656
+ if (!itemContent) continue
657
+
658
+ const value = itemContent.getAttribute('data-value')
659
+
660
+ if (value && !this.#selectedItems.has(value) && this.isItemChecked(item)) {
661
+ this.#addSelectedItem(item)
662
+ }
663
+ }
664
+
665
+ if (!this.noResults) return
666
+
667
+ if (this.#inErrorState()) {
668
+ this.noResults.setAttribute('hidden', '')
669
+ return
670
+ }
671
+
672
+ if (atLeastOneResult) {
673
+ this.noResults.setAttribute('hidden', '')
674
+ // TODO can we change this to search for `@panelId-list`
675
+ this.list?.querySelector('.ActionListWrap')?.removeAttribute('hidden')
676
+ } else {
677
+ this.list?.querySelector('.ActionListWrap')?.setAttribute('hidden', '')
678
+ this.noResults.removeAttribute('hidden')
679
+ }
680
+ }
681
+
682
+ #inErrorState(): boolean {
683
+ if (this.fragmentErrorElement && !this.fragmentErrorElement.hasAttribute('hidden')) {
684
+ return true
685
+ }
686
+
687
+ return !this.bannerErrorElement.hasAttribute('hidden')
688
+ }
689
+
690
+ #setErrorState(type: ErrorStateType) {
691
+ let errorElement = this.fragmentErrorElement
692
+
693
+ if (type === ErrorStateType.BODY) {
694
+ this.fragmentErrorElement?.removeAttribute('hidden')
695
+ this.bannerErrorElement.setAttribute('hidden', '')
696
+ } else {
697
+ errorElement = this.bannerErrorElement
698
+ this.bannerErrorElement?.removeAttribute('hidden')
699
+ this.fragmentErrorElement?.setAttribute('hidden', '')
700
+ }
701
+
702
+ // check if the errorElement is visible in the dom
703
+ if (errorElement && !errorElement.hasAttribute('hidden')) {
704
+ announceFromElement(errorElement, {element: this.ariaLiveContainer, assertive: true})
705
+ return
706
+ }
707
+ }
708
+
709
+ #clearErrorState() {
710
+ this.fragmentErrorElement?.setAttribute('hidden', '')
711
+ this.bannerErrorElement.setAttribute('hidden', '')
712
+ }
713
+
714
+ #maybeAnnounce() {
715
+ if (this.open && this.list) {
716
+ const items = this.items
717
+
718
+ if (items.length > 0) {
719
+ const instructions = 'tab for results'
720
+ announce(`${items.length} result${items.length === 1 ? '' : 's'} ${instructions}`, {
721
+ element: this.ariaLiveContainer,
722
+ })
723
+ } else {
724
+ const noResultsEl = this.noResults
725
+ if (noResultsEl) {
726
+ announceFromElement(noResultsEl, {element: this.ariaLiveContainer})
727
+ }
728
+ }
729
+ }
730
+ }
731
+
732
+ get #fetchStrategy(): FetchStrategy {
733
+ if (!this.list) return FetchStrategy.REMOTE
734
+
735
+ switch (this.list.getAttribute('data-fetch-strategy')) {
736
+ case 'local':
737
+ return FetchStrategy.LOCAL
738
+ case 'eventually_local':
739
+ return FetchStrategy.EVENTUALLY_LOCAL
740
+ default:
741
+ return FetchStrategy.REMOTE
742
+ }
743
+ }
744
+
745
+ get #filterInputTextFieldElement(): PrimerTextFieldElement {
746
+ return this.filterInputTextField.closest('primer-text-field') as PrimerTextFieldElement
747
+ }
748
+
749
+ #performFilteringLocally(): boolean {
750
+ return this.#fetchStrategy === FetchStrategy.LOCAL || this.#fetchStrategy === FetchStrategy.EVENTUALLY_LOCAL
751
+ }
752
+
753
+ #handleInvokerActivated(event: Event) {
754
+ event.preventDefault()
755
+
756
+ // eslint-disable-next-line no-restricted-syntax
757
+ event.stopPropagation()
758
+
759
+ if (this.open) {
760
+ this.hide()
761
+ } else {
762
+ this.show()
763
+ }
764
+ }
765
+
766
+ #handleDialogItemActivated(event: Event, dialog: HTMLElement) {
767
+ this.querySelector<HTMLElement>('.ActionListWrap')!.style.display = 'none'
768
+ const dialog_controller = new AbortController()
769
+ const {signal} = dialog_controller
770
+ const handleDialogClose = () => {
771
+ dialog_controller.abort()
772
+ this.querySelector<HTMLElement>('.ActionListWrap')!.style.display = ''
773
+ if (this.open) {
774
+ this.hide()
775
+ }
776
+ const activeElement = this.ownerDocument.activeElement
777
+ const lostFocus = this.ownerDocument.activeElement === this.ownerDocument.body
778
+ const focusInClosedMenu = this.contains(activeElement)
779
+ if (lostFocus || focusInClosedMenu) {
780
+ setTimeout(() => this.invokerElement?.focus(), 0)
781
+ }
782
+ }
783
+ // a modal <dialog> element will close all popovers
784
+ dialog.addEventListener('close', handleDialogClose, {signal})
785
+ dialog.addEventListener('cancel', handleDialogClose, {signal})
786
+ }
787
+
788
+ #handleItemActivated(item: SelectPanelItem) {
789
+ // Hide popover after current event loop to prevent changes in focus from
790
+ // altering the target of the event. Not doing this specifically affects
791
+ // <a> tags. It causes the event to be sent to the currently focused element
792
+ // instead of the anchor, which effectively prevents navigation, i.e. it
793
+ // appears as if hitting enter does nothing. Curiously, clicking instead
794
+ // works fine.
795
+ if (this.selectVariant !== 'multiple') {
796
+ setTimeout(() => {
797
+ if (this.open) {
798
+ this.hide()
799
+ }
800
+ })
801
+ }
802
+
803
+ // The rest of the code below deals with single/multiple selection behavior, and should not
804
+ // interfere with events fired by menu items whose behavior is specified outside the library.
805
+ if (this.selectVariant !== 'multiple' && this.selectVariant !== 'single') return
806
+
807
+ const checked = !this.isItemChecked(item)
808
+
809
+ const activationSuccess = this.dispatchEvent(
810
+ new CustomEvent('beforeItemActivated', {
811
+ bubbles: true,
812
+ detail: {item, checked},
813
+ cancelable: true,
814
+ }),
815
+ )
816
+
817
+ if (!activationSuccess) return
818
+
819
+ const itemContent = this.#getItemContent(item)
820
+
821
+ if (this.selectVariant === 'single') {
822
+ // Only check, never uncheck here. Single-select mode does not allow unchecking a checked item.
823
+ if (checked) {
824
+ this.#addSelectedItem(item)
825
+ itemContent?.setAttribute(this.ariaSelectionType, 'true')
826
+ }
827
+
828
+ for (const checkedItem of this.querySelectorAll(`[${this.ariaSelectionType}]`)) {
829
+ if (checkedItem !== itemContent) {
830
+ this.#removeSelectedItem(checkedItem)
831
+ checkedItem.setAttribute(this.ariaSelectionType, 'false')
832
+ }
833
+ }
834
+
835
+ this.#setDynamicLabel()
836
+ } else {
837
+ // multi-select mode allows unchecking a checked item
838
+ itemContent?.setAttribute(this.ariaSelectionType, `${checked}`)
839
+
840
+ if (checked) {
841
+ this.#addSelectedItem(item)
842
+ } else {
843
+ this.#removeSelectedItem(item)
844
+ }
845
+ }
846
+
847
+ this.#updateInput()
848
+ this.#updateTabIndices()
849
+
850
+ this.dispatchEvent(
851
+ new CustomEvent('itemActivated', {
852
+ bubbles: true,
853
+ detail: {item, checked},
854
+ }),
855
+ )
856
+ }
857
+
858
+ show() {
859
+ this.updateAnchorPosition()
860
+ this.dialog.showModal()
861
+ const event = new CustomEvent('dialog:open', {
862
+ detail: {dialog: this.dialog},
863
+ })
864
+ this.dispatchEvent(event)
865
+ }
866
+
867
+ hide() {
868
+ this.dialog.close()
869
+ }
870
+
871
+ #setDynamicLabel() {
872
+ if (!this.dynamicLabel) return
873
+ const invokerLabel = this.invokerLabel
874
+ if (!invokerLabel) return
875
+ this.#originalLabel ||= invokerLabel.textContent || ''
876
+ const itemLabel =
877
+ this.querySelector(`[${this.ariaSelectionType}=true] .ActionListItem-label`)?.textContent || this.#originalLabel
878
+ if (itemLabel) {
879
+ const prefixSpan = document.createElement('span')
880
+ prefixSpan.classList.add('color-fg-muted')
881
+ const contentSpan = document.createElement('span')
882
+ prefixSpan.textContent = `${this.dynamicLabelPrefix} `
883
+ contentSpan.textContent = itemLabel
884
+ invokerLabel.replaceChildren(prefixSpan, contentSpan)
885
+
886
+ if (this.dynamicAriaLabelPrefix) {
887
+ this.invokerElement?.setAttribute('aria-label', `${this.dynamicAriaLabelPrefix} ${itemLabel.trim()}`)
888
+ }
889
+ } else {
890
+ invokerLabel.textContent = this.#originalLabel
891
+ }
892
+ }
893
+
894
+ #updateInput() {
895
+ if (this.selectVariant === 'single') {
896
+ const input = this.querySelector(`[data-list-inputs=true] input`) as HTMLInputElement
897
+ if (!input) return
898
+
899
+ const selectedItem = this.selectedItems[0]
900
+
901
+ if (selectedItem) {
902
+ input.value = (selectedItem.value || selectedItem.label || '').trim()
903
+ if (selectedItem.inputName) input.name = selectedItem.inputName
904
+ input.removeAttribute('disabled')
905
+ } else {
906
+ input.setAttribute('disabled', 'disabled')
907
+ }
908
+ } else if (this.selectVariant !== 'none') {
909
+ // multiple select variant
910
+ const inputList = this.querySelector('[data-list-inputs=true]')
911
+ if (!inputList) return
912
+
913
+ const inputs = inputList.querySelectorAll('input')
914
+
915
+ if (inputs.length > 0) {
916
+ this.#inputName ||= (inputs[0] as HTMLInputElement).name
917
+ }
918
+
919
+ for (const selectedItem of this.selectedItems) {
920
+ const newInput = document.createElement('input')
921
+ newInput.setAttribute('data-list-input', 'true')
922
+ newInput.type = 'hidden'
923
+ newInput.autocomplete = 'off'
924
+ newInput.name = selectedItem.inputName || this.#inputName
925
+ newInput.value = (selectedItem.value || selectedItem.label || '').trim()
926
+
927
+ inputList.append(newInput)
928
+ }
929
+
930
+ for (const input of inputs) {
931
+ input.remove()
932
+ }
933
+ }
934
+ }
935
+
936
+ get #firstItem(): SelectPanelItem | null {
937
+ return (this.querySelector(visibleMenuItemSelectors)?.parentElement || null) as SelectPanelItem | null
938
+ }
939
+
940
+ get visibleItems(): SelectPanelItem[] {
941
+ return Array.from(this.querySelectorAll(visibleMenuItemSelectors)).map(
942
+ element => element.parentElement! as SelectPanelItem,
943
+ )
944
+ }
945
+
946
+ get items(): SelectPanelItem[] {
947
+ return Array.from(this.querySelectorAll(menuItemSelectors)).map(
948
+ element => element.parentElement! as SelectPanelItem,
949
+ )
950
+ }
951
+ get focusableItem(): HTMLElement | undefined {
952
+ for (const item of this.items) {
953
+ const itemContent = this.#getItemContent(item)
954
+ if (!itemContent) continue
955
+ if (itemContent.getAttribute('tabindex') === '0') {
956
+ return itemContent
957
+ }
958
+ }
959
+ }
960
+
961
+ getItemById(itemId: string): SelectPanelItem | null {
962
+ return this.querySelector(`li[data-item-id="${itemId}"`)
963
+ }
964
+
965
+ isItemDisabled(item: SelectPanelItem | null): boolean {
966
+ if (item) {
967
+ return item.classList.contains('ActionListItem--disabled')
968
+ } else {
969
+ return false
970
+ }
971
+ }
972
+
973
+ disableItem(item: SelectPanelItem | null) {
974
+ if (item) {
975
+ item.classList.add('ActionListItem--disabled')
976
+ this.#getItemContent(item)!.setAttribute('aria-disabled', 'true')
977
+ }
978
+ }
979
+
980
+ enableItem(item: SelectPanelItem | null) {
981
+ if (item) {
982
+ item.classList.remove('ActionListItem--disabled')
983
+ this.#getItemContent(item)!.removeAttribute('aria-disabled')
984
+ }
985
+ }
986
+
987
+ isItemHidden(item: SelectPanelItem | null): boolean {
988
+ if (item) {
989
+ return item.hasAttribute('hidden')
990
+ } else {
991
+ return false
992
+ }
993
+ }
994
+
995
+ #hideItem(item: SelectPanelItem | null) {
996
+ if (item) {
997
+ item.setAttribute('hidden', 'hidden')
998
+ }
999
+ }
1000
+
1001
+ #showItem(item: SelectPanelItem | null) {
1002
+ if (item) {
1003
+ item.removeAttribute('hidden')
1004
+ }
1005
+ }
1006
+
1007
+ isItemChecked(item: SelectPanelItem | null) {
1008
+ if (item) {
1009
+ return this.#getItemContent(item)!.getAttribute(this.ariaSelectionType) === 'true'
1010
+ } else {
1011
+ return false
1012
+ }
1013
+ }
1014
+
1015
+ checkItem(item: SelectPanelItem | null) {
1016
+ if (item && (this.selectVariant === 'single' || this.selectVariant === 'multiple')) {
1017
+ if (!this.isItemChecked(item)) {
1018
+ this.#handleItemActivated(item)
1019
+ }
1020
+ }
1021
+ }
1022
+
1023
+ uncheckItem(item: SelectPanelItem | null) {
1024
+ if (item && (this.selectVariant === 'single' || this.selectVariant === 'multiple')) {
1025
+ if (this.isItemChecked(item)) {
1026
+ this.#handleItemActivated(item)
1027
+ }
1028
+ }
1029
+ }
1030
+
1031
+ #getItemContent(item: SelectPanelItem): HTMLElement | null {
1032
+ return item.querySelector('.ActionListContent')
1033
+ }
1034
+ }
1035
+
1036
+ if (!window.customElements.get('select-panel')) {
1037
+ window.SelectPanelElement = SelectPanelElement
1038
+ window.customElements.define('select-panel', SelectPanelElement)
1039
+ }
1040
+
1041
+ declare global {
1042
+ interface Window {
1043
+ SelectPanelElement: typeof SelectPanelElement
1044
+ }
1045
+ }