@inglorious/web 2.5.0 → 2.6.1

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.
@@ -0,0 +1,332 @@
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
+ * Resets the select entity with default state.
11
+ * @param {SelectEntity} entity
12
+ */
13
+ create(entity) {
14
+ initSelect(entity)
15
+ },
16
+
17
+ /**
18
+ * Opens the select dropdown.
19
+ * @param {SelectEntity} entity
20
+ */
21
+ open(entity) {
22
+ openSelect(entity)
23
+ },
24
+
25
+ /**
26
+ * Closes the select dropdown.
27
+ * @param {SelectEntity} entity
28
+ */
29
+ close(entity) {
30
+ closeSelect(entity)
31
+ },
32
+
33
+ /**
34
+ * Toggles the select dropdown open/closed state.
35
+ * @param {SelectEntity} entity
36
+ */
37
+ toggle(entity) {
38
+ if (entity.isOpen) {
39
+ closeSelect(entity)
40
+ } else {
41
+ openSelect(entity)
42
+ }
43
+ },
44
+
45
+ /**
46
+ * Selects an option.
47
+ * @param {SelectEntity} entity
48
+ * @param {SelectOption} option
49
+ */
50
+ optionSelect(entity, option) {
51
+ if (entity.isDisabled) return
52
+
53
+ const optionValue = getOptionValue(option)
54
+
55
+ if (entity.isMulti) {
56
+ // Multi-select: add or remove from array
57
+ const index = entity.selectedValue.indexOf(optionValue)
58
+
59
+ if (index === -1) {
60
+ entity.selectedValue.push(optionValue)
61
+ } else {
62
+ entity.selectedValue.splice(index, 1)
63
+ }
64
+ } else {
65
+ // Single select: substitute value and close
66
+ entity.selectedValue = optionValue
67
+ closeSelect(entity)
68
+ }
69
+ },
70
+
71
+ /**
72
+ * Clears the current selection.
73
+ * @param {SelectEntity} entity
74
+ */
75
+ clear(entity) {
76
+ if (entity.isDisabled) return
77
+
78
+ entity.selectedValue = entity.isMulti ? [] : null
79
+ },
80
+
81
+ /**
82
+ * Updates the search term and filters options.
83
+ * @param {SelectEntity} entity
84
+ * @param {string} searchTerm
85
+ */
86
+ searchChange(entity, searchTerm) {
87
+ entity.searchTerm = searchTerm
88
+
89
+ if (entity.isRemote) {
90
+ entity.isLoading = true
91
+ entity.error = null
92
+ return
93
+ }
94
+
95
+ const filteredOptions = filterOptions(entity.options, entity.searchTerm)
96
+
97
+ entity.focusedIndex = filteredOptions.length ? 0 : -1
98
+ },
99
+
100
+ /**
101
+ * Moves focus to the next option.
102
+ * @param {SelectEntity} entity
103
+ */
104
+ focusNext(entity) {
105
+ const filteredOptions = filterOptions(entity.options, entity.searchTerm)
106
+
107
+ if (!filteredOptions.length) return
108
+
109
+ entity.focusedIndex = Math.min(
110
+ entity.focusedIndex + 1,
111
+ filteredOptions.length - 1,
112
+ )
113
+ },
114
+
115
+ /**
116
+ * Moves focus to the previous option.
117
+ * @param {SelectEntity} entity
118
+ */
119
+ focusPrev(entity) {
120
+ entity.focusedIndex = Math.max(entity.focusedIndex - 1, -1)
121
+ },
122
+
123
+ /**
124
+ * Moves focus to the first option.
125
+ * @param {SelectEntity} entity
126
+ */
127
+ focusFirst(entity) {
128
+ const filteredOptions = filterOptions(entity.options, entity.searchTerm)
129
+
130
+ if (filteredOptions.length) {
131
+ entity.focusedIndex = 0
132
+ }
133
+ },
134
+
135
+ /**
136
+ * Moves focus to the last option.
137
+ * @param {SelectEntity} entity
138
+ */
139
+ focusLast(entity) {
140
+ const filteredOptions = filterOptions(entity.options, entity.searchTerm)
141
+ if (filteredOptions.length) {
142
+ entity.focusedIndex = filteredOptions.length - 1
143
+ }
144
+ },
145
+ }
146
+
147
+ // Helper functions
148
+
149
+ /**
150
+ * Get the value of an option.
151
+ * @param {SelectOption} option
152
+ * @returns {string|number}
153
+ */
154
+ export function getOptionValue(option) {
155
+ if (typeof option === "object" && option !== null && "value" in option) {
156
+ return option.value
157
+ }
158
+
159
+ return option
160
+ }
161
+
162
+ /**
163
+ * Get the label of an option.
164
+ * @param {SelectOption} option
165
+ * @returns {string}
166
+ */
167
+ export function getOptionLabel(option) {
168
+ if (typeof option === "object" && option !== null && "label" in option) {
169
+ return String(option.label)
170
+ }
171
+
172
+ if (typeof option === "object" && option !== null && "value" in option) {
173
+ return String(option.value)
174
+ }
175
+
176
+ return String(option)
177
+ }
178
+
179
+ /**
180
+ * Filter options based on searchTerm.
181
+ * Search in label (case-insensitive).
182
+ * @param {SelectOption[]} options
183
+ * @param {string} searchTerm
184
+ * @returns {SelectOption[]}
185
+ */
186
+ export function filterOptions(options, searchTerm) {
187
+ if (!searchTerm || searchTerm.trim() === "") {
188
+ return options
189
+ }
190
+
191
+ const searchLower = String(searchTerm).toLowerCase().trim()
192
+
193
+ return options.filter((option) => {
194
+ const label = getOptionLabel(option)
195
+ return label.toLowerCase().includes(searchLower)
196
+ })
197
+ }
198
+
199
+ /**
200
+ * Check if an option is selected.
201
+ * For single: compare value.
202
+ * For multi: check if it is in the array.
203
+ * @param {SelectOption} option
204
+ * @param {string|number|(string|number)[]} selectedValue
205
+ * @param {boolean} isMulti
206
+ * @returns {boolean}
207
+ */
208
+ export function isOptionSelected(option, selectedValue, isMulti) {
209
+ const optionValue = getOptionValue(option)
210
+
211
+ if (isMulti && Array.isArray(selectedValue)) {
212
+ return selectedValue.some((val) => val === optionValue)
213
+ }
214
+
215
+ return selectedValue === optionValue
216
+ }
217
+
218
+ /**
219
+ * Find the index of an option by value.
220
+ * @param {SelectOption[]} options
221
+ * @param {string|number} value
222
+ * @returns {number}
223
+ */
224
+ export function findOptionIndex(options, value) {
225
+ return options.findIndex((option) => getOptionValue(option) === value)
226
+ }
227
+
228
+ /**
229
+ * Group options by a property.
230
+ * Returns: [{label: "Group 1", options: [...]}, ...].
231
+ * @param {SelectOption[]} options
232
+ * @param {string} groupBy
233
+ * @returns {{label: string, options: SelectOption[]}[] | null}
234
+ */
235
+ export function groupOptions(options, groupBy) {
236
+ if (!groupBy || typeof groupBy !== "string") {
237
+ return null
238
+ }
239
+
240
+ const groups = new Map()
241
+
242
+ options.forEach((option) => {
243
+ const groupKey = option[groupBy] ?? "Ungrouped"
244
+
245
+ if (!groups.has(groupKey)) {
246
+ groups.set(groupKey, [])
247
+ }
248
+
249
+ groups.get(groupKey).push(option)
250
+ })
251
+
252
+ return Array.from(groups.entries()).map(([label, options]) => ({
253
+ label,
254
+ options,
255
+ }))
256
+ }
257
+
258
+ /**
259
+ * Normalize an option to the standard format {value, label}.
260
+ * Accepts: string, number, or object {value, label, ...}.
261
+ * @param {SelectOption} option
262
+ * @returns {{value: string|number, label: string}}
263
+ */
264
+ export function formatOption(option) {
265
+ if (typeof option === "string" || typeof option === "number") {
266
+ return { value: option, label: String(option) }
267
+ }
268
+
269
+ if (typeof option === "object" && option !== null) {
270
+ return {
271
+ value: option.value ?? option,
272
+ label: option.label ?? String(option.value ?? option),
273
+ ...option, // Preserve other properties (disabled, group, etc)
274
+ }
275
+ }
276
+
277
+ return { value: option, label: String(option) }
278
+ }
279
+
280
+ // Private helper functions
281
+
282
+ function initSelect(entity) {
283
+ // Dropdown state
284
+ entity.isOpen ??= false
285
+ entity.searchTerm ??= ""
286
+ entity.focusedIndex ??= -1
287
+
288
+ // Selected values
289
+ entity.isMulti ??= false
290
+ entity.selectedValue ??= entity.isMulti ? [] : null
291
+
292
+ // Options
293
+ entity.options ??= []
294
+
295
+ // States
296
+ entity.isLoading ??= false
297
+ entity.error ??= null
298
+ entity.isDisabled ??= false
299
+ entity.isSearchable ??= true
300
+ entity.isClearable ??= true
301
+ entity.isCreatable ??= false
302
+
303
+ // Messages
304
+ entity.placeholder ??= "Select..."
305
+ entity.noOptionsMessage ??= "No options"
306
+ entity.loadingMessage ??= "Loading..."
307
+
308
+ // Group by
309
+ entity.groupBy ??= null
310
+ }
311
+
312
+ function closeSelect(entity) {
313
+ entity.isOpen = false
314
+ entity.focusedIndex = -1
315
+ }
316
+
317
+ function openSelect(entity) {
318
+ if (entity.isDisabled) return
319
+
320
+ entity.isOpen = true
321
+
322
+ // If searchable, the input will be focused during rendering
323
+ // Reset focusedIndex
324
+ entity.focusedIndex = -1
325
+
326
+ const filteredOptions = filterOptions(entity.options, entity.searchTerm)
327
+
328
+ // if there are no filtered options and not loading, focus the first option
329
+ if (filteredOptions.length && !entity.isLoading) {
330
+ entity.focusedIndex = 0
331
+ }
332
+ }
@@ -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
+ }