@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
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
|
}
|
package/src/chrome.spec.ts
CHANGED
|
@@ -90,6 +90,92 @@ function mountToolbarView(topBarHeight: number, bottomBarHeight: number): { host
|
|
|
90
90
|
return { host, view };
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* A widget mounted the way the docs site mounts a preview: a fixed-height flex
|
|
95
|
+
* row, the markup attached in ONE piece from an inert `<template>`.
|
|
96
|
+
*
|
|
97
|
+
* The template is not decoration. A nested composite (a split view holding
|
|
98
|
+
* toolbar views) upgraded child-by-child by the parser can lose its slotted
|
|
99
|
+
* children to an upgrade-order race, so both the docs site and these tests build
|
|
100
|
+
* the subtree first and attach it afterwards.
|
|
101
|
+
*/
|
|
102
|
+
function mountPreview(markup: string, height = 340, width = 640): HTMLElement {
|
|
103
|
+
const host = document.createElement('div');
|
|
104
|
+
host.style.cssText = `height: ${height}px; width: ${width}px; display: flex; overflow: hidden;`;
|
|
105
|
+
const template = document.createElement('template');
|
|
106
|
+
template.innerHTML = markup;
|
|
107
|
+
host.append(template.content.cloneNode(true));
|
|
108
|
+
document.body.append(host);
|
|
109
|
+
return host;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Rounded to the pixel it renders at, as `clampedWidth` does for the clamp. */
|
|
113
|
+
function heightOf(el: Element | null): number {
|
|
114
|
+
return el ? Math.round(el.getBoundingClientRect().height) : -1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The sidebar pane of the Navigation Split View preview (docs/adwaita/navigation.mdx). */
|
|
118
|
+
const SIDEBAR_PANE_PREVIEW = `
|
|
119
|
+
<adw-toolbar-view style="flex: 1;">
|
|
120
|
+
<adw-header-bar slot="top">
|
|
121
|
+
<adw-window-title slot="center" title="Mailboxes"></adw-window-title>
|
|
122
|
+
</adw-header-bar>
|
|
123
|
+
<adw-sidebar mode="sidebar" selected="0">
|
|
124
|
+
<adw-sidebar-section>
|
|
125
|
+
<adw-sidebar-item title="All Mail" subtitle="128 messages" icon-name="mail-unread-symbolic"></adw-sidebar-item>
|
|
126
|
+
<adw-sidebar-item title="Starred" subtitle="6 messages" icon-name="starred-symbolic"></adw-sidebar-item>
|
|
127
|
+
<adw-sidebar-item title="Drafts" subtitle="2 messages" icon-name="document-edit-symbolic"></adw-sidebar-item>
|
|
128
|
+
<adw-sidebar-item title="Archive" subtitle="512 messages" icon-name="folder-symbolic"></adw-sidebar-item>
|
|
129
|
+
</adw-sidebar-section>
|
|
130
|
+
</adw-sidebar>
|
|
131
|
+
</adw-toolbar-view>`;
|
|
132
|
+
|
|
133
|
+
/** The content pane of the same preview: a status page that centres itself. */
|
|
134
|
+
const STATUS_PANE_PREVIEW = `
|
|
135
|
+
<adw-toolbar-view style="flex: 1;">
|
|
136
|
+
<adw-header-bar slot="top">
|
|
137
|
+
<adw-window-title slot="center" title="All Mail"></adw-window-title>
|
|
138
|
+
</adw-header-bar>
|
|
139
|
+
<adw-status-page icon="mail-unread-symbolic" title="All Mail" description="Select a conversation from the list to read it here."></adw-status-page>
|
|
140
|
+
</adw-toolbar-view>`;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The root page of the Navigation View preview: a lone action button.
|
|
144
|
+
*
|
|
145
|
+
* A GTK button asked to sit in the middle of a ToolbarView says
|
|
146
|
+
* `halign/valign: CENTER`; the web spelling is a centring box around it, which is
|
|
147
|
+
* what the preview now carries. It used to carry a bare
|
|
148
|
+
* `class="adw-navigation-view-root-action"` matching no rule in any stylesheet.
|
|
149
|
+
*/
|
|
150
|
+
const ROOT_ACTION_PREVIEW = `
|
|
151
|
+
<adw-toolbar-view style="flex: 1;">
|
|
152
|
+
<adw-header-bar slot="top">
|
|
153
|
+
<adw-window-title slot="center" title="Contacts"></adw-window-title>
|
|
154
|
+
</adw-header-bar>
|
|
155
|
+
<div style="display: flex; align-items: center; justify-content: center; width: 100%; height: 100%;">
|
|
156
|
+
<adw-button label="Open contact" pill suggested></adw-button>
|
|
157
|
+
</div>
|
|
158
|
+
</adw-toolbar-view>`;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A tab view in a toolbar view: the horizontal scroller the shading also reaches.
|
|
162
|
+
*
|
|
163
|
+
* `expand-tabs` is off on purpose, so enough tabs really do overflow the strip
|
|
164
|
+
* rather than being squeezed into it.
|
|
165
|
+
*/
|
|
166
|
+
const TAB_STRIP_PREVIEW = (pages: number): string => `
|
|
167
|
+
<adw-toolbar-view style="flex: 1;">
|
|
168
|
+
<adw-header-bar slot="top">
|
|
169
|
+
<adw-window-title slot="center" title="Editor"></adw-window-title>
|
|
170
|
+
</adw-header-bar>
|
|
171
|
+
<adw-tab-view no-close="true">
|
|
172
|
+
${Array.from(
|
|
173
|
+
{ length: pages },
|
|
174
|
+
(_, i) => `<adw-tab-page title="A rather long tab title ${i}"><div>page ${i}</div></adw-tab-page>`,
|
|
175
|
+
).join('')}
|
|
176
|
+
</adw-tab-view>
|
|
177
|
+
</adw-toolbar-view>`;
|
|
178
|
+
|
|
93
179
|
export const AdwChromeTest = async () => {
|
|
94
180
|
await describe('adw-clamp allocation (AdwClampLayout conformance vectors)', async () => {
|
|
95
181
|
// The browser resolves the child's own minimum through the normal `min-width`
|
|
@@ -389,4 +475,151 @@ export const AdwChromeTest = async () => {
|
|
|
389
475
|
host.remove();
|
|
390
476
|
});
|
|
391
477
|
});
|
|
478
|
+
|
|
479
|
+
// `adw_toolbar_view_size_allocate` hands its ONE content widget the whole
|
|
480
|
+
// content rect; a widget that wants less says so with its own alignment.
|
|
481
|
+
// This port collected the content into a flex COLUMN, where an item that
|
|
482
|
+
// declares no `flex-grow` is content-sized instead, so the child stopped
|
|
483
|
+
// wherever its content did and the rest of the pane stayed empty.
|
|
484
|
+
await describe('adw-toolbar-view content allocation (size_allocate gives the content the whole rect)', async () => {
|
|
485
|
+
await it('stretches a lone content child past its own content height', async () => {
|
|
486
|
+
const host = mountPreview(SIDEBAR_PANE_PREVIEW);
|
|
487
|
+
await settle();
|
|
488
|
+
const area = host.querySelector('.adw-toolbar-view-content');
|
|
489
|
+
const sidebar = host.querySelector('adw-sidebar') as HTMLElement;
|
|
490
|
+
|
|
491
|
+
// The two discriminators, without which this passes on a broken build:
|
|
492
|
+
// the bar must really have taken height off the top, and the sidebar's
|
|
493
|
+
// own content must really be shorter than the area it has to fill,
|
|
494
|
+
// otherwise "child height == area height" is a coincidence. The rows are
|
|
495
|
+
// measured through the LIST, not through the sidebar's own scrollHeight:
|
|
496
|
+
// once the sidebar fills, its scrollHeight is its new height and the
|
|
497
|
+
// discriminator would have measured the fix instead of the content.
|
|
498
|
+
expect(heightOf(area) > 0 && heightOf(area) < 340).toBe(true);
|
|
499
|
+
expect(heightOf(sidebar.querySelector('.adw-sidebar-list')) < heightOf(area)).toBe(true);
|
|
500
|
+
|
|
501
|
+
expect(heightOf(sidebar)).toBe(heightOf(area));
|
|
502
|
+
host.remove();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
await it('centres a status page in the pane rather than against its top bar', async () => {
|
|
506
|
+
const host = mountPreview(STATUS_PANE_PREVIEW);
|
|
507
|
+
await settle();
|
|
508
|
+
const area = host.querySelector('.adw-toolbar-view-content') as HTMLElement;
|
|
509
|
+
const status = host.querySelector('adw-status-page') as HTMLElement;
|
|
510
|
+
|
|
511
|
+
expect(heightOf(area) > 0 && heightOf(area) < 340).toBe(true);
|
|
512
|
+
expect(heightOf(status)).toBe(heightOf(area));
|
|
513
|
+
|
|
514
|
+
// What the reader actually sees, and the discriminator at the same time:
|
|
515
|
+
// an Adw.StatusPage centres its own column, so equal air above the icon
|
|
516
|
+
// and below the description is only reachable once it owns the whole
|
|
517
|
+
// pane. Top-aligned in the pane it measured 24px of padding above and
|
|
518
|
+
// some 60px of window background below.
|
|
519
|
+
const areaBox = area.getBoundingClientRect();
|
|
520
|
+
const above =
|
|
521
|
+
(status.querySelector('.adw-status-page-icon') as HTMLElement).getBoundingClientRect().top -
|
|
522
|
+
areaBox.top;
|
|
523
|
+
const description = status.querySelector('.adw-status-page-description') as HTMLElement;
|
|
524
|
+
const below = areaBox.bottom - description.getBoundingClientRect().bottom;
|
|
525
|
+
expect(Math.abs(above - below) <= 2).toBe(true);
|
|
526
|
+
host.remove();
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
await it('stretches a centring box and lets IT place the button, which keeps its own size', async () => {
|
|
530
|
+
const host = mountPreview(ROOT_ACTION_PREVIEW);
|
|
531
|
+
await settle();
|
|
532
|
+
const area = host.querySelector('.adw-toolbar-view-content') as HTMLElement;
|
|
533
|
+
const button = host.querySelector('adw-button') as HTMLElement;
|
|
534
|
+
|
|
535
|
+
expect(heightOf(area.firstElementChild)).toBe(heightOf(area));
|
|
536
|
+
// A pill button that grew to the pane's height would be the fill applied
|
|
537
|
+
// one level too deep. The alignment box exists precisely to absorb it.
|
|
538
|
+
expect(heightOf(button) > 0 && heightOf(button) < heightOf(area)).toBe(true);
|
|
539
|
+
|
|
540
|
+
const areaBox = area.getBoundingClientRect();
|
|
541
|
+
const buttonBox = button.getBoundingClientRect();
|
|
542
|
+
expect(Math.abs(buttonBox.top - areaBox.top - (areaBox.bottom - buttonBox.bottom)) <= 2).toBe(true);
|
|
543
|
+
expect(Math.abs(buttonBox.left - areaBox.left - (areaBox.right - buttonBox.right)) <= 2).toBe(true);
|
|
544
|
+
host.remove();
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// The undershoot says "there IS content past this edge". A scroller whose
|
|
549
|
+
// content fits has none, so it must draw neither edge. That is the state the
|
|
550
|
+
// storybook got wrong for every scroller at rest, and the reason
|
|
551
|
+
// `scrollUndershootClasses` exists. Asserted here on a REAL scroller,
|
|
552
|
+
// because the core spec only ever sees the arithmetic.
|
|
553
|
+
await describe('adw-toolbar-view scroll shading (no undershoot where nothing can scroll)', async () => {
|
|
554
|
+
await it('leaves both edges clean while the content fits', async () => {
|
|
555
|
+
const host = mountPreview(SIDEBAR_PANE_PREVIEW);
|
|
556
|
+
await settle();
|
|
557
|
+
const area = host.querySelector('.adw-toolbar-view-content') as HTMLElement;
|
|
558
|
+
|
|
559
|
+
// Both scrollers under the view: the content area and the sidebar's own
|
|
560
|
+
// list, which is the node upstream shades (`AdwSidebar` wraps a
|
|
561
|
+
// GtkScrolledWindow) once the pane is tall enough to hold every row.
|
|
562
|
+
for (const el of [area, host.querySelector('adw-sidebar') as HTMLElement]) {
|
|
563
|
+
// The discriminator: the shading really is wired to this scroller, so
|
|
564
|
+
// "no undershoot class" is a decision and not a controller that never ran.
|
|
565
|
+
expect(el.classList.contains('adw-scroll-shaded')).toBe(true);
|
|
566
|
+
expect(el.scrollHeight).toBe(el.clientHeight);
|
|
567
|
+
expect(el.classList.contains('undershoot-top')).toBe(false);
|
|
568
|
+
expect(el.classList.contains('undershoot-bottom')).toBe(false);
|
|
569
|
+
}
|
|
570
|
+
host.remove();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
await it('draws the top edge once there IS content past it', async () => {
|
|
574
|
+
// A status page, not the sidebar: a status page cannot scroll on its own,
|
|
575
|
+
// so the content area is the scroller and the shade belongs to it. And the
|
|
576
|
+
// TOP edge, because `update_undershoots` gates the bottom one on a bottom
|
|
577
|
+
// bar this view does not have, so asserting the bottom edge here would pass
|
|
578
|
+
// whatever the controller decided.
|
|
579
|
+
const host = mountPreview(STATUS_PANE_PREVIEW, 120);
|
|
580
|
+
await settle();
|
|
581
|
+
const area = host.querySelector('.adw-toolbar-view-content') as HTMLElement;
|
|
582
|
+
expect(area.scrollHeight > area.clientHeight).toBe(true);
|
|
583
|
+
expect(area.classList.contains('undershoot-top')).toBe(false);
|
|
584
|
+
|
|
585
|
+
// A REAL scroll, then the frames the `scroll` event is delivered in.
|
|
586
|
+
area.scrollTop = area.scrollHeight;
|
|
587
|
+
await settle();
|
|
588
|
+
await settle();
|
|
589
|
+
expect(area.classList.contains('undershoot-top')).toBe(true);
|
|
590
|
+
|
|
591
|
+
// ONE owner draws the shade. `undershoot-top` is also a documented
|
|
592
|
+
// libadwaita style class with its own inset host shadow in
|
|
593
|
+
// `_style_classes.scss`, so a scroller wearing the state class used to
|
|
594
|
+
// get that 6px blurred shadow UNDER the 1px line + 4px fade this package
|
|
595
|
+
// paints on the pseudo-element: the same edge, twice, in two sizes.
|
|
596
|
+
expect(getComputedStyle(area).boxShadow).toBe('none');
|
|
597
|
+
expect(getComputedStyle(area, '::before').boxShadow === 'none').toBe(false);
|
|
598
|
+
host.remove();
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
await it('leaves a horizontally overflowing tab strip its own hairline', async () => {
|
|
602
|
+
// The horizontal pair has no rule in `_scrolling.scss` at all, because
|
|
603
|
+
// neither AdwTabBox nor AdwCarousel has a `scrolledwindow` node for GTK to
|
|
604
|
+
// shade. The style class did have one, and it is `box-shadow` too — so an
|
|
605
|
+
// overflowing strip silently traded its `inset 0 -1px` bottom shade for an
|
|
606
|
+
// edge shadow libadwaita never draws there.
|
|
607
|
+
const wide = mountPreview(TAB_STRIP_PREVIEW(8), 260, 320);
|
|
608
|
+
const narrow = mountPreview(TAB_STRIP_PREVIEW(1), 260, 320);
|
|
609
|
+
await settle();
|
|
610
|
+
await settle();
|
|
611
|
+
const overflowing = wide.querySelector('.adw-tab-bar') as HTMLElement;
|
|
612
|
+
const fitting = narrow.querySelector('.adw-tab-bar') as HTMLElement;
|
|
613
|
+
|
|
614
|
+
// The two discriminators: the strip really does overflow, and the shading
|
|
615
|
+
// controller really did reach it. Without them this is two identical bars.
|
|
616
|
+
expect(overflowing.scrollWidth > overflowing.clientWidth).toBe(true);
|
|
617
|
+
expect(overflowing.classList.contains('undershoot-end')).toBe(true);
|
|
618
|
+
expect(fitting.scrollWidth).toBe(fitting.clientWidth);
|
|
619
|
+
|
|
620
|
+
expect(getComputedStyle(overflowing).boxShadow).toBe(getComputedStyle(fitting).boxShadow);
|
|
621
|
+
wide.remove();
|
|
622
|
+
narrow.remove();
|
|
623
|
+
});
|
|
624
|
+
});
|
|
392
625
|
};
|
|
@@ -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
|
+
};
|