@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/ui",
3
- "version": "7.1.4",
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
- @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
+ }}
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) queueMicrotask(() => el.focus())
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