@gjsify/adwaita-web 0.42.0 → 0.44.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.
Files changed (32) hide show
  1. package/README.md +27 -0
  2. package/lib/types/adw-alert-dialog.spec.d.ts +1 -0
  3. package/lib/types/elements/adw-alert-dialog.d.ts +12 -0
  4. package/lib/types/elements/adw-carousel.d.ts +8 -0
  5. package/lib/types/elements/adw-entry-row.d.ts +5 -7
  6. package/lib/types/elements/adw-header-bar.d.ts +7 -0
  7. package/lib/types/elements/adw-navigation-split-view.d.ts +9 -0
  8. package/lib/types/elements/adw-preferences-group.d.ts +18 -9
  9. package/lib/types/elements/swipe-drag.d.ts +63 -0
  10. package/lib/types/slotted-children.d.ts +65 -0
  11. package/lib/types/slotted-children.spec.d.ts +1 -0
  12. package/package.json +6 -6
  13. package/src/adw-alert-dialog.spec.ts +146 -0
  14. package/src/adw-header-bar.spec.ts +4 -2
  15. package/src/elements/adw-action-row.ts +8 -6
  16. package/src/elements/adw-alert-dialog.ts +99 -23
  17. package/src/elements/adw-carousel.ts +86 -5
  18. package/src/elements/adw-dialog.ts +5 -6
  19. package/src/elements/adw-entry-row.ts +33 -34
  20. package/src/elements/adw-expander-row.ts +19 -15
  21. package/src/elements/adw-header-bar.ts +39 -20
  22. package/src/elements/adw-navigation-split-view.ts +31 -8
  23. package/src/elements/adw-overlay-split-view.ts +11 -12
  24. package/src/elements/adw-preferences-dialog.ts +7 -8
  25. package/src/elements/adw-preferences-group.ts +37 -36
  26. package/src/elements/adw-status-page.ts +10 -4
  27. package/src/elements/adw-toast-overlay.ts +6 -4
  28. package/src/elements/adw-toolbar-view.ts +16 -11
  29. package/src/elements/swipe-drag.ts +450 -0
  30. package/src/slotted-children.spec.ts +227 -0
  31. package/src/slotted-children.ts +212 -0
  32. package/src/test.browser.mts +4 -0
@@ -0,0 +1,450 @@
1
+ // Dragging a swipeable widget with the pointer — the DOM half of `AdwSwipeTracker`.
2
+ //
3
+ // THE INCIDENT
4
+ //
5
+ // Measured in Firefox on a bare `<adw-carousel>` with three 440 px pages: a 300 px mouse
6
+ // drag across it moved `position` 0 → 0 and `scrollLeft` 0 → 0. Nothing. A touchpad
7
+ // two-finger swipe moved 0 → 2 and writing `scrollLeft = 440` moved `position` to 1, so
8
+ // the strip was scrollable and the position feedback worked — the gesture was simply not
9
+ // implemented. Upstream it is not optional: `AdwCarousel` turns it on in `init`
10
+ // (`adw_swipe_tracker_set_allow_mouse_drag (self->tracker, TRUE)`, adw-carousel.c:1209)
11
+ // and exposes it as a public property whose default is TRUE (`allow-mouse-drag`, :1091),
12
+ // documented "If the value is FALSE, dragging is only available on touch."
13
+ //
14
+ // WHAT THIS DOES *NOT* TOUCH, and why that is the compatible answer
15
+ //
16
+ // Touch and touchpad swiping already work, because the widget is a real scroll container
17
+ // with `scroll-snap-type` and the browser owns those gestures — momentum, rubber-band and
18
+ // snapping included. GTK is likewise the platform there. Taking them over to run the
19
+ // tracker on every touchmove would replace a native gesture with an imitation of one, so
20
+ // this module handles the ONE input the browser gives no gesture for: a held mouse button.
21
+ //
22
+ // The consequence to know about is a real divergence and it is ledgered, not hidden: GTK
23
+ // runs its touchpad scroll through the same tracker, so `allow-long-swipes: false` bounds
24
+ // a touchpad flick to the adjacent page there and does not here — measured, a two-finger
25
+ // flick crossed two pages.
26
+ //
27
+ // WHY POINTER EVENTS AND NOT touch/mouse PAIRS
28
+ //
29
+ // `pointerType` is a fact about the event; `'ontouchstart' in window` is a fact about the
30
+ // device. A laptop with a touchscreen is both, so a listener set chosen from the device —
31
+ // the shape `@ribajs/extras`' touch-events service uses, ported from jQuery Mobile — picks
32
+ // one path for a machine that needs both. One set of pointer listeners and a check on
33
+ // `pointerType` cannot get that wrong.
34
+ //
35
+ // THE CLAIM IS A STATE MACHINE, NOT A BOOLEAN, and compressing it into one cost three
36
+ // behaviours. `drag_update_cb` (adw-swipe-tracker.c:640-750) runs in this order, and the
37
+ // order is the point:
38
+ //
39
+ // 1. `append_to_history` — UNCONDITIONAL (:670), ahead of everything below. A move made
40
+ // before the gesture is claimed still counts toward the velocity.
41
+ // 2. at STATE_NONE, the axis is checked and the gesture is DENIED on the wrong one
42
+ // (:673) — on the FIRST move, at any distance, not only once 16 px are travelled.
43
+ // 3. at STATE_PENDING, past the threshold, four more refusals before claiming: outside
44
+ // the swipe area, the wrong axis again (:715), a strip whose first and last snap
45
+ // point are the same (:720), and overshooting either end with overshoot disabled
46
+ // (:725-739) — which `AdwCarousel` always has.
47
+ // 4. only at STATE_SCROLLING does `gesture_update` apply the delta (:749).
48
+ //
49
+ // Skipping 3 is what made a backwards drag on page 0 swallow the click of the button
50
+ // under the cursor: the gesture claimed, clamped to the bound so nothing moved, and then
51
+ // suppressed the click anyway. Skipping 1 cost a whole event — with the first record
52
+ // discarded as the clock-starter, a two-move flick measured zero velocity and settled
53
+ // back instead of paging.
54
+ //
55
+ // HOW A DRAG BEATS A CLICK
56
+ //
57
+ // GTK does not blocklist widgets under the pointer: `should_suppress_drag`
58
+ // (adw-swipe-tracker.c:551) only refuses window handles, and a button inside a carousel
59
+ // loses its click because the drag gesture CLAIMS the event sequence once it passes
60
+ // `DRAG_THRESHOLD_DISTANCE`. This mirrors that: every `pointerdown` is tracked, nothing is
61
+ // pre-excluded, and the claim at 16 px is what suppresses the `click` that would otherwise
62
+ // follow. {@link NATIVELY_DRAGGABLE} is the one place a blocklist is unavoidable — a
63
+ // `<select>` or a range slider runs its own drag inside the browser, where there is no
64
+ // sequence to lose the race for.
65
+
66
+ import { ADW_SWIPE_DRAG_THRESHOLD, SwipeTracker } from '@gjsify/adwaita-core';
67
+
68
+ /**
69
+ * Controls that drag themselves, inside the browser, with no sequence for a claim to win.
70
+ *
71
+ * GTK needs no such list (see the header); this is the DOM's missing half of gesture
72
+ * claiming and nothing more. A `<button>` is deliberately ABSENT: losing its click to a
73
+ * deliberate drag is the upstream behaviour.
74
+ */
75
+ const NATIVELY_DRAGGABLE = 'select, textarea, input[type="range"], [contenteditable=""], [contenteditable="true"]';
76
+
77
+ /** Which way the widget pages, and therefore which axis claims a drag. */
78
+ export type AdwSwipeOrientation = 'horizontal' | 'vertical';
79
+
80
+ export interface AdwSwipeDragInit {
81
+ /** The element the listeners sit on. Items rebuilt under it stay covered. */
82
+ host: HTMLElement;
83
+ orientation: AdwSwipeOrientation;
84
+ /** Re-read on every press: may a mouse drag start right now? */
85
+ enabled: () => boolean;
86
+ /** Page pitch in px. The raw pixel delta is divided by it to reach progress. */
87
+ pitch: () => number;
88
+ /** Snap points, ascending, as `adw_swipeable_get_snap_points` returns them. */
89
+ points: () => readonly number[];
90
+ /** Progress now — the gesture is seeded from it and bounded by it. */
91
+ progress: () => number;
92
+ /** `AdwCarousel:allow-long-swipes`. */
93
+ allowLongSwipes: () => boolean;
94
+ /**
95
+ * `adw_swipeable_get_cancel_progress` — where a CANCELLED gesture goes.
96
+ *
97
+ * Not optional by accident: the tracker's own fallback is where the gesture began,
98
+ * and `AdwCarousel`'s answer is `get_closest_snap_point` — the point nearest where
99
+ * the finger is NOW (adw-carousel.c:1294-1299). The two differ for exactly the
100
+ * gesture that matters, one cancelled past the halfway mark: upstream commits to the
101
+ * page under the cursor, the fallback yanks it back.
102
+ */
103
+ cancelProgress: () => number;
104
+ /** Show this progress. Called on every move that the gesture owns. */
105
+ onUpdate: (progress: number) => void;
106
+ /**
107
+ * Whether a gesture may be claimed at all right now, on top of the refusals this
108
+ * module makes itself. `AdwCarousel` needs none — its `interactive` and
109
+ * `allow-mouse-drag` are {@link enabled} — but `is_in_swipe_area` lives here for the
110
+ * widgets that implement `get_swipe_area`, which the carousel does not.
111
+ */
112
+ claimable?: (start: { x: number; y: number }) => boolean;
113
+ /** Settle on this progress. `velocity` is raw px/ms, for an animation duration. */
114
+ onEnd: (progress: number, velocity: number) => void;
115
+ }
116
+
117
+ /** What a claimed gesture had to override on the host, so releasing it can put it back. */
118
+ interface FrozenStyles {
119
+ scrollSnapType: string;
120
+ scrollBehavior: string;
121
+ userSelect: string;
122
+ }
123
+
124
+ /**
125
+ * Make `host` draggable with a mouse, deciding where it lands the way `AdwSwipeTracker`
126
+ * does.
127
+ *
128
+ * The listener is the element's own and is bound once, without teardown: it survives a
129
+ * re-parent and is collected with the element.
130
+ */
131
+ /** What a caller gets back, so it can tell whether a gesture owns the widget. */
132
+ export interface AdwSwipeDrag {
133
+ /**
134
+ * Whether a gesture is currently claimed.
135
+ *
136
+ * A widget needs this to keep its OTHER inputs out of the offset while a drag owns
137
+ * it. Measured on `<adw-carousel>`: one wheel notch during a claimed drag ran the
138
+ * whole wheel path and jumped `scrollLeft` 80 → 440 in a frame, which the next move
139
+ * then yanked back. Upstream's `scroll_cb` is ungated too, but there the external
140
+ * request is a spring the next frame quietly overrides — here `claim()` sets
141
+ * `scroll-behavior: auto`, which turns it into an instant jump.
142
+ */
143
+ readonly dragging: boolean;
144
+ }
145
+
146
+ export function attachSwipeDrag(init: AdwSwipeDragInit): AdwSwipeDrag {
147
+ const tracker = new SwipeTracker({
148
+ points: init.points,
149
+ allowLongSwipes: init.allowLongSwipes,
150
+ cancelProgress: init.cancelProgress,
151
+ });
152
+
153
+ let pointerId: number | null = null;
154
+ let startX = 0;
155
+ let startY = 0;
156
+ let previousOffset = 0;
157
+ /** Where the pointer is now, for the second half of the swipe-area test. */
158
+ let lastX = 0;
159
+ let lastY = 0;
160
+ /** `ADW_SWIPE_TRACKER_STATE_*`: nothing pressed, a press that may become one, ours. */
161
+ let state: 'none' | 'pending' | 'scrolling' = 'none';
162
+ let frozen: FrozenStyles | null = null;
163
+ /**
164
+ * Set when a claimed drag ends, cleared by the click it eats — or by the next press,
165
+ * because a drag that ended OUTSIDE the host produces no click at all and a one-shot
166
+ * listener would then sit armed and swallow the next real one.
167
+ */
168
+ let suppressNextClick = false;
169
+
170
+ const axisOffset = (dx: number, dy: number) => (init.orientation === 'vertical' ? dy : dx);
171
+
172
+ /**
173
+ * Dragging LEFT must advance, so the offset is negated — `drag_update_cb`'s
174
+ * `offset = -offset`.
175
+ *
176
+ * LTR ONLY, deliberately. Upstream flips this sign for RTL through
177
+ * `adw_swipe_tracker_set_reversed` (`adw-carousel.c:455-465`), and taking a `reversed`
178
+ * flag here would be an afternoon's work — but the web carousel does not work in RTL
179
+ * at ALL yet: its offset model is `scrollLeft = position * distance`, and an RTL
180
+ * scroll container counts `scrollLeft` DOWN from 0, so even `scrollToPage(1)` moves
181
+ * nothing (measured). A reversed branch would compute a correct progress and write it
182
+ * to a container that ignores it. `status/open-todos.md` carries the whole gap; the
183
+ * flag belongs in the change that can test it.
184
+ */
185
+ const progressOffset = (dx: number, dy: number) => -axisOffset(dx, dy);
186
+
187
+ const release = () => {
188
+ // Listeners off and state cleared BEFORE the capture is released, because
189
+ // `releasePointerCapture` fires `lostpointercapture` synchronously and that is
190
+ // one of the endings this listens for — doing it in the other order re-enters
191
+ // `finish` in the middle of itself.
192
+ const captured = pointerId;
193
+ pointerId = null;
194
+ state = 'none';
195
+ tracker.reset();
196
+ window.removeEventListener('pointermove', onMove, true);
197
+ window.removeEventListener('pointerup', onFinish, true);
198
+ window.removeEventListener('pointercancel', onCancel, true);
199
+ window.removeEventListener('lostpointercapture', onLostCapture, true);
200
+
201
+ if (captured !== null && init.host.hasPointerCapture(captured)) {
202
+ init.host.releasePointerCapture(captured);
203
+ }
204
+ if (frozen !== null) {
205
+ init.host.style.scrollSnapType = frozen.scrollSnapType;
206
+ init.host.style.scrollBehavior = frozen.scrollBehavior;
207
+ init.host.style.userSelect = frozen.userSelect;
208
+ frozen = null;
209
+ }
210
+ };
211
+
212
+ /**
213
+ * `:720-739` — the refusals that happen at the threshold, after the axis and before
214
+ * the claim. Returns whether the gesture may be claimed at all.
215
+ */
216
+ const claimAllowed = (offset: number): boolean => {
217
+ const points = init.points();
218
+ if (points.length === 0) return false;
219
+ const first = points[0];
220
+ const last = points[points.length - 1];
221
+ // A strip with one snap point has nowhere to go (:720). The C cannot reach an
222
+ // EMPTY one — `adw_carousel_get_snap_points` returns `MAX (n, 1)` points
223
+ // (adw-carousel.c:1269) — so the length test above stands in for it.
224
+ if (Math.abs(first - last) < Number.EPSILON) return false;
225
+
226
+ const progress = init.progress();
227
+ // Overshooting an end with overshoot disabled, which AdwCarousel always is
228
+ // (:725-739). Without this a backwards drag on the first page claims, moves
229
+ // nothing, and still eats the click of whatever it started on.
230
+ const overshootingLower = offset < 0 && (Math.abs(progress - first) < Number.EPSILON || progress < first);
231
+ const overshootingUpper = offset > 0 && (Math.abs(progress - last) < Number.EPSILON || progress > last);
232
+ if (overshootingLower || overshootingUpper) return false;
233
+
234
+ // BOTH ends, as `is_in_swipe_area (start) || is_in_swipe_area (start + offset)`
235
+ // does (:709-712): a gesture that began outside the grabbable strip and has
236
+ // arrived inside it is still allowed.
237
+ if (init.claimable === undefined) return true;
238
+ return init.claimable({ x: startX, y: startY }) || init.claimable({ x: lastX, y: lastY });
239
+ };
240
+
241
+ const claim = () => {
242
+ state = 'scrolling';
243
+ // Captured HERE and not on the press. With capture set at `pointerdown`, the
244
+ // `click` that follows is dispatched at the CAPTURING element — so it never
245
+ // reaches the button the user actually pressed, and measured, every button inside
246
+ // a carousel stopped working. From the claim on it is harmless, because a claimed
247
+ // gesture suppresses that click on purpose.
248
+ if (pointerId !== null) init.host.setPointerCapture(pointerId);
249
+ // Snap and smooth scrolling both fight a drag: with them on, every write to
250
+ // `scrollLeft` is a request the browser re-animates and re-snaps, so the strip
251
+ // lags the cursor and lands somewhere the tracker did not choose. They go back
252
+ // on before the settle, which is what makes the release animate.
253
+ frozen = {
254
+ scrollSnapType: init.host.style.scrollSnapType,
255
+ scrollBehavior: init.host.style.scrollBehavior,
256
+ userSelect: init.host.style.userSelect,
257
+ };
258
+ init.host.style.scrollSnapType = 'none';
259
+ init.host.style.scrollBehavior = 'auto';
260
+ init.host.style.userSelect = 'none';
261
+ };
262
+
263
+ /**
264
+ * MOVE AND UP ARE LISTENED FOR ON THE WINDOW, and this is not belt-and-braces.
265
+ *
266
+ * Measured: with the listeners on the host and `setPointerCapture` alone, a drag that
267
+ * travelled past the left edge of the viewport never delivered `pointerup` to the
268
+ * track at all — `scroll-snap-type` stayed `none` and the strip sat at `scrollLeft`
269
+ * 387 of a 440 px page, forever, with the gesture still believing it was in flight.
270
+ * That is worse than the missing feature was. Capture is kept as well, because it is
271
+ * what keeps the moves targeted while the pointer is still inside.
272
+ */
273
+ const arm = () => {
274
+ window.addEventListener('pointermove', onMove, true);
275
+ window.addEventListener('pointerup', onFinish, true);
276
+ window.addEventListener('pointercancel', onCancel, true);
277
+ // The ending of last resort, for a CLAIMED gesture whose `pointerup` this never
278
+ // sees — the window losing focus mid-drag, an OS-level cancel. Without it such a
279
+ // gesture leaves the strip stopped mid-page with snapping switched off, which was
280
+ // measured and is worse than the feature being absent.
281
+ //
282
+ // It does NOT cover the host being removed from the document, which an earlier
283
+ // draft of this comment claimed: the implicit release dispatches
284
+ // `lostpointercapture` at the now-detached element, and an event dispatched in a
285
+ // detached tree never reaches `window`. Measured — the styles stayed frozen until
286
+ // a later `pointerup` cleaned up. The button check in `onMove` is the net that
287
+ // actually spans both, since a removed host stops delivering moves anyway.
288
+ window.addEventListener('lostpointercapture', onLostCapture, true);
289
+ };
290
+
291
+ init.host.addEventListener('pointerdown', (event: PointerEvent) => {
292
+ // FIRST, before any guard below can return. A cancelled gesture produces no click
293
+ // to consume the flag, so leaving it set until the next SUCCESSFUL press meant the
294
+ // next click was eaten instead — measured: cancel a drag, then click a `<textarea>`
295
+ // inside the carousel (which `NATIVELY_DRAGGABLE` refuses to drag) and the click
296
+ // never arrived.
297
+ suppressNextClick = false;
298
+
299
+ // A gesture still open under the SAME pointer means its end was never seen: close
300
+ // it, or one lost `pointerup` disables dragging for good. A DIFFERENT pointer is
301
+ // the opposite case and upstream is explicit about it — `drag_begin_cb` denies the
302
+ // NEW sequence and keeps the one in flight (adw-swipe-tracker.c:617-620). Tearing
303
+ // the live gesture down instead left the strip rubber-banding back with no
304
+ // `onEnd` and no tracker decision at all.
305
+ if (pointerId !== null) {
306
+ if (event.pointerId !== pointerId) return;
307
+ release();
308
+ }
309
+ // Mouse only. Touch and touchpad are the browser's own gestures, and a pen
310
+ // behaves like touch on every platform that has one.
311
+ if (event.pointerType !== 'mouse' || event.button !== 0) return;
312
+ if (!init.enabled()) return;
313
+ if ((event.target as Element | null)?.closest(NATIVELY_DRAGGABLE) !== null) return;
314
+
315
+ pointerId = event.pointerId;
316
+ startX = event.clientX;
317
+ startY = event.clientY;
318
+ lastX = event.clientX;
319
+ lastY = event.clientY;
320
+ previousOffset = 0;
321
+ state = 'pending';
322
+ // No capture yet — see `claim`. The moves that DECIDE the claim come from the
323
+ // window listeners instead, which is why they are armed here and not there: a
324
+ // 16 px threshold near an edge is otherwise unreachable.
325
+ arm();
326
+ });
327
+
328
+ function onMove(event: PointerEvent): void {
329
+ if (pointerId !== event.pointerId) return;
330
+
331
+ lastX = event.clientX;
332
+ lastY = event.clientY;
333
+ const dx = event.clientX - startX;
334
+ const dy = event.clientY - startY;
335
+ const offset = progressOffset(dx, dy);
336
+ const delta = offset - previousOffset;
337
+ previousOffset = offset;
338
+ // TRUNCATED to whole milliseconds, because the C's clock is a `guint32` and its
339
+ // `first_time == last_time` guard is a real low-pass filter: two events inside
340
+ // one millisecond yield NO velocity there. Left as a float, that guard is dead
341
+ // and two coalesced moves 0.1 ms apart read as tens of pages per second.
342
+ const time = Math.trunc(event.timeStamp);
343
+ // UNCONDITIONAL, and ahead of every refusal below — `append_to_history` at :670.
344
+ tracker.record(delta, time);
345
+
346
+ // NO BUTTON HELD means the press this is tracking ended somewhere unseen. Before
347
+ // the claim there is no pointer capture and therefore no `lostpointercapture`, so
348
+ // this is the only net that window: measured, one lost `pointerup` left the strip
349
+ // FOLLOWING a cursor with nothing pressed, snapping off, until the next click
350
+ // anywhere. Checked for every state, since a claimed gesture can lose its up too.
351
+ if ((event.buttons & 1) === 0) {
352
+ release();
353
+ return;
354
+ }
355
+
356
+ if (state === 'pending') {
357
+ // Whichever axis moved MORE decides whose gesture this is
358
+ // (`is_offset_vertical`, :661). Tested here and ONLY here: the C tests it at
359
+ // STATE_NONE, on the first move at any distance (:673), and again inside the
360
+ // threshold block (:715) — never at STATE_SCROLLING. One test in the pending
361
+ // branch covers both, because `dx`/`dy` are cumulative from the press.
362
+ //
363
+ // Hoisting it above this branch, as a first draft did, re-tested it on every
364
+ // claimed move — and `dx`/`dy` being cumulative means a drag that comes BACK
365
+ // toward its start ("changed my mind, with a little vertical wander") flips
366
+ // the dominant axis and cancelled itself mid-gesture.
367
+ const wantsVertical = Math.abs(dy) > Math.abs(dx);
368
+ if (wantsVertical !== (init.orientation === 'vertical')) {
369
+ release();
370
+ return;
371
+ }
372
+ if (Math.hypot(dx, dy) < ADW_SWIPE_DRAG_THRESHOLD) return;
373
+ if (!claimAllowed(offset)) {
374
+ release();
375
+ return;
376
+ }
377
+ // `gesture_prepare` is the C's first-move step and sets `initial_progress`
378
+ // (:202-204). It runs here rather than at `pointerdown` because until a move
379
+ // arrives there is no direction, and until the axis passes there is no
380
+ // gesture to prepare.
381
+ tracker.prepare(init.progress());
382
+ claim();
383
+ // From the claim, not from the press: the threshold travel is what BOUGHT
384
+ // the gesture and must not also be applied as progress (`prev_offset = offset`
385
+ // at :744), or the content jumps 16 px the instant it follows the cursor.
386
+ previousOffset = offset;
387
+ return;
388
+ }
389
+
390
+ if (state !== 'scrolling') return;
391
+ // The standard signal that this move is consumed. Deliberately NOT credited with
392
+ // the two things it usually is: text selection is prevented by the `user-select`
393
+ // in `claim`, and a native image or link drag begins from `dragstart` BEFORE the
394
+ // claim, so this line has never run by then — measured, an image dragged inside a
395
+ // page is never refused here. Kept because consuming the move is what it is for,
396
+ // not because a specific default was observed to need it.
397
+ event.preventDefault();
398
+ init.onUpdate(tracker.advance(delta, init.pitch()));
399
+ }
400
+
401
+ function finish(event: PointerEvent, cancelled: boolean): void {
402
+ if (pointerId !== event.pointerId) return;
403
+ if (state !== 'scrolling') {
404
+ release();
405
+ return;
406
+ }
407
+ if (cancelled) tracker.cancel();
408
+ const { to, velocity } = tracker.end(Math.trunc(event.timeStamp));
409
+ release();
410
+ // A deliberate drag must not also activate whatever it ended on — the DOM's
411
+ // stand-in for GTK's claimed event sequence.
412
+ suppressNextClick = true;
413
+ init.onEnd(to, velocity);
414
+ }
415
+
416
+ function onFinish(event: PointerEvent): void {
417
+ finish(event, false);
418
+ }
419
+
420
+ function onCancel(event: PointerEvent): void {
421
+ finish(event, true);
422
+ }
423
+
424
+ function onLostCapture(event: PointerEvent): void {
425
+ // Settled, not cancelled: the pointer is gone, so whatever the user did they are
426
+ // done doing it, and dropping them back where they started would undo a drag
427
+ // they can see on screen.
428
+ finish(event, false);
429
+ }
430
+
431
+ // CAPTURE phase and permanent, rather than a one-shot armed at the end of a drag: a
432
+ // listener that is only ever removed by the click it is waiting for outlives a drag
433
+ // that produced none.
434
+ init.host.addEventListener(
435
+ 'click',
436
+ (event: Event) => {
437
+ if (!suppressNextClick) return;
438
+ suppressNextClick = false;
439
+ event.stopPropagation();
440
+ event.preventDefault();
441
+ },
442
+ { capture: true },
443
+ );
444
+
445
+ return {
446
+ get dragging(): boolean {
447
+ return state === 'scrolling';
448
+ },
449
+ };
450
+ }
@@ -0,0 +1,227 @@
1
+ // Slot adoption, driven in both directions — declared at parse time and appended after
2
+ // connect — and required to reach the SAME place.
3
+ //
4
+ // The invariant this file exists for is an EQUALITY, not a presence: "the child is
5
+ // somewhere in the subtree" was true of the bug too. Before `src/slotted-children.ts` a
6
+ // `slot="suffix"` control appended after connect stayed a direct child of the host,
7
+ // beside the internal sections rather than inside one — still in the subtree, still
8
+ // findable by `querySelector`, and laid out somewhere else entirely. So every assertion
9
+ // below compares the declared placement against the appended one, box path AND index.
10
+ //
11
+ // The second half is DERIVED, for the reason `connect-lifecycle.spec.ts` gives about its
12
+ // own driver: a per-widget slot spec is one more spec per widget to forget. It reads the
13
+ // bindings back off the registered elements (`slottedChildrenOf`), so an element that
14
+ // gains a slot enrols itself, and one that stops binding drops out of the driver visibly
15
+ // instead of silently.
16
+ import { describe, expect, it } from '@gjsify/unit';
17
+
18
+ import * as adwaitaWeb from './index.js';
19
+ import { bindSlottedChildren, slottedChildrenOf } from './slotted-children.js';
20
+
21
+ /** One microtask checkpoint — where a MutationObserver callback lands. */
22
+ const settle = () => Promise.resolve();
23
+
24
+ /** A host mounted in the document, since adoption is only interesting where a widget is. */
25
+ function mounted<T extends HTMLElement>(el: T): T {
26
+ document.body.appendChild(el);
27
+ return el;
28
+ }
29
+
30
+ /**
31
+ * WHERE inside `host` a node sits: the chain of boxes down to its parent, plus its index
32
+ * among that parent's child nodes.
33
+ *
34
+ * The index is half the point. Two children that both reached the right box in the wrong
35
+ * order share a path, and a driver that stopped at the path would call that agreement.
36
+ */
37
+ function placement(host: Element, node: Node): string {
38
+ if (node.parentNode === null) return 'detached';
39
+ if (!host.contains(node)) return 'outside';
40
+ const steps: string[] = [];
41
+ for (let el = node.parentElement; el !== null && el !== host; el = el.parentElement) {
42
+ const classes = [...el.classList].sort().join('.');
43
+ steps.unshift(classes === '' ? el.localName : `${el.localName}.${classes}`);
44
+ }
45
+ const index = Array.from(node.parentNode.childNodes).indexOf(node as ChildNode);
46
+ return `${steps.join(' > ')}[${index}]`;
47
+ }
48
+
49
+ /** A probe carrying a slot name, or none at all for the default slot. */
50
+ function probe(slot: string | undefined, id: string): HTMLElement {
51
+ const el = document.createElement('div');
52
+ if (slot !== undefined) el.setAttribute('slot', slot);
53
+ el.dataset.probe = id;
54
+ return el;
55
+ }
56
+
57
+ export const AdwSlottedChildrenTest = async () => {
58
+ await describe('bindSlottedChildren', async () => {
59
+ await it('routes the children it was installed over, in document order', async () => {
60
+ const host = mounted(document.createElement('div'));
61
+ const first = probe('side', 'a');
62
+ const second = probe(undefined, 'b');
63
+ const third = probe('side', 'c');
64
+ host.append(first, second, third);
65
+
66
+ const side = document.createElement('div');
67
+ side.className = 'side';
68
+ const body = document.createElement('div');
69
+ body.className = 'body';
70
+ bindSlottedChildren(host, [{ name: 'side', into: side }, { into: body }]).install(side, body);
71
+
72
+ const report = [first, second, third].map((el) => placement(host, el)).join(' | ');
73
+ host.remove();
74
+ expect(report).toBe('div.side[0] | div.body[0] | div.side[1]');
75
+ });
76
+
77
+ await it('routes a child APPENDED after install to the same box, at the next index', async () => {
78
+ const host = mounted(document.createElement('div'));
79
+ const declared = probe('side', 'a');
80
+ host.append(declared);
81
+
82
+ const side = document.createElement('div');
83
+ side.className = 'side';
84
+ bindSlottedChildren(host, [{ name: 'side', into: side }]).install(side);
85
+
86
+ const late = probe('side', 'b');
87
+ host.append(late);
88
+ await settle();
89
+
90
+ const report = `${placement(host, declared)} | ${placement(host, late)}`;
91
+ host.remove();
92
+ expect(report).toBe('div.side[0] | div.side[1]');
93
+ });
94
+
95
+ await it('leaves a child whose slot name nothing declares where the author put it', async () => {
96
+ const host = mounted(document.createElement('div'));
97
+ const side = document.createElement('div');
98
+ side.className = 'side';
99
+ // No default slot: with one, an unmatched NAME would silently become default
100
+ // content and render in the wrong box looking deliberate. The native assignment
101
+ // algorithm assigns it nowhere, and so does this.
102
+ bindSlottedChildren(host, [{ name: 'side', into: side }]).install(side);
103
+
104
+ const typo = probe('sied', 'a');
105
+ host.append(typo);
106
+ await settle();
107
+
108
+ const report = placement(host, typo);
109
+ host.remove();
110
+ expect(report).toBe('[1]');
111
+ });
112
+
113
+ await it('re-adopts a child that was appended, removed and appended again', async () => {
114
+ const host = mounted(document.createElement('div'));
115
+ const side = document.createElement('div');
116
+ side.className = 'side';
117
+ bindSlottedChildren(host, [{ name: 'side', into: side }]).install(side);
118
+
119
+ const child = probe('side', 'a');
120
+ host.append(child);
121
+ await settle();
122
+ const first = `${placement(host, child)} n=${side.childElementCount}`;
123
+
124
+ child.remove();
125
+ await settle();
126
+ const gone = `${placement(host, child)} n=${side.childElementCount}`;
127
+
128
+ host.append(child);
129
+ await settle();
130
+ // n=1, not n=2: the node is MOVED back rather than copied, and the removal left
131
+ // no placeholder behind for it to be adopted alongside.
132
+ const again = `${placement(host, child)} n=${side.childElementCount}`;
133
+
134
+ host.remove();
135
+ expect(`${first} / ${gone} / ${again}`).toBe('div.side[0] n=1 / detached n=0 / div.side[0] n=1');
136
+ });
137
+
138
+ await it('hands a consumed child to the call and leaves nothing of it in the host', async () => {
139
+ const host = mounted(document.createElement('div'));
140
+ const taken: string[] = [];
141
+ const body = document.createElement('div');
142
+ body.className = 'body';
143
+ bindSlottedChildren(host, [
144
+ {
145
+ claims: (node) => node instanceof Element && node.localName === 'meta',
146
+ consume: (node) => taken.push((node as Element).getAttribute('name') ?? ''),
147
+ },
148
+ { into: body },
149
+ ]).install(body);
150
+
151
+ const data = document.createElement('meta');
152
+ data.setAttribute('name', 'late');
153
+ host.append(data);
154
+ await settle();
155
+
156
+ const report = `${taken.join(',')} | ${placement(host, data)} | body=${body.childElementCount}`;
157
+ host.remove();
158
+ expect(report).toBe('late | detached | body=0');
159
+ });
160
+ });
161
+
162
+ // Every registered element that binds a slot, both ways round. This is the driver the
163
+ // fix exists for: an element is asked for its own slots and then required to put a
164
+ // declared child and an appended one in the same place.
165
+ await describe('every slotted element adopts a late child where it adopts a declared one', async () => {
166
+ const tags: string[] = [];
167
+ for (const exported of Object.values(adwaitaWeb)) {
168
+ if (typeof exported !== 'function' || !(exported.prototype instanceof HTMLElement)) continue;
169
+ const tag = customElements.getName(exported as CustomElementConstructor);
170
+ if (tag !== null) tags.push(tag);
171
+ }
172
+ tags.sort();
173
+
174
+ /** The distinct slot names one element declares — `undefined` is its default slot. */
175
+ function slotNames(tag: string): Array<string | undefined> {
176
+ const el = mounted(document.createElement(tag));
177
+ const binding = slottedChildrenOf(el);
178
+ el.remove();
179
+ if (binding === undefined) return [];
180
+ const names = new Set<string | undefined>();
181
+ for (const slot of binding.slots) names.add(slot.name);
182
+ return [...names];
183
+ }
184
+
185
+ const slotted = tags.map((tag) => [tag, slotNames(tag)] as const).filter(([, names]) => names.length > 0);
186
+
187
+ await it('finds the elements that declare slots', () => {
188
+ // A driver that scans nothing passes vacuously and reports a green it never
189
+ // earned. A FLOOR, not an exact count: the point of deriving the list is that it
190
+ // grows without this file being edited.
191
+ expect(`slotted elements: ${slotted.length > 8}`).toBe('slotted elements: true');
192
+ });
193
+
194
+ for (const [tag, names] of slotted) {
195
+ for (const name of names) {
196
+ const label = name ?? '(default)';
197
+ await it(`<${tag}> slot="${label}"`, async () => {
198
+ // TWO children per slot, so the comparison pins ORDER and not only the box.
199
+ const declaredHost = document.createElement(tag);
200
+ const declaredA = probe(name, 'a');
201
+ const declaredB = probe(name, 'b');
202
+ declaredHost.append(declaredA, declaredB);
203
+ mounted(declaredHost);
204
+
205
+ const lateHost = mounted(document.createElement(tag));
206
+ const lateA = probe(name, 'a');
207
+ const lateB = probe(name, 'b');
208
+ lateHost.append(lateA);
209
+ lateHost.append(lateB);
210
+ await settle();
211
+
212
+ const declared = [declaredA, declaredB].map((el) => placement(declaredHost, el)).join(' | ');
213
+ const late = [lateA, lateB].map((el) => placement(lateHost, el)).join(' | ');
214
+ // Measure, THEN tear down, THEN assert — the reason
215
+ // `connect-lifecycle.spec.ts` gives: a host left on the page pushes every
216
+ // later one down and turns one failure into a cascade naming the wrong
217
+ // widget.
218
+ declaredHost.remove();
219
+ lateHost.remove();
220
+ expect(`<${tag}> slot=${label} appended: ${late}`).toBe(
221
+ `<${tag}> slot=${label} appended: ${declared}`,
222
+ );
223
+ });
224
+ }
225
+ }
226
+ });
227
+ };