tam_select 1.2.0 → 1.2.4

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.
data/src/tam-select.js CHANGED
@@ -1,25 +1,29 @@
1
1
  const DEFAULT_CLASSES = {
2
2
  wrapper: "tam-select relative w-full text-zinc-900 [color-scheme:light] dark:text-zinc-100 dark:[color-scheme:dark]",
3
- control: "flex min-h-11 w-full cursor-text flex-wrap items-center gap-2 rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm transition-colors hover:border-zinc-400 focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-500/20 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:shadow-black/20 dark:hover:border-zinc-600 dark:focus-within:border-blue-400 dark:focus-within:ring-blue-400/20",
3
+ control: "relative flex min-h-11 w-full cursor-text flex-wrap items-center gap-2 rounded-xl border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm transition duration-150 hover:border-zinc-400 focus-within:border-blue-500 focus-within:ring-4 focus-within:ring-blue-500/10 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:shadow-black/20 dark:hover:border-zinc-600 dark:focus-within:border-blue-400 dark:focus-within:ring-blue-400/10",
4
+ controlOpen: "border-blue-500 ring-4 ring-blue-500/10 dark:border-blue-400 dark:ring-blue-400/10",
5
+ controlInvalid: "border-red-500 ring-4 ring-red-500/10 dark:border-red-400 dark:ring-red-400/10",
4
6
  controlDisabled: "cursor-not-allowed bg-zinc-100 opacity-60 dark:bg-zinc-800 dark:text-zinc-400",
5
7
  input: "min-w-16 flex-1 border-0 bg-transparent p-0 text-left text-sm text-zinc-900 outline-none placeholder:text-zinc-400 focus:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-500",
8
+ inputClosed: "absolute inset-0 z-0 h-full w-full cursor-pointer opacity-0",
9
+ trigger: "absolute inset-0 z-0 h-full w-full cursor-pointer rounded-xl border-0 bg-transparent p-0 focus:outline-none disabled:cursor-not-allowed",
6
10
  searchIcon: "size-4 shrink-0 text-zinc-400 dark:text-zinc-500",
7
11
  placeholder: "pointer-events-none text-zinc-400 dark:text-zinc-500",
8
- tag: "inline-flex max-w-full items-center gap-1 rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-200 dark:bg-blue-950/50 dark:text-blue-300 dark:ring-blue-800",
12
+ tag: "relative z-10 inline-flex max-w-full items-center gap-1 rounded-lg bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-200 transition-colors dark:bg-blue-950/50 dark:text-blue-300 dark:ring-blue-800",
9
13
  tagRemove: "rounded p-0.5 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:hover:bg-blue-900",
10
- clear: "ml-auto rounded p-1 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:text-zinc-500 dark:hover:bg-zinc-800 dark:hover:text-zinc-200 dark:focus:ring-blue-400",
11
- chevron: "ml-1 size-4 shrink-0 text-zinc-400 transition-transform",
14
+ clear: "relative z-20 ml-auto rounded p-1 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:text-zinc-500 dark:hover:bg-zinc-800 dark:hover:text-zinc-200 dark:focus:ring-blue-400",
15
+ chevron: "pointer-events-none relative z-10 ml-1 size-4 shrink-0 text-zinc-400 transition-transform",
12
16
  chevronOpen: "rotate-180",
13
- dropdown: "absolute z-50 mt-1.5 max-h-72 w-full overflow-auto rounded-lg border border-zinc-200 bg-white p-1.5 shadow-xl ring-1 ring-black/5 dark:border-zinc-700 dark:bg-zinc-900 dark:shadow-black/40 dark:ring-white/10",
14
- option: "flex min-h-11 cursor-pointer items-center justify-between gap-3 rounded-md px-3 py-2 text-sm text-zinc-700 outline-none transition-colors dark:text-zinc-200",
17
+ dropdown: "absolute z-50 mt-1.5 max-h-72 w-full origin-top overflow-auto rounded-xl border border-zinc-200 bg-white p-1.5 shadow-2xl ring-1 ring-black/5 dark:border-zinc-700 dark:bg-zinc-900 dark:shadow-black/50 dark:ring-white/10",
18
+ option: "flex min-h-11 cursor-pointer items-center justify-between gap-3 rounded-lg px-3 py-2 text-sm text-zinc-700 outline-none transition-colors duration-100 dark:text-zinc-200",
15
19
  optionContent: "flex min-w-0 flex-1 items-center gap-3",
16
20
  optionText: "flex min-w-0 flex-1 flex-col",
17
21
  optionLabel: "font-normal",
18
22
  optionDetail: "text-xs font-normal text-zinc-500 dark:text-zinc-400",
19
23
  optionImage: "size-9 shrink-0 rounded-full bg-zinc-100 object-cover dark:bg-zinc-800",
20
24
  optionMeta: "shrink-0 rounded bg-zinc-100 px-1.5 py-0.5 text-xs font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300",
21
- optionActive: "bg-zinc-100 text-zinc-950 dark:bg-zinc-800 dark:text-white",
22
- optionSelected: "bg-blue-50 text-blue-700 dark:bg-blue-950/60 dark:text-blue-300",
25
+ optionActive: "bg-blue-600 text-white shadow-sm [&_*]:text-white dark:bg-blue-500 dark:text-white",
26
+ optionSelected: "bg-blue-50 font-medium text-blue-700 dark:bg-blue-950/60 dark:text-blue-300",
23
27
  optionDisabled: "cursor-not-allowed opacity-50",
24
28
  message: "px-3 py-6 text-center text-sm text-zinc-500 dark:text-zinc-400",
25
29
  spinner: "size-4 animate-spin rounded-full border-2 border-zinc-300 border-t-blue-600",
@@ -34,10 +38,11 @@ const ICONS = {
34
38
  }
35
39
 
36
40
  const uid = () => `tam-select-${Math.random().toString(36).slice(2, 10)}`
37
- const normalize = value => String(value ?? "").normalize("NFKD").toLocaleLowerCase()
41
+ const normalize = value => String(value ?? "").normalize("NFKD").replace(/\p{Mark}/gu, "").toLocaleLowerCase().trim()
38
42
  const toggleClasses = (element, classNames, force) => {
39
43
  String(classNames).split(/\s+/).filter(Boolean).forEach(className => element.classList.toggle(className, force))
40
44
  }
45
+ const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key)
41
46
 
42
47
  export class TamSelect {
43
48
  static instances = new WeakMap()
@@ -62,6 +67,7 @@ export class TamSelect {
62
67
  createText: query => `Create “${query}”`,
63
68
  loadingText: "Loading…",
64
69
  loadMoreText: "Load more",
70
+ inputTooShortText: remaining => `Enter ${remaining} more character${remaining === 1 ? "" : "s"}`,
65
71
  remoteUrl: null,
66
72
  queryParam: "q",
67
73
  pageParam: "page",
@@ -72,23 +78,35 @@ export class TamSelect {
72
78
  imageField: "image",
73
79
  itemsPath: "items",
74
80
  paginationPath: "pagination",
81
+ matcher: null,
75
82
  classes: {},
76
83
  headers: {},
77
84
  ...options
78
85
  }
79
86
  this.classes = { ...DEFAULT_CLASSES, ...this.options.classes }
80
87
  this.multiple = select.multiple
88
+ this.searchable = Boolean(this.options.searchable || this.options.creatable)
81
89
  this.items = []
82
- this.filtered = []
90
+ this.remoteResults = []
91
+ this.visibleItems = []
83
92
  this.activeIndex = -1
84
93
  this.opened = false
85
94
  this.loading = false
95
+ this.loadingPage = null
86
96
  this.error = null
87
97
  this.query = ""
88
- this.page = 1
98
+ this.page = 0
99
+ this.nextPage = null
89
100
  this.hasMore = false
90
- this.abortController = null
101
+ this.requestSequence = 0
102
+ this.activeRequest = null
103
+ this.optionIds = new WeakMap()
104
+ this.optionIdSequence = 0
105
+ this.invalidFromEvent = false
91
106
  this.originalTabIndex = select.getAttribute("tabindex")
107
+ this.originalAriaHidden = select.getAttribute("aria-hidden")
108
+ this.originalId = select.getAttribute("id")
109
+ this.hadSrOnlyClass = select.classList.contains("sr-only")
92
110
  this.build()
93
111
  this.bind()
94
112
  this.readNativeOptions()
@@ -100,34 +118,49 @@ export class TamSelect {
100
118
  this.id = this.select.id || uid()
101
119
  if (!this.select.id) this.select.id = this.id
102
120
  this.listboxId = `${this.id}-listbox`
121
+ this.valuesId = `${this.id}-value`
103
122
  this.select.classList.add("sr-only")
104
123
  this.select.tabIndex = -1
124
+ this.select.setAttribute("aria-hidden", "true")
105
125
 
106
126
  this.wrapper = document.createElement("div")
107
127
  this.wrapper.className = this.classes.wrapper
108
128
  this.wrapper.dataset.tamSelectRoot = ""
129
+
109
130
  this.control = document.createElement("div")
110
131
  this.control.className = this.classes.control
111
- this.control.setAttribute("role", "combobox")
112
- this.control.setAttribute("aria-haspopup", "listbox")
113
- this.control.setAttribute("aria-expanded", "false")
114
- this.control.setAttribute("aria-controls", this.listboxId)
115
- this.control.tabIndex = this.select.disabled ? -1 : 0
116
132
 
117
133
  this.values = document.createElement("div")
134
+ this.values.id = this.valuesId
118
135
  this.values.className = "contents"
136
+
119
137
  this.searchIcon = document.createElement("span")
120
138
  this.searchIcon.className = this.classes.searchIcon
121
139
  this.searchIcon.innerHTML = ICONS.search
122
- this.input = document.createElement("input")
123
- this.input.type = "text"
124
- this.input.className = this.classes.input
125
- this.input.placeholder = this.options.searchable ? this.options.searchPlaceholder : ""
126
- this.input.autocomplete = "off"
127
- this.input.spellcheck = false
128
- this.input.setAttribute("aria-autocomplete", "list")
129
- this.input.setAttribute("aria-controls", this.listboxId)
130
- if (!this.options.searchable) this.input.readOnly = true
140
+
141
+ if (this.searchable) {
142
+ this.input = document.createElement("input")
143
+ this.input.id = `${this.id}-search`
144
+ this.input.type = "text"
145
+ this.input.className = this.classes.input
146
+ this.input.autocomplete = "off"
147
+ this.input.spellcheck = false
148
+ this.input.setAttribute("role", "combobox")
149
+ this.input.setAttribute("aria-haspopup", "listbox")
150
+ this.input.setAttribute("aria-expanded", "false")
151
+ this.input.setAttribute("aria-controls", this.listboxId)
152
+ this.input.setAttribute("aria-autocomplete", "list")
153
+ this.focusTarget = this.input
154
+ } else {
155
+ this.trigger = document.createElement("button")
156
+ this.trigger.type = "button"
157
+ this.trigger.className = this.classes.trigger
158
+ this.trigger.setAttribute("role", "combobox")
159
+ this.trigger.setAttribute("aria-haspopup", "listbox")
160
+ this.trigger.setAttribute("aria-expanded", "false")
161
+ this.trigger.setAttribute("aria-controls", this.listboxId)
162
+ this.focusTarget = this.trigger
163
+ }
131
164
 
132
165
  this.clearButton = document.createElement("button")
133
166
  this.clearButton.type = "button"
@@ -138,85 +171,183 @@ export class TamSelect {
138
171
  this.chevron = document.createElement("span")
139
172
  this.chevron.className = this.classes.chevron
140
173
  this.chevron.innerHTML = ICONS.chevron
174
+
141
175
  this.dropdown = document.createElement("div")
142
176
  this.dropdown.id = this.listboxId
143
177
  this.dropdown.className = `${this.classes.dropdown} hidden`
144
178
  this.dropdown.setAttribute("role", "listbox")
145
179
  if (this.multiple) this.dropdown.setAttribute("aria-multiselectable", "true")
146
180
 
147
- this.control.append(this.values, this.searchIcon, this.input, this.clearButton, this.chevron)
181
+ const focusControl = this.searchable ? [this.searchIcon, this.input] : [this.trigger]
182
+ this.control.append(this.values, ...focusControl, this.clearButton, this.chevron)
148
183
  this.wrapper.append(this.control, this.dropdown)
149
184
  this.select.after(this.wrapper)
185
+ this.labelElements = this.findLabelElements()
150
186
  this.applyDisabled()
187
+ this.syncAria()
151
188
  }
152
189
 
153
190
  bind() {
154
191
  this.onControlClick = event => {
155
- if (this.select.disabled || event.target.closest("button")) return
192
+ if (!this.searchable || this.select.disabled || event.target.closest("button")) return
156
193
  this.open()
157
194
  this.input.focus()
158
195
  }
159
196
  this.onInput = () => {
160
197
  this.query = this.input.value
161
- this.page = 1
162
- this.open()
198
+ this.open(false)
163
199
  if (this.options.remoteUrl) this.scheduleRemote()
164
200
  else this.filterLocal()
165
201
  }
166
202
  this.onKeydown = event => this.handleKeydown(event)
167
203
  this.onClear = event => { event.stopPropagation(); this.clear() }
204
+ this.onTrigger = event => { event.preventDefault(); this.open(); this.trigger.focus() }
168
205
  this.onOutside = event => { if (!this.wrapper.contains(event.target)) this.close() }
169
- this.onNativeChange = () => { this.readNativeOptions(); this.renderSelection(); this.renderDropdown() }
206
+ this.onNativeChange = () => {
207
+ if (this.select.checkValidity()) this.invalidFromEvent = false
208
+ this.readNativeOptions()
209
+ this.renderSelection()
210
+ this.syncAria()
211
+ this.renderDropdown()
212
+ }
213
+ this.onNativeInvalid = event => {
214
+ event.preventDefault()
215
+ this.invalidFromEvent = true
216
+ this.syncAria()
217
+ this.focusTarget.focus()
218
+ }
170
219
  this.onScroll = () => {
171
220
  if (!this.options.remoteUrl || !this.hasMore || this.loading) return
172
- if (this.dropdown.scrollTop + this.dropdown.clientHeight >= this.dropdown.scrollHeight - 32) this.loadRemote(this.page + 1, true)
221
+ if (this.dropdown.scrollTop + this.dropdown.clientHeight >= this.dropdown.scrollHeight - 32) {
222
+ this.loadRemote(this.nextPage || this.page + 1, true)
223
+ }
224
+ }
225
+ this.onLabelClick = event => {
226
+ if (event.target.closest("a, button, input, select, textarea")) return
227
+ event.preventDefault()
228
+ this.focusTarget.focus()
173
229
  }
230
+
174
231
  this.control.addEventListener("click", this.onControlClick)
175
- this.input.addEventListener("input", this.onInput)
176
- this.control.addEventListener("keydown", this.onKeydown)
232
+ this.focusTarget.addEventListener("keydown", this.onKeydown)
233
+ if (this.input) this.input.addEventListener("input", this.onInput)
234
+ if (this.trigger) this.trigger.addEventListener("click", this.onTrigger)
177
235
  this.clearButton.addEventListener("click", this.onClear)
178
236
  this.dropdown.addEventListener("scroll", this.onScroll)
179
237
  this.select.addEventListener("change", this.onNativeChange)
238
+ this.select.addEventListener("invalid", this.onNativeInvalid)
239
+ this.labelElements.forEach(label => label.addEventListener("click", this.onLabelClick))
180
240
  document.addEventListener("pointerdown", this.onOutside)
181
241
  }
182
242
 
243
+ findLabelElements() {
244
+ const labels = Array.from(document.querySelectorAll("label")).filter(label => label.htmlFor === this.id || label.contains(this.select))
245
+ return [...new Set(labels)]
246
+ }
247
+
248
+ labelledBy() {
249
+ const nativeLabelledBy = this.select.getAttribute("aria-labelledby")
250
+ if (nativeLabelledBy) return nativeLabelledBy
251
+ if (!this.labelElements.length) return null
252
+ return this.labelElements.map(label => {
253
+ if (!label.id) label.id = uid()
254
+ return label.id
255
+ }).join(" ")
256
+ }
257
+
183
258
  readNativeOptions() {
259
+ const previousItems = new Map(this.items.map(item => [item.option, item]))
184
260
  this.items = Array.from(this.select.options)
185
- .filter(option => option.value !== "" || option.selected)
186
- .map(option => ({
187
- value: option.value,
188
- label: option.textContent.trim(),
189
- detail: option.dataset.detail || null,
190
- meta: option.dataset.meta || null,
191
- image: option.dataset.image || null,
192
- disabled: option.disabled,
193
- selected: option.selected,
194
- option
195
- }))
196
- this.filterLocal(false)
197
- }
198
-
199
- selectedItems() { return this.items.filter(item => item.option?.selected || item.selected) }
261
+ .filter(option => option.value !== "")
262
+ .map(option => {
263
+ const item = previousItems.get(option) || { option, id: this.optionId(option) }
264
+ Object.assign(item, {
265
+ value: option.value,
266
+ label: option.textContent.trim(),
267
+ detail: option.dataset.detail || null,
268
+ meta: option.dataset.meta || null,
269
+ image: option.dataset.image || null,
270
+ disabled: option.disabled,
271
+ selected: option.selected,
272
+ option
273
+ })
274
+ return item
275
+ })
276
+
277
+ if (this.options.remoteUrl) {
278
+ const currentOptions = new Set(this.items.map(item => item.option))
279
+ this.remoteResults = this.remoteResults.filter(item => currentOptions.has(item.option))
280
+ this.updateVisibleItems(this.remoteResults)
281
+ } else {
282
+ this.filterLocal(false)
283
+ }
284
+ }
285
+
286
+ selectedItems() {
287
+ return this.items.filter(item => item.option?.selected || item.selected)
288
+ }
289
+
290
+ matchesLocalItem(item) {
291
+ if (typeof this.options.matcher === "function") return Boolean(this.options.matcher(item, this.query))
292
+ const terms = normalize(this.query).split(/\s+/).filter(Boolean)
293
+ if (!terms.length) return true
294
+ const searchableText = normalize([item.label, item.detail, item.meta].filter(Boolean).join(" "))
295
+ return terms.every(term => searchableText.includes(term))
296
+ }
200
297
 
201
298
  filterLocal(render = true) {
202
- const needle = normalize(this.query)
203
- this.filtered = this.items.filter(item => !needle || [item.label, item.detail, item.meta].some(value => normalize(value).includes(needle)))
204
- this.activeIndex = this.filtered.findIndex(item => !item.disabled)
299
+ this.updateVisibleItems(this.items.filter(item => this.matchesLocalItem(item)))
205
300
  if (render) this.renderDropdown()
206
301
  }
207
302
 
303
+ updateVisibleItems(candidateItems, preserveActive = true) {
304
+ const previousId = preserveActive ? this.visibleItems[this.activeIndex]?.id : null
305
+ const entries = candidateItems
306
+ .filter(item => this.multiple || !item.selected)
307
+ .map(item => ({ type: "item", id: item.id, item, disabled: Boolean(item.disabled) }))
308
+
309
+ const createEntry = this.createEntry()
310
+ if (createEntry) entries.push(createEntry)
311
+
312
+ this.visibleItems = entries
313
+ const preservedIndex = previousId ? entries.findIndex(entry => entry.id === previousId && !entry.disabled) : -1
314
+ this.activeIndex = preservedIndex >= 0 ? preservedIndex : entries.findIndex(entry => !entry.disabled)
315
+ }
316
+
317
+ createEntry() {
318
+ const label = this.query.trim()
319
+ if (!this.options.creatable || !label) return null
320
+ const value = typeof this.options.createValue === "function" ? this.options.createValue(label) : label
321
+ const normalizedValue = normalize(value)
322
+ const normalizedLabel = normalize(label)
323
+ const duplicate = this.items.some(item => normalize(item.value) === normalizedValue || normalize(item.label) === normalizedLabel)
324
+ if (!normalizedValue || duplicate) return null
325
+ return { type: "create", id: `${this.listboxId}-create`, label, value: String(value), disabled: false }
326
+ }
327
+
208
328
  renderSelection() {
209
329
  const selected = this.selectedItems()
210
330
  this.values.replaceChildren()
211
- if (this.multiple) selected.forEach(item => this.values.append(this.makeTag(item)))
212
- else if (selected.length) {
331
+ if (this.multiple) {
332
+ selected.forEach(item => this.values.append(this.makeTag(item)))
333
+ } else if (selected.length) {
213
334
  this.values.append(this.makeItemContent(selected[0], true))
335
+ } else {
336
+ const placeholder = document.createElement("span")
337
+ placeholder.className = this.classes.placeholder
338
+ placeholder.textContent = this.options.placeholder
339
+ this.values.append(placeholder)
214
340
  }
341
+
215
342
  const hasValue = selected.length > 0
216
- this.searchIcon.classList.toggle("hidden", !this.options.searchable || (hasValue && !this.opened))
217
- this.input.classList.toggle("hidden", !this.multiple && hasValue && !this.opened)
218
- this.input.placeholder = hasValue ? "" : this.options.placeholder
343
+ this.values.classList.toggle("hidden", !this.multiple && this.opened)
344
+ this.searchIcon.classList.toggle("hidden", !this.searchable || (!this.multiple && !this.opened))
345
+ if (this.input) {
346
+ toggleClasses(this.input, this.classes.inputClosed, !this.multiple && !this.opened)
347
+ this.input.placeholder = this.multiple && !hasValue ? this.options.placeholder : this.options.searchPlaceholder
348
+ }
219
349
  this.clearButton.classList.toggle("hidden", !this.options.clearable || !hasValue || this.select.disabled)
350
+ this.syncAria()
220
351
  }
221
352
 
222
353
  makeTag(item) {
@@ -228,6 +359,7 @@ export class TamSelect {
228
359
  const remove = document.createElement("button")
229
360
  remove.type = "button"
230
361
  remove.className = this.classes.tagRemove
362
+ remove.disabled = this.select.disabled
231
363
  remove.setAttribute("aria-label", `Remove ${item.label}`)
232
364
  remove.innerHTML = ICONS.close
233
365
  remove.addEventListener("click", event => { event.stopPropagation(); this.deselect(item.value) })
@@ -238,49 +370,69 @@ export class TamSelect {
238
370
  renderDropdown() {
239
371
  if (!this.opened) return
240
372
  this.dropdown.replaceChildren()
373
+ this.dropdown.setAttribute("aria-busy", String(this.loading))
374
+
375
+ const remaining = Math.max(0, this.options.minQueryLength - this.query.trim().length)
376
+ if (this.options.remoteUrl && remaining > 0) {
377
+ const text = typeof this.options.inputTooShortText === "function" ? this.options.inputTooShortText(remaining) : this.options.inputTooShortText
378
+ this.renderMessage(text)
379
+ return this.syncActiveDescendant()
380
+ }
241
381
  if (this.error) {
242
382
  const error = document.createElement("div")
243
383
  error.className = this.classes.error
244
384
  error.setAttribute("role", "alert")
245
385
  error.textContent = this.error
246
386
  this.dropdown.append(error)
247
- return
387
+ return this.syncActiveDescendant()
248
388
  }
249
- if (this.loading && this.page === 1) return this.renderMessage(this.options.loadingText, true)
250
-
251
- const visible = this.multiple ? this.filtered : this.filtered.filter(item => !item.selected)
252
- visible.forEach((item, index) => this.dropdown.append(this.makeOption(item, index)))
253
- if (this.options.creatable && this.query.trim() && !this.items.some(item => normalize(item.label) === normalize(this.query))) {
254
- this.dropdown.append(this.makeCreateOption())
389
+ if (this.loading && this.loadingPage === 1) {
390
+ this.renderMessage(this.options.loadingText, true)
391
+ return this.syncActiveDescendant()
255
392
  }
256
- if (!this.dropdown.children.length) this.renderMessage(this.options.noResultsText)
257
- if (this.loading && this.page > 1) this.renderMessage(this.options.loadingText, true)
393
+
394
+ this.visibleItems.forEach((entry, index) => this.dropdown.append(this.makeOption(entry, index)))
395
+ if (!this.visibleItems.length) this.renderMessage(this.options.noResultsText)
396
+ if (this.loading && this.loadingPage > 1) this.renderMessage(this.options.loadingText, true)
258
397
  else if (this.hasMore) this.renderMessage(this.options.loadMoreText)
398
+ this.syncActiveDescendant()
259
399
  }
260
400
 
261
- makeOption(item, index) {
401
+ makeOption(entry, index) {
262
402
  const option = document.createElement("div")
263
- option.id = `${this.listboxId}-option-${index}`
264
- option.dataset.value = item.value
265
- option.className = [this.classes.option, index === this.activeIndex && this.classes.optionActive, item.selected && this.classes.optionSelected, item.disabled && this.classes.optionDisabled].filter(Boolean).join(" ")
403
+ option.id = entry.id
404
+ option.dataset.tamSelectEntry = String(index)
405
+ option.className = [
406
+ this.classes.option,
407
+ index === this.activeIndex && this.classes.optionActive,
408
+ index !== this.activeIndex && entry.type === "item" && entry.item.selected && this.classes.optionSelected,
409
+ entry.disabled && this.classes.optionDisabled
410
+ ].filter(Boolean).join(" ")
266
411
  option.setAttribute("role", "option")
267
- option.setAttribute("aria-selected", String(Boolean(item.selected)))
268
- option.setAttribute("aria-disabled", String(Boolean(item.disabled)))
269
- const content = this.makeItemContent(item)
270
- option.append(content)
271
- if (item.meta) {
272
- const meta = document.createElement("span")
273
- meta.className = this.classes.optionMeta
274
- meta.textContent = item.meta
275
- option.append(meta)
276
- }
277
- if (item.selected) {
278
- const check = document.createElement("span")
279
- check.innerHTML = ICONS.check
280
- option.append(check)
412
+ option.setAttribute("aria-selected", String(Boolean(entry.type === "item" && entry.item.selected)))
413
+ option.setAttribute("aria-disabled", String(Boolean(entry.disabled)))
414
+
415
+ if (entry.type === "create") {
416
+ option.textContent = typeof this.options.createText === "function" ? this.options.createText(entry.label) : this.options.createText
417
+ } else {
418
+ const item = entry.item
419
+ option.dataset.value = item.value
420
+ option.append(this.makeItemContent(item))
421
+ if (item.meta) {
422
+ const meta = document.createElement("span")
423
+ meta.className = this.classes.optionMeta
424
+ meta.textContent = item.meta
425
+ option.append(meta)
426
+ }
427
+ if (item.selected) {
428
+ const check = document.createElement("span")
429
+ check.innerHTML = ICONS.check
430
+ option.append(check)
431
+ }
281
432
  }
282
- option.addEventListener("pointermove", () => { this.activeIndex = index; this.updateActiveOption() })
283
- option.addEventListener("click", () => { if (!item.disabled) this.toggleItem(item) })
433
+
434
+ option.addEventListener("pointermove", () => { if (!entry.disabled) { this.activeIndex = index; this.updateActiveOption() } })
435
+ option.addEventListener("click", () => this.activateEntry(entry))
284
436
  return option
285
437
  }
286
438
 
@@ -312,18 +464,10 @@ export class TamSelect {
312
464
  return content
313
465
  }
314
466
 
315
- makeCreateOption() {
316
- const option = document.createElement("div")
317
- option.className = this.classes.option
318
- option.setAttribute("role", "option")
319
- option.textContent = typeof this.options.createText === "function" ? this.options.createText(this.query.trim()) : this.options.createText
320
- option.addEventListener("click", () => this.createItem(this.query.trim()))
321
- return option
322
- }
323
-
324
467
  renderMessage(text, spinner = false) {
325
468
  const message = document.createElement("div")
326
469
  message.className = this.classes.message
470
+ message.setAttribute("role", "status")
327
471
  if (spinner) {
328
472
  const row = document.createElement("span")
329
473
  row.className = "inline-flex items-center gap-2"
@@ -331,61 +475,101 @@ export class TamSelect {
331
475
  icon.className = this.classes.spinner
332
476
  row.append(icon, document.createTextNode(text))
333
477
  message.append(row)
334
- } else message.textContent = text
478
+ } else {
479
+ message.textContent = text
480
+ }
335
481
  this.dropdown.append(message)
336
482
  }
337
483
 
338
484
  handleKeydown(event) {
339
- if (this.select.disabled) return
485
+ if (this.select.disabled || event.target.closest("button") && event.target !== this.trigger) return
486
+
340
487
  if (["ArrowDown", "ArrowUp"].includes(event.key)) {
341
488
  event.preventDefault()
489
+ const wasOpen = this.opened
342
490
  this.open()
343
- const direction = event.key === "ArrowDown" ? 1 : -1
344
- this.moveActive(direction)
491
+ if (wasOpen || event.key === "ArrowUp") this.moveActive(event.key === "ArrowDown" ? 1 : -1)
492
+ else this.updateActiveOption()
345
493
  } else if (event.key === "Enter") {
494
+ event.preventDefault()
346
495
  if (!this.opened) return this.open()
496
+ this.activateEntry(this.visibleItems[this.activeIndex])
497
+ } else if (event.key === " " && this.trigger) {
498
+ event.preventDefault()
499
+ this.open()
500
+ } else if (event.key === "Escape" && this.opened) {
347
501
  event.preventDefault()
348
- const item = this.filtered[this.activeIndex]
349
- if (item && !item.disabled) this.toggleItem(item)
350
- else if (this.options.creatable && this.query.trim()) this.createItem(this.query.trim())
351
- } else if (event.key === "Escape") {
352
- event.preventDefault(); this.close(); this.control.focus()
353
- } else if (event.key === "Backspace" && this.multiple && !this.input.value) {
502
+ this.close()
503
+ this.focusTarget.focus()
504
+ } else if (event.key === "Backspace" && this.multiple && this.input && !this.input.value) {
354
505
  const last = this.selectedItems().at(-1)
355
506
  if (last) this.deselect(last.value)
356
- } else if (event.key === "Tab") this.close()
507
+ } else if (event.key === "Tab") {
508
+ this.close()
509
+ } else if (this.input && !this.opened && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
510
+ this.open(false)
511
+ }
357
512
  }
358
513
 
359
514
  moveActive(direction) {
360
- if (!this.filtered.length) return
515
+ if (!this.visibleItems.length) return
361
516
  let next = this.activeIndex
362
- for (let count = 0; count < this.filtered.length; count += 1) {
363
- next = (next + direction + this.filtered.length) % this.filtered.length
364
- if (!this.filtered[next].disabled) break
517
+ for (let count = 0; count < this.visibleItems.length; count += 1) {
518
+ next = (next + direction + this.visibleItems.length) % this.visibleItems.length
519
+ if (!this.visibleItems[next].disabled) {
520
+ this.activeIndex = next
521
+ return this.updateActiveOption()
522
+ }
365
523
  }
366
- this.activeIndex = next
367
- this.updateActiveOption()
368
524
  }
369
525
 
370
526
  updateActiveOption() {
371
- this.dropdown.querySelectorAll('[role="option"]').forEach((option, index) => toggleClasses(option, this.classes.optionActive, index === this.activeIndex))
372
- const active = this.dropdown.querySelector(`#${CSS.escape(`${this.listboxId}-option-${this.activeIndex}`)}`)
373
- if (active) { this.input.setAttribute("aria-activedescendant", active.id); active.scrollIntoView({ block: "nearest" }) }
527
+ this.dropdown.querySelectorAll("[data-tam-select-entry]").forEach(option => {
528
+ const index = Number(option.dataset.tamSelectEntry)
529
+ toggleClasses(option, this.classes.optionActive, index === this.activeIndex)
530
+ })
531
+ this.syncActiveDescendant(true)
532
+ }
533
+
534
+ syncActiveDescendant(scroll = false) {
535
+ const entry = this.visibleItems[this.activeIndex]
536
+ const active = entry && this.dropdown.querySelector(`[data-tam-select-entry="${this.activeIndex}"]`)
537
+ if (this.opened && active && !entry.disabled) {
538
+ this.focusTarget.setAttribute("aria-activedescendant", entry.id)
539
+ if (scroll) active.scrollIntoView({ block: "nearest" })
540
+ } else {
541
+ this.focusTarget.removeAttribute("aria-activedescendant")
542
+ }
543
+ }
544
+
545
+ activateEntry(entry) {
546
+ if (!entry || entry.disabled) return
547
+ if (entry.type === "create") this.createItem(entry.label, entry.value)
548
+ else this.toggleItem(entry.item)
374
549
  }
375
550
 
376
551
  toggleItem(item) {
552
+ if (item.disabled) return
377
553
  if (this.multiple && item.selected) this.deselect(item.value)
378
554
  else this.selectValue(item.value)
379
555
  }
380
556
 
381
557
  selectValue(value) {
382
558
  const item = this.ensureNativeOption(value)
559
+ if (item.disabled) return
383
560
  if (!this.multiple) Array.from(this.select.options).forEach(option => { option.selected = false })
384
561
  item.option.selected = true
385
562
  item.selected = true
386
563
  this.commit()
387
564
  if (this.options.closeAfterSelect) this.close()
388
- else { this.input.value = ""; this.query = ""; this.filterLocal() }
565
+ else this.resetQueryAfterSelection()
566
+ }
567
+
568
+ resetQueryAfterSelection() {
569
+ if (this.input) this.input.value = ""
570
+ this.query = ""
571
+ if (this.options.remoteUrl) this.scheduleRemote()
572
+ else this.filterLocal()
389
573
  }
390
574
 
391
575
  deselect(value) {
@@ -402,147 +586,338 @@ export class TamSelect {
402
586
  this.commit()
403
587
  }
404
588
 
405
- createItem(label) {
406
- const value = typeof this.options.createValue === "function" ? this.options.createValue(label) : label
589
+ createItem(label, candidateValue = undefined) {
590
+ const value = candidateValue ?? (typeof this.options.createValue === "function" ? this.options.createValue(label) : label)
591
+ const existing = this.items.find(item => normalize(item.value) === normalize(value) || normalize(item.label) === normalize(label))
592
+ if (existing) {
593
+ this.selectValue(existing.value)
594
+ return existing
595
+ }
407
596
  const item = this.addItem({ value, label, selected: false, created: true })
408
597
  this.selectValue(item.value)
409
598
  this.emit("tam-select:create", { item })
599
+ return item
410
600
  }
411
601
 
412
602
  addItem(raw) {
413
- const value = String(raw.value ?? raw[this.options.valueField])
603
+ const rawValue = raw.value ?? raw[this.options.valueField]
604
+ const value = String(rawValue ?? "")
414
605
  const existing = this.items.find(item => String(item.value) === value)
606
+ const label = String(raw.label ?? raw[this.options.labelField] ?? value)
607
+ const image = raw.image ?? raw[this.options.imageField]
415
608
  if (existing) {
416
- const image = raw.image ?? raw[this.options.imageField]
417
- Object.assign(existing, raw, {
418
- detail: raw.detail ?? existing.detail,
419
- meta: raw.meta ?? existing.meta,
420
- image: image ?? existing.image
421
- })
422
- if (existing.option) {
423
- if (existing.detail != null) existing.option.dataset.detail = String(existing.detail)
424
- if (existing.meta != null) existing.option.dataset.meta = String(existing.meta)
425
- if (existing.image != null) existing.option.dataset.image = String(existing.image)
426
- }
609
+ existing.value = value
610
+ existing.label = label
611
+ existing.detail = hasOwn(raw, "detail") ? raw.detail : existing.detail
612
+ existing.meta = hasOwn(raw, "meta") ? raw.meta : existing.meta
613
+ existing.image = image ?? existing.image
614
+ if (hasOwn(raw, "disabled")) existing.disabled = Boolean(raw.disabled)
615
+ this.syncNativeOption(existing)
427
616
  return existing
428
617
  }
429
- const label = String(raw.label ?? raw[this.options.labelField] ?? value)
618
+
430
619
  const option = new Option(label, value, Boolean(raw.selected), Boolean(raw.selected))
431
620
  option.dataset.tamSelectGenerated = ""
432
- if (raw.detail != null) option.dataset.detail = String(raw.detail)
433
- if (raw.meta != null) option.dataset.meta = String(raw.meta)
434
- const image = raw.image ?? raw[this.options.imageField]
435
- if (image != null) option.dataset.image = String(image)
436
621
  this.select.add(option)
437
- const item = { ...raw, value, label, image: image == null ? null : String(image), option, selected: option.selected, disabled: Boolean(raw.disabled) }
438
- option.disabled = item.disabled
622
+ const item = {
623
+ ...raw,
624
+ id: this.optionId(option),
625
+ value,
626
+ label,
627
+ detail: raw.detail ?? null,
628
+ meta: raw.meta ?? null,
629
+ image: image == null ? null : String(image),
630
+ option,
631
+ selected: option.selected,
632
+ disabled: Boolean(raw.disabled)
633
+ }
634
+ this.syncNativeOption(item)
439
635
  this.items.push(item)
440
- this.filterLocal(false)
441
636
  return item
442
637
  }
443
638
 
639
+ upsertRemoteItem(raw) {
640
+ const value = String(raw?.[this.options.valueField] ?? raw?.value ?? "")
641
+ const label = String(raw?.[this.options.labelField] ?? raw?.label ?? value)
642
+ const image = raw?.[this.options.imageField] ?? raw?.image ?? null
643
+ const item = this.addItem({
644
+ value,
645
+ label,
646
+ detail: raw?.detail ?? null,
647
+ meta: raw?.meta ?? null,
648
+ image,
649
+ disabled: Boolean(raw?.disabled)
650
+ })
651
+ item.detail = raw?.detail ?? null
652
+ item.meta = raw?.meta ?? null
653
+ item.image = image == null ? null : String(image)
654
+ item.disabled = Boolean(raw?.disabled)
655
+ this.syncNativeOption(item)
656
+ return item
657
+ }
658
+
659
+ syncNativeOption(item) {
660
+ if (!item.option) return
661
+ item.option.value = item.value
662
+ item.option.textContent = item.label
663
+ item.option.disabled = Boolean(item.disabled)
664
+ this.setDataAttribute(item.option, "detail", item.detail)
665
+ this.setDataAttribute(item.option, "meta", item.meta)
666
+ this.setDataAttribute(item.option, "image", item.image)
667
+ }
668
+
669
+ setDataAttribute(option, name, value) {
670
+ if (value == null || value === "") delete option.dataset[name]
671
+ else option.dataset[name] = String(value)
672
+ }
673
+
674
+ optionId(option) {
675
+ if (!this.optionIds.has(option)) {
676
+ this.optionIdSequence += 1
677
+ this.optionIds.set(option, `${this.listboxId}-option-${this.optionIdSequence}`)
678
+ }
679
+ return this.optionIds.get(option)
680
+ }
681
+
444
682
  ensureNativeOption(value) {
445
683
  return this.items.find(item => String(item.value) === String(value)) || this.addItem({ value, label: value })
446
684
  }
447
685
 
448
686
  commit() {
687
+ if (this.select.checkValidity()) this.invalidFromEvent = false
688
+ this.readNativeOptions()
449
689
  this.renderSelection()
450
690
  this.renderDropdown()
691
+ this.syncAria()
451
692
  this.select.dispatchEvent(new Event("change", { bubbles: true }))
452
693
  this.emit("tam-select:change", { value: this.value, items: this.selectedItems() })
453
694
  }
454
695
 
455
- get value() { return this.multiple ? this.selectedItems().map(item => item.value) : this.selectedItems()[0]?.value ?? "" }
696
+ get value() {
697
+ return this.multiple ? this.selectedItems().map(item => item.value) : this.selectedItems()[0]?.value ?? ""
698
+ }
456
699
 
457
700
  setValue(value) {
458
- const values = new Set((Array.isArray(value) ? value : [value]).map(String))
459
- this.items.forEach(item => { item.selected = values.has(String(item.value)); if (item.option) item.option.selected = item.selected })
701
+ const rawValues = value == null ? [] : (Array.isArray(value) ? value : [value])
702
+ const values = new Set(rawValues.map(String))
703
+ this.items.forEach(item => {
704
+ item.selected = values.has(String(item.value))
705
+ if (item.option) item.option.selected = item.selected
706
+ })
460
707
  this.commit()
461
708
  }
462
709
 
463
- open() {
710
+ open(loadRemote = true) {
464
711
  if (this.opened || this.select.disabled) return
465
712
  this.opened = true
466
713
  this.dropdown.classList.remove("hidden")
467
- this.control.setAttribute("aria-expanded", "true")
468
- this.chevron.classList.add(...this.classes.chevronOpen.split(" "))
714
+ this.focusTarget.setAttribute("aria-expanded", "true")
715
+ toggleClasses(this.control, this.classes.controlOpen, true)
716
+ toggleClasses(this.chevron, this.classes.chevronOpen, true)
469
717
  this.renderSelection()
470
- if (this.options.remoteUrl && !this.filtered.length) this.loadRemote(1)
718
+ if (this.options.remoteUrl && loadRemote) this.startRemoteSearch(false)
471
719
  else this.renderDropdown()
720
+ this.focusTarget.focus({ preventScroll: true })
472
721
  this.emit("tam-select:open")
473
722
  }
474
723
 
475
724
  close() {
476
725
  if (!this.opened) return
477
726
  this.opened = false
727
+ this.cancelRemoteWork()
478
728
  this.dropdown.classList.add("hidden")
479
- this.control.setAttribute("aria-expanded", "false")
480
- this.input.removeAttribute("aria-activedescendant")
481
- this.chevron.classList.remove(...this.classes.chevronOpen.split(" "))
482
- this.input.value = ""
729
+ this.focusTarget.setAttribute("aria-expanded", "false")
730
+ toggleClasses(this.control, this.classes.controlOpen, false)
731
+ this.focusTarget.removeAttribute("aria-activedescendant")
732
+ toggleClasses(this.chevron, this.classes.chevronOpen, false)
733
+ if (this.input) this.input.value = ""
483
734
  this.query = ""
484
- this.filterLocal(false)
735
+ this.loading = false
736
+ this.loadingPage = null
737
+ this.error = null
738
+ this.hasMore = false
739
+ this.nextPage = null
740
+ if (this.options.remoteUrl) {
741
+ this.remoteResults = []
742
+ this.updateVisibleItems([])
743
+ } else {
744
+ this.filterLocal(false)
745
+ }
485
746
  this.renderSelection()
486
747
  this.emit("tam-select:close")
487
748
  }
488
749
 
489
750
  scheduleRemote() {
751
+ this.startRemoteSearch(true)
752
+ }
753
+
754
+ startRemoteSearch(debounced) {
490
755
  clearTimeout(this.remoteTimer)
491
- if (this.query.length < this.options.minQueryLength) { this.filtered = []; return this.renderDropdown() }
492
- this.remoteTimer = setTimeout(() => this.loadRemote(1), this.options.debounce)
756
+ this.cancelActiveRequest()
757
+ this.loading = false
758
+ this.loadingPage = null
759
+ this.error = null
760
+ this.page = 0
761
+ this.nextPage = null
762
+ this.hasMore = false
763
+ this.remoteResults = []
764
+ this.updateVisibleItems([])
765
+
766
+ if (this.query.trim().length < this.options.minQueryLength) return this.renderDropdown()
767
+ if (!debounced || this.options.debounce <= 0) return this.loadRemote(1, false)
768
+
769
+ this.loading = true
770
+ this.loadingPage = 1
771
+ this.renderDropdown()
772
+ this.remoteTimer = setTimeout(() => {
773
+ this.remoteTimer = null
774
+ this.loadRemote(1, false)
775
+ }, this.options.debounce)
776
+ }
777
+
778
+ cancelActiveRequest() {
779
+ if (!this.activeRequest) return
780
+ this.activeRequest.controller.abort()
781
+ this.activeRequest = null
782
+ }
783
+
784
+ cancelRemoteWork() {
785
+ clearTimeout(this.remoteTimer)
786
+ this.remoteTimer = null
787
+ this.cancelActiveRequest()
493
788
  }
494
789
 
495
790
  async loadRemote(page = 1, append = false) {
496
- if (!append) this.abortController?.abort()
497
- this.abortController = new AbortController()
791
+ if (append && (this.loading || !this.hasMore || page <= this.page)) return
792
+ if (this.query.trim().length < this.options.minQueryLength) return
793
+
794
+ clearTimeout(this.remoteTimer)
795
+ this.remoteTimer = null
796
+ if (!append) {
797
+ this.cancelActiveRequest()
798
+ this.remoteResults = []
799
+ this.updateVisibleItems([])
800
+ }
801
+
802
+ const requestId = ++this.requestSequence
803
+ const controller = new AbortController()
804
+ const request = { id: requestId, controller, page, query: this.query }
805
+ this.activeRequest = request
498
806
  this.loading = true
807
+ this.loadingPage = page
499
808
  this.error = null
500
- this.page = page
501
809
  this.renderDropdown()
810
+
502
811
  try {
503
812
  const url = new URL(this.options.remoteUrl, window.location.origin)
504
- url.searchParams.set(this.options.queryParam, this.query)
813
+ url.searchParams.set(this.options.queryParam, request.query)
505
814
  url.searchParams.set(this.options.pageParam, page)
506
- const response = await fetch(url, { headers: { Accept: "application/json", ...this.options.headers }, signal: this.abortController.signal })
815
+ const response = await fetch(url, {
816
+ headers: { Accept: "application/json", ...this.options.headers },
817
+ signal: controller.signal
818
+ })
507
819
  if (!response.ok) throw new Error(`Request failed (${response.status})`)
508
820
  const data = await response.json()
509
- const rawItems = this.getPath(data, this.options.itemsPath) || []
510
- const remoteItems = rawItems.map(raw => this.addItem({ ...raw, value: String(raw[this.options.valueField]), label: String(raw[this.options.labelField]) }))
511
- const values = new Set(remoteItems.map(item => item.value))
512
- this.filtered = append ? [...this.filtered, ...remoteItems.filter(item => !this.filtered.some(old => old.value === item.value))] : this.items.filter(item => values.has(String(item.value)) || item.selected)
821
+ if (this.activeRequest?.id !== requestId) return
822
+
823
+ const rawItems = this.getPath(data, this.options.itemsPath)
824
+ const responseItems = (Array.isArray(rawItems) ? rawItems : []).map(raw => this.upsertRemoteItem(raw))
825
+ const combinedItems = append ? [...this.remoteResults, ...responseItems] : responseItems
826
+ this.remoteResults = this.deduplicateByValue(combinedItems)
827
+ this.page = page
513
828
  const pagination = this.getPath(data, this.options.paginationPath) || {}
514
- this.hasMore = Boolean(pagination.next_page || pagination.has_more || (pagination.page && pagination.total_pages && pagination.page < pagination.total_pages))
515
- this.activeIndex = this.filtered.findIndex(item => !item.disabled)
516
- this.emit("tam-select:load", { items: remoteItems, pagination })
829
+ this.nextPage = pagination.next_page ?? null
830
+ this.hasMore = Boolean(this.nextPage || pagination.has_more || (pagination.page && pagination.total_pages && pagination.page < pagination.total_pages))
831
+ this.updateVisibleItems(this.remoteResults)
832
+ this.emit("tam-select:load", { items: this.deduplicateByValue(responseItems), pagination })
517
833
  } catch (error) {
518
- if (error.name !== "AbortError") { this.error = error.message || "Unable to load options"; this.emit("tam-select:error", { error }) }
834
+ if (this.activeRequest?.id !== requestId) return
835
+ if (error.name !== "AbortError") {
836
+ this.error = error.message || "Unable to load options"
837
+ this.emit("tam-select:error", { error })
838
+ }
519
839
  } finally {
520
- this.loading = false
521
- this.renderDropdown()
840
+ if (this.activeRequest?.id === requestId) {
841
+ this.activeRequest = null
842
+ this.loading = false
843
+ this.loadingPage = null
844
+ this.renderDropdown()
845
+ }
522
846
  }
523
847
  }
524
848
 
525
- getPath(object, path) { return String(path).split(".").reduce((value, key) => value?.[key], object) }
849
+ deduplicateByValue(items) {
850
+ const seen = new Set()
851
+ return items.filter(item => {
852
+ const value = String(item.value)
853
+ if (seen.has(value)) return false
854
+ seen.add(value)
855
+ return true
856
+ })
857
+ }
858
+
859
+ getPath(object, path) {
860
+ return String(path).split(".").reduce((value, key) => value?.[key], object)
861
+ }
862
+
863
+ syncAria() {
864
+ const labelledBy = this.labelledBy()
865
+ if (labelledBy) {
866
+ const valueReference = this.trigger ? ` ${this.valuesId}` : ""
867
+ this.focusTarget.setAttribute("aria-labelledby", `${labelledBy}${valueReference}`)
868
+ this.focusTarget.removeAttribute("aria-label")
869
+ } else {
870
+ const ariaLabel = this.select.getAttribute("aria-label") || this.options.placeholder
871
+ this.focusTarget.setAttribute("aria-label", ariaLabel)
872
+ if (this.trigger) this.focusTarget.setAttribute("aria-labelledby", this.valuesId)
873
+ else this.focusTarget.removeAttribute("aria-labelledby")
874
+ }
875
+
876
+ const describedBy = this.select.getAttribute("aria-describedby")
877
+ if (describedBy) this.focusTarget.setAttribute("aria-describedby", describedBy)
878
+ else this.focusTarget.removeAttribute("aria-describedby")
879
+
880
+ this.focusTarget.setAttribute("aria-required", String(Boolean(this.select.required)))
881
+ this.focusTarget.setAttribute("aria-disabled", String(Boolean(this.select.disabled)))
882
+ const nativeInvalid = this.select.getAttribute("aria-invalid")
883
+ const invalid = this.invalidFromEvent || nativeInvalid === "true"
884
+ if (this.invalidFromEvent) this.focusTarget.setAttribute("aria-invalid", "true")
885
+ else if (nativeInvalid != null) this.focusTarget.setAttribute("aria-invalid", nativeInvalid)
886
+ else this.focusTarget.removeAttribute("aria-invalid")
887
+ toggleClasses(this.control, this.classes.controlInvalid, invalid)
888
+ }
526
889
 
527
890
  applyDisabled() {
528
891
  toggleClasses(this.control, this.classes.controlDisabled, this.select.disabled)
529
- this.control.tabIndex = this.select.disabled ? -1 : 0
530
- this.input.disabled = this.select.disabled
892
+ this.focusTarget.disabled = this.select.disabled
893
+ this.clearButton.disabled = this.select.disabled
894
+ this.syncAria()
531
895
  }
532
896
 
533
- refresh() { this.readNativeOptions(); this.applyDisabled(); this.renderSelection(); this.renderDropdown() }
897
+ refresh() {
898
+ this.readNativeOptions()
899
+ this.applyDisabled()
900
+ this.renderSelection()
901
+ this.renderDropdown()
902
+ }
534
903
 
535
- emit(name, detail = {}) { this.select.dispatchEvent(new CustomEvent(name, { bubbles: true, detail: { tamSelect: this, ...detail } })) }
904
+ emit(name, detail = {}) {
905
+ this.select.dispatchEvent(new CustomEvent(name, { bubbles: true, detail: { tamSelect: this, ...detail } }))
906
+ }
536
907
 
537
908
  destroy() {
538
- clearTimeout(this.remoteTimer)
539
- this.abortController?.abort()
909
+ this.cancelRemoteWork()
540
910
  document.removeEventListener("pointerdown", this.onOutside)
541
911
  this.select.removeEventListener("change", this.onNativeChange)
912
+ this.select.removeEventListener("invalid", this.onNativeInvalid)
913
+ this.labelElements.forEach(label => label.removeEventListener("click", this.onLabelClick))
542
914
  this.wrapper.remove()
543
- this.select.classList.remove("sr-only")
915
+ if (!this.hadSrOnlyClass) this.select.classList.remove("sr-only")
544
916
  if (this.originalTabIndex === null) this.select.removeAttribute("tabindex")
545
917
  else this.select.setAttribute("tabindex", this.originalTabIndex)
918
+ if (this.originalAriaHidden === null) this.select.removeAttribute("aria-hidden")
919
+ else this.select.setAttribute("aria-hidden", this.originalAriaHidden)
920
+ if (this.originalId === null) this.select.removeAttribute("id")
546
921
  TamSelect.instances.delete(this.select)
547
922
  }
548
923
  }