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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/app/assets/javascripts/app/components/primer/alpha/action_menu/action_menu_element.d.ts +0 -9
- data/app/assets/javascripts/app/components/primer/alpha/select_panel_element.d.ts +64 -0
- data/app/assets/javascripts/app/components/primer/aria_live.d.ts +8 -0
- data/app/assets/javascripts/app/components/primer/primer.d.ts +4 -0
- data/app/assets/javascripts/app/components/primer/shared_events.d.ts +9 -0
- data/app/assets/javascripts/primer_view_components.js +1 -1
- data/app/assets/javascripts/primer_view_components.js.map +1 -1
- data/app/assets/styles/primer_view_components.css +1 -1
- data/app/assets/styles/primer_view_components.css.map +1 -1
- data/app/components/primer/alpha/action_list.css +1 -1
- data/app/components/primer/alpha/action_list.css.map +1 -1
- data/app/components/primer/alpha/action_list.pcss +1 -0
- data/app/components/primer/alpha/action_menu/action_menu_element.d.ts +0 -9
- data/app/components/primer/alpha/action_menu/action_menu_element.ts +0 -11
- data/app/components/primer/alpha/action_menu.rb +13 -6
- data/app/components/primer/alpha/select_panel.html.erb +100 -0
- data/app/components/primer/alpha/select_panel.rb +486 -0
- data/app/components/primer/alpha/select_panel_element.d.ts +64 -0
- data/app/components/primer/alpha/select_panel_element.js +927 -0
- data/app/components/primer/alpha/select_panel_element.ts +1049 -0
- data/app/components/primer/aria_live.d.ts +8 -0
- data/app/components/primer/aria_live.js +38 -0
- data/app/components/primer/aria_live.ts +41 -0
- data/app/components/primer/base_component.rb +1 -1
- data/app/components/primer/primer.d.ts +4 -0
- data/app/components/primer/primer.js +4 -0
- data/app/components/primer/primer.ts +4 -0
- data/app/components/primer/shared_events.d.ts +9 -0
- data/app/components/primer/shared_events.js +1 -0
- data/app/components/primer/shared_events.ts +10 -0
- data/app/forms/example_toggle_switch_form/example_field_caption.html.erb +1 -1
- data/lib/primer/forms/toggle_switch.html.erb +1 -2
- data/lib/primer/static/generate_info_arch.rb +3 -2
- data/lib/primer/view_components/version.rb +1 -1
- data/lib/primer/yard/component_manifest.rb +2 -0
- data/previews/primer/alpha/action_menu_preview.rb +1 -1
- data/previews/primer/alpha/select_panel_preview/_interaction_subject_js.html.erb +25 -0
- data/previews/primer/alpha/select_panel_preview/eventually_local_fetch.html.erb +16 -0
- data/previews/primer/alpha/select_panel_preview/eventually_local_fetch_initial_failure.html.erb +12 -0
- data/previews/primer/alpha/select_panel_preview/eventually_local_fetch_no_results.html.erb +16 -0
- data/previews/primer/alpha/select_panel_preview/footer_buttons.html.erb +23 -0
- data/previews/primer/alpha/select_panel_preview/local_fetch.html.erb +19 -0
- data/previews/primer/alpha/select_panel_preview/local_fetch_no_results.html.erb +15 -0
- data/previews/primer/alpha/select_panel_preview/multiselect.html.erb +17 -0
- data/previews/primer/alpha/select_panel_preview/multiselect_form.html.erb +31 -0
- data/previews/primer/alpha/select_panel_preview/playground.html.erb +23 -0
- data/previews/primer/alpha/select_panel_preview/remote_fetch.html.erb +16 -0
- data/previews/primer/alpha/select_panel_preview/remote_fetch_filter_failure.html.erb +13 -0
- data/previews/primer/alpha/select_panel_preview/remote_fetch_initial_failure.html.erb +12 -0
- data/previews/primer/alpha/select_panel_preview/remote_fetch_no_results.html.erb +16 -0
- data/previews/primer/alpha/select_panel_preview/single_select.html.erb +20 -0
- data/previews/primer/alpha/select_panel_preview/single_select_form.html.erb +33 -0
- data/previews/primer/alpha/select_panel_preview/with_avatar_items.html.erb +19 -0
- data/previews/primer/alpha/select_panel_preview/with_dynamic_label.html.erb +23 -0
- data/previews/primer/alpha/select_panel_preview/with_dynamic_label_and_aria_prefix.html.erb +24 -0
- data/previews/primer/alpha/select_panel_preview/with_leading_icons.html.erb +31 -0
- data/previews/primer/alpha/select_panel_preview/with_subtitle.html.erb +25 -0
- data/previews/primer/alpha/select_panel_preview/with_trailing_icons.html.erb +19 -0
- data/previews/primer/alpha/select_panel_preview.rb +239 -0
- data/static/arguments.json +140 -0
- data/static/audited_at.json +2 -0
- data/static/constants.json +18 -0
- data/static/info_arch.json +950 -106
- data/static/previews.json +294 -0
- data/static/statuses.json +2 -0
- 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
|
+
}
|