@gjsify/adwaita-web 0.40.0 → 0.42.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 +142 -63
- 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 +48 -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-tab-view.d.ts +23 -0
- package/lib/types/elements/adw-toggle-group.d.ts +2 -0
- package/lib/types/elements/adw-toolbar-view.d.ts +8 -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/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 +3 -1
- package/lib/types/keyboard-operable.spec.d.ts +1 -0
- package/lib/types/scroll-shading.d.ts +56 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +10 -6
- package/scss/_banner.scss +1 -1
- package/scss/_carousel.scss +15 -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/_headerbar.scss +35 -6
- package/scss/_icon.scss +47 -0
- package/scss/_icons.generated.scss +36 -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/_scrolling.scss +107 -0
- package/scss/_status_page.scss +1 -1
- package/scss/_style_classes.scss +21 -8
- package/scss/_tab_view.scss +1 -1
- package/scss/_theme.scss +1 -0
- package/scss/_toast.scss +6 -24
- package/scss/_toolbar_view.scss +52 -22
- package/scss/_variables.scss +30 -3
- package/scss/_view_switcher.scss +80 -20
- package/scss/adwaita-skin.scss +1 -0
- package/src/adw-action-rows.spec.ts +21 -9
- package/src/adw-button-content.spec.ts +11 -6
- package/src/adw-carousel.spec.ts +141 -1
- package/src/adw-fonts.spec.ts +188 -0
- package/src/adw-header-bar.spec.ts +92 -0
- package/src/adw-icon.spec.ts +17 -8
- package/src/adw-row-state.spec.ts +11 -1
- package/src/adw-tab-view.spec.ts +151 -0
- package/src/bottom-sheet.spec.ts +87 -0
- package/src/breakpoints.ts +18 -4
- package/src/chrome.spec.ts +233 -0
- 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 +121 -4
- 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 +70 -7
- 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 +28 -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/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 +30 -1
- package/src/keyboard-operable.spec.ts +755 -0
- package/src/scroll-shading.ts +255 -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
- package/src/view-switcher.spec.ts +149 -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
|
+
};
|
|
@@ -36,6 +36,47 @@ function centerWidget(text: string): HTMLElement {
|
|
|
36
36
|
/** Remove every header bar a test mounted. */
|
|
37
37
|
function unmountAll(): void {
|
|
38
38
|
for (const bar of Array.from(document.querySelectorAll('adw-header-bar'))) bar.remove();
|
|
39
|
+
for (const host of Array.from(document.querySelectorAll('[data-header-bar-host]'))) host.remove();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The long title both geometry tests use, so the narrow bar really has to give way. */
|
|
43
|
+
const LONG_TITLE = 'A very very long document title indeed';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A header bar of a fixed width, one start button and two end buttons.
|
|
47
|
+
*
|
|
48
|
+
* The asymmetry is the point: it is what tells "centred on the BAR" apart from
|
|
49
|
+
* "centred in the space the buttons left over".
|
|
50
|
+
*/
|
|
51
|
+
function mountSizedHeaderBar(width: number): HTMLElement {
|
|
52
|
+
const host = document.createElement('div');
|
|
53
|
+
host.setAttribute('data-header-bar-host', '');
|
|
54
|
+
host.style.width = `${width}px`;
|
|
55
|
+
document.body.appendChild(host);
|
|
56
|
+
|
|
57
|
+
const bar = document.createElement('adw-header-bar');
|
|
58
|
+
bar.setAttribute('title', LONG_TITLE);
|
|
59
|
+
for (const [slot, count] of [
|
|
60
|
+
['start', 1],
|
|
61
|
+
['end', 2],
|
|
62
|
+
] as const) {
|
|
63
|
+
for (let i = 0; i < count; i++) {
|
|
64
|
+
const button = document.createElement('button');
|
|
65
|
+
button.className = 'adw-header-btn';
|
|
66
|
+
button.setAttribute('slot', slot);
|
|
67
|
+
button.textContent = '+';
|
|
68
|
+
bar.appendChild(button);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Appended LAST: the sections are built in `connectedCallback`, so the slotted
|
|
72
|
+
// children have to be in place before the bar enters the document.
|
|
73
|
+
host.appendChild(bar);
|
|
74
|
+
return bar;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The rect of one part of a mounted header bar. */
|
|
78
|
+
function rectOf(bar: HTMLElement, selector: string): DOMRect {
|
|
79
|
+
return (bar.querySelector(selector) as HTMLElement).getBoundingClientRect();
|
|
39
80
|
}
|
|
40
81
|
|
|
41
82
|
export const AdwHeaderBarTest = async () => {
|
|
@@ -113,4 +154,55 @@ export const AdwHeaderBarTest = async () => {
|
|
|
113
154
|
unmountAll();
|
|
114
155
|
});
|
|
115
156
|
});
|
|
157
|
+
|
|
158
|
+
// `Adw.HeaderBar` lays its three sections out with a `GtkCenterBox`
|
|
159
|
+
// (adw-header-bar.c:992), whose two rules pull in opposite directions and were
|
|
160
|
+
// each broken on their own once. Both are asserted here, because a fix for
|
|
161
|
+
// either one is exactly what breaks the other:
|
|
162
|
+
//
|
|
163
|
+
// - the centre is centred on the WHOLE bar, not in the space the sides left
|
|
164
|
+
// over, so a bar with unequal sides still shows its title in the middle;
|
|
165
|
+
// - `shrink_center_last` is FALSE, so the centre is clamped to
|
|
166
|
+
// `size - (start.natural + end.natural)` and pushed in from either side
|
|
167
|
+
// (gtkcenterlayout.c:155-157). The TITLE gives way, never the buttons.
|
|
168
|
+
await describe('<adw-header-bar> centre placement (GtkCenterBox)', async () => {
|
|
169
|
+
await it('centres the title on the BAR even when the two sides differ', () => {
|
|
170
|
+
const bar = mountSizedHeaderBar(600);
|
|
171
|
+
const barBox = bar.getBoundingClientRect();
|
|
172
|
+
const start = rectOf(bar, '.adw-header-bar-start');
|
|
173
|
+
const end = rectOf(bar, '.adw-header-bar-end');
|
|
174
|
+
const title = rectOf(bar, '.adw-window-title-title');
|
|
175
|
+
|
|
176
|
+
// The discriminator: with equal sides, centring on the bar and centring
|
|
177
|
+
// between the buttons are the same answer and this proves nothing.
|
|
178
|
+
expect(Math.round(end.width) > Math.round(start.width)).toBe(true);
|
|
179
|
+
|
|
180
|
+
const off = Math.abs((title.left + title.right) / 2 - (barBox.left + barBox.right) / 2);
|
|
181
|
+
expect(off <= 1).toBe(true);
|
|
182
|
+
unmountAll();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await it('ellipsizes instead of painting the title over the buttons', () => {
|
|
186
|
+
const bar = mountSizedHeaderBar(260);
|
|
187
|
+
const barBox = bar.getBoundingClientRect();
|
|
188
|
+
const start = rectOf(bar, '.adw-header-bar-start');
|
|
189
|
+
const end = rectOf(bar, '.adw-header-bar-end');
|
|
190
|
+
const centre = rectOf(bar, '.adw-header-bar-center');
|
|
191
|
+
const titleEl = bar.querySelector('.adw-window-title-title') as HTMLElement;
|
|
192
|
+
|
|
193
|
+
// The discriminator: the untruncated title really is wider than the whole
|
|
194
|
+
// bar, so there is a collision to avoid in the first place.
|
|
195
|
+
expect(titleEl.scrollWidth > Math.round(barBox.width)).toBe(true);
|
|
196
|
+
|
|
197
|
+
// A centred grid item is sized `fit-content`, whose floor is its own
|
|
198
|
+
// min-content — the whole `nowrap` string. That drew the centre section
|
|
199
|
+
// 277px wide in a 260px bar, starting outside it and running over all
|
|
200
|
+
// three buttons. Stretched into its track it can only be the leftover.
|
|
201
|
+
expect(centre.left >= start.right - 0.5).toBe(true);
|
|
202
|
+
expect(centre.right <= end.left + 0.5).toBe(true);
|
|
203
|
+
expect(centre.left >= barBox.left - 0.5 && centre.right <= barBox.right + 0.5).toBe(true);
|
|
204
|
+
expect(titleEl.scrollWidth > titleEl.clientWidth).toBe(true);
|
|
205
|
+
unmountAll();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
116
208
|
};
|
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/adw-tab-view.spec.ts
CHANGED
|
@@ -8,6 +8,10 @@ import { describe, expect, it } from '@gjsify/unit';
|
|
|
8
8
|
|
|
9
9
|
import type { AdwTabView } from './elements/adw-tab-view.js';
|
|
10
10
|
|
|
11
|
+
function chips(view: AdwTabView): HTMLButtonElement[] {
|
|
12
|
+
return Array.from(view.querySelectorAll('.adw-tab')) as HTMLButtonElement[];
|
|
13
|
+
}
|
|
14
|
+
|
|
11
15
|
function makeTabView(attrs = ''): AdwTabView {
|
|
12
16
|
const host = document.createElement('div');
|
|
13
17
|
host.innerHTML = `<adw-tab-view ${attrs}>
|
|
@@ -111,4 +115,151 @@ export const AdwTabViewTest = async () => {
|
|
|
111
115
|
view.parentElement?.remove();
|
|
112
116
|
});
|
|
113
117
|
});
|
|
118
|
+
|
|
119
|
+
// Switching a tab scrolls the TAB STRIP and nothing else. In C that is not a rule
|
|
120
|
+
// anyone has to keep, it is the shape of the API: `scroll_to_tab_full` writes
|
|
121
|
+
// `self->adjustment`, the strip's own adjustment
|
|
122
|
+
// (refs/libadwaita/src/adw-tab-box.c:928-961), and `select_page` is its only caller
|
|
123
|
+
// on a selection (:1728). The DOM has no equally narrow move, because `focus()` and
|
|
124
|
+
// `scrollIntoView()` both walk every scrollable ancestor up to the window, so the
|
|
125
|
+
// port handed the document a 6567px jump on a single ArrowRight, measured in a
|
|
126
|
+
// documentation page long enough to have somewhere to jump to.
|
|
127
|
+
await describe('adw-tab-view keyboard selection scrolls the STRIP, not the page', async () => {
|
|
128
|
+
const BAR_WIDTH = 220;
|
|
129
|
+
const CHIP_WIDTH = 90;
|
|
130
|
+
|
|
131
|
+
/** A view parked far below the fold, so a window scroll is the only way to reveal it. */
|
|
132
|
+
function makeFarBelowTheFold(count = 6): { view: AdwTabView; host: HTMLElement } {
|
|
133
|
+
const host = document.createElement('div');
|
|
134
|
+
const spacer = document.createElement('div');
|
|
135
|
+
spacer.style.height = '4000px';
|
|
136
|
+
host.appendChild(spacer);
|
|
137
|
+
const inner = document.createElement('div');
|
|
138
|
+
inner.style.width = `${BAR_WIDTH}px`;
|
|
139
|
+
host.appendChild(inner);
|
|
140
|
+
document.body.appendChild(host);
|
|
141
|
+
|
|
142
|
+
const view = document.createElement('adw-tab-view') as AdwTabView;
|
|
143
|
+
inner.appendChild(view);
|
|
144
|
+
for (const id of 'abcdefgh'.slice(0, count)) view.appendPage({ id, title: `Page ${id}` });
|
|
145
|
+
return { view, host };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
await it('ArrowRight moves focus without moving the window', async () => {
|
|
149
|
+
const { view, host } = makeFarBelowTheFold();
|
|
150
|
+
window.scrollTo(0, 0);
|
|
151
|
+
// The precondition IS the test: with the widget already on screen the
|
|
152
|
+
// browser would not scroll either way, and this would pass on the bug.
|
|
153
|
+
expect(view.getBoundingClientRect().top > window.innerHeight).toBe(true);
|
|
154
|
+
|
|
155
|
+
const before = window.scrollY;
|
|
156
|
+
chips(view)[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
|
157
|
+
|
|
158
|
+
expect(view.selectedId).toBe('b');
|
|
159
|
+
// Focus still travels with the roving tabindex; what changed is where the
|
|
160
|
+
// browser may scroll, not whether the chip is focused.
|
|
161
|
+
expect(document.activeElement).toBe(chips(view)[1]);
|
|
162
|
+
expect(window.scrollY).toBe(before);
|
|
163
|
+
|
|
164
|
+
host.remove();
|
|
165
|
+
window.scrollTo(0, 0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await it('End brings the last chip into the bar by scrolling the BAR', async () => {
|
|
169
|
+
const { view, host } = makeFarBelowTheFold();
|
|
170
|
+
window.scrollTo(0, 0);
|
|
171
|
+
const bar = view.querySelector('.adw-tab-bar') as HTMLElement;
|
|
172
|
+
// Pin the chip widths: a chip is a flex item that shrinks to whatever the
|
|
173
|
+
// strip has, so without this the six of them fit and `scroll_to_tab_full`
|
|
174
|
+
// has nothing to do, and the assertions below would hold vacuously.
|
|
175
|
+
for (const chip of chips(view)) chip.style.flex = '0 0 90px';
|
|
176
|
+
expect(bar.scrollWidth > bar.clientWidth).toBe(true);
|
|
177
|
+
expect(bar.scrollLeft).toBe(0);
|
|
178
|
+
|
|
179
|
+
const before = window.scrollY;
|
|
180
|
+
chips(view)[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
|
|
181
|
+
|
|
182
|
+
expect(view.selectedId).toBe('f');
|
|
183
|
+
expect(bar.scrollLeft > 0).toBe(true);
|
|
184
|
+
const last = chips(view)[5].getBoundingClientRect();
|
|
185
|
+
const box = bar.getBoundingClientRect();
|
|
186
|
+
expect(Math.round(last.right) <= Math.round(box.right)).toBe(true);
|
|
187
|
+
expect(window.scrollY).toBe(before);
|
|
188
|
+
|
|
189
|
+
host.remove();
|
|
190
|
+
window.scrollTo(0, 0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// The two above both press a key, so both would still pass if the strip scroll
|
|
194
|
+
// lived in the key handler and the padding term were dropped. Neither is what C
|
|
195
|
+
// does: `notify::selected-page` reaches `select_page` through the bar
|
|
196
|
+
// (adw-tab-bar.c:934 -> adw-tab-box.c:1728), so EVERY selection scrolls the
|
|
197
|
+
// strip, and `scroll_to_tab_full` parks the chip `padding` short of the edge
|
|
198
|
+
// rather than flush against it (adw-tab-box.c:952-959).
|
|
199
|
+
await it('a programmatic selection parks the chip a padding short of the edge', async () => {
|
|
200
|
+
const { view, host } = makeFarBelowTheFold(8);
|
|
201
|
+
window.scrollTo(0, 0);
|
|
202
|
+
const bar = view.querySelector('.adw-tab-bar') as HTMLElement;
|
|
203
|
+
for (const chip of chips(view)) {
|
|
204
|
+
// `min-width: 0` as well as the basis: a flex item's automatic minimum
|
|
205
|
+
// size is its MIN-CONTENT size, and the selected chip carries a close
|
|
206
|
+
// affordance the others hide, so without this the six-point-something
|
|
207
|
+
// pixels it adds land in the middle of the arithmetic under test.
|
|
208
|
+
chip.style.flex = `0 0 ${CHIP_WIDTH}px`;
|
|
209
|
+
chip.style.minWidth = '0';
|
|
210
|
+
}
|
|
211
|
+
expect(bar.scrollLeft).toBe(0);
|
|
212
|
+
|
|
213
|
+
const before = window.scrollY;
|
|
214
|
+
// No keypress and no click: the model is driven directly.
|
|
215
|
+
expect(view.setSelectedPage('d')).toBe(true);
|
|
216
|
+
|
|
217
|
+
const pageSize = bar.clientWidth;
|
|
218
|
+
// `padding = MIN (tab_width, page_size - tab_width) / 2` (adw-tab-box.c:952).
|
|
219
|
+
const padding = Math.min(CHIP_WIDTH, pageSize - CHIP_WIDTH) / 2;
|
|
220
|
+
// Chip 4 of 8 is mid-strip, so the computed offset is inside the adjustment's
|
|
221
|
+
// range: an assertion the clamp could satisfy on its own proves nothing.
|
|
222
|
+
expect(bar.scrollLeft > 0 && bar.scrollLeft < bar.scrollWidth - pageSize).toBe(true);
|
|
223
|
+
const chip = chips(view)[3].getBoundingClientRect();
|
|
224
|
+
expect(Math.round(bar.getBoundingClientRect().right - chip.right)).toBe(padding);
|
|
225
|
+
expect(window.scrollY).toBe(before);
|
|
226
|
+
|
|
227
|
+
host.remove();
|
|
228
|
+
window.scrollTo(0, 0);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// The same coordinate space, one call site over, and it was wrong there too.
|
|
233
|
+
// `update_visible` (adw-tab-box.c:769-797) decides `fully_visible` from `pos` against
|
|
234
|
+
// the adjustment, exactly as `scroll_to_tab_full` does, and the close affordance
|
|
235
|
+
// hangs off it: `show_close = (hovering && fully_visible) || selected || dragging`
|
|
236
|
+
// (adw-tab.c:124). Measured with `offsetLeft` instead, the comparison ran against
|
|
237
|
+
// whichever positioned ancestor the HOST page happened to have, so a tab view that
|
|
238
|
+
// merely sat indented reported every chip clipped and swallowed the close button on
|
|
239
|
+
// hover.
|
|
240
|
+
await describe('adw-tab-view close affordance is measured against the BAR', async () => {
|
|
241
|
+
await it('hovering an indented but fully visible chip shows its close button', async () => {
|
|
242
|
+
const host = document.createElement('div');
|
|
243
|
+
// The whole defect: an offset between the chip's offsetParent and the bar.
|
|
244
|
+
// Nothing in the widget is positioned, so this lands in `offsetLeft`.
|
|
245
|
+
host.style.marginLeft = '300px';
|
|
246
|
+
host.style.width = '400px';
|
|
247
|
+
document.body.appendChild(host);
|
|
248
|
+
const view = document.createElement('adw-tab-view') as AdwTabView;
|
|
249
|
+
host.appendChild(view);
|
|
250
|
+
for (const id of ['a', 'b', 'c']) view.appendPage({ id, title: `Page ${id}` });
|
|
251
|
+
|
|
252
|
+
const bar = view.querySelector('.adw-tab-bar') as HTMLElement;
|
|
253
|
+
expect(bar.scrollWidth <= bar.clientWidth).toBe(true);
|
|
254
|
+
|
|
255
|
+
// Chip 1, not chip 0: `selected` shows the close button on its own, which
|
|
256
|
+
// would mask the `hovering && fullyVisible` term under test.
|
|
257
|
+
const chip = chips(view)[1];
|
|
258
|
+
expect(chip.classList.contains('active')).toBe(false);
|
|
259
|
+
chip.dispatchEvent(new PointerEvent('pointerenter', { bubbles: false }));
|
|
260
|
+
expect((chip.querySelector('.adw-tab-close') as HTMLElement).hidden).toBe(false);
|
|
261
|
+
|
|
262
|
+
host.remove();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
114
265
|
};
|
package/src/bottom-sheet.spec.ts
CHANGED
|
@@ -201,6 +201,93 @@ export const AdwBottomSheetTest = async () => {
|
|
|
201
201
|
sheet.remove();
|
|
202
202
|
});
|
|
203
203
|
|
|
204
|
+
await it('a dismissed sheet re-opens, through either toggle spelling', () => {
|
|
205
|
+
// Reported as "the sheet never comes back": a preview that mounts open, gets
|
|
206
|
+
// dismissed by a scrim click, and then stays closed. Every other case here
|
|
207
|
+
// stops at its FIRST close, so a close path that latched (a listener torn
|
|
208
|
+
// down and never re-attached, a state that only travels one way) could have
|
|
209
|
+
// kept the suite green. The two spellings are the two a host actually
|
|
210
|
+
// writes: `sheet.open = !sheet.open`, and `toggleAttribute('open')` (what the
|
|
211
|
+
// storybook story uses), which only works while the element keeps reflecting
|
|
212
|
+
// the state back onto the attribute.
|
|
213
|
+
const sheet = document.createElement('adw-bottom-sheet') as AdwBottomSheet;
|
|
214
|
+
// `open="true"`, not a bare `open`: that is what Astro's MDX compiler emits
|
|
215
|
+
// for the documentation preview, so it is the markup that gets upgraded.
|
|
216
|
+
sheet.setAttribute('open', 'true');
|
|
217
|
+
sheet.innerHTML =
|
|
218
|
+
'<adw-bottom-sheet-content><button id="under">Under</button></adw-bottom-sheet-content>' +
|
|
219
|
+
'<adw-bottom-sheet-sheet><button id="inside">Inside</button></adw-bottom-sheet-sheet>';
|
|
220
|
+
document.body.appendChild(sheet);
|
|
221
|
+
expect(sheet.open).toBe(true);
|
|
222
|
+
|
|
223
|
+
const payloads: boolean[] = [];
|
|
224
|
+
sheet.addEventListener('notify::open', (event) => {
|
|
225
|
+
payloads.push((event as CustomEvent).detail.open);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
dimming(sheet).dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
229
|
+
expect(sheet.open).toBe(false);
|
|
230
|
+
expect(sheet.hasAttribute('open')).toBe(false);
|
|
231
|
+
|
|
232
|
+
sheet.open = true;
|
|
233
|
+
expect(sheet.open).toBe(true);
|
|
234
|
+
expect(sheet.hasAttribute('open')).toBe(true);
|
|
235
|
+
expect(sheet.classList.contains('open')).toBe(true);
|
|
236
|
+
// The scrim has to become interactive again, or the sheet is dismissible
|
|
237
|
+
// exactly once even though the state reopened.
|
|
238
|
+
expect(dimming(sheet).classList.contains('visible')).toBe(true);
|
|
239
|
+
|
|
240
|
+
sheet.toggleAttribute('open');
|
|
241
|
+
expect(sheet.open).toBe(false);
|
|
242
|
+
sheet.toggleAttribute('open');
|
|
243
|
+
expect(sheet.open).toBe(true);
|
|
244
|
+
|
|
245
|
+
expect(payloads).toStrictEqual([false, true, false, true]);
|
|
246
|
+
sheet.remove();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await it('survives the template-clone mount the docs preview uses, and reopens from it', () => {
|
|
250
|
+
// Every other case here builds the sheet with createElement, so the element
|
|
251
|
+
// is upgraded before it has children. The documentation site never does
|
|
252
|
+
// that: it clones an inert <template> and appends the whole subtree in ONE
|
|
253
|
+
// insertion, so the sheet is constructed, gets its attributes and is
|
|
254
|
+
// connected while the same walk is still upgrading its descendants, and its
|
|
255
|
+
// connectedCallback moves those descendants into the content/sheet layers
|
|
256
|
+
// mid-walk. That is the mount the reported "the sheet never comes back" was
|
|
257
|
+
// seen in, and the toggle button has to come back out of it wired and
|
|
258
|
+
// reachable or the preview has no way back from a scrim dismissal.
|
|
259
|
+
const tpl = document.createElement('template');
|
|
260
|
+
tpl.innerHTML =
|
|
261
|
+
'<adw-bottom-sheet open="true" modal="true" can-close="true">' +
|
|
262
|
+
'<adw-bottom-sheet-content><adw-button pill>Toggle sheet</adw-button></adw-bottom-sheet-content>' +
|
|
263
|
+
'<adw-bottom-sheet-sheet><button id="inside">Inside</button></adw-bottom-sheet-sheet>' +
|
|
264
|
+
'</adw-bottom-sheet>';
|
|
265
|
+
const stage = document.createElement('div');
|
|
266
|
+
document.body.appendChild(stage);
|
|
267
|
+
stage.append(tpl.content.cloneNode(true));
|
|
268
|
+
|
|
269
|
+
const sheet = stage.querySelector('adw-bottom-sheet') as AdwBottomSheet;
|
|
270
|
+
expect(sheet.open).toBe(true);
|
|
271
|
+
|
|
272
|
+
const toggle = sheet.querySelector('.adw-bottom-sheet-content adw-button') as HTMLElement;
|
|
273
|
+
expect(toggle).toBeTruthy();
|
|
274
|
+
expect(insideSheet(sheet).id).toBe('inside');
|
|
275
|
+
// What the page binds: a listener on the custom element, driven by a click
|
|
276
|
+
// on the inner native button.
|
|
277
|
+
toggle.addEventListener('click', () => sheet.toggleAttribute('open'));
|
|
278
|
+
const inner = toggle.querySelector('button') as HTMLElement;
|
|
279
|
+
|
|
280
|
+
dimming(sheet).dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
281
|
+
expect(sheet.open).toBe(false);
|
|
282
|
+
|
|
283
|
+
inner.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
284
|
+
expect(sheet.open).toBe(true);
|
|
285
|
+
expect(sheet.classList.contains('open')).toBe(true);
|
|
286
|
+
expect(dimming(sheet).classList.contains('visible')).toBe(true);
|
|
287
|
+
|
|
288
|
+
stage.remove();
|
|
289
|
+
});
|
|
290
|
+
|
|
204
291
|
await it('a dimming click on a CLOSED sheet does nothing and signals nothing', () => {
|
|
205
292
|
// The scrim is `can_target = open`, so it is not reachable at all — unlike
|
|
206
293
|
// Escape, it does not signal.
|