@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.
Files changed (46) hide show
  1. package/dist/adwaita-web.css +118 -55
  2. package/dist/adwaita-web.css.map +1 -1
  3. package/lib/types/elements/adw-combo-row.d.ts +10 -0
  4. package/lib/types/elements/adw-inline-view-switcher.d.ts +37 -1
  5. package/lib/types/elements/adw-navigation-split-view.d.ts +42 -0
  6. package/lib/types/elements/adw-overlay-split-view.d.ts +56 -0
  7. package/lib/types/elements/adw-spinner.d.ts +23 -0
  8. package/lib/types/elements/adw-view-stack.d.ts +12 -0
  9. package/lib/types/elements/adw-view-switcher-bar.d.ts +7 -5
  10. package/lib/types/elements/adw-view-switcher.d.ts +55 -3
  11. package/lib/types/elements/adw-wrap-box.d.ts +58 -3
  12. package/lib/types/styles.generated.d.ts +1 -1
  13. package/package.json +6 -6
  14. package/scss/_combo_row.scss +19 -0
  15. package/scss/_navigation_split_view.scss +52 -9
  16. package/scss/_navigation_view.scss +28 -4
  17. package/scss/_overlay_split_view.scss +122 -33
  18. package/scss/_panes.scss +57 -0
  19. package/scss/_sidebar.scss +8 -1
  20. package/scss/_spinner.scss +35 -21
  21. package/scss/_tab_view.scss +12 -13
  22. package/scss/_theme.scss +9 -0
  23. package/scss/_variables.scss +18 -1
  24. package/scss/_wrap_box.scss +9 -0
  25. package/scss/adwaita-skin.scss +1 -0
  26. package/src/adw-about-dialog.spec.ts +36 -0
  27. package/src/adw-avatar.spec.ts +37 -0
  28. package/src/adw-button-content.spec.ts +24 -0
  29. package/src/adw-row-state.spec.ts +49 -0
  30. package/src/adw-sidebar.spec.ts +32 -0
  31. package/src/adw-wrap-box.spec.ts +148 -1
  32. package/src/chrome.spec.ts +111 -12
  33. package/src/elements/adw-action-row.ts +11 -1
  34. package/src/elements/adw-combo-row.ts +17 -0
  35. package/src/elements/adw-inline-view-switcher.ts +91 -8
  36. package/src/elements/adw-navigation-split-view.ts +189 -14
  37. package/src/elements/adw-overlay-split-view.ts +395 -20
  38. package/src/elements/adw-spinner.ts +165 -21
  39. package/src/elements/adw-tab-view.ts +20 -2
  40. package/src/elements/adw-view-stack.ts +21 -0
  41. package/src/elements/adw-view-switcher-bar.ts +13 -5
  42. package/src/elements/adw-view-switcher.ts +103 -11
  43. package/src/elements/adw-wrap-box.ts +290 -135
  44. package/src/split-views.spec.ts +677 -2
  45. package/src/styles.generated.ts +1 -1
  46. package/src/view-switcher.spec.ts +219 -0
@@ -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
@@ -66,6 +66,7 @@
66
66
  @use 'toast';
67
67
  @use 'toolbar_view';
68
68
  @use 'wrap_box';
69
+ @use 'panes';
69
70
  @use 'overlay_split_view';
70
71
  @use 'navigation_split_view';
71
72
  @use 'carousel';
@@ -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
  };
@@ -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 () => {
@@ -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
  };
@@ -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: number | string | null): string {
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
  };
@@ -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
- // The element is the ring itself, so only the square, allocatable
206
- // boxes describe what a `size` attribute can produce.
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(200);
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
- expect(spinner.offsetWidth).toBe(diameter);
216
- expect(spinner.offsetHeight).toBe(diameter);
217
- expect(Number.parseFloat(spinner.style.borderWidth)).toBe(lineWidth);
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(200);
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.parseFloat(spinner.style.borderWidth);
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 (adw_spinner_measure minimum = natural)', async () => {
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 ${spinnerGeometry(size, size).diameter}px ring — ${rule}`, () => {
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
- expect(spinner.offsetWidth).toBe(spinnerGeometry(size, size).diameter);
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) return;
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.