tam_select 1.1.1 → 1.2.3

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,18 +1,29 @@
1
1
  const DEFAULT_CLASSES = {
2
- wrapper: "tam-select relative w-full",
3
- control: "flex min-h-10 w-full cursor-text flex-wrap items-center gap-1.5 rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm transition 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",
4
- controlDisabled: "cursor-not-allowed bg-zinc-100 opacity-60 dark:bg-zinc-800",
5
- input: "min-w-16 flex-1 border-0 bg-transparent p-0 text-sm text-zinc-900 outline-none placeholder:text-zinc-400 focus:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-500",
2
+ wrapper: "tam-select relative w-full text-zinc-900 [color-scheme:light] dark:text-zinc-100 dark:[color-scheme:dark]",
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",
6
+ controlDisabled: "cursor-not-allowed bg-zinc-100 opacity-60 dark:bg-zinc-800 dark:text-zinc-400",
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",
10
+ searchIcon: "size-4 shrink-0 text-zinc-400 dark:text-zinc-500",
6
11
  placeholder: "pointer-events-none text-zinc-400 dark:text-zinc-500",
7
- 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",
8
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",
9
- clear: "ml-auto rounded p-1 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:hover:bg-zinc-800 dark:hover:text-zinc-200",
10
- 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",
11
16
  chevronOpen: "rotate-180",
12
- dropdown: "absolute z-50 mt-1 max-h-72 w-full overflow-auto rounded-lg border border-zinc-200 bg-white p-1 shadow-xl ring-1 ring-black/5 dark:border-zinc-700 dark:bg-zinc-900",
13
- option: "flex cursor-pointer items-center justify-between gap-3 rounded-md px-3 py-2 text-sm text-zinc-700 outline-none dark:text-zinc-200",
14
- optionActive: "bg-zinc-100 dark:bg-zinc-800",
15
- optionSelected: "bg-blue-50 font-medium text-blue-700 dark:bg-blue-950/50 dark:text-blue-300",
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",
19
+ optionContent: "flex min-w-0 flex-1 items-center gap-3",
20
+ optionText: "flex min-w-0 flex-1 flex-col",
21
+ optionLabel: "font-normal",
22
+ optionDetail: "text-xs font-normal text-zinc-500 dark:text-zinc-400",
23
+ optionImage: "size-9 shrink-0 rounded-full bg-zinc-100 object-cover dark:bg-zinc-800",
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",
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",
16
27
  optionDisabled: "cursor-not-allowed opacity-50",
17
28
  message: "px-3 py-6 text-center text-sm text-zinc-500 dark:text-zinc-400",
18
29
  spinner: "size-4 animate-spin rounded-full border-2 border-zinc-300 border-t-blue-600",
@@ -20,16 +31,18 @@ const DEFAULT_CLASSES = {
20
31
  }
21
32
 
22
33
  const ICONS = {
34
+ search: '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" d="m14.5 14.5 3 3m-1.25-8.25a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/></svg>',
23
35
  chevron: '<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M5.22 7.22a.75.75 0 011.06 0L10 10.94l3.72-3.72a.75.75 0 111.06 1.06l-4.25 4.25a.75.75 0 01-1.06 0L5.22 8.28a.75.75 0 010-1.06z" clip-rule="evenodd"/></svg>',
24
36
  close: '<svg viewBox="0 0 20 20" fill="currentColor" class="size-3" aria-hidden="true"><path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"/></svg>',
25
37
  check: '<svg viewBox="0 0 20 20" fill="currentColor" class="size-4" aria-hidden="true"><path fill-rule="evenodd" d="M16.7 5.3a1 1 0 010 1.4l-8 8a1 1 0 01-1.4 0l-4-4a1 1 0 011.4-1.4L8 12.6l7.3-7.3a1 1 0 011.4 0z" clip-rule="evenodd"/></svg>'
26
38
  }
27
39
 
28
40
  const uid = () => `tam-select-${Math.random().toString(36).slice(2, 10)}`
29
- const normalize = value => String(value ?? "").normalize("NFKD").toLocaleLowerCase()
41
+ const normalize = value => String(value ?? "").normalize("NFKD").replace(/\p{Mark}/gu, "").toLocaleLowerCase().trim()
30
42
  const toggleClasses = (element, classNames, force) => {
31
43
  String(classNames).split(/\s+/).filter(Boolean).forEach(className => element.classList.toggle(className, force))
32
44
  }
45
+ const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key)
33
46
 
34
47
  export class TamSelect {
35
48
  static instances = new WeakMap()
@@ -54,6 +67,7 @@ export class TamSelect {
54
67
  createText: query => `Create “${query}”`,
55
68
  loadingText: "Loading…",
56
69
  loadMoreText: "Load more",
70
+ inputTooShortText: remaining => `Enter ${remaining} more character${remaining === 1 ? "" : "s"}`,
57
71
  remoteUrl: null,
58
72
  queryParam: "q",
59
73
  pageParam: "page",
@@ -61,25 +75,38 @@ export class TamSelect {
61
75
  minQueryLength: 0,
62
76
  valueField: "value",
63
77
  labelField: "label",
78
+ imageField: "image",
64
79
  itemsPath: "items",
65
80
  paginationPath: "pagination",
81
+ matcher: null,
66
82
  classes: {},
67
83
  headers: {},
68
84
  ...options
69
85
  }
70
86
  this.classes = { ...DEFAULT_CLASSES, ...this.options.classes }
71
87
  this.multiple = select.multiple
88
+ this.searchable = Boolean(this.options.searchable || this.options.creatable)
72
89
  this.items = []
73
- this.filtered = []
90
+ this.remoteResults = []
91
+ this.visibleItems = []
74
92
  this.activeIndex = -1
75
93
  this.opened = false
76
94
  this.loading = false
95
+ this.loadingPage = null
77
96
  this.error = null
78
97
  this.query = ""
79
- this.page = 1
98
+ this.page = 0
99
+ this.nextPage = null
80
100
  this.hasMore = false
81
- this.abortController = null
101
+ this.requestSequence = 0
102
+ this.activeRequest = null
103
+ this.optionIds = new WeakMap()
104
+ this.optionIdSequence = 0
105
+ this.invalidFromEvent = false
82
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")
83
110
  this.build()
84
111
  this.bind()
85
112
  this.readNativeOptions()
@@ -91,31 +118,49 @@ export class TamSelect {
91
118
  this.id = this.select.id || uid()
92
119
  if (!this.select.id) this.select.id = this.id
93
120
  this.listboxId = `${this.id}-listbox`
121
+ this.valuesId = `${this.id}-value`
94
122
  this.select.classList.add("sr-only")
95
123
  this.select.tabIndex = -1
124
+ this.select.setAttribute("aria-hidden", "true")
96
125
 
97
126
  this.wrapper = document.createElement("div")
98
127
  this.wrapper.className = this.classes.wrapper
99
128
  this.wrapper.dataset.tamSelectRoot = ""
129
+
100
130
  this.control = document.createElement("div")
101
131
  this.control.className = this.classes.control
102
- this.control.setAttribute("role", "combobox")
103
- this.control.setAttribute("aria-haspopup", "listbox")
104
- this.control.setAttribute("aria-expanded", "false")
105
- this.control.setAttribute("aria-controls", this.listboxId)
106
- this.control.tabIndex = this.select.disabled ? -1 : 0
107
132
 
108
133
  this.values = document.createElement("div")
134
+ this.values.id = this.valuesId
109
135
  this.values.className = "contents"
110
- this.input = document.createElement("input")
111
- this.input.type = "text"
112
- this.input.className = this.classes.input
113
- this.input.placeholder = this.options.searchable ? this.options.searchPlaceholder : ""
114
- this.input.autocomplete = "off"
115
- this.input.spellcheck = false
116
- this.input.setAttribute("aria-autocomplete", "list")
117
- this.input.setAttribute("aria-controls", this.listboxId)
118
- if (!this.options.searchable) this.input.readOnly = true
136
+
137
+ this.searchIcon = document.createElement("span")
138
+ this.searchIcon.className = this.classes.searchIcon
139
+ this.searchIcon.innerHTML = ICONS.search
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
+ }
119
164
 
120
165
  this.clearButton = document.createElement("button")
121
166
  this.clearButton.type = "button"
@@ -126,78 +171,183 @@ export class TamSelect {
126
171
  this.chevron = document.createElement("span")
127
172
  this.chevron.className = this.classes.chevron
128
173
  this.chevron.innerHTML = ICONS.chevron
174
+
129
175
  this.dropdown = document.createElement("div")
130
176
  this.dropdown.id = this.listboxId
131
177
  this.dropdown.className = `${this.classes.dropdown} hidden`
132
178
  this.dropdown.setAttribute("role", "listbox")
133
179
  if (this.multiple) this.dropdown.setAttribute("aria-multiselectable", "true")
134
180
 
135
- this.control.append(this.values, 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)
136
183
  this.wrapper.append(this.control, this.dropdown)
137
184
  this.select.after(this.wrapper)
185
+ this.labelElements = this.findLabelElements()
138
186
  this.applyDisabled()
187
+ this.syncAria()
139
188
  }
140
189
 
141
190
  bind() {
142
191
  this.onControlClick = event => {
143
- if (this.select.disabled || event.target.closest("button")) return
192
+ if (!this.searchable || this.select.disabled || event.target.closest("button")) return
144
193
  this.open()
145
194
  this.input.focus()
146
195
  }
147
196
  this.onInput = () => {
148
197
  this.query = this.input.value
149
- this.page = 1
150
- this.open()
198
+ this.open(false)
151
199
  if (this.options.remoteUrl) this.scheduleRemote()
152
200
  else this.filterLocal()
153
201
  }
154
202
  this.onKeydown = event => this.handleKeydown(event)
155
203
  this.onClear = event => { event.stopPropagation(); this.clear() }
204
+ this.onTrigger = event => { event.preventDefault(); this.open(); this.trigger.focus() }
156
205
  this.onOutside = event => { if (!this.wrapper.contains(event.target)) this.close() }
157
- 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
+ }
158
219
  this.onScroll = () => {
159
220
  if (!this.options.remoteUrl || !this.hasMore || this.loading) return
160
- 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()
161
229
  }
230
+
162
231
  this.control.addEventListener("click", this.onControlClick)
163
- this.input.addEventListener("input", this.onInput)
164
- 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)
165
235
  this.clearButton.addEventListener("click", this.onClear)
166
236
  this.dropdown.addEventListener("scroll", this.onScroll)
167
237
  this.select.addEventListener("change", this.onNativeChange)
238
+ this.select.addEventListener("invalid", this.onNativeInvalid)
239
+ this.labelElements.forEach(label => label.addEventListener("click", this.onLabelClick))
168
240
  document.addEventListener("pointerdown", this.onOutside)
169
241
  }
170
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
+
171
258
  readNativeOptions() {
259
+ const previousItems = new Map(this.items.map(item => [item.option, item]))
172
260
  this.items = Array.from(this.select.options)
173
- .filter(option => option.value !== "" || option.selected)
174
- .map(option => ({ value: option.value, label: option.textContent.trim(), disabled: option.disabled, selected: option.selected, option }))
175
- this.filterLocal(false)
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
+ }
176
284
  }
177
285
 
178
- selectedItems() { return this.items.filter(item => item.option?.selected || item.selected) }
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
+ }
179
297
 
180
298
  filterLocal(render = true) {
181
- const needle = normalize(this.query)
182
- this.filtered = this.items.filter(item => !needle || normalize(item.label).includes(needle))
183
- this.activeIndex = this.filtered.findIndex(item => !item.disabled)
299
+ this.updateVisibleItems(this.items.filter(item => this.matchesLocalItem(item)))
184
300
  if (render) this.renderDropdown()
185
301
  }
186
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
+
187
328
  renderSelection() {
188
329
  const selected = this.selectedItems()
189
330
  this.values.replaceChildren()
190
- if (this.multiple) selected.forEach(item => this.values.append(this.makeTag(item)))
191
- else if (selected.length) {
192
- const label = document.createElement("span")
193
- label.className = "min-w-0 flex-1 truncate"
194
- label.textContent = selected[0].label
195
- this.values.append(label)
331
+ if (this.multiple) {
332
+ selected.forEach(item => this.values.append(this.makeTag(item)))
333
+ } else if (selected.length) {
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)
196
340
  }
341
+
197
342
  const hasValue = selected.length > 0
198
- this.input.classList.toggle("hidden", !this.multiple && hasValue && !this.opened)
199
- 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
+ }
200
349
  this.clearButton.classList.toggle("hidden", !this.options.clearable || !hasValue || this.select.disabled)
350
+ this.syncAria()
201
351
  }
202
352
 
203
353
  makeTag(item) {
@@ -209,6 +359,7 @@ export class TamSelect {
209
359
  const remove = document.createElement("button")
210
360
  remove.type = "button"
211
361
  remove.className = this.classes.tagRemove
362
+ remove.disabled = this.select.disabled
212
363
  remove.setAttribute("aria-label", `Remove ${item.label}`)
213
364
  remove.innerHTML = ICONS.close
214
365
  remove.addEventListener("click", event => { event.stopPropagation(); this.deselect(item.value) })
@@ -219,60 +370,104 @@ export class TamSelect {
219
370
  renderDropdown() {
220
371
  if (!this.opened) return
221
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
+ }
222
381
  if (this.error) {
223
382
  const error = document.createElement("div")
224
383
  error.className = this.classes.error
225
384
  error.setAttribute("role", "alert")
226
385
  error.textContent = this.error
227
386
  this.dropdown.append(error)
228
- return
387
+ return this.syncActiveDescendant()
229
388
  }
230
- if (this.loading && this.page === 1) return this.renderMessage(this.options.loadingText, true)
231
-
232
- const visible = this.multiple ? this.filtered : this.filtered.filter(item => !item.selected)
233
- visible.forEach((item, index) => this.dropdown.append(this.makeOption(item, index)))
234
- if (this.options.creatable && this.query.trim() && !this.items.some(item => normalize(item.label) === normalize(this.query))) {
235
- this.dropdown.append(this.makeCreateOption())
389
+ if (this.loading && this.loadingPage === 1) {
390
+ this.renderMessage(this.options.loadingText, true)
391
+ return this.syncActiveDescendant()
236
392
  }
237
- if (!this.dropdown.children.length) this.renderMessage(this.options.noResultsText)
238
- 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)
239
397
  else if (this.hasMore) this.renderMessage(this.options.loadMoreText)
398
+ this.syncActiveDescendant()
240
399
  }
241
400
 
242
- makeOption(item, index) {
401
+ makeOption(entry, index) {
243
402
  const option = document.createElement("div")
244
- option.id = `${this.listboxId}-option-${index}`
245
- option.dataset.value = item.value
246
- 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(" ")
247
411
  option.setAttribute("role", "option")
248
- option.setAttribute("aria-selected", String(Boolean(item.selected)))
249
- option.setAttribute("aria-disabled", String(Boolean(item.disabled)))
250
- const label = document.createElement("span")
251
- label.className = "min-w-0 flex-1 truncate"
252
- label.textContent = item.label
253
- option.append(label)
254
- if (item.selected) {
255
- const check = document.createElement("span")
256
- check.innerHTML = ICONS.check
257
- 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
+ }
258
432
  }
259
- option.addEventListener("pointermove", () => { this.activeIndex = index; this.updateActiveOption() })
260
- 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))
261
436
  return option
262
437
  }
263
438
 
264
- makeCreateOption() {
265
- const option = document.createElement("div")
266
- option.className = this.classes.option
267
- option.setAttribute("role", "option")
268
- option.textContent = typeof this.options.createText === "function" ? this.options.createText(this.query.trim()) : this.options.createText
269
- option.addEventListener("click", () => this.createItem(this.query.trim()))
270
- return option
439
+ makeItemContent(item, selected = false) {
440
+ const content = document.createElement("span")
441
+ content.className = this.classes.optionContent
442
+ if (selected) content.classList.add("min-w-0", "flex-1")
443
+ if (item.image) {
444
+ const image = document.createElement("img")
445
+ image.className = this.classes.optionImage
446
+ image.src = item.image
447
+ image.alt = ""
448
+ image.loading = "lazy"
449
+ content.append(image)
450
+ }
451
+ const text = document.createElement("span")
452
+ text.className = this.classes.optionText
453
+ const label = document.createElement("span")
454
+ label.className = `${this.classes.optionLabel} truncate`
455
+ label.textContent = item.label
456
+ text.append(label)
457
+ if (item.detail) {
458
+ const detail = document.createElement("span")
459
+ detail.className = `${this.classes.optionDetail} truncate`
460
+ detail.textContent = item.detail
461
+ text.append(detail)
462
+ }
463
+ content.append(text)
464
+ return content
271
465
  }
272
466
 
273
467
  renderMessage(text, spinner = false) {
274
468
  const message = document.createElement("div")
275
469
  message.className = this.classes.message
470
+ message.setAttribute("role", "status")
276
471
  if (spinner) {
277
472
  const row = document.createElement("span")
278
473
  row.className = "inline-flex items-center gap-2"
@@ -280,61 +475,101 @@ export class TamSelect {
280
475
  icon.className = this.classes.spinner
281
476
  row.append(icon, document.createTextNode(text))
282
477
  message.append(row)
283
- } else message.textContent = text
478
+ } else {
479
+ message.textContent = text
480
+ }
284
481
  this.dropdown.append(message)
285
482
  }
286
483
 
287
484
  handleKeydown(event) {
288
- if (this.select.disabled) return
485
+ if (this.select.disabled || event.target.closest("button") && event.target !== this.trigger) return
486
+
289
487
  if (["ArrowDown", "ArrowUp"].includes(event.key)) {
290
488
  event.preventDefault()
489
+ const wasOpen = this.opened
291
490
  this.open()
292
- const direction = event.key === "ArrowDown" ? 1 : -1
293
- this.moveActive(direction)
491
+ if (wasOpen || event.key === "ArrowUp") this.moveActive(event.key === "ArrowDown" ? 1 : -1)
492
+ else this.updateActiveOption()
294
493
  } else if (event.key === "Enter") {
494
+ event.preventDefault()
295
495
  if (!this.opened) return this.open()
496
+ this.activateEntry(this.visibleItems[this.activeIndex])
497
+ } else if (event.key === " " && this.trigger) {
296
498
  event.preventDefault()
297
- const item = this.filtered[this.activeIndex]
298
- if (item && !item.disabled) this.toggleItem(item)
299
- else if (this.options.creatable && this.query.trim()) this.createItem(this.query.trim())
300
- } else if (event.key === "Escape") {
301
- event.preventDefault(); this.close(); this.control.focus()
302
- } else if (event.key === "Backspace" && this.multiple && !this.input.value) {
499
+ this.open()
500
+ } else if (event.key === "Escape" && this.opened) {
501
+ event.preventDefault()
502
+ this.close()
503
+ this.focusTarget.focus()
504
+ } else if (event.key === "Backspace" && this.multiple && this.input && !this.input.value) {
303
505
  const last = this.selectedItems().at(-1)
304
506
  if (last) this.deselect(last.value)
305
- } 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
+ }
306
512
  }
307
513
 
308
514
  moveActive(direction) {
309
- if (!this.filtered.length) return
515
+ if (!this.visibleItems.length) return
310
516
  let next = this.activeIndex
311
- for (let count = 0; count < this.filtered.length; count += 1) {
312
- next = (next + direction + this.filtered.length) % this.filtered.length
313
- 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
+ }
314
523
  }
315
- this.activeIndex = next
316
- this.updateActiveOption()
317
524
  }
318
525
 
319
526
  updateActiveOption() {
320
- this.dropdown.querySelectorAll('[role="option"]').forEach((option, index) => toggleClasses(option, this.classes.optionActive, index === this.activeIndex))
321
- const active = this.dropdown.querySelector(`#${CSS.escape(`${this.listboxId}-option-${this.activeIndex}`)}`)
322
- 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)
323
549
  }
324
550
 
325
551
  toggleItem(item) {
552
+ if (item.disabled) return
326
553
  if (this.multiple && item.selected) this.deselect(item.value)
327
554
  else this.selectValue(item.value)
328
555
  }
329
556
 
330
557
  selectValue(value) {
331
558
  const item = this.ensureNativeOption(value)
559
+ if (item.disabled) return
332
560
  if (!this.multiple) Array.from(this.select.options).forEach(option => { option.selected = false })
333
561
  item.option.selected = true
334
562
  item.selected = true
335
563
  this.commit()
336
564
  if (this.options.closeAfterSelect) this.close()
337
- 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()
338
573
  }
339
574
 
340
575
  deselect(value) {
@@ -351,130 +586,338 @@ export class TamSelect {
351
586
  this.commit()
352
587
  }
353
588
 
354
- createItem(label) {
355
- 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
+ }
356
596
  const item = this.addItem({ value, label, selected: false, created: true })
357
597
  this.selectValue(item.value)
358
598
  this.emit("tam-select:create", { item })
599
+ return item
359
600
  }
360
601
 
361
602
  addItem(raw) {
362
- const value = String(raw.value ?? raw[this.options.valueField])
603
+ const rawValue = raw.value ?? raw[this.options.valueField]
604
+ const value = String(rawValue ?? "")
363
605
  const existing = this.items.find(item => String(item.value) === value)
364
- if (existing) return existing
365
606
  const label = String(raw.label ?? raw[this.options.labelField] ?? value)
607
+ const image = raw.image ?? raw[this.options.imageField]
608
+ if (existing) {
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)
616
+ return existing
617
+ }
618
+
366
619
  const option = new Option(label, value, Boolean(raw.selected), Boolean(raw.selected))
367
620
  option.dataset.tamSelectGenerated = ""
368
621
  this.select.add(option)
369
- const item = { ...raw, value, label, option, selected: option.selected, disabled: Boolean(raw.disabled) }
370
- 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)
371
635
  this.items.push(item)
372
- this.filterLocal(false)
373
636
  return item
374
637
  }
375
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
+
376
682
  ensureNativeOption(value) {
377
683
  return this.items.find(item => String(item.value) === String(value)) || this.addItem({ value, label: value })
378
684
  }
379
685
 
380
686
  commit() {
687
+ if (this.select.checkValidity()) this.invalidFromEvent = false
688
+ this.readNativeOptions()
381
689
  this.renderSelection()
382
690
  this.renderDropdown()
691
+ this.syncAria()
383
692
  this.select.dispatchEvent(new Event("change", { bubbles: true }))
384
693
  this.emit("tam-select:change", { value: this.value, items: this.selectedItems() })
385
694
  }
386
695
 
387
- 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
+ }
388
699
 
389
700
  setValue(value) {
390
- const values = new Set((Array.isArray(value) ? value : [value]).map(String))
391
- 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
+ })
392
707
  this.commit()
393
708
  }
394
709
 
395
- open() {
710
+ open(loadRemote = true) {
396
711
  if (this.opened || this.select.disabled) return
397
712
  this.opened = true
398
713
  this.dropdown.classList.remove("hidden")
399
- this.control.setAttribute("aria-expanded", "true")
400
- 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)
401
717
  this.renderSelection()
402
- if (this.options.remoteUrl && !this.filtered.length) this.loadRemote(1)
718
+ if (this.options.remoteUrl && loadRemote) this.startRemoteSearch(false)
403
719
  else this.renderDropdown()
720
+ this.focusTarget.focus({ preventScroll: true })
404
721
  this.emit("tam-select:open")
405
722
  }
406
723
 
407
724
  close() {
408
725
  if (!this.opened) return
409
726
  this.opened = false
727
+ this.cancelRemoteWork()
410
728
  this.dropdown.classList.add("hidden")
411
- this.control.setAttribute("aria-expanded", "false")
412
- this.input.removeAttribute("aria-activedescendant")
413
- this.chevron.classList.remove(...this.classes.chevronOpen.split(" "))
414
- 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 = ""
415
734
  this.query = ""
416
- 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
+ }
417
746
  this.renderSelection()
418
747
  this.emit("tam-select:close")
419
748
  }
420
749
 
421
750
  scheduleRemote() {
751
+ this.startRemoteSearch(true)
752
+ }
753
+
754
+ startRemoteSearch(debounced) {
422
755
  clearTimeout(this.remoteTimer)
423
- if (this.query.length < this.options.minQueryLength) { this.filtered = []; return this.renderDropdown() }
424
- 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()
425
788
  }
426
789
 
427
790
  async loadRemote(page = 1, append = false) {
428
- if (!append) this.abortController?.abort()
429
- 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
430
806
  this.loading = true
807
+ this.loadingPage = page
431
808
  this.error = null
432
- this.page = page
433
809
  this.renderDropdown()
810
+
434
811
  try {
435
812
  const url = new URL(this.options.remoteUrl, window.location.origin)
436
- url.searchParams.set(this.options.queryParam, this.query)
813
+ url.searchParams.set(this.options.queryParam, request.query)
437
814
  url.searchParams.set(this.options.pageParam, page)
438
- 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
+ })
439
819
  if (!response.ok) throw new Error(`Request failed (${response.status})`)
440
820
  const data = await response.json()
441
- const rawItems = this.getPath(data, this.options.itemsPath) || []
442
- const remoteItems = rawItems.map(raw => this.addItem({ ...raw, value: String(raw[this.options.valueField]), label: String(raw[this.options.labelField]) }))
443
- const values = new Set(remoteItems.map(item => item.value))
444
- 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
445
828
  const pagination = this.getPath(data, this.options.paginationPath) || {}
446
- this.hasMore = Boolean(pagination.next_page || pagination.has_more || (pagination.page && pagination.total_pages && pagination.page < pagination.total_pages))
447
- this.activeIndex = this.filtered.findIndex(item => !item.disabled)
448
- 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 })
449
833
  } catch (error) {
450
- 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
+ }
451
839
  } finally {
452
- this.loading = false
453
- this.renderDropdown()
840
+ if (this.activeRequest?.id === requestId) {
841
+ this.activeRequest = null
842
+ this.loading = false
843
+ this.loadingPage = null
844
+ this.renderDropdown()
845
+ }
454
846
  }
455
847
  }
456
848
 
457
- 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
+ }
458
889
 
459
890
  applyDisabled() {
460
891
  toggleClasses(this.control, this.classes.controlDisabled, this.select.disabled)
461
- this.control.tabIndex = this.select.disabled ? -1 : 0
462
- this.input.disabled = this.select.disabled
892
+ this.focusTarget.disabled = this.select.disabled
893
+ this.clearButton.disabled = this.select.disabled
894
+ this.syncAria()
463
895
  }
464
896
 
465
- refresh() { this.readNativeOptions(); this.applyDisabled(); this.renderSelection(); this.renderDropdown() }
897
+ refresh() {
898
+ this.readNativeOptions()
899
+ this.applyDisabled()
900
+ this.renderSelection()
901
+ this.renderDropdown()
902
+ }
466
903
 
467
- 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
+ }
468
907
 
469
908
  destroy() {
470
- clearTimeout(this.remoteTimer)
471
- this.abortController?.abort()
909
+ this.cancelRemoteWork()
472
910
  document.removeEventListener("pointerdown", this.onOutside)
473
911
  this.select.removeEventListener("change", this.onNativeChange)
912
+ this.select.removeEventListener("invalid", this.onNativeInvalid)
913
+ this.labelElements.forEach(label => label.removeEventListener("click", this.onLabelClick))
474
914
  this.wrapper.remove()
475
- this.select.classList.remove("sr-only")
915
+ if (!this.hadSrOnlyClass) this.select.classList.remove("sr-only")
476
916
  if (this.originalTabIndex === null) this.select.removeAttribute("tabindex")
477
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")
478
921
  TamSelect.instances.delete(this.select)
479
922
  }
480
923
  }