@gjsify/adwaita-web 0.31.0 → 0.33.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 (183) hide show
  1. package/dist/adwaita-web.css +835 -299
  2. package/dist/adwaita-web.css.map +1 -1
  3. package/lib/types/adw-about-dialog.spec.d.ts +1 -0
  4. package/lib/types/adw-action-rows.spec.d.ts +1 -0
  5. package/lib/types/adw-avatar.spec.d.ts +1 -0
  6. package/lib/types/adw-banner.spec.d.ts +1 -0
  7. package/lib/types/adw-button-content.spec.d.ts +1 -0
  8. package/lib/types/adw-carousel.spec.d.ts +1 -0
  9. package/lib/types/adw-checks.spec.d.ts +1 -0
  10. package/lib/types/adw-header-bar.spec.d.ts +1 -0
  11. package/lib/types/adw-icon.spec.d.ts +1 -0
  12. package/lib/types/adw-progress-bar.spec.d.ts +1 -0
  13. package/lib/types/adw-sidebar.spec.d.ts +1 -0
  14. package/lib/types/adw-switch.spec.d.ts +1 -0
  15. package/lib/types/adw-wrap-box.spec.d.ts +1 -0
  16. package/lib/types/bottom-sheet.spec.d.ts +1 -0
  17. package/lib/types/breakpoints.d.ts +22 -0
  18. package/lib/types/breakpoints.spec.d.ts +1 -0
  19. package/lib/types/chrome.spec.d.ts +1 -0
  20. package/lib/types/elements/adw-about-dialog.d.ts +47 -8
  21. package/lib/types/elements/adw-action-row.d.ts +35 -0
  22. package/lib/types/elements/adw-avatar.d.ts +11 -0
  23. package/lib/types/elements/adw-bottom-sheet.d.ts +33 -4
  24. package/lib/types/elements/adw-button-content.d.ts +12 -0
  25. package/lib/types/elements/adw-button-row.d.ts +10 -1
  26. package/lib/types/elements/adw-carousel.d.ts +111 -8
  27. package/lib/types/elements/adw-checks.d.ts +57 -0
  28. package/lib/types/elements/adw-clamp.d.ts +4 -1
  29. package/lib/types/elements/adw-combo-row.d.ts +10 -0
  30. package/lib/types/elements/adw-data-grid.d.ts +11 -31
  31. package/lib/types/elements/adw-drop-down.d.ts +17 -20
  32. package/lib/types/elements/adw-entry-row.d.ts +93 -3
  33. package/lib/types/elements/adw-expander-row.d.ts +7 -1
  34. package/lib/types/elements/adw-header-bar.d.ts +15 -0
  35. package/lib/types/elements/adw-icon.d.ts +25 -0
  36. package/lib/types/elements/adw-inline-view-switcher.d.ts +73 -11
  37. package/lib/types/elements/adw-menu-button.d.ts +6 -6
  38. package/lib/types/elements/adw-navigation-split-view.d.ts +48 -0
  39. package/lib/types/elements/adw-navigation-view.d.ts +63 -20
  40. package/lib/types/elements/adw-overlay-split-view.d.ts +94 -0
  41. package/lib/types/elements/adw-password-entry-row.d.ts +19 -10
  42. package/lib/types/elements/adw-popover.d.ts +58 -0
  43. package/lib/types/elements/adw-preferences-dialog.d.ts +57 -2
  44. package/lib/types/elements/adw-preferences-group.d.ts +31 -0
  45. package/lib/types/elements/adw-progress-bar.d.ts +38 -0
  46. package/lib/types/elements/adw-sidebar.d.ts +37 -5
  47. package/lib/types/elements/adw-spinner.d.ts +23 -0
  48. package/lib/types/elements/adw-split-button.d.ts +53 -7
  49. package/lib/types/elements/adw-switch-row.d.ts +11 -2
  50. package/lib/types/elements/adw-switch.d.ts +15 -0
  51. package/lib/types/elements/adw-tab-view.d.ts +129 -12
  52. package/lib/types/elements/adw-toolbar-view.d.ts +5 -0
  53. package/lib/types/elements/adw-view-stack.d.ts +39 -19
  54. package/lib/types/elements/adw-view-switcher-bar.d.ts +25 -7
  55. package/lib/types/elements/adw-view-switcher.d.ts +76 -13
  56. package/lib/types/elements/adw-window-title.d.ts +3 -1
  57. package/lib/types/elements/adw-wrap-box.d.ts +58 -3
  58. package/lib/types/elements/view-switcher-dom.d.ts +44 -0
  59. package/lib/types/entry-rows.spec.d.ts +1 -0
  60. package/lib/types/index.d.ts +7 -0
  61. package/lib/types/navigation-view.spec.d.ts +1 -0
  62. package/lib/types/preferences-search.d.ts +20 -0
  63. package/lib/types/preferences.spec.d.ts +1 -0
  64. package/lib/types/split-button.spec.d.ts +1 -0
  65. package/lib/types/split-views.spec.d.ts +1 -0
  66. package/lib/types/styles.generated.d.ts +1 -1
  67. package/lib/types/tab-view.spec.d.ts +1 -0
  68. package/lib/types/view-stack.spec.d.ts +1 -0
  69. package/lib/types/view-switcher.spec.d.ts +1 -0
  70. package/package.json +6 -6
  71. package/scss/_banner.scss +13 -0
  72. package/scss/_bottom_sheet.scss +5 -6
  73. package/scss/_button.scss +3 -26
  74. package/scss/_button_content.scss +28 -0
  75. package/scss/_checks.scss +228 -0
  76. package/scss/_clamp.scss +9 -3
  77. package/scss/_combo_row.scss +19 -0
  78. package/scss/_data_grid.scss +15 -7
  79. package/scss/_drop_down.scss +12 -55
  80. package/scss/_expander_row.scss +5 -50
  81. package/scss/_icon.scss +51 -0
  82. package/scss/_icons.generated.scss +6 -0
  83. package/scss/_inline_view_switcher.scss +34 -0
  84. package/scss/_labels.scss +135 -0
  85. package/scss/_menu_button.scss +10 -70
  86. package/scss/_navigation_split_view.scss +47 -8
  87. package/scss/_overlay_split_view.scss +117 -32
  88. package/scss/_popover.scss +186 -0
  89. package/scss/_preferences.scss +29 -0
  90. package/scss/_preferences_dialog.scss +17 -0
  91. package/scss/_progress_bar.scss +161 -0
  92. package/scss/_reset.scss +18 -7
  93. package/scss/_spinner.scss +37 -19
  94. package/scss/_split_button.scss +6 -50
  95. package/scss/_switch.scss +90 -0
  96. package/scss/_switch_row.scss +6 -52
  97. package/scss/_tab_view.scss +63 -0
  98. package/scss/_theme.scss +10 -0
  99. package/scss/_toolbar_view.scss +74 -0
  100. package/scss/_variables.scss +34 -0
  101. package/scss/_view_switcher.scss +40 -0
  102. package/scss/_view_switcher_bar.scss +37 -0
  103. package/scss/_wrap_box.scss +65 -6
  104. package/scss/adwaita-skin.scss +23 -0
  105. package/src/adw-about-dialog.spec.ts +350 -0
  106. package/src/adw-action-rows.spec.ts +342 -0
  107. package/src/adw-avatar.spec.ts +176 -0
  108. package/src/adw-banner.spec.ts +152 -0
  109. package/src/adw-button-content.spec.ts +207 -0
  110. package/src/adw-button-row.spec.ts +12 -6
  111. package/src/adw-carousel.spec.ts +389 -0
  112. package/src/adw-checks.spec.ts +368 -0
  113. package/src/adw-drop-down.spec.ts +123 -0
  114. package/src/adw-header-bar.spec.ts +83 -0
  115. package/src/adw-icon.spec.ts +167 -0
  116. package/src/adw-progress-bar.spec.ts +173 -0
  117. package/src/adw-row-state.spec.ts +49 -0
  118. package/src/adw-sidebar.spec.ts +332 -0
  119. package/src/adw-switch.spec.ts +242 -0
  120. package/src/adw-tab-view.spec.ts +12 -3
  121. package/src/adw-view-switcher-bar.spec.ts +61 -13
  122. package/src/adw-wrap-box.spec.ts +341 -0
  123. package/src/bottom-sheet.spec.ts +325 -0
  124. package/src/breakpoints.spec.ts +147 -0
  125. package/src/breakpoints.ts +67 -0
  126. package/src/chrome.spec.ts +400 -0
  127. package/src/elements/adw-about-dialog.ts +223 -163
  128. package/src/elements/adw-action-row.ts +131 -11
  129. package/src/elements/adw-avatar.ts +48 -54
  130. package/src/elements/adw-banner.ts +46 -10
  131. package/src/elements/adw-bottom-sheet.ts +145 -60
  132. package/src/elements/adw-button-content.ts +107 -33
  133. package/src/elements/adw-button-row.ts +61 -37
  134. package/src/elements/adw-button.ts +5 -7
  135. package/src/elements/adw-carousel.ts +467 -105
  136. package/src/elements/adw-checks.ts +299 -0
  137. package/src/elements/adw-clamp.ts +80 -10
  138. package/src/elements/adw-combo-row.ts +32 -7
  139. package/src/elements/adw-data-grid.ts +43 -91
  140. package/src/elements/adw-drop-down.ts +117 -128
  141. package/src/elements/adw-entry-row.ts +404 -36
  142. package/src/elements/adw-expander-row.ts +61 -29
  143. package/src/elements/adw-header-bar.ts +31 -6
  144. package/src/elements/adw-icon.ts +148 -0
  145. package/src/elements/adw-inline-view-switcher.ts +325 -131
  146. package/src/elements/adw-menu-button.ts +93 -70
  147. package/src/elements/adw-navigation-split-view.ts +220 -14
  148. package/src/elements/adw-navigation-view.ts +286 -148
  149. package/src/elements/adw-overlay-split-view.ts +536 -28
  150. package/src/elements/adw-password-entry-row.ts +111 -89
  151. package/src/elements/adw-popover.ts +283 -0
  152. package/src/elements/adw-preferences-dialog.ts +120 -21
  153. package/src/elements/adw-preferences-group.ts +128 -22
  154. package/src/elements/adw-progress-bar.ts +189 -0
  155. package/src/elements/adw-sidebar.ts +301 -142
  156. package/src/elements/adw-spin-row.ts +11 -5
  157. package/src/elements/adw-spinner.ts +168 -9
  158. package/src/elements/adw-split-button.ts +371 -92
  159. package/src/elements/adw-status-page.ts +9 -9
  160. package/src/elements/adw-switch-row.ts +100 -30
  161. package/src/elements/adw-switch.ts +129 -0
  162. package/src/elements/adw-tab-view.ts +700 -129
  163. package/src/elements/adw-toggle-group.ts +5 -7
  164. package/src/elements/adw-toolbar-view.ts +84 -21
  165. package/src/elements/adw-view-stack.ts +183 -85
  166. package/src/elements/adw-view-switcher-bar.ts +171 -81
  167. package/src/elements/adw-view-switcher.ts +328 -117
  168. package/src/elements/adw-window-title.ts +44 -11
  169. package/src/elements/adw-wrap-box.ts +329 -78
  170. package/src/elements/view-switcher-dom.ts +108 -0
  171. package/src/entry-rows.spec.ts +391 -0
  172. package/src/index.ts +23 -0
  173. package/src/navigation-view.spec.ts +435 -0
  174. package/src/preferences-search.ts +135 -0
  175. package/src/preferences.spec.ts +250 -0
  176. package/src/source-view/adw-source-view.ts +2 -5
  177. package/src/split-button.spec.ts +470 -0
  178. package/src/split-views.spec.ts +893 -0
  179. package/src/styles.generated.ts +1 -1
  180. package/src/tab-view.spec.ts +451 -0
  181. package/src/test.browser.mts +49 -0
  182. package/src/view-stack.spec.ts +272 -0
  183. package/src/view-switcher.spec.ts +665 -0
@@ -0,0 +1,400 @@
1
+ // DOM-level conformance tests for the chrome elements — <adw-clamp>,
2
+ // <adw-spinner> and <adw-toolbar-view> — driven by the SAME vectors the
3
+ // NativeScript renderer asserts against (`@gjsify/adwaita-core/conformance`).
4
+ //
5
+ // Each of the three used to answer a question libadwaita had already answered,
6
+ // and answered it differently from the NativeScript port: the clamp was a plain
7
+ // `max-width` with no tightening and a `maximum-size="0"` that collapsed the
8
+ // child to nothing, the spinner defaulted to 24px and stroked a 2px ring where
9
+ // Adwaita strokes 3px, and the toolbar view carried none of the four style
10
+ // classes at all. Nothing failed, because nothing compared them.
11
+ //
12
+ // Sizes are driven by really resizing a host element and awaiting a real
13
+ // ResizeObserver delivery, the same way `breakpoints.spec.ts` does — never by
14
+ // faking one.
15
+ import { describe, expect, it } from '@gjsify/unit';
16
+
17
+ import { ADW_SPINNER_MIN_SIZE, ADW_SPINNER_TRACK_OPACITY, spinnerGeometry } from '@gjsify/adwaita-core';
18
+ import {
19
+ CLAMP_ALLOCATE_VECTORS,
20
+ CLAMP_PROPERTY_VECTORS,
21
+ SPINNER_GEOMETRY_VECTORS,
22
+ SPINNER_SIZE_VECTORS,
23
+ TOOLBAR_VIEW_CLASS_VECTORS,
24
+ } from '@gjsify/adwaita-core/conformance';
25
+
26
+ /** Wait for a ResizeObserver delivery (it runs after layout, before paint). */
27
+ function settle(): Promise<void> {
28
+ return new Promise((resolve) => {
29
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
30
+ });
31
+ }
32
+
33
+ /** A fixed-width host in the document, plus a way to resize it. */
34
+ function mountSized(width: number): { host: HTMLElement; resize: (w: number) => Promise<void> } {
35
+ const host = document.createElement('div');
36
+ host.style.width = `${width}px`;
37
+ document.body.appendChild(host);
38
+ return {
39
+ host,
40
+ resize: async (w: number) => {
41
+ host.style.width = `${w}px`;
42
+ await settle();
43
+ },
44
+ };
45
+ }
46
+
47
+ /** A clamp with one block child, mounted in a host of `available` px. */
48
+ function mountClamp(available: number): {
49
+ host: HTMLElement;
50
+ clamp: HTMLElement;
51
+ child: HTMLElement;
52
+ resize: (w: number) => Promise<void>;
53
+ } {
54
+ const { host, resize } = mountSized(available);
55
+ const clamp = document.createElement('adw-clamp');
56
+ const child = document.createElement('div');
57
+ // A bare block child fills the clamp and is capped by the max-width the
58
+ // element writes — the same shape every story uses.
59
+ child.textContent = 'clamped';
60
+ clamp.appendChild(child);
61
+ host.appendChild(clamp);
62
+ return { host, clamp, child, resize };
63
+ }
64
+
65
+ /** The child width a clamp settles on, rounded to the pixel it renders at. */
66
+ function clampedWidth(child: HTMLElement): number {
67
+ return Math.round(child.getBoundingClientRect().width);
68
+ }
69
+
70
+ /** The three classes `adw_clamp_layout_allocate` stamps on the child. */
71
+ function sizeClassOf(child: HTMLElement): string | null {
72
+ for (const cls of ['small', 'medium', 'large']) {
73
+ if (child.classList.contains(cls)) return cls;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /** A toolbar view with bars of the given pixel heights (0 = no bar at all). */
79
+ function mountToolbarView(topBarHeight: number, bottomBarHeight: number): { host: HTMLElement; view: HTMLElement } {
80
+ const { host } = mountSized(400);
81
+ host.style.height = '300px';
82
+ const view = document.createElement('adw-toolbar-view');
83
+ for (const [slot, height] of [
84
+ ['top', topBarHeight],
85
+ ['bottom', bottomBarHeight],
86
+ ] as const) {
87
+ if (height <= 0) continue;
88
+ const bar = document.createElement('div');
89
+ bar.setAttribute('slot', slot);
90
+ bar.style.height = `${height}px`;
91
+ view.appendChild(bar);
92
+ }
93
+ host.appendChild(view);
94
+ return { host, view };
95
+ }
96
+
97
+ export const AdwChromeTest = async () => {
98
+ await describe('adw-clamp allocation (AdwClampLayout conformance vectors)', async () => {
99
+ // The browser resolves the child's own minimum through the normal
100
+ // `min-width` cascade, so only the rows that pass a zero child minimum
101
+ // describe what this element can be held to.
102
+ const vectors = CLAMP_ALLOCATE_VECTORS.filter((v) => v.params.childMin === 0);
103
+
104
+ for (const { availableSize, params, childSize, sizeClass, offset, rule } of vectors) {
105
+ await it(`${availableSize}px available → a ${childSize}px ${sizeClass} child — ${rule}`, async () => {
106
+ const { host, clamp, child } = mountClamp(availableSize);
107
+ clamp.setAttribute('maximum-size', String(params.maximumSize));
108
+ clamp.setAttribute('tightening-threshold', String(params.tighteningThreshold));
109
+ await settle();
110
+
111
+ expect(clampedWidth(child)).toBe(childSize);
112
+ expect(sizeClassOf(child)).toBe(sizeClass);
113
+ // `margin-inline: auto` splits the remainder evenly; libadwaita
114
+ // truncates, so the two can differ by half a pixel.
115
+ const leading = child.getBoundingClientRect().left - clamp.getBoundingClientRect().left;
116
+ expect(Math.abs(leading - offset) <= 1).toBe(true);
117
+
118
+ host.remove();
119
+ });
120
+ }
121
+
122
+ await it('follows the viewport instead of pinning the child to the maximum', async () => {
123
+ // The NativeScript twin's bug in browser form: a narrow clamp must
124
+ // hand the child ALL of the available width, not the cap.
125
+ const { host, clamp, child, resize } = mountClamp(1000);
126
+ await settle();
127
+ expect(clampedWidth(child)).toBe(600);
128
+ await resize(360);
129
+ expect(clampedWidth(child)).toBe(360);
130
+ expect(Math.round(clamp.getBoundingClientRect().width)).toBe(360);
131
+ host.remove();
132
+ });
133
+
134
+ await it('honours tightening-threshold, which the element used to ignore', async () => {
135
+ // The storybook has exposed this control all along and only the GTK
136
+ // pane responded to it.
137
+ const { host, clamp, child } = mountClamp(700);
138
+ clamp.setAttribute('maximum-size', '600');
139
+ clamp.setAttribute('tightening-threshold', '400');
140
+ await settle();
141
+ expect(clampedWidth(child)).toBe(575);
142
+
143
+ // Raising the threshold above the maximum removes the easing.
144
+ clamp.setAttribute('tightening-threshold', '800');
145
+ await settle();
146
+ expect(clampedWidth(child)).toBe(600);
147
+ host.remove();
148
+ });
149
+
150
+ await it('clamps children appended after it was connected', async () => {
151
+ // The element only saw its children once, at connect time — the same
152
+ // "evaluated once" shape the toolbar view's empty-bar handling has.
153
+ const { host } = mountSized(1000);
154
+ const clamp = document.createElement('adw-clamp');
155
+ host.appendChild(clamp);
156
+ await settle();
157
+
158
+ const late = document.createElement('div');
159
+ late.textContent = 'appended later';
160
+ clamp.appendChild(late);
161
+ await settle();
162
+
163
+ expect(clampedWidth(late)).toBe(600);
164
+ host.remove();
165
+ });
166
+ });
167
+
168
+ await describe('adw-clamp maximum-size parsing (g_param_spec_int range)', async () => {
169
+ for (const { value, fallback, size, rule } of CLAMP_PROPERTY_VECTORS) {
170
+ // Only the rows whose fallback is the real property default describe
171
+ // what an attribute on THIS element resolves to.
172
+ if (fallback !== 600) continue;
173
+
174
+ await it(`maximum-size ${JSON.stringify(value ?? null)} caps at ${size}px — ${rule}`, async () => {
175
+ const { host, clamp, child } = mountClamp(1000);
176
+ // `null`/`undefined` are the ABSENT attribute, so nothing is set.
177
+ if (value !== null && value !== undefined) clamp.setAttribute('maximum-size', String(value));
178
+ await settle();
179
+
180
+ // 1000px is past `upper` for every cap in the table, so the child
181
+ // sits exactly at the resolved property and it is readable off
182
+ // the rendered width.
183
+ expect(clampedWidth(child)).toBe(size);
184
+ host.remove();
185
+ });
186
+ }
187
+
188
+ await it('recovers the default after an unparsable value, instead of freezing on the old cap', async () => {
189
+ // `parseFloat('abc')` produced `max-width: NaNpx`, which the CSSOM
190
+ // rejects — so the element silently kept whatever cap it had.
191
+ const { host, clamp, child } = mountClamp(1000);
192
+ clamp.setAttribute('maximum-size', '300');
193
+ await settle();
194
+ expect(clampedWidth(child)).toBe(300);
195
+
196
+ clamp.setAttribute('maximum-size', 'abc');
197
+ await settle();
198
+ expect(clampedWidth(child)).toBe(600);
199
+ host.remove();
200
+ });
201
+ });
202
+
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
+
207
+ for (const { width, height, diameter, lineWidth, rule } of SPINNER_GEOMETRY_VECTORS) {
208
+ // Only square, allocatable boxes describe what a `size` attribute
209
+ // can produce; the rectangular rows exercise the paintable directly.
210
+ if (width !== height || width < ADW_SPINNER_MIN_SIZE) continue;
211
+
212
+ await it(`size ${width} draws a ${diameter}px ring stroked ${lineWidth}px — ${rule}`, () => {
213
+ const { host } = mountSized(400);
214
+ const spinner = document.createElement('adw-spinner');
215
+ spinner.setAttribute('size', String(width));
216
+ host.appendChild(spinner);
217
+
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);
223
+ host.remove();
224
+ });
225
+ }
226
+
227
+ await it('strokes 3px at 24 and 6px at 48, where it used to stroke 2 and 4', () => {
228
+ const { host } = mountSized(400);
229
+ const spinner = document.createElement('adw-spinner');
230
+ host.appendChild(spinner);
231
+ const strokeAt = (size: number) => {
232
+ spinner.setAttribute('size', String(size));
233
+ return Number(ringOf(spinner).querySelector('circle')?.getAttribute('stroke-width'));
234
+ };
235
+ expect(strokeAt(24)).toBe(3);
236
+ expect(strokeAt(48)).toBe(6);
237
+ expect(strokeAt(64)).toBe(8);
238
+ host.remove();
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
+ });
253
+ });
254
+
255
+ await describe('adw-spinner sizing — the BOX and the RING are different things', async () => {
256
+ for (const { value, size, rule } of SPINNER_SIZE_VECTORS) {
257
+ await it(`size ${JSON.stringify(value ?? null)} → a ${size}px box — ${rule}`, () => {
258
+ const { host } = mountSized(400);
259
+ const spinner = document.createElement('adw-spinner');
260
+ if (value !== null && value !== undefined) spinner.setAttribute('size', String(value));
261
+ host.appendChild(spinner);
262
+
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);
270
+ host.remove();
271
+ });
272
+ }
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
+
284
+ await it('defaults to the measured 16, not the invented 24', () => {
285
+ const { host } = mountSized(400);
286
+ const spinner = document.createElement('adw-spinner');
287
+ host.appendChild(spinner);
288
+ expect(spinner.offsetWidth).toBe(ADW_SPINNER_MIN_SIZE);
289
+ host.remove();
290
+ });
291
+ });
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
+
358
+ await describe('adw-toolbar-view style classes (update_undershoots + the style setters)', async () => {
359
+ for (const { input, view, topBar, bottomBar, rule } of TOOLBAR_VIEW_CLASS_VECTORS) {
360
+ await it(`${input.topBarStyle}/${input.bottomBarStyle} → [${view.join(' ')}] — ${rule}`, () => {
361
+ const { host, view: element } = mountToolbarView(input.topBarHeight, input.bottomBarHeight);
362
+ element.setAttribute('top-bar-style', input.topBarStyle);
363
+ element.setAttribute('bottom-bar-style', input.bottomBarStyle);
364
+ if (input.extendContentToTopEdge) element.setAttribute('extend-content-to-top-edge', '');
365
+ if (input.extendContentToBottomEdge) element.setAttribute('extend-content-to-bottom-edge', '');
366
+
367
+ const topEl = element.querySelector('.adw-toolbar-view-top') as HTMLElement;
368
+ const bottomEl = element.querySelector('.adw-toolbar-view-bottom') as HTMLElement;
369
+
370
+ for (const cls of ['undershoot-top', 'undershoot-bottom']) {
371
+ expect(element.classList.contains(cls)).toBe(view.includes(cls));
372
+ }
373
+ for (const cls of ['raised', 'border']) {
374
+ expect(topEl.classList.contains(cls)).toBe(topBar.includes(cls));
375
+ expect(bottomEl.classList.contains(cls)).toBe(bottomBar.includes(cls));
376
+ }
377
+ host.remove();
378
+ });
379
+ }
380
+
381
+ await it('drops the undershoot again when the style stops being flat', () => {
382
+ const { host, view } = mountToolbarView(46, 0);
383
+ expect(view.classList.contains('undershoot-top')).toBe(true);
384
+ view.setAttribute('top-bar-style', 'raised');
385
+ expect(view.classList.contains('undershoot-top')).toBe(false);
386
+ view.setAttribute('top-bar-style', 'flat');
387
+ expect(view.classList.contains('undershoot-top')).toBe(true);
388
+ host.remove();
389
+ });
390
+
391
+ await it('treats an unknown style as flat, the way the enum guard does', () => {
392
+ const { host, view } = mountToolbarView(46, 0);
393
+ view.setAttribute('top-bar-style', 'shadowed');
394
+ const topEl = view.querySelector('.adw-toolbar-view-top') as HTMLElement;
395
+ expect(topEl.classList.contains('raised')).toBe(false);
396
+ expect(view.classList.contains('undershoot-top')).toBe(true);
397
+ host.remove();
398
+ });
399
+ });
400
+ };