@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,342 @@
1
+ // DOM-level conformance tests for the action-row family — <adw-action-row>,
2
+ // <adw-switch-row>, <adw-button-row> and <adw-window-title> — driven by the SAME
3
+ // vectors the NativeScript renderer asserts against
4
+ // (`@gjsify/adwaita-core/conformance`).
5
+ //
6
+ // What this suite exists to catch, in the words of what it found:
7
+ // - NEITHER renderer hid an empty TITLE. `string_is_not_empty` is bound to the
8
+ // title label as much as to the subtitle (adw-action-row.ui:49-53 vs :71-75),
9
+ // and the browser port hand-rolled the subtitle-only half of that block six
10
+ // times over.
11
+ // - a programmatic `switchRow.active = true` emitted nothing here, because the
12
+ // DOM does not fire `change` for a scripted `.checked =` — while the
13
+ // NativeScript port, whose `checkedChange` does fire, emitted. One C source,
14
+ // two opposite answers, and nothing comparing them.
15
+ // - clicking anywhere but the handle of a switch row did nothing, though
16
+ // `adw_switch_row_init` makes the whole row the switch's activator.
17
+ // - `<adw-button-row activatable="false">` was honoured, an opt-out libadwaita
18
+ // does not have — and `<adw-action-row activatable="false">` meant the
19
+ // OPPOSITE, because that element reads the attribute by presence.
20
+ import { describe, expect, it } from '@gjsify/unit';
21
+
22
+ import {
23
+ ACTION_ROW_ACTIVATION_VECTORS,
24
+ BUTTON_ROW_ACTIVATABLE_VECTORS,
25
+ BUTTON_ROW_ICON_VECTORS,
26
+ LABEL_VISIBILITY_VECTORS,
27
+ SWITCH_ROW_NOTIFY_VECTORS,
28
+ WINDOW_TITLE_VECTORS,
29
+ } from '@gjsify/adwaita-core/conformance';
30
+
31
+ import { normalizeIconName } from '@gjsify/adwaita-core';
32
+
33
+ import type { AdwActionRow } from './elements/adw-action-row.js';
34
+ import type { AdwSwitchRow } from './elements/adw-switch-row.js';
35
+ import type { AdwWindowTitle } from './elements/adw-window-title.js';
36
+
37
+ /**
38
+ * Mount an element imperatively — never through parsed HTML, because several
39
+ * label vectors hinge on exact whitespace (`' '`, `'\t'`) that an attribute
40
+ * value in markup is not a reliable carrier for.
41
+ */
42
+ function mount<T extends HTMLElement>(tag: string): { el: T; host: HTMLElement } {
43
+ const host = document.createElement('div');
44
+ document.body.appendChild(host);
45
+ const el = document.createElement(tag) as T;
46
+ host.appendChild(el);
47
+ return { el, host };
48
+ }
49
+
50
+ /** Set an attribute, where `null` means "the property is unset". */
51
+ function setAttr(el: HTMLElement, name: string, value: string | null) {
52
+ if (value === null) el.removeAttribute(name);
53
+ else el.setAttribute(name, value);
54
+ }
55
+
56
+ /** Whether the label matching `selector` is rendered at all. */
57
+ function labelVisible(el: HTMLElement, selector: string): boolean {
58
+ const label = el.querySelector(selector) as HTMLElement | null;
59
+ return label !== null && !label.hidden;
60
+ }
61
+
62
+ /** Collect the `detail` of every `event` dispatched on `el`. */
63
+ function record(el: HTMLElement, event: string): unknown[] {
64
+ const details: unknown[] = [];
65
+ el.addEventListener(event, (e) => details.push((e as CustomEvent).detail));
66
+ return details;
67
+ }
68
+
69
+ /** Let a MutationObserver callback (the sensitivity binding) be delivered. */
70
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
71
+
72
+ export const AdwActionRowsTest = async () => {
73
+ await describe('<adw-action-row> label visibility (libadwaita conformance vectors)', async () => {
74
+ for (const { text, visible, rule } of LABEL_VISIBILITY_VECTORS) {
75
+ await it(`title ${JSON.stringify(text)} → ${visible} — ${rule}`, () => {
76
+ const { el, host } = mount<AdwActionRow>('adw-action-row');
77
+ setAttr(el, 'title', text);
78
+ expect(labelVisible(el, '.adw-row-title')).toBe(visible);
79
+ host.remove();
80
+ });
81
+
82
+ await it(`subtitle ${JSON.stringify(text)} → ${visible} — ${rule}`, () => {
83
+ const { el, host } = mount<AdwActionRow>('adw-action-row');
84
+ setAttr(el, 'subtitle', text);
85
+ expect(labelVisible(el, '.adw-row-subtitle')).toBe(visible);
86
+ host.remove();
87
+ });
88
+ }
89
+
90
+ await it('renders the text it was given, whitespace included', () => {
91
+ const { el, host } = mount<AdwActionRow>('adw-action-row');
92
+ el.setAttribute('title', ' ');
93
+ expect((el.querySelector('.adw-row-title') as HTMLElement).textContent).toBe(' ');
94
+ host.remove();
95
+ });
96
+ });
97
+
98
+ await describe('<adw-action-row> activatable-widget (libadwaita conformance vectors)', async () => {
99
+ for (const vector of ACTION_ROW_ACTIVATION_VECTORS) {
100
+ await it(`${vector.name} — ${vector.rule}`, async () => {
101
+ const { el: row, host } = mount<AdwActionRow>('adw-action-row');
102
+ const widgets = new Map<string, HTMLButtonElement>();
103
+ let last: HTMLButtonElement | null = null;
104
+
105
+ for (const step of vector.steps) {
106
+ switch (step.op) {
107
+ case 'set-activatable-widget': {
108
+ if (step.widget === null) {
109
+ row.activatableWidget = null;
110
+ break;
111
+ }
112
+ let widget = widgets.get(step.widget);
113
+ if (!widget) {
114
+ widget = document.createElement('button');
115
+ widget.slot = 'suffix';
116
+ row.suffixSection.appendChild(widget);
117
+ widgets.set(step.widget, widget);
118
+ }
119
+ // `GtkWidget:sensitive` — `disabled` is its DOM name.
120
+ widget.disabled = step.sensitive === false;
121
+ row.activatableWidget = widget;
122
+ last = widget;
123
+ break;
124
+ }
125
+ case 'set-widget-sensitive':
126
+ if (last) last.disabled = !step.sensitive;
127
+ // The binding runs through a MutationObserver.
128
+ await tick();
129
+ break;
130
+ case 'set-activatable':
131
+ row.toggleAttribute('activatable', step.activatable);
132
+ break;
133
+ }
134
+ }
135
+
136
+ expect(row.activatable).toBe(vector.activatable);
137
+ expect(row.classList.contains('activatable')).toBe(vector.activatable);
138
+ const expectedWidget = vector.activatableWidget === null ? null : widgets.get(vector.activatableWidget);
139
+ expect(row.activatableWidget).toBe(expectedWidget ?? null);
140
+ host.remove();
141
+ });
142
+ }
143
+
144
+ await it('reads `activatable` by PRESENCE, like every other boolean attribute here', () => {
145
+ const { el: row, host } = mount<AdwActionRow>('adw-action-row');
146
+ row.setAttribute('activatable', 'false');
147
+ // The HTML boolean-attribute convention (`<input disabled="false">`
148
+ // is disabled), which is how every other boolean attribute in this
149
+ // package is read. NOT a libadwaita rule — libadwaita has a typed
150
+ // gboolean and no attribute parsing at all — so it is pinned here
151
+ // rather than in the cross-renderer vectors. Pinned so the next
152
+ // reader does not "fix" it into the string comparison
153
+ // <adw-button-row> used to carry, which gave one markup two
154
+ // opposite meanings inside one package.
155
+ expect(row.activatable).toBe(true);
156
+ host.remove();
157
+ });
158
+
159
+ await it('forwards an activation to the activatable widget, then emits `activated`', () => {
160
+ const { el: row, host } = mount<AdwActionRow>('adw-action-row');
161
+ const widget = document.createElement('button');
162
+ row.suffixSection.appendChild(widget);
163
+ let widgetClicks = 0;
164
+ widget.addEventListener('click', () => widgetClicks++);
165
+ row.activatableWidget = widget;
166
+
167
+ const activations = record(row, 'activated');
168
+ row.click();
169
+
170
+ expect(widgetClicks).toBe(1);
171
+ expect(activations.length).toBe(1);
172
+ host.remove();
173
+ });
174
+
175
+ await it('leaves a click that came from the widget itself to the widget', () => {
176
+ const { el: row, host } = mount<AdwActionRow>('adw-action-row');
177
+ const widget = document.createElement('button');
178
+ row.suffixSection.appendChild(widget);
179
+ let widgetClicks = 0;
180
+ widget.addEventListener('click', () => widgetClicks++);
181
+ row.activatableWidget = widget;
182
+ const activations = record(row, 'activated');
183
+
184
+ widget.click();
185
+
186
+ // One click, and no row activation: GtkListBox does not emit
187
+ // `row-activated` for a click a child widget claimed. Re-clicking the
188
+ // widget here would also bounce back into the row's own listener.
189
+ expect(widgetClicks).toBe(1);
190
+ expect(activations.length).toBe(0);
191
+ host.remove();
192
+ });
193
+
194
+ await it('stays silent while unactivatable', () => {
195
+ const { el: row, host } = mount<AdwActionRow>('adw-action-row');
196
+ const activations = record(row, 'activated');
197
+ row.click();
198
+ expect(activations.length).toBe(0);
199
+ host.remove();
200
+ });
201
+ });
202
+
203
+ await describe('<adw-switch-row> notify::active (libadwaita conformance vectors)', async () => {
204
+ for (const vector of SWITCH_ROW_NOTIFY_VECTORS) {
205
+ await it(`${vector.name} — ${vector.rule}`, () => {
206
+ const { el: row, host } = mount<AdwSwitchRow>('adw-switch-row');
207
+ const events = record(row, 'notify::active');
208
+
209
+ for (const step of vector.steps) {
210
+ if (step.op === 'set-active') row.active = step.active;
211
+ else row.click();
212
+ }
213
+
214
+ expect(row.active).toBe(vector.active);
215
+ expect((row.querySelector('input[type="checkbox"]') as HTMLInputElement).checked).toBe(vector.active);
216
+ expect(events.map((detail) => (detail as { active: boolean }).active)).toStrictEqual([
217
+ ...vector.emitted,
218
+ ]);
219
+ host.remove();
220
+ });
221
+ }
222
+
223
+ await it('toggles from a click on the handle, without double-toggling', () => {
224
+ const { el: row, host } = mount<AdwSwitchRow>('adw-switch-row');
225
+ const events = record(row, 'notify::active');
226
+ const checkbox = row.querySelector('input[type="checkbox"]') as HTMLInputElement;
227
+
228
+ checkbox.click();
229
+
230
+ expect(row.active).toBe(true);
231
+ expect(events.length).toBe(1);
232
+ host.remove();
233
+ });
234
+
235
+ await it('keeps aria-checked in step with the state (adw-switch-row.c:71-74)', () => {
236
+ const { el: row, host } = mount<AdwSwitchRow>('adw-switch-row');
237
+ expect(row.getAttribute('aria-checked')).toBe('false');
238
+ row.active = true;
239
+ expect(row.getAttribute('aria-checked')).toBe('true');
240
+ host.remove();
241
+ });
242
+
243
+ for (const { text, visible, rule } of LABEL_VISIBILITY_VECTORS) {
244
+ await it(`title ${JSON.stringify(text)} → ${visible} — ${rule}`, () => {
245
+ const { el, host } = mount<AdwSwitchRow>('adw-switch-row');
246
+ setAttr(el, 'title', text);
247
+ expect(labelVisible(el, '.adw-row-title')).toBe(visible);
248
+ host.remove();
249
+ });
250
+ }
251
+ });
252
+
253
+ await describe('<adw-button-row> (libadwaita conformance vectors)', async () => {
254
+ for (const vector of BUTTON_ROW_ICON_VECTORS) {
255
+ await it(`${JSON.stringify([vector.startIconName, vector.endIconName])} — ${vector.rule}`, () => {
256
+ const { el: row, host } = mount<HTMLElement>('adw-button-row');
257
+ setAttr(row, 'start-icon-name', vector.startIconName);
258
+ setAttr(row, 'end-icon-name', vector.endIconName);
259
+
260
+ // The two `image.icon.{start,end}` nodes are <adw-icon> now, not
261
+ // hand-rolled decorative spans.
262
+ const icons = Array.from(row.querySelectorAll('adw-icon')) as HTMLElement[];
263
+ expect(icons.length).toBe(2);
264
+ expect(!icons[0]!.hidden).toBe(vector.startIconVisible);
265
+ expect(!icons[1]!.hidden).toBe(vector.endIconVisible);
266
+ // The mask class is the NORMALIZED name: the generated classes
267
+ // never carry `-symbolic`, and this row used to interpolate the
268
+ // raw one — so `start-icon-name="list-add-symbolic"` asked for a
269
+ // class that has never existed and drew an empty box.
270
+ if (vector.startIconVisible) {
271
+ expect(icons[0]!.classList.contains(`adw-icon--${normalizeIconName(vector.startIconName)}`)).toBe(
272
+ true,
273
+ );
274
+ }
275
+ if (vector.endIconVisible) {
276
+ expect(icons[1]!.classList.contains(`adw-icon--${normalizeIconName(vector.endIconName)}`)).toBe(
277
+ true,
278
+ );
279
+ }
280
+ host.remove();
281
+ });
282
+ }
283
+
284
+ for (const vector of BUTTON_ROW_ACTIVATABLE_VECTORS) {
285
+ await it(`activatable=${JSON.stringify(vector.declared)} — ${vector.rule}`, () => {
286
+ const { el: row, host } = mount<HTMLElement>('adw-button-row');
287
+ setAttr(row, 'activatable', vector.declared);
288
+ const activations = record(row, 'activated');
289
+ row.click();
290
+
291
+ expect(row.classList.contains('activatable')).toBe(vector.activatable);
292
+ expect(activations.length).toBe(1);
293
+ host.remove();
294
+ });
295
+ }
296
+
297
+ for (const { text, visible, rule } of LABEL_VISIBILITY_VECTORS) {
298
+ await it(`title ${JSON.stringify(text)} → ${visible} — ${rule}`, () => {
299
+ const { el: row, host } = mount<HTMLElement>('adw-button-row');
300
+ setAttr(row, 'title', text);
301
+ expect(labelVisible(row, '.adw-button-row-title')).toBe(visible);
302
+ host.remove();
303
+ });
304
+ }
305
+ });
306
+
307
+ await describe('<adw-window-title> (libadwaita conformance vectors)', async () => {
308
+ for (const vector of WINDOW_TITLE_VECTORS) {
309
+ await it(`${vector.name} — ${vector.rule}`, () => {
310
+ const { el, host } = mount<AdwWindowTitle>('adw-window-title');
311
+ const notified: string[] = [];
312
+ el.addEventListener('notify::title', () => notified.push('title'));
313
+ el.addEventListener('notify::subtitle', () => notified.push('subtitle'));
314
+
315
+ for (const step of vector.steps) {
316
+ setAttr(el, step.op === 'set-title' ? 'title' : 'subtitle', step.value);
317
+ }
318
+
319
+ const title = el.querySelector('.adw-window-title-title') as HTMLElement;
320
+ const subtitle = el.querySelector('.adw-window-title-subtitle') as HTMLElement;
321
+ expect(title.textContent).toBe(vector.title);
322
+ expect(!title.hidden).toBe(vector.titleVisible);
323
+ expect(subtitle.textContent).toBe(vector.subtitle);
324
+ expect(!subtitle.hidden).toBe(vector.subtitleVisible);
325
+ expect(notified).toStrictEqual([...vector.notified]);
326
+ host.remove();
327
+ });
328
+ }
329
+
330
+ await it('adopts a declared title without calling it a change', () => {
331
+ const host = document.createElement('div');
332
+ document.body.appendChild(host);
333
+ host.innerHTML = '<adw-window-title title="Documents"></adw-window-title>';
334
+ const el = host.querySelector('adw-window-title') as AdwWindowTitle;
335
+ const title = el.querySelector('.adw-window-title-title') as HTMLElement;
336
+
337
+ expect(title.textContent).toBe('Documents');
338
+ expect(title.hidden).toBe(false);
339
+ host.remove();
340
+ });
341
+ });
342
+ };
@@ -0,0 +1,176 @@
1
+ // DOM-level conformance tests for <adw-avatar>, driven by the SAME vectors the
2
+ // NativeScript renderer asserts against (`@gjsify/adwaita-core/conformance`).
3
+ //
4
+ // The two renderers used to carry independent copies of the initials + colour
5
+ // derivation, and the copies had drifted apart AND away from libadwaita:
6
+ // this element rendered "AD" for `text="Ada"` where GTK and NS render "A", and
7
+ // both hashed UTF-16 code units where GLib hashes UTF-8 bytes, so every accented
8
+ // name was painted the wrong colour. Nothing failed, because nothing compared
9
+ // them. This suite is that comparison.
10
+ import { describe, expect, it } from '@gjsify/unit';
11
+
12
+ import { AVATAR_MODE_VECTORS } from '@gjsify/adwaita-core/conformance';
13
+
14
+ import { AVATAR_COLORS, avatarMaxFontSize } from '@gjsify/adwaita-core';
15
+ import {
16
+ AVATAR_COLOR_VECTORS,
17
+ AVATAR_FONT_SIZE_VECTORS,
18
+ AVATAR_INITIALS_VECTORS,
19
+ } from '@gjsify/adwaita-core/conformance';
20
+
21
+ /**
22
+ * Mount an avatar and set `text` through `setAttribute`, never through parsed
23
+ * HTML — several vectors hinge on exact whitespace (a tab, a run of spaces),
24
+ * which an attribute value in markup is not a reliable carrier for.
25
+ */
26
+ function mountAvatar(text: string, showInitials = true): { avatar: HTMLElement; host: HTMLElement } {
27
+ const host = document.createElement('div');
28
+ document.body.appendChild(host);
29
+ const avatar = document.createElement('adw-avatar');
30
+ if (showInitials) avatar.setAttribute('show-initials', '');
31
+ host.appendChild(avatar);
32
+ avatar.setAttribute('text', text);
33
+ return { avatar, host };
34
+ }
35
+
36
+ /** The rendered initials, i.e. what a user actually sees in the circle. */
37
+ function renderedInitials(avatar: HTMLElement): string {
38
+ const text = avatar.querySelector('.adw-avatar-text') as HTMLElement | null;
39
+ if (!text || text.hidden) return '';
40
+ return text.textContent ?? '';
41
+ }
42
+
43
+ /** `#rrggbb` in the `rgb(r, g, b)` form the CSSOM serialises inline styles to. */
44
+ function asRgb(hex: string): string {
45
+ const n = Number.parseInt(hex.slice(1), 16);
46
+ return `rgb(${(n >> 16) & 0xff}, ${(n >> 8) & 0xff}, ${n & 0xff})`;
47
+ }
48
+
49
+ export const AdwAvatarTest = async () => {
50
+ await describe('adw-avatar initials (libadwaita conformance vectors)', async () => {
51
+ for (const { text, initials, rule } of AVATAR_INITIALS_VECTORS) {
52
+ await it(`${JSON.stringify(text)} renders ${JSON.stringify(initials)} — ${rule}`, () => {
53
+ const { avatar, host } = mountAvatar(text);
54
+ expect(renderedInitials(avatar)).toBe(initials);
55
+ host.remove();
56
+ });
57
+ }
58
+
59
+ await it('falls back to the icon when there are no initials', () => {
60
+ const { avatar, host } = mountAvatar('');
61
+ expect(renderedInitials(avatar)).toBe('');
62
+ const icon = avatar.querySelector('.adw-avatar-icon') as HTMLElement;
63
+ expect(icon.hidden).toBe(false);
64
+ host.remove();
65
+ });
66
+
67
+ await it('shows the icon when show-initials is absent, even with a name', () => {
68
+ const { avatar, host } = mountAvatar('Ada Lovelace', false);
69
+ expect(renderedInitials(avatar)).toBe('');
70
+ host.remove();
71
+ });
72
+ });
73
+
74
+ await describe('adw-avatar colour (libadwaita conformance vectors)', async () => {
75
+ for (const { text, colorClass } of AVATAR_COLOR_VECTORS) {
76
+ // Only a genuinely EMPTY name drops to icon mode; a whitespace-only
77
+ // one stays in initials mode (blank label) and is still painted, so
78
+ // it belongs in this loop.
79
+ if (text.length === 0) continue;
80
+
81
+ await it(`${JSON.stringify(text)} paints color${colorClass}`, () => {
82
+ const { avatar, host } = mountAvatar(text);
83
+ const palette = AVATAR_COLORS[colorClass - 1]!;
84
+ expect(avatar.style.backgroundImage).toBe(
85
+ `linear-gradient(${asRgb(palette.start)}, ${asRgb(palette.stop)})`,
86
+ );
87
+ expect(avatar.style.color).toBe(asRgb(palette.fg));
88
+ host.remove();
89
+ });
90
+ }
91
+
92
+ await it('drops the gradient again when the avatar falls back to its icon', () => {
93
+ const { avatar, host } = mountAvatar('Ada Lovelace');
94
+ expect(avatar.style.backgroundImage).not.toBe('');
95
+ avatar.removeAttribute('show-initials');
96
+ expect(avatar.style.backgroundImage).toBe('');
97
+ host.remove();
98
+ });
99
+ });
100
+ await describe('adw-avatar font size (Adw.Avatar update_font_size)', async () => {
101
+ for (const { size, maxFontSize } of AVATAR_FONT_SIZE_VECTORS) {
102
+ await it(`size ${size} never exceeds the ${maxFontSize}px cap`, () => {
103
+ const { avatar, host } = mountAvatar('Ada Lovelace');
104
+ avatar.setAttribute('size', String(size));
105
+ const label = avatar.querySelector('.adw-avatar-text') as HTMLElement;
106
+ const applied = Number.parseFloat(label.style.fontSize);
107
+ // The measured aspect ratio decides the exact value; what the
108
+ // element must never do is overflow the circle.
109
+ expect(applied <= avatarMaxFontSize(size) + 0.001).toBe(true);
110
+ expect(applied > 0).toBe(true);
111
+ host.remove();
112
+ });
113
+ }
114
+
115
+ await it('grows monotonically across the old 31px/32px discontinuity', () => {
116
+ const { avatar, host } = mountAvatar('Ada Lovelace');
117
+ const label = avatar.querySelector('.adw-avatar-text') as HTMLElement;
118
+ const at = (size: number) => {
119
+ avatar.setAttribute('size', String(size));
120
+ return Number.parseFloat(label.style.fontSize);
121
+ };
122
+ // The old heuristic returned 16px at 31 and 13px at 32.
123
+ expect(at(32) >= at(31)).toBe(true);
124
+ expect(at(64) >= at(48)).toBe(true);
125
+ host.remove();
126
+ });
127
+ });
128
+
129
+ await describe('adw-avatar mode (Adw.Avatar update_visibility)', async () => {
130
+ await it('keeps a whitespace-only name in initials mode with a blank label', () => {
131
+ const { avatar, host } = mountAvatar(' ');
132
+ const label = avatar.querySelector('.adw-avatar-text') as HTMLElement;
133
+ const icon = avatar.querySelector('.adw-avatar-icon') as HTMLElement;
134
+ // libadwaita gates on strlen(text), not on the derived initials.
135
+ expect(label.hidden).toBe(false);
136
+ expect(label.textContent).toBe('');
137
+ expect(icon.hidden).toBe(true);
138
+ host.remove();
139
+ });
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
+ });
176
+ };
@@ -0,0 +1,152 @@
1
+ // DOM-level conformance tests for <adw-banner>, driven by the SAME vectors the
2
+ // NativeScript renderer asserts against (`@gjsify/adwaita-core/conformance`).
3
+ //
4
+ // The two renderers carried independent banner logic and had drifted apart AND
5
+ // away from libadwaita: `revealed` was opt-in here (right) and initialised TRUE
6
+ // on NativeScript with no `visibility` written at all (wrong), `use-markup` was
7
+ // opt-in in both with a comment here asserting that WAS the C default (it is
8
+ // TRUE, adw-banner.c:422-425), `button-style` was in neither, and the mnemonic
9
+ // marker the template pins onto the button (adw-banner.ui:33) was painted as a
10
+ // literal underscore. Nothing failed, because nothing compared them.
11
+ import { describe, expect, it } from '@gjsify/unit';
12
+
13
+ import { ADW_BANNER_DEFAULTS } from '@gjsify/adwaita-core';
14
+ import {
15
+ BANNER_BUTTON_STYLE_PARSE_VECTORS,
16
+ BANNER_BUTTON_STYLE_VECTORS,
17
+ BANNER_BUTTON_TEXT_VECTORS,
18
+ BANNER_BUTTON_VISIBLE_VECTORS,
19
+ BANNER_DEFAULT_VECTORS,
20
+ } from '@gjsify/adwaita-core/conformance';
21
+ import type { AdwBanner } from './elements/adw-banner.js';
22
+
23
+ /**
24
+ * Mount a banner, setting attributes through `setAttribute` rather than parsed
25
+ * HTML — several vectors hinge on exact whitespace (`button-label=" "`), which an
26
+ * attribute value in markup is not a reliable carrier for.
27
+ */
28
+ function mount(attributes: Record<string, string> = {}): { el: AdwBanner; host: HTMLElement } {
29
+ const host = document.createElement('div');
30
+ document.body.appendChild(host);
31
+ const el = document.createElement('adw-banner') as AdwBanner;
32
+ for (const [name, value] of Object.entries(attributes)) el.setAttribute(name, value);
33
+ host.appendChild(el);
34
+ return { el, host };
35
+ }
36
+
37
+ const titleEl = (el: AdwBanner): HTMLElement => el.querySelector('.adw-banner-title') as HTMLElement;
38
+ const buttonEl = (el: AdwBanner): HTMLButtonElement => el.querySelector('.adw-banner-button') as HTMLButtonElement;
39
+
40
+ export const AdwBannerTest = async () => {
41
+ await describe('adw-banner defaults (libadwaita conformance vectors)', async () => {
42
+ for (const { property, value, rule } of BANNER_DEFAULT_VECTORS) {
43
+ if (property === 'use-markup') continue; // deliberate departure, asserted below
44
+ await it(`${property} → ${JSON.stringify(value)} — ${rule}`, () => {
45
+ const { el, host } = mount();
46
+ const actual: Record<string, unknown> = {
47
+ title: titleEl(el).textContent,
48
+ 'button-label': buttonEl(el).textContent,
49
+ revealed: el.classList.contains('revealed'),
50
+ 'button-style': buttonEl(el).classList.contains('suggested-action') ? 'suggested' : 'default',
51
+ };
52
+ expect(actual[property]).toBe(value);
53
+ host.remove();
54
+ });
55
+ }
56
+
57
+ await it('an unrevealed banner is not laid out at all', () => {
58
+ const { el, host } = mount({ title: 'Metered connection' });
59
+ expect(getComputedStyle(el).display).toBe('none');
60
+ el.setAttribute('revealed', '');
61
+ expect(getComputedStyle(el).display === 'none').toBe(false);
62
+ host.remove();
63
+ });
64
+ });
65
+
66
+ await describe('adw-banner use-markup — a DELIBERATE departure from the C default', async () => {
67
+ // The C default is unambiguously TRUE (adw-banner.c:422-425) and core
68
+ // carries it as the spec value. This renderer does NOT adopt it: Pango
69
+ // markup is not HTML, and defaulting a `title` attribute to `innerHTML`
70
+ // would make an injection sink out of a widget that is not one in GTK.
71
+ // The departure is pinned here so it stays a decision rather than
72
+ // becoming a drift — and so does its cost.
73
+ await it('core still reports the libadwaita default as TRUE', () => {
74
+ expect(ADW_BANNER_DEFAULTS.useMarkup).toBe(true);
75
+ });
76
+
77
+ await it('the element defaults to TEXT — a title with tags shows them literally', () => {
78
+ const { el, host } = mount({ title: '<b>Metered</b> connection', revealed: '' });
79
+ expect(titleEl(el).textContent).toBe('<b>Metered</b> connection');
80
+ expect(titleEl(el).querySelector('b')).toBe(null);
81
+ host.remove();
82
+ });
83
+
84
+ await it('opting in renders markup, which is the developer asserting the title is trusted', () => {
85
+ const { el, host } = mount({ title: '<b>Metered</b> connection', revealed: '', 'use-markup': '' });
86
+ expect(titleEl(el).querySelector('b')?.textContent).toBe('Metered');
87
+ host.remove();
88
+ });
89
+ });
90
+
91
+ await describe('adw-banner button visibility (libadwaita conformance vectors)', async () => {
92
+ for (const { label, visible, rule } of BANNER_BUTTON_VISIBLE_VECTORS) {
93
+ if (label === null) continue; // an absent attribute, covered below
94
+ await it(`${JSON.stringify(label)} → ${visible} — ${rule}`, () => {
95
+ const { el, host } = mount({ revealed: '', 'button-label': label });
96
+ expect(buttonEl(el).hidden).toBe(!visible);
97
+ host.remove();
98
+ });
99
+ }
100
+
101
+ await it('no button-label attribute is the NULL case — no button', () => {
102
+ const { el, host } = mount({ revealed: '' });
103
+ expect(buttonEl(el).hidden).toBe(true);
104
+ host.remove();
105
+ });
106
+ });
107
+
108
+ await describe('adw-banner button text (adw-banner.ui:33 pins use-underline)', async () => {
109
+ for (const { label, text, rule } of BANNER_BUTTON_TEXT_VECTORS) {
110
+ await it(`${JSON.stringify(label)} → ${JSON.stringify(text)} — ${rule}`, () => {
111
+ const { el, host } = mount({ revealed: '', 'button-label': label });
112
+ expect(buttonEl(el).textContent).toBe(text);
113
+ host.remove();
114
+ });
115
+ }
116
+
117
+ await it('the TITLE keeps its underscores — adw-banner.ui:20 pins it to use-underline=False', () => {
118
+ const { el, host } = mount({ revealed: '', title: 'Snap_shot paused' });
119
+ expect(titleEl(el).textContent).toBe('Snap_shot paused');
120
+ host.remove();
121
+ });
122
+ });
123
+
124
+ await describe('adw-banner button-style (libadwaita conformance vectors)', async () => {
125
+ for (const { style, classes, rule } of BANNER_BUTTON_STYLE_VECTORS) {
126
+ await it(`${style} → [${classes.join(', ')}] — ${rule}`, () => {
127
+ const { el, host } = mount({ revealed: '', 'button-label': 'Resume', 'button-style': style });
128
+ const button = buttonEl(el);
129
+ expect(button.classList.contains('suggested-action')).toBe(classes.includes('suggested-action'));
130
+ expect(button.classList.contains('adw-banner-button')).toBe(true);
131
+ host.remove();
132
+ });
133
+ }
134
+
135
+ for (const { input, style, rule } of BANNER_BUTTON_STYLE_PARSE_VECTORS) {
136
+ if (input === null) continue; // an absent attribute, covered by the defaults block
137
+ await it(`attribute ${JSON.stringify(input)} → ${style} — ${rule}`, () => {
138
+ const { el, host } = mount({ revealed: '', 'button-label': 'Resume', 'button-style': input });
139
+ expect(buttonEl(el).classList.contains('suggested-action')).toBe(style === 'suggested');
140
+ host.remove();
141
+ });
142
+ }
143
+
144
+ await it('switching back to default takes the class off again (:766)', () => {
145
+ const { el, host } = mount({ revealed: '', 'button-label': 'Resume', 'button-style': 'suggested' });
146
+ expect(buttonEl(el).classList.contains('suggested-action')).toBe(true);
147
+ el.setAttribute('button-style', 'default');
148
+ expect(buttonEl(el).classList.contains('suggested-action')).toBe(false);
149
+ host.remove();
150
+ });
151
+ });
152
+ };