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.
- checksums.yaml +4 -4
- data/README.md +148 -49
- 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/generators/tam_select/templates/tam_select_paginatable.rb +11 -4
- data/lib/generators/tam_select/templates/tam_select_remote.rb +7 -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 +592 -149
- metadata +6 -3
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-
|
|
4
|
-
|
|
5
|
-
|
|
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-
|
|
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-
|
|
13
|
-
option: "flex cursor-pointer items-center justify-between gap-3 rounded-
|
|
14
|
-
|
|
15
|
-
|
|
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.
|
|
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 =
|
|
98
|
+
this.page = 0
|
|
99
|
+
this.nextPage = null
|
|
80
100
|
this.hasMore = false
|
|
81
|
-
this.
|
|
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
|
-
|
|
111
|
-
this.
|
|
112
|
-
this.
|
|
113
|
-
this.
|
|
114
|
-
|
|
115
|
-
this.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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.
|
|
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.
|
|
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 = () => {
|
|
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)
|
|
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.
|
|
164
|
-
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)
|
|
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 !== ""
|
|
174
|
-
.map(option =>
|
|
175
|
-
|
|
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() {
|
|
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
|
-
|
|
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)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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.
|
|
199
|
-
this.
|
|
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.
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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(
|
|
401
|
+
makeOption(entry, index) {
|
|
243
402
|
const option = document.createElement("div")
|
|
244
|
-
option.id =
|
|
245
|
-
option.dataset.
|
|
246
|
-
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(" ")
|
|
247
411
|
option.setAttribute("role", "option")
|
|
248
|
-
option.setAttribute("aria-selected", String(Boolean(item.selected)))
|
|
249
|
-
option.setAttribute("aria-disabled", String(Boolean(
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
option.addEventListener("
|
|
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
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
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
|
-
|
|
293
|
-
this.
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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")
|
|
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.
|
|
515
|
+
if (!this.visibleItems.length) return
|
|
310
516
|
let next = this.activeIndex
|
|
311
|
-
for (let count = 0; count < this.
|
|
312
|
-
next = (next + direction + this.
|
|
313
|
-
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
|
+
}
|
|
314
523
|
}
|
|
315
|
-
this.activeIndex = next
|
|
316
|
-
this.updateActiveOption()
|
|
317
524
|
}
|
|
318
525
|
|
|
319
526
|
updateActiveOption() {
|
|
320
|
-
this.dropdown.querySelectorAll(
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
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
|
|
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 = {
|
|
370
|
-
|
|
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() {
|
|
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
|
|
391
|
-
|
|
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.
|
|
400
|
-
this.
|
|
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 &&
|
|
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.
|
|
412
|
-
this.
|
|
413
|
-
this.
|
|
414
|
-
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 = ""
|
|
415
734
|
this.query = ""
|
|
416
|
-
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
|
+
}
|
|
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
|
-
|
|
424
|
-
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()
|
|
425
788
|
}
|
|
426
789
|
|
|
427
790
|
async loadRemote(page = 1, append = false) {
|
|
428
|
-
if (
|
|
429
|
-
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
|
|
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,
|
|
813
|
+
url.searchParams.set(this.options.queryParam, request.query)
|
|
437
814
|
url.searchParams.set(this.options.pageParam, page)
|
|
438
|
-
const response = await fetch(url, {
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
const
|
|
444
|
-
|
|
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.
|
|
447
|
-
this.
|
|
448
|
-
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 })
|
|
449
833
|
} catch (error) {
|
|
450
|
-
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
|
+
}
|
|
451
839
|
} finally {
|
|
452
|
-
this.
|
|
453
|
-
|
|
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
|
-
|
|
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.
|
|
462
|
-
this.
|
|
892
|
+
this.focusTarget.disabled = this.select.disabled
|
|
893
|
+
this.clearButton.disabled = this.select.disabled
|
|
894
|
+
this.syncAria()
|
|
463
895
|
}
|
|
464
896
|
|
|
465
|
-
refresh() {
|
|
897
|
+
refresh() {
|
|
898
|
+
this.readNativeOptions()
|
|
899
|
+
this.applyDisabled()
|
|
900
|
+
this.renderSelection()
|
|
901
|
+
this.renderDropdown()
|
|
902
|
+
}
|
|
466
903
|
|
|
467
|
-
emit(name, 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
|
-
|
|
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
|
}
|