@inglorious/ui 7.1.5 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/ui",
3
- "version": "7.1.5",
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,24 +21,72 @@ import {
21
21
 
22
22
  const NO_FOCUSED_INDEX = -1
23
23
 
24
- const outsideClickListeners = new Map()
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()
25
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
+ */
26
63
  function trackOutsideClick(props, el) {
27
- const previous = outsideClickListeners.get(props.id)
64
+ const key = props.id
65
+ const previous = outsideClicks.get(key)
66
+
28
67
  if (previous) {
29
- document.removeEventListener("click", previous)
30
- outsideClickListeners.delete(props.id)
68
+ clearTimeout(previous.timer)
69
+ document.removeEventListener("pointerdown", previous.listener)
70
+ outsideClicks.delete(key)
31
71
  }
32
72
 
33
73
  if (!el) return
34
74
 
35
- const root = el.closest(".iw-combobox")
75
+ // Resolve the root so the control and the dropdown both count as inside.
76
+ const root = el.closest(".iw-combobox") ?? el
36
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.
37
82
  if (!event.composedPath().includes(root)) props.onClose?.()
38
83
  }
39
84
 
40
- outsideClickListeners.set(props.id, listener)
41
- document.addEventListener("click", listener)
85
+ const timer = setTimeout(() => {
86
+ document.addEventListener("pointerdown", listener, { once: true })
87
+ })
88
+
89
+ outsideClicks.set(key, { timer, listener })
42
90
  }
43
91
 
44
92
  export const Combobox = {
@@ -92,7 +140,22 @@ export const Combobox = {
92
140
  Array.isArray(props.selectedValue) &&
93
141
  props.selectedValue.length,
94
142
  })}"
95
- @click=${() => !props.isDisabled && props.onToggle?.()}
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
+ }}
96
159
  >
97
160
  ${when(
98
161
  props.isMulti,
@@ -109,6 +172,7 @@ export const Combobox = {
109
172
  html`<button
110
173
  class="iw-combobox-clear"
111
174
  type="button"
175
+ @pointerdown=${(event) => event.stopPropagation()}
112
176
  @click=${(event) => {
113
177
  event.stopPropagation()
114
178
  props.onClear?.()
@@ -184,6 +248,7 @@ export const Combobox = {
184
248
 
185
249
  return html`<span
186
250
  class="iw-combobox-multi-value-tag"
251
+ @pointerdown=${(event) => event.stopPropagation()}
187
252
  @click=${(event) => event.stopPropagation()}
188
253
  >
189
254
  ${Chip.render({
@@ -239,7 +304,9 @@ export const Combobox = {
239
304
  @input=${(event) => props.onSearchChange?.(event.target.value)}
240
305
  @keydown=${(event) => handleKeyDown(props, event)}
241
306
  ${ref((el) => {
242
- if (el && props.isOpen) queueMicrotask(() => el.focus())
307
+ if (el && props.isOpen && shouldFocusSearch()) {
308
+ queueMicrotask(() => el.focus())
309
+ }
243
310
  })}
244
311
  />`
245
312
  },
@@ -327,6 +394,25 @@ export const Combobox = {
327
394
  },
328
395
  }
329
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
+
330
416
  /**
331
417
  * Handle key navigation.
332
418
  * @param {ComboboxProps} props