openproject-primer_view_components 0.37.1 → 0.38.0

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