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