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.
- checksums.yaml +4 -4
- data/README.md +107 -16
- data/Rakefile +5 -2
- data/lib/generators/tam_select/install_generator.rb +33 -1
- data/lib/generators/tam_select/templates/tam_select_controller.js +1 -1
- data/lib/generators/tam_select/templates/tam_select_helper.rb +1 -1
- data/lib/generators/tam_select/templates/tam_select_input.rb +1 -1
- data/lib/tam_select/version.rb +1 -1
- data/package.json +48 -0
- data/rails/app/controllers/concerns/tam_select_paginatable.rb +32 -0
- data/rails/app/helpers/tam_select_helper.rb +17 -0
- data/rails/app/inputs/tam_select_input.rb +30 -0
- data/rails/app/javascript/controllers/tam_select_controller.js +31 -0
- data/src/tam-select.js +550 -175
- metadata +6 -3
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-
|
|
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-
|
|
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-
|
|
14
|
-
option: "flex min-h-11 cursor-pointer items-center justify-between gap-3 rounded-
|
|
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-
|
|
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.
|
|
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 =
|
|
98
|
+
this.page = 0
|
|
99
|
+
this.nextPage = null
|
|
89
100
|
this.hasMore = false
|
|
90
|
-
this.
|
|
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
|
-
|
|
123
|
-
this.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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.
|
|
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.
|
|
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 = () => {
|
|
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)
|
|
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.
|
|
176
|
-
this.
|
|
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 !== ""
|
|
186
|
-
.map(option =>
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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)
|
|
212
|
-
|
|
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.
|
|
217
|
-
this.
|
|
218
|
-
this.input
|
|
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.
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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(
|
|
401
|
+
makeOption(entry, index) {
|
|
262
402
|
const option = document.createElement("div")
|
|
263
|
-
option.id =
|
|
264
|
-
option.dataset.
|
|
265
|
-
option.className = [
|
|
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(
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
option.append(
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
283
|
-
option.addEventListener("
|
|
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
|
|
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
|
-
|
|
344
|
-
this.
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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")
|
|
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.
|
|
515
|
+
if (!this.visibleItems.length) return
|
|
361
516
|
let next = this.activeIndex
|
|
362
|
-
for (let count = 0; count < this.
|
|
363
|
-
next = (next + direction + this.
|
|
364
|
-
if (!this.
|
|
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(
|
|
372
|
-
|
|
373
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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 = {
|
|
438
|
-
|
|
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() {
|
|
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
|
|
459
|
-
|
|
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.
|
|
468
|
-
this.
|
|
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 &&
|
|
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.
|
|
480
|
-
this.
|
|
481
|
-
this.
|
|
482
|
-
this.
|
|
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.
|
|
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
|
-
|
|
492
|
-
this.
|
|
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 (
|
|
497
|
-
this.
|
|
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,
|
|
813
|
+
url.searchParams.set(this.options.queryParam, request.query)
|
|
505
814
|
url.searchParams.set(this.options.pageParam, page)
|
|
506
|
-
const response = await fetch(url, {
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
const
|
|
512
|
-
|
|
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.
|
|
515
|
-
this.
|
|
516
|
-
this.
|
|
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 (
|
|
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.
|
|
521
|
-
|
|
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
|
-
|
|
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.
|
|
530
|
-
this.
|
|
892
|
+
this.focusTarget.disabled = this.select.disabled
|
|
893
|
+
this.clearButton.disabled = this.select.disabled
|
|
894
|
+
this.syncAria()
|
|
531
895
|
}
|
|
532
896
|
|
|
533
|
-
refresh() {
|
|
897
|
+
refresh() {
|
|
898
|
+
this.readNativeOptions()
|
|
899
|
+
this.applyDisabled()
|
|
900
|
+
this.renderSelection()
|
|
901
|
+
this.renderDropdown()
|
|
902
|
+
}
|
|
534
903
|
|
|
535
|
-
emit(name, 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
|
-
|
|
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
|
}
|