@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.
- package/dist/adwaita-web.css +112 -40
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/adw-button.spec.d.ts +1 -0
- package/lib/types/adw-entry.spec.d.ts +1 -0
- package/lib/types/adw-menu-button.spec.d.ts +1 -0
- package/lib/types/elements/adw-carousel.d.ts +48 -0
- package/lib/types/elements/adw-entry.d.ts +6 -0
- package/lib/types/elements/adw-header-bar.d.ts +5 -4
- package/lib/types/elements/adw-menu-button.d.ts +18 -7
- package/lib/types/elements/adw-tab-view.d.ts +23 -0
- package/lib/types/elements/adw-toolbar-view.d.ts +8 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/scroll-shading.d.ts +56 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +6 -6
- package/scss/_carousel.scss +15 -1
- package/scss/_headerbar.scss +35 -6
- package/scss/_icons.generated.scss +18 -0
- package/scss/_scrolling.scss +107 -0
- package/scss/_style_classes.scss +21 -8
- package/scss/_theme.scss +1 -0
- package/scss/_toolbar_view.scss +52 -22
- package/scss/_variables.scss +7 -0
- package/scss/_view_switcher.scss +80 -20
- package/scss/adwaita-skin.scss +1 -0
- package/src/adw-button.spec.ts +59 -0
- package/src/adw-carousel.spec.ts +141 -1
- package/src/adw-entry.spec.ts +73 -0
- package/src/adw-header-bar.spec.ts +127 -0
- package/src/adw-menu-button.spec.ts +52 -0
- package/src/adw-tab-view.spec.ts +151 -0
- package/src/bottom-sheet.spec.ts +87 -0
- package/src/chrome.spec.ts +233 -0
- package/src/elements/adw-button.ts +8 -10
- package/src/elements/adw-carousel.ts +121 -4
- package/src/elements/adw-entry.ts +53 -8
- package/src/elements/adw-header-bar.ts +32 -9
- package/src/elements/adw-menu-button.ts +20 -19
- package/src/elements/adw-status-page.ts +7 -2
- package/src/elements/adw-tab-view.ts +63 -3
- package/src/elements/adw-toolbar-view.ts +22 -0
- package/src/index.ts +6 -0
- package/src/scroll-shading.ts +255 -0
- package/src/styles.generated.ts +1 -1
- package/src/test.browser.mts +6 -0
- 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
|
|
13
|
-
* took the centre. `Adw.HeaderBar` has the same either/or:
|
|
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:
|
|
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('
|
|
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(
|
|
81
|
-
if (this._initialized
|
|
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)
|
|
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
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
599
|
-
|
|
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
|
+
}
|