@gjsify/adwaita-web 0.39.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adwaita-web.css +112 -40
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/adw-button.spec.d.ts +1 -0
- package/lib/types/adw-entry.spec.d.ts +1 -0
- package/lib/types/adw-menu-button.spec.d.ts +1 -0
- package/lib/types/elements/adw-carousel.d.ts +48 -0
- package/lib/types/elements/adw-entry.d.ts +6 -0
- package/lib/types/elements/adw-header-bar.d.ts +5 -4
- package/lib/types/elements/adw-menu-button.d.ts +18 -7
- package/lib/types/elements/adw-tab-view.d.ts +23 -0
- package/lib/types/elements/adw-toolbar-view.d.ts +8 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/scroll-shading.d.ts +56 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +6 -6
- package/scss/_carousel.scss +15 -1
- package/scss/_headerbar.scss +35 -6
- package/scss/_icons.generated.scss +18 -0
- package/scss/_scrolling.scss +107 -0
- package/scss/_style_classes.scss +21 -8
- package/scss/_theme.scss +1 -0
- package/scss/_toolbar_view.scss +52 -22
- package/scss/_variables.scss +7 -0
- package/scss/_view_switcher.scss +80 -20
- package/scss/adwaita-skin.scss +1 -0
- package/src/adw-button.spec.ts +59 -0
- package/src/adw-carousel.spec.ts +141 -1
- package/src/adw-entry.spec.ts +73 -0
- package/src/adw-header-bar.spec.ts +127 -0
- package/src/adw-menu-button.spec.ts +52 -0
- package/src/adw-tab-view.spec.ts +151 -0
- package/src/bottom-sheet.spec.ts +87 -0
- package/src/chrome.spec.ts +233 -0
- package/src/elements/adw-button.ts +8 -10
- package/src/elements/adw-carousel.ts +121 -4
- package/src/elements/adw-entry.ts +53 -8
- package/src/elements/adw-header-bar.ts +32 -9
- package/src/elements/adw-menu-button.ts +20 -19
- package/src/elements/adw-status-page.ts +7 -2
- package/src/elements/adw-tab-view.ts +63 -3
- package/src/elements/adw-toolbar-view.ts +22 -0
- package/src/index.ts +6 -0
- package/src/scroll-shading.ts +255 -0
- package/src/styles.generated.ts +1 -1
- package/src/test.browser.mts +6 -0
- package/src/view-switcher.spec.ts +149 -0
package/scss/adwaita-skin.scss
CHANGED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// DOM-level tests for <adw-button>'s style classes.
|
|
2
|
+
//
|
|
3
|
+
// The element and its NativeScript twin each kept their own attribute→class table,
|
|
4
|
+
// and the two disagreed: `circular` was here and not there. The table now lives in
|
|
5
|
+
// `@gjsify/adwaita-core`, and these drive its vectors through the REAL element, so a
|
|
6
|
+
// renderer that goes back to a private copy fails on the row that names the class.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from '@gjsify/unit';
|
|
9
|
+
import { BUTTON_STYLE_CLASS_VECTORS } from '@gjsify/adwaita-core/conformance';
|
|
10
|
+
|
|
11
|
+
/** The boolean attributes this element exposes — the short spellings. */
|
|
12
|
+
const WEB_ATTRIBUTES = new Set(['flat', 'suggested', 'destructive', 'circular', 'pill']);
|
|
13
|
+
|
|
14
|
+
/** Mount a button carrying the given boolean attributes; return the inner classes. */
|
|
15
|
+
function mountWithStyles(names: readonly string[]): string[] {
|
|
16
|
+
const el = document.createElement('adw-button');
|
|
17
|
+
el.setAttribute('label', 'Click me');
|
|
18
|
+
for (const name of names) el.setAttribute(name, '');
|
|
19
|
+
document.body.appendChild(el);
|
|
20
|
+
const btn = el.querySelector('button') as HTMLButtonElement;
|
|
21
|
+
return Array.from(btn.classList).filter((cls) => cls !== 'adw-button');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function unmountAll(): void {
|
|
25
|
+
for (const el of Array.from(document.querySelectorAll('adw-button'))) el.remove();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const AdwButtonTest = async () => {
|
|
29
|
+
// Rows naming a class the LONG way (`suggested-action`) are the NativeScript
|
|
30
|
+
// variant spelling — this element has no such attribute. They are partitioned
|
|
31
|
+
// out here rather than skipped inside the loop, and the partition is asserted,
|
|
32
|
+
// so a row cannot fall out of coverage unnoticed.
|
|
33
|
+
const reachable = BUTTON_STYLE_CLASS_VECTORS.filter((v) => v.names.every((n) => WEB_ATTRIBUTES.has(n)));
|
|
34
|
+
const unreachable = BUTTON_STYLE_CLASS_VECTORS.filter((v) => !v.names.every((n) => WEB_ATTRIBUTES.has(n)));
|
|
35
|
+
|
|
36
|
+
await describe('<adw-button> style classes', async () => {
|
|
37
|
+
for (const { names, classes, rule } of reachable) {
|
|
38
|
+
await it(`[${names.join(', ')}] → [${classes.join(', ')}] — ${rule}`, () => {
|
|
39
|
+
expect(mountWithStyles(names)).toStrictEqual([...classes]);
|
|
40
|
+
unmountAll();
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await it('the rows this element cannot express are exactly the long spellings', () => {
|
|
45
|
+
const names = unreachable.flatMap((v) => v.names).filter((n) => !WEB_ATTRIBUTES.has(n));
|
|
46
|
+
expect(names).toStrictEqual(['suggested-action', 'suggessted', 'not-a-style']);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await it('a style attribute added later is picked up', () => {
|
|
50
|
+
const el = document.createElement('adw-button');
|
|
51
|
+
el.setAttribute('label', 'Send');
|
|
52
|
+
document.body.appendChild(el);
|
|
53
|
+
el.setAttribute('suggested', '');
|
|
54
|
+
const btn = el.querySelector('button') as HTMLButtonElement;
|
|
55
|
+
expect(btn.classList.contains('suggested-action')).toBe(true);
|
|
56
|
+
unmountAll();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
};
|
package/src/adw-carousel.spec.ts
CHANGED
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
// element sets it as an inline style precisely so it stays overridable.
|
|
10
10
|
import { describe, expect, it } from '@gjsify/unit';
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import type { CarouselPageMeasurement } from '@gjsify/adwaita-core';
|
|
13
|
+
import {
|
|
14
|
+
CAROUSEL_PAGE_ALLOCATION_VECTORS,
|
|
15
|
+
CAROUSEL_PAGE_AT_POSITION_VECTORS,
|
|
16
|
+
CAROUSEL_PAGE_LIST_VECTORS,
|
|
17
|
+
} from '@gjsify/adwaita-core/conformance';
|
|
13
18
|
import type { CarouselPageOp } from '@gjsify/adwaita-core/conformance';
|
|
14
19
|
|
|
15
20
|
/** The carousel surface this suite drives — the element's own public API. */
|
|
@@ -62,6 +67,45 @@ function mount(labels: readonly string[], attributes: Record<string, string> = {
|
|
|
62
67
|
return { carousel, host, track };
|
|
63
68
|
}
|
|
64
69
|
|
|
70
|
+
/**
|
|
71
|
+
* One page that MEASURES what a conformance row says it does. `expand` is spelled
|
|
72
|
+
* `width: 100%`, the CSS of hexpand; `flex: none` keeps the slot's own flex layout from
|
|
73
|
+
* shrinking a natural size that is wider than the carousel before it can be measured.
|
|
74
|
+
*/
|
|
75
|
+
function sizedCard(page: CarouselPageMeasurement, index: number): HTMLElement {
|
|
76
|
+
const element = document.createElement('div');
|
|
77
|
+
element.dataset.label = `p${index}`;
|
|
78
|
+
const width = page.expand ? '100%' : `${page.natural}px`;
|
|
79
|
+
element.style.cssText = `flex:none;height:100%;width:${width};`;
|
|
80
|
+
return element;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Mount a carousel `available` px wide whose pages measure what `pages` says. */
|
|
84
|
+
function mountSized(available: number, pages: readonly CarouselPageMeasurement[]) {
|
|
85
|
+
const host = document.createElement('div');
|
|
86
|
+
host.style.cssText = `width:${available}px;height:80px;`;
|
|
87
|
+
document.body.appendChild(host);
|
|
88
|
+
|
|
89
|
+
const carousel = document.createElement('adw-carousel') as CarouselElement;
|
|
90
|
+
carousel.style.cssText = `display:block;width:${available}px;height:80px;`;
|
|
91
|
+
pages.forEach((page, index) => carousel.appendChild(sizedCard(page, index)));
|
|
92
|
+
host.appendChild(carousel);
|
|
93
|
+
|
|
94
|
+
const track = carousel.querySelector('.adw-carousel-track') as HTMLElement;
|
|
95
|
+
// Instant scrolls; snapping stays ON here, because where a page SNAPS is half of
|
|
96
|
+
// what these tests are about.
|
|
97
|
+
track.style.setProperty('scroll-behavior', 'auto');
|
|
98
|
+
return { carousel, host, track };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Each page's left edge relative to the carousel, rounded off the subpixel noise. */
|
|
102
|
+
function pageEdges(carousel: HTMLElement): number[] {
|
|
103
|
+
const origin = carousel.getBoundingClientRect().left;
|
|
104
|
+
return Array.from(carousel.querySelectorAll('.adw-carousel-page')).map((slot) =>
|
|
105
|
+
Math.round(slot.getBoundingClientRect().left - origin),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
65
109
|
/** The page labels in DOM order — what the user actually sees, not what the model says. */
|
|
66
110
|
function domOrder(carousel: HTMLElement): string[] {
|
|
67
111
|
return Array.from(carousel.querySelectorAll('.adw-carousel-page')).map(
|
|
@@ -159,6 +203,102 @@ export const AdwCarouselTest = async () => {
|
|
|
159
203
|
}
|
|
160
204
|
});
|
|
161
205
|
|
|
206
|
+
await describe('adw-carousel page allocation (libadwaita conformance vectors)', async () => {
|
|
207
|
+
// The rows the DOM can express. The element measures a RENDERED width, so a page
|
|
208
|
+
// whose MINIMUM exceeds the carousel has already been resolved by CSS before the
|
|
209
|
+
// element sees it, and a carousel with no pages has nothing to measure at all.
|
|
210
|
+
const measurable = CAROUSEL_PAGE_ALLOCATION_VECTORS.filter(
|
|
211
|
+
(vector) => vector.pages.length > 0 && vector.pages.every((page) => page.minimum === undefined),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
for (const vector of measurable) {
|
|
215
|
+
await it(vector.rule, async () => {
|
|
216
|
+
const { carousel, host, track } = mountSized(vector.available, vector.pages);
|
|
217
|
+
await flush();
|
|
218
|
+
|
|
219
|
+
const widths = Array.from(carousel.querySelectorAll('.adw-carousel-page')).map((slot) =>
|
|
220
|
+
Math.round(slot.getBoundingClientRect().width),
|
|
221
|
+
);
|
|
222
|
+
expect(widths).toStrictEqual(vector.pages.map(() => vector.pageSize));
|
|
223
|
+
// The half-gap is the track's own padding, which is what lets the page
|
|
224
|
+
// strip start inside the carousel instead of flush against its edge.
|
|
225
|
+
expect(Math.round(Number.parseFloat(getComputedStyle(track).paddingInlineStart))).toBe(
|
|
226
|
+
vector.leadingInset,
|
|
227
|
+
);
|
|
228
|
+
host.remove();
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
await it('shows the previous and next page at the edges', async () => {
|
|
233
|
+
// THE REPORTED DEFECT. With `flex: 0 0 100%` the pages of the Carousel story
|
|
234
|
+
// (440px cards in a 480px carousel) filled the width and both neighbours sat
|
|
235
|
+
// entirely off screen, where GTK leaves 20px of each showing.
|
|
236
|
+
const pages: CarouselPageMeasurement[] = [{ natural: 440 }, { natural: 440 }, { natural: 440 }];
|
|
237
|
+
const { carousel, host } = mountSized(480, pages);
|
|
238
|
+
await flush();
|
|
239
|
+
|
|
240
|
+
// At rest on page 0: centred, with page 1 starting 20px before the right edge.
|
|
241
|
+
expect(pageEdges(carousel)).toStrictEqual([20, 460, 900]);
|
|
242
|
+
|
|
243
|
+
carousel.scrollToPage(1);
|
|
244
|
+
await flush();
|
|
245
|
+
// Page 1 centred, 20px of page 0 showing at the left and 20px of page 2 at the right.
|
|
246
|
+
expect(pageEdges(carousel)).toStrictEqual([-420, 20, 460]);
|
|
247
|
+
host.remove();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await it('leaves the LAST page room to centre as well', async () => {
|
|
251
|
+
// The trailing half-gap is track padding too. A scroll container that dropped
|
|
252
|
+
// its end padding would stop `leadingInset` short of the last snap point, and
|
|
253
|
+
// the last page would never reach the middle.
|
|
254
|
+
const pages: CarouselPageMeasurement[] = [{ natural: 440 }, { natural: 440 }, { natural: 440 }];
|
|
255
|
+
const { carousel, host, track } = mountSized(480, pages);
|
|
256
|
+
await flush();
|
|
257
|
+
|
|
258
|
+
carousel.scrollToPage(2);
|
|
259
|
+
await flush();
|
|
260
|
+
expect(Math.round(track.scrollLeft)).toBe(880);
|
|
261
|
+
expect(carousel.currentPage).toBe(2);
|
|
262
|
+
expect(pageEdges(carousel)).toStrictEqual([-860, -420, 20]);
|
|
263
|
+
host.remove();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
await it('re-aims the scroll in flight instead of rewinding it', async () => {
|
|
267
|
+
// Mounted inside a HIDDEN ancestor, which is how real markup reaches this:
|
|
268
|
+
// `connectedCallback` measures 0, so the declared position waits for a
|
|
269
|
+
// layout, and revealing the host delivers the pending-position observer and
|
|
270
|
+
// the layout observer in ONE resize pass. The first fires a scroll to page
|
|
271
|
+
// 2 at the fallback pitch; the second narrows the pages and has to put the
|
|
272
|
+
// offset back, and `state.position` is still 0 there, because the `scroll`
|
|
273
|
+
// event that will teach it 2 fires a frame later. Restoring THAT parks the
|
|
274
|
+
// carousel on page 0 and swallows `position="2"`. GTK re-aims instead: it
|
|
275
|
+
// pushes the recomputed snap point into the running animation (:786-788).
|
|
276
|
+
//
|
|
277
|
+
// A carousel already laid out when it is mounted does NOT reach this: it
|
|
278
|
+
// allocates during `connectedCallback` and the pending observer never
|
|
279
|
+
// arms, which is why the mounted-visible spelling of this test passes
|
|
280
|
+
// either way and is not the one kept.
|
|
281
|
+
const host = document.createElement('div');
|
|
282
|
+
host.style.cssText = 'width:480px;height:80px;display:none;';
|
|
283
|
+
document.body.appendChild(host);
|
|
284
|
+
|
|
285
|
+
const carousel = document.createElement('adw-carousel') as CarouselElement;
|
|
286
|
+
carousel.style.cssText = 'display:block;width:480px;height:80px;';
|
|
287
|
+
carousel.setAttribute('position', '2');
|
|
288
|
+
for (let index = 0; index < 3; index++) carousel.appendChild(sizedCard({ natural: 440 }, index));
|
|
289
|
+
host.appendChild(carousel);
|
|
290
|
+
await flush();
|
|
291
|
+
|
|
292
|
+
host.style.display = 'block';
|
|
293
|
+
await flush();
|
|
294
|
+
|
|
295
|
+
const track = carousel.querySelector('.adw-carousel-track') as HTMLElement;
|
|
296
|
+
expect(Math.round(track.scrollLeft)).toBe(880);
|
|
297
|
+
expect(carousel.currentPage).toBe(2);
|
|
298
|
+
host.remove();
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
162
302
|
await describe('adw-carousel page lookup (libadwaita conformance vectors)', async () => {
|
|
163
303
|
for (const vector of CAROUSEL_PAGE_AT_POSITION_VECTORS) {
|
|
164
304
|
// Only rows whose snap points are a settled page list are reachable through
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// DOM-level tests for <adw-entry>'s length arithmetic.
|
|
2
|
+
//
|
|
3
|
+
// The element had NO max length and no text length, while its NativeScript twin has
|
|
4
|
+
// composed `@gjsify/adwaita-core`'s since it shipped — so the same consumer got a
|
|
5
|
+
// limit on one renderer and none on the other. These drive the core's own vectors
|
|
6
|
+
// against the real element, which is what makes the delegation checkable rather
|
|
7
|
+
// than merely claimed: a re-implementation that counts UTF-16 units instead of code
|
|
8
|
+
// points fails on the row that names it.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from '@gjsify/unit';
|
|
11
|
+
import { ENTRY_MAX_LENGTH_VECTORS, ENTRY_TEXT_LENGTH_VECTORS } from '@gjsify/adwaita-core/conformance';
|
|
12
|
+
|
|
13
|
+
import type { AdwEntry } from './elements/adw-entry.js';
|
|
14
|
+
|
|
15
|
+
/** Mount an entry with the given attributes. */
|
|
16
|
+
function mountEntry(attrs: Record<string, string> = {}): AdwEntry {
|
|
17
|
+
const entry = document.createElement('adw-entry') as AdwEntry;
|
|
18
|
+
for (const [name, value] of Object.entries(attrs)) entry.setAttribute(name, value);
|
|
19
|
+
document.body.appendChild(entry);
|
|
20
|
+
return entry;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function unmountAll(): void {
|
|
24
|
+
for (const entry of Array.from(document.querySelectorAll('adw-entry'))) entry.remove();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const AdwEntryTest = async () => {
|
|
28
|
+
await describe('<adw-entry> text length', async () => {
|
|
29
|
+
for (const { text, length, rule } of ENTRY_TEXT_LENGTH_VECTORS) {
|
|
30
|
+
await it(`${JSON.stringify(text)} → ${length} — ${rule}`, () => {
|
|
31
|
+
const entry = mountEntry();
|
|
32
|
+
entry.value = text;
|
|
33
|
+
expect(entry.textLength).toBe(length);
|
|
34
|
+
unmountAll();
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await describe('<adw-entry> max length', async () => {
|
|
40
|
+
for (const { text, maxLength, clamped, length, rule } of ENTRY_MAX_LENGTH_VECTORS) {
|
|
41
|
+
await it(`${JSON.stringify(text)} @ ${maxLength} — ${rule}`, () => {
|
|
42
|
+
const entry = mountEntry({ maxlength: String(maxLength) });
|
|
43
|
+
entry.value = text;
|
|
44
|
+
expect(entry.value).toBe(clamped);
|
|
45
|
+
expect(entry.textLength).toBe(length);
|
|
46
|
+
unmountAll();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await it('clamps what the ATTRIBUTE writes, not only the property', () => {
|
|
51
|
+
const entry = mountEntry({ maxlength: '5' });
|
|
52
|
+
entry.setAttribute('value', 'Ada Lovelace');
|
|
53
|
+
expect(entry.value).toBe('Ada L');
|
|
54
|
+
unmountAll();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await it('re-clamps when the limit is lowered under the current text', () => {
|
|
58
|
+
const entry = mountEntry();
|
|
59
|
+
entry.value = 'Ada Lovelace';
|
|
60
|
+
entry.maxLength = 3;
|
|
61
|
+
expect(entry.value).toBe('Ada');
|
|
62
|
+
unmountAll();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await it('an unusable limit means unlimited, never "truncate to empty"', () => {
|
|
66
|
+
const entry = mountEntry({ maxlength: 'nonsense' });
|
|
67
|
+
entry.value = 'Ada Lovelace';
|
|
68
|
+
expect(entry.maxLength).toBe(0);
|
|
69
|
+
expect(entry.value).toBe('Ada Lovelace');
|
|
70
|
+
unmountAll();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
};
|
|
@@ -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 () => {
|
|
@@ -78,4 +119,90 @@ export const AdwHeaderBarTest = async () => {
|
|
|
78
119
|
unmountAll();
|
|
79
120
|
});
|
|
80
121
|
});
|
|
122
|
+
|
|
123
|
+
// The centre is the `<adw-window-title>` Adw.HeaderBar itself derives, so the three
|
|
124
|
+
// rules that element holds through @gjsify/adwaita-core reach the header bar too.
|
|
125
|
+
// None of them did while the centre was a bare span.
|
|
126
|
+
await describe('<adw-header-bar> derives an <adw-window-title>', async () => {
|
|
127
|
+
await it('builds one rather than a bare span', () => {
|
|
128
|
+
const bar = mountHeaderBar({ title: 'Documents' });
|
|
129
|
+
expect(bar.querySelector('adw-window-title')).toBeTruthy();
|
|
130
|
+
unmountAll();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await it('renders a subtitle, which it could not before', () => {
|
|
134
|
+
const bar = mountHeaderBar({ title: 'Documents', subtitle: '12 items' });
|
|
135
|
+
expect(centerText(bar)).toContain('Documents');
|
|
136
|
+
expect(centerText(bar)).toContain('12 items');
|
|
137
|
+
unmountAll();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await it('hides the title line when the title is empty — the blank-line bug', () => {
|
|
141
|
+
// adw-window-title.c:207-208. A header bar with only a subtitle used to
|
|
142
|
+
// reserve an empty line above it, because the span was always there.
|
|
143
|
+
const bar = mountHeaderBar({ title: '', subtitle: 'Loading…' });
|
|
144
|
+
const titleEl = bar.querySelector('.adw-window-title-title') as HTMLElement | null;
|
|
145
|
+
expect(titleEl?.hidden).toBe(true);
|
|
146
|
+
unmountAll();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await it('a subtitle survives a title write', () => {
|
|
150
|
+
const bar = mountHeaderBar({ title: 'Documents', subtitle: '12 items' });
|
|
151
|
+
bar.setAttribute('title', 'Pictures');
|
|
152
|
+
expect(centerText(bar)).toContain('Pictures');
|
|
153
|
+
expect(centerText(bar)).toContain('12 items');
|
|
154
|
+
unmountAll();
|
|
155
|
+
});
|
|
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
|
+
});
|
|
81
208
|
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// DOM-level tests for <adw-menu-button>'s `menu` attribute.
|
|
2
|
+
//
|
|
3
|
+
// The element parsed the JSON itself, and the copy was weaker than the core parser
|
|
4
|
+
// the split button next to it already used: it kept `id` and `icon` whatever their
|
|
5
|
+
// runtime type, so author markup could put a number in a field typed `string` — and
|
|
6
|
+
// `id` is exactly what the activation event reports. These drive the core's parse
|
|
7
|
+
// vectors against the real element.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from '@gjsify/unit';
|
|
10
|
+
import { SPLIT_BUTTON_MENU_PARSE_VECTORS } from '@gjsify/adwaita-core/conformance';
|
|
11
|
+
|
|
12
|
+
/** Mount a menu button carrying `menu` as JSON, and return its rendered item labels. */
|
|
13
|
+
function mountWithMenu(json: string): { el: HTMLElement; labels: string[] } {
|
|
14
|
+
const el = document.createElement('adw-menu-button');
|
|
15
|
+
el.setAttribute('menu', json);
|
|
16
|
+
document.body.appendChild(el);
|
|
17
|
+
const labels = Array.from(el.querySelectorAll('.adw-popover button')).map((b) => b.textContent?.trim() ?? '');
|
|
18
|
+
return { el, labels };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function unmountAll(): void {
|
|
22
|
+
for (const el of Array.from(document.querySelectorAll('adw-menu-button'))) el.remove();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const AdwMenuButtonTest = async () => {
|
|
26
|
+
await describe('<adw-menu-button> menu attribute', async () => {
|
|
27
|
+
for (const { json, entries, rule } of SPLIT_BUTTON_MENU_PARSE_VECTORS) {
|
|
28
|
+
await it(`${rule}`, () => {
|
|
29
|
+
const { labels } = mountWithMenu(json ?? '');
|
|
30
|
+
expect(labels).toStrictEqual(entries.map((entry) => entry.label));
|
|
31
|
+
unmountAll();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await it('drops a non-string id instead of leaking a number into the event', () => {
|
|
36
|
+
const el = document.createElement('adw-menu-button');
|
|
37
|
+
el.setAttribute('menu', '[{"label":"Open","id":7}]');
|
|
38
|
+
document.body.appendChild(el);
|
|
39
|
+
|
|
40
|
+
let seen: unknown = 'not fired';
|
|
41
|
+
el.addEventListener('menu-item-activated', (e) => {
|
|
42
|
+
seen = (e as CustomEvent<{ id: string }>).detail.id;
|
|
43
|
+
});
|
|
44
|
+
(el.querySelector('.adw-popover button') as HTMLElement | null)?.click();
|
|
45
|
+
|
|
46
|
+
// `id` fell back to the label, which is the documented default — rather
|
|
47
|
+
// than arriving as the number 7 in a field every consumer reads as text.
|
|
48
|
+
expect(seen).toBe('Open');
|
|
49
|
+
unmountAll();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
};
|
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
|
};
|