@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.
- package/README.md +27 -0
- package/lib/types/adw-alert-dialog.spec.d.ts +1 -0
- package/lib/types/elements/adw-alert-dialog.d.ts +12 -0
- package/lib/types/elements/adw-carousel.d.ts +8 -0
- package/lib/types/elements/adw-entry-row.d.ts +5 -7
- package/lib/types/elements/adw-header-bar.d.ts +7 -0
- package/lib/types/elements/adw-navigation-split-view.d.ts +9 -0
- package/lib/types/elements/adw-preferences-group.d.ts +18 -9
- package/lib/types/elements/swipe-drag.d.ts +63 -0
- package/lib/types/slotted-children.d.ts +65 -0
- package/lib/types/slotted-children.spec.d.ts +1 -0
- package/package.json +6 -6
- package/src/adw-alert-dialog.spec.ts +146 -0
- package/src/adw-header-bar.spec.ts +4 -2
- package/src/elements/adw-action-row.ts +8 -6
- package/src/elements/adw-alert-dialog.ts +99 -23
- package/src/elements/adw-carousel.ts +86 -5
- package/src/elements/adw-dialog.ts +5 -6
- package/src/elements/adw-entry-row.ts +33 -34
- package/src/elements/adw-expander-row.ts +19 -15
- package/src/elements/adw-header-bar.ts +39 -20
- package/src/elements/adw-navigation-split-view.ts +31 -8
- package/src/elements/adw-overlay-split-view.ts +11 -12
- package/src/elements/adw-preferences-dialog.ts +7 -8
- package/src/elements/adw-preferences-group.ts +37 -36
- package/src/elements/adw-status-page.ts +10 -4
- package/src/elements/adw-toast-overlay.ts +6 -4
- package/src/elements/adw-toolbar-view.ts +16 -11
- package/src/elements/swipe-drag.ts +450 -0
- package/src/slotted-children.spec.ts +227 -0
- package/src/slotted-children.ts +212 -0
- 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
|
+
};
|