@inglorious/web 2.4.0 → 2.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/README.md +223 -4
- package/package.json +6 -4
- package/src/form.js +4 -1
- package/src/form.test.js +9 -9
- package/src/index.js +1 -0
- package/src/list.js +2 -1
- package/src/list.test.js +1 -1
- package/src/router.js +8 -6
- package/src/router.test.js +14 -0
- package/src/select/base.css +52 -0
- package/src/select/logic.js +342 -0
- package/src/select/rendering.js +352 -0
- package/src/select/theme.css +133 -0
- package/src/select.js +7 -0
- package/src/select.test.js +415 -0
- package/src/table/logic.js +184 -2
- package/types/index.d.ts +3 -0
- package/types/select.d.ts +136 -0
- package/types/table.d.ts +239 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/* eslint-disable no-magic-numbers */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import('../../types/select').SelectEntity} SelectEntity
|
|
5
|
+
* @typedef {import('../../types/select').SelectOption} SelectOption
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const logic = {
|
|
9
|
+
/**
|
|
10
|
+
* Initializes the select entity with default state.
|
|
11
|
+
* @param {SelectEntity} entity
|
|
12
|
+
*/
|
|
13
|
+
init(entity) {
|
|
14
|
+
initSelect(entity)
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resets the select entity when a 'create' event payload matches its ID.
|
|
19
|
+
* @param {SelectEntity} entity
|
|
20
|
+
* @param {string|number} id
|
|
21
|
+
*/
|
|
22
|
+
create(entity, id) {
|
|
23
|
+
if (id !== entity.id) return
|
|
24
|
+
initSelect(entity)
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Opens the select dropdown.
|
|
29
|
+
* @param {SelectEntity} entity
|
|
30
|
+
*/
|
|
31
|
+
open(entity) {
|
|
32
|
+
openSelect(entity)
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Closes the select dropdown.
|
|
37
|
+
* @param {SelectEntity} entity
|
|
38
|
+
*/
|
|
39
|
+
close(entity) {
|
|
40
|
+
closeSelect(entity)
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Toggles the select dropdown open/closed state.
|
|
45
|
+
* @param {SelectEntity} entity
|
|
46
|
+
*/
|
|
47
|
+
toggle(entity) {
|
|
48
|
+
if (entity.isOpen) {
|
|
49
|
+
closeSelect(entity)
|
|
50
|
+
} else {
|
|
51
|
+
openSelect(entity)
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Selects an option.
|
|
57
|
+
* @param {SelectEntity} entity
|
|
58
|
+
* @param {SelectOption} option
|
|
59
|
+
*/
|
|
60
|
+
optionSelect(entity, option) {
|
|
61
|
+
if (entity.isDisabled) return
|
|
62
|
+
|
|
63
|
+
const optionValue = getOptionValue(option)
|
|
64
|
+
|
|
65
|
+
if (entity.isMulti) {
|
|
66
|
+
// Multi-select: add or remove from array
|
|
67
|
+
const index = entity.selectedValue.indexOf(optionValue)
|
|
68
|
+
|
|
69
|
+
if (index === -1) {
|
|
70
|
+
entity.selectedValue.push(optionValue)
|
|
71
|
+
} else {
|
|
72
|
+
entity.selectedValue.splice(index, 1)
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
// Single select: substitute value and close
|
|
76
|
+
entity.selectedValue = optionValue
|
|
77
|
+
closeSelect(entity)
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Clears the current selection.
|
|
83
|
+
* @param {SelectEntity} entity
|
|
84
|
+
*/
|
|
85
|
+
clear(entity) {
|
|
86
|
+
if (entity.isDisabled) return
|
|
87
|
+
|
|
88
|
+
entity.selectedValue = entity.isMulti ? [] : null
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Updates the search term and filters options.
|
|
93
|
+
* @param {SelectEntity} entity
|
|
94
|
+
* @param {string} searchTerm
|
|
95
|
+
*/
|
|
96
|
+
searchChange(entity, searchTerm) {
|
|
97
|
+
entity.searchTerm = searchTerm
|
|
98
|
+
|
|
99
|
+
if (entity.isRemote) {
|
|
100
|
+
entity.isLoading = true
|
|
101
|
+
entity.error = null
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const filteredOptions = filterOptions(entity.options, entity.searchTerm)
|
|
106
|
+
|
|
107
|
+
entity.focusedIndex = filteredOptions.length ? 0 : -1
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Moves focus to the next option.
|
|
112
|
+
* @param {SelectEntity} entity
|
|
113
|
+
*/
|
|
114
|
+
focusNext(entity) {
|
|
115
|
+
const filteredOptions = filterOptions(entity.options, entity.searchTerm)
|
|
116
|
+
|
|
117
|
+
if (!filteredOptions.length) return
|
|
118
|
+
|
|
119
|
+
entity.focusedIndex = Math.min(
|
|
120
|
+
entity.focusedIndex + 1,
|
|
121
|
+
filteredOptions.length - 1,
|
|
122
|
+
)
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Moves focus to the previous option.
|
|
127
|
+
* @param {SelectEntity} entity
|
|
128
|
+
*/
|
|
129
|
+
focusPrev(entity) {
|
|
130
|
+
entity.focusedIndex = Math.max(entity.focusedIndex - 1, -1)
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Moves focus to the first option.
|
|
135
|
+
* @param {SelectEntity} entity
|
|
136
|
+
*/
|
|
137
|
+
focusFirst(entity) {
|
|
138
|
+
const filteredOptions = filterOptions(entity.options, entity.searchTerm)
|
|
139
|
+
|
|
140
|
+
if (filteredOptions.length) {
|
|
141
|
+
entity.focusedIndex = 0
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Moves focus to the last option.
|
|
147
|
+
* @param {SelectEntity} entity
|
|
148
|
+
*/
|
|
149
|
+
focusLast(entity) {
|
|
150
|
+
const filteredOptions = filterOptions(entity.options, entity.searchTerm)
|
|
151
|
+
if (filteredOptions.length) {
|
|
152
|
+
entity.focusedIndex = filteredOptions.length - 1
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Helper functions
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get the value of an option.
|
|
161
|
+
* @param {SelectOption} option
|
|
162
|
+
* @returns {string|number}
|
|
163
|
+
*/
|
|
164
|
+
export function getOptionValue(option) {
|
|
165
|
+
if (typeof option === "object" && option !== null && "value" in option) {
|
|
166
|
+
return option.value
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return option
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get the label of an option.
|
|
174
|
+
* @param {SelectOption} option
|
|
175
|
+
* @returns {string}
|
|
176
|
+
*/
|
|
177
|
+
export function getOptionLabel(option) {
|
|
178
|
+
if (typeof option === "object" && option !== null && "label" in option) {
|
|
179
|
+
return String(option.label)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (typeof option === "object" && option !== null && "value" in option) {
|
|
183
|
+
return String(option.value)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return String(option)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Filter options based on searchTerm.
|
|
191
|
+
* Search in label (case-insensitive).
|
|
192
|
+
* @param {SelectOption[]} options
|
|
193
|
+
* @param {string} searchTerm
|
|
194
|
+
* @returns {SelectOption[]}
|
|
195
|
+
*/
|
|
196
|
+
export function filterOptions(options, searchTerm) {
|
|
197
|
+
if (!searchTerm || searchTerm.trim() === "") {
|
|
198
|
+
return options
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const searchLower = String(searchTerm).toLowerCase().trim()
|
|
202
|
+
|
|
203
|
+
return options.filter((option) => {
|
|
204
|
+
const label = getOptionLabel(option)
|
|
205
|
+
return label.toLowerCase().includes(searchLower)
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Check if an option is selected.
|
|
211
|
+
* For single: compare value.
|
|
212
|
+
* For multi: check if it is in the array.
|
|
213
|
+
* @param {SelectOption} option
|
|
214
|
+
* @param {string|number|(string|number)[]} selectedValue
|
|
215
|
+
* @param {boolean} isMulti
|
|
216
|
+
* @returns {boolean}
|
|
217
|
+
*/
|
|
218
|
+
export function isOptionSelected(option, selectedValue, isMulti) {
|
|
219
|
+
const optionValue = getOptionValue(option)
|
|
220
|
+
|
|
221
|
+
if (isMulti && Array.isArray(selectedValue)) {
|
|
222
|
+
return selectedValue.some((val) => val === optionValue)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return selectedValue === optionValue
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Find the index of an option by value.
|
|
230
|
+
* @param {SelectOption[]} options
|
|
231
|
+
* @param {string|number} value
|
|
232
|
+
* @returns {number}
|
|
233
|
+
*/
|
|
234
|
+
export function findOptionIndex(options, value) {
|
|
235
|
+
return options.findIndex((option) => getOptionValue(option) === value)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Group options by a property.
|
|
240
|
+
* Returns: [{label: "Group 1", options: [...]}, ...].
|
|
241
|
+
* @param {SelectOption[]} options
|
|
242
|
+
* @param {string} groupBy
|
|
243
|
+
* @returns {{label: string, options: SelectOption[]}[] | null}
|
|
244
|
+
*/
|
|
245
|
+
export function groupOptions(options, groupBy) {
|
|
246
|
+
if (!groupBy || typeof groupBy !== "string") {
|
|
247
|
+
return null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const groups = new Map()
|
|
251
|
+
|
|
252
|
+
options.forEach((option) => {
|
|
253
|
+
const groupKey = option[groupBy] ?? "Ungrouped"
|
|
254
|
+
|
|
255
|
+
if (!groups.has(groupKey)) {
|
|
256
|
+
groups.set(groupKey, [])
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
groups.get(groupKey).push(option)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
return Array.from(groups.entries()).map(([label, options]) => ({
|
|
263
|
+
label,
|
|
264
|
+
options,
|
|
265
|
+
}))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Normalize an option to the standard format {value, label}.
|
|
270
|
+
* Accepts: string, number, or object {value, label, ...}.
|
|
271
|
+
* @param {SelectOption} option
|
|
272
|
+
* @returns {{value: string|number, label: string}}
|
|
273
|
+
*/
|
|
274
|
+
export function formatOption(option) {
|
|
275
|
+
if (typeof option === "string" || typeof option === "number") {
|
|
276
|
+
return { value: option, label: String(option) }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (typeof option === "object" && option !== null) {
|
|
280
|
+
return {
|
|
281
|
+
value: option.value ?? option,
|
|
282
|
+
label: option.label ?? String(option.value ?? option),
|
|
283
|
+
...option, // Preserve other properties (disabled, group, etc)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { value: option, label: String(option) }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Private helper functions
|
|
291
|
+
|
|
292
|
+
function initSelect(entity) {
|
|
293
|
+
// Dropdown state
|
|
294
|
+
entity.isOpen ??= false
|
|
295
|
+
entity.searchTerm ??= ""
|
|
296
|
+
entity.focusedIndex ??= -1
|
|
297
|
+
|
|
298
|
+
// Selected values
|
|
299
|
+
entity.isMulti ??= false
|
|
300
|
+
entity.selectedValue ??= entity.isMulti ? [] : null
|
|
301
|
+
|
|
302
|
+
// Options
|
|
303
|
+
entity.options ??= []
|
|
304
|
+
|
|
305
|
+
// States
|
|
306
|
+
entity.isLoading ??= false
|
|
307
|
+
entity.error ??= null
|
|
308
|
+
entity.isDisabled ??= false
|
|
309
|
+
entity.isSearchable ??= true
|
|
310
|
+
entity.isClearable ??= true
|
|
311
|
+
entity.isCreatable ??= false
|
|
312
|
+
|
|
313
|
+
// Messages
|
|
314
|
+
entity.placeholder ??= "Select..."
|
|
315
|
+
entity.noOptionsMessage ??= "No options"
|
|
316
|
+
entity.loadingMessage ??= "Loading..."
|
|
317
|
+
|
|
318
|
+
// Group by
|
|
319
|
+
entity.groupBy ??= null
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function closeSelect(entity) {
|
|
323
|
+
entity.isOpen = false
|
|
324
|
+
entity.focusedIndex = -1
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function openSelect(entity) {
|
|
328
|
+
if (entity.isDisabled) return
|
|
329
|
+
|
|
330
|
+
entity.isOpen = true
|
|
331
|
+
|
|
332
|
+
// If searchable, the input will be focused during rendering
|
|
333
|
+
// Reset focusedIndex
|
|
334
|
+
entity.focusedIndex = -1
|
|
335
|
+
|
|
336
|
+
const filteredOptions = filterOptions(entity.options, entity.searchTerm)
|
|
337
|
+
|
|
338
|
+
// if there are no filtered options and not loading, focus the first option
|
|
339
|
+
if (filteredOptions.length && !entity.isLoading) {
|
|
340
|
+
entity.focusedIndex = 0
|
|
341
|
+
}
|
|
342
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { html } from "lit-html"
|
|
2
|
+
import { classMap } from "lit-html/directives/class-map.js"
|
|
3
|
+
import { ref } from "lit-html/directives/ref.js"
|
|
4
|
+
import { repeat } from "lit-html/directives/repeat.js"
|
|
5
|
+
import { when } from "lit-html/directives/when.js"
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
filterOptions,
|
|
9
|
+
getOptionLabel,
|
|
10
|
+
getOptionValue,
|
|
11
|
+
isOptionSelected,
|
|
12
|
+
} from "./logic.js"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {import('../../types/select').SelectEntity} SelectEntity
|
|
16
|
+
* @typedef {import('../../types/select').SelectOption} SelectOption
|
|
17
|
+
* @typedef {import('../../types/mount').Api} Api
|
|
18
|
+
* @typedef {import('lit-html').TemplateResult} TemplateResult
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const rendering = {
|
|
22
|
+
/**
|
|
23
|
+
* Main render function.
|
|
24
|
+
* @param {SelectEntity} entity
|
|
25
|
+
* @param {Api} api
|
|
26
|
+
* @returns {TemplateResult}
|
|
27
|
+
*/
|
|
28
|
+
render(entity, api) {
|
|
29
|
+
const type = api.getType(entity.type)
|
|
30
|
+
|
|
31
|
+
return html`<div class="iw-select">
|
|
32
|
+
${type.renderControl(entity, api)}
|
|
33
|
+
${when(entity.isOpen, () => type.renderDropdown(entity, api))}
|
|
34
|
+
</div>`
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Render the control (input/button that opens the select).
|
|
39
|
+
* @param {SelectEntity} entity
|
|
40
|
+
* @param {Api} api
|
|
41
|
+
* @returns {TemplateResult}
|
|
42
|
+
*/
|
|
43
|
+
renderControl(entity, api) {
|
|
44
|
+
const type = api.getType(entity.type)
|
|
45
|
+
|
|
46
|
+
return html`<div
|
|
47
|
+
class="iw-select-control ${classMap({
|
|
48
|
+
"iw-select-control-open": entity.isOpen,
|
|
49
|
+
"iw-select-control-disabled": entity.isDisabled,
|
|
50
|
+
"iw-select-control-selection":
|
|
51
|
+
entity.isMulti && entity.selectedValue.length,
|
|
52
|
+
})}"
|
|
53
|
+
@click=${() => !entity.isDisabled && api.notify(`#${entity.id}:toggle`)}
|
|
54
|
+
>
|
|
55
|
+
${when(
|
|
56
|
+
entity.isMulti,
|
|
57
|
+
() => type.renderMultiValue(entity, api),
|
|
58
|
+
() => type.renderSingleValue(entity, api),
|
|
59
|
+
)}
|
|
60
|
+
${when(
|
|
61
|
+
entity.isClearable &&
|
|
62
|
+
((entity.isMulti && entity.selectedValue.length) ||
|
|
63
|
+
(!entity.isMulti && entity.selectedValue !== null)),
|
|
64
|
+
() =>
|
|
65
|
+
html`<div
|
|
66
|
+
class="iw-select-clear"
|
|
67
|
+
@click=${(event) => {
|
|
68
|
+
event.stopPropagation()
|
|
69
|
+
api.notify(`#${entity.id}:clear`)
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<span>×</span>
|
|
73
|
+
</div>`,
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
<div class="iw-select-arrow"><span>▼</span></div>
|
|
77
|
+
</div>`
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Render the selected value (single).
|
|
82
|
+
* @param {SelectEntity} entity
|
|
83
|
+
* @returns {TemplateResult}
|
|
84
|
+
*/
|
|
85
|
+
renderSingleValue(entity) {
|
|
86
|
+
if (entity.selectedValue === null) {
|
|
87
|
+
return html`<span class="iw-select-placeholder"
|
|
88
|
+
>${entity.placeholder}</span
|
|
89
|
+
>`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const selectedOption = entity.options.find(
|
|
93
|
+
(opt) => getOptionValue(opt) === entity.selectedValue,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return html`<span class="iw-select-value"
|
|
97
|
+
>${selectedOption
|
|
98
|
+
? getOptionLabel(selectedOption)
|
|
99
|
+
: entity.selectedValue}</span
|
|
100
|
+
>`
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Render the selected values (multi-select).
|
|
105
|
+
* @param {SelectEntity} entity
|
|
106
|
+
* @param {Api} api
|
|
107
|
+
* @returns {TemplateResult}
|
|
108
|
+
*/
|
|
109
|
+
renderMultiValue(entity, api) {
|
|
110
|
+
const type = api.getType(entity.type)
|
|
111
|
+
|
|
112
|
+
if (!Array.isArray(entity.selectedValue) || !entity.selectedValue.length) {
|
|
113
|
+
return html`<span class="iw-select-placeholder"
|
|
114
|
+
>${entity.placeholder}</span
|
|
115
|
+
>`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return html`<div class="iw-select-multi-value">
|
|
119
|
+
${repeat(
|
|
120
|
+
entity.selectedValue,
|
|
121
|
+
(value) => value,
|
|
122
|
+
(value) => type.renderMultiValueTag(entity, value, api),
|
|
123
|
+
)}
|
|
124
|
+
</div>`
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Render a tag for a selected value in multi-select mode.
|
|
129
|
+
* @param {SelectEntity} entity
|
|
130
|
+
* @param {string|number} value
|
|
131
|
+
* @param {Api} api
|
|
132
|
+
* @returns {TemplateResult}
|
|
133
|
+
*/
|
|
134
|
+
renderMultiValueTag(entity, value, api) {
|
|
135
|
+
const option = entity.options.find((opt) => getOptionValue(opt) === value)
|
|
136
|
+
const label = option ? getOptionLabel(option) : String(value)
|
|
137
|
+
|
|
138
|
+
return html`<div
|
|
139
|
+
class="iw-select-multi-value-tag"
|
|
140
|
+
@click=${(event) => {
|
|
141
|
+
event.stopPropagation()
|
|
142
|
+
api.notify(`#${entity.id}:optionSelect`, option || { value })
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
<span class="iw-select-multi-value-tag-label">${label}</span>
|
|
146
|
+
<span class="iw-select-multi-value-remove"> × </span>
|
|
147
|
+
</div>`
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Render the dropdown.
|
|
152
|
+
* @param {SelectEntity} entity
|
|
153
|
+
* @param {Api} api
|
|
154
|
+
* @returns {TemplateResult}
|
|
155
|
+
*/
|
|
156
|
+
renderDropdown(entity, api) {
|
|
157
|
+
const type = api.getType(entity.type)
|
|
158
|
+
|
|
159
|
+
const filteredOptions = filterOptions(entity.options, entity.searchTerm)
|
|
160
|
+
|
|
161
|
+
return html`<div
|
|
162
|
+
class="iw-select-dropdown"
|
|
163
|
+
${ref((el) => {
|
|
164
|
+
if (el) {
|
|
165
|
+
setTimeout(() => {
|
|
166
|
+
document.addEventListener(
|
|
167
|
+
"click",
|
|
168
|
+
(event) => {
|
|
169
|
+
if (!el.contains(event.target)) {
|
|
170
|
+
api.notify(`#${entity.id}:close`)
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
{ once: true },
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
})}
|
|
178
|
+
>
|
|
179
|
+
${when(entity.isSearchable, () => type.renderSearchInput(entity, api))}
|
|
180
|
+
${when(entity.isLoading, () => type.renderLoading(entity, api))}
|
|
181
|
+
${when(!entity.isLoading && !filteredOptions.length, () =>
|
|
182
|
+
type.renderNoOptions(entity, api),
|
|
183
|
+
)}
|
|
184
|
+
${when(!entity.isLoading && filteredOptions.length, () =>
|
|
185
|
+
type.renderOptions(entity, api),
|
|
186
|
+
)}
|
|
187
|
+
</div>`
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Render the search input.
|
|
192
|
+
* @param {SelectEntity} entity
|
|
193
|
+
* @param {Api} api
|
|
194
|
+
* @returns {TemplateResult}
|
|
195
|
+
*/
|
|
196
|
+
renderSearchInput(entity, api) {
|
|
197
|
+
return html`<input
|
|
198
|
+
class="iw-select-dropdown-search"
|
|
199
|
+
type="text"
|
|
200
|
+
placeholder="Search..."
|
|
201
|
+
.value=${entity.searchTerm}
|
|
202
|
+
@input=${(event) =>
|
|
203
|
+
api.notify(`#${entity.id}:searchChange`, event.target.value)}
|
|
204
|
+
@keydown=${(event) => handleKeyDown(entity, event, api)}
|
|
205
|
+
${ref((el) => {
|
|
206
|
+
if (el && entity.isOpen) {
|
|
207
|
+
// Focus input when dropdown opens
|
|
208
|
+
queueMicrotask(() => el.focus())
|
|
209
|
+
}
|
|
210
|
+
})}
|
|
211
|
+
/>`
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Render the list of options.
|
|
216
|
+
* @param {SelectEntity} entity
|
|
217
|
+
* @param {Api} api
|
|
218
|
+
* @returns {TemplateResult}
|
|
219
|
+
*/
|
|
220
|
+
renderOptions(entity, api) {
|
|
221
|
+
const type = api.getType(entity.type)
|
|
222
|
+
|
|
223
|
+
const filteredOptions = filterOptions(entity.options, entity.searchTerm)
|
|
224
|
+
|
|
225
|
+
return html`<div class="iw-select-dropdown-options">
|
|
226
|
+
${repeat(
|
|
227
|
+
filteredOptions,
|
|
228
|
+
(option) => getOptionValue(option),
|
|
229
|
+
(option, index) => type.renderOption(entity, option, index, api),
|
|
230
|
+
)}
|
|
231
|
+
</div>`
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Render an individual option.
|
|
236
|
+
* @param {SelectEntity} entity
|
|
237
|
+
* @param {SelectOption} option
|
|
238
|
+
* @param {number} index
|
|
239
|
+
* @param {Api} api
|
|
240
|
+
* @returns {TemplateResult}
|
|
241
|
+
*/
|
|
242
|
+
renderOption(entity, option, index, api) {
|
|
243
|
+
const optionLabel = getOptionLabel(option)
|
|
244
|
+
const isSelected = isOptionSelected(
|
|
245
|
+
option,
|
|
246
|
+
entity.selectedValue,
|
|
247
|
+
entity.isMulti,
|
|
248
|
+
)
|
|
249
|
+
const isFocused = index === entity.focusedIndex
|
|
250
|
+
|
|
251
|
+
return html`<div
|
|
252
|
+
class="iw-select-dropdown-options-option ${classMap({
|
|
253
|
+
"iw-select-dropdown-options-option-selected": isSelected,
|
|
254
|
+
"iw-select-dropdown-options-option-focused": isFocused,
|
|
255
|
+
"iw-select-dropdown-options-option-disabled": option.isDisabled,
|
|
256
|
+
})}"
|
|
257
|
+
@click=${() =>
|
|
258
|
+
!option.isDisabled && api.notify(`#${entity.id}:optionSelect`, option)}
|
|
259
|
+
@mouseenter=${() => (entity.focusedIndex = index)}
|
|
260
|
+
>
|
|
261
|
+
${when(
|
|
262
|
+
entity.isMulti,
|
|
263
|
+
() =>
|
|
264
|
+
html`<input
|
|
265
|
+
type="checkbox"
|
|
266
|
+
.checked=${isSelected}
|
|
267
|
+
?disabled=${option.isDisabled}
|
|
268
|
+
/>`,
|
|
269
|
+
)}
|
|
270
|
+
<span>${optionLabel}</span>
|
|
271
|
+
</div>`
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Render the loading state.
|
|
276
|
+
* @param {SelectEntity} entity
|
|
277
|
+
* @returns {TemplateResult}
|
|
278
|
+
*/
|
|
279
|
+
renderLoading(entity) {
|
|
280
|
+
return html`<div class="iw-select-loading">${entity.loadingMessage}</div>`
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Render when there are no options.
|
|
285
|
+
* @param {SelectEntity} entity
|
|
286
|
+
* @returns {TemplateResult}
|
|
287
|
+
*/
|
|
288
|
+
renderNoOptions(entity) {
|
|
289
|
+
return html`<div class="iw-select-no-options">
|
|
290
|
+
${entity.noOptionsMessage}
|
|
291
|
+
</div>`
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Keyboard navigation handler.
|
|
297
|
+
* @param {SelectEntity} entity
|
|
298
|
+
* @param {KeyboardEvent} event
|
|
299
|
+
* @param {Api} api
|
|
300
|
+
*/
|
|
301
|
+
function handleKeyDown(entity, event, api) {
|
|
302
|
+
const filteredOptions = filterOptions(entity.options, entity.searchTerm)
|
|
303
|
+
|
|
304
|
+
switch (event.key) {
|
|
305
|
+
case "ArrowDown":
|
|
306
|
+
event.preventDefault()
|
|
307
|
+
api.notify(`#${entity.id}:focusNext`)
|
|
308
|
+
break
|
|
309
|
+
|
|
310
|
+
case "ArrowUp":
|
|
311
|
+
event.preventDefault()
|
|
312
|
+
api.notify(`#${entity.id}:focusPrev`)
|
|
313
|
+
break
|
|
314
|
+
|
|
315
|
+
case "Enter":
|
|
316
|
+
event.preventDefault()
|
|
317
|
+
if (
|
|
318
|
+
entity.focusedIndex &&
|
|
319
|
+
!filteredOptions[entity.focusedIndex].isDisabled
|
|
320
|
+
) {
|
|
321
|
+
if (!entity.isMulti) {
|
|
322
|
+
// trigger the outside click listener to consume it
|
|
323
|
+
setTimeout(() => document.body.click())
|
|
324
|
+
}
|
|
325
|
+
api.notify(
|
|
326
|
+
`#${entity.id}:optionSelect`,
|
|
327
|
+
filteredOptions[entity.focusedIndex],
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
break
|
|
331
|
+
|
|
332
|
+
case "Escape":
|
|
333
|
+
event.preventDefault()
|
|
334
|
+
// trigger the outside click listener to consume it
|
|
335
|
+
setTimeout(() => document.body.click())
|
|
336
|
+
api.notify(`#${entity.id}:close`)
|
|
337
|
+
break
|
|
338
|
+
|
|
339
|
+
case "Home":
|
|
340
|
+
event.preventDefault()
|
|
341
|
+
api.notify(`#${entity.id}:focusFirst`)
|
|
342
|
+
break
|
|
343
|
+
|
|
344
|
+
case "End":
|
|
345
|
+
event.preventDefault()
|
|
346
|
+
api.notify(`#${entity.id}:focusLast`)
|
|
347
|
+
break
|
|
348
|
+
|
|
349
|
+
default:
|
|
350
|
+
break
|
|
351
|
+
}
|
|
352
|
+
}
|