@inglorious/web 2.5.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.
@@ -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
+ }