@gjsify/adwaita-web 0.32.0 → 0.34.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 +118 -55
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/elements/adw-combo-row.d.ts +10 -0
- package/lib/types/elements/adw-inline-view-switcher.d.ts +37 -1
- package/lib/types/elements/adw-navigation-split-view.d.ts +42 -0
- package/lib/types/elements/adw-overlay-split-view.d.ts +56 -0
- package/lib/types/elements/adw-spinner.d.ts +23 -0
- package/lib/types/elements/adw-view-stack.d.ts +12 -0
- package/lib/types/elements/adw-view-switcher-bar.d.ts +7 -5
- package/lib/types/elements/adw-view-switcher.d.ts +55 -3
- package/lib/types/elements/adw-wrap-box.d.ts +58 -3
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +6 -6
- package/scss/_combo_row.scss +19 -0
- package/scss/_navigation_split_view.scss +52 -9
- package/scss/_navigation_view.scss +28 -4
- package/scss/_overlay_split_view.scss +122 -33
- package/scss/_panes.scss +57 -0
- package/scss/_sidebar.scss +8 -1
- package/scss/_spinner.scss +35 -21
- package/scss/_tab_view.scss +12 -13
- package/scss/_theme.scss +9 -0
- package/scss/_variables.scss +18 -1
- package/scss/_wrap_box.scss +9 -0
- package/scss/adwaita-skin.scss +1 -0
- package/src/adw-about-dialog.spec.ts +36 -0
- package/src/adw-avatar.spec.ts +37 -0
- package/src/adw-button-content.spec.ts +24 -0
- package/src/adw-row-state.spec.ts +49 -0
- package/src/adw-sidebar.spec.ts +32 -0
- package/src/adw-wrap-box.spec.ts +148 -1
- package/src/chrome.spec.ts +111 -12
- package/src/elements/adw-action-row.ts +11 -1
- package/src/elements/adw-combo-row.ts +17 -0
- package/src/elements/adw-inline-view-switcher.ts +91 -8
- package/src/elements/adw-navigation-split-view.ts +189 -14
- package/src/elements/adw-overlay-split-view.ts +395 -20
- package/src/elements/adw-spinner.ts +165 -21
- package/src/elements/adw-tab-view.ts +20 -2
- package/src/elements/adw-view-stack.ts +21 -0
- package/src/elements/adw-view-switcher-bar.ts +13 -5
- package/src/elements/adw-view-switcher.ts +103 -11
- package/src/elements/adw-wrap-box.ts +290 -135
- package/src/split-views.spec.ts +677 -2
- package/src/styles.generated.ts +1 -1
- package/src/view-switcher.spec.ts +219 -0
|
@@ -1,45 +1,189 @@
|
|
|
1
1
|
// <adw-spinner> — A continuously-spinning loading indicator.
|
|
2
|
-
// Attributes: size (px; the
|
|
2
|
+
// Attributes: size (px; the BOX, floored at the measured minimum 16).
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// `
|
|
7
|
-
// `
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
4
|
+
// THE BOX AND THE RING ARE DIFFERENT THINGS. `adw_spinner_measure`
|
|
5
|
+
// (adw-spinner.c:78-81) reports MIN_SIZE as both minimum and natural and has no
|
|
6
|
+
// upper bound — `MAX_SIZE` at :15 is defined and never referenced — while
|
|
7
|
+
// `adw_spinner_snapshot` (:95-99) hands the widget's real width and height to
|
|
8
|
+
// the paintable, which caps only `radius` (adw-spinner-paintable.c:343) and
|
|
9
|
+
// still centres on the box (:349-351). So `size="200"` occupies 200px of layout
|
|
10
|
+
// and draws a 64px ring in the middle. This element used to BE the ring, which
|
|
11
|
+
// is why its suite asserted the opposite of the core's `SPINNER_SIZE_VECTORS`.
|
|
11
12
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
13
|
+
// THE ARC IS NOT A ROTATING QUARTER-CIRCLE. It extends, overlaps, contracts and
|
|
14
|
+
// idles on an ease-in-out-sine while the figure turns; the arithmetic is
|
|
15
|
+
// `@gjsify/adwaita-core`'s `spinnerArc`, shared with the vectors that judge it.
|
|
16
|
+
// Expressing that needs a per-frame value, so this element draws an SVG arc and
|
|
17
|
+
// advances it from one module-level ticker — not one timer per spinner. CSS
|
|
18
|
+
// keyframes cannot express it: a `border-top-color` chase is a fixed 90 degrees,
|
|
19
|
+
// which is what this element drew for its whole life.
|
|
20
|
+
//
|
|
21
|
+
// IT KEEPS SPINNING UNDER REDUCED MOTION. `adw_spinner_paintable_set_widget`
|
|
22
|
+
// calls `adw_animation_set_follow_enable_animations_setting (…, FALSE)` (:537)
|
|
23
|
+
// — deliberately, because a frozen busy indicator reads as a hang. The old
|
|
24
|
+
// `@media (prefers-reduced-motion: reduce) { animation: none }` did exactly that.
|
|
25
|
+
//
|
|
26
|
+
// Reference: refs/libadwaita/src/adw-spinner.c (MIN_SIZE, adw_spinner_measure, the a11y role)
|
|
27
|
+
// Reference: refs/libadwaita/src/adw-spinner-paintable.c (the animation + geometry)
|
|
15
28
|
// Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
|
|
16
29
|
// Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
|
|
17
30
|
|
|
18
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
ADW_SPINNER_STILL_PROGRESS,
|
|
33
|
+
ADW_SPINNER_TRACK_OPACITY,
|
|
34
|
+
resolveSpinnerSize,
|
|
35
|
+
spinnerArc,
|
|
36
|
+
spinnerGeometry,
|
|
37
|
+
spinnerProgressAt,
|
|
38
|
+
} from '@gjsify/adwaita-core';
|
|
39
|
+
|
|
40
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every mapped spinner, driven by ONE `requestAnimationFrame` loop.
|
|
44
|
+
*
|
|
45
|
+
* The C animates per widget off the frame clock, which costs nothing extra
|
|
46
|
+
* because the clock is already ticking. A browser has no such clock, so the
|
|
47
|
+
* equivalent is one shared rAF: N spinners on a page must not mean N loops, and
|
|
48
|
+
* the loop must not exist at all while none are mapped.
|
|
49
|
+
*/
|
|
50
|
+
const mapped = new Set<AdwSpinner>();
|
|
51
|
+
let frameHandle = 0;
|
|
52
|
+
|
|
53
|
+
function tick(now: number): void {
|
|
54
|
+
for (const spinner of mapped) spinner.drawAt(now);
|
|
55
|
+
frameHandle = mapped.size > 0 ? requestAnimationFrame(tick) : 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function startTicking(): void {
|
|
59
|
+
if (frameHandle === 0 && mapped.size > 0) frameHandle = requestAnimationFrame(tick);
|
|
60
|
+
}
|
|
19
61
|
|
|
20
62
|
export class AdwSpinner extends HTMLElement {
|
|
21
63
|
static get observedAttributes() {
|
|
22
64
|
return ['size'];
|
|
23
65
|
}
|
|
24
66
|
|
|
67
|
+
private _svg: SVGSVGElement | null = null;
|
|
68
|
+
private _track: SVGCircleElement | null = null;
|
|
69
|
+
private _arc: SVGCircleElement | null = null;
|
|
70
|
+
/** Path length of the ring, so the dash arithmetic is done once per resize. */
|
|
71
|
+
private _circumference = 0;
|
|
72
|
+
/** Ring radius in px — the dash lengths are arc-length, i.e. radians × r. */
|
|
73
|
+
private _radius = 0;
|
|
74
|
+
/** The timestamp this spinner was mapped at, so its progress starts at 0. */
|
|
75
|
+
private _origin: number | null = null;
|
|
76
|
+
|
|
25
77
|
connectedCallback() {
|
|
26
|
-
this.
|
|
78
|
+
this._build();
|
|
79
|
+
// `widget_map_cb` (adw-spinner-paintable.c:181-185) plays the animation
|
|
80
|
+
// on MAP, not on construction: an off-screen spinner burns nothing.
|
|
81
|
+
mapped.add(this);
|
|
82
|
+
this._origin = null;
|
|
83
|
+
startTicking();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
disconnectedCallback() {
|
|
87
|
+
mapped.delete(this);
|
|
27
88
|
}
|
|
28
89
|
|
|
29
90
|
attributeChangedCallback() {
|
|
30
91
|
this._sync();
|
|
31
92
|
}
|
|
32
93
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Draw the frame for `now`, in `requestAnimationFrame` timebase.
|
|
96
|
+
*
|
|
97
|
+
* Public because the shared ticker is a module-level function rather than a
|
|
98
|
+
* method — and because a test needs to place the spinner at a known moment
|
|
99
|
+
* without waiting for real frames.
|
|
100
|
+
*/
|
|
101
|
+
drawAt(now: number): void {
|
|
102
|
+
if (this._origin === null) this._origin = now;
|
|
103
|
+
this._paint(spinnerProgressAt(now - this._origin));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private _build(): void {
|
|
107
|
+
if (this._svg) return;
|
|
108
|
+
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
109
|
+
svg.setAttribute('fill', 'none');
|
|
110
|
+
// The ring is decorative; the ROLE is on the host, per adw-spinner.c:124.
|
|
111
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
112
|
+
|
|
113
|
+
const track = document.createElementNS(SVG_NS, 'circle');
|
|
114
|
+
track.setAttribute('stroke', 'currentColor');
|
|
115
|
+
// `CIRCLE_OPACITY` of the WIDGET's colour (adw-spinner-paintable.c:367-369),
|
|
116
|
+
// not a hardcoded grey — so the track follows the text colour everywhere.
|
|
117
|
+
track.setAttribute('stroke-opacity', String(ADW_SPINNER_TRACK_OPACITY));
|
|
118
|
+
|
|
119
|
+
const arc = document.createElementNS(SVG_NS, 'circle');
|
|
120
|
+
arc.setAttribute('stroke', 'currentColor');
|
|
121
|
+
// `GSK_LINE_CAP_ROUND` (adw-spinner-paintable.c:354). The port's
|
|
122
|
+
// `border-top-color` ends were square-cut, visible from 48px up.
|
|
123
|
+
arc.setAttribute('stroke-linecap', 'round');
|
|
124
|
+
|
|
125
|
+
svg.append(track, arc);
|
|
126
|
+
this._svg = svg;
|
|
127
|
+
this._track = track;
|
|
128
|
+
this._arc = arc;
|
|
129
|
+
this.replaceChildren(svg);
|
|
130
|
+
|
|
131
|
+
// `gtk_widget_class_set_accessible_role (…, PROGRESS_BAR)` (adw-spinner.c:124)
|
|
132
|
+
// and `GTK_ACCESSIBLE_STATE_BUSY, TRUE` (:139-141). A repo-wide grep for
|
|
133
|
+
// either found ZERO hits before this: screen readers announced nothing.
|
|
134
|
+
this.setAttribute('role', 'progressbar');
|
|
135
|
+
this.setAttribute('aria-busy', 'true');
|
|
136
|
+
|
|
137
|
+
this._sync();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Re-derive the box, the ring and the stroke from the `size` attribute. */
|
|
141
|
+
private _sync(): void {
|
|
142
|
+
const svg = this._svg;
|
|
143
|
+
const track = this._track;
|
|
144
|
+
const arc = this._arc;
|
|
145
|
+
if (!svg || !track || !arc) return;
|
|
146
|
+
|
|
38
147
|
const size = resolveSpinnerSize(this.getAttribute('size'));
|
|
39
148
|
const { diameter, lineWidth } = spinnerGeometry(size, size);
|
|
40
|
-
|
|
41
|
-
this.style.
|
|
42
|
-
this.style.
|
|
149
|
+
// THE BOX: whatever was asked for, floored at the measured minimum.
|
|
150
|
+
this.style.width = `${size}px`;
|
|
151
|
+
this.style.height = `${size}px`;
|
|
152
|
+
// THE RING: capped at 64, centred by the host's flex box.
|
|
153
|
+
svg.setAttribute('width', String(diameter));
|
|
154
|
+
svg.setAttribute('height', String(diameter));
|
|
155
|
+
svg.setAttribute('viewBox', `0 0 ${diameter} ${diameter}`);
|
|
156
|
+
|
|
157
|
+
// The stroke is centred on the path, so the circle sits half a stroke in
|
|
158
|
+
// — `radius - line_width / 2` (adw-spinner-paintable.c:364).
|
|
159
|
+
const radius = (diameter - lineWidth) / 2;
|
|
160
|
+
this._radius = radius;
|
|
161
|
+
this._circumference = 2 * Math.PI * radius;
|
|
162
|
+
for (const circle of [track, arc]) {
|
|
163
|
+
circle.setAttribute('cx', String(diameter / 2));
|
|
164
|
+
circle.setAttribute('cy', String(diameter / 2));
|
|
165
|
+
circle.setAttribute('r', String(radius));
|
|
166
|
+
circle.setAttribute('stroke-width', String(lineWidth));
|
|
167
|
+
}
|
|
168
|
+
// A resize while unmapped still has to leave a drawable frame behind:
|
|
169
|
+
// the resting pose, which is what a paintable without an animation draws.
|
|
170
|
+
this._paint(this._origin === null ? ADW_SPINNER_STILL_PROGRESS : this._lastProgress);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The progress the last painted frame used, so a resize can repaint it. */
|
|
174
|
+
private _lastProgress = ADW_SPINNER_STILL_PROGRESS;
|
|
175
|
+
|
|
176
|
+
private _paint(progress: number): void {
|
|
177
|
+
const arc = this._arc;
|
|
178
|
+
if (!arc || this._radius <= 0) return;
|
|
179
|
+
this._lastProgress = progress;
|
|
180
|
+
const { end, length } = spinnerArc(progress);
|
|
181
|
+
const drawn = length * this._radius;
|
|
182
|
+
arc.setAttribute('stroke-dasharray', `${drawn} ${Math.max(this._circumference - drawn, 0)}`);
|
|
183
|
+
// An SVG circle's path starts at 3 o'clock and runs clockwise, which is
|
|
184
|
+
// the same origin and direction the C's angles use — so a radian is a
|
|
185
|
+
// path distance and the dash only has to be shifted to `end`.
|
|
186
|
+
arc.setAttribute('stroke-dashoffset', String(-end * this._radius));
|
|
43
187
|
}
|
|
44
188
|
}
|
|
45
189
|
|
|
@@ -513,6 +513,19 @@ export class AdwTabView extends HTMLElement {
|
|
|
513
513
|
const icon = createAdwIcon(null, 'adw-tab-icon');
|
|
514
514
|
tab.appendChild(icon);
|
|
515
515
|
|
|
516
|
+
// `AdwTabPage:loading` swaps the icon image for an `AdwSpinnerPaintable`
|
|
517
|
+
// — the SAME paintable `Adw.Spinner` uses (`update_icons`,
|
|
518
|
+
// adw-tab.c:172-190). So this is a real `<adw-spinner>` in the icon's
|
|
519
|
+
// slot, not a ring drawn again in CSS: the stylesheet used to restate
|
|
520
|
+
// the border, the grey and the 0.8s under a comment claiming it was
|
|
521
|
+
// "not a second copy of them", and it inherited every spinner defect
|
|
522
|
+
// independently.
|
|
523
|
+
const spinner = document.createElement('adw-spinner');
|
|
524
|
+
spinner.className = 'adw-tab-spinner';
|
|
525
|
+
spinner.setAttribute('size', '16');
|
|
526
|
+
spinner.hidden = true;
|
|
527
|
+
tab.appendChild(spinner);
|
|
528
|
+
|
|
516
529
|
const label = document.createElement('span');
|
|
517
530
|
label.className = 'adw-tab-title';
|
|
518
531
|
tab.appendChild(label);
|
|
@@ -588,9 +601,14 @@ export class AdwTabView extends HTMLElement {
|
|
|
588
601
|
const icon = tab.querySelector('.adw-tab-icon') as AdwIcon | null;
|
|
589
602
|
if (icon) {
|
|
590
603
|
icon.iconName = icons.icon;
|
|
591
|
-
|
|
592
|
-
|
|
604
|
+
// The two occupy the same slot and are never both visible: C
|
|
605
|
+
// REPLACES the image's contents rather than stacking a second node.
|
|
606
|
+
icon.hidden = !icons.iconVisible || icons.spinner;
|
|
593
607
|
}
|
|
608
|
+
const spinner = tab.querySelector('.adw-tab-spinner') as HTMLElement | null;
|
|
609
|
+
// The spinner is mounted only while it spins, so an idle tab holds no
|
|
610
|
+
// element in the shared rAF ticker.
|
|
611
|
+
if (spinner) spinner.hidden = !(icons.spinner && icons.iconVisible);
|
|
594
612
|
const indicator = tab.querySelector('.adw-tab-indicator') as AdwIcon | null;
|
|
595
613
|
if (indicator) {
|
|
596
614
|
indicator.iconName = page.indicatorIcon;
|
|
@@ -168,6 +168,7 @@ export class AdwViewStack extends HTMLElement {
|
|
|
168
168
|
this.appendChild(content);
|
|
169
169
|
this._applyVisibility();
|
|
170
170
|
this._reflectName();
|
|
171
|
+
this._emitItemsChanged();
|
|
171
172
|
return page;
|
|
172
173
|
}
|
|
173
174
|
|
|
@@ -188,6 +189,7 @@ export class AdwViewStack extends HTMLElement {
|
|
|
188
189
|
content?.remove();
|
|
189
190
|
this._applyVisibility();
|
|
190
191
|
this._reflectName();
|
|
192
|
+
this._emitItemsChanged();
|
|
191
193
|
return true;
|
|
192
194
|
}
|
|
193
195
|
|
|
@@ -200,9 +202,28 @@ export class AdwViewStack extends HTMLElement {
|
|
|
200
202
|
setPageVisible(name: string, visible: boolean): boolean {
|
|
201
203
|
const moved = this._state.setPageVisible(name, visible);
|
|
202
204
|
this._applyVisibility();
|
|
205
|
+
// A visibility flip changes what the pages MODEL reports, which is what
|
|
206
|
+
// `update_bar_revealed` counts (adw-view-switcher-bar.c:340) — hiding
|
|
207
|
+
// the second-to-last visible page must retract the bar.
|
|
208
|
+
this._emitItemsChanged();
|
|
203
209
|
return moved;
|
|
204
210
|
}
|
|
205
211
|
|
|
212
|
+
/**
|
|
213
|
+
* The pages model's `items-changed`, which the DOM stack had no counterpart
|
|
214
|
+
* for at all.
|
|
215
|
+
*
|
|
216
|
+
* `AdwViewSwitcherBar` re-runs `update_bar_revealed` on it
|
|
217
|
+
* (adw-view-switcher-bar.c:340) and `AdwViewSwitcher` rebinds its buttons
|
|
218
|
+
* (adw-view-switcher.c:258-263). Both ports listened only on
|
|
219
|
+
* `notify::visible-child`, so adding a page or hiding a non-selected one
|
|
220
|
+
* left `revealed` — and the button row — stale, with a manual `refresh()`
|
|
221
|
+
* as the documented workaround.
|
|
222
|
+
*/
|
|
223
|
+
private _emitItemsChanged(): void {
|
|
224
|
+
this.dispatchEvent(new CustomEvent('items-changed', { bubbles: true, detail: { count: this.pages.length } }));
|
|
225
|
+
}
|
|
226
|
+
|
|
206
227
|
private _onStateChange(change: ViewStackStateChange<HTMLElement>): void {
|
|
207
228
|
this._applyVisibility();
|
|
208
229
|
this._reflectName();
|
|
@@ -147,11 +147,13 @@ export class AdwViewSwitcherBar extends HTMLElement {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
/**
|
|
150
|
-
* Rebuild the buttons
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
150
|
+
* Rebuild the buttons by hand.
|
|
151
|
+
*
|
|
152
|
+
* Kept as public API — a consumer that mutates a page in place still has a
|
|
153
|
+
* way to say so — but no longer the ONLY way: the stack emits
|
|
154
|
+
* `items-changed` now and this bar binds it, which is the mechanism
|
|
155
|
+
* libadwaita uses (adw-view-switcher-bar.c:340,
|
|
156
|
+
* adw-view-switcher.c:258-263).
|
|
155
157
|
*/
|
|
156
158
|
refresh(): void {
|
|
157
159
|
this._rebuild();
|
|
@@ -163,10 +165,16 @@ export class AdwViewSwitcherBar extends HTMLElement {
|
|
|
163
165
|
|
|
164
166
|
private _bindStack(): void {
|
|
165
167
|
this._stack?.addEventListener('notify::visible-child', this._onStackNotify);
|
|
168
|
+
// `update_bar_revealed` runs on the pages model's `items-changed` too
|
|
169
|
+
// (adw-view-switcher-bar.c:340), which is the half that was missing: a
|
|
170
|
+
// page added without moving the selection, or a non-selected page
|
|
171
|
+
// hidden, changes the COUNT the reveal is decided from.
|
|
172
|
+
this._stack?.addEventListener('items-changed', this._onStackNotify);
|
|
166
173
|
}
|
|
167
174
|
|
|
168
175
|
private _unbindStack(): void {
|
|
169
176
|
this._stack?.removeEventListener('notify::visible-child', this._onStackNotify);
|
|
177
|
+
this._stack?.removeEventListener('items-changed', this._onStackNotify);
|
|
170
178
|
}
|
|
171
179
|
|
|
172
180
|
private _resolveStack(value: AdwViewStack | string | null): AdwViewStack | null {
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
buildViewSwitcherButtons,
|
|
57
57
|
isViewSwitcherPolicy,
|
|
58
58
|
} from '@gjsify/adwaita-core';
|
|
59
|
+
import type { ViewSwitcherScheduler } from '@gjsify/adwaita-core';
|
|
59
60
|
// Aliased because `AdwViewSwitcherPage` is already this module's page ELEMENT —
|
|
60
61
|
// the custom element the package has always exported under that name.
|
|
61
62
|
import type {
|
|
@@ -82,18 +83,50 @@ interface PageNodes {
|
|
|
82
83
|
body: HTMLDivElement;
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
86
|
+
/**
|
|
87
|
+
* A single page. Children of `<adw-view-switcher>`; consumed at connect time.
|
|
88
|
+
*
|
|
89
|
+
* Deliberately carries NO `observedAttributes`. It used to declare six of them
|
|
90
|
+
* and no `attributeChangedCallback`, which is a dead declaration — the browser
|
|
91
|
+
* computes the list and has nowhere to deliver to — so changing `badge-number`,
|
|
92
|
+
* `title`, `icon-name` or `needs-attention` after connect did nothing at all,
|
|
93
|
+
* where C rebinds on every page `notify` (adw-view-switcher.c:184-193, :223).
|
|
94
|
+
*
|
|
95
|
+
* A callback could not fix it either: the switcher consumes these elements at
|
|
96
|
+
* connect and `replaceChildren`s them away, so a detached page has no `closest`
|
|
97
|
+
* to reach its owner through. The switcher watches them with a `MutationObserver`
|
|
98
|
+
* instead, which keeps working after the detach and needs nothing from this class.
|
|
99
|
+
*/
|
|
100
|
+
export class AdwViewSwitcherPage extends HTMLElement {}
|
|
91
101
|
|
|
92
102
|
export class AdwViewSwitcher extends HTMLElement {
|
|
93
|
-
|
|
103
|
+
/**
|
|
104
|
+
* The dwell timer, injectable.
|
|
105
|
+
*
|
|
106
|
+
* It used to be `new ViewSwitcherState({ scheduler: domViewSwitcherScheduler })`
|
|
107
|
+
* inline, which left NOWHERE to hand a clock in — so
|
|
108
|
+
* `VIEW_SWITCHER_DRAG_VECTORS` was driven by the core suite alone, and the
|
|
109
|
+
* conformance header claimed all three suites drove it. `scheduler` is a
|
|
110
|
+
* property so a test can replace it before the element connects; the DOM one
|
|
111
|
+
* is the default, exactly as before.
|
|
112
|
+
*/
|
|
113
|
+
scheduler: ViewSwitcherScheduler = domViewSwitcherScheduler;
|
|
114
|
+
private _stateInstance: ViewSwitcherState | null = null;
|
|
115
|
+
|
|
116
|
+
/** Built on first access, so a `scheduler` set before connect is the one used. */
|
|
117
|
+
private get _state(): ViewSwitcherState {
|
|
118
|
+
if (!this._stateInstance) {
|
|
119
|
+
this._stateInstance = new ViewSwitcherState({ scheduler: this.scheduler });
|
|
120
|
+
this._stateInstance.subscribe((change) => this._onStateChange(change));
|
|
121
|
+
}
|
|
122
|
+
return this._stateInstance;
|
|
123
|
+
}
|
|
94
124
|
private _barEl!: HTMLDivElement;
|
|
95
125
|
private _contentEl!: HTMLDivElement;
|
|
96
126
|
private _nodes: PageNodes[] = [];
|
|
127
|
+
/** The page ELEMENTS, kept because the switcher detaches them from the tree. */
|
|
128
|
+
private _declared: HTMLElement[] = [];
|
|
129
|
+
private _pageObserver: MutationObserver | undefined;
|
|
97
130
|
private _policy: AdwViewSwitcherPolicy = 'narrow';
|
|
98
131
|
private _initialized = false;
|
|
99
132
|
|
|
@@ -105,7 +138,6 @@ export class AdwViewSwitcher extends HTMLElement {
|
|
|
105
138
|
super();
|
|
106
139
|
// Subscribing touches no attributes and no children, so it is legal in a
|
|
107
140
|
// custom element constructor — unlike anything DOM-shaped.
|
|
108
|
-
this._state.subscribe((change) => this._onStateChange(change));
|
|
109
141
|
}
|
|
110
142
|
|
|
111
143
|
/** The policy controlling the button layout ('wide' | 'narrow'). */
|
|
@@ -153,7 +185,15 @@ export class AdwViewSwitcher extends HTMLElement {
|
|
|
153
185
|
}
|
|
154
186
|
|
|
155
187
|
connectedCallback() {
|
|
156
|
-
if (this._initialized)
|
|
188
|
+
if (this._initialized) {
|
|
189
|
+
// Re-entering a document. `disconnectedCallback` drops the page
|
|
190
|
+
// observer on the way out, and this early return used to skip
|
|
191
|
+
// rebuilding it — so a switcher that had merely been MOVED (a
|
|
192
|
+
// slideshow slide, a client-side route change) stopped noticing
|
|
193
|
+
// page attribute changes for good, and did it silently.
|
|
194
|
+
this._watchPages();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
157
197
|
|
|
158
198
|
// `:scope >` rather than a subtree scan: an unscoped query let an outer
|
|
159
199
|
// switcher adopt the pages of a nested one, since both use the same tag.
|
|
@@ -172,6 +212,8 @@ export class AdwViewSwitcher extends HTMLElement {
|
|
|
172
212
|
this._contentEl = document.createElement('div');
|
|
173
213
|
this._contentEl.className = 'adw-view-switcher-content';
|
|
174
214
|
|
|
215
|
+
this._declared = declared;
|
|
216
|
+
this._watchPages();
|
|
175
217
|
this._nodes = declared.map((pageEl, index) => this._buildPage(pageEl, index));
|
|
176
218
|
this._barEl.append(...this._nodes.map((nodes) => nodes.button));
|
|
177
219
|
this._contentEl.append(...this._nodes.map((nodes) => nodes.body));
|
|
@@ -193,6 +235,41 @@ export class AdwViewSwitcher extends HTMLElement {
|
|
|
193
235
|
// A drag that leaves with the element would otherwise switch pages after
|
|
194
236
|
// the widget is gone; C drops the source in dispose the same way.
|
|
195
237
|
this._state.cancelDrag();
|
|
238
|
+
this._pageObserver?.disconnect();
|
|
239
|
+
this._pageObserver = undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Re-read every declared page and push it into the state.
|
|
244
|
+
*
|
|
245
|
+
* Called by `AdwViewSwitcherPage.attributeChangedCallback`, which is what
|
|
246
|
+
* makes a runtime `badge-number` change reach the button. `setPages` keeps
|
|
247
|
+
* the selection where it can, so this is a repaint and not a reset.
|
|
248
|
+
*/
|
|
249
|
+
refreshPages(): void {
|
|
250
|
+
if (!this._initialized) return;
|
|
251
|
+
this._state.setPages(this._declared.map((pageEl) => readSwitcherPage(pageEl)));
|
|
252
|
+
this._render();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Watch the declared pages for attribute changes.
|
|
257
|
+
*
|
|
258
|
+
* A `MutationObserver` rather than an `attributeChangedCallback` on the page:
|
|
259
|
+
* the switcher consumes those elements at connect and `replaceChildren`s them
|
|
260
|
+
* out of the tree, so a detached page cannot find its owner — but an observer
|
|
261
|
+
* bound to it keeps firing. This is the DOM's `notify::` on
|
|
262
|
+
* `AdwViewStackPage`, which the C's `update_button` handler binds per page
|
|
263
|
+
* (adw-view-switcher.c:184-193).
|
|
264
|
+
*
|
|
265
|
+
* It delivers on a MICROTASK where GTK's notify is synchronous. That is the
|
|
266
|
+
* DOM's contract for watching a node, it is invisible to a user, and nothing
|
|
267
|
+
* in this element reads the button back inside the setter.
|
|
268
|
+
*/
|
|
269
|
+
private _watchPages(): void {
|
|
270
|
+
this._pageObserver?.disconnect();
|
|
271
|
+
this._pageObserver = new MutationObserver(() => this.refreshPages());
|
|
272
|
+
for (const pageEl of this._declared) this._pageObserver.observe(pageEl, { attributes: true });
|
|
196
273
|
}
|
|
197
274
|
|
|
198
275
|
attributeChangedCallback(name: string) {
|
|
@@ -239,8 +316,23 @@ export class AdwViewSwitcher extends HTMLElement {
|
|
|
239
316
|
this._state.setSelected(index);
|
|
240
317
|
});
|
|
241
318
|
// GtkDropControllerMotion's enter/leave, which drive TIMEOUT_EXPAND.
|
|
242
|
-
|
|
243
|
-
|
|
319
|
+
//
|
|
320
|
+
// The `relatedTarget` test is the whole point: this button contains an
|
|
321
|
+
// icon and a label, and the DOM fires `dragleave` + `dragenter` when the
|
|
322
|
+
// pointer crosses BETWEEN them. `ViewSwitcherDragSwitch.leave` clears the
|
|
323
|
+
// dwell timer and `enter` re-arms a fresh 500ms, so a drag that merely
|
|
324
|
+
// slid from the icon to the label restarted the countdown and the page
|
|
325
|
+
// never switched. C has one `GtkDropControllerMotion` per BUTTON
|
|
326
|
+
// (adw-view-switcher-button.c:79-96), which has no such internal edges —
|
|
327
|
+
// `relatedTarget` containment is that same "did we really leave" test.
|
|
328
|
+
button.addEventListener('dragenter', (event) => {
|
|
329
|
+
if (button.contains((event as DragEvent).relatedTarget as Node | null)) return;
|
|
330
|
+
this._state.dragEnter(index);
|
|
331
|
+
});
|
|
332
|
+
button.addEventListener('dragleave', (event) => {
|
|
333
|
+
if (button.contains((event as DragEvent).relatedTarget as Node | null)) return;
|
|
334
|
+
this._state.dragLeave(index);
|
|
335
|
+
});
|
|
244
336
|
|
|
245
337
|
const body = document.createElement('div');
|
|
246
338
|
body.className = 'adw-view-switcher-page';
|