@inglorious/ui 7.2.0 → 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 +81 -23
- package/src/controls/combobox/template.js +276 -145
- 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,72 +33,144 @@ 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
|
|
|
116
96
|
/**
|
|
117
97
|
* Renders the clickable control element that shows the current selection and toggles the dropdown.
|
|
118
98
|
* Handles placeholder, clear button, and the open/disabled state.
|
|
99
|
+
*
|
|
100
|
+
* The control is the only way into a closed combobox, so it has to be
|
|
101
|
+
* focusable: it was a plain div with a click handler, which meant tabbing
|
|
102
|
+
* skipped the field entirely and none of the keyboard handling was reachable.
|
|
103
|
+
* It keeps focus while the dropdown is open unless a search input takes it.
|
|
119
104
|
* @param {ComboboxProps} props
|
|
120
105
|
* @returns {TemplateResult}
|
|
121
106
|
*/
|
|
122
107
|
renderControl(props) {
|
|
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
|
+
|
|
123
136
|
return html`<div
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
137
|
+
id=${controlId}
|
|
138
|
+
class="${classMap(frameClasses)}"
|
|
139
|
+
role="combobox"
|
|
140
|
+
aria-haspopup="listbox"
|
|
141
|
+
aria-expanded=${isOpen ? "true" : "false"}
|
|
142
|
+
aria-controls=${controlId ? `${controlId}-listbox` : ""}
|
|
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}
|
|
148
|
+
@keydown=${(event) => handleControlKeyDown(props, event)}
|
|
149
|
+
@click=${() => !isDisabled && onToggle?.()}
|
|
133
150
|
>
|
|
134
151
|
${when(
|
|
135
|
-
|
|
152
|
+
isMulti,
|
|
136
153
|
() => this.renderMultiValue?.(props),
|
|
137
154
|
() => this.renderSingleValue?.(props),
|
|
138
155
|
)}
|
|
139
156
|
${when(
|
|
140
|
-
|
|
141
|
-
((
|
|
142
|
-
|
|
143
|
-
props.selectedValue.length) ||
|
|
144
|
-
(!props.isMulti && props.selectedValue !== null)),
|
|
157
|
+
isClearable &&
|
|
158
|
+
((isMulti && Array.isArray(selectedValue) && selectedValue.length) ||
|
|
159
|
+
(!isMulti && selectedValue !== null)),
|
|
145
160
|
() =>
|
|
146
161
|
html`<button
|
|
147
162
|
class="iw-combobox-clear"
|
|
148
163
|
type="button"
|
|
149
164
|
@click=${(event) => {
|
|
150
165
|
event.stopPropagation()
|
|
151
|
-
|
|
166
|
+
onClear?.()
|
|
152
167
|
}}
|
|
153
168
|
>
|
|
154
169
|
×
|
|
155
170
|
</button>`,
|
|
156
171
|
)}
|
|
157
172
|
|
|
158
|
-
<
|
|
173
|
+
<span class="iw-combobox-arrow" aria-hidden="true">▼</span>
|
|
159
174
|
</div>`
|
|
160
175
|
},
|
|
161
176
|
|
|
@@ -166,21 +181,21 @@ export const Combobox = {
|
|
|
166
181
|
* @returns {TemplateResult}
|
|
167
182
|
*/
|
|
168
183
|
renderSingleValue(props) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
172
189
|
>`
|
|
173
190
|
}
|
|
174
191
|
|
|
175
|
-
const selectedOption =
|
|
176
|
-
(option) => getOptionValue(option) ===
|
|
192
|
+
const selectedOption = options.find(
|
|
193
|
+
(option) => getOptionValue(option) === selectedValue,
|
|
177
194
|
)
|
|
178
195
|
|
|
179
|
-
return html`<span class="iw-combobox-value"
|
|
196
|
+
return html`<span class="iw-combobox-control iw-combobox-value"
|
|
180
197
|
>${
|
|
181
|
-
selectedOption
|
|
182
|
-
? getOptionLabel(selectedOption)
|
|
183
|
-
: String(props.selectedValue)
|
|
198
|
+
selectedOption ? getOptionLabel(selectedOption) : String(selectedValue)
|
|
184
199
|
}</span
|
|
185
200
|
>`
|
|
186
201
|
},
|
|
@@ -192,15 +207,17 @@ export const Combobox = {
|
|
|
192
207
|
* @returns {TemplateResult}
|
|
193
208
|
*/
|
|
194
209
|
renderMultiValue(props) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
198
215
|
>`
|
|
199
216
|
}
|
|
200
217
|
|
|
201
|
-
return html`<div class="iw-combobox-multi-value">
|
|
218
|
+
return html`<div class="iw-combobox-control iw-combobox-multi-value">
|
|
202
219
|
${repeat(
|
|
203
|
-
|
|
220
|
+
selectedValue,
|
|
204
221
|
(value) => value,
|
|
205
222
|
(value) => this.renderMultiValueTag?.(props, value),
|
|
206
223
|
)}
|
|
@@ -215,9 +232,11 @@ export const Combobox = {
|
|
|
215
232
|
* @returns {TemplateResult}
|
|
216
233
|
*/
|
|
217
234
|
renderMultiValueTag(props, value) {
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
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"
|
|
221
240
|
|
|
222
241
|
return html`<span
|
|
223
242
|
class="iw-combobox-multi-value-tag"
|
|
@@ -230,8 +249,8 @@ export const Combobox = {
|
|
|
230
249
|
size: chipSize,
|
|
231
250
|
shape: "rounded",
|
|
232
251
|
onClick: () => {
|
|
233
|
-
|
|
234
|
-
|
|
252
|
+
onOptionSelect?.(option)
|
|
253
|
+
onChange?.(option)
|
|
235
254
|
},
|
|
236
255
|
})}
|
|
237
256
|
</span>`
|
|
@@ -244,18 +263,22 @@ export const Combobox = {
|
|
|
244
263
|
* @returns {TemplateResult}
|
|
245
264
|
*/
|
|
246
265
|
renderDropdown(props) {
|
|
247
|
-
const
|
|
266
|
+
const { id, options, searchTerm, isSearchable, isLoading } = props
|
|
267
|
+
|
|
268
|
+
const filteredOptions = filterOptions(options, searchTerm)
|
|
248
269
|
|
|
249
270
|
return html`<div
|
|
271
|
+
id=${id ? `${id}-control-listbox` : ""}
|
|
250
272
|
class="iw-combobox-dropdown"
|
|
273
|
+
role="listbox"
|
|
251
274
|
${ref((el) => trackOutsideClick(props, el))}
|
|
252
275
|
>
|
|
253
|
-
${when(
|
|
254
|
-
${when(
|
|
255
|
-
${when(!
|
|
276
|
+
${when(isSearchable, () => this.renderSearchInput?.(props))}
|
|
277
|
+
${when(isLoading, () => this.renderLoading?.(props))}
|
|
278
|
+
${when(!isLoading && !filteredOptions.length, () =>
|
|
256
279
|
this.renderNoOptions?.(props),
|
|
257
280
|
)}
|
|
258
|
-
${when(!
|
|
281
|
+
${when(!isLoading && filteredOptions.length, () =>
|
|
259
282
|
this.renderOptions?.(props),
|
|
260
283
|
)}
|
|
261
284
|
</div>`
|
|
@@ -268,15 +291,17 @@ export const Combobox = {
|
|
|
268
291
|
* @returns {TemplateResult}
|
|
269
292
|
*/
|
|
270
293
|
renderSearchInput(props) {
|
|
294
|
+
const { searchTerm, isOpen, onSearchChange } = props
|
|
295
|
+
|
|
271
296
|
return html`<input
|
|
272
297
|
class="iw-combobox-dropdown-search"
|
|
273
298
|
type="text"
|
|
274
299
|
placeholder="Search..."
|
|
275
|
-
.value=${
|
|
276
|
-
@input=${(event) =>
|
|
300
|
+
.value=${searchTerm ?? ""}
|
|
301
|
+
@input=${(event) => onSearchChange?.(event.target.value)}
|
|
277
302
|
@keydown=${(event) => handleKeyDown(props, event)}
|
|
278
303
|
${ref((el) => {
|
|
279
|
-
if (el &&
|
|
304
|
+
if (el && isOpen) queueMicrotask(() => el.focus())
|
|
280
305
|
})}
|
|
281
306
|
/>`
|
|
282
307
|
},
|
|
@@ -288,7 +313,9 @@ export const Combobox = {
|
|
|
288
313
|
* @returns {TemplateResult}
|
|
289
314
|
*/
|
|
290
315
|
renderOptions(props) {
|
|
291
|
-
const
|
|
316
|
+
const { options, searchTerm } = props
|
|
317
|
+
|
|
318
|
+
const filteredOptions = filterOptions(options, searchTerm)
|
|
292
319
|
|
|
293
320
|
return html`<div class="iw-combobox-dropdown-options">
|
|
294
321
|
${repeat(
|
|
@@ -307,15 +334,24 @@ export const Combobox = {
|
|
|
307
334
|
* @returns {TemplateResult}
|
|
308
335
|
*/
|
|
309
336
|
renderOption(props, { option, index }) {
|
|
337
|
+
const {
|
|
338
|
+
id,
|
|
339
|
+
selectedValue,
|
|
340
|
+
focusedIndex,
|
|
341
|
+
isMulti,
|
|
342
|
+
onOptionSelect,
|
|
343
|
+
onChange,
|
|
344
|
+
onFocusSet,
|
|
345
|
+
} = props
|
|
346
|
+
|
|
310
347
|
const normalized = formatOption(option)
|
|
311
|
-
const isSelected = isOptionSelected(
|
|
312
|
-
|
|
313
|
-
props.selectedValue,
|
|
314
|
-
props.isMulti,
|
|
315
|
-
)
|
|
316
|
-
const isFocused = index === props.focusedIndex
|
|
348
|
+
const isSelected = isOptionSelected(option, selectedValue, isMulti)
|
|
349
|
+
const isFocused = index === focusedIndex
|
|
317
350
|
|
|
318
351
|
return html`<div
|
|
352
|
+
id=${id ? `${id}-option-${index}` : ""}
|
|
353
|
+
role="option"
|
|
354
|
+
aria-selected=${isSelected ? "true" : "false"}
|
|
319
355
|
class="iw-combobox-dropdown-options-option ${classMap({
|
|
320
356
|
"iw-combobox-dropdown-options-option-selected": isSelected,
|
|
321
357
|
"iw-combobox-dropdown-options-option-focused": isFocused,
|
|
@@ -323,13 +359,13 @@ export const Combobox = {
|
|
|
323
359
|
})}"
|
|
324
360
|
@click=${() => {
|
|
325
361
|
if (normalized.isDisabled) return
|
|
326
|
-
|
|
327
|
-
|
|
362
|
+
onOptionSelect?.(normalized)
|
|
363
|
+
onChange?.(normalized)
|
|
328
364
|
}}
|
|
329
|
-
@mouseenter=${() =>
|
|
365
|
+
@mouseenter=${() => onFocusSet?.(index)}
|
|
330
366
|
>
|
|
331
367
|
${when(
|
|
332
|
-
|
|
368
|
+
isMulti,
|
|
333
369
|
() =>
|
|
334
370
|
html`<input
|
|
335
371
|
type="checkbox"
|
|
@@ -348,7 +384,9 @@ export const Combobox = {
|
|
|
348
384
|
* @returns {TemplateResult}
|
|
349
385
|
*/
|
|
350
386
|
renderLoading(props) {
|
|
351
|
-
|
|
387
|
+
const { loadingMessage } = props
|
|
388
|
+
|
|
389
|
+
return html`<div class="iw-combobox-loading">${loadingMessage}</div>`
|
|
352
390
|
},
|
|
353
391
|
|
|
354
392
|
/**
|
|
@@ -358,55 +396,148 @@ export const Combobox = {
|
|
|
358
396
|
* @returns {TemplateResult}
|
|
359
397
|
*/
|
|
360
398
|
renderNoOptions(props) {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
</div>`
|
|
399
|
+
const { noOptionsMessage } = props
|
|
400
|
+
return html`<div class="iw-combobox-no-options">${noOptionsMessage}</div>`
|
|
364
401
|
},
|
|
365
402
|
}
|
|
366
403
|
|
|
404
|
+
/**
|
|
405
|
+
* Opens a closed combobox from the keyboard, and navigates an open one.
|
|
406
|
+
*
|
|
407
|
+
* The control keeps DOM focus whenever the combobox is not searchable — there
|
|
408
|
+
* is no search input to hand focus to — so while open it drives the same
|
|
409
|
+
* navigation handleKeyDown() gives the search input, rather than toggling.
|
|
410
|
+
* @param {ComboboxProps} props
|
|
411
|
+
* @param {KeyboardEvent} event
|
|
412
|
+
*/
|
|
413
|
+
function handleControlKeyDown(props, event) {
|
|
414
|
+
const { isDisabled, isOpen, onToggle } = props
|
|
415
|
+
|
|
416
|
+
if (isDisabled) return
|
|
417
|
+
|
|
418
|
+
if (isOpen) {
|
|
419
|
+
handleKeyDown(props, event)
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (event.key !== "Enter" && event.key !== " " && event.key !== "ArrowDown") {
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
// Space would otherwise scroll the page out from under the dropdown.
|
|
427
|
+
event.preventDefault()
|
|
428
|
+
onToggle?.()
|
|
429
|
+
}
|
|
430
|
+
|
|
367
431
|
/**
|
|
368
432
|
* Handle key navigation.
|
|
369
433
|
* @param {ComboboxProps} props
|
|
370
434
|
* @param {KeyboardEvent} event
|
|
371
435
|
*/
|
|
372
436
|
function handleKeyDown(props, event) {
|
|
373
|
-
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)
|
|
374
451
|
|
|
375
452
|
switch (event.key) {
|
|
376
453
|
case "ArrowDown":
|
|
377
454
|
event.preventDefault()
|
|
378
|
-
|
|
455
|
+
onFocusNext?.()
|
|
379
456
|
break
|
|
380
457
|
case "ArrowUp":
|
|
381
458
|
event.preventDefault()
|
|
382
|
-
|
|
459
|
+
onFocusPrev?.()
|
|
383
460
|
break
|
|
384
461
|
case "Enter":
|
|
385
462
|
event.preventDefault()
|
|
386
|
-
if (
|
|
387
|
-
|
|
388
|
-
filteredOptions[props.focusedIndex]
|
|
389
|
-
) {
|
|
390
|
-
const option = formatOption(filteredOptions[props.focusedIndex])
|
|
463
|
+
if (focusedIndex > NO_FOCUSED_INDEX && filteredOptions[focusedIndex]) {
|
|
464
|
+
const option = formatOption(filteredOptions[focusedIndex])
|
|
391
465
|
if (!option.isDisabled) {
|
|
392
|
-
|
|
393
|
-
|
|
466
|
+
onOptionSelect?.(option)
|
|
467
|
+
onChange?.(option)
|
|
394
468
|
}
|
|
395
469
|
}
|
|
396
470
|
break
|
|
397
471
|
case "Escape":
|
|
398
472
|
event.preventDefault()
|
|
399
|
-
|
|
473
|
+
onClose?.()
|
|
400
474
|
break
|
|
401
475
|
case "Home":
|
|
402
476
|
event.preventDefault()
|
|
403
|
-
|
|
477
|
+
onFocusFirst?.()
|
|
404
478
|
break
|
|
405
479
|
case "End":
|
|
406
480
|
event.preventDefault()
|
|
407
|
-
|
|
481
|
+
onFocusLast?.()
|
|
408
482
|
break
|
|
409
483
|
default:
|
|
410
484
|
break
|
|
411
485
|
}
|
|
412
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
|
+
}
|