@gjsify/adwaita-web 0.32.0 → 0.33.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 (38) hide show
  1. package/dist/adwaita-web.css +72 -50
  2. package/dist/adwaita-web.css.map +1 -1
  3. package/lib/types/elements/adw-combo-row.d.ts +10 -0
  4. package/lib/types/elements/adw-inline-view-switcher.d.ts +37 -1
  5. package/lib/types/elements/adw-navigation-split-view.d.ts +42 -0
  6. package/lib/types/elements/adw-overlay-split-view.d.ts +56 -0
  7. package/lib/types/elements/adw-spinner.d.ts +23 -0
  8. package/lib/types/elements/adw-view-stack.d.ts +12 -0
  9. package/lib/types/elements/adw-view-switcher-bar.d.ts +7 -5
  10. package/lib/types/elements/adw-view-switcher.d.ts +55 -3
  11. package/lib/types/elements/adw-wrap-box.d.ts +58 -3
  12. package/lib/types/styles.generated.d.ts +1 -1
  13. package/package.json +6 -6
  14. package/scss/_combo_row.scss +19 -0
  15. package/scss/_navigation_split_view.scss +47 -8
  16. package/scss/_overlay_split_view.scss +117 -32
  17. package/scss/_spinner.scss +35 -21
  18. package/scss/_tab_view.scss +12 -13
  19. package/scss/_wrap_box.scss +9 -0
  20. package/src/adw-avatar.spec.ts +37 -0
  21. package/src/adw-button-content.spec.ts +24 -0
  22. package/src/adw-row-state.spec.ts +49 -0
  23. package/src/adw-wrap-box.spec.ts +148 -1
  24. package/src/chrome.spec.ts +111 -12
  25. package/src/elements/adw-action-row.ts +11 -1
  26. package/src/elements/adw-combo-row.ts +17 -0
  27. package/src/elements/adw-inline-view-switcher.ts +91 -8
  28. package/src/elements/adw-navigation-split-view.ts +179 -14
  29. package/src/elements/adw-overlay-split-view.ts +378 -20
  30. package/src/elements/adw-spinner.ts +165 -21
  31. package/src/elements/adw-tab-view.ts +20 -2
  32. package/src/elements/adw-view-stack.ts +21 -0
  33. package/src/elements/adw-view-switcher-bar.ts +13 -5
  34. package/src/elements/adw-view-switcher.ts +103 -11
  35. package/src/elements/adw-wrap-box.ts +290 -135
  36. package/src/split-views.spec.ts +586 -2
  37. package/src/styles.generated.ts +1 -1
  38. package/src/view-switcher.spec.ts +219 -0
@@ -12,12 +12,64 @@ import {
12
12
  DEFAULT_MIN_SIDEBAR_WIDTH,
13
13
  DEFAULT_SIDEBAR_WIDTH_FRACTION,
14
14
  OverlaySplitViewState,
15
+ isSidebarAtVisualStart,
16
+ layoutOverlaySplitView,
17
+ resolveOverlaySidebarWidth,
15
18
  resolveSidebarBounds,
19
+ resolveSwipeArea,
20
+ resolveSwipeSnapPoints,
21
+ resolveSwipeStart,
22
+ swipeCancelProgress,
16
23
  type AdwPackType,
24
+ type AdwTextDirection,
25
+ type SplitViewAnimator,
17
26
  } from '@gjsify/adwaita-core';
18
27
 
19
28
  import { bindBreakpointSetter } from '../breakpoints.js';
20
29
 
30
+ /**
31
+ * The reveal spring, as a CSS-timed animator.
32
+ *
33
+ * libadwaita animates with a spring `(1, 0.5, 500)`
34
+ * (adw-overlay-split-view.c:1163-1165). A browser cannot be handed spring
35
+ * parameters, so this drives the progress from `requestAnimationFrame` on a
36
+ * critically-damped approximation and lets the element write the resulting
37
+ * geometry — which is the same seam the NativeScript port fills with
38
+ * `View.animate()`, and the same one a test fills with an instant fake.
39
+ */
40
+ const CSS_SPLIT_VIEW_ANIMATOR: SplitViewAnimator = {
41
+ animate(request) {
42
+ let handle = 0;
43
+ let cancelled = false;
44
+ // 250ms is the visible settle time of libadwaita's spring at these
45
+ // parameters; the CURVE is an approximation and says so, the ENDPOINTS
46
+ // are exact because `onDone` writes `to`.
47
+ const duration = 250;
48
+ const start = performance.now();
49
+ const step = (now: number) => {
50
+ if (cancelled) return;
51
+ const t = Math.min((now - start) / duration, 1);
52
+ // ease-out: fast first, settling — the visible signature of a spring
53
+ // with no overshoot, which is what `clamp` asks for anyway.
54
+ const eased = 1 - (1 - t) * (1 - t);
55
+ request.onValue(request.from + (request.to - request.from) * eased);
56
+ if (t < 1) {
57
+ handle = requestAnimationFrame(step);
58
+ return;
59
+ }
60
+ request.onValue(request.to);
61
+ request.onDone();
62
+ };
63
+ handle = requestAnimationFrame(step);
64
+ return {
65
+ cancel() {
66
+ cancelled = true;
67
+ if (handle) cancelAnimationFrame(handle);
68
+ },
69
+ };
70
+ },
71
+ };
72
+
21
73
  /**
22
74
  * Read a boolean attribute the way a GTK property with a TRUE default has to be
23
75
  * read: absence means the default, not false.
@@ -48,6 +100,13 @@ export class AdwOverlaySplitView extends HTMLElement {
48
100
  private _state = new OverlaySplitViewState();
49
101
  /** Re-entrancy guard for {@link _reflectShowSidebar}. */
50
102
  private _reflecting = false;
103
+ /** The view's own width in CSS px — the size the sidebar fraction is OF. */
104
+ private _measuredWidth = 0;
105
+ private _resizeObserver: ResizeObserver | undefined;
106
+ /** The pointer currently panning the sidebar, if any. */
107
+ private _swipePointer: number | null = null;
108
+ /** Where the pan started, and the progress it started from. */
109
+ private _swipeOrigin = { x: 0, progress: 0 };
51
110
 
52
111
  /**
53
112
  * Every attribute {@link _readAttribute} handles must be listed here — an
@@ -65,6 +124,8 @@ export class AdwOverlaySplitView extends HTMLElement {
65
124
  'min-sidebar-width',
66
125
  'max-sidebar-width',
67
126
  'sidebar-width-fraction',
127
+ 'enable-show-gesture',
128
+ 'enable-hide-gesture',
68
129
  'breakpoint',
69
130
  ];
70
131
  }
@@ -107,6 +168,17 @@ export class AdwOverlaySplitView extends HTMLElement {
107
168
  }
108
169
 
109
170
  connectedCallback() {
171
+ this._buildOnce();
172
+ // EVERY connect, not only the first — see `_bindToDocument`.
173
+ this._bindToDocument();
174
+ this._reflectShowSidebar();
175
+ this._syncClasses();
176
+ this._syncSidebarWidth();
177
+ this._syncBreakpoint();
178
+ }
179
+
180
+ /** The DOM surgery and the state, which happen once per element lifetime. */
181
+ private _buildOnce() {
110
182
  if (this._initialized) return;
111
183
  this._initialized = true;
112
184
 
@@ -150,18 +222,80 @@ export class AdwOverlaySplitView extends HTMLElement {
150
222
  showSidebar: readTriStateAttr(this, 'show-sidebar', true),
151
223
  pinSidebar: this.hasAttribute('pin-sidebar'),
152
224
  sidebarPosition: this.getAttribute('sidebar-position') === 'end' ? 'end' : ('start' as AdwPackType),
225
+ enableShowGesture: readTriStateAttr(this, 'enable-show-gesture', true),
226
+ enableHideGesture: readTriStateAttr(this, 'enable-hide-gesture', true),
227
+ animator: CSS_SPLIT_VIEW_ANIMATOR,
153
228
  });
154
- this._reflectShowSidebar();
155
- this._syncClasses();
156
- this._syncSidebarWidth();
157
- this._syncBreakpoint();
229
+ // Every progress step repaints — the reveal is a CONTINUUM now, not two
230
+ // end states, which is what the five OVERLAY_SWIPE_* tables describe.
231
+ this._state.subscribe(() => {
232
+ this._reflectShowSidebar();
233
+ this._syncClasses();
234
+ });
235
+
236
+ // `escape_shortcut_cb` (adw-overlay-split-view.c:705-716) — absent from
237
+ // BOTH ports, which made `OverlaySplitViewState.escape()` dead code.
238
+ // Bound on the element, so it only fires while focus is inside the view
239
+ // and still propagates when the state declines to consume it.
240
+ //
241
+ // Bound ONCE, here, and never removed: a listener on the element itself
242
+ // survives detaching and is collected with the element, so there is
243
+ // nothing to leak — while re-adding it per connect would stack a second
244
+ // handler on every move. Only the two bindings that reach OUTSIDE the
245
+ // element (the observer below and the breakpoint's media query) need a
246
+ // teardown, and they are re-established on every connect.
247
+ this.addEventListener('keydown', this._onKeyDown);
248
+ this._bindSwipe();
249
+ }
250
+
251
+ /**
252
+ * Everything that must be re-established every time the element enters a
253
+ * document — NOT once per lifetime.
254
+ *
255
+ * `connectedCallback` returns early on the second connect (the DOM is
256
+ * already built), so anything torn down in `disconnectedCallback` used to be
257
+ * gone for good: moving the view between parents — which is what a slideshow
258
+ * or a client-side route change does — killed the ResizeObserver and left
259
+ * `_measuredWidth` frozen at whatever it last saw, or at 0 if the view had
260
+ * never been visible. That is the other half of the docs-site regression.
261
+ */
262
+ private _bindToDocument() {
263
+ // The view's own box is the size source, the same one the breakpoints
264
+ // use: the sidebar width is a FRACTION of it and has to be recomputed
265
+ // when it changes, which a one-shot read at connect could not do.
266
+ this._resizeObserver?.disconnect();
267
+ this._resizeObserver = new ResizeObserver((entries) => {
268
+ const entry = entries[entries.length - 1];
269
+ if (!entry) return;
270
+ const box = entry.borderBoxSize?.[0];
271
+ this._measuredWidth = box ? box.inlineSize : entry.contentRect.width;
272
+ this._syncSidebarWidth();
273
+ this._syncClasses();
274
+ });
275
+ this._resizeObserver.observe(this);
276
+ // Seed synchronously so the FIRST paint after a connect is already
277
+ // placed. The observer's initial callback is a frame away, and on a
278
+ // re-connect there may be no size change at all for it to report.
279
+ // Zero is the honest answer while the view is in a `display: none`
280
+ // subtree, and `_syncGeometry` knows what to do with it.
281
+ this._measuredWidth = this.offsetWidth;
158
282
  }
159
283
 
160
284
  disconnectedCallback() {
161
285
  this._disposeBreakpoint?.();
162
286
  this._disposeBreakpoint = undefined;
287
+ this._resizeObserver?.disconnect();
288
+ this._resizeObserver = undefined;
163
289
  }
164
290
 
291
+ /** `escape_shortcut_cb` — only a COLLAPSED view with a revealed sidebar consumes it. */
292
+ private readonly _onKeyDown = (event: KeyboardEvent) => {
293
+ if (event.key !== 'Escape' || event.defaultPrevented) return;
294
+ // The state decides: an uncollapsed view lets Escape through so an
295
+ // enclosing dialog still closes.
296
+ if (this._state.escape()) event.preventDefault();
297
+ };
298
+
165
299
  attributeChangedCallback(_name: string, _old: string | null, _val: string | null) {
166
300
  if (!this._initialized) return;
167
301
  if (_name === 'breakpoint') {
@@ -169,10 +303,20 @@ export class AdwOverlaySplitView extends HTMLElement {
169
303
  return;
170
304
  }
171
305
  this._readAttribute(_name);
172
- this._syncClasses();
173
- if (_name === 'min-sidebar-width' || _name === 'max-sidebar-width' || _name === 'sidebar-width-fraction') {
306
+ // `collapsed` is in the list because `get_sidebar_width` IGNORES the
307
+ // fraction while collapsed and clamps the VIEW width instead
308
+ // (adw-overlay-split-view.c:462-463) — a collapsed sidebar is a
309
+ // different number, not the same one drawn differently. Leaving it out
310
+ // is the frame-late bug the NativeScript port paid for.
311
+ if (
312
+ _name === 'min-sidebar-width' ||
313
+ _name === 'max-sidebar-width' ||
314
+ _name === 'sidebar-width-fraction' ||
315
+ _name === 'collapsed'
316
+ ) {
174
317
  this._syncSidebarWidth();
175
318
  }
319
+ this._syncClasses();
176
320
  }
177
321
 
178
322
  /**
@@ -197,6 +341,14 @@ export class AdwOverlaySplitView extends HTMLElement {
197
341
  if (name === 'show-sidebar') {
198
342
  state.setShowSidebar(readTriStateAttr(this, 'show-sidebar', true));
199
343
  }
344
+ // Both default TRUE (adw-overlay-split-view.c:1178-1210), so they read
345
+ // through the tri-state helper for the same reason `show-sidebar` does.
346
+ if (name === 'enable-show-gesture') {
347
+ state.setEnableShowGesture(readTriStateAttr(this, 'enable-show-gesture', true));
348
+ }
349
+ if (name === 'enable-hide-gesture') {
350
+ state.setEnableHideGesture(readTriStateAttr(this, 'enable-hide-gesture', true));
351
+ }
200
352
  // Last: it can change `show-sidebar`, so it must not be overwritten after.
201
353
  if (name === 'collapsed') state.setCollapsed(this.hasAttribute('collapsed'));
202
354
  this._reflectShowSidebar();
@@ -248,12 +400,47 @@ export class AdwOverlaySplitView extends HTMLElement {
248
400
  );
249
401
  }
250
402
 
403
+ /**
404
+ * The reading direction `start` / `end` are resolved against.
405
+ *
406
+ * `get_start_or_end` (adw-overlay-split-view.c:227-234) returns
407
+ * `GTK_PACK_END` under RTL, so a `start` sidebar is drawn on the RIGHT.
408
+ * Neither renderer mirrored any of it; the browser has the direction for
409
+ * free and only had to read it.
410
+ */
411
+ private get _direction(): AdwTextDirection {
412
+ return getComputedStyle(this).direction === 'rtl' ? 'rtl' : 'ltr';
413
+ }
414
+
415
+ /** The sizing properties as the core takes them. */
416
+ private get _widthSpec() {
417
+ return {
418
+ minSidebarWidth: this.minSidebarWidth,
419
+ maxSidebarWidth: this.maxSidebarWidth,
420
+ sidebarWidthFraction: this.sidebarWidthFraction,
421
+ };
422
+ }
423
+
424
+ /** The sidebar's allocated width in px — `get_sidebar_width` (:441-466). */
425
+ private get _sidebarWidth(): number {
426
+ return resolveOverlaySidebarWidth({
427
+ ...this._widthSpec,
428
+ totalWidth: this._measuredWidth,
429
+ collapsed: this._state.collapsed,
430
+ });
431
+ }
432
+
251
433
  private _syncClasses() {
252
434
  const state = this._state;
253
435
  this.classList.toggle('collapsed', state.collapsed);
254
436
  this.classList.toggle('show-sidebar', state.showSidebar);
437
+ const atStart = isSidebarAtVisualStart(state.sidebarPosition, this._direction);
255
438
  this.classList.toggle('sidebar-start', state.sidebarPosition === 'start');
256
439
  this.classList.toggle('sidebar-end', state.sidebarPosition === 'end');
440
+ // The VISUAL side, which under RTL is the opposite of the logical one.
441
+ // The stylesheet needs it for the divider edge, which `border-inline-*`
442
+ // alone cannot get right: the divider follows where the pane is DRAWN.
443
+ this.classList.toggle('sidebar-at-visual-start', atStart);
257
444
 
258
445
  // Derived, not re-decided here: the shield only exists while collapsed
259
446
  // AND revealed, and each pane is reachable by keyboard only when it is
@@ -262,16 +449,90 @@ export class AdwOverlaySplitView extends HTMLElement {
262
449
  this._sidebarEl?.setAttribute('aria-hidden', String(!state.sidebarFocusable));
263
450
  this._contentEl?.setAttribute('aria-hidden', String(!state.contentFocusable));
264
451
 
265
- // In docked mode, use negative margin to collapse sidebar space
266
- // while keeping the sidebar's intrinsic width (slide animation).
267
- if (this._sidebarEl && !this.collapsed) {
268
- if (!this.showSidebar) {
269
- const w = this._sidebarEl.offsetWidth;
270
- this._sidebarEl.style.marginRight = `-${w}px`;
271
- } else {
272
- this._sidebarEl.style.marginRight = '';
452
+ this._syncGeometry();
453
+ }
454
+
455
+ /**
456
+ * Place the sidebar for the current progress — `allocate_uncollapsed`
457
+ * (:572-610) and `allocate_collapsed` (:653-703), through the core.
458
+ *
459
+ * The slide used to be `marginRight: -offsetWidth` REGARDLESS of which edge
460
+ * the sidebar is on, so an `end` sidebar slid the wrong way; and it was only
461
+ * two states, so a swipe had nothing to drive. `layoutOverlaySplitView`
462
+ * returns the pane rect for any progress including the overshoot, which is
463
+ * what makes a continuous gesture expressible at all.
464
+ */
465
+ private _syncGeometry() {
466
+ const sidebar = this._sidebarEl;
467
+ if (!sidebar) return;
468
+ // NOTHING MEASURED YET — HAND THE PLACEMENT BACK TO THE STYLESHEET.
469
+ //
470
+ // This used to `return` and leave the inline styles alone, which is the
471
+ // one thing it must not do. The stylesheet no longer carries a
472
+ // `transform: translateX(±100%)` for the hidden end state (that is the
473
+ // point of driving the offset per frame), so an absolutely-positioned
474
+ // pane with no `left` resolves to `left: 0` at full opacity: a HIDDEN
475
+ // sidebar painted over the content at the wrong edge. Shipped that way
476
+ // in v0.32.0's `feat(adwaita)` commit and visible on the docs site,
477
+ // whose slideshow builds each slide hidden and moves it in — so the
478
+ // first measurement never arrived and the fallback was all there was.
479
+ //
480
+ // Clearing rather than skipping matters just as much: a STALE inline
481
+ // `left` from an earlier measurement outranks the resting rule (an
482
+ // absolutely-positioned box with `left` + `width` ignores `right`), so
483
+ // leaving the old value behind would defeat the stylesheet exactly when
484
+ // it is needed. `_resting.scss` carries the four end states.
485
+ if (this._measuredWidth <= 0) {
486
+ for (const prop of [
487
+ 'left',
488
+ 'marginLeft',
489
+ 'marginRight',
490
+ 'width',
491
+ 'minWidth',
492
+ 'maxWidth',
493
+ 'opacity',
494
+ 'pointerEvents',
495
+ ] as const) {
496
+ sidebar.style[prop] = '';
273
497
  }
498
+ if (this._backdropEl) this._backdropEl.style.opacity = '';
499
+ return;
274
500
  }
501
+ const state = this._state;
502
+ const measured = this._sidebarWidth;
503
+ const layout = layoutOverlaySplitView({
504
+ totalWidth: this._measuredWidth,
505
+ sidebarWidth: measured,
506
+ showProgress: state.showProgress,
507
+ collapsed: state.collapsed,
508
+ sidebarPosition: state.sidebarPosition,
509
+ direction: this._direction,
510
+ });
511
+ const atStart = isSidebarAtVisualStart(state.sidebarPosition, this._direction);
512
+
513
+ sidebar.style.width = `${layout.sidebar.width}px`;
514
+ if (state.collapsed) {
515
+ // Floating above the content: absolutely placed at the rect the core
516
+ // returned, so an overshoot widens the pane instead of detaching it.
517
+ sidebar.style.marginLeft = '';
518
+ sidebar.style.marginRight = '';
519
+ sidebar.style.insetInlineStart = '';
520
+ sidebar.style.left = `${layout.sidebar.x}px`;
521
+ } else {
522
+ // Docked: the pane keeps its place in the flex row and the hidden
523
+ // part is taken off the edge it is ON — the left one at visual
524
+ // start, the right one at visual end.
525
+ sidebar.style.left = '';
526
+ const hidden = measured - Math.trunc(measured * Math.min(state.showProgress, 1));
527
+ sidebar.style.marginLeft = atStart ? `${-hidden}px` : '';
528
+ sidebar.style.marginRight = atStart ? '' : `${-hidden}px`;
529
+ }
530
+ sidebar.style.opacity = state.showProgress <= 0 ? '0' : '1';
531
+ // `sidebarPainted` is the snapshot gate (:757): below zero progress there
532
+ // is nothing on screen, and a pane that is not painted must not take
533
+ // pointer events either.
534
+ sidebar.style.pointerEvents = state.sidebarPainted ? '' : 'none';
535
+ if (this._backdropEl) this._backdropEl.style.opacity = String(1 - layout.shadowProgress);
275
536
  }
276
537
 
277
538
  private _syncSidebarWidth() {
@@ -280,14 +541,111 @@ export class AdwOverlaySplitView extends HTMLElement {
280
541
  // libadwaita never lets `max` fall below `min`, and CSS resolves that
281
542
  // conflict the other way round (min-width wins over max-width), so an
282
543
  // inverted pair would render differently here than in GTK.
283
- const bounds = resolveSidebarBounds(
284
- { minSidebarWidth: this.minSidebarWidth, maxSidebarWidth: this.maxSidebarWidth },
285
- 0,
286
- { ceil: true },
287
- );
544
+ const bounds = resolveSidebarBounds(this._widthSpec, 0, { ceil: true });
288
545
  this._sidebarEl.style.minWidth = `${bounds.min}px`;
289
546
  this._sidebarEl.style.maxWidth = `${bounds.max}px`;
290
- this._sidebarEl.style.width = `${this.sidebarWidthFraction * 100}%`;
547
+ // NOT a CSS percentage. `(int)` truncates toward zero, and a percentage
548
+ // is fractional — core's own header forbids it in as many words, and
549
+ // this element carried one for its whole life.
550
+ this._syncGeometry();
551
+ }
552
+
553
+ // --- swipe (Adw.Swipeable) ---
554
+
555
+ /**
556
+ * The pan gesture the five `OVERLAY_SWIPE_*` tables describe.
557
+ *
558
+ * There was no gesture code in either renderer — grep for `pan` in both
559
+ * split views returned zero — so `showProgress` was only ever 0 or 1 and the
560
+ * tables shipped unexercised. Pointer events give the browser the whole
561
+ * gesture in three handlers; every DECISION in them is core's.
562
+ */
563
+ private _bindSwipe() {
564
+ this.addEventListener('pointerdown', (event) => {
565
+ if (this._swipePointer !== null || event.button !== 0) return;
566
+ const state = this._state;
567
+ const rect = this.getBoundingClientRect();
568
+ const area = resolveSwipeArea({
569
+ isDrag: true,
570
+ sidebarWidth: this._sidebarWidth,
571
+ showProgress: state.showProgress,
572
+ totalWidth: rect.width,
573
+ totalHeight: rect.height,
574
+ sidebarPosition: state.sidebarPosition,
575
+ direction: this._direction,
576
+ });
577
+ const x = event.clientX - rect.left;
578
+ const y = event.clientY - rect.top;
579
+ if (x < area.x || x > area.x + area.width || y < area.y || y > area.y + area.height) return;
580
+ this._swipePointer = event.pointerId;
581
+ this._swipeOrigin = { x: event.clientX, progress: state.showProgress };
582
+ });
583
+
584
+ this.addEventListener('pointermove', (event) => {
585
+ if (event.pointerId !== this._swipePointer) return;
586
+ const state = this._state;
587
+ const width = this._sidebarWidth || 1;
588
+ const atStart = isSidebarAtVisualStart(state.sidebarPosition, this._direction);
589
+ // Dragging AWAY from the sidebar's edge opens it, so the sign of the
590
+ // travel depends on which edge that is — the same `atStart` predicate
591
+ // the layout uses.
592
+ const travel = ((event.clientX - this._swipeOrigin.x) / width) * (atStart ? 1 : -1);
593
+ if (!state.swipeActive) {
594
+ if (
595
+ !resolveSwipeStart({
596
+ showProgress: state.showProgress,
597
+ collapsed: state.collapsed,
598
+ direction: travel >= 0 ? 'forward' : 'back',
599
+ enableShowGesture: state.enableShowGesture,
600
+ enableHideGesture: state.enableHideGesture,
601
+ })
602
+ ) {
603
+ this._swipePointer = null;
604
+ return;
605
+ }
606
+ state.beginSwipe();
607
+ // Capture keeps the move/up events coming once the pointer
608
+ // leaves the element — an optimisation, not a precondition. It
609
+ // THROWS for a pointer id the browser has no active pointer for,
610
+ // which Firefox does strictly and Chromium tolerates: a synthetic
611
+ // event, or one whose pointer was already released, aborted the
612
+ // whole gesture before the progress was ever written. The swipe
613
+ // still works without it, so the failure is swallowed on purpose.
614
+ try {
615
+ this.setPointerCapture(event.pointerId);
616
+ } catch {
617
+ /* no active pointer for this id — the gesture does not need capture */
618
+ }
619
+ }
620
+ state.setShowProgress(this._swipeOrigin.progress + travel);
621
+ });
622
+
623
+ const release = (event: PointerEvent, cancelled: boolean) => {
624
+ if (event.pointerId !== this._swipePointer) return;
625
+ this._swipePointer = null;
626
+ const state = this._state;
627
+ if (!state.swipeActive) return;
628
+ // Same asymmetry on the way out: `hasPointerCapture` is false when the
629
+ // capture above was refused, so the release is already conditional.
630
+ if (this.hasPointerCapture(event.pointerId)) this.releasePointerCapture(event.pointerId);
631
+ // A cancelled gesture snaps back to where it came from
632
+ // (`get_cancel_progress`, :1253-1258); a released one settles on the
633
+ // NEAREST allowed snap point.
634
+ const points = resolveSwipeSnapPoints({
635
+ showProgress: state.showProgress,
636
+ enableShowGesture: state.enableShowGesture,
637
+ enableHideGesture: state.enableHideGesture,
638
+ swipeActive: true,
639
+ });
640
+ const target = cancelled
641
+ ? swipeCancelProgress(state.showProgress)
642
+ : points.reduce((best, point) =>
643
+ Math.abs(point - state.showProgress) < Math.abs(best - state.showProgress) ? point : best,
644
+ );
645
+ state.endSwipe(target);
646
+ };
647
+ this.addEventListener('pointerup', (event) => release(event, false));
648
+ this.addEventListener('pointercancel', (event) => release(event, true));
291
649
  }
292
650
  }
293
651