@oslokommune/punkt-elements 15.4.5 → 16.0.2
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/CHANGELOG.md +34 -0
- package/dist/{card-CnPjrdre.js → card-CmfUyl_s.js} +1 -1
- package/dist/{card-5S2r9UD1.cjs → card-Db9QSEqh.cjs} +1 -1
- package/dist/{checkbox-D98_NjcU.cjs → checkbox-Cpyay9_l.cjs} +1 -1
- package/dist/{checkbox-BSz71IeT.js → checkbox-D6nltMuc.js} +1 -1
- package/dist/combobox-Bv37b6cI.cjs +135 -0
- package/dist/combobox-CoO8T-F-.js +818 -0
- package/dist/{datepicker-SEKblnRR.cjs → datepicker-CrvQ5Y5w.cjs} +1 -1
- package/dist/{datepicker-nnyTW0vf.js → datepicker-DbsIuC5Z.js} +2 -2
- package/dist/index.d.ts +157 -90
- package/dist/{input-element-Bkv6Yxld.js → input-element-BGNbdzy2.js} +1 -1
- package/dist/{input-element-DM0tY799.cjs → input-element-CSDVA3Y6.cjs} +1 -1
- package/dist/listbox-Dm2mKp6_.cjs +101 -0
- package/dist/listbox-OdkIn9_A.js +431 -0
- package/dist/pkt-card.cjs +1 -1
- package/dist/pkt-card.js +1 -1
- package/dist/pkt-checkbox.cjs +1 -1
- package/dist/pkt-checkbox.js +1 -1
- package/dist/pkt-combobox.cjs +1 -1
- package/dist/pkt-combobox.js +1 -1
- package/dist/pkt-datepicker.cjs +1 -1
- package/dist/pkt-datepicker.js +2 -2
- package/dist/pkt-header.cjs +1 -1
- package/dist/pkt-header.js +1 -1
- package/dist/pkt-index.cjs +1 -1
- package/dist/pkt-index.js +9 -9
- package/dist/pkt-listbox.cjs +1 -1
- package/dist/pkt-listbox.js +1 -1
- package/dist/pkt-options-controller-BogGk-6J.cjs +1 -0
- package/dist/{pkt-options-controller-BcGywCmf.js → pkt-options-controller-Z-bPox7n.js} +2 -2
- package/dist/pkt-radiobutton.cjs +1 -1
- package/dist/pkt-radiobutton.js +1 -1
- package/dist/pkt-select.cjs +1 -1
- package/dist/pkt-select.js +1 -1
- package/dist/pkt-tag.cjs +1 -1
- package/dist/pkt-tag.js +1 -1
- package/dist/pkt-textarea.cjs +1 -1
- package/dist/pkt-textarea.js +1 -1
- package/dist/pkt-textinput.cjs +1 -1
- package/dist/pkt-textinput.js +1 -1
- package/dist/{radiobutton-95wp024h.cjs → radiobutton-CNHCpKn0.cjs} +1 -1
- package/dist/{radiobutton-CTFAV5GU.js → radiobutton-DgC27mb0.js} +1 -1
- package/dist/{select-YLvYAQX6.js → select-7VuYtPZv.js} +2 -2
- package/dist/{select-CZ_Lx5W6.cjs → select-PWPy5gTB.cjs} +1 -1
- package/dist/{tag-68q0_Sn0.js → tag-DZPqFiem.js} +37 -33
- package/dist/tag-DmbgBCKu.cjs +27 -0
- package/dist/{textarea-CuTsE1WX.cjs → textarea-CO7Ikug5.cjs} +1 -1
- package/dist/{textarea-DhWH99qN.js → textarea-VpCEjVFx.js} +1 -1
- package/dist/{textinput-BCi9p0Du.js → textinput-C2AZ9ss2.js} +1 -1
- package/dist/{textinput-st4Vml5J.cjs → textinput-DRFZU3dA.cjs} +1 -1
- package/package.json +4 -4
- package/src/components/card/card.ts +1 -0
- package/src/components/combobox/combobox-base.ts +158 -0
- package/src/components/combobox/combobox-handlers.ts +419 -0
- package/src/components/combobox/combobox-types.ts +10 -0
- package/src/components/combobox/combobox-utils.ts +135 -0
- package/src/components/combobox/combobox-value.ts +248 -0
- package/src/components/combobox/combobox.accessibility.test.ts +243 -0
- package/src/components/combobox/{combobox.test.ts → combobox.core.test.ts} +104 -46
- package/src/components/combobox/combobox.interaction.test.ts +436 -0
- package/src/components/combobox/combobox.selection.test.ts +543 -0
- package/src/components/combobox/combobox.ts +260 -734
- package/src/components/listbox/index.ts +2 -0
- package/src/components/listbox/listbox.interaction.test.ts +580 -0
- package/src/components/listbox/listbox.test.ts +32 -6
- package/src/components/listbox/listbox.ts +109 -126
- package/src/components/tag/tag.ts +3 -0
- package/dist/combobox-C5YcNVSZ.cjs +0 -128
- package/dist/combobox-cer7PLSE.js +0 -533
- package/dist/listbox-C7NEa9SU.cjs +0 -96
- package/dist/listbox-Cykec1bj.js +0 -361
- package/dist/pkt-options-controller-BnTmkl3g.cjs +0 -1
- package/dist/tag-BnT5onW2.cjs +0 -26
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { findOptionByValue, findTypeaheadMatches } from 'shared-utils/combobox/option-utils'
|
|
2
|
+
import {
|
|
3
|
+
getInputKeyAction,
|
|
4
|
+
getInputValueAction,
|
|
5
|
+
checkForMatches,
|
|
6
|
+
getSingleValueForInput,
|
|
7
|
+
} from 'shared-utils/combobox/input-utils'
|
|
8
|
+
import { ComboboxValue } from './combobox-value'
|
|
9
|
+
import type { PktTag } from '../tag'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Event handler layer for PktCombobox.
|
|
13
|
+
* Handles user interactions: input, focus, keyboard, clicks, tags.
|
|
14
|
+
*/
|
|
15
|
+
export class ComboboxHandlers extends ComboboxValue {
|
|
16
|
+
protected handleInput(e: InputEvent): void {
|
|
17
|
+
e.stopPropagation()
|
|
18
|
+
e.stopImmediatePropagation()
|
|
19
|
+
|
|
20
|
+
if (this.disabled) return
|
|
21
|
+
|
|
22
|
+
this.touched = true
|
|
23
|
+
const input = e.target as HTMLInputElement
|
|
24
|
+
this._search = input.value
|
|
25
|
+
this.checkForMatches()
|
|
26
|
+
|
|
27
|
+
if (this.typeahead) {
|
|
28
|
+
if (this._search) {
|
|
29
|
+
const { filtered, suggestion } = findTypeaheadMatches(this.options, this._search)
|
|
30
|
+
this._options = filtered
|
|
31
|
+
|
|
32
|
+
if (
|
|
33
|
+
e.inputType !== 'deleteContentBackward' &&
|
|
34
|
+
suggestion?.label &&
|
|
35
|
+
this.inputRef.value &&
|
|
36
|
+
this.inputRef.value.type !== 'hidden'
|
|
37
|
+
) {
|
|
38
|
+
input.value = suggestion.label
|
|
39
|
+
window.setTimeout(
|
|
40
|
+
() => input.setSelectionRange(this._search.length, input.value.length),
|
|
41
|
+
0,
|
|
42
|
+
)
|
|
43
|
+
input.selectionDirection = 'backward'
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
this._options = [...this.options]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
protected handleFocus(): void {
|
|
52
|
+
if (this.disabled) return
|
|
53
|
+
|
|
54
|
+
// After selecting a value in single+typeahead, focus returns to the input.
|
|
55
|
+
// Skip reopening the dropdown so screen readers announce the selected value.
|
|
56
|
+
if (this._suppressNextOpen) {
|
|
57
|
+
this._suppressNextOpen = false
|
|
58
|
+
this._inputFocus = true
|
|
59
|
+
this.requestUpdate()
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (
|
|
64
|
+
!this.multiple &&
|
|
65
|
+
this._value[0] &&
|
|
66
|
+
this.inputRef.value &&
|
|
67
|
+
this.inputRef.value.type !== 'hidden'
|
|
68
|
+
) {
|
|
69
|
+
this.inputRef.value.value = getSingleValueForInput(
|
|
70
|
+
this._value[0],
|
|
71
|
+
this.options,
|
|
72
|
+
this.displayValueAs,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
this._inputFocus = true
|
|
76
|
+
this._search = ''
|
|
77
|
+
this._options = [...this.options]
|
|
78
|
+
this._isOptionsOpen = true
|
|
79
|
+
this.onFocus()
|
|
80
|
+
|
|
81
|
+
this.requestUpdate()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
protected handleFocusOut(e: FocusEvent): void {
|
|
85
|
+
if (this.disabled || !this._isOptionsOpen) return
|
|
86
|
+
|
|
87
|
+
const related = e.relatedTarget as Element | null
|
|
88
|
+
const isFocusInsideCombobox =
|
|
89
|
+
related?.closest('pkt-combobox')?.id === this.id ||
|
|
90
|
+
(e.target as Element)?.getAttribute('data-focusfix') === this.id ||
|
|
91
|
+
related === this.inputRef.value ||
|
|
92
|
+
related === this.triggerRef.value
|
|
93
|
+
|
|
94
|
+
if (!isFocusInsideCombobox) {
|
|
95
|
+
this.closeAndProcessInput()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Shared close logic used by both focusout and outside-click handlers.
|
|
101
|
+
* Processes any pending input value, then closes the dropdown.
|
|
102
|
+
*/
|
|
103
|
+
protected closeAndProcessInput(): void {
|
|
104
|
+
this._inputFocus = false
|
|
105
|
+
this._addValueText = null
|
|
106
|
+
this._userInfoMessage = ''
|
|
107
|
+
this._search = ''
|
|
108
|
+
|
|
109
|
+
if (this.inputRef.value && this.inputRef.value.type !== 'hidden') {
|
|
110
|
+
const inputText = this.inputRef.value.value
|
|
111
|
+
|
|
112
|
+
if (!this.multiple) {
|
|
113
|
+
if (!inputText) {
|
|
114
|
+
// Empty input — clear the selection
|
|
115
|
+
if (this._value[0]) this.removeSelected(this._value[0])
|
|
116
|
+
} else {
|
|
117
|
+
// Try to match input text to an option (by value or label)
|
|
118
|
+
const match = findOptionByValue(this.options, inputText)
|
|
119
|
+
if (match && match.value !== this._value[0]) {
|
|
120
|
+
// Input matches a different option — select it
|
|
121
|
+
this._value[0] && this.removeSelected(this._value[0])
|
|
122
|
+
this.setSelected(match.value)
|
|
123
|
+
} else if (!match && this.allowUserInput) {
|
|
124
|
+
// No match + allowUserInput — set as user value
|
|
125
|
+
this._value[0] && this.removeSelected(this._value[0])
|
|
126
|
+
this.addNewUserValue(inputText)
|
|
127
|
+
}
|
|
128
|
+
// No match, no allowUserInput — discard, keep previous selection
|
|
129
|
+
}
|
|
130
|
+
} else if (inputText !== '') {
|
|
131
|
+
// Multi: process typed text (add/select/remove)
|
|
132
|
+
const { action, value } = getInputValueAction(
|
|
133
|
+
inputText,
|
|
134
|
+
this._value,
|
|
135
|
+
this.options,
|
|
136
|
+
this.allowUserInput,
|
|
137
|
+
this.multiple,
|
|
138
|
+
)
|
|
139
|
+
switch (action) {
|
|
140
|
+
case 'addUserValue':
|
|
141
|
+
this.addNewUserValue(value)
|
|
142
|
+
break
|
|
143
|
+
case 'selectOption':
|
|
144
|
+
this.setSelected(value)
|
|
145
|
+
break
|
|
146
|
+
case 'removeValue':
|
|
147
|
+
this.removeValue(value)
|
|
148
|
+
break
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Restore input to display text of current selection (or clear)
|
|
153
|
+
if (!this.multiple && this._value[0]) {
|
|
154
|
+
this.inputRef.value.value = getSingleValueForInput(
|
|
155
|
+
this._value[0],
|
|
156
|
+
this.options,
|
|
157
|
+
this.displayValueAs,
|
|
158
|
+
)
|
|
159
|
+
} else {
|
|
160
|
+
this.inputRef.value.value = ''
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
this._isOptionsOpen = false
|
|
164
|
+
this.onBlur()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
protected handleBlur(): void {
|
|
168
|
+
this._inputFocus = false
|
|
169
|
+
this.onBlur()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
protected handleInputClick(e: MouseEvent): void {
|
|
173
|
+
if (this.disabled) {
|
|
174
|
+
e.preventDefault()
|
|
175
|
+
e.stopImmediatePropagation()
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
if (this._hasTextInput) {
|
|
179
|
+
this.inputRef.value?.focus()
|
|
180
|
+
this.requestUpdate()
|
|
181
|
+
} else {
|
|
182
|
+
// Select-only: toggle the listbox
|
|
183
|
+
e.stopImmediatePropagation()
|
|
184
|
+
e.preventDefault()
|
|
185
|
+
this._isOptionsOpen = !this._isOptionsOpen
|
|
186
|
+
if (this._isOptionsOpen) {
|
|
187
|
+
this.listboxRef.value?.focusFirstOrSelectedOption()
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
protected handlePlaceholderClick(e: MouseEvent): void {
|
|
193
|
+
if (this.disabled) return
|
|
194
|
+
e.stopPropagation()
|
|
195
|
+
if (this._hasTextInput && this.inputRef.value) {
|
|
196
|
+
this.inputRef.value.focus()
|
|
197
|
+
this._inputFocus = true
|
|
198
|
+
this.requestUpdate()
|
|
199
|
+
} else {
|
|
200
|
+
this._isOptionsOpen = !this._isOptionsOpen
|
|
201
|
+
this.requestUpdate()
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
protected handleSelectOnlyKeydown(e: KeyboardEvent): void {
|
|
206
|
+
if (this.disabled) return
|
|
207
|
+
|
|
208
|
+
switch (e.key) {
|
|
209
|
+
case 'Enter':
|
|
210
|
+
case ' ':
|
|
211
|
+
case 'ArrowDown':
|
|
212
|
+
case 'ArrowUp':
|
|
213
|
+
e.preventDefault()
|
|
214
|
+
if (!this._isOptionsOpen) {
|
|
215
|
+
this._isOptionsOpen = true
|
|
216
|
+
this.listboxRef.value?.focusFirstOrSelectedOption()
|
|
217
|
+
} else {
|
|
218
|
+
this._isOptionsOpen = false
|
|
219
|
+
}
|
|
220
|
+
break
|
|
221
|
+
case 'Escape':
|
|
222
|
+
if (this._isOptionsOpen) {
|
|
223
|
+
e.preventDefault()
|
|
224
|
+
this._isOptionsOpen = false
|
|
225
|
+
}
|
|
226
|
+
break
|
|
227
|
+
case 'Home':
|
|
228
|
+
case 'End':
|
|
229
|
+
e.preventDefault()
|
|
230
|
+
if (!this._isOptionsOpen) {
|
|
231
|
+
this._isOptionsOpen = true
|
|
232
|
+
}
|
|
233
|
+
this.listboxRef.value?.focusFirstOrSelectedOption()
|
|
234
|
+
break
|
|
235
|
+
case 'ArrowLeft':
|
|
236
|
+
if (this.multiple && this._value.length > 0) {
|
|
237
|
+
e.preventDefault()
|
|
238
|
+
this.focusTag(this._value.length - 1)
|
|
239
|
+
}
|
|
240
|
+
break
|
|
241
|
+
case 'Backspace':
|
|
242
|
+
case 'Delete':
|
|
243
|
+
if (this.multiple && this._value.length > 0) {
|
|
244
|
+
e.preventDefault()
|
|
245
|
+
this.removeSelected(this._value[this._value.length - 1])
|
|
246
|
+
}
|
|
247
|
+
break
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
protected handleOptionToggled(e: CustomEvent) {
|
|
252
|
+
this.toggleValue(e.detail)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
protected handleSearch(e: CustomEvent) {
|
|
256
|
+
e.stopPropagation()
|
|
257
|
+
this._search = e.detail.toLowerCase()
|
|
258
|
+
this.checkForMatches()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
protected handleInputKeydown(e: KeyboardEvent): void {
|
|
262
|
+
// Backspace has special DOM-dependent conditions
|
|
263
|
+
if (e.key === 'Backspace') {
|
|
264
|
+
const inputEmpty = !this.inputRef.value?.value
|
|
265
|
+
if (!this._search && inputEmpty && this.multiple && this._value.length > 0)
|
|
266
|
+
this.removeLastValue(e)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (e.key === 'ArrowLeft' && this.multiple && this._value.length > 0) {
|
|
271
|
+
const input = this.inputRef.value
|
|
272
|
+
if (input && input.selectionStart === 0 && !input.value) {
|
|
273
|
+
e.preventDefault()
|
|
274
|
+
this.focusTag(this._value.length - 1)
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const action = getInputKeyAction(e.key, e.shiftKey, this.multiple)
|
|
280
|
+
if (!action) return
|
|
281
|
+
|
|
282
|
+
// When the dropdown is closed, let Tab move focus naturally.
|
|
283
|
+
if (e.key === 'Tab' && !this._isOptionsOpen) return
|
|
284
|
+
|
|
285
|
+
// For Tab/'focusListbox': only focus the listbox if it has focusable options.
|
|
286
|
+
// If the listbox is empty (no matches), close and let Tab move focus naturally.
|
|
287
|
+
if (action === 'focusListbox' && e.key === 'Tab') {
|
|
288
|
+
const hasFocusable = this.listboxRef.value?.querySelector(
|
|
289
|
+
'[role="option"]:not([data-disabled]), [data-type="new-option"]',
|
|
290
|
+
)
|
|
291
|
+
if (!hasFocusable) {
|
|
292
|
+
this.closeAndProcessInput()
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
e.preventDefault()
|
|
298
|
+
switch (action) {
|
|
299
|
+
case 'addValue':
|
|
300
|
+
this.addValue()
|
|
301
|
+
break
|
|
302
|
+
case 'focusListbox':
|
|
303
|
+
this.listboxRef.value?.focusFirstOrSelectedOption()
|
|
304
|
+
break
|
|
305
|
+
case 'closeOptions':
|
|
306
|
+
this._isOptionsOpen = false
|
|
307
|
+
// Don't refocus — the text input already has focus, and focusing
|
|
308
|
+
// it again would trigger handleFocus which reopens the dropdown
|
|
309
|
+
break
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
protected handleTagRemove(value: string | null): void {
|
|
314
|
+
this.removeSelected(value)
|
|
315
|
+
if (this._hasTextInput && this.inputRef.value) {
|
|
316
|
+
this._inputFocus = true
|
|
317
|
+
this.inputRef.value.focus()
|
|
318
|
+
this.requestUpdate()
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
protected getInsideTags(): HTMLElement[] {
|
|
323
|
+
return Array.from(
|
|
324
|
+
this.querySelectorAll<HTMLElement>('.pkt-combobox__input .pkt-combobox__tag-list pkt-tag'),
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
protected focusTag(index: number): void {
|
|
329
|
+
const tags = this.getInsideTags()
|
|
330
|
+
tags.forEach((tag, i) => {
|
|
331
|
+
;(tag as PktTag).buttonTabindex = i === index ? 0 : -1
|
|
332
|
+
})
|
|
333
|
+
// Focus the button inside the target pkt-tag
|
|
334
|
+
const btn = tags[index]?.querySelector('button')
|
|
335
|
+
btn?.focus()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
protected resetTagTabindices(): void {
|
|
339
|
+
const tags = this.getInsideTags()
|
|
340
|
+
tags.forEach((tag) => {
|
|
341
|
+
;(tag as PktTag).buttonTabindex = -1
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
protected handleTagKeydown(e: KeyboardEvent, index: number): void {
|
|
346
|
+
e.stopPropagation()
|
|
347
|
+
const returnFocusToTrigger = () => {
|
|
348
|
+
this.resetTagTabindices()
|
|
349
|
+
if (this._hasTextInput && this.inputRef.value) {
|
|
350
|
+
this.inputRef.value.focus()
|
|
351
|
+
} else {
|
|
352
|
+
this.triggerRef.value?.focus()
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
switch (e.key) {
|
|
357
|
+
case 'ArrowLeft':
|
|
358
|
+
e.preventDefault()
|
|
359
|
+
if (index > 0) {
|
|
360
|
+
this.focusTag(index - 1)
|
|
361
|
+
}
|
|
362
|
+
break
|
|
363
|
+
case 'ArrowRight':
|
|
364
|
+
e.preventDefault()
|
|
365
|
+
if (index < this._value.length - 1) {
|
|
366
|
+
this.focusTag(index + 1)
|
|
367
|
+
} else {
|
|
368
|
+
returnFocusToTrigger()
|
|
369
|
+
}
|
|
370
|
+
break
|
|
371
|
+
case 'Backspace':
|
|
372
|
+
case 'Delete':
|
|
373
|
+
e.preventDefault()
|
|
374
|
+
{
|
|
375
|
+
const val = this._value[index]
|
|
376
|
+
const nextIndex = index >= this._value.length - 1 ? index - 1 : index
|
|
377
|
+
this.removeSelected(val)
|
|
378
|
+
if (nextIndex >= 0) {
|
|
379
|
+
this.requestUpdate()
|
|
380
|
+
this.updateComplete.then(() => this.focusTag(nextIndex))
|
|
381
|
+
} else {
|
|
382
|
+
returnFocusToTrigger()
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
break
|
|
386
|
+
case 'Tab':
|
|
387
|
+
// Let Tab move focus naturally, but reset roving tabindex
|
|
388
|
+
// so the next Tab into the combobox lands on the trigger, not a tag
|
|
389
|
+
this.resetTagTabindices()
|
|
390
|
+
break
|
|
391
|
+
case 'Escape':
|
|
392
|
+
e.preventDefault()
|
|
393
|
+
returnFocusToTrigger()
|
|
394
|
+
break
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
protected checkForMatches() {
|
|
399
|
+
const inputValue = this.inputRef.value?.value || this._search || ''
|
|
400
|
+
const result = checkForMatches(
|
|
401
|
+
inputValue,
|
|
402
|
+
this._value,
|
|
403
|
+
this.options,
|
|
404
|
+
this.allowUserInput,
|
|
405
|
+
this.multiple,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
if (result.shouldRemoveValue) {
|
|
409
|
+
this.removeValue(this._value[0])
|
|
410
|
+
}
|
|
411
|
+
if (result.shouldResetInput) {
|
|
412
|
+
this.resetComboboxInput(false)
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
this._addValueText = result.addValueText
|
|
417
|
+
this._userInfoMessage = result.userInfoMessage
|
|
418
|
+
}
|
|
419
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for the combobox component family.
|
|
3
|
+
* Re-exports from shared-types for backward compatibility.
|
|
4
|
+
*/
|
|
5
|
+
export type {
|
|
6
|
+
IPktComboboxOption,
|
|
7
|
+
TPktComboboxTagSkin,
|
|
8
|
+
TPktComboboxDisplayValue,
|
|
9
|
+
TPktComboboxTagPlacement,
|
|
10
|
+
} from 'shared-types/combobox'
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { IPktComboboxOption } from 'shared-types/combobox'
|
|
2
|
+
import type { TTagSkin } from '../tag'
|
|
3
|
+
import { buildFulltext } from 'shared-utils/combobox/option-utils'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Lit-specific utility functions for PktCombobox component.
|
|
7
|
+
*
|
|
8
|
+
* Framework-agnostic functions live in shared-utils/combobox/.
|
|
9
|
+
* Only Lit-specific functions (using Ref, in-place mutation, PktSlotController) stay here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Selection state helpers (mutating, Lit-specific)
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* In-place option mutation helpers that preserve referential identity
|
|
16
|
+
* between this._options and this.options in the Lit component.
|
|
17
|
+
*/
|
|
18
|
+
export const selectionMutators = {
|
|
19
|
+
markOptionSelected(options: IPktComboboxOption[], value: string | null): void {
|
|
20
|
+
if (!value) return
|
|
21
|
+
for (const option of options) {
|
|
22
|
+
if (option.value === value) {
|
|
23
|
+
option.selected = true
|
|
24
|
+
break
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
markOptionDeselected(options: IPktComboboxOption[], value: string | null): void {
|
|
30
|
+
if (!value) return
|
|
31
|
+
for (const option of options) {
|
|
32
|
+
if (option.value === value) {
|
|
33
|
+
option.selected = false
|
|
34
|
+
break
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
markAllSelected(options: IPktComboboxOption[]): void {
|
|
40
|
+
for (const option of options) {
|
|
41
|
+
option.selected = true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
markAllDeselected(options: IPktComboboxOption[]): void {
|
|
46
|
+
for (const option of options) {
|
|
47
|
+
option.selected = false
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
removeUserAddedOptions(options: IPktComboboxOption[]): IPktComboboxOption[] {
|
|
52
|
+
return options.filter((option) => !option.userAdded)
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Slot parsing (Lit-specific)
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parses option elements from slot controller nodes into IPktComboboxOption[].
|
|
60
|
+
*/
|
|
61
|
+
export const slotUtils = {
|
|
62
|
+
parseOptionsFromSlot(nodes: Element[]): IPktComboboxOption[] {
|
|
63
|
+
const options: IPktComboboxOption[] = []
|
|
64
|
+
|
|
65
|
+
nodes.forEach((node: Element) => {
|
|
66
|
+
if (!node.textContent && !node.getAttribute('value')) return
|
|
67
|
+
|
|
68
|
+
const option: IPktComboboxOption = {
|
|
69
|
+
value: node.getAttribute('value') || node.textContent || '',
|
|
70
|
+
label: node.textContent || node.getAttribute('value') || '',
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (node.getAttribute('data-prefix')) {
|
|
74
|
+
option.prefix = node.getAttribute('data-prefix') || undefined
|
|
75
|
+
}
|
|
76
|
+
if (node.getAttribute('tagskincolor')) {
|
|
77
|
+
option.tagSkinColor = node.getAttribute('tagskincolor') as TTagSkin
|
|
78
|
+
}
|
|
79
|
+
if (node.getAttribute('description')) {
|
|
80
|
+
option.description = node.getAttribute('description') || undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
option.fulltext = buildFulltext(option)
|
|
84
|
+
options.push(option)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
return options
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Options state synchronization (Lit-specific, in-place mutation)
|
|
92
|
+
|
|
93
|
+
export const optionStateUtils = {
|
|
94
|
+
/**
|
|
95
|
+
* Ensures options have labels and fulltext set.
|
|
96
|
+
* Also syncs selected state with current values.
|
|
97
|
+
*
|
|
98
|
+
* IMPORTANT: Mutates options in place to preserve referential identity
|
|
99
|
+
* between this._options and this.options in the Lit component.
|
|
100
|
+
*/
|
|
101
|
+
syncOptionsWithValues(
|
|
102
|
+
options: IPktComboboxOption[],
|
|
103
|
+
values: string[],
|
|
104
|
+
): { options: IPktComboboxOption[]; newValues: string[] } {
|
|
105
|
+
const newValues = [...values]
|
|
106
|
+
|
|
107
|
+
options.forEach((option) => {
|
|
108
|
+
if (option.value && !option.label) {
|
|
109
|
+
option.label = option.value
|
|
110
|
+
}
|
|
111
|
+
if (option.selected && !newValues.includes(option.value)) {
|
|
112
|
+
newValues.push(option.value)
|
|
113
|
+
}
|
|
114
|
+
option.fulltext = buildFulltext(option)
|
|
115
|
+
option.selected = option.selected || newValues.includes(option.value)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
return { options, newValues }
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Merges user-added options with new options, preserving user additions.
|
|
123
|
+
*/
|
|
124
|
+
mergeWithUserAdded(
|
|
125
|
+
newOptions: IPktComboboxOption[],
|
|
126
|
+
previousOptions: IPktComboboxOption[],
|
|
127
|
+
): IPktComboboxOption[] {
|
|
128
|
+
const userAddedValues = previousOptions.filter((option) => option?.userAdded && option.selected)
|
|
129
|
+
const filteredUserAdded = userAddedValues.filter(
|
|
130
|
+
(userOpt) =>
|
|
131
|
+
!(Array.isArray(newOptions) ? newOptions : []).some((opt) => opt.value === userOpt.value),
|
|
132
|
+
)
|
|
133
|
+
return [...filteredUserAdded, ...newOptions]
|
|
134
|
+
},
|
|
135
|
+
}
|