@inglorious/ui 7.2.1 → 7.2.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/package.json +1 -1
- package/src/controls/combobox/combobox.stories.js +66 -4
- package/src/controls/combobox/style.css +74 -23
- package/src/controls/combobox/template.js +242 -154
- package/src/controls/input/index.js +1 -1
- package/src/controls/input/input.stories.js +35 -0
- package/src/controls/input/style.css +43 -56
- package/src/controls/input/template.js +112 -88
- package/src/controls/select/index.js +1 -1
- package/src/controls/select/select.stories.js +35 -3
- package/src/controls/select/style.css +145 -66
- package/src/controls/select/template.js +178 -74
- package/types/controls/combobox.d.ts +3 -0
- package/types/controls/select.d.ts +4 -0
|
@@ -21,66 +21,9 @@ import {
|
|
|
21
21
|
|
|
22
22
|
const NO_FOCUSED_INDEX = -1
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
* Registered outside-pointer listeners, keyed by the dropdown element they
|
|
26
|
-
* belong to. Used only to deduplicate: the ref callback is handed a fresh arrow
|
|
27
|
-
* on every re-render, so without this each pass would stack another listener on
|
|
28
|
-
* the document.
|
|
29
|
-
* @type {WeakMap<Element, { props: ComboboxProps, listener: (event: Event) => void }>}
|
|
30
|
-
*/
|
|
31
|
-
const outsideListeners = new WeakMap()
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Listen on the document for presses outside the dropdown, closing it when one
|
|
35
|
-
* happens.
|
|
36
|
-
*
|
|
37
|
-
* This listens for `pointerdown` rather than `click` on purpose. Mobile browsers
|
|
38
|
-
* synthesise `click` asynchronously after `touchend`, and drop it entirely when
|
|
39
|
-
* the touch drifts into a scroll gesture or the target is replaced meanwhile.
|
|
40
|
-
* A suppressed click would leave the listener armed, and it would then close the
|
|
41
|
-
* *next* dropdown on the tap that opened it, leaving the combobox looking stuck.
|
|
42
|
-
* `pointerdown` is dispatched synchronously for mouse, touch and pen.
|
|
43
|
-
*
|
|
44
|
-
* No id is needed: the element keys the listener, and it retires itself once its
|
|
45
|
-
* dropdown has left the DOM.
|
|
46
|
-
* @param {ComboboxProps} props
|
|
47
|
-
* @param {Element|null|undefined} el dropdown element, or undefined on unmount
|
|
48
|
-
*/
|
|
49
|
-
function trackOutsideClick(props, el) {
|
|
50
|
-
if (!el) return
|
|
51
|
-
|
|
52
|
-
const existing = outsideListeners.get(el)
|
|
53
|
-
if (existing) {
|
|
54
|
-
// A re-render of the same dropdown: keep the one listener, but point it at
|
|
55
|
-
// the current props so onClose is not captured from the first render.
|
|
56
|
-
existing.props = props
|
|
57
|
-
return
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const entry = { props }
|
|
61
|
-
entry.listener = (event) => {
|
|
62
|
-
// The dropdown left the DOM: this press is the last one to hear from it.
|
|
63
|
-
// Retiring here is what keeps a torn-down dropdown from closing whatever
|
|
64
|
-
// opens next.
|
|
65
|
-
if (!el.isConnected) {
|
|
66
|
-
document.removeEventListener("pointerdown", entry.listener)
|
|
67
|
-
return
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// The root, not the dropdown: the control is a sibling of the dropdown, and a
|
|
71
|
-
// press on it has to count as inside. Resolved per event rather than at mount
|
|
72
|
-
// because the dropdown is not connected yet when the ref first runs.
|
|
73
|
-
const root = el.closest(".iw-combobox") ?? el
|
|
74
|
-
if (!event.composedPath().includes(root)) entry.props.onClose?.()
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
outsideListeners.set(el, entry)
|
|
78
|
-
document.addEventListener("pointerdown", entry.listener)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
24
|
export const Combobox = {
|
|
82
25
|
/**
|
|
83
|
-
* Main entrypoint for the combobox control. It renders the
|
|
26
|
+
* Main entrypoint for the combobox control. It renders the frame, label, control, and dropdown when open.
|
|
84
27
|
* Comboboxes support search, single or multi-select values, and clearable selection.
|
|
85
28
|
* @param {ComboboxProps} props
|
|
86
29
|
* @returns {TemplateResult}
|
|
@@ -90,26 +33,63 @@ export const Combobox = {
|
|
|
90
33
|
},
|
|
91
34
|
|
|
92
35
|
/**
|
|
93
|
-
* Main entrypoint for the combobox control. It renders the
|
|
36
|
+
* Main entrypoint for the combobox control. It renders the frame, label, control, and dropdown when open.
|
|
94
37
|
* Comboboxes support search, single or multi-select values, and clearable selection.
|
|
95
38
|
* @param {ComboboxProps} props
|
|
96
39
|
* @returns {TemplateResult}
|
|
97
40
|
*/
|
|
98
41
|
renderCombobox(props) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
42
|
+
const {
|
|
43
|
+
id,
|
|
44
|
+
type, // eslint-disable-line no-unused-vars
|
|
45
|
+
label,
|
|
46
|
+
hint,
|
|
47
|
+
error,
|
|
48
|
+
isRequired = false,
|
|
49
|
+
isFullWidth = false,
|
|
50
|
+
isOpen = false,
|
|
51
|
+
} = props
|
|
52
|
+
// The control is a div, not a labellable element, so a `for` binding is not
|
|
53
|
+
// available. It is referenced by id from the control's aria-labelledby
|
|
54
|
+
// instead, which is what a screen reader actually reads here.
|
|
55
|
+
// Only minted when there is something to point at. A reference to an id
|
|
56
|
+
// that is not in the DOM is worse than no reference at all.
|
|
57
|
+
const labelId = label && id ? `${id}-label` : ""
|
|
58
|
+
const messageId = (error || hint) && id ? `${id}-message` : ""
|
|
59
|
+
|
|
60
|
+
const comboboxClasses = {
|
|
61
|
+
"iw-combobox": true,
|
|
62
|
+
"iw-combobox-full-width": !!isFullWidth,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return html`<div class=${classMap(comboboxClasses)}>
|
|
66
|
+
${when(
|
|
67
|
+
label,
|
|
68
|
+
() =>
|
|
69
|
+
html`<label id=${labelId || ""} class="iw-combobox-label">
|
|
70
|
+
${label}
|
|
71
|
+
${when(
|
|
72
|
+
isRequired,
|
|
73
|
+
() => html`<span class="iw-combobox-required">*</span>`,
|
|
74
|
+
)}
|
|
75
|
+
</label>`,
|
|
76
|
+
)}
|
|
77
|
+
${this.renderControl?.({ ...props, labelId, messageId })}
|
|
78
|
+
${when(isOpen, () => this.renderDropdown?.(props))}
|
|
79
|
+
${when(
|
|
80
|
+
error,
|
|
81
|
+
() =>
|
|
82
|
+
html`<span id=${messageId ?? ""} class="iw-combobox-error-message"
|
|
83
|
+
>${error}</span
|
|
84
|
+
>`,
|
|
85
|
+
)}
|
|
86
|
+
${when(
|
|
87
|
+
!error && hint,
|
|
88
|
+
() =>
|
|
89
|
+
html`<span id=${messageId || ""} class="iw-combobox-hint"
|
|
90
|
+
>${hint}</span
|
|
91
|
+
>`,
|
|
92
|
+
)}
|
|
113
93
|
</div>`
|
|
114
94
|
},
|
|
115
95
|
|
|
@@ -125,50 +105,72 @@ export const Combobox = {
|
|
|
125
105
|
* @returns {TemplateResult}
|
|
126
106
|
*/
|
|
127
107
|
renderControl(props) {
|
|
128
|
-
const
|
|
108
|
+
const {
|
|
109
|
+
id,
|
|
110
|
+
labelId,
|
|
111
|
+
messageId,
|
|
112
|
+
selectedValue,
|
|
113
|
+
error,
|
|
114
|
+
size = "md",
|
|
115
|
+
isDisabled = false,
|
|
116
|
+
isRequired = false,
|
|
117
|
+
isOpen = false,
|
|
118
|
+
isMulti = false,
|
|
119
|
+
isClearable = false,
|
|
120
|
+
onToggle,
|
|
121
|
+
onClear,
|
|
122
|
+
} = props
|
|
123
|
+
|
|
124
|
+
const controlId = id ? `${id}-control` : ""
|
|
125
|
+
|
|
126
|
+
const frameClasses = {
|
|
127
|
+
"iw-combobox-frame": true,
|
|
128
|
+
[`iw-combobox-${size}`]: true,
|
|
129
|
+
"iw-combobox-frame-open": isOpen,
|
|
130
|
+
"iw-combobox-frame-disabled": isDisabled,
|
|
131
|
+
"iw-combobox-frame-error": !!error,
|
|
132
|
+
"iw-combobox-frame-selection":
|
|
133
|
+
isMulti && Array.isArray(selectedValue) && selectedValue.length,
|
|
134
|
+
}
|
|
135
|
+
|
|
129
136
|
return html`<div
|
|
130
|
-
id=${controlId
|
|
131
|
-
class="
|
|
132
|
-
"iw-combobox-control-open": props.isOpen,
|
|
133
|
-
"iw-combobox-control-disabled": props.isDisabled,
|
|
134
|
-
"iw-combobox-control-selection":
|
|
135
|
-
props.isMulti &&
|
|
136
|
-
Array.isArray(props.selectedValue) &&
|
|
137
|
-
props.selectedValue.length,
|
|
138
|
-
})}"
|
|
137
|
+
id=${controlId}
|
|
138
|
+
class="${classMap(frameClasses)}"
|
|
139
139
|
role="combobox"
|
|
140
140
|
aria-haspopup="listbox"
|
|
141
|
-
aria-expanded=${
|
|
141
|
+
aria-expanded=${isOpen ? "true" : "false"}
|
|
142
142
|
aria-controls=${controlId ? `${controlId}-listbox` : ""}
|
|
143
|
-
|
|
143
|
+
aria-labelledby=${labelId || ""}
|
|
144
|
+
aria-describedby=${messageId || ""}
|
|
145
|
+
aria-required=${isRequired ? "true" : "false"}
|
|
146
|
+
aria-invalid=${error ? "true" : "false"}
|
|
147
|
+
tabindex=${isDisabled ? -1 : 0}
|
|
144
148
|
@keydown=${(event) => handleControlKeyDown(props, event)}
|
|
145
|
-
@click=${() => !
|
|
149
|
+
@click=${() => !isDisabled && onToggle?.()}
|
|
146
150
|
>
|
|
147
151
|
${when(
|
|
148
|
-
|
|
152
|
+
isMulti,
|
|
149
153
|
() => this.renderMultiValue?.(props),
|
|
150
154
|
() => this.renderSingleValue?.(props),
|
|
151
155
|
)}
|
|
152
156
|
${when(
|
|
153
|
-
|
|
154
|
-
((
|
|
155
|
-
|
|
156
|
-
props.selectedValue.length) ||
|
|
157
|
-
(!props.isMulti && props.selectedValue !== null)),
|
|
157
|
+
isClearable &&
|
|
158
|
+
((isMulti && Array.isArray(selectedValue) && selectedValue.length) ||
|
|
159
|
+
(!isMulti && selectedValue !== null)),
|
|
158
160
|
() =>
|
|
159
161
|
html`<button
|
|
160
162
|
class="iw-combobox-clear"
|
|
161
163
|
type="button"
|
|
162
164
|
@click=${(event) => {
|
|
163
165
|
event.stopPropagation()
|
|
164
|
-
|
|
166
|
+
onClear?.()
|
|
165
167
|
}}
|
|
166
168
|
>
|
|
167
169
|
×
|
|
168
170
|
</button>`,
|
|
169
171
|
)}
|
|
170
172
|
|
|
171
|
-
<
|
|
173
|
+
<span class="iw-combobox-arrow" aria-hidden="true">▼</span>
|
|
172
174
|
</div>`
|
|
173
175
|
},
|
|
174
176
|
|
|
@@ -179,21 +181,21 @@ export const Combobox = {
|
|
|
179
181
|
* @returns {TemplateResult}
|
|
180
182
|
*/
|
|
181
183
|
renderSingleValue(props) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
184
|
+
const { options, selectedValue, placeholder } = props
|
|
185
|
+
|
|
186
|
+
if (selectedValue === null) {
|
|
187
|
+
return html`<span class="iw-combobox-control iw-combobox-placeholder"
|
|
188
|
+
>${placeholder}</span
|
|
185
189
|
>`
|
|
186
190
|
}
|
|
187
191
|
|
|
188
|
-
const selectedOption =
|
|
189
|
-
(option) => getOptionValue(option) ===
|
|
192
|
+
const selectedOption = options.find(
|
|
193
|
+
(option) => getOptionValue(option) === selectedValue,
|
|
190
194
|
)
|
|
191
195
|
|
|
192
|
-
return html`<span class="iw-combobox-value"
|
|
196
|
+
return html`<span class="iw-combobox-control iw-combobox-value"
|
|
193
197
|
>${
|
|
194
|
-
selectedOption
|
|
195
|
-
? getOptionLabel(selectedOption)
|
|
196
|
-
: String(props.selectedValue)
|
|
198
|
+
selectedOption ? getOptionLabel(selectedOption) : String(selectedValue)
|
|
197
199
|
}</span
|
|
198
200
|
>`
|
|
199
201
|
},
|
|
@@ -205,15 +207,17 @@ export const Combobox = {
|
|
|
205
207
|
* @returns {TemplateResult}
|
|
206
208
|
*/
|
|
207
209
|
renderMultiValue(props) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
210
|
+
const { selectedValue, placeholder } = props
|
|
211
|
+
|
|
212
|
+
if (!Array.isArray(selectedValue) || !selectedValue.length) {
|
|
213
|
+
return html`<span class="iw-combobox-control iw-combobox-placeholder"
|
|
214
|
+
>${placeholder}</span
|
|
211
215
|
>`
|
|
212
216
|
}
|
|
213
217
|
|
|
214
|
-
return html`<div class="iw-combobox-multi-value">
|
|
218
|
+
return html`<div class="iw-combobox-control iw-combobox-multi-value">
|
|
215
219
|
${repeat(
|
|
216
|
-
|
|
220
|
+
selectedValue,
|
|
217
221
|
(value) => value,
|
|
218
222
|
(value) => this.renderMultiValueTag?.(props, value),
|
|
219
223
|
)}
|
|
@@ -228,9 +232,11 @@ export const Combobox = {
|
|
|
228
232
|
* @returns {TemplateResult}
|
|
229
233
|
*/
|
|
230
234
|
renderMultiValueTag(props, value) {
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
const
|
|
235
|
+
const { id, options, size, onOptionSelect, onChange } = props
|
|
236
|
+
|
|
237
|
+
const option = options.find((opt) => getOptionValue(opt) === value)
|
|
238
|
+
const tagId = `${id}-tag-${String(value)}`
|
|
239
|
+
const chipSize = size === "lg" ? "md" : "sm"
|
|
234
240
|
|
|
235
241
|
return html`<span
|
|
236
242
|
class="iw-combobox-multi-value-tag"
|
|
@@ -243,8 +249,8 @@ export const Combobox = {
|
|
|
243
249
|
size: chipSize,
|
|
244
250
|
shape: "rounded",
|
|
245
251
|
onClick: () => {
|
|
246
|
-
|
|
247
|
-
|
|
252
|
+
onOptionSelect?.(option)
|
|
253
|
+
onChange?.(option)
|
|
248
254
|
},
|
|
249
255
|
})}
|
|
250
256
|
</span>`
|
|
@@ -257,20 +263,22 @@ export const Combobox = {
|
|
|
257
263
|
* @returns {TemplateResult}
|
|
258
264
|
*/
|
|
259
265
|
renderDropdown(props) {
|
|
260
|
-
const
|
|
266
|
+
const { id, options, searchTerm, isSearchable, isLoading } = props
|
|
267
|
+
|
|
268
|
+
const filteredOptions = filterOptions(options, searchTerm)
|
|
261
269
|
|
|
262
270
|
return html`<div
|
|
263
|
-
id=${
|
|
271
|
+
id=${id ? `${id}-control-listbox` : ""}
|
|
264
272
|
class="iw-combobox-dropdown"
|
|
265
273
|
role="listbox"
|
|
266
274
|
${ref((el) => trackOutsideClick(props, el))}
|
|
267
275
|
>
|
|
268
|
-
${when(
|
|
269
|
-
${when(
|
|
270
|
-
${when(!
|
|
276
|
+
${when(isSearchable, () => this.renderSearchInput?.(props))}
|
|
277
|
+
${when(isLoading, () => this.renderLoading?.(props))}
|
|
278
|
+
${when(!isLoading && !filteredOptions.length, () =>
|
|
271
279
|
this.renderNoOptions?.(props),
|
|
272
280
|
)}
|
|
273
|
-
${when(!
|
|
281
|
+
${when(!isLoading && filteredOptions.length, () =>
|
|
274
282
|
this.renderOptions?.(props),
|
|
275
283
|
)}
|
|
276
284
|
</div>`
|
|
@@ -283,15 +291,17 @@ export const Combobox = {
|
|
|
283
291
|
* @returns {TemplateResult}
|
|
284
292
|
*/
|
|
285
293
|
renderSearchInput(props) {
|
|
294
|
+
const { searchTerm, isOpen, onSearchChange } = props
|
|
295
|
+
|
|
286
296
|
return html`<input
|
|
287
297
|
class="iw-combobox-dropdown-search"
|
|
288
298
|
type="text"
|
|
289
299
|
placeholder="Search..."
|
|
290
|
-
.value=${
|
|
291
|
-
@input=${(event) =>
|
|
300
|
+
.value=${searchTerm ?? ""}
|
|
301
|
+
@input=${(event) => onSearchChange?.(event.target.value)}
|
|
292
302
|
@keydown=${(event) => handleKeyDown(props, event)}
|
|
293
303
|
${ref((el) => {
|
|
294
|
-
if (el &&
|
|
304
|
+
if (el && isOpen) queueMicrotask(() => el.focus())
|
|
295
305
|
})}
|
|
296
306
|
/>`
|
|
297
307
|
},
|
|
@@ -303,7 +313,9 @@ export const Combobox = {
|
|
|
303
313
|
* @returns {TemplateResult}
|
|
304
314
|
*/
|
|
305
315
|
renderOptions(props) {
|
|
306
|
-
const
|
|
316
|
+
const { options, searchTerm } = props
|
|
317
|
+
|
|
318
|
+
const filteredOptions = filterOptions(options, searchTerm)
|
|
307
319
|
|
|
308
320
|
return html`<div class="iw-combobox-dropdown-options">
|
|
309
321
|
${repeat(
|
|
@@ -322,16 +334,22 @@ export const Combobox = {
|
|
|
322
334
|
* @returns {TemplateResult}
|
|
323
335
|
*/
|
|
324
336
|
renderOption(props, { option, index }) {
|
|
337
|
+
const {
|
|
338
|
+
id,
|
|
339
|
+
selectedValue,
|
|
340
|
+
focusedIndex,
|
|
341
|
+
isMulti,
|
|
342
|
+
onOptionSelect,
|
|
343
|
+
onChange,
|
|
344
|
+
onFocusSet,
|
|
345
|
+
} = props
|
|
346
|
+
|
|
325
347
|
const normalized = formatOption(option)
|
|
326
|
-
const isSelected = isOptionSelected(
|
|
327
|
-
|
|
328
|
-
props.selectedValue,
|
|
329
|
-
props.isMulti,
|
|
330
|
-
)
|
|
331
|
-
const isFocused = index === props.focusedIndex
|
|
348
|
+
const isSelected = isOptionSelected(option, selectedValue, isMulti)
|
|
349
|
+
const isFocused = index === focusedIndex
|
|
332
350
|
|
|
333
351
|
return html`<div
|
|
334
|
-
id=${
|
|
352
|
+
id=${id ? `${id}-option-${index}` : ""}
|
|
335
353
|
role="option"
|
|
336
354
|
aria-selected=${isSelected ? "true" : "false"}
|
|
337
355
|
class="iw-combobox-dropdown-options-option ${classMap({
|
|
@@ -341,13 +359,13 @@ export const Combobox = {
|
|
|
341
359
|
})}"
|
|
342
360
|
@click=${() => {
|
|
343
361
|
if (normalized.isDisabled) return
|
|
344
|
-
|
|
345
|
-
|
|
362
|
+
onOptionSelect?.(normalized)
|
|
363
|
+
onChange?.(normalized)
|
|
346
364
|
}}
|
|
347
|
-
@mouseenter=${() =>
|
|
365
|
+
@mouseenter=${() => onFocusSet?.(index)}
|
|
348
366
|
>
|
|
349
367
|
${when(
|
|
350
|
-
|
|
368
|
+
isMulti,
|
|
351
369
|
() =>
|
|
352
370
|
html`<input
|
|
353
371
|
type="checkbox"
|
|
@@ -366,7 +384,9 @@ export const Combobox = {
|
|
|
366
384
|
* @returns {TemplateResult}
|
|
367
385
|
*/
|
|
368
386
|
renderLoading(props) {
|
|
369
|
-
|
|
387
|
+
const { loadingMessage } = props
|
|
388
|
+
|
|
389
|
+
return html`<div class="iw-combobox-loading">${loadingMessage}</div>`
|
|
370
390
|
},
|
|
371
391
|
|
|
372
392
|
/**
|
|
@@ -376,9 +396,8 @@ export const Combobox = {
|
|
|
376
396
|
* @returns {TemplateResult}
|
|
377
397
|
*/
|
|
378
398
|
renderNoOptions(props) {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
</div>`
|
|
399
|
+
const { noOptionsMessage } = props
|
|
400
|
+
return html`<div class="iw-combobox-no-options">${noOptionsMessage}</div>`
|
|
382
401
|
},
|
|
383
402
|
}
|
|
384
403
|
|
|
@@ -392,9 +411,11 @@ export const Combobox = {
|
|
|
392
411
|
* @param {KeyboardEvent} event
|
|
393
412
|
*/
|
|
394
413
|
function handleControlKeyDown(props, event) {
|
|
395
|
-
|
|
414
|
+
const { isDisabled, isOpen, onToggle } = props
|
|
415
|
+
|
|
416
|
+
if (isDisabled) return
|
|
396
417
|
|
|
397
|
-
if (
|
|
418
|
+
if (isOpen) {
|
|
398
419
|
handleKeyDown(props, event)
|
|
399
420
|
return
|
|
400
421
|
}
|
|
@@ -404,7 +425,7 @@ function handleControlKeyDown(props, event) {
|
|
|
404
425
|
}
|
|
405
426
|
// Space would otherwise scroll the page out from under the dropdown.
|
|
406
427
|
event.preventDefault()
|
|
407
|
-
|
|
428
|
+
onToggle?.()
|
|
408
429
|
}
|
|
409
430
|
|
|
410
431
|
/**
|
|
@@ -413,43 +434,110 @@ function handleControlKeyDown(props, event) {
|
|
|
413
434
|
* @param {KeyboardEvent} event
|
|
414
435
|
*/
|
|
415
436
|
function handleKeyDown(props, event) {
|
|
416
|
-
const
|
|
437
|
+
const {
|
|
438
|
+
options,
|
|
439
|
+
searchTerm,
|
|
440
|
+
focusedIndex,
|
|
441
|
+
onFocusFirst,
|
|
442
|
+
onFocusPrev,
|
|
443
|
+
onFocusNext,
|
|
444
|
+
onFocusLast,
|
|
445
|
+
onOptionSelect,
|
|
446
|
+
onChange,
|
|
447
|
+
onClose,
|
|
448
|
+
} = props
|
|
449
|
+
|
|
450
|
+
const filteredOptions = filterOptions(options, searchTerm)
|
|
417
451
|
|
|
418
452
|
switch (event.key) {
|
|
419
453
|
case "ArrowDown":
|
|
420
454
|
event.preventDefault()
|
|
421
|
-
|
|
455
|
+
onFocusNext?.()
|
|
422
456
|
break
|
|
423
457
|
case "ArrowUp":
|
|
424
458
|
event.preventDefault()
|
|
425
|
-
|
|
459
|
+
onFocusPrev?.()
|
|
426
460
|
break
|
|
427
461
|
case "Enter":
|
|
428
462
|
event.preventDefault()
|
|
429
|
-
if (
|
|
430
|
-
|
|
431
|
-
filteredOptions[props.focusedIndex]
|
|
432
|
-
) {
|
|
433
|
-
const option = formatOption(filteredOptions[props.focusedIndex])
|
|
463
|
+
if (focusedIndex > NO_FOCUSED_INDEX && filteredOptions[focusedIndex]) {
|
|
464
|
+
const option = formatOption(filteredOptions[focusedIndex])
|
|
434
465
|
if (!option.isDisabled) {
|
|
435
|
-
|
|
436
|
-
|
|
466
|
+
onOptionSelect?.(option)
|
|
467
|
+
onChange?.(option)
|
|
437
468
|
}
|
|
438
469
|
}
|
|
439
470
|
break
|
|
440
471
|
case "Escape":
|
|
441
472
|
event.preventDefault()
|
|
442
|
-
|
|
473
|
+
onClose?.()
|
|
443
474
|
break
|
|
444
475
|
case "Home":
|
|
445
476
|
event.preventDefault()
|
|
446
|
-
|
|
477
|
+
onFocusFirst?.()
|
|
447
478
|
break
|
|
448
479
|
case "End":
|
|
449
480
|
event.preventDefault()
|
|
450
|
-
|
|
481
|
+
onFocusLast?.()
|
|
451
482
|
break
|
|
452
483
|
default:
|
|
453
484
|
break
|
|
454
485
|
}
|
|
455
486
|
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Registered outside-pointer listeners, keyed by the dropdown element they
|
|
490
|
+
* belong to. Used only to deduplicate: the ref callback is handed a fresh arrow
|
|
491
|
+
* on every re-render, so without this each pass would stack another listener on
|
|
492
|
+
* the document.
|
|
493
|
+
* @type {WeakMap<Element, { props: ComboboxProps, listener: (event: Event) => void }>}
|
|
494
|
+
*/
|
|
495
|
+
const outsideListeners = new WeakMap()
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Listen on the document for presses outside the dropdown, closing it when one
|
|
499
|
+
* happens.
|
|
500
|
+
*
|
|
501
|
+
* This listens for `pointerdown` rather than `click` on purpose. Mobile browsers
|
|
502
|
+
* synthesise `click` asynchronously after `touchend`, and drop it entirely when
|
|
503
|
+
* the touch drifts into a scroll gesture or the target is replaced meanwhile.
|
|
504
|
+
* A suppressed click would leave the listener armed, and it would then close the
|
|
505
|
+
* *next* dropdown on the tap that opened it, leaving the combobox looking stuck.
|
|
506
|
+
* `pointerdown` is dispatched synchronously for mouse, touch and pen.
|
|
507
|
+
*
|
|
508
|
+
* No id is needed: the element keys the listener, and it retires itself once its
|
|
509
|
+
* dropdown has left the DOM.
|
|
510
|
+
* @param {ComboboxProps} props
|
|
511
|
+
* @param {Element|null|undefined} el dropdown element, or undefined on unmount
|
|
512
|
+
*/
|
|
513
|
+
function trackOutsideClick(props, el) {
|
|
514
|
+
if (!el) return
|
|
515
|
+
|
|
516
|
+
const existing = outsideListeners.get(el)
|
|
517
|
+
if (existing) {
|
|
518
|
+
// A re-render of the same dropdown: keep the one listener, but point it at
|
|
519
|
+
// the current props so onClose is not captured from the first render.
|
|
520
|
+
existing.props = props
|
|
521
|
+
return
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const entry = { props }
|
|
525
|
+
entry.listener = (event) => {
|
|
526
|
+
// The dropdown left the DOM: this press is the last one to hear from it.
|
|
527
|
+
// Retiring here is what keeps a torn-down dropdown from closing whatever
|
|
528
|
+
// opens next.
|
|
529
|
+
if (!el.isConnected) {
|
|
530
|
+
document.removeEventListener("pointerdown", entry.listener)
|
|
531
|
+
return
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// The root, not the dropdown: the control is a sibling of the dropdown, and a
|
|
535
|
+
// press on it has to count as inside. Resolved per event rather than at mount
|
|
536
|
+
// because the dropdown is not connected yet when the ref first runs.
|
|
537
|
+
const root = el.closest(".iw-combobox") ?? el
|
|
538
|
+
if (!event.composedPath().includes(root)) entry.props.onClose?.()
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
outsideListeners.set(el, entry)
|
|
542
|
+
document.addEventListener("pointerdown", entry.listener)
|
|
543
|
+
}
|
|
@@ -174,6 +174,25 @@ WithError.args = {
|
|
|
174
174
|
error: "Please enter a valid email address",
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
export const WithIcon = {}
|
|
178
|
+
WithIcon.args = {
|
|
179
|
+
...Default.args,
|
|
180
|
+
icon: "@",
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export const WithIconAfter = {}
|
|
184
|
+
WithIconAfter.args = {
|
|
185
|
+
...Default.args,
|
|
186
|
+
iconAfter: "@",
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const WithBothIcons = {}
|
|
190
|
+
WithBothIcons.args = {
|
|
191
|
+
...Default.args,
|
|
192
|
+
icon: "@",
|
|
193
|
+
iconAfter: "@",
|
|
194
|
+
}
|
|
195
|
+
|
|
177
196
|
export const Required = {}
|
|
178
197
|
Required.args = {
|
|
179
198
|
...Default.args,
|
|
@@ -206,6 +225,13 @@ Small.args = {
|
|
|
206
225
|
size: "sm",
|
|
207
226
|
}
|
|
208
227
|
|
|
228
|
+
export const Medium = {}
|
|
229
|
+
Medium.args = {
|
|
230
|
+
...Default.args,
|
|
231
|
+
label: "Medium Input",
|
|
232
|
+
size: "md",
|
|
233
|
+
}
|
|
234
|
+
|
|
209
235
|
export const Large = {}
|
|
210
236
|
Large.args = {
|
|
211
237
|
...Default.args,
|
|
@@ -286,3 +312,12 @@ Color.args = {
|
|
|
286
312
|
inputType: "color",
|
|
287
313
|
value: "#7c3aed",
|
|
288
314
|
}
|
|
315
|
+
|
|
316
|
+
export const Password = {}
|
|
317
|
+
Password.args = {
|
|
318
|
+
...Default.args,
|
|
319
|
+
label: "Password",
|
|
320
|
+
inputType: "password",
|
|
321
|
+
placeholder: "••••••••",
|
|
322
|
+
iconAfter: "@",
|
|
323
|
+
}
|