primer_view_components 0.27.0 → 0.29.0

Sign up to get free protection for your applications and to get access to all the features.
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
+ }