@gjsify/adwaita-web 0.41.0 → 0.43.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/README.md +98 -3
- package/dist/adwaita-web.css +30 -23
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/adw-fonts.spec.d.ts +2 -0
- package/lib/types/breakpoints.d.ts +10 -1
- package/lib/types/connect-lifecycle.spec.d.ts +1 -0
- package/lib/types/elements/adw-about-dialog.d.ts +1 -0
- package/lib/types/elements/adw-action-row.d.ts +2 -0
- package/lib/types/elements/adw-alert-dialog.d.ts +14 -2
- package/lib/types/elements/adw-carousel.d.ts +8 -0
- package/lib/types/elements/adw-dialog.d.ts +2 -7
- package/lib/types/elements/adw-icon.d.ts +17 -1
- package/lib/types/elements/adw-navigation-split-view.d.ts +7 -0
- package/lib/types/elements/adw-overlay-split-view.d.ts +6 -0
- package/lib/types/elements/adw-popover.d.ts +1 -0
- package/lib/types/elements/adw-preferences-dialog.d.ts +1 -0
- package/lib/types/elements/adw-switch.d.ts +10 -0
- package/lib/types/elements/adw-toggle-group.d.ts +2 -0
- package/lib/types/elements/modal-surface.d.ts +59 -0
- package/lib/types/elements/roving-focus.d.ts +26 -0
- package/lib/types/elements/row-activation.d.ts +26 -0
- package/lib/types/elements/swipe-drag.d.ts +63 -0
- package/lib/types/empty-sections.d.ts +15 -0
- package/lib/types/empty-sections.spec.d.ts +1 -0
- package/lib/types/fonts.d.ts +1 -0
- package/lib/types/icon-registry.d.ts +35 -0
- package/lib/types/icon-registry.spec.d.ts +18 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/keyboard-operable.spec.d.ts +1 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +10 -6
- package/scss/_banner.scss +1 -1
- package/scss/_checks.scss +8 -8
- package/scss/_combo_row.scss +1 -1
- package/scss/_drop_down.scss +1 -1
- package/scss/_expander_row.scss +1 -1
- package/scss/_icon.scss +47 -0
- package/scss/_icons.generated.scss +18 -6
- package/scss/_password_entry_row.scss +1 -1
- package/scss/_preferences_dialog.scss +3 -2
- package/scss/_progress_bar.scss +1 -1
- package/scss/_row.scss +1 -1
- package/scss/_status_page.scss +1 -1
- package/scss/_tab_view.scss +1 -1
- package/scss/_toast.scss +6 -24
- package/scss/_variables.scss +23 -3
- package/src/adw-action-rows.spec.ts +21 -9
- package/src/adw-button-content.spec.ts +11 -6
- package/src/adw-fonts.spec.ts +188 -0
- package/src/adw-icon.spec.ts +17 -8
- package/src/adw-row-state.spec.ts +11 -1
- package/src/breakpoints.ts +18 -4
- package/src/connect-lifecycle.spec.ts +351 -0
- package/src/elements/adw-about-dialog.ts +24 -18
- package/src/elements/adw-action-row.ts +14 -2
- package/src/elements/adw-alert-dialog.ts +39 -20
- package/src/elements/adw-button-row.ts +3 -0
- package/src/elements/adw-carousel.ts +86 -5
- package/src/elements/adw-clamp.ts +3 -2
- package/src/elements/adw-data-grid.ts +5 -2
- package/src/elements/adw-dialog.ts +16 -63
- package/src/elements/adw-expander-row.ts +18 -4
- package/src/elements/adw-header-bar.ts +2 -0
- package/src/elements/adw-icon.ts +32 -6
- package/src/elements/adw-inline-view-switcher.ts +12 -0
- package/src/elements/adw-menu-button.ts +17 -10
- package/src/elements/adw-navigation-split-view.ts +30 -7
- package/src/elements/adw-overlay-split-view.ts +15 -3
- package/src/elements/adw-popover.ts +11 -0
- package/src/elements/adw-preferences-dialog.ts +18 -16
- package/src/elements/adw-preferences-group.ts +7 -0
- package/src/elements/adw-sidebar.ts +15 -0
- package/src/elements/adw-status-page.ts +12 -4
- package/src/elements/adw-switch-row.ts +7 -0
- package/src/elements/adw-switch.ts +19 -1
- package/src/elements/adw-tab-view.ts +7 -4
- package/src/elements/adw-toast-overlay.ts +8 -1
- package/src/elements/adw-toggle-group.ts +94 -2
- package/src/elements/adw-toolbar-view.ts +6 -2
- package/src/elements/adw-view-switcher-bar.ts +18 -0
- package/src/elements/adw-view-switcher.ts +14 -0
- package/src/elements/modal-surface.ts +180 -0
- package/src/elements/roving-focus.ts +154 -0
- package/src/elements/row-activation.ts +92 -0
- package/src/elements/swipe-drag.ts +450 -0
- package/src/empty-sections.spec.ts +81 -0
- package/src/empty-sections.ts +54 -0
- package/src/fonts.ts +25 -0
- package/src/icon-registry.spec.ts +240 -0
- package/src/icon-registry.ts +157 -0
- package/src/index.ts +24 -1
- package/src/keyboard-operable.spec.ts +755 -0
- package/src/source-view/theme.ts +15 -3
- package/src/split-button.spec.ts +6 -2
- package/src/style-classes.spec.ts +6 -0
- package/src/styles.generated.ts +1 -1
- package/src/test.browser.mts +14 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Does the Adwaita typeface actually SHIP, or is it only named?
|
|
2
|
+
//
|
|
3
|
+
// THE INCIDENT
|
|
4
|
+
//
|
|
5
|
+
// `src/index.ts` opened with `import '@gjsify/adwaita-fonts';` and the comment
|
|
6
|
+
// "Registers @font-face (fontsource pattern)". It registered nothing. That
|
|
7
|
+
// package's `.` export is `index.css`; under `gjsify build --app browser` the
|
|
8
|
+
// css-as-string plugin turns a CSS import into `export default "<css>"`, and a
|
|
9
|
+
// SIDE-EFFECT import of a module with no side effect is tree-shaken. Measured on
|
|
10
|
+
// 0.41.0: a probe entry whose only statement is that import builds to a 0-byte
|
|
11
|
+
// bundle with zero `@font-face`, exit 0. So every gjsify-built browser app using
|
|
12
|
+
// this package declared `font-family: 'Adwaita Sans'` and registered no face.
|
|
13
|
+
//
|
|
14
|
+
// It survived for the package's whole life because the workstation it was
|
|
15
|
+
// written on is a GNOME desktop with `adwaita-sans-fonts` installed system-wide
|
|
16
|
+
// (24 matching `fc-list` rows). Every screenshot looked right. Every computed
|
|
17
|
+
// `font-family` read back `'Adwaita Sans', …`. Both were true of a tree that
|
|
18
|
+
// shipped no font.
|
|
19
|
+
//
|
|
20
|
+
// SO NOTHING HERE MAY READ A COMPUTED FONT
|
|
21
|
+
//
|
|
22
|
+
// `getComputedStyle(el).fontFamily` returns the DECLARED stack, and
|
|
23
|
+
// `document.fonts.check()` answers yes for a face fontconfig supplies. Either
|
|
24
|
+
// would pass on this host with the bug fully present — a green test that checked
|
|
25
|
+
// nothing. So every assertion that has to be HOST-PROOF reads `document.fonts`
|
|
26
|
+
// (the FontFaceSet) or the `CSSFontFaceRule`s of the injected sheet, and a
|
|
27
|
+
// system-installed family appears in NEITHER: only faces this document
|
|
28
|
+
// registered are there.
|
|
29
|
+
//
|
|
30
|
+
// ONE case is deliberately not host-proof and says so at the call site: "renders
|
|
31
|
+
// distinct bolds" measures rendered WIDTHS, because whether the shipped bytes
|
|
32
|
+
// carry a `wght` axis is invisible from the declaration. Its discriminator is
|
|
33
|
+
// the `face.weight` assertion beside it, which fontconfig cannot satisfy.
|
|
34
|
+
import { describe, expect, it } from '@gjsify/unit';
|
|
35
|
+
|
|
36
|
+
import '@gjsify/adwaita-web';
|
|
37
|
+
import { ADWAITA_FONTS_STYLE_ID, applyAdwaitaFonts } from './fonts.js';
|
|
38
|
+
|
|
39
|
+
const FAMILY = 'Adwaita Sans';
|
|
40
|
+
|
|
41
|
+
/** Family names round-trip through CSS serialisation, so compare unquoted. */
|
|
42
|
+
const unquote = (value: string): string => value.trim().replace(/^['"]|['"]$/g, '');
|
|
43
|
+
|
|
44
|
+
/** The document-registered faces for `FAMILY` — a system-installed one is never here. */
|
|
45
|
+
const registeredFaces = (): FontFace[] => [...document.fonts].filter((face) => unquote(face.family) === FAMILY);
|
|
46
|
+
|
|
47
|
+
/** Every `@font-face` rule of every same-origin sheet, as `CSSFontFaceRule`s. */
|
|
48
|
+
const fontFaceRules = (sheet: CSSStyleSheet): CSSFontFaceRule[] => {
|
|
49
|
+
const out: CSSFontFaceRule[] = [];
|
|
50
|
+
// Indexed, not spread: `cssRules` is a LIVE list, and `unicorn/no-useless-spread`
|
|
51
|
+
// is right that copying it to iterate buys nothing.
|
|
52
|
+
for (let index = 0; index < sheet.cssRules.length; index++) {
|
|
53
|
+
const rule = sheet.cssRules[index];
|
|
54
|
+
if (rule instanceof CSSFontFaceRule) out.push(rule);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** The `<style>` element carrying an id, or `null`. */
|
|
60
|
+
const sheetOf = (id: string): CSSStyleSheet | null => {
|
|
61
|
+
const element = document.getElementById(id) as HTMLStyleElement | null;
|
|
62
|
+
return element?.sheet ?? null;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Rendered width of a fixed string in `FAMILY` at `weight`, at a size that makes the difference readable. */
|
|
66
|
+
const widthAt = (weight: number): number => {
|
|
67
|
+
const span = document.createElement('span');
|
|
68
|
+
span.style.cssText = `position:absolute;white-space:pre;font-size:64px;font-family:'${FAMILY}';font-weight:${weight}`;
|
|
69
|
+
span.textContent = 'Hamburgefonstiv';
|
|
70
|
+
document.body.appendChild(span);
|
|
71
|
+
const width = span.getBoundingClientRect().width;
|
|
72
|
+
span.remove();
|
|
73
|
+
return width;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const AdwFontsTest = async () => {
|
|
77
|
+
await describe('the adwaita-web root entry', async () => {
|
|
78
|
+
// THE DISCRIMINATOR for everything below: this runs BEFORE the opt-in, on
|
|
79
|
+
// a host that HAS the family installed. If `registeredFaces()` could be
|
|
80
|
+
// satisfied by fontconfig, this would already be non-empty and the
|
|
81
|
+
// "after" assertions would prove nothing.
|
|
82
|
+
await it('registers no face of its own — the family is only NAMED', async () => {
|
|
83
|
+
expect(registeredFaces().length).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await it('carries no @font-face in its stylesheet, which is the size decision', async () => {
|
|
87
|
+
// PINNED, not incidental. Inlining the two faces as base64 is
|
|
88
|
+
// 2.39 MB / 1.18 MB gzip against a 190 KB / 26 KB stylesheet, and
|
|
89
|
+
// `--app browser` has no code splitting, so a lazy face costs the same
|
|
90
|
+
// bytes as an eager one. If that trade is ever revisited (a subsetted
|
|
91
|
+
// woff2 would change it), this assertion is where the decision is
|
|
92
|
+
// recorded and has to be re-argued.
|
|
93
|
+
const sheet = sheetOf('adwaita-web-style');
|
|
94
|
+
expect(sheet).not.toBeNull();
|
|
95
|
+
expect(fontFaceRules(sheet as CSSStyleSheet).length).toBe(0);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// The removal below is an INVARIANT of this suite, not tidiness: a registered
|
|
100
|
+
// webface changes text METRICS for every element in the document, and this
|
|
101
|
+
// package's suite is full of layout assertions that measure text —
|
|
102
|
+
// `<adw-header-bar>`'s "ellipsizes instead of painting the title over the
|
|
103
|
+
// buttons" is one, and it went red in a discriminator run where the face 404'd
|
|
104
|
+
// mid-suite. `finally` is what MAKES it hold: it runs whether the body threw or
|
|
105
|
+
// not, so the cleanup no longer depends on the last case being reached, on it
|
|
106
|
+
// staying last, or on this suite staying last in `test.browser.mts`. The
|
|
107
|
+
// `it('leaves the document as it found it')` inside states the invariant; this
|
|
108
|
+
// guarantees it.
|
|
109
|
+
try {
|
|
110
|
+
await describe('applyAdwaitaFonts', async () => {
|
|
111
|
+
await it('registers both shipped faces in document.fonts', async () => {
|
|
112
|
+
expect(applyAdwaitaFonts()).toBe(true);
|
|
113
|
+
|
|
114
|
+
const faces = registeredFaces();
|
|
115
|
+
expect(faces.length).toBe(2);
|
|
116
|
+
expect(
|
|
117
|
+
faces
|
|
118
|
+
.map((face) => face.style)
|
|
119
|
+
.sort()
|
|
120
|
+
.join(','),
|
|
121
|
+
).toBe('italic,normal');
|
|
122
|
+
// The RANGE, not `400`. Both TTFs are variable fonts (`fvar`:
|
|
123
|
+
// wght 100-900), and a face declared `font-weight: 400` pins the
|
|
124
|
+
// axis there and leaves the engine to synthesise every bolder
|
|
125
|
+
// weight — while `dist/adwaita-web.css` asks for 700 at 27
|
|
126
|
+
// declarations and 800 at 6. Host-proof: a system-installed face
|
|
127
|
+
// never appears in `document.fonts`, so fontconfig cannot satisfy
|
|
128
|
+
// this even on a GNOME desktop.
|
|
129
|
+
for (const face of faces) expect(face.weight).toBe('100 900');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await it('the faces are self-contained data: URIs, not a relative url()', async () => {
|
|
133
|
+
// The half a `@font-face` COUNT cannot see. Keeping the string alive
|
|
134
|
+
// is not enough: `src: url('./files/adwaita-sans-400.ttf')` resolves
|
|
135
|
+
// against the DOCUMENT, and a `--app browser` build emits one file
|
|
136
|
+
// and no assets, so that rule parses and then 404s.
|
|
137
|
+
const sheet = sheetOf(ADWAITA_FONTS_STYLE_ID);
|
|
138
|
+
expect(sheet).not.toBeNull();
|
|
139
|
+
const rules = fontFaceRules(sheet as CSSStyleSheet);
|
|
140
|
+
expect(rules.length).toBe(2);
|
|
141
|
+
for (const rule of rules) {
|
|
142
|
+
expect(unquote(rule.style.getPropertyValue('font-family'))).toBe(FAMILY);
|
|
143
|
+
expect(rule.style.getPropertyValue('src')).toContain('data:font/ttf;base64,');
|
|
144
|
+
expect(rule.style.getPropertyValue('src')).not.toContain('./files/');
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await it('the engine can LOAD the registered face, not merely see the rule', async () => {
|
|
149
|
+
// The effect, not the state. A `@font-face` whose payload the engine
|
|
150
|
+
// cannot parse sits in `document.fonts` with `status: "error"`, and a
|
|
151
|
+
// rule-count assertion is happy with it.
|
|
152
|
+
const loaded = await document.fonts.load(`400 16px '${FAMILY}'`);
|
|
153
|
+
expect(loaded.length).toBeGreaterThan(0);
|
|
154
|
+
for (const face of loaded) expect(face.status).toBe('loaded');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await it('renders distinct bolds rather than one synthesised weight', async () => {
|
|
158
|
+
// What the declaration alone cannot prove: that the shipped BYTES
|
|
159
|
+
// really carry a `wght` axis. A face clamped to one weight leaves the
|
|
160
|
+
// engine to fake every bold, and a fake is one stroke-widening applied
|
|
161
|
+
// to the same outlines — so 700 and 800 come out PIXEL-IDENTICAL
|
|
162
|
+
// (measured on the clamp: 499.683 px for both, against 517.767 /
|
|
163
|
+
// 533.283 with the range). Comparing 400 against 700 would NOT see
|
|
164
|
+
// that; a synthetic bold is wider than regular too.
|
|
165
|
+
//
|
|
166
|
+
// This one reads the rendered EFFECT, so unlike the assertions above
|
|
167
|
+
// it is not by itself host-proof — a fontconfig-supplied variable
|
|
168
|
+
// Adwaita Sans would also give three widths. Its discriminator is the
|
|
169
|
+
// `face.weight` assertion above, which fontconfig can never satisfy.
|
|
170
|
+
await document.fonts.load(`700 64px '${FAMILY}'`);
|
|
171
|
+
await document.fonts.load(`800 64px '${FAMILY}'`);
|
|
172
|
+
expect(widthAt(700)).not.toBe(widthAt(800));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await it('is idempotent — a second call neither injects nor duplicates', async () => {
|
|
176
|
+
expect(applyAdwaitaFonts()).toBe(false);
|
|
177
|
+
expect(registeredFaces().length).toBe(2);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await it('leaves the document as it found it', async () => {
|
|
181
|
+
document.getElementById(ADWAITA_FONTS_STYLE_ID)?.remove();
|
|
182
|
+
expect(registeredFaces().length).toBe(0);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
} finally {
|
|
186
|
+
document.getElementById(ADWAITA_FONTS_STYLE_ID)?.remove();
|
|
187
|
+
}
|
|
188
|
+
};
|
package/src/adw-icon.spec.ts
CHANGED
|
@@ -37,9 +37,15 @@ export const AdwIconTest = async () => {
|
|
|
37
37
|
// The base class is unconditional: a nameless icon is still an icon box,
|
|
38
38
|
// which `<adw-status-page>` hides rather than omits.
|
|
39
39
|
expect(el.classList.contains('adw-icon')).toBe(true);
|
|
40
|
-
// …and
|
|
41
|
-
// cannot make, where `icon-name="a b"`
|
|
42
|
-
|
|
40
|
+
// …and EXACTLY ONE mask class is on it — the assertion a hand-rolled copy
|
|
41
|
+
// cannot make, where `icon-name="a b"` left a stray `b`. A name that was
|
|
42
|
+
// GIVEN but is unusable falls back to the glyph GTK falls back to, so only
|
|
43
|
+
// an absent or empty name leaves the node classless.
|
|
44
|
+
// Spelled out rather than reusing the impl's own predicate, so the
|
|
45
|
+
// expectation cannot agree with a wrong implementation.
|
|
46
|
+
let drawn = normalized;
|
|
47
|
+
if (drawn === '' && (icon ?? '') !== '') drawn = 'image-missing';
|
|
48
|
+
expect(extraClasses(el)).toStrictEqual(drawn === '' ? [] : [`adw-icon--${drawn}`]);
|
|
43
49
|
host.remove();
|
|
44
50
|
});
|
|
45
51
|
}
|
|
@@ -110,8 +116,9 @@ export const AdwIconTest = async () => {
|
|
|
110
116
|
const { el, host } = mount<AdwMenuButton>('adw-menu-button');
|
|
111
117
|
el.setAttribute('icon-name', 'a b');
|
|
112
118
|
const icon = el.querySelector('.adw-icon') as HTMLElement;
|
|
113
|
-
// `adw-icon--a b` would have added a second, unrelated `b` class.
|
|
114
|
-
|
|
119
|
+
// `adw-icon--a b` would have added a second, unrelated `b` class. What it gets
|
|
120
|
+
// instead is the ONE fallback class, not two tokens and not none.
|
|
121
|
+
expect(extraClasses(icon)).toStrictEqual(['adw-icon--image-missing', 'adw-menu-button-icon']);
|
|
115
122
|
host.remove();
|
|
116
123
|
});
|
|
117
124
|
|
|
@@ -124,9 +131,11 @@ export const AdwIconTest = async () => {
|
|
|
124
131
|
];
|
|
125
132
|
const items = [...el.querySelectorAll('.adw-menu-button-item')];
|
|
126
133
|
expect(items.length).toBe(3);
|
|
127
|
-
// An
|
|
128
|
-
//
|
|
129
|
-
|
|
134
|
+
// An entry that ASKED for an icon keeps its slot even when the name is
|
|
135
|
+
// unusable — it shows the broken glyph, which is what tells the author the
|
|
136
|
+
// name is wrong. Only an entry with no `icon` key gets no node at all.
|
|
137
|
+
const bad = items[0].querySelector('.adw-icon') as HTMLElement;
|
|
138
|
+
expect(extraClasses(bad)).toStrictEqual(['adw-icon--image-missing', 'adw-menu-button-item-icon']);
|
|
130
139
|
expect(items[2].querySelector('.adw-icon')).toBe(null);
|
|
131
140
|
const good = items[1].querySelector('.adw-icon') as HTMLElement;
|
|
132
141
|
expect(extraClasses(good)).toStrictEqual(['adw-icon--view-refresh', 'adw-menu-button-item-icon']);
|
|
@@ -188,7 +188,12 @@ export const AdwRowStateTest = async () => {
|
|
|
188
188
|
expect(buttons.length).toBe(3);
|
|
189
189
|
expect(group.active).toBe(1);
|
|
190
190
|
expect(buttons[1].classList.contains('active')).toBe(true);
|
|
191
|
-
|
|
191
|
+
// `aria-checked`, not `aria-pressed`: upstream declares the group a
|
|
192
|
+
// `RADIO_GROUP` and each toggle a `RADIO` (adw-toggle-group.c:1191, :860),
|
|
193
|
+
// and `aria-pressed` is the independent toolbar toggle-BUTTON pattern. This
|
|
194
|
+
// line pinned the wrong one until the roles landed.
|
|
195
|
+
expect(buttons[1].getAttribute('aria-checked')).toBe('true');
|
|
196
|
+
expect(buttons[1].getAttribute('role')).toBe('radio');
|
|
192
197
|
|
|
193
198
|
buttons[2].click();
|
|
194
199
|
expect(group.active).toBe(2);
|
|
@@ -205,6 +210,11 @@ export const AdwRowStateTest = async () => {
|
|
|
205
210
|
group.active = 0;
|
|
206
211
|
expect(group.active).toBe(0);
|
|
207
212
|
expect(buttons[0].classList.contains('active')).toBe(true);
|
|
213
|
+
// The roving tabindex and the state attribute move with the pill. Reading
|
|
214
|
+
// only `classList` here would pass on a `_render` that repainted the group
|
|
215
|
+
// and left Tab entering on the toggle that is no longer active.
|
|
216
|
+
expect(buttons.map((btn) => btn.tabIndex)).toStrictEqual([0, -1, -1]);
|
|
217
|
+
expect(buttons.map((btn) => btn.getAttribute('aria-checked'))).toStrictEqual(['true', 'false', 'false']);
|
|
208
218
|
expect(events.details.length).toBe(1);
|
|
209
219
|
|
|
210
220
|
events.stop();
|
package/src/breakpoints.ts
CHANGED
|
@@ -46,6 +46,15 @@ export function addBreakpoints(element: Element, breakpoints: readonly AdwBreakp
|
|
|
46
46
|
* of an `Adw.Breakpoint` with one `add_setter()` call. Returns a dispose function; call it
|
|
47
47
|
* before re-binding and on disconnect.
|
|
48
48
|
*
|
|
49
|
+
* `applied` is what the binding being REPLACED had last set, and it is not optional
|
|
50
|
+
* bookkeeping: an element rebinds on every connect, and a fresh `AdwBreakpoint` starts
|
|
51
|
+
* at `false` while firing on transitions only. Without carrying it over, a view the
|
|
52
|
+
* breakpoint had collapsed and that then moves into a WIDE parent evaluates
|
|
53
|
+
* `matches === applied === false` and never hears `onUnapply` — measured stuck at
|
|
54
|
+
* `collapsed` after 800px → 500px → 900px. Pass what the setter last received, never
|
|
55
|
+
* what the attribute currently says: markup that declares the state itself was never
|
|
56
|
+
* this breakpoint's doing and must not be undone by it.
|
|
57
|
+
*
|
|
49
58
|
* A missing or unparsable condition binds nothing and returns a no-op, leaving whatever
|
|
50
59
|
* the attribute already says untouched — an element must not silently flip state because
|
|
51
60
|
* its condition had a typo.
|
|
@@ -54,11 +63,16 @@ export function bindBreakpointSetter(
|
|
|
54
63
|
element: Element,
|
|
55
64
|
condition: string | null,
|
|
56
65
|
setValue: (active: boolean) => void,
|
|
66
|
+
applied = false,
|
|
57
67
|
): () => void {
|
|
58
68
|
if (!condition) return () => {};
|
|
59
|
-
const breakpoint = new AdwBreakpoint(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
69
|
+
const breakpoint = new AdwBreakpoint(
|
|
70
|
+
condition,
|
|
71
|
+
{
|
|
72
|
+
onApply: () => setValue(true),
|
|
73
|
+
onUnapply: () => setValue(false),
|
|
74
|
+
},
|
|
75
|
+
applied,
|
|
76
|
+
);
|
|
63
77
|
return addBreakpoints(element, [breakpoint]);
|
|
64
78
|
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// "Evaluated once at connect time", driven over EVERY element the package registers.
|
|
2
|
+
//
|
|
3
|
+
// THE INCIDENT, in two shapes
|
|
4
|
+
//
|
|
5
|
+
// 1. `<adw-toolbar-view>` documents `topBar` as the imperative append point and then
|
|
6
|
+
// derives `topEl.hidden = childElementCount === 0` inside the `_initialized` guard,
|
|
7
|
+
// never again. A header bar appended AFTER connect therefore sits in the DOM with
|
|
8
|
+
// the right classes at offsetHeight 0 — the compiled sheet ends with a global
|
|
9
|
+
// `[hidden] { display: none !important }`, so the widget's own `display: flex` does
|
|
10
|
+
// not win — while the IDENTICAL declared bar renders at 48px. Six getters across
|
|
11
|
+
// three files had it.
|
|
12
|
+
// 2. Most element classes open `connectedCallback` with an `_initialized` early
|
|
13
|
+
// return. For the ones that also tear something down in `disconnectedCallback`, a
|
|
14
|
+
// bare re-parent — a slideshow slide, a client-side route, moving a node between
|
|
15
|
+
// two containers — dropped the outside binding and never restored it.
|
|
16
|
+
//
|
|
17
|
+
// A per-widget spec is one more spec per widget to forget, and both shapes were found
|
|
18
|
+
// by reading rather than by a failing test. So neither list here is written down:
|
|
19
|
+
// {@link registeredElements} reads the CustomElementRegistry and {@link containerGetters}
|
|
20
|
+
// reads the accessor shape, so a container written in that shape enrols itself.
|
|
21
|
+
//
|
|
22
|
+
// Its static sibling is `scripts/check-adwaita-connect-rebind.mjs`, which holds shape 2
|
|
23
|
+
// at the source level, where a missing rebind is visible without a widget that shows it.
|
|
24
|
+
import { describe, expect, it } from '@gjsify/unit';
|
|
25
|
+
|
|
26
|
+
import * as adwaitaWeb from './index.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Elements that cannot RENDER a child, however the DOM lets you append one. A container
|
|
30
|
+
* getter returning one of these is an imperative handle (`<adw-entry>.input`, for
|
|
31
|
+
* focus and selection), not an append point, and appending to it measures 0 forever.
|
|
32
|
+
* The HTML void-element set, not a per-widget ledger — a new container still enrols
|
|
33
|
+
* itself; only a new void element in the spec would need a line here.
|
|
34
|
+
*/
|
|
35
|
+
const NON_RENDERING = new Set([
|
|
36
|
+
'input',
|
|
37
|
+
'img',
|
|
38
|
+
'br',
|
|
39
|
+
'hr',
|
|
40
|
+
'area',
|
|
41
|
+
'base',
|
|
42
|
+
'col',
|
|
43
|
+
'embed',
|
|
44
|
+
'link',
|
|
45
|
+
'meta',
|
|
46
|
+
'source',
|
|
47
|
+
'track',
|
|
48
|
+
'wbr',
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Undo the two states in which a correctly-built widget legitimately reads
|
|
53
|
+
* `display: none`, so the driver measures the widget and not the state. Both cost a
|
|
54
|
+
* reviewer real time: a driver without this goes red on correct code, which is worse
|
|
55
|
+
* than the bug it hunts.
|
|
56
|
+
*
|
|
57
|
+
* Asked of the ELEMENT, not looked up per tag: every dialog answers `present()`, so a
|
|
58
|
+
* fifth one enrols itself instead of failing the day it grows a container getter. The
|
|
59
|
+
* expander is keyed by tag because "collapsed" is its own idea and nothing else has it.
|
|
60
|
+
*
|
|
61
|
+
* Everything else is expected to lay a child out with no setup at all, which is what
|
|
62
|
+
* "append here imperatively" promises.
|
|
63
|
+
*/
|
|
64
|
+
function reveal(el: HTMLElement): void {
|
|
65
|
+
// `<adw-dialog>.contentArea` and every dialog after it are merely not PRESENTED —
|
|
66
|
+
// `display: none` until `present()`, exactly as `Adw.Dialog` is until it is pushed.
|
|
67
|
+
const dialog = el as HTMLElement & { present?: () => void };
|
|
68
|
+
if (typeof dialog.present === 'function') dialog.present();
|
|
69
|
+
// `<adw-expander-row>.contentSection` is merely COLLAPSED — it is the disclosure.
|
|
70
|
+
if (el.localName === 'adw-expander-row') el.setAttribute('expanded', '');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every tag the package registers, asked of the registry that registered them, next to
|
|
75
|
+
* every exported element class the registry does NOT know.
|
|
76
|
+
*
|
|
77
|
+
* The second half is a `define` that was dropped or renamed: those tags would silently
|
|
78
|
+
* leave this whole driver. It is REPORTED rather than asserted here, because this runs
|
|
79
|
+
* at namespace level — an `expect` that throws out here escapes `runTests`, and the
|
|
80
|
+
* runner's promise chain never reaches `browserSignalDone()`, so the page never sets
|
|
81
|
+
* `data-tests-done` and Playwright kills the leg on a timeout naming no test. This
|
|
82
|
+
* namespace is registered FIRST, so that would take all of adwaita-web's browser
|
|
83
|
+
* assertions with it and report none of them.
|
|
84
|
+
*/
|
|
85
|
+
function registeredElements(): { tags: string[]; unregistered: string[] } {
|
|
86
|
+
const tags: string[] = [];
|
|
87
|
+
const unregistered: string[] = [];
|
|
88
|
+
for (const exported of Object.values(adwaitaWeb)) {
|
|
89
|
+
if (typeof exported !== 'function' || !(exported.prototype instanceof HTMLElement)) continue;
|
|
90
|
+
const tag = customElements.getName(exported as CustomElementConstructor);
|
|
91
|
+
if (tag === null) unregistered.push(exported.name);
|
|
92
|
+
else tags.push(tag);
|
|
93
|
+
}
|
|
94
|
+
return { tags: tags.sort(), unregistered: unregistered.sort() };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The containers a consumer is invited to append into, derived from the accessor SHAPE:
|
|
99
|
+
* a getter with NO setter whose value is an element this one already CONTAINS.
|
|
100
|
+
*
|
|
101
|
+
* Each half earns its place. No setter, because a container you append into is not one
|
|
102
|
+
* you replace by assignment — that is what separates `topBar` from `anchor` and
|
|
103
|
+
* `activatableWidget`. Contained, because the handle has to be part of THIS widget's
|
|
104
|
+
* own subtree — `<adw-popover>.anchor` defaults to `parentElement` and is not. And a
|
|
105
|
+
* value that is not an element at all (`items`, `groups`, a `null` slot on an empty
|
|
106
|
+
* widget) is nothing to append to.
|
|
107
|
+
*
|
|
108
|
+
* So this enrols the SHAPE the six containers of the incident have, not every possible
|
|
109
|
+
* one: a container exposed as a get/set pair, or one the widget only attaches once it
|
|
110
|
+
* has children, is invisible here and needs its own coverage.
|
|
111
|
+
*/
|
|
112
|
+
function containerGetters(el: HTMLElement): Array<[string, HTMLElement]> {
|
|
113
|
+
const found: Array<[string, HTMLElement]> = [];
|
|
114
|
+
for (let proto = Object.getPrototypeOf(el); proto && proto !== HTMLElement.prototype;) {
|
|
115
|
+
for (const [name, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(proto))) {
|
|
116
|
+
if (typeof descriptor.get !== 'function' || descriptor.set !== undefined) continue;
|
|
117
|
+
const value: unknown = descriptor.get.call(el);
|
|
118
|
+
if (!(value instanceof HTMLElement) || value === el || !el.contains(value)) continue;
|
|
119
|
+
if (NON_RENDERING.has(value.localName)) continue;
|
|
120
|
+
found.push([name, value]);
|
|
121
|
+
}
|
|
122
|
+
proto = Object.getPrototypeOf(proto);
|
|
123
|
+
}
|
|
124
|
+
return found.sort(([a], [b]) => a.localeCompare(b));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A visible host of a known size, so a collapsed child cannot hide behind a 0-width page. */
|
|
128
|
+
function visibleHost(): HTMLElement {
|
|
129
|
+
const host = document.createElement('div');
|
|
130
|
+
host.style.width = '800px';
|
|
131
|
+
host.style.height = '600px';
|
|
132
|
+
document.body.appendChild(host);
|
|
133
|
+
return host;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A slotted split-view pane, so the two slot names are spelled once and not inside markup. */
|
|
137
|
+
function pane(slot: string): HTMLElement {
|
|
138
|
+
const el = document.createElement('div');
|
|
139
|
+
el.setAttribute('slot', slot);
|
|
140
|
+
el.textContent = slot;
|
|
141
|
+
return el;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A child with a size of its OWN, so the box measured is the container's answer, not the content's. */
|
|
145
|
+
function probe(): HTMLElement {
|
|
146
|
+
const box = document.createElement('div');
|
|
147
|
+
box.style.width = '24px';
|
|
148
|
+
box.style.height = '24px';
|
|
149
|
+
// A flex container would otherwise shrink it to nothing and the measurement would
|
|
150
|
+
// say "collapsed" about a container that is perfectly fine.
|
|
151
|
+
box.style.flex = '0 0 auto';
|
|
152
|
+
return box;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Two frames: one for the append to land, one for anything a MutationObserver schedules. */
|
|
156
|
+
async function settle(): Promise<void> {
|
|
157
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** `laid out`, or the real numbers — a bare `false` names neither the widget nor the size. */
|
|
161
|
+
function verdict(el: HTMLElement): string {
|
|
162
|
+
const box = el.getBoundingClientRect();
|
|
163
|
+
if (box.width <= 0 || box.height <= 0) return `collapsed ${Math.round(box.width)}x${Math.round(box.height)}`;
|
|
164
|
+
// A box is not a pixel. `visibility` INHERITS, so a container that hid its subtree
|
|
165
|
+
// that way leaves the child measuring its full 24x24 — one read on the CHILD is
|
|
166
|
+
// enough to tell the two apart, and no walk up the tree is needed.
|
|
167
|
+
if (getComputedStyle(el).visibility === 'hidden') return 'invisible';
|
|
168
|
+
const onScreen = box.bottom > 0 && box.top < window.innerHeight && box.right > 0 && box.left < window.innerWidth;
|
|
169
|
+
return onScreen ? 'laid out' : `off-screen at ${Math.round(box.left)},${Math.round(box.top)}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `same box`, or both sizes — the numbers are what says whether a difference is real. */
|
|
173
|
+
function boxDelta(a: HTMLElement, b: HTMLElement): string {
|
|
174
|
+
const x = a.getBoundingClientRect();
|
|
175
|
+
const y = b.getBoundingClientRect();
|
|
176
|
+
const same = Math.abs(x.width - y.width) < 1 && Math.abs(x.height - y.height) < 1;
|
|
177
|
+
return same
|
|
178
|
+
? 'same box'
|
|
179
|
+
: `${x.width.toFixed(2)}x${x.height.toFixed(2)} vs ${y.width.toFixed(2)}x${y.height.toFixed(2)}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The rendered SHAPE of a subtree — tags, classes and what is hidden, no text and no
|
|
184
|
+
* generated ids. It is what a re-parent must not change: two elements built the same
|
|
185
|
+
* way differ here only because one of them derived something from where it was built.
|
|
186
|
+
*/
|
|
187
|
+
function skeleton(el: Element): string {
|
|
188
|
+
const classes = [...el.classList].sort().join('.');
|
|
189
|
+
const hidden = (el as HTMLElement).hidden ? '[hidden]' : '';
|
|
190
|
+
const children = [...el.children].map(skeleton).join(',');
|
|
191
|
+
return `${el.localName}${classes === '' ? '' : `.${classes}`}${hidden}(${children})`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export const AdwConnectLifecycleTest = async () => {
|
|
195
|
+
const { tags, unregistered } = registeredElements();
|
|
196
|
+
|
|
197
|
+
await describe('imperative append points', async () => {
|
|
198
|
+
await it('finds the elements and their containers', async () => {
|
|
199
|
+
// A dropped or renamed `define`, named as ONE failing test: the tags it
|
|
200
|
+
// carries would otherwise leave this driver with nothing going red.
|
|
201
|
+
expect(`unregistered: ${unregistered.join(', ')}`).toBe('unregistered: ');
|
|
202
|
+
// A scan that finds nothing passes vacuously, and every `it` below it would
|
|
203
|
+
// report a green it never earned. Floors, not exact counts: the point of the
|
|
204
|
+
// derivation is that both numbers grow without this file being edited.
|
|
205
|
+
expect(tags.length > 40).toBe(true);
|
|
206
|
+
const containers = tags.flatMap((tag) => {
|
|
207
|
+
const host = visibleHost();
|
|
208
|
+
const el = document.createElement(tag);
|
|
209
|
+
host.appendChild(el);
|
|
210
|
+
reveal(el);
|
|
211
|
+
const names = containerGetters(el).map(([name]) => `${tag}.${name}`);
|
|
212
|
+
host.remove();
|
|
213
|
+
return names;
|
|
214
|
+
});
|
|
215
|
+
expect(containers.length > 5).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
for (const tag of tags) {
|
|
219
|
+
await it(`<${tag}> lays out a child appended after connect`, async () => {
|
|
220
|
+
const host = visibleHost();
|
|
221
|
+
const el = document.createElement(tag);
|
|
222
|
+
host.appendChild(el);
|
|
223
|
+
reveal(el);
|
|
224
|
+
|
|
225
|
+
const containers = containerGetters(el);
|
|
226
|
+
const probes = containers.map(([name, container]) => {
|
|
227
|
+
const child = probe();
|
|
228
|
+
container.appendChild(child);
|
|
229
|
+
return [name, child] as const;
|
|
230
|
+
});
|
|
231
|
+
await settle();
|
|
232
|
+
|
|
233
|
+
const report = probes.map(([name, child]) => `${name}: ${verdict(child)}`).join(' | ');
|
|
234
|
+
const wanted = probes.map(([name]) => `${name}: laid out`).join(' | ');
|
|
235
|
+
// Measure, THEN tear down, THEN assert: a failing expectation throws, and
|
|
236
|
+
// a host left on the page pushes every later one down until the next
|
|
237
|
+
// widget is measured off-screen. That cascade turned one real failure
|
|
238
|
+
// into four, three of which named the wrong widget.
|
|
239
|
+
host.remove();
|
|
240
|
+
expect(`<${tag}> ${report}`).toBe(`<${tag}> ${wanted}`);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await describe('re-parent', async () => {
|
|
246
|
+
for (const tag of tags) {
|
|
247
|
+
await it(`<${tag}> survives a move out of a display:none parent`, async () => {
|
|
248
|
+
// The reproduction shape the incident named: built where there is no
|
|
249
|
+
// layout at all, then moved somewhere there is.
|
|
250
|
+
const dark = visibleHost();
|
|
251
|
+
dark.style.display = 'none';
|
|
252
|
+
const moved = document.createElement(tag);
|
|
253
|
+
dark.appendChild(moved);
|
|
254
|
+
reveal(moved);
|
|
255
|
+
|
|
256
|
+
// THREE hosts, one widget each. Put the two in one host and the row
|
|
257
|
+
// stylesheets separate them for us: `:last-child` drops the hairline, so
|
|
258
|
+
// the reference row measured 52px and the moved one 51px — a difference
|
|
259
|
+
// in the SETUP, reported against `<adw-expander-row>` and `<adw-spin-row>`.
|
|
260
|
+
const control = visibleHost();
|
|
261
|
+
const fresh = document.createElement(tag);
|
|
262
|
+
control.appendChild(fresh);
|
|
263
|
+
reveal(fresh);
|
|
264
|
+
|
|
265
|
+
const light = visibleHost();
|
|
266
|
+
light.appendChild(moved);
|
|
267
|
+
reveal(moved);
|
|
268
|
+
await settle();
|
|
269
|
+
|
|
270
|
+
// Built in the dark and moved into the light, a widget must be
|
|
271
|
+
// indistinguishable from one built here — same rendered shape, same box.
|
|
272
|
+
// BOTH skeletons on a mismatch: one of them alone leaves the reader to
|
|
273
|
+
// reproduce the run to find out what actually differed.
|
|
274
|
+
const shape =
|
|
275
|
+
skeleton(moved) === skeleton(fresh) ? 'same shape' : `${skeleton(moved)} vs ${skeleton(fresh)}`;
|
|
276
|
+
const size = boxDelta(moved, fresh);
|
|
277
|
+
dark.remove();
|
|
278
|
+
control.remove();
|
|
279
|
+
light.remove();
|
|
280
|
+
expect(`<${tag}> ${shape}`).toBe(`<${tag}> same shape`);
|
|
281
|
+
expect(`<${tag}> ${size}`).toBe(`<${tag}> same box`);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Shape and box are blind to a binding that reaches OUTSIDE the element — a dead
|
|
287
|
+
// MediaQuery or document listener changes nothing until something fires it. These
|
|
288
|
+
// two fire it. Both split views are driven, the overlay one as the control: it
|
|
289
|
+
// already split `_buildOnce` from `_bindToDocument`, so a probe that cannot tell the
|
|
290
|
+
// two apart is measuring nothing.
|
|
291
|
+
await describe('outside bindings after a re-parent', async () => {
|
|
292
|
+
for (const tag of ['adw-navigation-split-view', 'adw-overlay-split-view']) {
|
|
293
|
+
await it(`<${tag}> still tracks its breakpoint after a move`, async () => {
|
|
294
|
+
const first = visibleHost();
|
|
295
|
+
const view = document.createElement(tag);
|
|
296
|
+
view.setAttribute('breakpoint', 'max-width: 720px');
|
|
297
|
+
view.append(pane('sidebar'), pane('content'));
|
|
298
|
+
first.appendChild(view);
|
|
299
|
+
await settle();
|
|
300
|
+
const wide = view.hasAttribute('collapsed') ? 'collapsed' : 'expanded';
|
|
301
|
+
|
|
302
|
+
const second = visibleHost();
|
|
303
|
+
second.appendChild(view);
|
|
304
|
+
second.style.width = '500px';
|
|
305
|
+
await settle();
|
|
306
|
+
const narrow = view.hasAttribute('collapsed') ? 'collapsed' : 'expanded';
|
|
307
|
+
|
|
308
|
+
// BOTH directions, because only one of them needs the carried state.
|
|
309
|
+
// Collapsing after a move works off a fresh breakpoint (false → true is a
|
|
310
|
+
// transition); coming back WIDE does not, and a breakpoint rebuilt at
|
|
311
|
+
// `applied = false` matches its own start state and never unapplies. The
|
|
312
|
+
// view stayed collapsed at 900px in both engines until the rebind carried
|
|
313
|
+
// what the last one had set.
|
|
314
|
+
const third = visibleHost();
|
|
315
|
+
third.style.width = '900px';
|
|
316
|
+
third.appendChild(view);
|
|
317
|
+
await settle();
|
|
318
|
+
const again = view.hasAttribute('collapsed') ? 'collapsed' : 'expanded';
|
|
319
|
+
|
|
320
|
+
first.remove();
|
|
321
|
+
second.remove();
|
|
322
|
+
third.remove();
|
|
323
|
+
expect(`<${tag}> 800px:${wide} → 500px:${narrow} → 900px:${again}`).toBe(
|
|
324
|
+
`<${tag}> 800px:expanded → 500px:collapsed → 900px:expanded`,
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
await it('<adw-popover> still dismisses on an outside click after a move', async () => {
|
|
330
|
+
const first = visibleHost();
|
|
331
|
+
const popover = document.createElement('adw-popover');
|
|
332
|
+
first.appendChild(popover);
|
|
333
|
+
(popover as HTMLElement & { popup(): void }).popup();
|
|
334
|
+
|
|
335
|
+
const second = visibleHost();
|
|
336
|
+
second.appendChild(popover);
|
|
337
|
+
const outside = visibleHost();
|
|
338
|
+
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
|
339
|
+
const afterClick = popover.hasAttribute('open') ? 'still open' : 'dismissed';
|
|
340
|
+
|
|
341
|
+
(popover as HTMLElement & { popup(): void }).popup();
|
|
342
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
343
|
+
const afterEscape = popover.hasAttribute('open') ? 'still open' : 'dismissed';
|
|
344
|
+
|
|
345
|
+
first.remove();
|
|
346
|
+
second.remove();
|
|
347
|
+
outside.remove();
|
|
348
|
+
expect(`click:${afterClick} escape:${afterEscape}`).toBe('click:dismissed escape:dismissed');
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
};
|