@gjsify/adwaita-web 0.32.0 → 0.34.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 +118 -55
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/elements/adw-combo-row.d.ts +10 -0
- package/lib/types/elements/adw-inline-view-switcher.d.ts +37 -1
- package/lib/types/elements/adw-navigation-split-view.d.ts +42 -0
- package/lib/types/elements/adw-overlay-split-view.d.ts +56 -0
- package/lib/types/elements/adw-spinner.d.ts +23 -0
- package/lib/types/elements/adw-view-stack.d.ts +12 -0
- package/lib/types/elements/adw-view-switcher-bar.d.ts +7 -5
- package/lib/types/elements/adw-view-switcher.d.ts +55 -3
- package/lib/types/elements/adw-wrap-box.d.ts +58 -3
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +6 -6
- package/scss/_combo_row.scss +19 -0
- package/scss/_navigation_split_view.scss +52 -9
- package/scss/_navigation_view.scss +28 -4
- package/scss/_overlay_split_view.scss +122 -33
- package/scss/_panes.scss +57 -0
- package/scss/_sidebar.scss +8 -1
- package/scss/_spinner.scss +35 -21
- package/scss/_tab_view.scss +12 -13
- package/scss/_theme.scss +9 -0
- package/scss/_variables.scss +18 -1
- package/scss/_wrap_box.scss +9 -0
- package/scss/adwaita-skin.scss +1 -0
- package/src/adw-about-dialog.spec.ts +36 -0
- package/src/adw-avatar.spec.ts +37 -0
- package/src/adw-button-content.spec.ts +24 -0
- package/src/adw-row-state.spec.ts +49 -0
- package/src/adw-sidebar.spec.ts +32 -0
- package/src/adw-wrap-box.spec.ts +148 -1
- package/src/chrome.spec.ts +111 -12
- package/src/elements/adw-action-row.ts +11 -1
- package/src/elements/adw-combo-row.ts +17 -0
- package/src/elements/adw-inline-view-switcher.ts +91 -8
- package/src/elements/adw-navigation-split-view.ts +189 -14
- package/src/elements/adw-overlay-split-view.ts +395 -20
- package/src/elements/adw-spinner.ts +165 -21
- package/src/elements/adw-tab-view.ts +20 -2
- package/src/elements/adw-view-stack.ts +21 -0
- package/src/elements/adw-view-switcher-bar.ts +13 -5
- package/src/elements/adw-view-switcher.ts +103 -11
- package/src/elements/adw-wrap-box.ts +290 -135
- package/src/split-views.spec.ts +677 -2
- package/src/styles.generated.ts +1 -1
- package/src/view-switcher.spec.ts +219 -0
package/scss/_wrap_box.scss
CHANGED
|
@@ -23,6 +23,15 @@ adw-wrap-box {
|
|
|
23
23
|
align-content: flex-start;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// `wrap-policy`, published by the component as a custom property. The FALLBACK
|
|
27
|
+
// is the load-bearing part: it is `ADW_WRAP_NATURAL`, libadwaita's default
|
|
28
|
+
// (adw-wrap-box.c:491-495), where CSS defaults `flex-shrink` to 1 — so a wrap
|
|
29
|
+
// box that has not upgraded yet, or one whose policy is unset, draws Adwaita's
|
|
30
|
+
// choice rather than the cascade's.
|
|
31
|
+
adw-wrap-box > * {
|
|
32
|
+
flex-shrink: var(--adw-wrap-box-child-shrink, 0);
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
// FILL grows the children and keeps the spacing constant
|
|
27
36
|
// (adw-wrap-layout.c:317-319, :326-341); SPREAD deliberately does NOT, so the
|
|
28
37
|
// gaps take the leftover instead. `data-line-justify` is the mode the component
|
package/scss/adwaita-skin.scss
CHANGED
|
@@ -347,4 +347,40 @@ export const AdwAboutDialogTest = async () => {
|
|
|
347
347
|
mounted.dispose();
|
|
348
348
|
});
|
|
349
349
|
});
|
|
350
|
+
|
|
351
|
+
// AN OPENED DIALOG HAS TO OCCUPY SPACE.
|
|
352
|
+
//
|
|
353
|
+
// Every other test in this file reads the built DOM, and all of them passed
|
|
354
|
+
// while the dialog rendered INVISIBLE — its sheet measured 360×0. The suite
|
|
355
|
+
// could not see that, because none of it looks at geometry.
|
|
356
|
+
//
|
|
357
|
+
// The sheet is `position: fixed` with a width and no height, which is what
|
|
358
|
+
// `adw-about-dialog.ui:7` declares: `content-width` 360 and no
|
|
359
|
+
// content-height, libadwaita taking the height from the content. The content
|
|
360
|
+
// could not supply one — the navigation pages were absolutely positioned, so
|
|
361
|
+
// they contributed nothing to the page stack, which contributed nothing to
|
|
362
|
+
// the view, which left the sheet at zero; and the pages, being `inset: 0` of
|
|
363
|
+
// a zero-height parent, were zero in turn.
|
|
364
|
+
await describe('adw-about-dialog — an opened dialog is actually on screen', async () => {
|
|
365
|
+
await it('gives its sheet a height derived from its content', () => {
|
|
366
|
+
const mounted = mount({ 'application-name': 'Adwaita Storybook', version: '0.11.0' });
|
|
367
|
+
mounted.dialog.setAttribute('open', '');
|
|
368
|
+
|
|
369
|
+
const sheet = mounted.dialog.querySelector('.adw-about-dialog-sheet') as HTMLElement;
|
|
370
|
+
const rect = sheet.getBoundingClientRect();
|
|
371
|
+
// HEIGHT only. The width was never the defect, and pinning it here
|
|
372
|
+
// needs `calc(100vw - 48px)` restated in JS — where `100vw` and
|
|
373
|
+
// `innerWidth` disagree by the scrollbar (360 against a measured
|
|
374
|
+
// 342), so the assertion would report the harness rather than the
|
|
375
|
+
// widget. A test should assert the rule the bug broke.
|
|
376
|
+
expect(rect.height > 0).toBe(true);
|
|
377
|
+
// And not merely the header: the sheet has to be taller than the one
|
|
378
|
+
// child that had a size of its own all along, which is what made the
|
|
379
|
+
// collapse invisible to every other test in this file.
|
|
380
|
+
const header = sheet.querySelector('adw-header-bar') as HTMLElement;
|
|
381
|
+
expect(rect.height > header.getBoundingClientRect().height).toBe(true);
|
|
382
|
+
|
|
383
|
+
mounted.dispose();
|
|
384
|
+
});
|
|
385
|
+
});
|
|
350
386
|
};
|
package/src/adw-avatar.spec.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// them. This suite is that comparison.
|
|
10
10
|
import { describe, expect, it } from '@gjsify/unit';
|
|
11
11
|
|
|
12
|
+
import { AVATAR_MODE_VECTORS } from '@gjsify/adwaita-core/conformance';
|
|
13
|
+
|
|
12
14
|
import { AVATAR_COLORS, avatarMaxFontSize } from '@gjsify/adwaita-core';
|
|
13
15
|
import {
|
|
14
16
|
AVATAR_COLOR_VECTORS,
|
|
@@ -136,4 +138,39 @@ export const AdwAvatarTest = async () => {
|
|
|
136
138
|
host.remove();
|
|
137
139
|
});
|
|
138
140
|
});
|
|
141
|
+
|
|
142
|
+
await describe('<adw-avatar> mode (update_visibility, adw-avatar.c:117)', async () => {
|
|
143
|
+
// The `hasCustomImage` rows are not reachable through this element: it
|
|
144
|
+
// passes `hasCustomImage: false` unconditionally, because it has no
|
|
145
|
+
// custom-image support at all — `Adw.Avatar:custom-image` is unported.
|
|
146
|
+
// Named below rather than silently filtered.
|
|
147
|
+
for (const { hasCustomImage, showInitials, text, mode, rule } of AVATAR_MODE_VECTORS) {
|
|
148
|
+
if (hasCustomImage) continue;
|
|
149
|
+
await it(`initials=${showInitials} "${text}" → ${mode} — ${rule}`, () => {
|
|
150
|
+
const host = document.createElement('div');
|
|
151
|
+
document.body.appendChild(host);
|
|
152
|
+
const avatar = document.createElement('adw-avatar');
|
|
153
|
+
if (showInitials) avatar.setAttribute('show-initials', '');
|
|
154
|
+
if (text) avatar.setAttribute('text', text);
|
|
155
|
+
host.appendChild(avatar);
|
|
156
|
+
|
|
157
|
+
// Exactly ONE of the two reachable modes is drawn — the point of
|
|
158
|
+
// the table is the precedence, and "both visible at once" is the
|
|
159
|
+
// failure it guards against.
|
|
160
|
+
const initials = (avatar.querySelector('.adw-avatar-text') as HTMLElement).hidden === false;
|
|
161
|
+
const icon = (avatar.querySelector('.adw-avatar-icon') as HTMLElement).hidden === false;
|
|
162
|
+
expect(initials).toBe(mode === 'initials');
|
|
163
|
+
expect(icon).toBe(mode === 'icon');
|
|
164
|
+
host.remove();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await it('leaves out only the custom-image rows, which this element cannot reach', () => {
|
|
169
|
+
const unreachable = AVATAR_MODE_VECTORS.filter((vector) => vector.hasCustomImage);
|
|
170
|
+
expect(unreachable.length).toBeGreaterThan(0);
|
|
171
|
+
// Every one of them is an `image` outcome, i.e. the whole mode is
|
|
172
|
+
// missing rather than a corner of it.
|
|
173
|
+
for (const vector of unreachable) expect(vector.mode).toBe('image');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
139
176
|
};
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
// `image-missing` (:355-356).
|
|
14
14
|
import { describe, expect, it } from '@gjsify/unit';
|
|
15
15
|
|
|
16
|
+
import { BUTTON_CONTENT_DEFAULT_VECTORS } from '@gjsify/adwaita-core/conformance';
|
|
17
|
+
|
|
16
18
|
import { BUTTON_CONTENT_STYLE_CLASS, normalizeIconName } from '@gjsify/adwaita-core';
|
|
17
19
|
import {
|
|
18
20
|
BUTTON_CONTENT_ELLIPSIZE_VECTORS,
|
|
@@ -180,4 +182,26 @@ export const AdwButtonContentTest = async () => {
|
|
|
180
182
|
});
|
|
181
183
|
}
|
|
182
184
|
});
|
|
185
|
+
|
|
186
|
+
await describe('<adw-button-content> the four GParamSpec defaults', async () => {
|
|
187
|
+
for (const { property, value, rule } of BUTTON_CONTENT_DEFAULT_VECTORS) {
|
|
188
|
+
await it(`${property} defaults to ${JSON.stringify(value)} — ${rule}`, () => {
|
|
189
|
+
const host = document.createElement('div');
|
|
190
|
+
document.body.appendChild(host);
|
|
191
|
+
const content = document.createElement('adw-button-content');
|
|
192
|
+
host.appendChild(content);
|
|
193
|
+
|
|
194
|
+
// A bare element must READ BACK the C's defaults. The table was
|
|
195
|
+
// driven by nobody, so an element that invented its own defaults
|
|
196
|
+
// — `use-underline` true, say, which is the banner button's — would
|
|
197
|
+
// have looked correct.
|
|
198
|
+
const actual =
|
|
199
|
+
typeof value === 'boolean'
|
|
200
|
+
? content.hasAttribute(property)
|
|
201
|
+
: (content.getAttribute(property) ?? '');
|
|
202
|
+
expect(actual).toStrictEqual(value);
|
|
203
|
+
host.remove();
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
});
|
|
183
207
|
};
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// display silently.
|
|
10
10
|
import { describe, expect, it } from '@gjsify/unit';
|
|
11
11
|
|
|
12
|
+
import { COMBO_CHOOSER_VECTORS } from '@gjsify/adwaita-core/conformance';
|
|
13
|
+
|
|
12
14
|
import type { AdwComboRow } from './elements/adw-combo-row.js';
|
|
13
15
|
import type { AdwExpanderRow } from './elements/adw-expander-row.js';
|
|
14
16
|
import type { AdwSpinRow } from './elements/adw-spin-row.js';
|
|
@@ -72,6 +74,53 @@ export const AdwRowStateTest = async () => {
|
|
|
72
74
|
expect((row.querySelector('.adw-row-value') as HTMLSpanElement).textContent).toBe('');
|
|
73
75
|
host.remove();
|
|
74
76
|
});
|
|
77
|
+
|
|
78
|
+
for (const { count, presentsChooser, rule } of COMBO_CHOOSER_VECTORS) {
|
|
79
|
+
await it(`${count} option(s) → ${presentsChooser ? 'a chooser' : 'not a chooser'}: ${rule}`, async () => {
|
|
80
|
+
const items = JSON.stringify(Array.from({ length: count }, (_, i) => `Item ${i}`));
|
|
81
|
+
const { el: row, host } = parse<AdwComboRow>(
|
|
82
|
+
`<adw-combo-row title="Pick" items='${items}'></adw-combo-row>`,
|
|
83
|
+
'adw-combo-row',
|
|
84
|
+
);
|
|
85
|
+
const value = row.querySelector('.adw-row-value') as HTMLSpanElement;
|
|
86
|
+
const select = row.querySelector('select') as HTMLSelectElement;
|
|
87
|
+
|
|
88
|
+
// The ARROW: `.adw-row-value::after` is masked in, and the
|
|
89
|
+
// `[data-presents-chooser='false']` block removes it. Asserted
|
|
90
|
+
// as "not none" rather than against the authored `inline-block`,
|
|
91
|
+
// because `.adw-row-value` is a flex container and blockifies
|
|
92
|
+
// its children's display.
|
|
93
|
+
expect(getComputedStyle(value, '::after').display === 'none').toBe(!presentsChooser);
|
|
94
|
+
// ...and the ROW being activatable, which on this port is the
|
|
95
|
+
// overlaid <select> taking clicks at all. Hiding the arrow
|
|
96
|
+
// without this leaves a row that still opens a one-entry popup.
|
|
97
|
+
expect(select.disabled).toBe(!presentsChooser);
|
|
98
|
+
|
|
99
|
+
host.remove();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
await it('gains its arrow back when the model grows past one item', async () => {
|
|
104
|
+
const { el: row, host } = parse<AdwComboRow>(
|
|
105
|
+
`<adw-combo-row title="Pick" items='["Only"]'></adw-combo-row>`,
|
|
106
|
+
'adw-combo-row',
|
|
107
|
+
);
|
|
108
|
+
const select = row.querySelector('select') as HTMLSelectElement;
|
|
109
|
+
expect(select.disabled).toBe(true);
|
|
110
|
+
|
|
111
|
+
// `items` is read at connect only, so the model is replaced through
|
|
112
|
+
// the state the element composes — the same path a consumer has.
|
|
113
|
+
(
|
|
114
|
+
row as unknown as { _state: { setOptions(o: { label: string; value: string }[]): void } }
|
|
115
|
+
)._state.setOptions([
|
|
116
|
+
{ label: 'One', value: 'a' },
|
|
117
|
+
{ label: 'Two', value: 'b' },
|
|
118
|
+
]);
|
|
119
|
+
expect(select.disabled).toBe(false);
|
|
120
|
+
expect(row.dataset.presentsChooser).toBe('true');
|
|
121
|
+
|
|
122
|
+
host.remove();
|
|
123
|
+
});
|
|
75
124
|
});
|
|
76
125
|
|
|
77
126
|
await describe('adw-spin-row (SpinState)', async () => {
|
package/src/adw-sidebar.spec.ts
CHANGED
|
@@ -329,4 +329,36 @@ export const AdwSidebarTest = async () => {
|
|
|
329
329
|
host.remove();
|
|
330
330
|
});
|
|
331
331
|
});
|
|
332
|
+
|
|
333
|
+
// A row's own box, which nothing had ever measured.
|
|
334
|
+
//
|
|
335
|
+
// `.adw-sidebar-item` carried `width: 100%` ON TOP of `margin: 0 6px 2px`.
|
|
336
|
+
// The list is a column flex container, so a row already stretches to it and
|
|
337
|
+
// stretching subtracts margins; `width: 100%` resolves against the
|
|
338
|
+
// containing block instead, which does not, so every row came out 12px too
|
|
339
|
+
// wide — inset on the left, hanging past the right edge with its rounded
|
|
340
|
+
// corners clipped away. Every earlier test asked what a row CONTAINED, so
|
|
341
|
+
// all of them passed.
|
|
342
|
+
await describe('a sidebar row sits inside its list, not over its edge', async () => {
|
|
343
|
+
await it('leaves the same margin on both sides', async () => {
|
|
344
|
+
const { sidebar, host } = mountSidebar([{ items: [{ title: 'Inbox' }, { title: 'Starred' }] }], {
|
|
345
|
+
selected: '0',
|
|
346
|
+
});
|
|
347
|
+
host.setAttribute('style', 'width:220px');
|
|
348
|
+
|
|
349
|
+
const list = sidebar.querySelector('.adw-sidebar-list') as HTMLElement;
|
|
350
|
+
const listBox = list.getBoundingClientRect();
|
|
351
|
+
|
|
352
|
+
for (const row of rowsOf(sidebar)) {
|
|
353
|
+
const box = row.getBoundingClientRect();
|
|
354
|
+
const left = box.left - listBox.left;
|
|
355
|
+
const right = listBox.right - box.right;
|
|
356
|
+
// Symmetric, and INSIDE — a negative right gap is the overhang.
|
|
357
|
+
expect(Math.round(left)).toBe(Math.round(right));
|
|
358
|
+
expect(right >= 0).toBe(true);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
host.remove();
|
|
362
|
+
});
|
|
363
|
+
});
|
|
332
364
|
};
|
package/src/adw-wrap-box.spec.ts
CHANGED
|
@@ -23,7 +23,12 @@
|
|
|
23
23
|
import { describe, expect, it } from '@gjsify/unit';
|
|
24
24
|
|
|
25
25
|
import {
|
|
26
|
+
WRAP_BOX_CHILD_ORDER_VECTORS,
|
|
27
|
+
WRAP_BOX_LENGTH_VECTORS,
|
|
26
28
|
WRAP_BOX_LINE_VECTORS,
|
|
29
|
+
WRAP_BOX_NATURAL_LENGTH_VECTORS,
|
|
30
|
+
WRAP_BOX_NOTIFY_PROPERTIES,
|
|
31
|
+
WRAP_BOX_POLICY_VECTORS,
|
|
27
32
|
WRAP_BOX_SPACING_NOTIFY_VECTORS,
|
|
28
33
|
WRAP_BOX_SPACING_VECTORS,
|
|
29
34
|
} from '@gjsify/adwaita-core/conformance';
|
|
@@ -49,7 +54,7 @@ function unmountAll(): void {
|
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
/** A vector value as it reads in a test name (`JSON.stringify` flattens NaN to null). */
|
|
52
|
-
function label(value:
|
|
57
|
+
function label(value: unknown): string {
|
|
53
58
|
return typeof value === 'string' ? JSON.stringify(value) : String(value);
|
|
54
59
|
}
|
|
55
60
|
|
|
@@ -191,4 +196,146 @@ export const AdwWrapBoxTest = async () => {
|
|
|
191
196
|
});
|
|
192
197
|
}
|
|
193
198
|
});
|
|
199
|
+
|
|
200
|
+
await describe('<adw-wrap-box> the properties neither port had (#1048)', async () => {
|
|
201
|
+
for (const { value, policy, flexShrink, rule } of WRAP_BOX_POLICY_VECTORS) {
|
|
202
|
+
await it(`wrap-policy ${label(value)} → ${policy}: ${rule}`, () => {
|
|
203
|
+
const attrs: Record<string, string> = {};
|
|
204
|
+
if (value !== null) attrs['wrap-policy'] = String(value);
|
|
205
|
+
const { box, children } = mountWrapBox(attrs);
|
|
206
|
+
expect((box as HTMLElement & { wrapPolicy: string }).wrapPolicy).toBe(policy);
|
|
207
|
+
expect(getComputedStyle(children[0]).flexShrink).toBe(String(flexShrink));
|
|
208
|
+
unmountAll();
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await it('a box with no wrap-policy forbids shrinking — CSS defaults to the OTHER one', () => {
|
|
213
|
+
const { children } = mountWrapBox({});
|
|
214
|
+
expect(getComputedStyle(children[0]).flexShrink).toBe('0');
|
|
215
|
+
unmountAll();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
for (const { value, unit, dpi, px, rule } of WRAP_BOX_LENGTH_VECTORS) {
|
|
219
|
+
// The element resolves at the default dpi; the scaled rows are the
|
|
220
|
+
// core suite's, since a browser has no `gtk-xft-dpi` to report.
|
|
221
|
+
if (dpi !== 96 || value < 0) continue;
|
|
222
|
+
await it(`child-spacing ${value}${String(unit)} → ${px}px: ${rule}`, () => {
|
|
223
|
+
const attrs: Record<string, string> = { 'child-spacing': String(value) };
|
|
224
|
+
if (unit !== null) attrs['child-spacing-unit'] = String(unit);
|
|
225
|
+
const { box } = mountWrapBox(attrs);
|
|
226
|
+
expect(getComputedStyle(box).columnGap).toBe(`${px}px`);
|
|
227
|
+
unmountAll();
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for (const { value, length, rule } of WRAP_BOX_NATURAL_LENGTH_VECTORS) {
|
|
232
|
+
await it(`natural-line-length ${label(value)} → ${length}: ${rule}`, () => {
|
|
233
|
+
const attrs: Record<string, string> = {};
|
|
234
|
+
if (value !== null) attrs['natural-line-length'] = String(value);
|
|
235
|
+
const { box } = mountWrapBox(attrs);
|
|
236
|
+
expect((box as HTMLElement & { naturalLineLength: number }).naturalLineLength).toBe(length);
|
|
237
|
+
// -1 is UNSET, not a length: it must not reach the style at all.
|
|
238
|
+
expect(box.style.maxWidth).toBe(length < 0 ? '' : `${length}px`);
|
|
239
|
+
unmountAll();
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
await it('a VERTICAL box caps the block axis, because that is where its lines run', () => {
|
|
244
|
+
const { box } = mountWrapBox({ orientation: 'vertical', 'natural-line-length': '400' });
|
|
245
|
+
expect(box.style.maxHeight).toBe('400px');
|
|
246
|
+
expect(box.style.maxWidth).toBe('');
|
|
247
|
+
unmountAll();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await it('pack-direction and wrap-reverse reverse the axes they own', () => {
|
|
251
|
+
const packed = mountWrapBox({ 'pack-direction': 'end-to-start' });
|
|
252
|
+
expect(getComputedStyle(packed.box).flexDirection).toBe('row-reverse');
|
|
253
|
+
unmountAll();
|
|
254
|
+
|
|
255
|
+
const reversed = mountWrapBox({ 'wrap-reverse': '' });
|
|
256
|
+
expect(getComputedStyle(reversed.box).flexWrap).toBe('wrap-reverse');
|
|
257
|
+
unmountAll();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
await it('notifies for EVERY property, not just the two spacings', () => {
|
|
261
|
+
const { box } = mountWrapBox({});
|
|
262
|
+
const seen: string[] = [];
|
|
263
|
+
for (const property of WRAP_BOX_NOTIFY_PROPERTIES) {
|
|
264
|
+
box.addEventListener(`notify::${property}`, () => seen.push(property));
|
|
265
|
+
}
|
|
266
|
+
// One real change per property, each different from the default.
|
|
267
|
+
box.setAttribute('child-spacing', '6');
|
|
268
|
+
box.setAttribute('child-spacing-unit', 'sp');
|
|
269
|
+
box.setAttribute('pack-direction', 'end-to-start');
|
|
270
|
+
box.setAttribute('align', '1');
|
|
271
|
+
box.setAttribute('justify', 'fill');
|
|
272
|
+
box.toggleAttribute('justify-last-line', true);
|
|
273
|
+
box.setAttribute('line-spacing', '4');
|
|
274
|
+
box.setAttribute('line-spacing-unit', 'pt');
|
|
275
|
+
box.toggleAttribute('line-homogeneous', true);
|
|
276
|
+
box.setAttribute('natural-line-length', '400');
|
|
277
|
+
box.setAttribute('natural-line-length-unit', 'sp');
|
|
278
|
+
box.toggleAttribute('wrap-reverse', true);
|
|
279
|
+
box.setAttribute('wrap-policy', 'minimum');
|
|
280
|
+
box.setAttribute('orientation', 'vertical');
|
|
281
|
+
|
|
282
|
+
expect([...seen].sort()).toStrictEqual([...WRAP_BOX_NOTIFY_PROPERTIES].sort());
|
|
283
|
+
unmountAll();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await it('and stays silent for a value that normalises to what it already holds', () => {
|
|
287
|
+
const { box } = mountWrapBox({ 'wrap-policy': 'minimum' });
|
|
288
|
+
let notified = 0;
|
|
289
|
+
box.addEventListener('notify::wrap-policy', () => {
|
|
290
|
+
notified++;
|
|
291
|
+
});
|
|
292
|
+
box.setAttribute('wrap-policy', 'not-a-policy'); // → the default, a change
|
|
293
|
+
expect(notified).toBe(1);
|
|
294
|
+
box.setAttribute('wrap-policy', 'also-not-a-policy'); // → the default again
|
|
295
|
+
expect(notified).toBe(1);
|
|
296
|
+
unmountAll();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await describe('<adw-wrap-box> child list (insert/reorder after, remove_all)', async () => {
|
|
301
|
+
for (const { op, children, child, sibling, result, rule } of WRAP_BOX_CHILD_ORDER_VECTORS) {
|
|
302
|
+
const name = `${op} ${child} after ${sibling ?? 'NULL'} in [${children.join(',')}]`;
|
|
303
|
+
await it(`${name}: ${rule}`, () => {
|
|
304
|
+
const box = document.createElement('adw-wrap-box') as HTMLElement & {
|
|
305
|
+
insertChildAfter(c: Element, s?: Element | null): boolean;
|
|
306
|
+
reorderChildAfter(c: Element, s?: Element | null): boolean;
|
|
307
|
+
};
|
|
308
|
+
document.body.appendChild(box);
|
|
309
|
+
const byId = new Map<string, HTMLElement>();
|
|
310
|
+
const make = (id: string) => {
|
|
311
|
+
const view = document.createElement('div');
|
|
312
|
+
view.dataset.id = id;
|
|
313
|
+
byId.set(id, view);
|
|
314
|
+
return view;
|
|
315
|
+
};
|
|
316
|
+
for (const id of children) box.appendChild(make(id));
|
|
317
|
+
// The child being placed may be one of the mounted ones (a
|
|
318
|
+
// reorder, or the "already parented" refusal) or a fresh one.
|
|
319
|
+
const target = byId.get(child) ?? make(child);
|
|
320
|
+
// A sibling the box does not have still has to be a real element.
|
|
321
|
+
const after = sibling === null ? null : (byId.get(sibling) ?? make(sibling));
|
|
322
|
+
|
|
323
|
+
const ok =
|
|
324
|
+
op === 'insert-after' ? box.insertChildAfter(target, after) : box.reorderChildAfter(target, after);
|
|
325
|
+
expect(ok).toBe(result !== null);
|
|
326
|
+
const order = [...box.children].map((view) => (view as HTMLElement).dataset.id);
|
|
327
|
+
expect(order).toStrictEqual(result === null ? [...children] : [...result]);
|
|
328
|
+
unmountAll();
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
await it('removeAll empties the box, leaving the box itself mounted', () => {
|
|
333
|
+
const { box } = mountWrapBox({}, 4);
|
|
334
|
+
expect(box.children.length).toBe(4);
|
|
335
|
+
(box as HTMLElement & { removeAll(): void }).removeAll();
|
|
336
|
+
expect(box.children.length).toBe(0);
|
|
337
|
+
expect(box.isConnected).toBe(true);
|
|
338
|
+
unmountAll();
|
|
339
|
+
});
|
|
340
|
+
});
|
|
194
341
|
};
|
package/src/chrome.spec.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// faking one.
|
|
15
15
|
import { describe, expect, it } from '@gjsify/unit';
|
|
16
16
|
|
|
17
|
-
import { ADW_SPINNER_MIN_SIZE, spinnerGeometry } from '@gjsify/adwaita-core';
|
|
17
|
+
import { ADW_SPINNER_MIN_SIZE, ADW_SPINNER_TRACK_OPACITY, spinnerGeometry } from '@gjsify/adwaita-core';
|
|
18
18
|
import {
|
|
19
19
|
CLAMP_ALLOCATE_VECTORS,
|
|
20
20
|
CLAMP_PROPERTY_VECTORS,
|
|
@@ -201,52 +201,86 @@ export const AdwChromeTest = async () => {
|
|
|
201
201
|
});
|
|
202
202
|
|
|
203
203
|
await describe('adw-spinner geometry (AdwSpinnerPaintable conformance vectors)', async () => {
|
|
204
|
+
/** The `<svg>` the element draws its ring into. */
|
|
205
|
+
const ringOf = (spinner: HTMLElement) => spinner.querySelector('svg') as SVGSVGElement;
|
|
206
|
+
|
|
204
207
|
for (const { width, height, diameter, lineWidth, rule } of SPINNER_GEOMETRY_VECTORS) {
|
|
205
|
-
//
|
|
206
|
-
//
|
|
208
|
+
// Only square, allocatable boxes describe what a `size` attribute
|
|
209
|
+
// can produce; the rectangular rows exercise the paintable directly.
|
|
207
210
|
if (width !== height || width < ADW_SPINNER_MIN_SIZE) continue;
|
|
208
211
|
|
|
209
212
|
await it(`size ${width} draws a ${diameter}px ring stroked ${lineWidth}px — ${rule}`, () => {
|
|
210
|
-
const { host } = mountSized(
|
|
213
|
+
const { host } = mountSized(400);
|
|
211
214
|
const spinner = document.createElement('adw-spinner');
|
|
212
215
|
spinner.setAttribute('size', String(width));
|
|
213
216
|
host.appendChild(spinner);
|
|
214
217
|
|
|
215
|
-
|
|
216
|
-
expect(
|
|
217
|
-
expect(
|
|
218
|
+
const svg = ringOf(spinner);
|
|
219
|
+
expect(svg.getAttribute('width')).toBe(String(diameter));
|
|
220
|
+
expect(svg.getAttribute('height')).toBe(String(diameter));
|
|
221
|
+
const arc = svg.querySelectorAll('circle')[1] as SVGCircleElement;
|
|
222
|
+
expect(Number(arc.getAttribute('stroke-width'))).toBe(lineWidth);
|
|
218
223
|
host.remove();
|
|
219
224
|
});
|
|
220
225
|
}
|
|
221
226
|
|
|
222
227
|
await it('strokes 3px at 24 and 6px at 48, where it used to stroke 2 and 4', () => {
|
|
223
|
-
const { host } = mountSized(
|
|
228
|
+
const { host } = mountSized(400);
|
|
224
229
|
const spinner = document.createElement('adw-spinner');
|
|
225
230
|
host.appendChild(spinner);
|
|
226
231
|
const strokeAt = (size: number) => {
|
|
227
232
|
spinner.setAttribute('size', String(size));
|
|
228
|
-
return Number
|
|
233
|
+
return Number(ringOf(spinner).querySelector('circle')?.getAttribute('stroke-width'));
|
|
229
234
|
};
|
|
230
235
|
expect(strokeAt(24)).toBe(3);
|
|
231
236
|
expect(strokeAt(48)).toBe(6);
|
|
232
237
|
expect(strokeAt(64)).toBe(8);
|
|
233
238
|
host.remove();
|
|
234
239
|
});
|
|
240
|
+
|
|
241
|
+
await it('rounds its arc ends — GSK_LINE_CAP_ROUND, square-cut before', () => {
|
|
242
|
+
const { host } = mountSized(400);
|
|
243
|
+
const spinner = document.createElement('adw-spinner');
|
|
244
|
+
spinner.setAttribute('size', '64');
|
|
245
|
+
host.appendChild(spinner);
|
|
246
|
+
const [track, arc] = [...ringOf(spinner).querySelectorAll('circle')];
|
|
247
|
+
expect(arc.getAttribute('stroke-linecap')).toBe('round');
|
|
248
|
+
// The TRACK is the widget colour at CIRCLE_OPACITY, not a grey.
|
|
249
|
+
expect(track.getAttribute('stroke')).toBe('currentColor');
|
|
250
|
+
expect(Number(track.getAttribute('stroke-opacity'))).toBe(ADW_SPINNER_TRACK_OPACITY);
|
|
251
|
+
host.remove();
|
|
252
|
+
});
|
|
235
253
|
});
|
|
236
254
|
|
|
237
|
-
await describe('adw-spinner sizing
|
|
255
|
+
await describe('adw-spinner sizing — the BOX and the RING are different things', async () => {
|
|
238
256
|
for (const { value, size, rule } of SPINNER_SIZE_VECTORS) {
|
|
239
|
-
await it(`size ${JSON.stringify(value ?? null)} → a ${
|
|
257
|
+
await it(`size ${JSON.stringify(value ?? null)} → a ${size}px box — ${rule}`, () => {
|
|
240
258
|
const { host } = mountSized(400);
|
|
241
259
|
const spinner = document.createElement('adw-spinner');
|
|
242
260
|
if (value !== null && value !== undefined) spinner.setAttribute('size', String(value));
|
|
243
261
|
host.appendChild(spinner);
|
|
244
262
|
|
|
245
|
-
|
|
263
|
+
// THE BOX takes the whole request — this suite used to assert the
|
|
264
|
+
// ring's diameter here, contradicting the core table it drives.
|
|
265
|
+
expect(spinner.offsetWidth).toBe(size);
|
|
266
|
+
expect(spinner.offsetHeight).toBe(size);
|
|
267
|
+
// THE RING is capped and centred inside it.
|
|
268
|
+
const svg = spinner.querySelector('svg') as SVGSVGElement;
|
|
269
|
+
expect(Number(svg.getAttribute('width'))).toBe(spinnerGeometry(size, size).diameter);
|
|
246
270
|
host.remove();
|
|
247
271
|
});
|
|
248
272
|
}
|
|
249
273
|
|
|
274
|
+
await it('a 200px spinner occupies 200px and draws a 64px ring', () => {
|
|
275
|
+
const { host } = mountSized(400);
|
|
276
|
+
const spinner = document.createElement('adw-spinner');
|
|
277
|
+
spinner.setAttribute('size', '200');
|
|
278
|
+
host.appendChild(spinner);
|
|
279
|
+
expect(spinner.offsetWidth).toBe(200);
|
|
280
|
+
expect(Number((spinner.querySelector('svg') as SVGSVGElement).getAttribute('width'))).toBe(64);
|
|
281
|
+
host.remove();
|
|
282
|
+
});
|
|
283
|
+
|
|
250
284
|
await it('defaults to the measured 16, not the invented 24', () => {
|
|
251
285
|
const { host } = mountSized(400);
|
|
252
286
|
const spinner = document.createElement('adw-spinner');
|
|
@@ -256,6 +290,71 @@ export const AdwChromeTest = async () => {
|
|
|
256
290
|
});
|
|
257
291
|
});
|
|
258
292
|
|
|
293
|
+
await describe('adw-spinner animation + accessibility (#1066)', async () => {
|
|
294
|
+
await it('announces itself — role progressbar, aria-busy (zero hits before)', () => {
|
|
295
|
+
const { host } = mountSized(400);
|
|
296
|
+
const spinner = document.createElement('adw-spinner');
|
|
297
|
+
host.appendChild(spinner);
|
|
298
|
+
expect(spinner.getAttribute('role')).toBe('progressbar');
|
|
299
|
+
expect(spinner.getAttribute('aria-busy')).toBe('true');
|
|
300
|
+
host.remove();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
await it('BREATHES — the drawn arc length changes across a turn', () => {
|
|
304
|
+
const { host } = mountSized(400);
|
|
305
|
+
const spinner = document.createElement('adw-spinner') as HTMLElement & { drawAt(now: number): void };
|
|
306
|
+
spinner.setAttribute('size', '64');
|
|
307
|
+
host.appendChild(spinner);
|
|
308
|
+
const arc = spinner.querySelectorAll('circle')[1] as SVGCircleElement;
|
|
309
|
+
|
|
310
|
+
const drawnAt = (ms: number) => {
|
|
311
|
+
spinner.drawAt(ms);
|
|
312
|
+
return Number.parseFloat((arc.getAttribute('stroke-dasharray') ?? '0').split(' ')[0]);
|
|
313
|
+
};
|
|
314
|
+
// `drawAt` takes the FIRST call as the origin, so these are offsets
|
|
315
|
+
// into one animation, sampled across a cycle.
|
|
316
|
+
const lengths = [0, 200, 500, 900, 1300, 1900].map(drawnAt);
|
|
317
|
+
const spread = Math.max(...lengths) - Math.min(...lengths);
|
|
318
|
+
// A fixed 90-degree border chase — what this element used to draw —
|
|
319
|
+
// has a spread of exactly zero.
|
|
320
|
+
expect(spread).toBeGreaterThan(0);
|
|
321
|
+
host.remove();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await it('KEEPS SPINNING under reduced motion — a frozen busy indicator reads as a hang', () => {
|
|
325
|
+
// libadwaita opts the spinner out of the animation setting on purpose
|
|
326
|
+
// (adw-spinner-paintable.c:537). The old stylesheet had
|
|
327
|
+
// `@media (prefers-reduced-motion: reduce) { animation: none }`, so
|
|
328
|
+
// this asserts the ABSENCE of any CSS animation to switch off: the
|
|
329
|
+
// arc is driven per frame from JS and cannot be disabled by a query.
|
|
330
|
+
const { host } = mountSized(400);
|
|
331
|
+
const spinner = document.createElement('adw-spinner');
|
|
332
|
+
host.appendChild(spinner);
|
|
333
|
+
expect(getComputedStyle(spinner).animationName).toBe('none');
|
|
334
|
+
host.remove();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await it('advances while mapped and stops when it is not', () => {
|
|
338
|
+
const { host } = mountSized(400);
|
|
339
|
+
const spinner = document.createElement('adw-spinner') as HTMLElement & { drawAt(now: number): void };
|
|
340
|
+
host.appendChild(spinner);
|
|
341
|
+
const arc = spinner.querySelectorAll('circle')[1] as SVGCircleElement;
|
|
342
|
+
spinner.drawAt(0);
|
|
343
|
+
const first = arc.getAttribute('stroke-dashoffset');
|
|
344
|
+
spinner.drawAt(400);
|
|
345
|
+
expect(arc.getAttribute('stroke-dashoffset')).not.toBe(first);
|
|
346
|
+
|
|
347
|
+
// Unmapping removes it from the shared ticker — `widget_map_cb`
|
|
348
|
+
// (adw-spinner-paintable.c:181-185) is what plays the animation, so
|
|
349
|
+
// an off-screen spinner burns nothing.
|
|
350
|
+
spinner.remove();
|
|
351
|
+
const parked = arc.getAttribute('stroke-dashoffset');
|
|
352
|
+
spinner.drawAt(800); // only reachable directly; the ticker has dropped it
|
|
353
|
+
expect(arc.getAttribute('stroke-dashoffset')).not.toBe(parked);
|
|
354
|
+
host.remove();
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
259
358
|
await describe('adw-toolbar-view style classes (update_undershoots + the style setters)', async () => {
|
|
260
359
|
for (const { input, view, topBar, bottomBar, rule } of TOOLBAR_VIEW_CLASS_VECTORS) {
|
|
261
360
|
await it(`${input.topBarStyle}/${input.bottomBarStyle} → [${view.join(' ')}] — ${rule}`, () => {
|
|
@@ -84,7 +84,17 @@ export class AdwActionRow extends HTMLElement {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
connectedCallback() {
|
|
87
|
-
if (this._initialized)
|
|
87
|
+
if (this._initialized) {
|
|
88
|
+
// Re-entering a document. `disconnectedCallback` drops the
|
|
89
|
+
// sensitivity observer, and returning here without rebuilding it
|
|
90
|
+
// left a MOVED row permanently blind to its activatable widget
|
|
91
|
+
// becoming insensitive — the same shape that broke the overlay
|
|
92
|
+
// split view's ResizeObserver and both view switchers' page
|
|
93
|
+
// observers. Anything torn down on the way out has to be rebuilt
|
|
94
|
+
// on the way back in.
|
|
95
|
+
this._observeSensitivity(this._state.activatableWidget);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
88
98
|
this._initialized = true;
|
|
89
99
|
|
|
90
100
|
const prefixChildren = Array.from(this.querySelectorAll(':scope > [slot="prefix"]'));
|
|
@@ -85,6 +85,7 @@ export class AdwComboRow extends HTMLElement {
|
|
|
85
85
|
this._state.subscribe((change) => {
|
|
86
86
|
this._valueEl.textContent = change.label;
|
|
87
87
|
if (this._select.selectedIndex !== change.selected) this._select.selectedIndex = change.selected;
|
|
88
|
+
this._syncChooser();
|
|
88
89
|
if (!change.interactive) return;
|
|
89
90
|
this.setAttribute('selected', String(change.selected));
|
|
90
91
|
this.dispatchEvent(
|
|
@@ -101,6 +102,7 @@ export class AdwComboRow extends HTMLElement {
|
|
|
101
102
|
this._state.select(this._select.selectedIndex);
|
|
102
103
|
});
|
|
103
104
|
|
|
105
|
+
this._syncChooser();
|
|
104
106
|
this._renderText();
|
|
105
107
|
}
|
|
106
108
|
|
|
@@ -115,6 +117,21 @@ export class AdwComboRow extends HTMLElement {
|
|
|
115
117
|
}
|
|
116
118
|
}
|
|
117
119
|
|
|
120
|
+
/**
|
|
121
|
+
* `model_changed` (adw-combo-row.c:187-195) — one option or none is not a
|
|
122
|
+
* choice, so the arrow goes and the row stops being activatable.
|
|
123
|
+
*
|
|
124
|
+
* Both halves have to be expressed: `data-presents-chooser` gates the
|
|
125
|
+
* `.adw-row-value::after` mask in the stylesheet, and DISABLING the overlaid
|
|
126
|
+
* `<select>` is what makes the row inert — hiding the arrow alone would leave
|
|
127
|
+
* a row that still opens a one-entry popup on click.
|
|
128
|
+
*/
|
|
129
|
+
private _syncChooser() {
|
|
130
|
+
const presents = this._state.presentsChooser;
|
|
131
|
+
this.dataset.presentsChooser = presents ? 'true' : 'false';
|
|
132
|
+
this._select.disabled = !presents;
|
|
133
|
+
}
|
|
134
|
+
|
|
118
135
|
private _renderText() {
|
|
119
136
|
// The `string_is_not_empty` label rule, from core — this block was one of
|
|
120
137
|
// six hand-rolled copies, all of which omitted the TITLE half.
|