@gjsify/adwaita-web 0.39.0 → 0.41.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 (46) hide show
  1. package/dist/adwaita-web.css +112 -40
  2. package/dist/adwaita-web.css.map +1 -1
  3. package/lib/types/adw-button.spec.d.ts +1 -0
  4. package/lib/types/adw-entry.spec.d.ts +1 -0
  5. package/lib/types/adw-menu-button.spec.d.ts +1 -0
  6. package/lib/types/elements/adw-carousel.d.ts +48 -0
  7. package/lib/types/elements/adw-entry.d.ts +6 -0
  8. package/lib/types/elements/adw-header-bar.d.ts +5 -4
  9. package/lib/types/elements/adw-menu-button.d.ts +18 -7
  10. package/lib/types/elements/adw-tab-view.d.ts +23 -0
  11. package/lib/types/elements/adw-toolbar-view.d.ts +8 -0
  12. package/lib/types/index.d.ts +2 -0
  13. package/lib/types/scroll-shading.d.ts +56 -0
  14. package/lib/types/styles.generated.d.ts +1 -1
  15. package/package.json +6 -6
  16. package/scss/_carousel.scss +15 -1
  17. package/scss/_headerbar.scss +35 -6
  18. package/scss/_icons.generated.scss +18 -0
  19. package/scss/_scrolling.scss +107 -0
  20. package/scss/_style_classes.scss +21 -8
  21. package/scss/_theme.scss +1 -0
  22. package/scss/_toolbar_view.scss +52 -22
  23. package/scss/_variables.scss +7 -0
  24. package/scss/_view_switcher.scss +80 -20
  25. package/scss/adwaita-skin.scss +1 -0
  26. package/src/adw-button.spec.ts +59 -0
  27. package/src/adw-carousel.spec.ts +141 -1
  28. package/src/adw-entry.spec.ts +73 -0
  29. package/src/adw-header-bar.spec.ts +127 -0
  30. package/src/adw-menu-button.spec.ts +52 -0
  31. package/src/adw-tab-view.spec.ts +151 -0
  32. package/src/bottom-sheet.spec.ts +87 -0
  33. package/src/chrome.spec.ts +233 -0
  34. package/src/elements/adw-button.ts +8 -10
  35. package/src/elements/adw-carousel.ts +121 -4
  36. package/src/elements/adw-entry.ts +53 -8
  37. package/src/elements/adw-header-bar.ts +32 -9
  38. package/src/elements/adw-menu-button.ts +20 -19
  39. package/src/elements/adw-status-page.ts +7 -2
  40. package/src/elements/adw-tab-view.ts +63 -3
  41. package/src/elements/adw-toolbar-view.ts +22 -0
  42. package/src/index.ts +6 -0
  43. package/src/scroll-shading.ts +255 -0
  44. package/src/styles.generated.ts +1 -1
  45. package/src/test.browser.mts +6 -0
  46. package/src/view-switcher.spec.ts +149 -0
@@ -1,22 +1,37 @@
1
1
  // <adw-header-bar> — Adwaita header bar with centered title and start/end button slots.
2
+ //
3
+ // The derived centre is an `<adw-window-title>`, which is what `Adw.HeaderBar`
4
+ // itself puts there (`adw-header-bar.c`: the title widget it creates when none is
5
+ // given IS an `AdwWindowTitle`). It used to be a bare span with
6
+ // `textContent = title ?? ''`, and that span carried none of the three rules the
7
+ // window title already held in `@gjsify/adwaita-core`: an EMPTY title still
8
+ // reserved a blank line, re-setting the same value repainted, and there was no
9
+ // subtitle at all. So a header bar could not show what its own NativeScript twin
10
+ // could, and the fix is delegation rather than a fourth copy of the derivation.
11
+ //
12
+ // Reference: refs/libadwaita/src/adw-header-bar.c
2
13
  // Adapted from Adwaita Web UI Framework (https://github.com/mclellac/adwaita-web).
3
14
  // Copyright (c) 2025 csm. MIT License.
4
15
  // Modifications: Reimplemented as Web Component for @gjsify/adwaita-web.
5
16
 
17
+ // Registers <adw-window-title>: the derived centre is one, so importing the bar
18
+ // alone must still define it.
19
+ import './adw-window-title.js';
20
+
6
21
  export class AdwHeaderBar extends HTMLElement {
7
22
  private _initialized = false;
8
23
  private _startEl: HTMLDivElement | null = null;
9
24
  private _centerEl: HTMLDivElement | null = null;
10
25
  private _endEl: HTMLDivElement | null = null;
11
26
  /**
12
- * The generated title span, or `null` when a `slot="center"` title-widget
13
- * took the centre. `Adw.HeaderBar` has the same either/or: setting
14
- * `title-widget` replaces the derived `AdwWindowTitle` outright.
27
+ * The derived `<adw-window-title>`, or `null` when a `slot="center"`
28
+ * title-widget took the centre. `Adw.HeaderBar` has the same either/or:
29
+ * setting `title-widget` replaces the derived `AdwWindowTitle` outright.
15
30
  */
16
- private _titleEl: HTMLSpanElement | null = null;
31
+ private _titleEl: HTMLElement | null = null;
17
32
 
18
33
  static get observedAttributes() {
19
- return ['title'];
34
+ return ['title', 'subtitle'];
20
35
  }
21
36
 
22
37
  /** The start (left) section container — append buttons/widgets here. */
@@ -56,7 +71,7 @@ export class AdwHeaderBar extends HTMLElement {
56
71
  if (centerChildren.length > 0) {
57
72
  for (const child of centerChildren) this._centerEl.appendChild(child);
58
73
  } else {
59
- this._titleEl = document.createElement('span');
74
+ this._titleEl = document.createElement('adw-window-title');
60
75
  this._titleEl.className = 'adw-header-bar-title';
61
76
  this._centerEl.appendChild(this._titleEl);
62
77
  }
@@ -77,15 +92,23 @@ export class AdwHeaderBar extends HTMLElement {
77
92
  * kept whatever it was created with. `Adw.HeaderBar`'s derived title widget
78
93
  * is bound to the property and re-renders on every change.
79
94
  */
80
- attributeChangedCallback(name: string) {
81
- if (this._initialized && name === 'title') this._renderTitle();
95
+ attributeChangedCallback() {
96
+ if (this._initialized) this._renderTitle();
82
97
  }
83
98
 
84
99
  private _renderTitle() {
85
100
  // A `slot="center"` widget replaced the derived title, so there is
86
101
  // nothing for the attribute to write to — the same either/or as
87
102
  // `Adw.HeaderBar:title-widget`.
88
- if (this._titleEl) this._titleEl.textContent = this.getAttribute('title') ?? '';
103
+ if (!this._titleEl) return;
104
+ // Forwarded as ATTRIBUTES, so the window title's own change detection and
105
+ // empty-string collapse do the work. Removing rather than writing `''`
106
+ // keeps "unset" distinguishable from "set to empty" on the child.
107
+ for (const name of ['title', 'subtitle']) {
108
+ const value = this.getAttribute(name);
109
+ if (value === null) this._titleEl.removeAttribute(name);
110
+ else this._titleEl.setAttribute(name, value);
111
+ }
89
112
  }
90
113
  }
91
114
 
@@ -20,8 +20,8 @@
20
20
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
21
21
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
22
22
 
23
- import { isSplitButtonDirection, menuButtonPopupDirection } from '@gjsify/adwaita-core';
24
- import type { SplitButtonDirection } from '@gjsify/adwaita-core';
23
+ import { isSplitButtonDirection, menuButtonPopupDirection, parseMenuEntries } from '@gjsify/adwaita-core';
24
+ import type { AdwMenuEntry, SplitButtonDirection } from '@gjsify/adwaita-core';
25
25
 
26
26
  // SIDE-EFFECT import, deliberately separate from the type import below: it guarantees
27
27
  // `adw-popover` is defined before this module's `customElements.define` can upgrade a
@@ -34,12 +34,15 @@ import type { AdwPopover } from './adw-popover.js';
34
34
 
35
35
  import { createAdwIcon } from './adw-icon.js';
36
36
 
37
- /** A menu entry. `id` defaults to `label` on activation; `icon` is optional. */
38
- export interface AdwMenuItem {
39
- id?: string;
40
- label: string;
41
- icon?: string;
42
- }
37
+ /**
38
+ * A menu entry — `@gjsify/adwaita-core`'s {@link AdwMenuEntry}, re-exported under the
39
+ * name this element has always used.
40
+ *
41
+ * It used to be a SECOND declaration of the same shape, and the NativeScript menu
42
+ * button declared a THIRD that was missing `icon` entirely. One type, so a consumer
43
+ * writing menu entries writes the same object for either renderer.
44
+ */
45
+ export type AdwMenuItem = AdwMenuEntry;
43
46
 
44
47
  /**
45
48
  * Where the popover sits, per `GtkArrowType`. `menuButtonPopupDirection` folds `none`
@@ -152,18 +155,16 @@ export class AdwMenuButton extends HTMLElement {
152
155
  this._render();
153
156
  }
154
157
 
158
+ /**
159
+ * The `menu` attribute, through the core parser the split button already used.
160
+ *
161
+ * The copy this replaces was weaker in a way markup can reach: it kept `id` and
162
+ * `icon` WHATEVER their runtime type, so `{"label":"Open","id":7}` produced an
163
+ * entry whose `id` is a number in a field typed `string` — and `id` is what the
164
+ * activation event reports. The core parser keeps only string-typed extras.
165
+ */
155
166
  private _parseMenuAttr(): AdwMenuItem[] {
156
- const raw = this.getAttribute('menu');
157
- if (!raw) return [];
158
- try {
159
- const parsed: unknown = JSON.parse(raw);
160
- if (!Array.isArray(parsed)) return [];
161
- return parsed
162
- .filter((e): e is AdwMenuItem => typeof e === 'object' && e !== null && 'label' in e)
163
- .map((e) => ({ id: e.id, label: String(e.label), icon: e.icon }));
164
- } catch {
165
- return [];
166
- }
167
+ return parseMenuEntries(this.getAttribute('menu'));
167
168
  }
168
169
 
169
170
  private _onPopoverToggled(open: boolean): void {
@@ -7,6 +7,8 @@
7
7
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
8
8
  // icon node is <adw-icon>.
9
9
 
10
+ import { stringIsNotEmpty } from '@gjsify/adwaita-core';
11
+
10
12
  import { type AdwIcon, createAdwIcon } from './adw-icon.js';
11
13
 
12
14
  export class AdwStatusPage extends HTMLElement {
@@ -51,13 +53,16 @@ export class AdwStatusPage extends HTMLElement {
51
53
  this._iconEl.iconName = this.getAttribute('icon');
52
54
  this._iconEl.hidden = this._iconEl.resolvedIconName === '';
53
55
 
56
+ // `string_is_not_empty` from the core, not a local `.length === 0`: the same
57
+ // closure the NativeScript port binds its labels to, and the reason a title of
58
+ // `' '` stays VISIBLE — it reads the first byte, it never trims.
54
59
  const title = this.getAttribute('title') ?? '';
55
60
  this._titleEl.textContent = title;
56
- this._titleEl.hidden = title.length === 0;
61
+ this._titleEl.hidden = !stringIsNotEmpty(title);
57
62
 
58
63
  const description = this.getAttribute('description') ?? '';
59
64
  this._descEl.textContent = description;
60
- this._descEl.hidden = description.length === 0;
65
+ this._descEl.hidden = !stringIsNotEmpty(description);
61
66
 
62
67
  this._childEl.hidden = this._childEl.childElementCount === 0;
63
68
  }
@@ -57,6 +57,9 @@ export type AdwTabViewPageSpec = AdwTabPageSpec<HTMLElement>;
57
57
  /** The live `<adw-tab-page>` properties, each mapped onto a state setter. */
58
58
  const PAGE_ATTRIBUTES = ['title', 'tooltip', 'icon', 'indicator-icon', 'loading', 'needs-attention', 'pinned'];
59
59
 
60
+ /** `SPACING`: the slack `scroll_to_tab_full` allows before it scrolls (adw-tab-box.c:24). */
61
+ const TAB_SPACING = 5;
62
+
60
63
  /**
61
64
  * A single page. Declared as a child of <adw-tab-view>; the element itself becomes the
62
65
  * page panel, so its attributes keep driving the tab after connect — a renderer that
@@ -581,6 +584,20 @@ export class AdwTabView extends HTMLElement {
581
584
  this._refreshCloseVisibility(id);
582
585
  }
583
586
 
587
+ /**
588
+ * `get_tab_position` in the BAR's scroll space: how far the chip sits from the
589
+ * strip's scroll origin, the one coordinate both `scroll_to_tab_full` and
590
+ * `update_visible` compare against the adjustment.
591
+ *
592
+ * Never `offsetLeft`. Nothing in this widget is positioned, so a chip's offsetParent
593
+ * is whichever positioned ancestor the HOST page happens to have; a tab view that
594
+ * merely sits indented then measured every chip against the wrong origin and
595
+ * reported all of them clipped.
596
+ */
597
+ private _tabPosition(tab: HTMLElement): number {
598
+ return tab.getBoundingClientRect().left - this._barEl.getBoundingClientRect().left + this._barEl.scrollLeft;
599
+ }
600
+
584
601
  /**
585
602
  * `tabCloseVisible`, with its pinned gate. `dragging` is constantly false — tab
586
603
  * drag-and-drop is compositor work and is not modelled — and `fullyVisible` is
@@ -595,8 +612,14 @@ export class AdwTabView extends HTMLElement {
595
612
  if (!close) return;
596
613
 
597
614
  const barLeft = this._barEl.scrollLeft;
598
- const fullyVisible =
599
- tab.offsetLeft >= barLeft && tab.offsetLeft + tab.offsetWidth <= barLeft + this._barEl.clientWidth;
615
+ const pos = this._tabPosition(tab);
616
+ // `update_visible` (adw-tab-box.c:769-797). C also demands SPACING of slack on
617
+ // both sides; that term is deliberately NOT transplanted, because it is not
618
+ // slack there either: the allocation loop starts the first tab at `pos = SPACING`
619
+ // and puts SPACING between tabs (:3270-3286), so `pos - SPACING >= value` is
620
+ // exactly "flush against the leading edge". This strip has no such gaps, so the
621
+ // same term would report the first chip clipped and hide its close button.
622
+ const fullyVisible = pos >= barLeft && pos + tab.offsetWidth <= barLeft + this._barEl.clientWidth;
600
623
  close.hidden = !tabCloseVisible({
601
624
  hovering: this._hovered.has(id),
602
625
  fullyVisible,
@@ -608,6 +631,7 @@ export class AdwTabView extends HTMLElement {
608
631
 
609
632
  private _onSelectionChange(change: TabViewSelectionChange): void {
610
633
  this._applyActiveState();
634
+ this._scrollSelectedTabIntoBar();
611
635
  this._reflectSelected();
612
636
  this.dispatchEvent(
613
637
  new CustomEvent('notify::selected-page', {
@@ -641,6 +665,34 @@ export class AdwTabView extends HTMLElement {
641
665
  }
642
666
  }
643
667
 
668
+ /**
669
+ * `scroll_to_tab_full` (adw-tab-box.c:928-961), called on every selection the way
670
+ * `select_page` calls it (:1728): bring the selected chip inside the BAR's own
671
+ * scroll window, padded by half of whichever is smaller, the chip or the leftover.
672
+ *
673
+ * The bar is the only thing C ever scrolls for a tab: `scroll_to_tab_full` writes
674
+ * `self->adjustment`, the strip's own adjustment, and nothing else. The DOM has no
675
+ * such narrow API: `focus()` and `scrollIntoView()` both walk EVERY scrollable
676
+ * ancestor up to the window. So this moves `scrollLeft` by hand, and the focus call
677
+ * below passes `preventScroll`.
678
+ */
679
+ private _scrollSelectedTabIntoBar(): void {
680
+ const id = this._state.selectedId;
681
+ const tab = id === null ? undefined : this._tabs.get(id);
682
+ if (!tab) return;
683
+ const pageSize = this._barEl.clientWidth;
684
+ const width = tab.offsetWidth;
685
+ // Unmeasured (detached, or still inside connectedCallback): nothing to scroll to.
686
+ if (pageSize <= 0 || width <= 0) return;
687
+
688
+ const value = this._barEl.scrollLeft;
689
+ const pos = this._tabPosition(tab);
690
+ const padding = Math.min(width, pageSize - width) / 2;
691
+ if (pos - TAB_SPACING < value) this._barEl.scrollLeft = pos - padding;
692
+ else if (pos + width + TAB_SPACING > value + pageSize)
693
+ this._barEl.scrollLeft = pos + width + padding - pageSize;
694
+ }
695
+
644
696
  private _applyBarVisibility(): void {
645
697
  const revealed = tabsRevealed({
646
698
  autohide: this.autohide,
@@ -710,9 +762,17 @@ export class AdwTabView extends HTMLElement {
710
762
  event.preventDefault();
711
763
  // Roving tabindex: the newly-active tab is the only focusable one, so
712
764
  // focus has to travel with it or the next keypress goes nowhere.
765
+ //
766
+ // `preventScroll` because a bare `focus()` scrolls every scrollable ancestor
767
+ // up to and including the WINDOW, while C only ever moves the tab strip
768
+ // (`scroll_to_tab_full` writes `self->adjustment`, adw-tab-box.c:928-961).
769
+ // Measured in a long documentation page: one ArrowRight took the window from
770
+ // y=1800 to y=8367, i.e. switching a tab yanked the whole document to the widget.
771
+ // `_scrollSelectedTabIntoBar` (already run by the selection change) is the
772
+ // strip-local scroll that is supposed to happen instead.
713
773
  if (inBar) {
714
774
  const id = this._state.selectedId;
715
- if (id !== null) this._tabs.get(id)?.focus();
775
+ if (id !== null) this._tabs.get(id)?.focus({ preventScroll: true });
716
776
  }
717
777
  }
718
778
 
@@ -30,6 +30,7 @@ import {
30
30
  parseToolbarStyle,
31
31
  toolbarViewClasses,
32
32
  } from '@gjsify/adwaita-core';
33
+ import { AdwScrollShading } from '../scroll-shading.js';
33
34
 
34
35
  export class AdwToolbarView extends HTMLElement {
35
36
  private _topEl!: HTMLDivElement;
@@ -37,6 +38,14 @@ export class AdwToolbarView extends HTMLElement {
37
38
  private _bottomEl!: HTMLDivElement;
38
39
  private _initialized = false;
39
40
  private _resize: ResizeObserver | null = null;
41
+ /**
42
+ * The scroll indicators for every scroller under this view.
43
+ *
44
+ * Owned HERE and not by a scroller widget, because upstream scopes them here:
45
+ * `toolbarview.undershoot-top scrolledwindow` shades whatever scrolls inside,
46
+ * however deeply nested — a split view's two panes included.
47
+ */
48
+ private _shading: AdwScrollShading | null = null;
40
49
 
41
50
  static get observedAttributes() {
42
51
  return ['top-bar-style', 'bottom-bar-style', 'extend-content-to-top-edge', 'extend-content-to-bottom-edge'];
@@ -87,6 +96,10 @@ export class AdwToolbarView extends HTMLElement {
87
96
 
88
97
  // `update_undershoots` runs from size_allocate, so the classes have to
89
98
  // follow the bars' real heights rather than a one-shot read.
99
+ // Before `_syncClasses`, which hands it the two undershoot flags it gates on.
100
+ this._shading = new AdwScrollShading(this._contentEl);
101
+ this._shading.connect();
102
+
90
103
  this._resize = new ResizeObserver(() => this._syncClasses());
91
104
  this._resize.observe(this._topEl);
92
105
  this._resize.observe(this._bottomEl);
@@ -96,6 +109,8 @@ export class AdwToolbarView extends HTMLElement {
96
109
  disconnectedCallback() {
97
110
  this._resize?.disconnect();
98
111
  this._resize = null;
112
+ this._shading?.disconnect();
113
+ this._shading = null;
99
114
  }
100
115
 
101
116
  attributeChangedCallback() {
@@ -117,6 +132,13 @@ export class AdwToolbarView extends HTMLElement {
117
132
  this._topEl.classList.toggle(cls, topBar.includes(cls));
118
133
  this._bottomEl.classList.toggle(cls, bottomBar.includes(cls));
119
134
  }
135
+
136
+ // The same two flags reach the scrollers, where the shade is actually
137
+ // drawn — a raised bar brings its own shadow and must not get a second one.
138
+ this._shading?.setUndershootEdges({
139
+ top: view.includes('undershoot-top'),
140
+ bottom: view.includes('undershoot-bottom'),
141
+ });
120
142
  }
121
143
  }
122
144
 
package/src/index.ts CHANGED
@@ -34,6 +34,12 @@ export {
34
34
  } from './accent.js';
35
35
  export type { ApplyAccentOptions } from './accent.js';
36
36
 
37
+ // GtkScrolledWindow's undershoot/overshoot indicators. `adw-toolbar-view` drives
38
+ // this itself; exported so a consumer that owns its own chrome can shade a scroller
39
+ // without one.
40
+ export { AdwScrollShading } from './scroll-shading.js';
41
+ export type { AdwUndershootEdges } from './scroll-shading.js';
42
+
37
43
  export { AdwAvatar } from './elements/adw-avatar.js';
38
44
  export { AdwBanner } from './elements/adw-banner.js';
39
45
  export { AdwCard } from './elements/adw-card.js';
@@ -0,0 +1,255 @@
1
+ // Browser wiring for GtkScrolledWindow's edge indicators — the DOM half of
2
+ // `@gjsify/adwaita-core`'s `scrolling.ts`, which owns the arithmetic.
3
+ //
4
+ // WHY a discovery pass and not a widget. GTK has a `scrolledwindow` node, so the
5
+ // stylesheet can say `toolbarview.undershoot-top scrolledwindow` and be done. The
6
+ // browser has no such element — any block with `overflow: auto` is one — so the
7
+ // equivalent scope has to be computed. The scope is deliberately the same as
8
+ // upstream's: a DESCENDANT selector, so a toolbar view shades EVERY scroller under
9
+ // it, not only the one it directly wraps. That is what makes the storybook's three
10
+ // panes (story list, preview, controls) each shade themselves independently.
11
+ //
12
+ // WHY the top and bottom indicators share one pseudo-element each. Undershoot and
13
+ // overshoot at the same edge are mutually exclusive by construction: an undershoot
14
+ // says content is hidden past the edge, an overshoot says the position is past it,
15
+ // and `overshootDistance` is 0 for as long as `isScrolledFromStart` is true. One
16
+ // box per edge, switched by which state is live.
17
+ //
18
+ // WHY sticky. The indicator has to stay welded to the scrollport edge while the
19
+ // content moves under it, and it must consume no layout. `position: sticky` plus a
20
+ // negative margin equal to its height is the only combination that does both — an
21
+ // absolutely-positioned child of a scroll container scrolls away with the content.
22
+ // The one constraint this leaves: a scroller that is itself a flex/grid container
23
+ // with a `gap` pays that gap once for the pseudo-element. Scrollers are wrappers,
24
+ // so this has not bitten; a scroller with a gap should move it to an inner box.
25
+ //
26
+ // Reference: refs/gtk/gtk/gtkscrolledwindow.c
27
+ // Reference: refs/libadwaita/src/stylesheet/widgets/_scrolling.scss
28
+ // Copyright (c) GNOME contributors (GTK, libadwaita). LGPLv2.1+.
29
+
30
+ import {
31
+ ADW_OVERSHOOT_SETTLE_MS,
32
+ ADW_UNDERSHOOT_CLASSES,
33
+ accumulateOvershoot,
34
+ overshootDistance,
35
+ type ScrollAdjustment,
36
+ scrollUndershootClasses,
37
+ } from '@gjsify/adwaita-core';
38
+
39
+ /** Marks a scroller adwaita-web has taken over, so the stylesheet can target it. */
40
+ const SHADED_CLASS = 'adw-scroll-shaded';
41
+
42
+ /** The custom property carrying the live overshoot size, in px. */
43
+ const OVERSHOOT_PROPERTY = '--adw-overshoot';
44
+
45
+ /** Which edge is currently overshooting, as a state class. */
46
+ const OVERSHOOT_CLASSES = ['overshoot-top', 'overshoot-bottom'] as const;
47
+
48
+ /**
49
+ * `overflow` values that make a box a scrollport.
50
+ *
51
+ * `overlay` is a WebKit alias for `auto` that a browser may still report from
52
+ * author CSS even though it is removed from the spec, so a scroller written that
53
+ * way is one and has to be treated as one.
54
+ */
55
+ const SCROLLABLE_OVERFLOW = new Set(['auto', 'scroll', 'overlay']);
56
+
57
+ /** Per-axis adjustments as the DOM reports them. */
58
+ function adjustmentsOf(el: HTMLElement): { vertical?: ScrollAdjustment; horizontal?: ScrollAdjustment } {
59
+ const style = getComputedStyle(el);
60
+ const vertical = SCROLLABLE_OVERFLOW.has(style.overflowY)
61
+ ? { value: el.scrollTop, lower: 0, upper: el.scrollHeight, pageSize: el.clientHeight }
62
+ : undefined;
63
+ const horizontal = SCROLLABLE_OVERFLOW.has(style.overflowX)
64
+ ? { value: el.scrollLeft, lower: 0, upper: el.scrollWidth, pageSize: el.clientWidth }
65
+ : undefined;
66
+ return { vertical, horizontal };
67
+ }
68
+
69
+ /** Whether a box scrolls on either axis at all. */
70
+ function isScroller(el: HTMLElement): boolean {
71
+ const style = getComputedStyle(el);
72
+ return SCROLLABLE_OVERFLOW.has(style.overflowY) || SCROLLABLE_OVERFLOW.has(style.overflowX);
73
+ }
74
+
75
+ /**
76
+ * A wheel delta in CSS pixels, whatever unit the event chose to speak in.
77
+ *
78
+ * `deltaMode` is not decoration — Firefox reports `DOM_DELTA_LINE` (3-ish per
79
+ * detent) where Chrome reports pixels (100-ish per detent), so an unconverted
80
+ * delta makes the overshoot two orders of magnitude smaller on one browser. The
81
+ * line height is the conventional 16px default scaled by the browser's own
82
+ * three-lines-per-detent step.
83
+ */
84
+ function wheelDeltaPx(event: WheelEvent, port: number): number {
85
+ if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) return event.deltaY * 16;
86
+ if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) return event.deltaY * port;
87
+ return event.deltaY;
88
+ }
89
+
90
+ /** Which vertical edges may draw an undershoot — a toolbar view's own two flags. */
91
+ export interface AdwUndershootEdges {
92
+ /** `undershoot-top` on the view: the top bar is flat and was allocated a height. */
93
+ top: boolean;
94
+ /** `undershoot-bottom` on the view. */
95
+ bottom: boolean;
96
+ }
97
+
98
+ /**
99
+ * Keeps every scroller under a root marked with its live edge indicators.
100
+ *
101
+ * One instance per toolbar view. Scroll and wheel are listened for in the CAPTURE
102
+ * phase on the root: `scroll` does not bubble, but capture propagation runs down
103
+ * the whole path regardless, so one pair of listeners covers scrollers that appear
104
+ * later without re-binding per element.
105
+ */
106
+ export class AdwScrollShading {
107
+ private _root: HTMLElement;
108
+ private _mutations: MutationObserver | null = null;
109
+ private _resize: ResizeObserver | null = null;
110
+ /** The unclamped position per scroller, i.e. `priv->unclamped_vadj_value`. */
111
+ private _unclamped = new WeakMap<HTMLElement, number>();
112
+ private _settleTimers = new WeakMap<HTMLElement, ReturnType<typeof setTimeout>>();
113
+ /** Every scroller currently marked, so teardown and rescans can clear them. */
114
+ private _shaded = new Set<HTMLElement>();
115
+ private _pendingScan = 0;
116
+ private _undershootEdges: AdwUndershootEdges = { top: true, bottom: true };
117
+
118
+ constructor(root: HTMLElement) {
119
+ this._root = root;
120
+ }
121
+
122
+ /**
123
+ * Which edges may draw an UNDERSHOOT. The overshoot glow is not gated: upstream
124
+ * draws it on every `scrolledwindow`, while the undershoot is what a toolbar
125
+ * view turns on for a flat bar and off for a raised one, which has its own
126
+ * shadow (`update_undershoots`).
127
+ */
128
+ setUndershootEdges(edges: AdwUndershootEdges): void {
129
+ if (edges.top === this._undershootEdges.top && edges.bottom === this._undershootEdges.bottom) return;
130
+ this._undershootEdges = { ...edges };
131
+ for (const el of this._shaded) this._update(el);
132
+ }
133
+
134
+ /** Start observing. Idempotent. */
135
+ connect(): void {
136
+ if (this._mutations) return;
137
+ this._root.addEventListener('scroll', this._onScroll, { capture: true, passive: true });
138
+ this._root.addEventListener('wheel', this._onWheel, { capture: true, passive: true });
139
+ // A rescan reads the computed overflow of every descendant, so it is coalesced
140
+ // to one per frame: replacing a story fires a mutation record per node.
141
+ this._mutations = new MutationObserver(() => this._scheduleRefresh());
142
+ this._mutations.observe(this._root, { childList: true, subtree: true });
143
+ this._resize = new ResizeObserver(() => this._scheduleRefresh());
144
+ this._resize.observe(this._root);
145
+ this.refresh();
146
+ }
147
+
148
+ /** Stop observing and clear every class this instance set. */
149
+ disconnect(): void {
150
+ this._root.removeEventListener('scroll', this._onScroll, { capture: true });
151
+ this._root.removeEventListener('wheel', this._onWheel, { capture: true });
152
+ this._mutations?.disconnect();
153
+ this._mutations = null;
154
+ this._resize?.disconnect();
155
+ this._resize = null;
156
+ if (this._pendingScan) cancelAnimationFrame(this._pendingScan);
157
+ this._pendingScan = 0;
158
+ for (const el of this._shaded) this._clear(el);
159
+ this._shaded.clear();
160
+ }
161
+
162
+ private _scheduleRefresh(): void {
163
+ if (this._pendingScan) return;
164
+ this._pendingScan = requestAnimationFrame(() => {
165
+ this._pendingScan = 0;
166
+ this.refresh();
167
+ });
168
+ }
169
+
170
+ /** Re-scan for scrollers and re-derive every indicator. */
171
+ refresh(): void {
172
+ const found = new Set<HTMLElement>();
173
+ // The ROOT counts: a toolbar view whose content area scrolls on its own is
174
+ // the common case, and `querySelectorAll` never returns the element it was
175
+ // called on.
176
+ if (isScroller(this._root)) found.add(this._root);
177
+ for (const el of this._root.querySelectorAll<HTMLElement>('*')) {
178
+ if (isScroller(el)) found.add(el);
179
+ }
180
+ for (const el of this._shaded) {
181
+ if (!found.has(el)) this._clear(el);
182
+ }
183
+ this._shaded = found;
184
+ for (const el of found) this._update(el);
185
+ }
186
+
187
+ private _clear(el: HTMLElement): void {
188
+ el.classList.remove(SHADED_CLASS, ...ADW_UNDERSHOOT_CLASSES, ...OVERSHOOT_CLASSES);
189
+ el.style.removeProperty(OVERSHOOT_PROPERTY);
190
+ }
191
+
192
+ /** Re-derive the undershoot classes of one scroller from its current position. */
193
+ private _update(el: HTMLElement): void {
194
+ el.classList.add(SHADED_CLASS);
195
+ const live = scrollUndershootClasses(adjustmentsOf(el));
196
+ for (const cls of ADW_UNDERSHOOT_CLASSES) {
197
+ const allowed =
198
+ (cls !== 'undershoot-top' || this._undershootEdges.top) &&
199
+ (cls !== 'undershoot-bottom' || this._undershootEdges.bottom);
200
+ el.classList.toggle(cls, allowed && live.includes(cls));
201
+ }
202
+ }
203
+
204
+ private _onScroll = (event: Event): void => {
205
+ const el = event.target;
206
+ if (el instanceof HTMLElement && this._shaded.has(el)) this._update(el);
207
+ };
208
+
209
+ /**
210
+ * The overshoot half. A browser scroll container clamps at its edge and reports
211
+ * nothing further, so the push past it is read off the wheel deltas the
212
+ * container could not consume — which is exactly the value upstream keeps in
213
+ * `unclamped_vadj_value` and clamps one overshoot distance out.
214
+ */
215
+ private _onWheel = (event: WheelEvent): void => {
216
+ const el = event.target instanceof HTMLElement ? this._scrollerFor(event.target) : null;
217
+ if (!el) return;
218
+ const { vertical } = adjustmentsOf(el);
219
+ if (!vertical) return;
220
+
221
+ const previous = this._unclamped.get(el) ?? vertical.value;
222
+ // A scroll that landed back inside the range resets the accumulator: the
223
+ // position the user pushed from is the real one again.
224
+ const base = overshootDistance(previous, vertical) === 0 ? vertical.value : previous;
225
+ const next = accumulateOvershoot(base, wheelDeltaPx(event, vertical.pageSize), vertical);
226
+ this._unclamped.set(el, next);
227
+ this._applyOvershoot(el, overshootDistance(next, vertical));
228
+
229
+ // Spring back once the wheel STOPS, not between two detents of one gesture.
230
+ clearTimeout(this._settleTimers.get(el));
231
+ this._settleTimers.set(
232
+ el,
233
+ setTimeout(() => {
234
+ this._unclamped.set(el, el.scrollTop);
235
+ this._applyOvershoot(el, 0);
236
+ }, ADW_OVERSHOOT_SETTLE_MS),
237
+ );
238
+ };
239
+
240
+ private _applyOvershoot(el: HTMLElement, distance: number): void {
241
+ el.classList.toggle('overshoot-top', distance < 0);
242
+ el.classList.toggle('overshoot-bottom', distance > 0);
243
+ if (distance === 0) el.style.removeProperty(OVERSHOOT_PROPERTY);
244
+ else el.style.setProperty(OVERSHOOT_PROPERTY, `${Math.abs(distance)}px`);
245
+ }
246
+
247
+ /** The nearest shaded scroller at or above a wheel target, root included. */
248
+ private _scrollerFor(target: HTMLElement): HTMLElement | null {
249
+ for (let el: HTMLElement | null = target; el; el = el.parentElement) {
250
+ if (this._shaded.has(el)) return el;
251
+ if (el === this._root) break;
252
+ }
253
+ return null;
254
+ }
255
+ }