@gjsify/adwaita-web 0.48.0 → 0.49.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/lib/types/elements/adw-tab-view.d.ts +23 -5
- package/lib/types/elements/adw-view-stack.d.ts +17 -2
- package/lib/types/shared-trees.spec.d.ts +2 -0
- package/package.json +6 -6
- package/src/adw-avatar.spec.ts +17 -0
- package/src/adw-tab-view.spec.ts +8 -5
- package/src/elements/adw-avatar.ts +11 -3
- package/src/elements/adw-tab-view.ts +42 -22
- package/src/elements/adw-view-stack.ts +20 -4
- package/src/elements/adw-view-switcher-bar.ts +6 -3
- package/src/shared-trees.spec.ts +151 -0
- package/src/tab-view.spec.ts +69 -15
- package/src/test.browser.mts +2 -0
- package/src/view-stack.spec.ts +5 -3
|
@@ -23,13 +23,19 @@ export declare class AdwTabView extends HTMLElement {
|
|
|
23
23
|
private readonly _hovered;
|
|
24
24
|
private _initialized;
|
|
25
25
|
private _generatedIds;
|
|
26
|
-
/** Guards the `selected` attribute reflection against re-entering the model. */
|
|
26
|
+
/** Guards the `selected-page` attribute reflection against re-entering the model. */
|
|
27
27
|
private _reflecting;
|
|
28
28
|
static get observedAttributes(): string[];
|
|
29
29
|
constructor();
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
/**
|
|
31
|
+
* The selected page (`Adw.TabView:selected-page`), `null` when the view is empty.
|
|
32
|
+
*
|
|
33
|
+
* THE PAGE, NOT ITS POSITION (ADR 0048). Its position is {@link selectedIndex}, which
|
|
34
|
+
* stays a read-only report; `Adw.TabView` selects by object, and the model has held
|
|
35
|
+
* that object all along.
|
|
36
|
+
*/
|
|
37
|
+
get selectedPage(): AdwTabViewPage | null;
|
|
38
|
+
set selectedPage(page: AdwTabViewPage | null);
|
|
33
39
|
/**
|
|
34
40
|
* Whether the tab bar hides itself when it has nothing to show —
|
|
35
41
|
* `tabsRevealed`, which keeps the bar up for a single PINNED tab.
|
|
@@ -57,7 +63,8 @@ export declare class AdwTabView extends HTMLElement {
|
|
|
57
63
|
get selectedIndex(): number;
|
|
58
64
|
get selectedContent(): HTMLElement | null;
|
|
59
65
|
isClosing(id: string): boolean;
|
|
60
|
-
|
|
66
|
+
/** `adw_tab_view_set_selected_page`, which takes the PAGE; an id is accepted too. */
|
|
67
|
+
setSelectedPage(page: AdwTabViewPage | string | null): boolean;
|
|
61
68
|
selectNthPage(n: number): boolean;
|
|
62
69
|
selectPreviousPage(): boolean;
|
|
63
70
|
selectNextPage(): boolean;
|
|
@@ -142,6 +149,17 @@ export declare class AdwTabView extends HTMLElement {
|
|
|
142
149
|
private _scrollSelectedTabIntoBar;
|
|
143
150
|
private _applyBarVisibility;
|
|
144
151
|
private _reflectSelected;
|
|
152
|
+
/**
|
|
153
|
+
* `selected-page="<page id>"` — the markup half of {@link selectedPage} (ADR 0048).
|
|
154
|
+
*
|
|
155
|
+
* The id is the one `<adw-tab-page page-id>` declares; a page that declares none gets
|
|
156
|
+
* a generated one, which an author cannot predict and therefore cannot select by. That
|
|
157
|
+
* is the same bargain `<adw-view-stack visible-child-name>` already makes for a page
|
|
158
|
+
* with no name, and it is why `page-id` exists.
|
|
159
|
+
*
|
|
160
|
+
* An id no page holds is IGNORED rather than treated as the first page — libadwaita
|
|
161
|
+
* refuses a foreign `AdwTabPage` outright, and `setSelectedPage` is the refusal here.
|
|
162
|
+
*/
|
|
145
163
|
private _applySelectedAttribute;
|
|
146
164
|
/**
|
|
147
165
|
* The `Adw.TabView` shortcut table (`init_shortcuts`), plus the ArrowLeft/ArrowRight
|
|
@@ -6,9 +6,24 @@ export declare class AdwViewStack extends HTMLElement {
|
|
|
6
6
|
static get observedAttributes(): string[];
|
|
7
7
|
constructor();
|
|
8
8
|
get pages(): readonly AdwViewStackPageInfo[];
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Zero-based position of the visible page, `-1` when nothing is selected.
|
|
11
|
+
*
|
|
12
|
+
* A REPORT, NOT A DOOR (ADR 0048). `Adw.ViewStack` selects by NAME, and an ordinal is
|
|
13
|
+
* derived — an insertion before the visible page increments it, a reorder rewrites it,
|
|
14
|
+
* a removal decrements it. Selecting is {@link visibleChildName}.
|
|
15
|
+
*/
|
|
10
16
|
get visibleChildIndex(): number;
|
|
11
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Show the page at position `n`. Returns whether the selection moved.
|
|
19
|
+
*
|
|
20
|
+
* THE ORDINAL IS A CALL, NOT A PROPERTY (ADR 0048): it resolves against the page list
|
|
21
|
+
* as it is now and keeps nothing, where a held index comes to mean another page after
|
|
22
|
+
* an insert, a reorder or a removal. Port-owned — `refs/libadwaita` gives
|
|
23
|
+
* `AdwViewStack` no ordinal API — and named for the tab view's existing
|
|
24
|
+
* `selectNthPage`. The guard on `n` is the core's and is pinned by the shared vectors.
|
|
25
|
+
*/
|
|
26
|
+
selectNthPage(n: number): boolean;
|
|
12
27
|
get visibleChildName(): string;
|
|
13
28
|
set visibleChildName(name: string | null | undefined);
|
|
14
29
|
get visibleChild(): HTMLElement | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/adwaita-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.0",
|
|
4
4
|
"description": "Adwaita/Libadwaita web components for browser targets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -45,14 +45,14 @@
|
|
|
45
45
|
"@codemirror/language": "^6.12.4",
|
|
46
46
|
"@codemirror/state": "^6.7.0",
|
|
47
47
|
"@codemirror/view": "^6.43.4",
|
|
48
|
-
"@gjsify/adwaita-core": "^0.
|
|
49
|
-
"@gjsify/adwaita-fonts": "^0.
|
|
50
|
-
"@gjsify/adwaita-icons": "^0.
|
|
48
|
+
"@gjsify/adwaita-core": "^0.49.0",
|
|
49
|
+
"@gjsify/adwaita-fonts": "^0.49.0",
|
|
50
|
+
"@gjsify/adwaita-icons": "^0.49.0",
|
|
51
51
|
"@lezer/highlight": "^1.2.3"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@gjsify/cli": "^0.
|
|
55
|
-
"@gjsify/unit": "^0.
|
|
54
|
+
"@gjsify/cli": "^0.49.0",
|
|
55
|
+
"@gjsify/unit": "^0.49.0",
|
|
56
56
|
"sass": "^1.101.0",
|
|
57
57
|
"typescript": "^6.0.3"
|
|
58
58
|
},
|
package/src/adw-avatar.spec.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { AVATAR_COLORS, avatarMaxFontSize } from '@gjsify/adwaita-core';
|
|
|
13
13
|
import {
|
|
14
14
|
AVATAR_COLOR_VECTORS,
|
|
15
15
|
AVATAR_FONT_SIZE_VECTORS,
|
|
16
|
+
AVATAR_ICON_SIZE_VECTORS,
|
|
16
17
|
AVATAR_INITIALS_VECTORS,
|
|
17
18
|
} from '@gjsify/adwaita-core/conformance';
|
|
18
19
|
|
|
@@ -124,6 +125,22 @@ export const AdwAvatarTest = async () => {
|
|
|
124
125
|
});
|
|
125
126
|
});
|
|
126
127
|
|
|
128
|
+
await describe('adw-avatar fallback glyph size (adw-avatar.c:756)', async () => {
|
|
129
|
+
for (const { size, iconSize } of AVATAR_ICON_SIZE_VECTORS) {
|
|
130
|
+
await it(`size ${size} draws the glyph at ${iconSize}`, () => {
|
|
131
|
+
// Through the ELEMENT, not the helper: the box was hand-typed here as
|
|
132
|
+
// `round(size * 0.55)` and drew ~10% larger than GTK at every size,
|
|
133
|
+
// with nothing in a position to say so.
|
|
134
|
+
const { avatar, host } = mountAvatar('Ada Lovelace', false);
|
|
135
|
+
avatar.setAttribute('size', String(size));
|
|
136
|
+
const icon = avatar.querySelector('.adw-avatar-icon') as HTMLElement;
|
|
137
|
+
expect(icon.hidden).toBe(false);
|
|
138
|
+
expect(icon.getAttribute('size')).toBe(String(iconSize));
|
|
139
|
+
host.remove();
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
127
144
|
await describe('adw-avatar mode (Adw.Avatar update_visibility)', async () => {
|
|
128
145
|
await it('keeps a whitespace-only name in initials mode with a blank label', () => {
|
|
129
146
|
const { avatar, host } = mountAvatar(' ');
|
package/src/adw-tab-view.spec.ts
CHANGED
|
@@ -28,7 +28,7 @@ export const AdwTabViewTest = async () => {
|
|
|
28
28
|
await it('builds one tab per page and shows the first page', async () => {
|
|
29
29
|
const view = makeTabView();
|
|
30
30
|
expect(view.querySelectorAll('.adw-tab').length).toBe(3);
|
|
31
|
-
expect(view.
|
|
31
|
+
expect(view.selectedIndex).toBe(0);
|
|
32
32
|
const pages = view.querySelectorAll('.adw-tab-page');
|
|
33
33
|
expect((pages[0] as HTMLElement).hidden).toBe(false);
|
|
34
34
|
expect((pages[1] as HTMLElement).hidden).toBe(true);
|
|
@@ -42,15 +42,18 @@ export const AdwTabViewTest = async () => {
|
|
|
42
42
|
notified = (event as CustomEvent).detail.selected;
|
|
43
43
|
});
|
|
44
44
|
(view.querySelectorAll('.adw-tab')[2] as HTMLButtonElement).click();
|
|
45
|
-
expect(view.
|
|
45
|
+
expect(view.selectedIndex).toBe(2);
|
|
46
46
|
expect(notified).toBe(2);
|
|
47
47
|
expect((view.querySelectorAll('.adw-tab-page')[2] as HTMLElement).hidden).toBe(false);
|
|
48
48
|
view.parentElement?.remove();
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
await it('setting the selected attribute switches pages', async () => {
|
|
51
|
+
await it('setting the selected-page attribute switches pages', async () => {
|
|
52
52
|
const view = makeTabView();
|
|
53
|
-
|
|
53
|
+
// ADR 0048: the markup door names the PAGE. `makeTabView` declares no
|
|
54
|
+
// `page-id`, so the ids are the generated ones the element reflects back.
|
|
55
|
+
const second = view.pages[1]!.id;
|
|
56
|
+
view.setAttribute('selected-page', second);
|
|
54
57
|
expect((view.querySelectorAll('.adw-tab-page')[1] as HTMLElement).hidden).toBe(false);
|
|
55
58
|
expect((view.querySelectorAll('.adw-tab-page')[0] as HTMLElement).hidden).toBe(true);
|
|
56
59
|
view.parentElement?.remove();
|
|
@@ -70,7 +73,7 @@ export const AdwTabViewTest = async () => {
|
|
|
70
73
|
expect(view.querySelectorAll('.adw-tab').length).toBe(2);
|
|
71
74
|
// Closing a page BEFORE the selection must not also select the tab,
|
|
72
75
|
// and must leave the selected PAGE selected.
|
|
73
|
-
expect(view.
|
|
76
|
+
expect(view.selectedIndex).toBe(0);
|
|
74
77
|
view.parentElement?.remove();
|
|
75
78
|
});
|
|
76
79
|
});
|
|
@@ -19,7 +19,14 @@
|
|
|
19
19
|
// (adw-avatar.c:117-125) — the branch that wins over both others — could not be reached,
|
|
20
20
|
// and the vector row covering it had nothing to drive.
|
|
21
21
|
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
avatarColor,
|
|
24
|
+
avatarFontSize,
|
|
25
|
+
avatarIconSize,
|
|
26
|
+
avatarInitials,
|
|
27
|
+
avatarMaxFontSize,
|
|
28
|
+
avatarMode,
|
|
29
|
+
} from '@gjsify/adwaita-core';
|
|
23
30
|
|
|
24
31
|
import { type GtkImage, createGtkImage } from './gtk-image.js';
|
|
25
32
|
|
|
@@ -100,8 +107,9 @@ export class AdwAvatar extends HTMLElement {
|
|
|
100
107
|
this._iconEl.iconName = this.getAttribute('icon') ?? 'avatar-default';
|
|
101
108
|
// The glyph scales with the avatar: `size` sizes the BOX and the mask
|
|
102
109
|
// together, which is what `.adw-avatar-icon`'s `mask-size: contain`
|
|
103
|
-
// was there to approximate.
|
|
104
|
-
|
|
110
|
+
// was there to approximate. The factor moved into the core when the
|
|
111
|
+
// NativeScript avatar grew the same fallback — one number, two renderers.
|
|
112
|
+
this._iconEl.size = avatarIconSize(size);
|
|
105
113
|
this._iconEl.hidden = false;
|
|
106
114
|
}
|
|
107
115
|
}
|
|
@@ -106,11 +106,11 @@ export class AdwTabView extends HTMLElement {
|
|
|
106
106
|
private readonly _hovered = new Set<string>();
|
|
107
107
|
private _initialized = false;
|
|
108
108
|
private _generatedIds = 0;
|
|
109
|
-
/** Guards the `selected` attribute reflection against re-entering the model. */
|
|
109
|
+
/** Guards the `selected-page` attribute reflection against re-entering the model. */
|
|
110
110
|
private _reflecting = false;
|
|
111
111
|
|
|
112
112
|
static get observedAttributes() {
|
|
113
|
-
return ['selected', 'autohide', 'expand-tabs', 'no-close'];
|
|
113
|
+
return ['selected-page', 'autohide', 'expand-tabs', 'no-close'];
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
constructor() {
|
|
@@ -131,13 +131,26 @@ export class AdwTabView extends HTMLElement {
|
|
|
131
131
|
this._state.subscribe((change) => this._onSelectionChange(change));
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
/**
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
/**
|
|
135
|
+
* The selected page (`Adw.TabView:selected-page`), `null` when the view is empty.
|
|
136
|
+
*
|
|
137
|
+
* THE PAGE, NOT ITS POSITION (ADR 0048). Its position is {@link selectedIndex}, which
|
|
138
|
+
* stays a read-only report; `Adw.TabView` selects by object, and the model has held
|
|
139
|
+
* that object all along.
|
|
140
|
+
*/
|
|
141
|
+
get selectedPage(): AdwTabViewPage | null {
|
|
142
|
+
return this._state.selectedPage;
|
|
137
143
|
}
|
|
138
144
|
|
|
139
|
-
set
|
|
140
|
-
|
|
145
|
+
set selectedPage(page: AdwTabViewPage | null) {
|
|
146
|
+
// Straight to the core, which owns the page list and therefore owns
|
|
147
|
+
// `page_belongs_to_this_view` — the check an ID cannot answer: an id is unique
|
|
148
|
+
// WITHIN a view, never across two (a declared `page-id` is the author's to repeat
|
|
149
|
+
// and `_nextId` counts per element, so two views both hold a `tab-1`). Measured
|
|
150
|
+
// before the core took the page: `viewA.selectedPage = viewB.pages[2]` moved viewA
|
|
151
|
+
// to index 2 instead of refusing. The refusal carries C's own diagnostic now,
|
|
152
|
+
// where a renderer-side guard could only return in silence.
|
|
153
|
+
this._state.setSelectedPage(page);
|
|
141
154
|
}
|
|
142
155
|
|
|
143
156
|
/**
|
|
@@ -177,8 +190,8 @@ export class AdwTabView extends HTMLElement {
|
|
|
177
190
|
this._initialized = true;
|
|
178
191
|
|
|
179
192
|
// Read the declared selection BEFORE adopting anything: the auto-select
|
|
180
|
-
// of the first page reflects its own
|
|
181
|
-
const declaredSelection = this.getAttribute('selected');
|
|
193
|
+
// of the first page reflects its own id into this very attribute.
|
|
194
|
+
const declaredSelection = this.getAttribute('selected-page');
|
|
182
195
|
|
|
183
196
|
// Snapshot the declared pages, then take over the subtree. The elements
|
|
184
197
|
// themselves become the panels, so they must survive replaceChildren.
|
|
@@ -195,12 +208,12 @@ export class AdwTabView extends HTMLElement {
|
|
|
195
208
|
|
|
196
209
|
attributeChangedCallback(name: string, _old: string | null, _value: string | null) {
|
|
197
210
|
if (!this._initialized) return;
|
|
198
|
-
if (name === 'selected') {
|
|
211
|
+
if (name === 'selected-page') {
|
|
199
212
|
// The LIVE attribute, not the value captured when the reaction was
|
|
200
213
|
// queued: reflection writes this attribute, and a custom-element
|
|
201
214
|
// reaction can run after a later write has already superseded it.
|
|
202
215
|
if (this._reflecting) return;
|
|
203
|
-
const current = this.getAttribute('selected');
|
|
216
|
+
const current = this.getAttribute('selected-page');
|
|
204
217
|
if (current !== null) this._applySelectedAttribute(current);
|
|
205
218
|
return;
|
|
206
219
|
}
|
|
@@ -272,8 +285,9 @@ export class AdwTabView extends HTMLElement {
|
|
|
272
285
|
return this._state.isClosing(id);
|
|
273
286
|
}
|
|
274
287
|
|
|
275
|
-
|
|
276
|
-
|
|
288
|
+
/** `adw_tab_view_set_selected_page`, which takes the PAGE; an id is accepted too. */
|
|
289
|
+
setSelectedPage(page: AdwTabViewPage | string | null): boolean {
|
|
290
|
+
return this._state.setSelectedPage(page);
|
|
277
291
|
}
|
|
278
292
|
|
|
279
293
|
selectNthPage(n: number): boolean {
|
|
@@ -724,19 +738,25 @@ export class AdwTabView extends HTMLElement {
|
|
|
724
738
|
|
|
725
739
|
private _reflectSelected(): void {
|
|
726
740
|
this._reflecting = true;
|
|
727
|
-
const
|
|
728
|
-
if (
|
|
729
|
-
else if (this.getAttribute('selected') !==
|
|
741
|
+
const id = this._state.selectedId;
|
|
742
|
+
if (id === null) this.removeAttribute('selected-page');
|
|
743
|
+
else if (this.getAttribute('selected-page') !== id) this.setAttribute('selected-page', id);
|
|
730
744
|
this._reflecting = false;
|
|
731
745
|
}
|
|
732
746
|
|
|
747
|
+
/**
|
|
748
|
+
* `selected-page="<page id>"` — the markup half of {@link selectedPage} (ADR 0048).
|
|
749
|
+
*
|
|
750
|
+
* The id is the one `<adw-tab-page page-id>` declares; a page that declares none gets
|
|
751
|
+
* a generated one, which an author cannot predict and therefore cannot select by. That
|
|
752
|
+
* is the same bargain `<adw-view-stack visible-child-name>` already makes for a page
|
|
753
|
+
* with no name, and it is why `page-id` exists.
|
|
754
|
+
*
|
|
755
|
+
* An id no page holds is IGNORED rather than treated as the first page — libadwaita
|
|
756
|
+
* refuses a foreign `AdwTabPage` outright, and `setSelectedPage` is the refusal here.
|
|
757
|
+
*/
|
|
733
758
|
private _applySelectedAttribute(value: string): void {
|
|
734
|
-
|
|
735
|
-
// An unparseable value is IGNORED rather than treated as 0, and an out-of-range
|
|
736
|
-
// one is refused rather than clamped: libadwaita refuses both, so
|
|
737
|
-
// `selected="oops"` must not select the first page nor `selected="99"` the last.
|
|
738
|
-
if (Number.isNaN(index)) return;
|
|
739
|
-
this._state.selectNthPage(index);
|
|
759
|
+
this._state.setSelectedPage(value);
|
|
740
760
|
}
|
|
741
761
|
|
|
742
762
|
/**
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
// Properties (mirroring Adw.ViewStack):
|
|
27
27
|
// pages — readonly page descriptors ({ name, title, icon, content, visible }).
|
|
28
28
|
// visibleChildName — name of the visible page ('' when nothing is selected).
|
|
29
|
-
// visibleChildIndex —
|
|
29
|
+
// visibleChildIndex — READ-ONLY report: position of the visible page, -1 for none.
|
|
30
|
+
// Selecting is `visibleChildName` (ADR 0048).
|
|
30
31
|
// visibleChild — the visible page's content element (or null).
|
|
31
32
|
// Methods:
|
|
32
33
|
// add(content, name, title?, icon?) — append a page; returns its descriptor.
|
|
@@ -70,13 +71,28 @@ export class AdwViewStack extends HTMLElement {
|
|
|
70
71
|
return this._state.pages;
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Zero-based position of the visible page, `-1` when nothing is selected.
|
|
76
|
+
*
|
|
77
|
+
* A REPORT, NOT A DOOR (ADR 0048). `Adw.ViewStack` selects by NAME, and an ordinal is
|
|
78
|
+
* derived — an insertion before the visible page increments it, a reorder rewrites it,
|
|
79
|
+
* a removal decrements it. Selecting is {@link visibleChildName}.
|
|
80
|
+
*/
|
|
74
81
|
get visibleChildIndex(): number {
|
|
75
82
|
return this._state.visibleIndex;
|
|
76
83
|
}
|
|
77
84
|
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Show the page at position `n`. Returns whether the selection moved.
|
|
87
|
+
*
|
|
88
|
+
* THE ORDINAL IS A CALL, NOT A PROPERTY (ADR 0048): it resolves against the page list
|
|
89
|
+
* as it is now and keeps nothing, where a held index comes to mean another page after
|
|
90
|
+
* an insert, a reorder or a removal. Port-owned — `refs/libadwaita` gives
|
|
91
|
+
* `AdwViewStack` no ordinal API — and named for the tab view's existing
|
|
92
|
+
* `selectNthPage`. The guard on `n` is the core's and is pinned by the shared vectors.
|
|
93
|
+
*/
|
|
94
|
+
selectNthPage(n: number): boolean {
|
|
95
|
+
return this._state.setVisibleIndex(n);
|
|
80
96
|
}
|
|
81
97
|
|
|
82
98
|
get visibleChildName(): string {
|
|
@@ -99,8 +99,10 @@ export class AdwViewSwitcherBar extends HTMLElement {
|
|
|
99
99
|
const stack = this._stack;
|
|
100
100
|
if (!stack) return;
|
|
101
101
|
const index = Number(item.dataset.pageIndex);
|
|
102
|
-
//
|
|
103
|
-
|
|
102
|
+
// The roving-focus list is ordinal and the stack resolves it (ADR 0048).
|
|
103
|
+
// Re-selecting the current page would re-run the stack's transition, which
|
|
104
|
+
// is why the position is compared before the call.
|
|
105
|
+
if (stack.visibleChildIndex !== index) stack.selectNthPage(index);
|
|
104
106
|
},
|
|
105
107
|
});
|
|
106
108
|
}
|
|
@@ -238,7 +240,8 @@ export class AdwViewSwitcherBar extends HTMLElement {
|
|
|
238
240
|
|
|
239
241
|
button.append(icon, label, indicator);
|
|
240
242
|
button.addEventListener('click', () => {
|
|
241
|
-
|
|
243
|
+
// Ordinal here, resolved by the stack (ADR 0048) — as in `select` above.
|
|
244
|
+
this._stack?.selectNthPage(pageIndex);
|
|
242
245
|
});
|
|
243
246
|
|
|
244
247
|
return { button, icon, label, indicator };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// ONE AUTHORED TREE, BUILT BY THIS RENDERER — ADR 0051, and the renderer ADR 0027 § 9
|
|
2
|
+
// names by name: "the same authored tree, rendered through this host and through
|
|
3
|
+
// `adwaita-web`, satisfies the same `@gjsify/adwaita-core/conformance` vectors with no
|
|
4
|
+
// per-surface markup branch".
|
|
5
|
+
//
|
|
6
|
+
// The sibling driver is `packages/framework/gtk-host/src/shared-trees.spec.ts`. One corpus
|
|
7
|
+
// — `scripts/adwaita-gallery-shared-trees.mjs`, read here and not transcribed — one set of
|
|
8
|
+
// expectations, a driver per renderer: ADR 0030's shape one level up, so a green-here /
|
|
9
|
+
// red-there diff is attributable to the RENDERER and not to two suites disagreeing about
|
|
10
|
+
// what they test.
|
|
11
|
+
//
|
|
12
|
+
// TWO DECLARED TRANSFORMS ON THE WAY IN, and no third. `hostTagOf` turns the authored GIR
|
|
13
|
+
// class name into the element name, which is the same case rule `gtk-host` stamps its tags
|
|
14
|
+
// with; and an authored property name becomes its kebab-case ATTRIBUTE, because this
|
|
15
|
+
// renderer's door is markup. Both are total functions over the corpus with no tag list and
|
|
16
|
+
// no per-block case — a `switch` on a widget name here would be the per-surface branch
|
|
17
|
+
// § 9 forbids, and the reason the corpus admits a block only when it needs no alias.
|
|
18
|
+
//
|
|
19
|
+
// The readers below go through the REAL DOM the element rendered, never a state object of
|
|
20
|
+
// its own: an element asserting against its own bookkeeping agrees with itself while the
|
|
21
|
+
// page is wrong.
|
|
22
|
+
//
|
|
23
|
+
// What is NOT here is everything that is not about this renderer — which tables the corpus
|
|
24
|
+
// reaches, which block is declared to reach none, whether an expectation's address exists.
|
|
25
|
+
// Those are facts about the CORPUS and are asserted once, in `adwaita-core`'s own suite,
|
|
26
|
+
// which runs on Node as well as in a browser.
|
|
27
|
+
|
|
28
|
+
import { describe, expect, it } from '@gjsify/unit';
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
authoredTags,
|
|
32
|
+
sharedTreeExpectations,
|
|
33
|
+
subjectIndexOf,
|
|
34
|
+
type SharedTreeExpectation,
|
|
35
|
+
type SharedTreeNode,
|
|
36
|
+
} from '@gjsify/adwaita-core/conformance';
|
|
37
|
+
|
|
38
|
+
// `attributeOf` — `buttonLabel` -> `button-label` — comes from the corpus rather than
|
|
39
|
+
// being spelled again here, because arm 13 of `check-generated-website-data.mjs` reads
|
|
40
|
+
// gallery fences back through the SAME rule: two copies would let this driver and that
|
|
41
|
+
// check agree with each other while the fence a reader copies means something else.
|
|
42
|
+
import {
|
|
43
|
+
ADWAITA_GALLERY_SHARED_TREES,
|
|
44
|
+
attributeOf,
|
|
45
|
+
hostTagOf,
|
|
46
|
+
} from '../../../../scripts/adwaita-gallery-shared-trees.mjs';
|
|
47
|
+
|
|
48
|
+
import '@gjsify/adwaita-web';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The whole renderer-specific half of this driver: an element, its authored properties as
|
|
52
|
+
* attributes, its children, in that order.
|
|
53
|
+
*
|
|
54
|
+
* A boolean is the ATTRIBUTE'S PRESENCE, which is what every element here reads
|
|
55
|
+
* (`hasAttribute('revealed')`, `hasAttribute('expanded')`) — spelling `"true"` would set a
|
|
56
|
+
* present attribute for `false` as well.
|
|
57
|
+
*/
|
|
58
|
+
function build(node: SharedTreeNode): HTMLElement {
|
|
59
|
+
const el = document.createElement(hostTagOf(node.tag));
|
|
60
|
+
for (const [prop, value] of Object.entries(node.props ?? {})) {
|
|
61
|
+
if (typeof value === 'boolean') el.toggleAttribute(attributeOf(prop), value);
|
|
62
|
+
else el.setAttribute(attributeOf(prop), String(value));
|
|
63
|
+
}
|
|
64
|
+
for (const child of node.children ?? []) el.append(build(child));
|
|
65
|
+
return el;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Connect the authored root — these elements build on connect — run, and take it down again.
|
|
70
|
+
*
|
|
71
|
+
* The teardown is in a `finally` because a RED test must not leave a mounted tree behind:
|
|
72
|
+
* the next test would then be reading a document two blocks deep, and the failure it
|
|
73
|
+
* reported would name the wrong renderer.
|
|
74
|
+
*/
|
|
75
|
+
function mounted<T>(node: SharedTreeNode, use: (root: Element) => T): T {
|
|
76
|
+
const host = document.createElement('div');
|
|
77
|
+
host.append(build(node));
|
|
78
|
+
document.body.append(host);
|
|
79
|
+
try {
|
|
80
|
+
return use(host.firstElementChild!);
|
|
81
|
+
} finally {
|
|
82
|
+
host.remove();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const bannerButton = (banner: Element) => banner.querySelector<HTMLButtonElement>('.adw-banner-button');
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read one observable off the element this renderer built.
|
|
90
|
+
*
|
|
91
|
+
* The ONE seam between the shared expectations and this renderer, and the mirror of
|
|
92
|
+
* `read()` in the gtk-host driver. Everything above it is renderer-free.
|
|
93
|
+
*/
|
|
94
|
+
function read(expectation: SharedTreeExpectation, el: Element): string | number | boolean {
|
|
95
|
+
switch (expectation.observable) {
|
|
96
|
+
case 'entry-text-length':
|
|
97
|
+
return (el as unknown as { textLength: number }).textLength;
|
|
98
|
+
case 'switch-row-active':
|
|
99
|
+
return (el as unknown as { active: boolean }).active;
|
|
100
|
+
case 'banner-button-visible': {
|
|
101
|
+
const button = bannerButton(el);
|
|
102
|
+
return button ? !button.hidden : false;
|
|
103
|
+
}
|
|
104
|
+
case 'banner-button-text':
|
|
105
|
+
return bannerButton(el)?.textContent ?? '';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The realised tree, depth-first in document order and filtered to the authored element
|
|
111
|
+
* names: the parts an element renders for itself (a listbox, a revealer, a title span) are
|
|
112
|
+
* not the renderer's promise; the ORDER and the NESTING of what was authored is.
|
|
113
|
+
*/
|
|
114
|
+
function realised(root: Element, wanted: readonly string[]): Element[] {
|
|
115
|
+
const set = new Set(wanted);
|
|
116
|
+
return [root, ...root.querySelectorAll('*')].filter((el) => set.has(el.tagName.toLowerCase()));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const AdwSharedTreesTest = async () => {
|
|
120
|
+
await describe('the shared corpus builds through adwaita-web', async () => {
|
|
121
|
+
for (const block of ADWAITA_GALLERY_SHARED_TREES) {
|
|
122
|
+
await it(`${block.widget} builds, and the REAL DOM carries the authored nodes in order`, () => {
|
|
123
|
+
const wanted = authoredTags(block.root, hostTagOf);
|
|
124
|
+
|
|
125
|
+
mounted(block.root, (root) => {
|
|
126
|
+
const built = realised(root, wanted).map((el) => el.tagName.toLowerCase());
|
|
127
|
+
expect(built).toStrictEqual(wanted);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await describe('the shared corpus against the adwaita-core vectors it reaches', async () => {
|
|
134
|
+
for (const block of ADWAITA_GALLERY_SHARED_TREES) {
|
|
135
|
+
for (const expectation of sharedTreeExpectations(block.root)) {
|
|
136
|
+
await it(`${block.widget} — ${expectation.path}: ${expectation.table} — ${expectation.rule}`, () => {
|
|
137
|
+
mounted(block.root, (root) => {
|
|
138
|
+
// The SAME filtered walk the shape test asserts, so the element an
|
|
139
|
+
// expectation is read off is the one at the authored ADDRESS rather
|
|
140
|
+
// than the first of its name the DOM happens to contain.
|
|
141
|
+
const built = realised(root, authoredTags(block.root, hostTagOf));
|
|
142
|
+
const subject = built[subjectIndexOf(block.root, expectation.path)]!;
|
|
143
|
+
expect(subject.tagName.toLowerCase()).toBe(hostTagOf(expectation.gtype));
|
|
144
|
+
|
|
145
|
+
expect(read(expectation, subject)).toBe(expectation.expected);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
};
|
package/src/tab-view.spec.ts
CHANGED
|
@@ -207,7 +207,7 @@ export const AdwTabViewConformanceTest = async () => {
|
|
|
207
207
|
});
|
|
208
208
|
});
|
|
209
209
|
|
|
210
|
-
await describe('adw-tab-view selected attribute', async () => {
|
|
210
|
+
await describe('adw-tab-view selected-page attribute', async () => {
|
|
211
211
|
function mountThree(attrs = ''): { view: AdwTabView; host: HTMLElement } {
|
|
212
212
|
const host = document.createElement('div');
|
|
213
213
|
host.innerHTML = `<adw-tab-view ${attrs}>
|
|
@@ -219,38 +219,92 @@ export const AdwTabViewConformanceTest = async () => {
|
|
|
219
219
|
return { view: host.querySelector('adw-tab-view') as AdwTabView, host };
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
-
await it('selects the declared
|
|
223
|
-
const { view, host } = mountThree('selected="
|
|
222
|
+
await it('selects the declared PAGE instead of the auto-pick', () => {
|
|
223
|
+
const { view, host } = mountThree('selected-page="c"');
|
|
224
224
|
expect(view.selectedIndex).toBe(2);
|
|
225
|
+
expect(view.selectedId).toBe('c');
|
|
225
226
|
expect(shownPanels(view)).toStrictEqual([false, false, true]);
|
|
226
227
|
host.remove();
|
|
227
228
|
});
|
|
228
229
|
|
|
229
|
-
await it('IGNORES an
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
230
|
+
await it('IGNORES an id no page holds instead of falling back to the first', () => {
|
|
231
|
+
// The ordinal spellings are just unknown ids now (ADR 0048), and a fallback to
|
|
232
|
+
// page 0 would be the same wrong answer clamping used to give: libadwaita
|
|
233
|
+
// refuses a foreign `AdwTabPage` outright.
|
|
233
234
|
const { view, host } = mountThree();
|
|
234
235
|
for (const value of ['99', '-1', 'oops']) {
|
|
235
|
-
view.setAttribute('selected', value);
|
|
236
|
+
view.setAttribute('selected-page', value);
|
|
236
237
|
expect(view.selectedIndex).toBe(0);
|
|
237
238
|
}
|
|
238
239
|
//...and the attribute is put back in sync rather than left lying.
|
|
239
|
-
view.setAttribute('selected', '
|
|
240
|
+
view.setAttribute('selected-page', 'b');
|
|
240
241
|
expect(view.selectedIndex).toBe(1);
|
|
241
|
-
expect(view.getAttribute('selected')).toBe(
|
|
242
|
+
expect(view.getAttribute('selected-page')).toBe(view.pages[1]!.id);
|
|
243
|
+
host.remove();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
await it('REFUSES a page belonging to ANOTHER view, and RECORDS it', () => {
|
|
247
|
+
// `page_belongs_to_this_view`. An id is only unique WITHIN a view — declared ones
|
|
248
|
+
// are the author's to repeat and `_nextId` counts per element, so two views both
|
|
249
|
+
// hold a `tab-1`. Both views here hold 'a'/'b'/'c', which is the collision.
|
|
250
|
+
const first = mountThree();
|
|
251
|
+
const second = mountThree();
|
|
252
|
+
second.view.selectedPage = second.view.pages[2]!;
|
|
253
|
+
expect(second.view.selectedIndex).toBe(2);
|
|
254
|
+
|
|
255
|
+
first.view.selectedPage = second.view.pages[2]!;
|
|
256
|
+
expect(first.view.selectedIndex).toBe(0);
|
|
257
|
+
expect(second.view.selectedIndex).toBe(2);
|
|
258
|
+
// The refusal carries C's own assertion. It did NOT while the check was a
|
|
259
|
+
// renderer-side guard returning in silence, which is why the check moved into
|
|
260
|
+
// `TabViewState` — the one place that owns the page list.
|
|
261
|
+
expect(first.view.diagnostics).toStrictEqual([
|
|
262
|
+
"adw_tab_view_set_selected_page: assertion 'page_belongs_to_this_view (self, selected_page)' failed",
|
|
263
|
+
]);
|
|
264
|
+
expect(second.view.diagnostics).toStrictEqual([]);
|
|
265
|
+
|
|
266
|
+
// ...and this view's OWN page of that same id is accepted, so what was refused
|
|
267
|
+
// is the IDENTITY and not the id.
|
|
268
|
+
first.view.selectedPage = first.view.pages[2]!;
|
|
269
|
+
expect(first.view.selectedIndex).toBe(2);
|
|
270
|
+
first.host.remove();
|
|
271
|
+
second.host.remove();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await it('RECORDS the refusal, where the ordinal door recorded nothing', () => {
|
|
275
|
+
// The behaviour change ADR 0048 does not name: `selected="99"` went through
|
|
276
|
+
// `selectNthPage`, which refuses an out-of-range index SILENTLY, while an
|
|
277
|
+
// unknown id goes through `setSelectedPage`, which is where C's
|
|
278
|
+
// `page_belongs_to_this_view` assertion lives. Three refusals, three records —
|
|
279
|
+
// if this list stays empty the element is swallowing the typo the reader made.
|
|
280
|
+
const { view, host } = mountThree();
|
|
281
|
+
expect(view.diagnostics).toStrictEqual([]);
|
|
282
|
+
for (const value of ['99', 'oops']) view.setAttribute('selected-page', value);
|
|
283
|
+
expect(view.diagnostics).toStrictEqual([
|
|
284
|
+
"adw_tab_view_set_selected_page: assertion 'page_belongs_to_this_view (self, selected_page)' failed",
|
|
285
|
+
"adw_tab_view_set_selected_page: assertion 'page_belongs_to_this_view (self, selected_page)' failed",
|
|
286
|
+
]);
|
|
287
|
+
|
|
288
|
+
// ...and `null` while pages exist is the OTHER half of the same C assertion,
|
|
289
|
+
// which `view.selected = -1` also used to reach in silence.
|
|
290
|
+
view.selectedPage = null;
|
|
291
|
+
expect(view.selectedIndex).toBe(0);
|
|
292
|
+
expect(view.diagnostics).toHaveLength(3);
|
|
293
|
+
expect(view.diagnostics[2]).toBe(
|
|
294
|
+
"adw_tab_view_set_selected_page: assertion 'ADW_IS_TAB_PAGE (selected_page)' failed",
|
|
295
|
+
);
|
|
242
296
|
host.remove();
|
|
243
297
|
});
|
|
244
298
|
|
|
245
299
|
await it('notifies on a PROGRAMMATIC selection, not only on a click', () => {
|
|
246
|
-
// C notifies on EVERY path: a programmatic `view.
|
|
247
|
-
// a tab click does.
|
|
300
|
+
// C notifies on EVERY path: a programmatic `view.selectedPage = …` must notify
|
|
301
|
+
// as a tab click does.
|
|
248
302
|
const { view, host } = mountThree();
|
|
249
303
|
const seen: number[] = [];
|
|
250
304
|
view.addEventListener('notify::selected-page', (event) =>
|
|
251
305
|
seen.push((event as CustomEvent).detail.selected),
|
|
252
306
|
);
|
|
253
|
-
view.
|
|
307
|
+
view.selectedPage = view.pages[2]!;
|
|
254
308
|
expect(seen).toStrictEqual([2]);
|
|
255
309
|
host.remove();
|
|
256
310
|
});
|
|
@@ -259,11 +313,11 @@ export const AdwTabViewConformanceTest = async () => {
|
|
|
259
313
|
const { view, host } = mountThree();
|
|
260
314
|
const chips = tabChips(view);
|
|
261
315
|
chips[1]!.click();
|
|
262
|
-
expect(view.getAttribute('selected')).toBe(
|
|
316
|
+
expect(view.getAttribute('selected-page')).toBe(view.pages[1]!.id);
|
|
263
317
|
|
|
264
318
|
for (const id of ['a', 'b', 'c']) view.closePage(id);
|
|
265
319
|
expect(view.nPages).toBe(0);
|
|
266
|
-
expect(view.hasAttribute('selected')).toBe(false);
|
|
320
|
+
expect(view.hasAttribute('selected-page')).toBe(false);
|
|
267
321
|
expect(view.selectedIndex).toBe(-1);
|
|
268
322
|
host.remove();
|
|
269
323
|
});
|
package/src/test.browser.mts
CHANGED
|
@@ -53,8 +53,10 @@ import { AdwEmptySectionsTest } from './empty-sections.spec.js';
|
|
|
53
53
|
import { AdwSlottedChildrenTest } from './slotted-children.spec.js';
|
|
54
54
|
import { AdwKeyboardOperableTest } from './keyboard-operable.spec.js';
|
|
55
55
|
import { AdwFontsTest } from './adw-fonts.spec.js';
|
|
56
|
+
import { AdwSharedTreesTest } from './shared-trees.spec.js';
|
|
56
57
|
|
|
57
58
|
run({
|
|
59
|
+
AdwSharedTreesTest,
|
|
58
60
|
AdwKeyboardOperableTest,
|
|
59
61
|
AdwConnectLifecycleTest,
|
|
60
62
|
AdwEmptySectionsTest,
|
package/src/view-stack.spec.ts
CHANGED
|
@@ -66,7 +66,9 @@ function mountStack(pages: readonly ViewStackVectorPage[], visibleChildName?: st
|
|
|
66
66
|
function applyOp(stack: AdwViewStack, op: ViewStackVectorOp): void {
|
|
67
67
|
switch (op.kind) {
|
|
68
68
|
case 'selectIndex':
|
|
69
|
-
|
|
69
|
+
// ADR 0048: the element has no ordinal SETTER any more; the ordinal is this
|
|
70
|
+
// call, and it forwards to the same core guard the vector pins.
|
|
71
|
+
stack.selectNthPage(op.index);
|
|
70
72
|
break;
|
|
71
73
|
case 'selectName':
|
|
72
74
|
stack.visibleChildName = op.name;
|
|
@@ -224,7 +226,7 @@ export const AdwViewStackTest = async () => {
|
|
|
224
226
|
await it('reflects the selection back, including an empty name', () => {
|
|
225
227
|
const { stack, host } = mountStack([{ name: 'a' }, { name: '' }]);
|
|
226
228
|
expect(stack.getAttribute('visible-child-name')).toBe('a');
|
|
227
|
-
stack.
|
|
229
|
+
stack.selectNthPage(1);
|
|
228
230
|
// Reflection guarded by `if (current && …)` never fires for nameless pages.
|
|
229
231
|
expect(stack.getAttribute('visible-child-name')).toBe('');
|
|
230
232
|
host.remove();
|
|
@@ -252,7 +254,7 @@ export const AdwViewStackTest = async () => {
|
|
|
252
254
|
// with duplicate names a naive re-lookup resolves to the FIRST match and
|
|
253
255
|
// undoes the selection.
|
|
254
256
|
const { stack, host } = mountStack([{ name: 'dup' }, { name: 'dup' }]);
|
|
255
|
-
stack.
|
|
257
|
+
stack.selectNthPage(1);
|
|
256
258
|
expect(stack.visibleChildIndex).toBe(1);
|
|
257
259
|
expect(stack.getAttribute('visible-child-name')).toBe('dup');
|
|
258
260
|
host.remove();
|