@gjsify/adwaita-web 0.21.0 → 0.23.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 +48 -0
- package/dist/adwaita-web.css +364 -0
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/adw-row-state.spec.d.ts +1 -0
- package/lib/types/adw-toast-overlay.spec.d.ts +1 -0
- package/lib/types/adw-view-switcher-bar.spec.d.ts +1 -0
- package/lib/types/elements/adw-alert-dialog.d.ts +6 -5
- package/lib/types/elements/adw-combo-row.d.ts +2 -1
- package/lib/types/elements/adw-expander-row.d.ts +4 -1
- package/lib/types/elements/adw-spin-row.d.ts +6 -4
- package/lib/types/elements/adw-toast-overlay.d.ts +42 -1
- package/lib/types/elements/adw-toggle-group.d.ts +2 -1
- package/lib/types/style-isolation.spec.d.ts +1 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +6 -5
- package/scss/_reset.scss +69 -0
- package/scss/_toast.scss +5 -2
- package/scss/_view_switcher_bar.scss +10 -0
- package/scss/adwaita-skin.scss +3 -0
- package/src/adw-row-state.spec.ts +217 -0
- package/src/adw-toast-overlay.spec.ts +172 -0
- package/src/adw-view-switcher-bar.spec.ts +31 -0
- package/src/elements/adw-alert-dialog.ts +88 -57
- package/src/elements/adw-combo-row.ts +42 -15
- package/src/elements/adw-expander-row.ts +46 -10
- package/src/elements/adw-spin-row.ts +56 -32
- package/src/elements/adw-toast-overlay.ts +151 -39
- package/src/elements/adw-toggle-group.ts +30 -14
- package/src/style-isolation.spec.ts +36 -0
- package/src/styles.generated.ts +1 -1
- package/src/test.browser.mts +15 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/adwaita-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Adwaita/Libadwaita web components for browser targets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -41,13 +41,14 @@
|
|
|
41
41
|
"@codemirror/language": "^6.12.4",
|
|
42
42
|
"@codemirror/state": "^6.7.0",
|
|
43
43
|
"@codemirror/view": "^6.43.4",
|
|
44
|
-
"@gjsify/adwaita-
|
|
45
|
-
"@gjsify/adwaita-
|
|
44
|
+
"@gjsify/adwaita-core": "^0.23.0",
|
|
45
|
+
"@gjsify/adwaita-fonts": "^0.23.0",
|
|
46
|
+
"@gjsify/adwaita-icons": "^0.23.0",
|
|
46
47
|
"@lezer/highlight": "^1.2.3"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|
|
49
|
-
"@gjsify/cli": "^0.
|
|
50
|
-
"@gjsify/unit": "^0.
|
|
50
|
+
"@gjsify/cli": "^0.23.0",
|
|
51
|
+
"@gjsify/unit": "^0.23.0",
|
|
51
52
|
"sass": "^1.101.0",
|
|
52
53
|
"typescript": "^6.0.3"
|
|
53
54
|
},
|
package/scss/_reset.scss
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// _reset.scss — style-isolation boundary for embedding in arbitrary host pages.
|
|
2
|
+
//
|
|
3
|
+
// adwaita-web renders as LIGHT-DOM custom elements (by design: the widgets
|
|
4
|
+
// compose with the page and stay overridable via the `--adw-*` / `--*` design
|
|
5
|
+
// tokens). The trade-off of light DOM is that a host page's *inherited*
|
|
6
|
+
// typography — `font-family`, `color`, `line-height`, `letter-spacing`, … — and
|
|
7
|
+
// generic element resets can leak into the widgets.
|
|
8
|
+
//
|
|
9
|
+
// This partial establishes an Adwaita typographic + box-model context at EVERY
|
|
10
|
+
// component boundary, so inheritance from the host stops at the widget. It sets
|
|
11
|
+
// only a FLOOR: it is `@use`d before the component partials, so each component's
|
|
12
|
+
// own rules override it, and consumers keep full control through the tokens and
|
|
13
|
+
// ordinary higher-specificity rules (isolation without giving up overriding).
|
|
14
|
+
//
|
|
15
|
+
// The box battles (borders, backgrounds) are already won by the components'
|
|
16
|
+
// class-scoped rules against realistic element-level host CSS; the real leak is
|
|
17
|
+
// inherited typography + a host `* {}` box-sizing reset, which is what this
|
|
18
|
+
// neutralises.
|
|
19
|
+
|
|
20
|
+
// Every custom element adwaita-web defines. Keep in sync with
|
|
21
|
+
// `src/elements/*` (the `customElements.define('adw-…')` calls).
|
|
22
|
+
$adw-components:
|
|
23
|
+
adw-about-dialog, adw-action-row, adw-alert-dialog, adw-alert-response,
|
|
24
|
+
adw-avatar, adw-banner, adw-bottom-sheet, adw-bottom-sheet-content,
|
|
25
|
+
adw-bottom-sheet-sheet, adw-button, adw-button-content, adw-button-row,
|
|
26
|
+
adw-card, adw-carousel, adw-carousel-indicator-dots,
|
|
27
|
+
adw-carousel-indicator-lines, adw-clamp, adw-combo-row, adw-data-grid,
|
|
28
|
+
adw-dialog, adw-drop-down, adw-entry, adw-entry-row, adw-expander-row,
|
|
29
|
+
adw-header-bar, adw-inline-view-switcher, adw-menu-button, adw-navigation-page,
|
|
30
|
+
adw-navigation-split-view, adw-navigation-view, adw-overlay-split-view,
|
|
31
|
+
adw-password-entry-row, adw-preferences-dialog, adw-preferences-group,
|
|
32
|
+
adw-preferences-page, adw-sidebar, adw-sidebar-item, adw-sidebar-section,
|
|
33
|
+
adw-spinner, adw-spin-row, adw-split-button, adw-status-page, adw-switch-row,
|
|
34
|
+
adw-tab-page, adw-tab-view, adw-toast-overlay, adw-toggle, adw-toggle-group,
|
|
35
|
+
adw-toolbar-view, adw-view-stack, adw-view-stack-page, adw-view-switcher,
|
|
36
|
+
adw-view-switcher-bar, adw-view-switcher-page, adw-window, adw-window-title,
|
|
37
|
+
adw-wrap-box;
|
|
38
|
+
|
|
39
|
+
// Roots: re-root the inherited typographic properties (and the box model) so a
|
|
40
|
+
// host page's font / colour / spacing cannot flow into the widget. Every value
|
|
41
|
+
// is the Adwaita default; a component that wants something else overrides it on
|
|
42
|
+
// its own element (this rule is emitted first).
|
|
43
|
+
#{$adw-components} {
|
|
44
|
+
box-sizing: border-box;
|
|
45
|
+
color: var(--window-fg-color);
|
|
46
|
+
font-family: var(--font-family);
|
|
47
|
+
font-size: var(--font-size-base);
|
|
48
|
+
font-weight: 400;
|
|
49
|
+
font-style: normal;
|
|
50
|
+
font-variant: normal;
|
|
51
|
+
line-height: normal;
|
|
52
|
+
letter-spacing: normal;
|
|
53
|
+
word-spacing: normal;
|
|
54
|
+
text-align: start;
|
|
55
|
+
text-indent: 0;
|
|
56
|
+
text-transform: none;
|
|
57
|
+
text-shadow: none;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Descendants: `box-sizing` does not inherit, so a host `*, *::before {
|
|
61
|
+
// box-sizing: content-box }` reset would still reach internal elements. Pin it
|
|
62
|
+
// to `border-box` (adwaita's model throughout) inside every component.
|
|
63
|
+
@each $c in $adw-components {
|
|
64
|
+
#{$c} *,
|
|
65
|
+
#{$c} *::before,
|
|
66
|
+
#{$c} *::after {
|
|
67
|
+
box-sizing: border-box;
|
|
68
|
+
}
|
|
69
|
+
}
|
package/scss/_toast.scss
CHANGED
|
@@ -20,8 +20,11 @@ adw-toast-overlay {
|
|
|
20
20
|
height: 100%;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
//
|
|
24
|
-
//
|
|
23
|
+
// The toast slot: layered over the bottom edge of the overlay and centered,
|
|
24
|
+
// the way AdwToastOverlay floats a toast above its child. Like
|
|
25
|
+
// Adw.ToastOverlay only ONE toast is mounted at a time (the queue lives in
|
|
26
|
+
// `@gjsify/adwaita-core`); the flex column keeps it centered and lets an
|
|
27
|
+
// outgoing `.hiding` strip finish its exit transition in place.
|
|
25
28
|
.adw-toast-overlay-toasts {
|
|
26
29
|
position: absolute;
|
|
27
30
|
bottom: var(--spacing-m);
|
|
@@ -11,6 +11,16 @@
|
|
|
11
11
|
|
|
12
12
|
adw-view-switcher-bar {
|
|
13
13
|
display: block;
|
|
14
|
+
|
|
15
|
+
// `revealed` drives visibility by toggling the `hidden` attribute
|
|
16
|
+
// (_applyRevealed → this.hidden). Re-assert display:none for [hidden] as an
|
|
17
|
+
// AUTHOR rule: the UA `[hidden] { display: none }` loses to this block's own
|
|
18
|
+
// author `display: block` (cascade ORIGIN beats specificity), so without this
|
|
19
|
+
// an unrevealed bar would stay visible. Author + higher specificity → wins.
|
|
20
|
+
&[hidden] {
|
|
21
|
+
display: none;
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
box-sizing: border-box;
|
|
15
25
|
// The bottom bar over a headerbar-toned surface with a top hairline (the
|
|
16
26
|
// libadwaita `actionbar` inset top border), NOT a bottom border.
|
package/scss/adwaita-skin.scss
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
@use 'variables';
|
|
11
11
|
@use 'theme';
|
|
12
12
|
@use 'root';
|
|
13
|
+
// Style-isolation boundary — emitted before the components so their own rules
|
|
14
|
+
// override this typographic/box-model floor (see _reset.scss).
|
|
15
|
+
@use 'reset';
|
|
13
16
|
@use 'window';
|
|
14
17
|
@use 'headerbar';
|
|
15
18
|
@use 'window_title';
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// DOM-level behaviour tests for the rows whose state machines now come from
|
|
2
|
+
// `@gjsify/adwaita-core` (ADR 0004): <adw-combo-row> (ComboState),
|
|
3
|
+
// <adw-spin-row> (SpinState), <adw-toggle-group> (ToggleGroupState) and
|
|
4
|
+
// <adw-expander-row> (ExpanderState).
|
|
5
|
+
//
|
|
6
|
+
// They pin the contract the composition must preserve — most importantly the
|
|
7
|
+
// programmatic-vs-interactive split this port has always had: a user interaction
|
|
8
|
+
// emits the `notify::*` event, a programmatic property/attribute set updates the
|
|
9
|
+
// display silently.
|
|
10
|
+
import { describe, expect, it } from '@gjsify/unit';
|
|
11
|
+
|
|
12
|
+
import type { AdwComboRow } from './elements/adw-combo-row.js';
|
|
13
|
+
import type { AdwExpanderRow } from './elements/adw-expander-row.js';
|
|
14
|
+
import type { AdwSpinRow } from './elements/adw-spin-row.js';
|
|
15
|
+
import type { AdwToggleGroup } from './elements/adw-toggle-group.js';
|
|
16
|
+
|
|
17
|
+
/** Parse markup so attributes are present when connectedCallback runs. */
|
|
18
|
+
function parse<T extends HTMLElement>(html: string, selector: string): { el: T; host: HTMLElement } {
|
|
19
|
+
const host = document.createElement('div');
|
|
20
|
+
document.body.appendChild(host);
|
|
21
|
+
host.innerHTML = html;
|
|
22
|
+
return { el: host.querySelector(selector) as T, host };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Collect the `detail` of every `event` dispatched on `el` while `run()` executes. */
|
|
26
|
+
function record(el: HTMLElement, event: string): { details: unknown[]; stop: () => void } {
|
|
27
|
+
const details: unknown[] = [];
|
|
28
|
+
const listener = (e: Event) => details.push((e as CustomEvent).detail);
|
|
29
|
+
el.addEventListener(event, listener);
|
|
30
|
+
return { details, stop: () => el.removeEventListener(event, listener) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const AdwRowStateTest = async () => {
|
|
34
|
+
await describe('adw-combo-row (ComboState)', async () => {
|
|
35
|
+
await it('renders the selected label and notifies only on a user pick', async () => {
|
|
36
|
+
const { el: row, host } = parse<AdwComboRow>(
|
|
37
|
+
`<adw-combo-row title="Colour" items='["Red","Green","Blue"]' selected="1"></adw-combo-row>`,
|
|
38
|
+
'adw-combo-row',
|
|
39
|
+
);
|
|
40
|
+
const select = row.querySelector('select') as HTMLSelectElement;
|
|
41
|
+
const value = row.querySelector('.adw-row-value') as HTMLSpanElement;
|
|
42
|
+
const events = record(row, 'notify::selected');
|
|
43
|
+
|
|
44
|
+
expect(row.selected).toBe(1);
|
|
45
|
+
expect(value.textContent).toBe('Green');
|
|
46
|
+
expect(select.selectedIndex).toBe(1);
|
|
47
|
+
|
|
48
|
+
// A user pick through the native <select>.
|
|
49
|
+
select.selectedIndex = 2;
|
|
50
|
+
select.dispatchEvent(new Event('change'));
|
|
51
|
+
expect(row.selected).toBe(2);
|
|
52
|
+
expect(value.textContent).toBe('Blue');
|
|
53
|
+
expect(row.getAttribute('selected')).toBe('2');
|
|
54
|
+
expect(events.details.length).toBe(1);
|
|
55
|
+
expect((events.details[0] as { selected: number }).selected).toBe(2);
|
|
56
|
+
|
|
57
|
+
// A programmatic set refreshes the display but stays silent.
|
|
58
|
+
row.selected = 0;
|
|
59
|
+
expect(value.textContent).toBe('Red');
|
|
60
|
+
expect(select.selectedIndex).toBe(0);
|
|
61
|
+
expect(events.details.length).toBe(1);
|
|
62
|
+
|
|
63
|
+
events.stop();
|
|
64
|
+
host.remove();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await it('renders an empty value for an out-of-range selection', async () => {
|
|
68
|
+
const { el: row, host } = parse<AdwComboRow>(
|
|
69
|
+
`<adw-combo-row title="Empty" items='[]' selected="3"></adw-combo-row>`,
|
|
70
|
+
'adw-combo-row',
|
|
71
|
+
);
|
|
72
|
+
expect((row.querySelector('.adw-row-value') as HTMLSpanElement).textContent).toBe('');
|
|
73
|
+
host.remove();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await describe('adw-spin-row (SpinState)', async () => {
|
|
78
|
+
await it('steps at step precision and notifies only on a stepper press', async () => {
|
|
79
|
+
const { el: row, host } = parse<AdwSpinRow>(
|
|
80
|
+
'<adw-spin-row title="Amount" min="0" max="10" step="0.1" value="0"></adw-spin-row>',
|
|
81
|
+
'adw-spin-row',
|
|
82
|
+
);
|
|
83
|
+
const input = row.querySelector('input') as HTMLInputElement;
|
|
84
|
+
const inc = row.querySelector('.adw-spin-inc') as HTMLButtonElement;
|
|
85
|
+
const events = record(row, 'notify::value');
|
|
86
|
+
|
|
87
|
+
expect(input.value).toBe('0.0');
|
|
88
|
+
|
|
89
|
+
inc.click();
|
|
90
|
+
inc.click();
|
|
91
|
+
inc.click();
|
|
92
|
+
// Without the step-precision rounding this drifts to 0.30000000000000004.
|
|
93
|
+
expect(row.value).toBe(0.3);
|
|
94
|
+
expect(input.value).toBe('0.3');
|
|
95
|
+
expect(events.details.length).toBe(3);
|
|
96
|
+
|
|
97
|
+
// A programmatic set clamps to max and stays silent.
|
|
98
|
+
row.value = 99;
|
|
99
|
+
expect(row.value).toBe(10);
|
|
100
|
+
expect(input.value).toBe('10.0');
|
|
101
|
+
expect(events.details.length).toBe(3);
|
|
102
|
+
|
|
103
|
+
events.stop();
|
|
104
|
+
host.remove();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await it('clamps to the lower bound and re-clamps when a bound moves', async () => {
|
|
108
|
+
const { el: row, host } = parse<AdwSpinRow>(
|
|
109
|
+
'<adw-spin-row title="Amount" min="2" max="8" step="1" value="5"></adw-spin-row>',
|
|
110
|
+
'adw-spin-row',
|
|
111
|
+
);
|
|
112
|
+
const input = row.querySelector('input') as HTMLInputElement;
|
|
113
|
+
|
|
114
|
+
row.value = -4;
|
|
115
|
+
expect(row.value).toBe(2);
|
|
116
|
+
|
|
117
|
+
// Moving the upper bound below the value pulls the value into range.
|
|
118
|
+
row.value = 8;
|
|
119
|
+
row.setAttribute('max', '6');
|
|
120
|
+
expect(row.value).toBe(6);
|
|
121
|
+
expect(input.value).toBe('6');
|
|
122
|
+
|
|
123
|
+
host.remove();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await describe('adw-toggle-group (ToggleGroupState)', async () => {
|
|
128
|
+
await it('tracks the active segment and notifies only on a click', async () => {
|
|
129
|
+
const { el: group, host } = parse<AdwToggleGroup>(
|
|
130
|
+
`<adw-toggle-group active="1">
|
|
131
|
+
<adw-toggle label="One"></adw-toggle>
|
|
132
|
+
<adw-toggle label="Two"></adw-toggle>
|
|
133
|
+
<adw-toggle label="Three"></adw-toggle>
|
|
134
|
+
</adw-toggle-group>`,
|
|
135
|
+
'adw-toggle-group',
|
|
136
|
+
);
|
|
137
|
+
const buttons = Array.from(group.querySelectorAll('button.adw-toggle')) as HTMLButtonElement[];
|
|
138
|
+
const events = record(group, 'notify::active');
|
|
139
|
+
|
|
140
|
+
expect(buttons.length).toBe(3);
|
|
141
|
+
expect(group.active).toBe(1);
|
|
142
|
+
expect(buttons[1].classList.contains('active')).toBe(true);
|
|
143
|
+
expect(buttons[1].getAttribute('aria-pressed')).toBe('true');
|
|
144
|
+
|
|
145
|
+
buttons[2].click();
|
|
146
|
+
expect(group.active).toBe(2);
|
|
147
|
+
expect(buttons[2].classList.contains('active')).toBe(true);
|
|
148
|
+
expect(buttons[1].classList.contains('active')).toBe(false);
|
|
149
|
+
expect(group.getAttribute('active')).toBe('2');
|
|
150
|
+
expect(events.details.length).toBe(1);
|
|
151
|
+
|
|
152
|
+
// Re-clicking the active segment is a no-op.
|
|
153
|
+
buttons[2].click();
|
|
154
|
+
expect(events.details.length).toBe(1);
|
|
155
|
+
|
|
156
|
+
// A programmatic set moves the pill without notifying.
|
|
157
|
+
group.active = 0;
|
|
158
|
+
expect(group.active).toBe(0);
|
|
159
|
+
expect(buttons[0].classList.contains('active')).toBe(true);
|
|
160
|
+
expect(events.details.length).toBe(1);
|
|
161
|
+
|
|
162
|
+
events.stop();
|
|
163
|
+
host.remove();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await it('clamps an out-of-range active attribute to the last segment', async () => {
|
|
167
|
+
const { el: group, host } = parse<AdwToggleGroup>(
|
|
168
|
+
`<adw-toggle-group active="99">
|
|
169
|
+
<adw-toggle label="One"></adw-toggle>
|
|
170
|
+
<adw-toggle label="Two"></adw-toggle>
|
|
171
|
+
</adw-toggle-group>`,
|
|
172
|
+
'adw-toggle-group',
|
|
173
|
+
);
|
|
174
|
+
expect(group.active).toBe(1);
|
|
175
|
+
host.remove();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await describe('adw-expander-row (ExpanderState)', async () => {
|
|
180
|
+
await it('discloses on a header click and notifies only then', async () => {
|
|
181
|
+
const { el: row, host } = parse<AdwExpanderRow>(
|
|
182
|
+
'<adw-expander-row title="More"><adw-action-row title="Nested"></adw-action-row></adw-expander-row>',
|
|
183
|
+
'adw-expander-row',
|
|
184
|
+
);
|
|
185
|
+
const header = row.querySelector('.adw-expander-row-header') as HTMLDivElement;
|
|
186
|
+
const events = record(row, 'notify::expanded');
|
|
187
|
+
|
|
188
|
+
expect(row.expanded).toBe(false);
|
|
189
|
+
expect(row.classList.contains('expanded')).toBe(false);
|
|
190
|
+
|
|
191
|
+
header.click();
|
|
192
|
+
expect(row.expanded).toBe(true);
|
|
193
|
+
expect(row.hasAttribute('expanded')).toBe(true);
|
|
194
|
+
expect(row.classList.contains('expanded')).toBe(true);
|
|
195
|
+
expect(events.details.length).toBe(1);
|
|
196
|
+
|
|
197
|
+
// A programmatic collapse reflects to the attribute but stays silent.
|
|
198
|
+
row.expanded = false;
|
|
199
|
+
expect(row.hasAttribute('expanded')).toBe(false);
|
|
200
|
+
expect(row.classList.contains('expanded')).toBe(false);
|
|
201
|
+
expect(events.details.length).toBe(1);
|
|
202
|
+
|
|
203
|
+
events.stop();
|
|
204
|
+
host.remove();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await it('adopts a declarative expanded attribute', async () => {
|
|
208
|
+
const { el: row, host } = parse<AdwExpanderRow>(
|
|
209
|
+
'<adw-expander-row title="Open" expanded></adw-expander-row>',
|
|
210
|
+
'adw-expander-row',
|
|
211
|
+
);
|
|
212
|
+
expect(row.expanded).toBe(true);
|
|
213
|
+
expect(row.classList.contains('expanded')).toBe(true);
|
|
214
|
+
host.remove();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// DOM-level behaviour tests for <adw-toast-overlay>. Runs in a real browser via
|
|
2
|
+
// the @gjsify/adwaita-web browser test axis.
|
|
3
|
+
//
|
|
4
|
+
// The regression these guard: the overlay used to append EVERY toast as its own
|
|
5
|
+
// strip the moment `addToast()` was called, so three calls stacked three visible
|
|
6
|
+
// toasts and each ran its own timer. `Adw.ToastOverlay` shows exactly ONE toast
|
|
7
|
+
// at a time and queues the rest FIFO, advancing only when the visible one is
|
|
8
|
+
// dismissed (manually or by its timeout) — the policy `AdwToastQueue` in
|
|
9
|
+
// `@gjsify/adwaita-core` owns and this element now composes.
|
|
10
|
+
//
|
|
11
|
+
// Timing is driven through core's injected `ToastScheduler` seam (a virtual
|
|
12
|
+
// clock), so the auto-dismiss lifecycle is asserted deterministically with no
|
|
13
|
+
// real timers.
|
|
14
|
+
import { describe, expect, it } from '@gjsify/unit';
|
|
15
|
+
|
|
16
|
+
import type { AdwToastOverlay, ToastScheduler, ToastTimerHandle } from './elements/adw-toast-overlay.js';
|
|
17
|
+
|
|
18
|
+
/** A deterministic {@link ToastScheduler}: timers fire only when time is advanced. */
|
|
19
|
+
class VirtualClock implements ToastScheduler {
|
|
20
|
+
private _now = 0;
|
|
21
|
+
private _seq = 0;
|
|
22
|
+
private readonly _timers = new Map<number, { at: number; run: () => void }>();
|
|
23
|
+
|
|
24
|
+
schedule(callback: () => void, ms: number): ToastTimerHandle {
|
|
25
|
+
const handle = ++this._seq;
|
|
26
|
+
this._timers.set(handle, { at: this._now + ms, run: callback });
|
|
27
|
+
return handle;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
cancel(handle: ToastTimerHandle): void {
|
|
31
|
+
this._timers.delete(handle as number);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Move the clock forward, draining every timer that comes due. */
|
|
35
|
+
advance(ms: number): void {
|
|
36
|
+
this._now += ms;
|
|
37
|
+
// A fired timer may schedule another one that is already due (the exit
|
|
38
|
+
// fallback of a toast dismissed mid-advance), so drain until settled.
|
|
39
|
+
for (let guard = 0; guard < 100; guard++) {
|
|
40
|
+
const due = [...this._timers].filter(([, timer]) => timer.at <= this._now);
|
|
41
|
+
if (due.length === 0) return;
|
|
42
|
+
for (const [handle, timer] of due) {
|
|
43
|
+
this._timers.delete(handle);
|
|
44
|
+
timer.run();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw new Error('VirtualClock.advance did not settle — a timer keeps rescheduling itself');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Mount an overlay driven by a virtual clock instead of the browser's timers. */
|
|
52
|
+
function mountOverlay(): { overlay: AdwToastOverlay; clock: VirtualClock; host: HTMLElement } {
|
|
53
|
+
const host = document.createElement('div');
|
|
54
|
+
document.body.appendChild(host);
|
|
55
|
+
host.innerHTML = '<adw-toast-overlay><p>content</p></adw-toast-overlay>';
|
|
56
|
+
const overlay = host.querySelector('adw-toast-overlay') as AdwToastOverlay;
|
|
57
|
+
const clock = new VirtualClock();
|
|
58
|
+
overlay.scheduler = clock;
|
|
59
|
+
return { overlay, clock, host };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The titles of every toast strip currently in the overlay's DOM. */
|
|
63
|
+
function toastTitles(overlay: AdwToastOverlay): string[] {
|
|
64
|
+
return Array.from(overlay.querySelectorAll('.adw-toast .adw-toast-title')).map((el) => el.textContent ?? '');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const AdwToastOverlayTest = async () => {
|
|
68
|
+
await describe('adw-toast-overlay', async () => {
|
|
69
|
+
await it('shows exactly one toast at a time, in FIFO order', async () => {
|
|
70
|
+
const { overlay, clock, host } = mountOverlay();
|
|
71
|
+
|
|
72
|
+
overlay.addToast('First', { timeout: 3 });
|
|
73
|
+
overlay.addToast('Second', { timeout: 3 });
|
|
74
|
+
overlay.addToast('Third', { timeout: 3 });
|
|
75
|
+
|
|
76
|
+
// BEFORE the fix this was ['First', 'Second', 'Third'] — all three
|
|
77
|
+
// strips mounted at once.
|
|
78
|
+
expect(toastTitles(overlay)).toStrictEqual(['First']);
|
|
79
|
+
expect(overlay.pendingToasts).toBe(2);
|
|
80
|
+
expect(overlay.currentToast?.title).toBe('First');
|
|
81
|
+
|
|
82
|
+
// The next toast appears only once the previous one timed out.
|
|
83
|
+
clock.advance(3000);
|
|
84
|
+
expect(toastTitles(overlay)).toStrictEqual(['Second']);
|
|
85
|
+
expect(overlay.pendingToasts).toBe(1);
|
|
86
|
+
|
|
87
|
+
clock.advance(3000);
|
|
88
|
+
expect(toastTitles(overlay)).toStrictEqual(['Third']);
|
|
89
|
+
expect(overlay.pendingToasts).toBe(0);
|
|
90
|
+
|
|
91
|
+
// The last one fades out; the exit fallback detaches the strip.
|
|
92
|
+
clock.advance(3000);
|
|
93
|
+
expect(overlay.currentToast).toBeNull();
|
|
94
|
+
clock.advance(300);
|
|
95
|
+
expect(toastTitles(overlay)).toStrictEqual([]);
|
|
96
|
+
|
|
97
|
+
host.remove();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await it('holds the queue while a sticky toast (timeout 0) is shown', async () => {
|
|
101
|
+
const { overlay, clock, host } = mountOverlay();
|
|
102
|
+
|
|
103
|
+
overlay.addToast('Sticky', { timeout: 0 });
|
|
104
|
+
overlay.addToast('Next', { timeout: 3 });
|
|
105
|
+
|
|
106
|
+
// No timer was scheduled for the sticky toast, so time does not advance it.
|
|
107
|
+
clock.advance(60_000);
|
|
108
|
+
expect(toastTitles(overlay)).toStrictEqual(['Sticky']);
|
|
109
|
+
expect(overlay.pendingToasts).toBe(1);
|
|
110
|
+
|
|
111
|
+
// Dismissing it hands the slot to the queued toast.
|
|
112
|
+
overlay.dismiss();
|
|
113
|
+
expect(toastTitles(overlay)).toStrictEqual(['Next']);
|
|
114
|
+
expect(overlay.pendingToasts).toBe(0);
|
|
115
|
+
|
|
116
|
+
host.remove();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await it('advances the queue when the close button is pressed', async () => {
|
|
120
|
+
const { overlay, host } = mountOverlay();
|
|
121
|
+
|
|
122
|
+
overlay.addToast('First', { timeout: 3 });
|
|
123
|
+
overlay.addToast('Second', { timeout: 3 });
|
|
124
|
+
|
|
125
|
+
const close = overlay.querySelector('.adw-toast-close-button') as HTMLButtonElement;
|
|
126
|
+
close.click();
|
|
127
|
+
|
|
128
|
+
expect(toastTitles(overlay)).toStrictEqual(['Second']);
|
|
129
|
+
expect(overlay.pendingToasts).toBe(0);
|
|
130
|
+
|
|
131
|
+
host.remove();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await it('runs the action callback and advances the queue', async () => {
|
|
135
|
+
const { overlay, host } = mountOverlay();
|
|
136
|
+
|
|
137
|
+
let undone = 0;
|
|
138
|
+
overlay.addToast('File moved to Trash', {
|
|
139
|
+
timeout: 3,
|
|
140
|
+
buttonLabel: 'Undo',
|
|
141
|
+
onAction: () => {
|
|
142
|
+
undone += 1;
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
overlay.addToast('Second', { timeout: 3 });
|
|
146
|
+
|
|
147
|
+
const action = overlay.querySelector('.adw-toast-action-button') as HTMLButtonElement;
|
|
148
|
+
expect(action.textContent).toBe('Undo');
|
|
149
|
+
action.click();
|
|
150
|
+
|
|
151
|
+
expect(undone).toBe(1);
|
|
152
|
+
expect(toastTitles(overlay)).toStrictEqual(['Second']);
|
|
153
|
+
|
|
154
|
+
host.remove();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await it('drops the queued toasts on clearToasts()', async () => {
|
|
158
|
+
const { overlay, clock, host } = mountOverlay();
|
|
159
|
+
|
|
160
|
+
overlay.addToast('First', { timeout: 3 });
|
|
161
|
+
overlay.addToast('Second', { timeout: 3 });
|
|
162
|
+
overlay.clearToasts();
|
|
163
|
+
|
|
164
|
+
expect(overlay.currentToast).toBeNull();
|
|
165
|
+
expect(overlay.pendingToasts).toBe(0);
|
|
166
|
+
clock.advance(300);
|
|
167
|
+
expect(toastTitles(overlay)).toStrictEqual([]);
|
|
168
|
+
|
|
169
|
+
host.remove();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// adw-view-switcher-bar visibility regression (browser axis). Guards that the
|
|
2
|
+
// `revealed` flag actually shows/hides the bar. The bar's base scss sets
|
|
3
|
+
// `display: block`, an AUTHOR rule that would defeat the UA `[hidden]{display:none}`
|
|
4
|
+
// the component relies on (cascade origin beats specificity) — so an unrevealed
|
|
5
|
+
// bar rendered visible until scss/_view_switcher_bar.scss re-asserted
|
|
6
|
+
// `&[hidden] { display: none }`. This test pins that fix.
|
|
7
|
+
import { describe, expect, it } from '@gjsify/unit';
|
|
8
|
+
|
|
9
|
+
export const AdwViewSwitcherBarTest = async () => {
|
|
10
|
+
await describe('adw-view-switcher-bar revealed → visibility', async () => {
|
|
11
|
+
await it('is display:none by default (not revealed) and block once revealed', async () => {
|
|
12
|
+
const bar = document.createElement('adw-view-switcher-bar');
|
|
13
|
+
document.body.appendChild(bar);
|
|
14
|
+
|
|
15
|
+
// Default: `revealed` absent → the component sets `hidden`, and the
|
|
16
|
+
// scss `[hidden]` author rule must win over the base `display: block`.
|
|
17
|
+
expect(bar.hasAttribute('revealed')).toBe(false);
|
|
18
|
+
expect(getComputedStyle(bar).display).toBe('none');
|
|
19
|
+
|
|
20
|
+
// Reveal it → shown.
|
|
21
|
+
bar.setAttribute('revealed', '');
|
|
22
|
+
expect(getComputedStyle(bar).display).toBe('block');
|
|
23
|
+
|
|
24
|
+
// Hide it again → back to none (the round-trip, not a one-way latch).
|
|
25
|
+
bar.removeAttribute('revealed');
|
|
26
|
+
expect(getComputedStyle(bar).display).toBe('none');
|
|
27
|
+
|
|
28
|
+
bar.remove();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
};
|