slim-select 3.4.3 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/classes.d.ts +47 -0
- package/dist/helpers.d.ts +5 -0
- package/dist/index.d.ts +53 -319
- package/dist/index.umd.d.ts +2 -0
- package/dist/react/index.d.ts +4 -319
- package/dist/react/index.js +1471 -1697
- package/dist/react/index.umd.js +2 -2
- package/dist/react/react.d.ts +17 -0
- package/dist/react/react.test.d.ts +1 -0
- package/dist/react/slim-select/react/index.d.ts +4 -0
- package/dist/render.d.ts +90 -0
- package/dist/select.d.ts +38 -0
- package/dist/settings.d.ts +40 -0
- package/dist/slimselect.cjs.js +2 -2
- package/dist/slimselect.cjs.js.map +1 -1
- package/dist/slimselect.css +1 -1
- package/dist/slimselect.css.map +1 -1
- package/dist/slimselect.es.js +1245 -1385
- package/dist/slimselect.es.js.map +1 -1
- package/dist/slimselect.iife.js +2 -2
- package/dist/slimselect.iife.js.map +1 -1
- package/dist/slimselect.js +2 -2
- package/dist/slimselect.js.map +1 -1
- package/dist/src/docs/main.d.ts +0 -0
- package/dist/src/docs/router.d.ts +2 -0
- package/dist/src/docs/store.d.ts +12 -0
- package/dist/store.d.ts +53 -0
- package/dist/vue/index.d.ts +4 -319
- package/dist/vue/index.js +1347 -1504
- package/dist/vue/index.umd.js +1 -1
- package/dist/vue/slim-select/vue/index.d.ts +4 -0
- package/package.json +30 -36
- package/src/slim-select/slimselect.scss +47 -5
- package/.nvmrc +0 -1
- package/.prettierignore +0 -1
- package/.prettierrc +0 -7
- package/CODE_OF_CONDUCT.md +0 -46
- package/CONTRIBUTING.md +0 -1
- package/cdn.html +0 -21
- package/docs.go +0 -12
- package/example.html +0 -25
- package/index.html +0 -76
- package/release.js +0 -191
- package/sitemap.js +0 -108
- package/src/slim-select/accessibility.test.ts +0 -782
- package/src/slim-select/classes.test.ts +0 -73
- package/src/slim-select/classes.ts +0 -117
- package/src/slim-select/helpers.test.ts +0 -162
- package/src/slim-select/helpers.ts +0 -61
- package/src/slim-select/index.test.ts +0 -1356
- package/src/slim-select/index.ts +0 -594
- package/src/slim-select/index.umd.ts +0 -8
- package/src/slim-select/react/index.tsx +0 -9
- package/src/slim-select/react/react.test.tsx +0 -498
- package/src/slim-select/react/react.tsx +0 -209
- package/src/slim-select/render.test.ts +0 -1927
- package/src/slim-select/render.ts +0 -1771
- package/src/slim-select/select.test.ts +0 -683
- package/src/slim-select/select.ts +0 -659
- package/src/slim-select/settings.test.ts +0 -285
- package/src/slim-select/settings.ts +0 -78
- package/src/slim-select/store.test.ts +0 -1007
- package/src/slim-select/store.ts +0 -433
- package/src/slim-select/vue/index.ts +0 -9
- package/src/slim-select/vue/vue.test.ts +0 -1091
- package/src/slim-select/vue/vue.vue +0 -224
- package/tsconfig.json +0 -26
- package/vite.config.docs.mts +0 -29
- package/vite.config.lib.es-cjs.ts +0 -40
- package/vite.config.lib.umd-iife.ts +0 -34
- package/vite.config.react.mts +0 -41
- package/vite.config.vue.mts +0 -40
- package/vitest.config.ts +0 -30
|
@@ -1,1771 +0,0 @@
|
|
|
1
|
-
import { debounce } from './helpers'
|
|
2
|
-
import Settings from './settings'
|
|
3
|
-
import Store, { Optgroup, Option } from './store'
|
|
4
|
-
import CssClasses from './classes'
|
|
5
|
-
|
|
6
|
-
export interface Callbacks {
|
|
7
|
-
open: () => void
|
|
8
|
-
close: () => void
|
|
9
|
-
addable?: (
|
|
10
|
-
value: string
|
|
11
|
-
) => Promise<Partial<Option> | string> | Partial<Option> | string | false | undefined | null | Error
|
|
12
|
-
setSelected: (value: string | string[], runAfterChange: boolean) => void
|
|
13
|
-
addOption: (option: Option) => void
|
|
14
|
-
search: (search: string) => void
|
|
15
|
-
beforeChange?: (newVal: Option[], oldVal: Option[]) => boolean | void
|
|
16
|
-
afterChange?: (newVal: Option[]) => void
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface Main {
|
|
20
|
-
main: HTMLDivElement
|
|
21
|
-
values: HTMLDivElement
|
|
22
|
-
deselect: {
|
|
23
|
-
main: HTMLDivElement
|
|
24
|
-
svg: SVGSVGElement
|
|
25
|
-
path: SVGPathElement
|
|
26
|
-
}
|
|
27
|
-
arrow: {
|
|
28
|
-
main: SVGSVGElement
|
|
29
|
-
path: SVGPathElement
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface Content {
|
|
34
|
-
main: HTMLDivElement
|
|
35
|
-
search: Search
|
|
36
|
-
list: HTMLDivElement
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface Search {
|
|
40
|
-
main: HTMLDivElement
|
|
41
|
-
input: HTMLInputElement
|
|
42
|
-
addable?: {
|
|
43
|
-
main: HTMLDivElement
|
|
44
|
-
svg: SVGSVGElement
|
|
45
|
-
path: SVGPathElement
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export default class Render {
|
|
50
|
-
public settings: Settings
|
|
51
|
-
public store: Store
|
|
52
|
-
public callbacks: Callbacks
|
|
53
|
-
|
|
54
|
-
// Used to compute the range selection
|
|
55
|
-
private lastSelectedOption: Option | null
|
|
56
|
-
private lastRenderedOptions: Option[]
|
|
57
|
-
|
|
58
|
-
// Timeout tracking for cleanup
|
|
59
|
-
private closeAnimationTimeout: ReturnType<typeof setTimeout> | null = null
|
|
60
|
-
|
|
61
|
-
// Elements
|
|
62
|
-
public main: Main
|
|
63
|
-
public content: Content
|
|
64
|
-
|
|
65
|
-
// Classes
|
|
66
|
-
public classes: CssClasses
|
|
67
|
-
|
|
68
|
-
constructor(settings: Required<Settings>, classes: Required<CssClasses>, store: Store, callbacks: Callbacks) {
|
|
69
|
-
this.store = store
|
|
70
|
-
this.settings = settings
|
|
71
|
-
this.classes = classes
|
|
72
|
-
this.callbacks = callbacks
|
|
73
|
-
this.lastSelectedOption = null
|
|
74
|
-
this.lastRenderedOptions = []
|
|
75
|
-
|
|
76
|
-
this.main = this.mainDiv()
|
|
77
|
-
this.content = this.contentDiv()
|
|
78
|
-
|
|
79
|
-
// Add classes and styles to main/content
|
|
80
|
-
this.updateClassStyles()
|
|
81
|
-
this.updateAriaAttributes()
|
|
82
|
-
|
|
83
|
-
// Position content off-screen initially to prevent scrollbars
|
|
84
|
-
// This must be after updateClassStyles() since it removes all style attributes
|
|
85
|
-
// The main element isn't in the DOM yet, so getBoundingClientRect() would return zeros
|
|
86
|
-
// Once opened, moveContent() will position it correctly
|
|
87
|
-
if (this.settings.contentPosition !== 'relative') {
|
|
88
|
-
this.content.main.style.top = '-9999px'
|
|
89
|
-
this.content.main.style.left = '-9999px'
|
|
90
|
-
this.content.main.style.margin = '0'
|
|
91
|
-
this.content.main.style.width = 'auto'
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Add content to the content location settings
|
|
95
|
-
if (this.settings.contentLocation) {
|
|
96
|
-
this.settings.contentLocation.appendChild(this.content.main)
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Helper method to add classes that may contain spaces
|
|
101
|
-
// Splits by spaces and adds each class individually to avoid DOMException
|
|
102
|
-
private addClasses(element: HTMLElement | SVGElement, classValue: string): void {
|
|
103
|
-
if (!classValue || classValue.trim() === '') {
|
|
104
|
-
return
|
|
105
|
-
}
|
|
106
|
-
const classes = classValue.split(' ').filter((c) => c.trim() !== '')
|
|
107
|
-
for (const cls of classes) {
|
|
108
|
-
element.classList.add(cls.trim())
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Helper method to remove classes that may contain spaces
|
|
113
|
-
private removeClasses(element: HTMLElement | SVGElement, classValue: string): void {
|
|
114
|
-
if (!classValue || classValue.trim() === '') {
|
|
115
|
-
return
|
|
116
|
-
}
|
|
117
|
-
const classes = classValue.split(' ').filter((c) => c.trim() !== '')
|
|
118
|
-
for (const cls of classes) {
|
|
119
|
-
element.classList.remove(cls.trim())
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Remove disabled classes
|
|
124
|
-
public enable(): void {
|
|
125
|
-
// Remove disabled class
|
|
126
|
-
this.removeClasses(this.main.main, this.classes.disabled)
|
|
127
|
-
this.main.main.setAttribute('aria-disabled', 'false')
|
|
128
|
-
|
|
129
|
-
// Set search input to "enabled"
|
|
130
|
-
this.content.search.input.disabled = false
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Set disabled classes
|
|
134
|
-
public disable(): void {
|
|
135
|
-
// Add disabled class
|
|
136
|
-
this.addClasses(this.main.main, this.classes.disabled)
|
|
137
|
-
this.main.main.setAttribute('aria-disabled', 'true')
|
|
138
|
-
|
|
139
|
-
// Set search input to disabled
|
|
140
|
-
this.content.search.input.disabled = true
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
public open(): void {
|
|
144
|
-
this.main.arrow.path.setAttribute('d', this.classes.arrowOpen)
|
|
145
|
-
this.main.main.setAttribute('aria-expanded', 'true')
|
|
146
|
-
|
|
147
|
-
// Clear any pending close animation timeout to prevent race conditions
|
|
148
|
-
if (this.closeAnimationTimeout) {
|
|
149
|
-
clearTimeout(this.closeAnimationTimeout)
|
|
150
|
-
this.closeAnimationTimeout = null
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Set direction class on both main and content (persists, never removed)
|
|
154
|
-
const isAbove = this.settings.openPosition === 'up'
|
|
155
|
-
const dirClass = isAbove ? this.classes.dirAbove : this.classes.dirBelow
|
|
156
|
-
this.addClasses(this.main.main, dirClass)
|
|
157
|
-
this.addClasses(this.content.main, dirClass)
|
|
158
|
-
|
|
159
|
-
// Add open class to content to trigger open animation
|
|
160
|
-
this.addClasses(this.content.main, this.classes.contentOpen)
|
|
161
|
-
|
|
162
|
-
// Make search visible to screen readers when opened
|
|
163
|
-
this.content.search.input.removeAttribute('aria-hidden')
|
|
164
|
-
|
|
165
|
-
// move the content in to the right location
|
|
166
|
-
this.moveContent()
|
|
167
|
-
|
|
168
|
-
// Move to last selected option
|
|
169
|
-
const selectedOptions = this.store.getSelectedOptions()
|
|
170
|
-
if (selectedOptions.length) {
|
|
171
|
-
const selectedId = selectedOptions[selectedOptions.length - 1].id
|
|
172
|
-
const selectedOption = this.content.list.querySelector('[data-id="' + selectedId + '"]') as HTMLElement
|
|
173
|
-
if (selectedOption) {
|
|
174
|
-
this.ensureElementInView(this.content.list, selectedOption)
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
public close(): void {
|
|
180
|
-
this.main.main.setAttribute('aria-expanded', 'false')
|
|
181
|
-
this.main.arrow.path.setAttribute('d', this.classes.arrowClose)
|
|
182
|
-
|
|
183
|
-
// Remove open class from content to trigger close animation
|
|
184
|
-
// Direction class (dirAbove/dirBelow) persists to maintain correct transform-origin
|
|
185
|
-
this.removeClasses(this.content.main, this.classes.contentOpen)
|
|
186
|
-
|
|
187
|
-
// Hide search from screen readers when closed
|
|
188
|
-
this.content.search.input.setAttribute('aria-hidden', 'true')
|
|
189
|
-
|
|
190
|
-
// Clear active descendant when closed
|
|
191
|
-
this.main.main.removeAttribute('aria-activedescendant')
|
|
192
|
-
|
|
193
|
-
// Remove direction class from main and content after animation is complete
|
|
194
|
-
const animationTiming = this.getAnimationTiming()
|
|
195
|
-
this.closeAnimationTimeout = setTimeout(() => {
|
|
196
|
-
this.removeClasses(this.main.main, this.classes.dirAbove)
|
|
197
|
-
this.removeClasses(this.main.main, this.classes.dirBelow)
|
|
198
|
-
this.removeClasses(this.content.main, this.classes.dirAbove)
|
|
199
|
-
this.removeClasses(this.content.main, this.classes.dirBelow)
|
|
200
|
-
this.closeAnimationTimeout = null
|
|
201
|
-
}, animationTiming)
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
private getAnimationTiming(): number {
|
|
205
|
-
const computedStyle = getComputedStyle(this.content.main)
|
|
206
|
-
const cssValue = computedStyle.getPropertyValue('--ss-animation-timing').trim()
|
|
207
|
-
|
|
208
|
-
if (cssValue) {
|
|
209
|
-
// Parse CSS time value (e.g., "0.2s" or "200ms")
|
|
210
|
-
if (cssValue.endsWith('ms')) {
|
|
211
|
-
return parseFloat(cssValue)
|
|
212
|
-
} else if (cssValue.endsWith('s')) {
|
|
213
|
-
return parseFloat(cssValue) * 1000
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Fall back to default 200ms
|
|
218
|
-
return 200
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
public updateClassStyles(): void {
|
|
222
|
-
// Clear all classes and styles
|
|
223
|
-
this.main.main.className = ''
|
|
224
|
-
this.main.main.removeAttribute('style')
|
|
225
|
-
this.content.main.className = ''
|
|
226
|
-
this.content.main.removeAttribute('style')
|
|
227
|
-
|
|
228
|
-
// Make sure main/content has its base class
|
|
229
|
-
this.addClasses(this.main.main, this.classes.main)
|
|
230
|
-
this.addClasses(this.content.main, this.classes.content)
|
|
231
|
-
|
|
232
|
-
// Add styles
|
|
233
|
-
if (this.settings.style !== '') {
|
|
234
|
-
this.main.main.style.cssText = this.settings.style
|
|
235
|
-
this.content.main.style.cssText = this.settings.style
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// Add classes
|
|
239
|
-
if (this.settings.class.length) {
|
|
240
|
-
for (const c of this.settings.class) {
|
|
241
|
-
if (c.trim() !== '') {
|
|
242
|
-
this.main.main.classList.add(c.trim())
|
|
243
|
-
this.content.main.classList.add(c.trim())
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Misc classes
|
|
249
|
-
// Add content position class
|
|
250
|
-
if (this.settings.contentPosition === 'relative' || this.settings.contentPosition === 'fixed') {
|
|
251
|
-
this.content.main.classList.add('ss-' + this.settings.contentPosition)
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
public updateAriaAttributes() {
|
|
256
|
-
const listboxId = this.content.list.id
|
|
257
|
-
|
|
258
|
-
// Main combobox
|
|
259
|
-
this.main.main.role = 'combobox'
|
|
260
|
-
this.main.main.setAttribute('aria-haspopup', 'listbox')
|
|
261
|
-
this.main.main.setAttribute('aria-controls', listboxId)
|
|
262
|
-
this.main.main.setAttribute('aria-expanded', 'false')
|
|
263
|
-
|
|
264
|
-
this.content.list.setAttribute('role', 'listbox')
|
|
265
|
-
this.content.list.setAttribute('aria-label', this.settings.ariaLabel + ' listbox')
|
|
266
|
-
|
|
267
|
-
// Add aria-multiselectable for multiple selects
|
|
268
|
-
if (this.settings.isMultiple) {
|
|
269
|
-
this.content.list.setAttribute('aria-multiselectable', 'true')
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// Search input should also control the listbox
|
|
273
|
-
this.content.search.input.setAttribute('aria-controls', listboxId)
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
public mainDiv(): Main {
|
|
277
|
-
// Create main container
|
|
278
|
-
const main = document.createElement('div')
|
|
279
|
-
|
|
280
|
-
// Add id to data-id
|
|
281
|
-
main.dataset.id = this.settings.id
|
|
282
|
-
// main.id = this.settings.id+'-main' // Remove for now as it is not needed and add duplicate id errors
|
|
283
|
-
|
|
284
|
-
// Add label
|
|
285
|
-
main.setAttribute('aria-label', this.settings.ariaLabel)
|
|
286
|
-
|
|
287
|
-
// Set tabable to allow tabbing to the element
|
|
288
|
-
main.tabIndex = 0
|
|
289
|
-
|
|
290
|
-
// Deal with keyboard events on the main div
|
|
291
|
-
// This is to allow for normal selecting
|
|
292
|
-
// when you may not have a search bar
|
|
293
|
-
main.onkeydown = (e: KeyboardEvent): boolean => {
|
|
294
|
-
// Convert above if else statemets to switch
|
|
295
|
-
switch (e.key) {
|
|
296
|
-
case 'ArrowUp':
|
|
297
|
-
case 'ArrowDown':
|
|
298
|
-
this.callbacks.open()
|
|
299
|
-
e.key === 'ArrowDown' ? this.highlight('down') : this.highlight('up')
|
|
300
|
-
return false
|
|
301
|
-
case 'Tab':
|
|
302
|
-
this.callbacks.close()
|
|
303
|
-
return true // Continue doing normal tabbing
|
|
304
|
-
case 'Enter':
|
|
305
|
-
case ' ':
|
|
306
|
-
this.callbacks.open()
|
|
307
|
-
const highlighted = this.content.list.querySelector(
|
|
308
|
-
'.' + this.classes.getFirst('highlighted')
|
|
309
|
-
) as HTMLDivElement
|
|
310
|
-
if (highlighted) {
|
|
311
|
-
highlighted.click()
|
|
312
|
-
}
|
|
313
|
-
return false
|
|
314
|
-
case 'Escape':
|
|
315
|
-
this.callbacks.close()
|
|
316
|
-
return false
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// Check if they type a-z, A-Z and 0-9
|
|
320
|
-
if (e.key.length === 1) {
|
|
321
|
-
this.callbacks.open()
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
return true
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Add onclick for main div
|
|
328
|
-
main.onclick = (e: Event): void => {
|
|
329
|
-
// Dont do anything if disabled
|
|
330
|
-
if (this.settings.disabled) {
|
|
331
|
-
return
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
this.settings.isOpen ? this.callbacks.close() : this.callbacks.open()
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// Add values
|
|
338
|
-
const values = document.createElement('div')
|
|
339
|
-
this.addClasses(values, this.classes.values)
|
|
340
|
-
main.appendChild(values)
|
|
341
|
-
|
|
342
|
-
// Add deselect
|
|
343
|
-
const deselect = document.createElement('div')
|
|
344
|
-
this.addClasses(deselect, this.classes.deselect)
|
|
345
|
-
|
|
346
|
-
// Check if deselect is to be shown or not
|
|
347
|
-
const selectedOptions = this.store?.getSelectedOptions()
|
|
348
|
-
if (!this.settings.allowDeselect || (this.settings.isMultiple && selectedOptions && selectedOptions.length <= 0)) {
|
|
349
|
-
this.addClasses(deselect, this.classes.hide)
|
|
350
|
-
} else {
|
|
351
|
-
this.removeClasses(deselect, this.classes.hide)
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// Add deselect onclick event
|
|
355
|
-
deselect.onclick = (e: Event) => {
|
|
356
|
-
e.stopPropagation()
|
|
357
|
-
|
|
358
|
-
// Dont do anything if disabled
|
|
359
|
-
if (this.settings.disabled) {
|
|
360
|
-
return
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// By Default we will delete
|
|
364
|
-
let shouldDelete = true
|
|
365
|
-
const before = this.store.getSelectedOptions()
|
|
366
|
-
const after = [] as Option[]
|
|
367
|
-
|
|
368
|
-
// Add beforeChange callback
|
|
369
|
-
if (this.callbacks.beforeChange) {
|
|
370
|
-
shouldDelete = this.callbacks.beforeChange(after, before) === true
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
if (shouldDelete) {
|
|
374
|
-
if (this.settings.isMultiple) {
|
|
375
|
-
this.callbacks.setSelected([], false)
|
|
376
|
-
this.updateDeselectAll()
|
|
377
|
-
} else {
|
|
378
|
-
// Get first option and set it as selected
|
|
379
|
-
const firstOption = this.store.getFirstOption()
|
|
380
|
-
const id = firstOption ? firstOption.id : ''
|
|
381
|
-
|
|
382
|
-
this.callbacks.setSelected(id, false)
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// Check if we need to close the dropdown
|
|
386
|
-
if (this.settings.closeOnSelect) {
|
|
387
|
-
this.callbacks.close()
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
// Run afterChange callback
|
|
391
|
-
if (this.callbacks.afterChange) {
|
|
392
|
-
this.callbacks.afterChange(this.store.getSelectedOptions())
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Add deselect svg
|
|
398
|
-
const deselectSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
399
|
-
deselectSvg.setAttribute('viewBox', '0 0 100 100')
|
|
400
|
-
const deselectPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
401
|
-
deselectPath.setAttribute('d', this.classes.deselectPath)
|
|
402
|
-
deselectSvg.appendChild(deselectPath)
|
|
403
|
-
deselect.appendChild(deselectSvg)
|
|
404
|
-
main.appendChild(deselect)
|
|
405
|
-
|
|
406
|
-
// Add arrow
|
|
407
|
-
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
408
|
-
this.addClasses(arrow, this.classes.arrow)
|
|
409
|
-
arrow.setAttribute('viewBox', '0 0 100 100')
|
|
410
|
-
const arrowPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
411
|
-
arrowPath.setAttribute('d', this.classes.arrowClose)
|
|
412
|
-
if (this.settings.alwaysOpen) {
|
|
413
|
-
this.addClasses(arrow, this.classes.hide)
|
|
414
|
-
}
|
|
415
|
-
arrow.appendChild(arrowPath)
|
|
416
|
-
main.appendChild(arrow)
|
|
417
|
-
|
|
418
|
-
return {
|
|
419
|
-
main: main,
|
|
420
|
-
values: values,
|
|
421
|
-
deselect: {
|
|
422
|
-
main: deselect,
|
|
423
|
-
svg: deselectSvg,
|
|
424
|
-
path: deselectPath
|
|
425
|
-
},
|
|
426
|
-
arrow: {
|
|
427
|
-
main: arrow,
|
|
428
|
-
path: arrowPath
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
public mainFocus(eventType: string | null): void {
|
|
434
|
-
// Trigger focus but dont scroll to it
|
|
435
|
-
// Need for prevent refocus the element if event is not keyboard event.
|
|
436
|
-
// For example if event is mouse click or tachpad click this condition prevent refocus on element
|
|
437
|
-
// because click by mouse change focus position and not need return focus to element.
|
|
438
|
-
if (eventType !== 'click') {
|
|
439
|
-
this.main.main.focus({ preventScroll: true })
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
public placeholder(): HTMLDivElement {
|
|
444
|
-
// Figure out if there is a placeholder option
|
|
445
|
-
const placeholderOption = this.store.filter((o) => o.placeholder, false) as Option[]
|
|
446
|
-
|
|
447
|
-
// If there is a placeholder option use that
|
|
448
|
-
// If placeholder has an html value, use that
|
|
449
|
-
// If placeholder has a text, use that
|
|
450
|
-
// If nothing is set, use the placeholder text
|
|
451
|
-
let placeholderText = this.settings.placeholderText
|
|
452
|
-
if (placeholderOption.length) {
|
|
453
|
-
if (placeholderOption[0].html !== '') {
|
|
454
|
-
placeholderText = placeholderOption[0].html
|
|
455
|
-
} else if (placeholderOption[0].text !== '') {
|
|
456
|
-
placeholderText = placeholderOption[0].text
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// Create placeholder div
|
|
461
|
-
const placeholder = document.createElement('div')
|
|
462
|
-
this.addClasses(placeholder, this.classes.placeholder)
|
|
463
|
-
placeholder.innerHTML = placeholderText
|
|
464
|
-
return placeholder
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// Get selected values and append to multiSelected values container
|
|
468
|
-
// and remove those who shouldnt exist
|
|
469
|
-
public renderValues(): void {
|
|
470
|
-
// If single select set placeholder or selected value
|
|
471
|
-
if (!this.settings.isMultiple) {
|
|
472
|
-
this.renderSingleValue()
|
|
473
|
-
return
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
this.renderMultipleValues()
|
|
477
|
-
|
|
478
|
-
this.updateDeselectAll()
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
private renderSingleValue(): void {
|
|
482
|
-
const selected = this.store.filter((o: Option): boolean => {
|
|
483
|
-
return o.selected && !o.placeholder
|
|
484
|
-
}, false) as Option[]
|
|
485
|
-
const selectedSingle = selected.length > 0 ? selected[0] : null
|
|
486
|
-
|
|
487
|
-
// If nothing is seleected use settings placeholder text
|
|
488
|
-
if (!selectedSingle) {
|
|
489
|
-
this.main.values.innerHTML = this.placeholder().outerHTML
|
|
490
|
-
} else {
|
|
491
|
-
// Create single value container
|
|
492
|
-
const singleValue = document.createElement('div')
|
|
493
|
-
this.addClasses(singleValue, this.classes.single)
|
|
494
|
-
if (selectedSingle.html) {
|
|
495
|
-
singleValue.innerHTML = selectedSingle.html
|
|
496
|
-
} else {
|
|
497
|
-
singleValue.innerText = selectedSingle.text
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// If there is a selected value, set a single div
|
|
501
|
-
this.main.values.innerHTML = singleValue.outerHTML
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
// If allowDeselect is false or selected value is empty just hide deselect
|
|
505
|
-
if (!this.settings.allowDeselect || !selected.length) {
|
|
506
|
-
this.addClasses(this.main.deselect.main, this.classes.hide)
|
|
507
|
-
} else {
|
|
508
|
-
this.removeClasses(this.main.deselect.main, this.classes.hide)
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
private renderMultipleValues(): void {
|
|
513
|
-
// Get various pieces of data
|
|
514
|
-
let currentNodes = this.main.values.childNodes as NodeListOf<HTMLDivElement>
|
|
515
|
-
let selectedOptions = this.store.filter((opt: Option) => {
|
|
516
|
-
// Only grab options that are selected and display is true
|
|
517
|
-
return opt.selected && opt.display
|
|
518
|
-
}, false) as Option[]
|
|
519
|
-
|
|
520
|
-
// If selectedOptions is empty set placeholder
|
|
521
|
-
if (selectedOptions.length === 0) {
|
|
522
|
-
this.main.values.innerHTML = this.placeholder().outerHTML
|
|
523
|
-
return
|
|
524
|
-
} else {
|
|
525
|
-
// If there is a placeholder, remove it
|
|
526
|
-
const placeholder = this.main.values.querySelector('.' + this.classes.getFirst('placeholder'))
|
|
527
|
-
if (placeholder) {
|
|
528
|
-
placeholder.remove()
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// If selectedOptions is greater than maxItems, set maxValuesMessage
|
|
533
|
-
if (selectedOptions.length > this.settings.maxValuesShown) {
|
|
534
|
-
// Creating the element that shows the number of selected items
|
|
535
|
-
const singleValue = document.createElement('div')
|
|
536
|
-
this.addClasses(singleValue, this.classes.max)
|
|
537
|
-
singleValue.textContent = this.settings.maxValuesMessage.replace('{number}', selectedOptions.length.toString())
|
|
538
|
-
|
|
539
|
-
// If there is a selected value, set a single div
|
|
540
|
-
this.main.values.innerHTML = singleValue.outerHTML
|
|
541
|
-
return
|
|
542
|
-
} else {
|
|
543
|
-
// If there is a message, remove it
|
|
544
|
-
const maxValuesMessage = this.main.values.querySelector('.' + this.classes.getFirst('max'))
|
|
545
|
-
if (maxValuesMessage) {
|
|
546
|
-
maxValuesMessage.remove()
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
// Lets check for data selected order
|
|
551
|
-
if (this.settings.keepOrder) {
|
|
552
|
-
selectedOptions = this.store.selectedOrderOptions(selectedOptions)
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// Loop through currentNodes and only include ones that are not in selectedIDs
|
|
556
|
-
let removeNodes: HTMLDivElement[] = []
|
|
557
|
-
for (let i = 0; i < currentNodes.length; i++) {
|
|
558
|
-
const node = currentNodes[i]
|
|
559
|
-
const id = node.getAttribute('data-id')
|
|
560
|
-
if (id) {
|
|
561
|
-
// Check if id is in selectedOptions
|
|
562
|
-
const found = selectedOptions.filter((opt: Option) => {
|
|
563
|
-
return opt.id === id
|
|
564
|
-
}, false)
|
|
565
|
-
|
|
566
|
-
// If not found, add to removeNodes
|
|
567
|
-
if (!found.length) {
|
|
568
|
-
removeNodes.push(node)
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
// Loop through and remove
|
|
574
|
-
for (const n of removeNodes) {
|
|
575
|
-
this.addClasses(n, this.classes.valueOut)
|
|
576
|
-
setTimeout(() => {
|
|
577
|
-
if (this.main.values.hasChildNodes() && this.main.values.contains(n)) {
|
|
578
|
-
this.main.values.removeChild(n)
|
|
579
|
-
}
|
|
580
|
-
}, 100)
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
// Add values that dont currently exist
|
|
584
|
-
currentNodes = this.main.values.childNodes as NodeListOf<HTMLDivElement>
|
|
585
|
-
for (let d = 0; d < selectedOptions.length; d++) {
|
|
586
|
-
let shouldAdd = true
|
|
587
|
-
for (let i = 0; i < currentNodes.length; i++) {
|
|
588
|
-
if (selectedOptions[d].id === String(currentNodes[i].dataset.id)) {
|
|
589
|
-
shouldAdd = false
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
// If shouldAdd, insertAdjacentElement it to the values container in the order of the selectedOptions
|
|
594
|
-
if (shouldAdd) {
|
|
595
|
-
// If keepOrder is true, we will just append it to the end
|
|
596
|
-
if (this.settings.keepOrder) {
|
|
597
|
-
this.main.values.appendChild(this.multipleValue(selectedOptions[d]))
|
|
598
|
-
} else {
|
|
599
|
-
// else we will insert it in the order of the selectedOptions
|
|
600
|
-
if (currentNodes.length === 0) {
|
|
601
|
-
this.main.values.appendChild(this.multipleValue(selectedOptions[d]))
|
|
602
|
-
} else if (d === 0) {
|
|
603
|
-
this.main.values.insertBefore(this.multipleValue(selectedOptions[d]), currentNodes[d])
|
|
604
|
-
} else {
|
|
605
|
-
currentNodes[d - 1].insertAdjacentElement('afterend', this.multipleValue(selectedOptions[d]))
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
public multipleValue(option: Option): HTMLDivElement {
|
|
613
|
-
const value = document.createElement('div')
|
|
614
|
-
this.addClasses(value, this.classes.value)
|
|
615
|
-
value.dataset.id = option.id
|
|
616
|
-
|
|
617
|
-
const text = document.createElement('div')
|
|
618
|
-
this.addClasses(text, this.classes.valueText)
|
|
619
|
-
text.textContent = option.text // For multiple values always use text
|
|
620
|
-
value.appendChild(text)
|
|
621
|
-
|
|
622
|
-
// Only add deletion if the option is not mandatory
|
|
623
|
-
if (!option.mandatory) {
|
|
624
|
-
// Create delete div element
|
|
625
|
-
const deleteDiv = document.createElement('div')
|
|
626
|
-
this.addClasses(deleteDiv, this.classes.valueDelete)
|
|
627
|
-
deleteDiv.setAttribute('tabindex', '0') // Make the div focusable for tab navigation
|
|
628
|
-
|
|
629
|
-
// Add delete onclick event
|
|
630
|
-
deleteDiv.onclick = (e: Event) => {
|
|
631
|
-
e.preventDefault()
|
|
632
|
-
e.stopPropagation()
|
|
633
|
-
|
|
634
|
-
// Dont do anything if disabled
|
|
635
|
-
if (this.settings.disabled) {
|
|
636
|
-
return
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// By Default we will delete
|
|
640
|
-
let shouldDelete = true
|
|
641
|
-
const before = this.store.getSelectedOptions()
|
|
642
|
-
const after = before.filter((o) => {
|
|
643
|
-
return o.selected && o.id !== option.id
|
|
644
|
-
}, true)
|
|
645
|
-
|
|
646
|
-
// Check if minSelected is set and if after length so, return
|
|
647
|
-
if (this.settings.minSelected && after.length < this.settings.minSelected) {
|
|
648
|
-
return
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
// If there is a beforeDeselect function run it
|
|
652
|
-
if (this.callbacks.beforeChange) {
|
|
653
|
-
shouldDelete = this.callbacks.beforeChange(after, before) === true
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
if (shouldDelete) {
|
|
657
|
-
// Loop through after and append ids to a variable called selected
|
|
658
|
-
let selectedIds: string[] = []
|
|
659
|
-
for (const o of after) {
|
|
660
|
-
if (o instanceof Optgroup) {
|
|
661
|
-
for (const c of o.options) {
|
|
662
|
-
if (c.id) {
|
|
663
|
-
selectedIds.push(c.id)
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
if (o instanceof Option) {
|
|
669
|
-
selectedIds.push(o.id)
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
this.callbacks.setSelected(selectedIds, false)
|
|
673
|
-
|
|
674
|
-
// Check if we need to close the dropdown
|
|
675
|
-
if (this.settings.closeOnSelect) {
|
|
676
|
-
this.callbacks.close()
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
// Run afterChange callback
|
|
680
|
-
if (this.callbacks.afterChange) {
|
|
681
|
-
this.callbacks.afterChange(after)
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
this.updateDeselectAll()
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// Add delete svg
|
|
689
|
-
const deleteSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
690
|
-
deleteSvg.setAttribute('viewBox', '0 0 100 100')
|
|
691
|
-
const deletePath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
692
|
-
deletePath.setAttribute('d', this.classes.optionDelete)
|
|
693
|
-
deleteSvg.appendChild(deletePath)
|
|
694
|
-
deleteDiv.appendChild(deleteSvg)
|
|
695
|
-
|
|
696
|
-
value.appendChild(deleteDiv)
|
|
697
|
-
|
|
698
|
-
// Add keydown event listener for keyboard navigation (Enter key)
|
|
699
|
-
deleteDiv.onkeydown = (e) => {
|
|
700
|
-
if (e.key === 'Enter') {
|
|
701
|
-
deleteDiv.click() // Trigger the click event when Enter is pressed
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
return value
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
public contentDiv(): Content {
|
|
710
|
-
const main = document.createElement('div')
|
|
711
|
-
|
|
712
|
-
// Add id to data-id
|
|
713
|
-
main.dataset.id = this.settings.id
|
|
714
|
-
// main.id = this.settings.id + '-content' // Remove for now as it is not needed and add duplicate id errors
|
|
715
|
-
|
|
716
|
-
// Add search
|
|
717
|
-
const search = this.searchDiv()
|
|
718
|
-
main.appendChild(search.main)
|
|
719
|
-
|
|
720
|
-
// Add list
|
|
721
|
-
const list = this.listDiv()
|
|
722
|
-
main.appendChild(list)
|
|
723
|
-
|
|
724
|
-
return {
|
|
725
|
-
main: main,
|
|
726
|
-
search: search,
|
|
727
|
-
list: list
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
public moveContent(): void {
|
|
732
|
-
// If contentPosition is relative, dont move the content anywhere other than below
|
|
733
|
-
if (this.settings.contentPosition === 'relative') {
|
|
734
|
-
this.moveContentBelow()
|
|
735
|
-
return
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
// If openContent is not auto set content
|
|
739
|
-
if (this.settings.openPosition === 'down') {
|
|
740
|
-
this.moveContentBelow()
|
|
741
|
-
return
|
|
742
|
-
} else if (this.settings.openPosition === 'up') {
|
|
743
|
-
this.moveContentAbove()
|
|
744
|
-
return
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
// Auto - Determine where to put the content
|
|
748
|
-
if (this.putContent() === 'up') {
|
|
749
|
-
this.moveContentAbove()
|
|
750
|
-
} else {
|
|
751
|
-
this.moveContentBelow()
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
public searchDiv(): Search {
|
|
756
|
-
const main = document.createElement('div')
|
|
757
|
-
const input = document.createElement('input')
|
|
758
|
-
const addable = document.createElement('div')
|
|
759
|
-
this.addClasses(main, this.classes.search)
|
|
760
|
-
|
|
761
|
-
// Setup search return object
|
|
762
|
-
const searchReturn: Search = {
|
|
763
|
-
main,
|
|
764
|
-
input
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
// We still want the search to be tabable but not shown
|
|
768
|
-
if (!this.settings.showSearch) {
|
|
769
|
-
this.addClasses(main, this.classes.hide)
|
|
770
|
-
input.readOnly = true
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
input.type = 'search'
|
|
774
|
-
input.placeholder = this.settings.searchPlaceholder
|
|
775
|
-
input.tabIndex = -1
|
|
776
|
-
input.setAttribute('aria-label', this.settings.searchPlaceholder)
|
|
777
|
-
input.setAttribute('aria-autocomplete', 'list')
|
|
778
|
-
input.setAttribute('autocapitalize', 'off')
|
|
779
|
-
input.setAttribute('autocomplete', 'off')
|
|
780
|
-
input.setAttribute('autocorrect', 'off')
|
|
781
|
-
// Hide from screen readers by default (shown when opened)
|
|
782
|
-
input.setAttribute('aria-hidden', 'true')
|
|
783
|
-
|
|
784
|
-
input.oninput = debounce((e: Event) => {
|
|
785
|
-
this.callbacks.search((e.target as HTMLInputElement).value)
|
|
786
|
-
}, 100)
|
|
787
|
-
|
|
788
|
-
// Deal with keyboard events on search input field
|
|
789
|
-
input.onkeydown = (e: KeyboardEvent): boolean => {
|
|
790
|
-
// Convert above if else statemets to switch
|
|
791
|
-
switch (e.key) {
|
|
792
|
-
case 'ArrowUp':
|
|
793
|
-
case 'ArrowDown':
|
|
794
|
-
e.key === 'ArrowDown' ? this.highlight('down') : this.highlight('up')
|
|
795
|
-
return false
|
|
796
|
-
case 'Tab':
|
|
797
|
-
// When tabbing close the dropdown
|
|
798
|
-
// which will also focus on main div
|
|
799
|
-
// and then continuing normal tabbing
|
|
800
|
-
this.callbacks.close()
|
|
801
|
-
return true // Continue doing normal tabbing
|
|
802
|
-
case 'Escape':
|
|
803
|
-
this.callbacks.close()
|
|
804
|
-
return false
|
|
805
|
-
case ' ':
|
|
806
|
-
const highlighted = this.content.list.querySelector(
|
|
807
|
-
'.' + this.classes.getFirst('highlighted')
|
|
808
|
-
) as HTMLDivElement
|
|
809
|
-
if (highlighted) {
|
|
810
|
-
highlighted.click()
|
|
811
|
-
return false
|
|
812
|
-
}
|
|
813
|
-
return true
|
|
814
|
-
case 'Enter':
|
|
815
|
-
// Check if there's a highlighted option first
|
|
816
|
-
const highlightedEnter = this.content.list.querySelector(
|
|
817
|
-
'.' + this.classes.getFirst('highlighted')
|
|
818
|
-
) as HTMLDivElement
|
|
819
|
-
if (highlightedEnter) {
|
|
820
|
-
// If an option is highlighted, select it (even if addable is enabled)
|
|
821
|
-
highlightedEnter.click()
|
|
822
|
-
return false
|
|
823
|
-
} else if (this.callbacks.addable) {
|
|
824
|
-
// If no option is highlighted and addable is enabled, add new item
|
|
825
|
-
addable.click()
|
|
826
|
-
return false
|
|
827
|
-
}
|
|
828
|
-
return true
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
return true // Allow normal typing
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
main.appendChild(input)
|
|
835
|
-
|
|
836
|
-
// If addable is enabled, add the addable div
|
|
837
|
-
if (this.callbacks.addable) {
|
|
838
|
-
// Add main class
|
|
839
|
-
this.addClasses(addable, this.classes.addable)
|
|
840
|
-
|
|
841
|
-
// Add svg icon
|
|
842
|
-
const plus = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
843
|
-
plus.setAttribute('viewBox', '0 0 100 100')
|
|
844
|
-
const plusPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
845
|
-
plusPath.setAttribute('d', this.classes.addablePath)
|
|
846
|
-
plus.appendChild(plusPath)
|
|
847
|
-
addable.appendChild(plus)
|
|
848
|
-
|
|
849
|
-
// Add click event to addable div
|
|
850
|
-
addable.onclick = (e: Event) => {
|
|
851
|
-
e.preventDefault()
|
|
852
|
-
e.stopPropagation()
|
|
853
|
-
|
|
854
|
-
// Do nothing if addable is not set
|
|
855
|
-
if (!this.callbacks.addable) {
|
|
856
|
-
return
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
// Grab input value
|
|
860
|
-
const inputValue = this.content.search.input.value.trim()
|
|
861
|
-
if (inputValue === '') {
|
|
862
|
-
this.content.search.input.focus()
|
|
863
|
-
return
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
// Run finish will be ran at the end of the addable function.
|
|
867
|
-
// Reason its in a function is so we can run it after the
|
|
868
|
-
// addable function is done for promise based addables
|
|
869
|
-
const runFinish = (oo: Partial<Option>) => {
|
|
870
|
-
let newOption = new Option(oo)
|
|
871
|
-
|
|
872
|
-
// Call addOption to add the new option
|
|
873
|
-
this.callbacks.addOption(newOption)
|
|
874
|
-
|
|
875
|
-
// set selected value for single and multiple
|
|
876
|
-
if (this.settings.isMultiple) {
|
|
877
|
-
let ids = this.store.getSelected()
|
|
878
|
-
ids.push(newOption.id)
|
|
879
|
-
this.callbacks.setSelected(ids, true)
|
|
880
|
-
} else {
|
|
881
|
-
this.callbacks.setSelected([newOption.id], true)
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
// Clear search
|
|
885
|
-
this.callbacks.search('')
|
|
886
|
-
|
|
887
|
-
// Close it only if closeOnSelect = true
|
|
888
|
-
if (this.settings.closeOnSelect) {
|
|
889
|
-
setTimeout(() => {
|
|
890
|
-
// Give it a little padding for a better looking animation
|
|
891
|
-
this.callbacks.close()
|
|
892
|
-
}, 100)
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
// Call addable callback
|
|
897
|
-
const addableValue = this.callbacks.addable(inputValue)
|
|
898
|
-
|
|
899
|
-
// If addableValue is false, undefined or null, do nothing
|
|
900
|
-
if (addableValue === false || addableValue === undefined || addableValue === null) {
|
|
901
|
-
return
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
// If addableValue is a promise, wait for it to resolve
|
|
905
|
-
if (addableValue instanceof Promise) {
|
|
906
|
-
addableValue.then((value) => {
|
|
907
|
-
if (typeof value === 'string') {
|
|
908
|
-
runFinish({
|
|
909
|
-
text: value,
|
|
910
|
-
value: value
|
|
911
|
-
})
|
|
912
|
-
} else if (addableValue instanceof Error) {
|
|
913
|
-
this.renderError(addableValue.message)
|
|
914
|
-
} else {
|
|
915
|
-
runFinish(value)
|
|
916
|
-
}
|
|
917
|
-
})
|
|
918
|
-
} else if (typeof addableValue === 'string') {
|
|
919
|
-
runFinish({
|
|
920
|
-
text: addableValue,
|
|
921
|
-
value: addableValue
|
|
922
|
-
})
|
|
923
|
-
} else if (addableValue instanceof Error) {
|
|
924
|
-
this.renderError(addableValue.message)
|
|
925
|
-
} else {
|
|
926
|
-
runFinish(addableValue)
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
return
|
|
930
|
-
}
|
|
931
|
-
main.appendChild(addable)
|
|
932
|
-
|
|
933
|
-
// Add the addable to the search return
|
|
934
|
-
searchReturn.addable = {
|
|
935
|
-
main: addable,
|
|
936
|
-
svg: plus,
|
|
937
|
-
path: plusPath
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
return searchReturn
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
public searchFocus(): void {
|
|
945
|
-
this.content.search.input.focus({ preventScroll: true })
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
public getOptions(notPlaceholder = false, notDisabled = false, notHidden = false): HTMLDivElement[] {
|
|
949
|
-
// Put together query string
|
|
950
|
-
let query = '.' + this.classes.getFirst('option')
|
|
951
|
-
if (notPlaceholder) {
|
|
952
|
-
query += ':not(.' + this.classes.getFirst('placeholder') + ')'
|
|
953
|
-
}
|
|
954
|
-
if (notDisabled) {
|
|
955
|
-
query += ':not(.' + this.classes.getFirst('disabled') + ')'
|
|
956
|
-
}
|
|
957
|
-
if (notHidden) {
|
|
958
|
-
query += ':not(.' + this.classes.getFirst('hide') + ')'
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
return Array.from(this.content.list.querySelectorAll(query))
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
// highlightUp is used to highlight the previous option in the list
|
|
965
|
-
public highlight(dir: 'up' | 'down'): void {
|
|
966
|
-
// Get full list of options in list
|
|
967
|
-
const options = this.getOptions(true, true, true)
|
|
968
|
-
|
|
969
|
-
// If there are no options, do nothing
|
|
970
|
-
if (options.length === 0) {
|
|
971
|
-
return
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
// If length is 1, highlight it
|
|
975
|
-
if (options.length === 1) {
|
|
976
|
-
// Check if option doesnt already have highlighted class
|
|
977
|
-
if (!options[0].classList.contains(this.classes.getFirst('highlighted'))) {
|
|
978
|
-
this.addClasses(options[0], this.classes.highlighted)
|
|
979
|
-
return
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
// Loop through options and see if there are no highlighted ones
|
|
984
|
-
let highlighted = false
|
|
985
|
-
for (const o of options) {
|
|
986
|
-
if (o.classList.contains(this.classes.getFirst('highlighted'))) {
|
|
987
|
-
highlighted = true
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
// If no highlighted, see if any are selected and if so highlight selected first one
|
|
992
|
-
if (!highlighted) {
|
|
993
|
-
for (const o of options) {
|
|
994
|
-
if (o.classList.contains(this.classes.getFirst('selected'))) {
|
|
995
|
-
this.addClasses(o, this.classes.highlighted)
|
|
996
|
-
break
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
// Loop through options and find the highlighted one
|
|
1002
|
-
for (let i = 0; i < options.length; i++) {
|
|
1003
|
-
// Found highlighted option
|
|
1004
|
-
if (options[i].classList.contains(this.classes.getFirst('highlighted'))) {
|
|
1005
|
-
const prevOption = options[i]
|
|
1006
|
-
// Remove highlighted class from current one
|
|
1007
|
-
this.removeClasses(prevOption, this.classes.highlighted)
|
|
1008
|
-
|
|
1009
|
-
// If previous option has parent classes ss-optgroup with ss-open then click it
|
|
1010
|
-
const prevParent = prevOption.parentElement
|
|
1011
|
-
if (prevParent && prevParent.classList.contains(this.classes.getFirst('mainOpen'))) {
|
|
1012
|
-
const optgroupLabel = prevParent.querySelector('.' + this.classes.getFirst('optgroupLabel')) as HTMLDivElement
|
|
1013
|
-
if (optgroupLabel) {
|
|
1014
|
-
optgroupLabel.click()
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
// Highlight the next one
|
|
1019
|
-
let selectOption =
|
|
1020
|
-
options[dir === 'down' ? (i + 1 < options.length ? i + 1 : 0) : i - 1 >= 0 ? i - 1 : options.length - 1]
|
|
1021
|
-
this.addClasses(selectOption, this.classes.highlighted)
|
|
1022
|
-
this.ensureElementInView(this.content.list, selectOption)
|
|
1023
|
-
|
|
1024
|
-
// Update aria-activedescendant for screen readers
|
|
1025
|
-
if (selectOption.id) {
|
|
1026
|
-
this.main.main.setAttribute('aria-activedescendant', selectOption.id)
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
// If selected option has parent classes ss-optgroup with ss-close then click it
|
|
1030
|
-
const selectParent = selectOption.parentElement
|
|
1031
|
-
if (selectParent && selectParent.classList.contains(this.classes.getFirst('close'))) {
|
|
1032
|
-
const optgroupLabel = selectParent.querySelector(
|
|
1033
|
-
'.' + this.classes.getFirst('optgroupLabel')
|
|
1034
|
-
) as HTMLDivElement
|
|
1035
|
-
if (optgroupLabel) {
|
|
1036
|
-
optgroupLabel.click()
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
return
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
// If we get here, there is no highlighted option
|
|
1045
|
-
// So we will highlight the first or last based upon direction
|
|
1046
|
-
const firstHighlight = options[dir === 'down' ? 0 : options.length - 1]
|
|
1047
|
-
this.addClasses(firstHighlight, this.classes.highlighted)
|
|
1048
|
-
|
|
1049
|
-
// Update aria-activedescendant for screen readers
|
|
1050
|
-
if (firstHighlight.id) {
|
|
1051
|
-
this.main.main.setAttribute('aria-activedescendant', firstHighlight.id)
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
// Scroll to highlighted one
|
|
1055
|
-
this.ensureElementInView(this.content.list, firstHighlight)
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
// Create main container that options will reside
|
|
1059
|
-
public listDiv(): HTMLDivElement {
|
|
1060
|
-
const options = document.createElement('div')
|
|
1061
|
-
this.addClasses(options, this.classes.list)
|
|
1062
|
-
|
|
1063
|
-
// Add id for ARIA controls reference
|
|
1064
|
-
const listId = this.settings.id + '-list'
|
|
1065
|
-
options.id = listId
|
|
1066
|
-
options.dataset.id = listId
|
|
1067
|
-
|
|
1068
|
-
return options
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
public renderError(error: string) {
|
|
1072
|
-
// Clear out innerHtml
|
|
1073
|
-
this.content.list.innerHTML = ''
|
|
1074
|
-
|
|
1075
|
-
const errorDiv = document.createElement('div')
|
|
1076
|
-
this.addClasses(errorDiv, this.classes.error)
|
|
1077
|
-
errorDiv.textContent = error
|
|
1078
|
-
this.content.list.appendChild(errorDiv)
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
public renderSearching() {
|
|
1082
|
-
// Clear out innerHtml
|
|
1083
|
-
this.content.list.innerHTML = ''
|
|
1084
|
-
|
|
1085
|
-
const searchingDiv = document.createElement('div')
|
|
1086
|
-
this.addClasses(searchingDiv, this.classes.searching)
|
|
1087
|
-
searchingDiv.textContent = this.settings.searchingText
|
|
1088
|
-
this.content.list.appendChild(searchingDiv)
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
// Take in data and add options to
|
|
1092
|
-
public renderOptions(data: (Option | Optgroup)[]): void {
|
|
1093
|
-
this.lastRenderedOptions = data
|
|
1094
|
-
.map((o) => (o instanceof Option ? [o] : o.options.map((po) => new Option(po))))
|
|
1095
|
-
.flat()
|
|
1096
|
-
|
|
1097
|
-
// Clear out innerHtml
|
|
1098
|
-
this.content.list.innerHTML = ''
|
|
1099
|
-
|
|
1100
|
-
// If no results show no results text
|
|
1101
|
-
if (data.length === 0) {
|
|
1102
|
-
const noResults = document.createElement('div')
|
|
1103
|
-
this.addClasses(noResults, this.classes.search)
|
|
1104
|
-
|
|
1105
|
-
//
|
|
1106
|
-
if (this.callbacks.addable) {
|
|
1107
|
-
noResults.innerHTML = this.settings.addableText.replace('{value}', this.content.search.input.value)
|
|
1108
|
-
} else {
|
|
1109
|
-
noResults.innerHTML = this.settings.searchText
|
|
1110
|
-
}
|
|
1111
|
-
this.content.list.appendChild(noResults)
|
|
1112
|
-
return
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
// If settings has allowDeselect and isSingle, add empty placeholder in the event they want to deselect
|
|
1116
|
-
if (this.settings.allowDeselect && !this.settings.isMultiple) {
|
|
1117
|
-
// Check if store options have a placeholder
|
|
1118
|
-
const placeholderOption = this.store.filter((o) => o.placeholder, false) as Option[]
|
|
1119
|
-
if (!placeholderOption.length) {
|
|
1120
|
-
this.store.addOption(
|
|
1121
|
-
new Option({
|
|
1122
|
-
text: '',
|
|
1123
|
-
value: '',
|
|
1124
|
-
selected: false,
|
|
1125
|
-
placeholder: true
|
|
1126
|
-
}),
|
|
1127
|
-
true
|
|
1128
|
-
)
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
// Append individual options to div container
|
|
1133
|
-
const fragment = document.createDocumentFragment()
|
|
1134
|
-
for (const d of data) {
|
|
1135
|
-
// Create optgroup
|
|
1136
|
-
if (d instanceof Optgroup) {
|
|
1137
|
-
// Create optgroup
|
|
1138
|
-
const optgroupEl = document.createElement('div')
|
|
1139
|
-
this.addClasses(optgroupEl, this.classes.optgroup)
|
|
1140
|
-
|
|
1141
|
-
// Create label
|
|
1142
|
-
const optgroupLabel = document.createElement('div')
|
|
1143
|
-
this.addClasses(optgroupLabel, this.classes.optgroupLabel)
|
|
1144
|
-
optgroupEl.appendChild(optgroupLabel)
|
|
1145
|
-
|
|
1146
|
-
// Create label text div element
|
|
1147
|
-
const optgroupLabelText = document.createElement('div')
|
|
1148
|
-
this.addClasses(optgroupLabelText, this.classes.optgroupLabelText)
|
|
1149
|
-
optgroupLabelText.textContent = d.label
|
|
1150
|
-
optgroupLabel.appendChild(optgroupLabelText)
|
|
1151
|
-
|
|
1152
|
-
// Create options container
|
|
1153
|
-
const optgroupActions = document.createElement('div')
|
|
1154
|
-
this.addClasses(optgroupActions, this.classes.optgroupActions)
|
|
1155
|
-
optgroupLabel.appendChild(optgroupActions)
|
|
1156
|
-
|
|
1157
|
-
// If selectByGroup is true and isMultiple then add click event to label
|
|
1158
|
-
if (this.settings.isMultiple && d.selectAll) {
|
|
1159
|
-
// Create new div to hold a checkbox svg
|
|
1160
|
-
const selectAll = document.createElement('div')
|
|
1161
|
-
this.addClasses(selectAll, this.classes.optgroupSelectAll)
|
|
1162
|
-
|
|
1163
|
-
// Check options and if all are selected, if so add class selected
|
|
1164
|
-
let allSelected = true
|
|
1165
|
-
for (const o of d.options) {
|
|
1166
|
-
if (!o.selected) {
|
|
1167
|
-
allSelected = false
|
|
1168
|
-
break
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
// Add class if all selected
|
|
1173
|
-
if (allSelected) {
|
|
1174
|
-
this.addClasses(selectAll, this.classes.selected)
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
// Add select all text span
|
|
1178
|
-
const selectAllText = document.createElement('span')
|
|
1179
|
-
selectAllText.textContent = d.selectAllText
|
|
1180
|
-
selectAll.appendChild(selectAllText)
|
|
1181
|
-
|
|
1182
|
-
// Create new svg for checkbox
|
|
1183
|
-
const selectAllSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
1184
|
-
selectAllSvg.setAttribute('viewBox', '0 0 100 100')
|
|
1185
|
-
selectAll.appendChild(selectAllSvg)
|
|
1186
|
-
|
|
1187
|
-
// Create new path for box
|
|
1188
|
-
const selectAllBox = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
1189
|
-
selectAllBox.setAttribute('d', this.classes.optgroupSelectAllBox)
|
|
1190
|
-
selectAllSvg.appendChild(selectAllBox)
|
|
1191
|
-
|
|
1192
|
-
// Create new path for check
|
|
1193
|
-
const selectAllCheck = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
1194
|
-
selectAllCheck.setAttribute('d', this.classes.optgroupSelectAllCheck)
|
|
1195
|
-
selectAllSvg.appendChild(selectAllCheck)
|
|
1196
|
-
|
|
1197
|
-
// Add click event listener to select all
|
|
1198
|
-
selectAll.addEventListener('click', (e: MouseEvent) => {
|
|
1199
|
-
e.preventDefault()
|
|
1200
|
-
e.stopPropagation()
|
|
1201
|
-
|
|
1202
|
-
// Get the store current selected values
|
|
1203
|
-
const currentSelected = this.store.getSelected()
|
|
1204
|
-
|
|
1205
|
-
// If all selected, remove all options from selected
|
|
1206
|
-
// call setSelected and return
|
|
1207
|
-
if (allSelected) {
|
|
1208
|
-
// Put together new list minus all options in this optgroup
|
|
1209
|
-
const newSelected = currentSelected.filter((s) => {
|
|
1210
|
-
for (const o of d.options) {
|
|
1211
|
-
if (s === o.id) {
|
|
1212
|
-
return false
|
|
1213
|
-
}
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
return true
|
|
1217
|
-
})
|
|
1218
|
-
|
|
1219
|
-
this.callbacks.setSelected(newSelected, true)
|
|
1220
|
-
return
|
|
1221
|
-
} else {
|
|
1222
|
-
// Put together new list with all options in this optgroup
|
|
1223
|
-
let optionIds = d.options.map((o) => o.id).filter((id) => id !== undefined)
|
|
1224
|
-
const newSelected = currentSelected.concat(optionIds)
|
|
1225
|
-
|
|
1226
|
-
// Loop through options and if they don't exist in the store
|
|
1227
|
-
// run addOption callback
|
|
1228
|
-
for (const o of d.options) {
|
|
1229
|
-
if (o.id && !this.store.getOptionByID(o.id)) {
|
|
1230
|
-
this.callbacks.addOption(new Option(o))
|
|
1231
|
-
}
|
|
1232
|
-
}
|
|
1233
|
-
|
|
1234
|
-
this.callbacks.setSelected(newSelected, true)
|
|
1235
|
-
return
|
|
1236
|
-
}
|
|
1237
|
-
})
|
|
1238
|
-
|
|
1239
|
-
// Append select all to label
|
|
1240
|
-
optgroupActions.appendChild(selectAll)
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
// If optgroup has collapsable
|
|
1244
|
-
if (d.closable !== 'off') {
|
|
1245
|
-
// Create new div to hold a checkbox svg
|
|
1246
|
-
const optgroupClosable = document.createElement('div')
|
|
1247
|
-
this.addClasses(optgroupClosable, this.classes.optgroupClosable)
|
|
1248
|
-
|
|
1249
|
-
// Create svg arrow
|
|
1250
|
-
const optgroupClosableSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
1251
|
-
optgroupClosableSvg.setAttribute('viewBox', '0 0 100 100')
|
|
1252
|
-
this.addClasses(optgroupClosableSvg, this.classes.arrow)
|
|
1253
|
-
optgroupClosable.appendChild(optgroupClosableSvg)
|
|
1254
|
-
|
|
1255
|
-
// Create new path for arrow
|
|
1256
|
-
const optgroupClosableArrow = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
1257
|
-
optgroupClosableSvg.appendChild(optgroupClosableArrow)
|
|
1258
|
-
|
|
1259
|
-
// If any options are selected or someone is searching, set optgroup to open
|
|
1260
|
-
if (d.options.some((o) => o.selected) || this.content.search.input.value.trim() !== '') {
|
|
1261
|
-
this.addClasses(optgroupClosable, this.classes.mainOpen)
|
|
1262
|
-
optgroupClosableArrow.setAttribute('d', this.classes.arrowOpen)
|
|
1263
|
-
} else if (d.closable === 'open') {
|
|
1264
|
-
this.addClasses(optgroupEl, this.classes.mainOpen)
|
|
1265
|
-
optgroupClosableArrow.setAttribute('d', this.classes.arrowOpen)
|
|
1266
|
-
} else if (d.closable === 'close') {
|
|
1267
|
-
this.addClasses(optgroupEl, this.classes.close)
|
|
1268
|
-
optgroupClosableArrow.setAttribute('d', this.classes.arrowClose)
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
// Add click event listener to close
|
|
1272
|
-
optgroupLabel.addEventListener('click', (e: MouseEvent) => {
|
|
1273
|
-
e.preventDefault()
|
|
1274
|
-
e.stopPropagation()
|
|
1275
|
-
|
|
1276
|
-
// If optgroup is closed, open it
|
|
1277
|
-
if (optgroupEl.classList.contains(this.classes.getFirst('close'))) {
|
|
1278
|
-
this.removeClasses(optgroupEl, this.classes.close)
|
|
1279
|
-
this.addClasses(optgroupEl, this.classes.mainOpen)
|
|
1280
|
-
optgroupClosableArrow.setAttribute('d', this.classes.arrowOpen)
|
|
1281
|
-
} else {
|
|
1282
|
-
this.removeClasses(optgroupEl, this.classes.mainOpen)
|
|
1283
|
-
this.addClasses(optgroupEl, this.classes.close)
|
|
1284
|
-
optgroupClosableArrow.setAttribute('d', this.classes.arrowClose)
|
|
1285
|
-
}
|
|
1286
|
-
})
|
|
1287
|
-
|
|
1288
|
-
// Append close to label
|
|
1289
|
-
optgroupActions.appendChild(optgroupClosable)
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
// Add optgroup label
|
|
1293
|
-
optgroupEl.appendChild(optgroupLabel)
|
|
1294
|
-
|
|
1295
|
-
// Loop through options
|
|
1296
|
-
for (const option of d.options) {
|
|
1297
|
-
optgroupEl.appendChild(this.option(new Option(option)))
|
|
1298
|
-
fragment.appendChild(optgroupEl)
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
// Create option
|
|
1303
|
-
if (d instanceof Option) {
|
|
1304
|
-
fragment.appendChild(this.option(d as Option))
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
// Append fragment to list
|
|
1309
|
-
this.content.list.appendChild(fragment)
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
// Create option div element
|
|
1313
|
-
public option(option: Option): HTMLDivElement {
|
|
1314
|
-
// Add hidden placeholder
|
|
1315
|
-
if (option.placeholder) {
|
|
1316
|
-
const placeholder = document.createElement('div')
|
|
1317
|
-
this.addClasses(placeholder, this.classes.option)
|
|
1318
|
-
this.addClasses(placeholder, this.classes.hide)
|
|
1319
|
-
return placeholder
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
// Create option
|
|
1323
|
-
const optionEl = document.createElement('div')
|
|
1324
|
-
optionEl.dataset.id = option.id // Dataset id for identifying an option
|
|
1325
|
-
optionEl.id = this.settings.id + '-' + option.id // Unique ID for ARIA references
|
|
1326
|
-
this.addClasses(optionEl, this.classes.option)
|
|
1327
|
-
optionEl.setAttribute('role', 'option') // WCAG attribute
|
|
1328
|
-
if (option.class) {
|
|
1329
|
-
option.class.split(' ').forEach((dataClass: string) => {
|
|
1330
|
-
optionEl.classList.add(dataClass)
|
|
1331
|
-
})
|
|
1332
|
-
}
|
|
1333
|
-
if (option.style) {
|
|
1334
|
-
optionEl.style.cssText = option.style
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
// Set option content
|
|
1338
|
-
if (this.settings.searchHighlight && this.content.search.input.value.trim() !== '') {
|
|
1339
|
-
optionEl.innerHTML = this.highlightText(
|
|
1340
|
-
option.html !== '' ? option.html : option.text,
|
|
1341
|
-
this.content.search.input.value,
|
|
1342
|
-
this.classes.searchHighlighter
|
|
1343
|
-
)
|
|
1344
|
-
} else if (option.html !== '') {
|
|
1345
|
-
optionEl.innerHTML = option.html
|
|
1346
|
-
} else {
|
|
1347
|
-
optionEl.textContent = option.text
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
// Set title attribute
|
|
1351
|
-
if (this.settings.showOptionTooltips && optionEl.textContent) {
|
|
1352
|
-
optionEl.setAttribute('title', optionEl.textContent)
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
// If option is disabled
|
|
1356
|
-
if (!option.display) {
|
|
1357
|
-
this.addClasses(optionEl, this.classes.hide)
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
// If allowed to deselect, null onclick and add disabled
|
|
1361
|
-
if (option.disabled) {
|
|
1362
|
-
this.addClasses(optionEl, this.classes.disabled)
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
|
-
// If option is selected and hideSelectedOption is true, hide it
|
|
1366
|
-
if (option.selected && this.settings.hideSelected) {
|
|
1367
|
-
this.addClasses(optionEl, this.classes.hide)
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
// If option is selected
|
|
1371
|
-
if (option.selected) {
|
|
1372
|
-
this.addClasses(optionEl, this.classes.selected)
|
|
1373
|
-
optionEl.setAttribute('aria-selected', 'true')
|
|
1374
|
-
this.main.main.setAttribute('aria-activedescendant', optionEl.id)
|
|
1375
|
-
} else {
|
|
1376
|
-
this.removeClasses(optionEl, this.classes.selected)
|
|
1377
|
-
optionEl.setAttribute('aria-selected', 'false')
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
// Add click event listener
|
|
1381
|
-
optionEl.addEventListener('click', (e: MouseEvent) => {
|
|
1382
|
-
e.preventDefault()
|
|
1383
|
-
e.stopPropagation()
|
|
1384
|
-
|
|
1385
|
-
// Setup variables
|
|
1386
|
-
const selectedOptions = this.store.getSelected()
|
|
1387
|
-
const element = e.currentTarget as HTMLDivElement
|
|
1388
|
-
const elementID = String(element.dataset.id)
|
|
1389
|
-
const isCmd = e.ctrlKey || e.metaKey // Cmd (Mac) or Ctrl (Windows/Linux)
|
|
1390
|
-
|
|
1391
|
-
// If the option is disabled, do nothing
|
|
1392
|
-
if (option.disabled) {
|
|
1393
|
-
return
|
|
1394
|
-
}
|
|
1395
|
-
|
|
1396
|
-
// allowDeselect only applies to single-select mode
|
|
1397
|
-
// In multi-select, you can always toggle options on/off
|
|
1398
|
-
if (!this.settings.isMultiple && option.selected && !this.settings.allowDeselect) {
|
|
1399
|
-
return
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
// Prevent deselection of mandatory options
|
|
1403
|
-
if (option.selected && option.mandatory) {
|
|
1404
|
-
return
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
// Check limit and do nothing if limit is reached and the option is not selected
|
|
1408
|
-
// Also check reverse for min limit and is selected (allow Cmd to bypass minSelected)
|
|
1409
|
-
if (
|
|
1410
|
-
(this.settings.isMultiple && this.settings.maxSelected <= selectedOptions.length && !option.selected) ||
|
|
1411
|
-
(this.settings.isMultiple && this.settings.minSelected >= selectedOptions.length && option.selected && !isCmd)
|
|
1412
|
-
) {
|
|
1413
|
-
return
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
// Setup variables
|
|
1417
|
-
let shouldUpdate = false
|
|
1418
|
-
const before = this.store.getSelectedOptions()
|
|
1419
|
-
let after = [] as Option[]
|
|
1420
|
-
|
|
1421
|
-
// If multiple - mimic native browser multi-select behavior
|
|
1422
|
-
if (this.settings.isMultiple) {
|
|
1423
|
-
const isCurrentlySelected = before.some((o: Option) => o.id === elementID)
|
|
1424
|
-
const isShift = e.shiftKey
|
|
1425
|
-
|
|
1426
|
-
// Shift+Click: Select range from last clicked to current
|
|
1427
|
-
if (isShift && this.lastSelectedOption) {
|
|
1428
|
-
const options = this.lastRenderedOptions
|
|
1429
|
-
const lastIndex = options.findIndex((o: Option) => o.id === this.lastSelectedOption!.id)
|
|
1430
|
-
const currentIndex = options.findIndex((o: Option) => o.id === option.id)
|
|
1431
|
-
|
|
1432
|
-
if (lastIndex >= 0 && currentIndex >= 0) {
|
|
1433
|
-
const startIndex = Math.min(lastIndex, currentIndex)
|
|
1434
|
-
const endIndex = Math.max(lastIndex, currentIndex)
|
|
1435
|
-
const rangeOptions = options.slice(startIndex, endIndex + 1)
|
|
1436
|
-
|
|
1437
|
-
// Check if range would exceed maxSelected
|
|
1438
|
-
const newSelections = rangeOptions.filter((opt) => !before.find((b) => b.id === opt.id))
|
|
1439
|
-
if (before.length + newSelections.length <= this.settings.maxSelected) {
|
|
1440
|
-
// Add range to existing selections
|
|
1441
|
-
after = before.concat(newSelections)
|
|
1442
|
-
} else {
|
|
1443
|
-
// Range too large, keep existing selections
|
|
1444
|
-
after = before
|
|
1445
|
-
}
|
|
1446
|
-
} else {
|
|
1447
|
-
after = before
|
|
1448
|
-
}
|
|
1449
|
-
}
|
|
1450
|
-
// Cmd/Ctrl+Click: Toggle selection without affecting others (keeps dropdown open)
|
|
1451
|
-
else if (isCmd) {
|
|
1452
|
-
if (isCurrentlySelected) {
|
|
1453
|
-
// Deselect this option
|
|
1454
|
-
after = before.filter((o: Option) => o.id !== elementID)
|
|
1455
|
-
} else {
|
|
1456
|
-
// Add this option to selection
|
|
1457
|
-
after = before.concat(option)
|
|
1458
|
-
}
|
|
1459
|
-
this.lastSelectedOption = option
|
|
1460
|
-
}
|
|
1461
|
-
// Regular Click: Toggle this option (add/remove), will close dropdown
|
|
1462
|
-
else {
|
|
1463
|
-
if (isCurrentlySelected) {
|
|
1464
|
-
// Deselect this option
|
|
1465
|
-
after = before.filter((o: Option) => o.id !== elementID)
|
|
1466
|
-
} else {
|
|
1467
|
-
// Add this option to selection
|
|
1468
|
-
after = before.concat(option)
|
|
1469
|
-
}
|
|
1470
|
-
this.lastSelectedOption = option
|
|
1471
|
-
}
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
// If single
|
|
1475
|
-
if (!this.settings.isMultiple) {
|
|
1476
|
-
if (option.selected) {
|
|
1477
|
-
// If selected after would remove
|
|
1478
|
-
after = []
|
|
1479
|
-
} else {
|
|
1480
|
-
// If not selected after would add
|
|
1481
|
-
after = [option]
|
|
1482
|
-
}
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
// If no beforeOnChange is set automatically update at end
|
|
1486
|
-
if (!this.callbacks.beforeChange) {
|
|
1487
|
-
shouldUpdate = true
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
if (this.callbacks.beforeChange) {
|
|
1491
|
-
// Check if beforeChange returns false
|
|
1492
|
-
if (this.callbacks.beforeChange(after, before) === false) {
|
|
1493
|
-
shouldUpdate = false
|
|
1494
|
-
} else {
|
|
1495
|
-
shouldUpdate = true
|
|
1496
|
-
}
|
|
1497
|
-
}
|
|
1498
|
-
|
|
1499
|
-
if (shouldUpdate) {
|
|
1500
|
-
// Check if the option exists in the store
|
|
1501
|
-
// if not run addOption callback
|
|
1502
|
-
if (!this.store.getOptionByID(elementID)) {
|
|
1503
|
-
this.callbacks.addOption(option)
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
// Get values from after and set as selected
|
|
1507
|
-
this.callbacks.setSelected(
|
|
1508
|
-
after.map((o: Option) => o.id),
|
|
1509
|
-
false
|
|
1510
|
-
)
|
|
1511
|
-
|
|
1512
|
-
// Close dropdown unless using modifier keys in multi-select
|
|
1513
|
-
// (mimics native multi-select behavior where you can keep selecting)
|
|
1514
|
-
const isModifierKey = e.ctrlKey || e.metaKey || e.shiftKey // Cmd/Ctrl or Shift
|
|
1515
|
-
const shouldClose = this.settings.closeOnSelect && !(this.settings.isMultiple && isModifierKey)
|
|
1516
|
-
|
|
1517
|
-
if (shouldClose) {
|
|
1518
|
-
this.callbacks.close()
|
|
1519
|
-
}
|
|
1520
|
-
|
|
1521
|
-
// callback that the value has changed
|
|
1522
|
-
if (this.callbacks.afterChange) {
|
|
1523
|
-
this.callbacks.afterChange(after)
|
|
1524
|
-
}
|
|
1525
|
-
}
|
|
1526
|
-
})
|
|
1527
|
-
|
|
1528
|
-
return optionEl
|
|
1529
|
-
}
|
|
1530
|
-
|
|
1531
|
-
public destroy(): void {
|
|
1532
|
-
// Clear any pending timeouts
|
|
1533
|
-
if (this.closeAnimationTimeout) {
|
|
1534
|
-
clearTimeout(this.closeAnimationTimeout)
|
|
1535
|
-
this.closeAnimationTimeout = null
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
// Remove main
|
|
1539
|
-
this.main.main.remove()
|
|
1540
|
-
|
|
1541
|
-
// Remove content
|
|
1542
|
-
this.content.main.remove()
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
private highlightText(str: string, search: any, className: string) {
|
|
1546
|
-
const searchTerm = search.trim()
|
|
1547
|
-
if (searchTerm === '') {
|
|
1548
|
-
return str
|
|
1549
|
-
}
|
|
1550
|
-
|
|
1551
|
-
// Escape special regex characters in the search term to prevent regex injection
|
|
1552
|
-
const escapedSearch = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
1553
|
-
|
|
1554
|
-
// Create a temporary div to parse HTML and work with text nodes only
|
|
1555
|
-
const tempDiv = document.createElement('div')
|
|
1556
|
-
tempDiv.innerHTML = str
|
|
1557
|
-
|
|
1558
|
-
// Function to recursively process text nodes
|
|
1559
|
-
const highlightTextNodes = (node: Node) => {
|
|
1560
|
-
if (node.nodeType === Node.TEXT_NODE) {
|
|
1561
|
-
const text = node.textContent || ''
|
|
1562
|
-
const regex = new RegExp('(' + escapedSearch + ')', 'i')
|
|
1563
|
-
|
|
1564
|
-
if (regex.test(text)) {
|
|
1565
|
-
// Create a temporary container for the highlighted content
|
|
1566
|
-
const wrapper = document.createElement('span')
|
|
1567
|
-
const parts = text.split(regex)
|
|
1568
|
-
|
|
1569
|
-
parts.forEach((part, index) => {
|
|
1570
|
-
if (part && regex.test(part)) {
|
|
1571
|
-
// This is the matched part - wrap it in mark
|
|
1572
|
-
const mark = document.createElement('mark')
|
|
1573
|
-
mark.className = className
|
|
1574
|
-
mark.textContent = part
|
|
1575
|
-
wrapper.appendChild(mark)
|
|
1576
|
-
} else if (part) {
|
|
1577
|
-
// This is not the matched part - keep as text
|
|
1578
|
-
wrapper.appendChild(document.createTextNode(part))
|
|
1579
|
-
}
|
|
1580
|
-
})
|
|
1581
|
-
|
|
1582
|
-
// Replace the text node with the wrapper
|
|
1583
|
-
node.parentNode?.replaceChild(wrapper, node)
|
|
1584
|
-
}
|
|
1585
|
-
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
1586
|
-
// Recursively process child nodes
|
|
1587
|
-
Array.from(node.childNodes).forEach((child) => highlightTextNodes(child))
|
|
1588
|
-
}
|
|
1589
|
-
}
|
|
1590
|
-
|
|
1591
|
-
// Process all text nodes in the temporary div
|
|
1592
|
-
Array.from(tempDiv.childNodes).forEach((node) => highlightTextNodes(node))
|
|
1593
|
-
|
|
1594
|
-
return tempDiv.innerHTML
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
private setContentDirection(direction: 'above' | 'below'): void {
|
|
1598
|
-
const isAbove = direction === 'above'
|
|
1599
|
-
const addClass = isAbove ? this.classes.dirAbove : this.classes.dirBelow
|
|
1600
|
-
const removeClass = isAbove ? this.classes.dirBelow : this.classes.dirAbove
|
|
1601
|
-
|
|
1602
|
-
// Set direction classes on both main and content
|
|
1603
|
-
this.removeClasses(this.main.main, removeClass)
|
|
1604
|
-
this.addClasses(this.main.main, addClass)
|
|
1605
|
-
this.removeClasses(this.content.main, removeClass)
|
|
1606
|
-
this.addClasses(this.content.main, addClass)
|
|
1607
|
-
|
|
1608
|
-
// Set margin to position content
|
|
1609
|
-
if (isAbove) {
|
|
1610
|
-
const mainHeight = this.main.main.offsetHeight
|
|
1611
|
-
const contentHeight = this.content.main.offsetHeight
|
|
1612
|
-
this.content.main.style.margin = '-' + (mainHeight + contentHeight - 1) + 'px 0px 0px 0px'
|
|
1613
|
-
} else {
|
|
1614
|
-
this.content.main.style.margin = '-1px 0px 0px 0px'
|
|
1615
|
-
}
|
|
1616
|
-
}
|
|
1617
|
-
|
|
1618
|
-
private setContentPosition(): void {
|
|
1619
|
-
if (this.settings.contentPosition === 'relative') {
|
|
1620
|
-
return
|
|
1621
|
-
}
|
|
1622
|
-
|
|
1623
|
-
// getBoundingClientRect() returns viewport-relative coordinates.
|
|
1624
|
-
const containerRect = this.main.main.getBoundingClientRect()
|
|
1625
|
-
let top: number
|
|
1626
|
-
let left: number
|
|
1627
|
-
|
|
1628
|
-
if (this.settings.contentPosition === 'fixed') {
|
|
1629
|
-
// position:fixed is relative to the viewport, so viewport coords work directly.
|
|
1630
|
-
top = containerRect.top + containerRect.height
|
|
1631
|
-
left = containerRect.left
|
|
1632
|
-
} else {
|
|
1633
|
-
// position:absolute is relative to the containing block. By default the content
|
|
1634
|
-
// is appended to document.body (static), so the containing block is the initial
|
|
1635
|
-
// containing block (document origin). We must convert viewport coords to document
|
|
1636
|
-
// coords by adding scroll offsets. Using offsetParent subtraction was unreliable
|
|
1637
|
-
// because offsetParent for body children varies across browsers (body vs html)
|
|
1638
|
-
// and body margin caused misalignment.
|
|
1639
|
-
top = containerRect.top + window.scrollY + containerRect.height
|
|
1640
|
-
left = containerRect.left + window.scrollX
|
|
1641
|
-
}
|
|
1642
|
-
|
|
1643
|
-
this.content.main.style.top = top + 'px'
|
|
1644
|
-
this.content.main.style.left = left + 'px'
|
|
1645
|
-
|
|
1646
|
-
// Apply content width based on contentWidth setting
|
|
1647
|
-
// "" → width matches trigger (default/current behavior)
|
|
1648
|
-
// "500px" → exact width of 500px
|
|
1649
|
-
// ">500px" → min-width of 500px, width auto (content can grow)
|
|
1650
|
-
// "<500px" → max-width of 500px, width auto (content can shrink)
|
|
1651
|
-
const cw = this.settings.contentWidth
|
|
1652
|
-
this.content.main.style.width = ''
|
|
1653
|
-
this.content.main.style.minWidth = ''
|
|
1654
|
-
this.content.main.style.maxWidth = ''
|
|
1655
|
-
|
|
1656
|
-
if (!cw) {
|
|
1657
|
-
// Default: match trigger width exactly
|
|
1658
|
-
this.content.main.style.width = containerRect.width + 'px'
|
|
1659
|
-
} else if (cw.startsWith('>')) {
|
|
1660
|
-
// Min-width mode: at least this wide, can grow to fit content
|
|
1661
|
-
this.content.main.style.minWidth = cw.slice(1)
|
|
1662
|
-
} else if (cw.startsWith('<')) {
|
|
1663
|
-
// Max-width mode: no wider than this, content wraps if longer
|
|
1664
|
-
this.content.main.style.maxWidth = cw.slice(1)
|
|
1665
|
-
} else {
|
|
1666
|
-
// Exact width
|
|
1667
|
-
this.content.main.style.width = cw
|
|
1668
|
-
}
|
|
1669
|
-
|
|
1670
|
-
// If content overflows the right side of the viewport, shift it left so the right edge
|
|
1671
|
-
// lines up with the trigger (or stays inside the viewport). Use rAF so we measure after
|
|
1672
|
-
// layout (and after width:auto / min-width have been applied).
|
|
1673
|
-
const padding = 20
|
|
1674
|
-
const viewportRight = window.innerWidth - padding
|
|
1675
|
-
|
|
1676
|
-
const applyOverflowShift = (): void => {
|
|
1677
|
-
const contentRect = this.content.main.getBoundingClientRect()
|
|
1678
|
-
const contentRight = contentRect.right
|
|
1679
|
-
if (contentRight <= viewportRight) return
|
|
1680
|
-
|
|
1681
|
-
const overflow = contentRight - viewportRight
|
|
1682
|
-
const currentLeft = parseFloat(this.content.main.style.left) || 0
|
|
1683
|
-
|
|
1684
|
-
if (this.settings.contentPosition === 'fixed') {
|
|
1685
|
-
const newLeft = Math.max(padding, currentLeft - overflow)
|
|
1686
|
-
this.content.main.style.left = newLeft + 'px'
|
|
1687
|
-
} else {
|
|
1688
|
-
const newLeft = Math.max(window.scrollX + padding, currentLeft - overflow)
|
|
1689
|
-
this.content.main.style.left = newLeft + 'px'
|
|
1690
|
-
}
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
// First rAF: layout done (width/min-width applied). Second rAF: catch scrollbar etc.
|
|
1694
|
-
requestAnimationFrame(() => {
|
|
1695
|
-
applyOverflowShift()
|
|
1696
|
-
requestAnimationFrame(applyOverflowShift)
|
|
1697
|
-
})
|
|
1698
|
-
}
|
|
1699
|
-
|
|
1700
|
-
public moveContentAbove(): void {
|
|
1701
|
-
this.setContentDirection('above')
|
|
1702
|
-
this.setContentPosition()
|
|
1703
|
-
}
|
|
1704
|
-
|
|
1705
|
-
public moveContentBelow(): void {
|
|
1706
|
-
this.setContentDirection('below')
|
|
1707
|
-
this.setContentPosition()
|
|
1708
|
-
}
|
|
1709
|
-
|
|
1710
|
-
public ensureElementInView(container: HTMLElement, element: HTMLElement): void {
|
|
1711
|
-
// Determine container top and bottom
|
|
1712
|
-
const cTop = container.scrollTop + container.offsetTop // Make sure to have offsetTop
|
|
1713
|
-
const cBottom = cTop + container.clientHeight
|
|
1714
|
-
|
|
1715
|
-
// Determine element top and bottom
|
|
1716
|
-
const eTop = element.offsetTop
|
|
1717
|
-
const eBottom = eTop + element.clientHeight
|
|
1718
|
-
|
|
1719
|
-
// Check if out of view
|
|
1720
|
-
if (eTop < cTop) {
|
|
1721
|
-
container.scrollTop -= cTop - eTop
|
|
1722
|
-
} else if (eBottom > cBottom) {
|
|
1723
|
-
container.scrollTop += eBottom - cBottom
|
|
1724
|
-
}
|
|
1725
|
-
}
|
|
1726
|
-
|
|
1727
|
-
public putContent(): 'up' | 'down' {
|
|
1728
|
-
// Get main and content height
|
|
1729
|
-
const mainHeight = this.main.main.offsetHeight
|
|
1730
|
-
const mainRect = this.main.main.getBoundingClientRect()
|
|
1731
|
-
const contentHeight = this.content.main.offsetHeight
|
|
1732
|
-
|
|
1733
|
-
// From bottom of mainHeight figure out if content will fit below without going below the window
|
|
1734
|
-
const spaceBelow = window.innerHeight - (mainRect.top + mainHeight)
|
|
1735
|
-
|
|
1736
|
-
// If space below is less than content height
|
|
1737
|
-
if (spaceBelow <= contentHeight) {
|
|
1738
|
-
// If space above is more than content height
|
|
1739
|
-
if (mainRect.top > contentHeight) {
|
|
1740
|
-
// Move content above
|
|
1741
|
-
return 'up'
|
|
1742
|
-
} else {
|
|
1743
|
-
// Move content below
|
|
1744
|
-
return 'down'
|
|
1745
|
-
}
|
|
1746
|
-
}
|
|
1747
|
-
|
|
1748
|
-
// Move content below
|
|
1749
|
-
return 'down'
|
|
1750
|
-
}
|
|
1751
|
-
|
|
1752
|
-
// Updates deselect based on item count and allowDeselect setting
|
|
1753
|
-
public updateDeselectAll(): void {
|
|
1754
|
-
if (!this.store || !this.settings) {
|
|
1755
|
-
return
|
|
1756
|
-
}
|
|
1757
|
-
const selected = this.store.getSelectedOptions()
|
|
1758
|
-
const hasSelectedItems = selected && selected.length > 0
|
|
1759
|
-
const isMultiple = this.settings.isMultiple
|
|
1760
|
-
const allowDeselect = this.settings.allowDeselect
|
|
1761
|
-
|
|
1762
|
-
const deselectButton = this.main.deselect.main
|
|
1763
|
-
const hideClass = this.classes.hide
|
|
1764
|
-
|
|
1765
|
-
if (allowDeselect && !(isMultiple && !hasSelectedItems)) {
|
|
1766
|
-
this.removeClasses(deselectButton, hideClass)
|
|
1767
|
-
} else {
|
|
1768
|
-
this.addClasses(deselectButton, hideClass)
|
|
1769
|
-
}
|
|
1770
|
-
}
|
|
1771
|
-
}
|