@inglorious/ui 7.1.4 → 7.1.6
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/template.js +109 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/ui",
|
|
3
|
-
"version": "7.1.
|
|
3
|
+
"version": "7.1.6",
|
|
4
4
|
"description": "A design system for Inglorious Web with render-function components and customizable themes",
|
|
5
5
|
"author": "Matteo Antony Mistretta <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,6 +21,74 @@ import {
|
|
|
21
21
|
|
|
22
22
|
const NO_FOCUSED_INDEX = -1
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Window in which an emulated click is treated as the tail of a pointer
|
|
26
|
+
* interaction that already handled the gesture.
|
|
27
|
+
*/
|
|
28
|
+
const GHOST_CLICK_MS = 700
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Last pointer activation per control element, used to stop the click that
|
|
32
|
+
* browsers synthesise after a tap from toggling the dropdown a second time.
|
|
33
|
+
* @type {WeakMap<Element, number>}
|
|
34
|
+
*/
|
|
35
|
+
const pointerActivations = new WeakMap()
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pending outside-pointer listeners, keyed by combobox id. Each entry owns both
|
|
39
|
+
* the deferred timer and the listener so both can be torn down on unmount.
|
|
40
|
+
* @type {Map<string|number, { timer: any, listener: (event: Event) => void }>}
|
|
41
|
+
*/
|
|
42
|
+
const outsideClicks = new Map()
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Attach (or tear down) the document listener that closes the dropdown when the
|
|
46
|
+
* user interacts outside of it.
|
|
47
|
+
*
|
|
48
|
+
* This listens for `pointerdown` rather than `click` on purpose. Mobile browsers
|
|
49
|
+
* synthesise `click` asynchronously after `touchend`, and drop it entirely when
|
|
50
|
+
* the touch drifts into a scroll gesture or the target is replaced meanwhile.
|
|
51
|
+
* A suppressed click leaves the listener armed, so it then closes the *next*
|
|
52
|
+
* dropdown on the tap that opened it and the combobox looks stuck. `pointerdown`
|
|
53
|
+
* is dispatched synchronously for mouse, touch and pen, so there is no race.
|
|
54
|
+
*
|
|
55
|
+
* The registration is deferred by a macrotask because the pointerdown that opens
|
|
56
|
+
* the dropdown is already travelling towards the document. The downside is that
|
|
57
|
+
* the dropdown can unmount before the timer runs, which would leave a listener
|
|
58
|
+
* bound to a detached node behind; tearing the pending timer down on unmount
|
|
59
|
+
* avoids that.
|
|
60
|
+
* @param {ComboboxProps} props
|
|
61
|
+
* @param {Element|null|undefined} el dropdown element, or undefined on unmount
|
|
62
|
+
*/
|
|
63
|
+
function trackOutsideClick(props, el) {
|
|
64
|
+
const key = props.id
|
|
65
|
+
const previous = outsideClicks.get(key)
|
|
66
|
+
|
|
67
|
+
if (previous) {
|
|
68
|
+
clearTimeout(previous.timer)
|
|
69
|
+
document.removeEventListener("pointerdown", previous.listener)
|
|
70
|
+
outsideClicks.delete(key)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!el) return
|
|
74
|
+
|
|
75
|
+
// Resolve the root so the control and the dropdown both count as inside.
|
|
76
|
+
const root = el.closest(".iw-combobox") ?? el
|
|
77
|
+
const listener = (event) => {
|
|
78
|
+
outsideClicks.delete(key)
|
|
79
|
+
// composedPath is captured at dispatch time, so it still reports the
|
|
80
|
+
// combobox as an ancestor even when the pressed node was removed from the
|
|
81
|
+
// DOM while the event was bubbling.
|
|
82
|
+
if (!event.composedPath().includes(root)) props.onClose?.()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const timer = setTimeout(() => {
|
|
86
|
+
document.addEventListener("pointerdown", listener, { once: true })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
outsideClicks.set(key, { timer, listener })
|
|
90
|
+
}
|
|
91
|
+
|
|
24
92
|
export const Combobox = {
|
|
25
93
|
/**
|
|
26
94
|
* Main entrypoint for the combobox control. It renders the wrapper, label, control, and dropdown when open.
|
|
@@ -72,7 +140,22 @@ export const Combobox = {
|
|
|
72
140
|
Array.isArray(props.selectedValue) &&
|
|
73
141
|
props.selectedValue.length,
|
|
74
142
|
})}"
|
|
75
|
-
@
|
|
143
|
+
@pointerdown=${(event) => {
|
|
144
|
+
// Touch and pen only: the mouse keeps using click, so a mouse press
|
|
145
|
+
// never runs the toggle twice.
|
|
146
|
+
if (props.isDisabled) return
|
|
147
|
+
if (event.pointerType === "mouse") return
|
|
148
|
+
pointerActivations.set(event.currentTarget, Date.now())
|
|
149
|
+
props.onToggle?.()
|
|
150
|
+
}}
|
|
151
|
+
@click=${(event) => {
|
|
152
|
+
if (props.isDisabled) return
|
|
153
|
+
// Swallow the click a browser synthesises after a tap that the
|
|
154
|
+
// pointerdown above already handled.
|
|
155
|
+
const lastActivation = pointerActivations.get(event.currentTarget) ?? 0
|
|
156
|
+
if (Date.now() - lastActivation < GHOST_CLICK_MS) return
|
|
157
|
+
props.onToggle?.()
|
|
158
|
+
}}
|
|
76
159
|
>
|
|
77
160
|
${when(
|
|
78
161
|
props.isMulti,
|
|
@@ -89,6 +172,7 @@ export const Combobox = {
|
|
|
89
172
|
html`<button
|
|
90
173
|
class="iw-combobox-clear"
|
|
91
174
|
type="button"
|
|
175
|
+
@pointerdown=${(event) => event.stopPropagation()}
|
|
92
176
|
@click=${(event) => {
|
|
93
177
|
event.stopPropagation()
|
|
94
178
|
props.onClear?.()
|
|
@@ -164,6 +248,7 @@ export const Combobox = {
|
|
|
164
248
|
|
|
165
249
|
return html`<span
|
|
166
250
|
class="iw-combobox-multi-value-tag"
|
|
251
|
+
@pointerdown=${(event) => event.stopPropagation()}
|
|
167
252
|
@click=${(event) => event.stopPropagation()}
|
|
168
253
|
>
|
|
169
254
|
${Chip.render({
|
|
@@ -191,21 +276,7 @@ export const Combobox = {
|
|
|
191
276
|
|
|
192
277
|
return html`<div
|
|
193
278
|
class="iw-combobox-dropdown"
|
|
194
|
-
${ref((el) =>
|
|
195
|
-
if (el) {
|
|
196
|
-
setTimeout(() => {
|
|
197
|
-
document.addEventListener(
|
|
198
|
-
"click",
|
|
199
|
-
(event) => {
|
|
200
|
-
if (!el.contains(event.target)) {
|
|
201
|
-
props.onClose?.()
|
|
202
|
-
}
|
|
203
|
-
},
|
|
204
|
-
{ once: true },
|
|
205
|
-
)
|
|
206
|
-
})
|
|
207
|
-
}
|
|
208
|
-
})}
|
|
279
|
+
${ref((el) => trackOutsideClick(props, el))}
|
|
209
280
|
>
|
|
210
281
|
${when(props.isSearchable, () => this.renderSearchInput?.(props))}
|
|
211
282
|
${when(props.isLoading, () => this.renderLoading?.(props))}
|
|
@@ -233,7 +304,9 @@ export const Combobox = {
|
|
|
233
304
|
@input=${(event) => props.onSearchChange?.(event.target.value)}
|
|
234
305
|
@keydown=${(event) => handleKeyDown(props, event)}
|
|
235
306
|
${ref((el) => {
|
|
236
|
-
if (el && props.isOpen
|
|
307
|
+
if (el && props.isOpen && shouldFocusSearch()) {
|
|
308
|
+
queueMicrotask(() => el.focus())
|
|
309
|
+
}
|
|
237
310
|
})}
|
|
238
311
|
/>`
|
|
239
312
|
},
|
|
@@ -321,6 +394,25 @@ export const Combobox = {
|
|
|
321
394
|
},
|
|
322
395
|
}
|
|
323
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Whether opening the dropdown should move focus into the search field.
|
|
399
|
+
*
|
|
400
|
+
* Focusing it is what lets keyboard users start typing straight away, but on a
|
|
401
|
+
* touch device it raises the on-screen keyboard, which covers the very option
|
|
402
|
+
* list the dropdown was opened to read. Coarse pointers therefore open the
|
|
403
|
+
* dropdown without focusing the search field.
|
|
404
|
+
* @returns {boolean}
|
|
405
|
+
*/
|
|
406
|
+
function shouldFocusSearch() {
|
|
407
|
+
if (
|
|
408
|
+
typeof window === "undefined" ||
|
|
409
|
+
typeof window.matchMedia !== "function"
|
|
410
|
+
) {
|
|
411
|
+
return true
|
|
412
|
+
}
|
|
413
|
+
return window.matchMedia("(hover: hover) and (pointer: fine)").matches
|
|
414
|
+
}
|
|
415
|
+
|
|
324
416
|
/**
|
|
325
417
|
* Handle key navigation.
|
|
326
418
|
* @param {ComboboxProps} props
|