stimeo-ui 0.1.0.pre.beta.3 → 0.2.0

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.
@@ -2,6 +2,90 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/tooltip_controller.ts
4
4
 
5
+ // src/utils/escape_layer.ts
6
+ var EscapeLayer = class _EscapeLayer {
7
+ static #registries = /* @__PURE__ */ new WeakMap();
8
+ #ownerDocument = null;
9
+ /** Dismissal callback while active; `null` when inactive. */
10
+ #onDismiss = null;
11
+ /** Live predicate deciding whether the layer claims a press; `null` = always. */
12
+ #claims = null;
13
+ /**
14
+ * Activates this layer at the top of its document's Escape stack, installing
15
+ * the document's shared resolver listener if this is its first layer.
16
+ * Re-activating an already-active layer moves it to the top.
17
+ */
18
+ activate(ownerDocument = document, options) {
19
+ this.deactivate();
20
+ let registry = _EscapeLayer.#registries.get(ownerDocument);
21
+ if (!registry) {
22
+ registry = _EscapeLayer.#createRegistry();
23
+ _EscapeLayer.#registries.set(ownerDocument, registry);
24
+ ownerDocument.addEventListener("keydown", registry.onKeydown);
25
+ }
26
+ registry.stack.push(this);
27
+ this.#ownerDocument = ownerDocument;
28
+ this.#onDismiss = options.onDismiss;
29
+ this.#claims = options.claims ?? null;
30
+ }
31
+ /**
32
+ * Removes this layer from its document's Escape stack, uninstalling the
33
+ * shared listener when the stack empties. Safe to call when inactive.
34
+ */
35
+ deactivate() {
36
+ const ownerDocument = this.#ownerDocument;
37
+ if (!ownerDocument) return;
38
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
39
+ if (registry) {
40
+ const index = registry.stack.lastIndexOf(this);
41
+ if (index >= 0) registry.stack.splice(index, 1);
42
+ if (registry.stack.length === 0) {
43
+ ownerDocument.removeEventListener("keydown", registry.onKeydown);
44
+ _EscapeLayer.#registries.delete(ownerDocument);
45
+ }
46
+ }
47
+ this.#ownerDocument = null;
48
+ this.#onDismiss = null;
49
+ this.#claims = null;
50
+ }
51
+ /**
52
+ * Whether this active layer would own a press right now: it is the topmost
53
+ * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
54
+ * and diagnostics — production dismissal goes through the shared listener.
55
+ */
56
+ get ownsEscape() {
57
+ const ownerDocument = this.#ownerDocument;
58
+ if (!ownerDocument) return false;
59
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
60
+ if (!registry) return false;
61
+ return _EscapeLayer.#resolveOwner(registry.stack) === this;
62
+ }
63
+ /** Builds a document's registry with its shared resolver listener. */
64
+ static #createRegistry() {
65
+ const registry = {
66
+ stack: [],
67
+ onKeydown: (event) => {
68
+ if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
69
+ const owner = _EscapeLayer.#resolveOwner(registry.stack);
70
+ if (!owner) return;
71
+ event.preventDefault();
72
+ owner.#onDismiss?.();
73
+ }
74
+ };
75
+ return registry;
76
+ }
77
+ /** The topmost stack layer whose claims predicate passes, or `null`. */
78
+ static #resolveOwner(stack) {
79
+ for (let index = stack.length - 1; index >= 0; index--) {
80
+ const layer = stack[index];
81
+ if (!layer) continue;
82
+ if (layer.#claims && !layer.#claims()) continue;
83
+ return layer;
84
+ }
85
+ return null;
86
+ }
87
+ };
88
+
5
89
  // src/utils/safe_timeout.ts
6
90
  var TimerRegistry = class {
7
91
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -88,27 +172,39 @@ var TooltipController = class extends Controller {
88
172
  hideDelay: { type: Number, default: 0 },
89
173
  closeOnScroll: { type: Boolean, default: false }
90
174
  };
91
- static actions = ["hide", "onKeydown", "show"];
92
- /** Pending show/hide timers, torn down together on disconnect. */
175
+ static actions = ["hide", "show"];
176
+ /** Registry whose pending timers are cancelled individually with their guard ids. */
93
177
  #timers = new SafeTimeout();
94
- /** The id of the currently pending show or hide timer, if any. */
178
+ /** Escape-stack membership while shown; the shared resolver dismisses via it. */
179
+ #escapeLayer = new EscapeLayer();
180
+ /** The id of the currently pending show timer, if any. */
95
181
  #pendingShow = null;
182
+ /** The id of the currently pending hide timer, if any. */
96
183
  #pendingHide = null;
184
+ /** Whether focus or the pointer currently requires the tooltip to persist. */
185
+ #focusActive = false;
186
+ #pointerActive = false;
97
187
  /** Cleanup for the dismiss-on-scroll listeners while shown, or `null`. */
98
188
  #stopScrollDismiss = null;
99
- /** Starts hidden. */
189
+ /** Starts hidden with no stale timer or interaction state from a prior connection. */
100
190
  connect() {
191
+ this.#cancelShow();
192
+ this.#cancelHide();
193
+ this.#resetInteractionState();
101
194
  this.#conceal();
102
195
  }
103
- /** Clears timers and the document `Escape` / scroll listeners so nothing outlives the element. */
196
+ /** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */
104
197
  disconnect() {
105
- this.#timers.clearAll();
106
- document.removeEventListener("keydown", this.#onDocumentKeydown);
198
+ this.#cancelShow();
199
+ this.#cancelHide();
200
+ this.#resetInteractionState();
201
+ this.#escapeLayer.deactivate();
107
202
  this.#stopScrollDismiss?.();
108
203
  this.#stopScrollDismiss = null;
109
204
  }
110
- /** Shows the tooltip, after `showDelay` ms (or immediately at 0). Cancels a pending hide. */
111
- show() {
205
+ /** Shows after `showDelay`, recording the focus/pointer reason supplied by an action event. */
206
+ show(event) {
207
+ this.#activateInteraction(event);
112
208
  this.#cancelHide();
113
209
  if (this.#isVisible || this.#pendingShow !== null) return;
114
210
  if (this.showDelayValue <= 0) {
@@ -120,8 +216,13 @@ var TooltipController = class extends Controller {
120
216
  this.#reveal();
121
217
  }, this.showDelayValue);
122
218
  }
123
- /** Hides the tooltip, after `hideDelay` ms (or immediately at 0). Cancels a pending show. */
124
- hide() {
219
+ /** Hides after `hideDelay` once no focus/pointer reason remains; eventless calls are explicit. */
220
+ hide(event) {
221
+ const interactionEnded = this.#deactivateInteraction(event);
222
+ if (interactionEnded && this.#hasActiveInteraction) {
223
+ this.#cancelHide();
224
+ return;
225
+ }
125
226
  this.#cancelShow();
126
227
  if (!this.#isVisible || this.#pendingHide !== null) return;
127
228
  if (this.hideDelayValue <= 0) {
@@ -133,52 +234,57 @@ var TooltipController = class extends Controller {
133
234
  this.#conceal();
134
235
  }, this.hideDelayValue);
135
236
  }
136
- /**
137
- * Dismisses the tooltip on `Escape` from the trigger. The authoritative
138
- * dismissal path is the document-level listener (added while shown) so a
139
- * hover-triggered tooltip is dismissible regardless of focus; this handler
140
- * keeps the documented `keydown->#onKeydown` binding meaningful too.
141
- */
142
- onKeydown(event) {
143
- if (event.key === "Escape" && this.#isVisible) {
144
- event.preventDefault();
145
- this.#cancelShow();
146
- this.#cancelHide();
147
- this.#conceal();
148
- }
149
- }
150
- /** Reveals the content and starts watching for a dismissing `Escape`/scroll. */
237
+ /** Reveals the content and joins the Escape stack / starts the scroll watcher. */
151
238
  #reveal() {
152
239
  if (!this.hasContentTarget) return;
153
240
  this.contentTarget.hidden = false;
154
241
  this.contentTarget.setAttribute("data-state", "open");
155
- document.addEventListener("keydown", this.#onDocumentKeydown);
242
+ this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });
156
243
  if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
157
- this.#stopScrollDismiss = observeScrollDismiss(this.element, () => {
158
- this.#cancelShow();
159
- this.#cancelHide();
160
- this.#conceal();
161
- });
244
+ this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());
162
245
  }
163
246
  }
164
- /** Hides the content and stops watching for `Escape`/scroll. */
247
+ /** Hides the content and leaves the Escape stack / stops the scroll watcher. */
165
248
  #conceal() {
166
- document.removeEventListener("keydown", this.#onDocumentKeydown);
249
+ this.#escapeLayer.deactivate();
167
250
  this.#stopScrollDismiss?.();
168
251
  this.#stopScrollDismiss = null;
169
252
  if (!this.hasContentTarget) return;
170
253
  this.contentTarget.hidden = true;
171
254
  this.contentTarget.setAttribute("data-state", "closed");
172
255
  }
173
- /** Document-level `Escape` watcher (active only while shown). */
174
- #onDocumentKeydown = (event) => {
175
- if (event.key === "Escape") {
176
- event.preventDefault();
177
- this.#cancelShow();
178
- this.#cancelHide();
179
- this.#conceal();
256
+ /** Cancels pending timers and conceals immediately (shared Escape / scroll path). */
257
+ #dismiss() {
258
+ this.#cancelShow();
259
+ this.#cancelHide();
260
+ this.#conceal();
261
+ }
262
+ /** Records the modality whose enter/focus event requires the tooltip to stay visible. */
263
+ #activateInteraction(event) {
264
+ if (event?.type === "mouseenter") this.#pointerActive = true;
265
+ if (event?.type === "focusin") this.#focusActive = true;
266
+ }
267
+ /** Clears a modality on leave/blur and reports whether the event represented such a change. */
268
+ #deactivateInteraction(event) {
269
+ if (event?.type === "mouseleave") {
270
+ this.#pointerActive = false;
271
+ return true;
180
272
  }
181
- };
273
+ if (event?.type === "focusout") {
274
+ this.#focusActive = false;
275
+ return true;
276
+ }
277
+ return false;
278
+ }
279
+ /** Discards interaction reasons at a lifecycle boundary. */
280
+ #resetInteractionState() {
281
+ this.#focusActive = false;
282
+ this.#pointerActive = false;
283
+ }
284
+ /** Whether focus or pointer presence still requires a persistent tooltip. */
285
+ get #hasActiveInteraction() {
286
+ return this.#focusActive || this.#pointerActive;
287
+ }
182
288
  /** Cancels any pending show timer. */
183
289
  #cancelShow() {
184
290
  if (this.#pendingShow !== null) {