@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,368 @@
1
+ // DOM-level tests for <adw-checkbox> and <adw-radio>, plus the browser side of
2
+ // the `RADIO_GROUP_VECTORS` conformance table.
3
+ //
4
+ // THE ONE CLAIM WORTH THE MOST HERE: the browser's native radio exclusivity is
5
+ // NOT enough. `<input type="radio" name="g">` unchecks its sibling INPUT and
6
+ // stops there — the sibling `<adw-radio>` HOST keeps its `checked` attribute,
7
+ // which is the element's published state AND the selector `_checks.scss` paints
8
+ // from, so a group left to the browser draws two selected radios. Every vector
9
+ // below reads back the HOST attribute of every member for exactly that reason.
10
+ //
11
+ // The vectors run under a per-scenario group-name prefix: `RadioGroupState` is
12
+ // document-global by design (as a bare `<input type=radio name=x>` outside a
13
+ // form is), so two scenarios sharing the literal name `colour` would share a
14
+ // selection. The prefix is the isolation, not a change to what is being tested.
15
+
16
+ import { describe, expect, it } from '@gjsify/unit';
17
+ import { RADIO_GROUP_VECTORS } from '@gjsify/adwaita-core/conformance';
18
+
19
+ import type { AdwCheckbox, AdwRadio } from './elements/adw-checks.js';
20
+
21
+ function host(): HTMLElement {
22
+ const el = document.createElement('div');
23
+ document.body.appendChild(el);
24
+ return el;
25
+ }
26
+
27
+ function mountCheckbox(): { el: AdwCheckbox; parent: HTMLElement } {
28
+ const parent = host();
29
+ const el = document.createElement('adw-checkbox') as AdwCheckbox;
30
+ parent.appendChild(el);
31
+ return { el, parent };
32
+ }
33
+
34
+ /** The parts, by the markup contract the stylesheet selects on. */
35
+ function parts(check: HTMLElement): { input: HTMLInputElement; indicator: HTMLElement; label: HTMLElement } {
36
+ return {
37
+ input: check.querySelector('input.adw-check-input') as HTMLInputElement,
38
+ indicator: check.querySelector('.adw-check-indicator') as HTMLElement,
39
+ label: check.querySelector('.adw-check-label') as HTMLElement,
40
+ };
41
+ }
42
+
43
+ /** Collect the `detail` of every `event` bubbling to `el`. */
44
+ function record(el: HTMLElement, event: string): unknown[] {
45
+ const details: unknown[] = [];
46
+ el.addEventListener(event, (e) => details.push((e as CustomEvent).detail));
47
+ return details;
48
+ }
49
+
50
+ /** The glyph the indicator's mask is currently painting. */
51
+ function glyph(check: HTMLElement): string {
52
+ return getComputedStyle(parts(check).indicator, '::after').getPropertyValue('mask-image');
53
+ }
54
+
55
+ export const AdwChecksTest = async () => {
56
+ await describe('<adw-checkbox> state', async () => {
57
+ await it('builds the input + indicator + label markup', () => {
58
+ const { el, parent } = mountCheckbox();
59
+ const { input, indicator, label } = parts(el);
60
+ expect(input).toBeTruthy();
61
+ expect(input.type).toBe('checkbox');
62
+ expect(indicator).toBeTruthy();
63
+ // The focus ring is `input:focus-visible ~ .adw-check-indicator`, so
64
+ // the input MUST precede the indicator.
65
+ expect(input.nextElementSibling).toBe(indicator);
66
+ expect(el.checked).toBe(false);
67
+ expect(input.getAttribute('aria-checked')).toBe('false');
68
+ // An empty label takes no space.
69
+ expect(label.hidden).toBe(true);
70
+ parent.remove();
71
+ });
72
+
73
+ await it('adopts authored child nodes as the label, and the attribute wins', () => {
74
+ const parent = host();
75
+ const authored = document.createElement('adw-checkbox') as AdwCheckbox;
76
+ authored.textContent = 'Enable';
77
+ parent.appendChild(authored);
78
+ expect(parts(authored).label.textContent).toBe('Enable');
79
+ expect(parts(authored).label.hidden).toBe(false);
80
+
81
+ const labelled = document.createElement('adw-checkbox') as AdwCheckbox;
82
+ labelled.setAttribute('label', 'From the attribute');
83
+ labelled.textContent = 'ignored';
84
+ parent.appendChild(labelled);
85
+ expect(parts(labelled).label.textContent).toBe('From the attribute');
86
+ parent.remove();
87
+ });
88
+
89
+ await it('a programmatic set notifies, and re-setting the same value does not', () => {
90
+ const { el, parent } = mountCheckbox();
91
+ const events = record(el, 'notify::checked');
92
+ el.checked = true;
93
+ expect(events).toStrictEqual([{ checked: true }]);
94
+ expect(parts(el).input.checked).toBe(true);
95
+ el.checked = true;
96
+ expect(events.length).toBe(1);
97
+ el.checked = false;
98
+ expect(events.length).toBe(2);
99
+ parent.remove();
100
+ });
101
+
102
+ await it('a click toggles exactly once', () => {
103
+ const { el, parent } = mountCheckbox();
104
+ const events = record(el, 'notify::checked');
105
+ el.click();
106
+ expect(el.checked).toBe(true);
107
+ expect(el.hasAttribute('checked')).toBe(true);
108
+ expect(events.length).toBe(1);
109
+ parent.remove();
110
+ });
111
+
112
+ await it('a click on the hidden input toggles exactly once', () => {
113
+ // The input is 0x0 so a pointer never reaches it, but keyboard
114
+ // activation and `input.click()` do — and the host listener must not
115
+ // toggle a second time on the way out.
116
+ const { el, parent } = mountCheckbox();
117
+ const events = record(el, 'notify::checked');
118
+ parts(el).input.click();
119
+ expect(el.checked).toBe(true);
120
+ expect(events.length).toBe(1);
121
+ parent.remove();
122
+ });
123
+
124
+ await it('a disabled checkbox does not toggle on a click', () => {
125
+ const { el, parent } = mountCheckbox();
126
+ el.disabled = true;
127
+ const events = record(el, 'notify::checked');
128
+ el.click();
129
+ expect(el.checked).toBe(false);
130
+ expect(events.length).toBe(0);
131
+ expect(parts(el).input.disabled).toBe(true);
132
+ // …but it is not frozen: a programmatic set still applies, matching
133
+ // GObject property semantics (sensitivity gates INPUT, not value).
134
+ el.checked = true;
135
+ expect(events).toStrictEqual([{ checked: true }]);
136
+ parent.remove();
137
+ });
138
+ });
139
+
140
+ await describe('<adw-checkbox> indeterminate', async () => {
141
+ await it('announces as mixed and outranks checked', () => {
142
+ const { el, parent } = mountCheckbox();
143
+ el.checked = true;
144
+ el.indeterminate = true;
145
+ const { input } = parts(el);
146
+ // HTML has a native mixed CHECKBOX, and an explicit aria-checked so
147
+ // the radio (which has no native mixed) reads the same way.
148
+ expect(input.indeterminate).toBe(true);
149
+ expect(input.getAttribute('aria-checked')).toBe('mixed');
150
+ parent.remove();
151
+ });
152
+
153
+ await it('draws the dash even when also checked', () => {
154
+ // `check, radio { &:indeterminate { … dash.svg } }`
155
+ // (_checks.scss:79-81) is re-declared AFTER both `:checked` rules at
156
+ // equal specificity, so the dash wins. Comparing the three states
157
+ // against each other proves the cascade, not just that a mask exists.
158
+ const parent = host();
159
+ const make = (attrs: string[]) => {
160
+ const el = document.createElement('adw-checkbox') as AdwCheckbox;
161
+ for (const attr of attrs) el.setAttribute(attr, '');
162
+ parent.appendChild(el);
163
+ return el;
164
+ };
165
+ const checked = make(['checked']);
166
+ const mixed = make(['indeterminate']);
167
+ const both = make(['checked', 'indeterminate']);
168
+
169
+ expect(glyph(both)).toBe(glyph(mixed));
170
+ expect(glyph(both)).not.toBe(glyph(checked));
171
+ parent.remove();
172
+ });
173
+
174
+ await it('activating an indeterminate checkbox clears it and checks', () => {
175
+ // HTML's pre-click activation steps, not a reading of
176
+ // GtkCheckButton:inconsistent — GTK's is not verifiable in this tree.
177
+ const { el, parent } = mountCheckbox();
178
+ el.indeterminate = true;
179
+ el.click();
180
+ expect(el.indeterminate).toBe(false);
181
+ expect(el.checked).toBe(true);
182
+ parent.remove();
183
+ });
184
+ });
185
+
186
+ await describe('check and radio share one stylesheet', async () => {
187
+ await it('both draw the 14px + 3px indicator, differing only in the radius', () => {
188
+ const parent = host();
189
+ const check = document.createElement('adw-checkbox') as AdwCheckbox;
190
+ const radio = document.createElement('adw-radio') as AdwRadio;
191
+ parent.append(check, radio);
192
+
193
+ for (const el of [check, radio]) {
194
+ const style = getComputedStyle(parts(el).indicator);
195
+ // GTK sizes the CONTENT box at 14px and grows the drawn control
196
+ // with `padding: 3px` (_checks.scss:22-26) — content-box, not
197
+ // the ADR-0010 descendant border-box pin.
198
+ expect(style.width).toBe('14px');
199
+ expect(style.height).toBe('14px');
200
+ expect(style.paddingTop).toBe('3px');
201
+ }
202
+
203
+ // `check { border-radius: 6px }` (:67-68) vs
204
+ // `radio { border-radius: 100% }` (:73-74).
205
+ expect(getComputedStyle(parts(check).indicator).borderTopLeftRadius).toBe('6px');
206
+ expect(getComputedStyle(parts(radio).indicator).borderTopLeftRadius).toContain('%');
207
+ parent.remove();
208
+ });
209
+
210
+ await it('a checked radio FILLS, with the SAME accent background as a checked check', () => {
211
+ // refs/adwaita-web's `_radio.scss:22-33` colours only the border and
212
+ // the dot, leaving the indicator on the view background; libadwaita
213
+ // fills the whole control from the block it SHARES with the checkbox
214
+ // (:52-60). libadwaita wins — so the two fills must be identical.
215
+ //
216
+ // THREE elements rather than one flipped in place: the indicator
217
+ // TRANSITIONS `background`, so a computed read taken right after
218
+ // `checked = true` returns the INTERPOLATED colour — still the old
219
+ // one. Declaring the state before insertion leaves no transition to
220
+ // be part-way through. The names are distinct so seeding one group
221
+ // cannot reach the reference radio.
222
+ const parent = host();
223
+ const plain = document.createElement('adw-radio') as AdwRadio;
224
+ plain.setAttribute('name', 'fill-plain');
225
+ const radio = document.createElement('adw-radio') as AdwRadio;
226
+ radio.setAttribute('name', 'fill-checked');
227
+ radio.setAttribute('value', 'x');
228
+ radio.setAttribute('checked', '');
229
+ const check = document.createElement('adw-checkbox') as AdwCheckbox;
230
+ check.setAttribute('checked', '');
231
+ parent.append(plain, radio, check);
232
+
233
+ const before = getComputedStyle(parts(plain).indicator).backgroundColor;
234
+ const after = getComputedStyle(parts(radio).indicator).backgroundColor;
235
+
236
+ expect(after).not.toBe(before);
237
+ expect(after).toBe(getComputedStyle(parts(check).indicator).backgroundColor);
238
+ parent.remove();
239
+ });
240
+ });
241
+
242
+ await describe('<adw-radio> group exclusivity (conformance vectors)', async () => {
243
+ for (const [index, vector] of RADIO_GROUP_VECTORS.entries()) {
244
+ await it(`${vector.name} — ${vector.rule}`, () => {
245
+ const parent = host();
246
+ // Per-scenario prefix: the registry is document-global, so two
247
+ // scenarios sharing a literal group name would share a selection.
248
+ const scope = (name: string) => `v${index}-${name}`;
249
+
250
+ const members = vector.members.map(([name, value]) => {
251
+ const el = document.createElement('adw-radio') as AdwRadio;
252
+ el.setAttribute('name', scope(name));
253
+ el.setAttribute('value', value);
254
+ parent.appendChild(el);
255
+ return el;
256
+ });
257
+ const events = record(parent, 'notify::checked');
258
+
259
+ for (const step of vector.steps) {
260
+ const target = members.find((el) => el.name === scope(step.name) && el.value === step.value);
261
+ expect(target).toBeTruthy();
262
+ (target as AdwRadio).click();
263
+ }
264
+
265
+ const expected = new Map(vector.selected.map(([name, value]) => [scope(name), value]));
266
+ for (const [i, el] of members.entries()) {
267
+ const [name, value] = vector.members[i];
268
+ const shouldHold = expected.get(scope(name)) === value;
269
+ // The HOST attribute, not the input: this is precisely what
270
+ // the browser's own exclusivity leaves stale.
271
+ expect(el.hasAttribute('checked')).toBe(shouldHold);
272
+ expect(parts(el).input.checked).toBe(shouldHold);
273
+ }
274
+
275
+ // Every emitted change repaints the winner, and the loser too
276
+ // when there was one — that second repaint is the whole reason
277
+ // the group state exists.
278
+ const expectedEvents = vector.emitted.reduce(
279
+ (total, change) => total + (change.deselected === null ? 1 : 2),
280
+ 0,
281
+ );
282
+ expect(events.length).toBe(expectedEvents);
283
+ parent.remove();
284
+ });
285
+ }
286
+ });
287
+
288
+ await describe('<adw-radio> group behaviour the browser alone gets wrong', async () => {
289
+ await it('picking a second member clears the first HOST attribute', () => {
290
+ const parent = host();
291
+ const first = document.createElement('adw-radio') as AdwRadio;
292
+ first.setAttribute('name', 'stale-check');
293
+ first.setAttribute('value', 'a');
294
+ first.setAttribute('checked', '');
295
+ const second = document.createElement('adw-radio') as AdwRadio;
296
+ second.setAttribute('name', 'stale-check');
297
+ second.setAttribute('value', 'b');
298
+ parent.append(first, second);
299
+
300
+ second.click();
301
+
302
+ expect(second.checked).toBe(true);
303
+ expect(first.checked).toBe(false);
304
+ expect(first.hasAttribute('checked')).toBe(false);
305
+ expect(parts(first).input.checked).toBe(false);
306
+ parent.remove();
307
+ });
308
+
309
+ await it('groups are scoped by name', () => {
310
+ const parent = host();
311
+ const make = (name: string, value: string) => {
312
+ const el = document.createElement('adw-radio') as AdwRadio;
313
+ el.setAttribute('name', name);
314
+ el.setAttribute('value', value);
315
+ parent.appendChild(el);
316
+ return el;
317
+ };
318
+ const left = make('scoped-a', 'x');
319
+ const right = make('scoped-b', 'x');
320
+
321
+ left.click();
322
+ right.click();
323
+
324
+ expect(left.checked).toBe(true);
325
+ expect(right.checked).toBe(true);
326
+ parent.remove();
327
+ });
328
+
329
+ await it('a NAMELESS radio is not in a group', () => {
330
+ // HTML forms a radio button group only from a non-empty `name`.
331
+ // Without that rule every nameless radio in the document would land
332
+ // in one `''` group and fight over it — which is exactly what a
333
+ // registry keyed by name does if nothing guards the empty key.
334
+ const parent = host();
335
+ const first = document.createElement('adw-radio') as AdwRadio;
336
+ const second = document.createElement('adw-radio') as AdwRadio;
337
+ parent.append(first, second);
338
+
339
+ first.click();
340
+ second.click();
341
+
342
+ expect(first.checked).toBe(true);
343
+ expect(second.checked).toBe(true);
344
+ parent.remove();
345
+ });
346
+
347
+ await it('a value falls back to the label text so a bare group still works', () => {
348
+ const parent = host();
349
+ const make = (text: string) => {
350
+ const el = document.createElement('adw-radio') as AdwRadio;
351
+ el.setAttribute('name', 'bare');
352
+ el.setAttribute('label', text);
353
+ parent.appendChild(el);
354
+ return el;
355
+ };
356
+ const red = make('Red');
357
+ const green = make('Green');
358
+
359
+ expect(red.value).toBe('Red');
360
+ red.click();
361
+ green.click();
362
+
363
+ expect(red.checked).toBe(false);
364
+ expect(green.checked).toBe(true);
365
+ parent.remove();
366
+ });
367
+ });
368
+ };
@@ -2,8 +2,21 @@
2
2
  // @gjsify/adwaita-web browser test axis. Asserts option parsing, selected↔value
3
3
  // sync, notify-on-change semantics, popover open/close, item selection, search
4
4
  // filtering and the disabled guard.
5
+ //
6
+ // The SELECTION half is driven by the SAME table the NativeScript renderer
7
+ // asserts against (`COMBO_SELECTION_VECTORS`, `@gjsify/adwaita-core/conformance`)
8
+ // — replayed against the real element through the API a consumer would use, and
9
+ // read back as what a user would see: the button label, `selected`/`selectedValue`
10
+ // and the `change` stream. `<adw-combo-row>` composes the same `ComboState` but
11
+ // cannot drive the table, because two of its four step ops have no DOM spelling:
12
+ // its options come from the `items` attribute at connect time only (`items` is not
13
+ // even observed), and it publishes no select-by-value setter. So this element is
14
+ // the browser's driver for both.
5
15
  import { describe, it, expect } from '@gjsify/unit';
6
16
 
17
+ import { COMBO_SELECTION_VECTORS } from '@gjsify/adwaita-core/conformance';
18
+ import type { ComboSelectionStep, ComboSelectionVector } from '@gjsify/adwaita-core/conformance';
19
+
7
20
  import type { AdwDropDown } from './elements/adw-drop-down.js';
8
21
 
9
22
  function makeDropDown(): AdwDropDown {
@@ -12,7 +25,117 @@ function makeDropDown(): AdwDropDown {
12
25
  return el;
13
26
  }
14
27
 
28
+ /** The text the closed button shows — the rendered `selectedLabel`. */
29
+ function labelText(dd: AdwDropDown): string {
30
+ return dd.querySelector('.adw-drop-down-label')?.textContent ?? '';
31
+ }
32
+
33
+ /** Open the chooser the way a user does. A no-op over an empty model, as the element guards. */
34
+ function openChooser(dd: AdwDropDown): void {
35
+ (dd.querySelector('.adw-drop-down-button') as HTMLButtonElement).click();
36
+ }
37
+
38
+ /**
39
+ * Replay one vector step against the real element — property sets for the
40
+ * programmatic ops, a popover gesture for the interactive one.
41
+ */
42
+ function applyStep(dd: AdwDropDown, step: ComboSelectionStep): void {
43
+ switch (step.op) {
44
+ case 'setOptions':
45
+ dd.options = step.options;
46
+ return;
47
+ case 'setSelectedIndex':
48
+ dd.selected = step.index;
49
+ return;
50
+ case 'setSelectedValue':
51
+ dd.selectedValue = step.value;
52
+ return;
53
+ case 'select':
54
+ openChooser(dd);
55
+ if (step.index < 0) {
56
+ // The dismissed chooser: the popover closes with nothing picked,
57
+ // which here is an outside pointerdown rather than a cancelled
58
+ // native sheet.
59
+ document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
60
+ return;
61
+ }
62
+ (dd.querySelectorAll('.adw-drop-down-item')[step.index] as HTMLButtonElement | undefined)?.click();
63
+ return;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * The ONE row this element answers differently, and does so on purpose: its
69
+ * published `selected` contract REJECTS an out-of-range set (`_selectIndex` gates
70
+ * on `ComboState.hasIndex`), where the core state accepts it and reports an empty
71
+ * value — the split `ComboState.hasIndex`'s doc comment records and
72
+ * `<adw-combo-row>` takes the other side of. The row is still driven below,
73
+ * against the element's answer rather than the state's, so neither policy can
74
+ * change silently.
75
+ */
76
+ const DROP_DOWN_REJECTS = 'an index past the end';
77
+
78
+ /** The named vector — a hard failure if the table renamed it out from under the carve-out. */
79
+ function vectorNamed(name: string): ComboSelectionVector {
80
+ const vector = COMBO_SELECTION_VECTORS.find((v) => v.name === name);
81
+ if (!vector) throw new Error(`COMBO_SELECTION_VECTORS has no row "${name}" — this spec's carve-out is stale`);
82
+ return vector;
83
+ }
84
+
85
+ /** Mount a drop-down and record every `change` detail it dispatches. */
86
+ function mountRecording(): { dd: AdwDropDown; changes: unknown[] } {
87
+ const dd = makeDropDown();
88
+ const changes: unknown[] = [];
89
+ dd.addEventListener('change', (event) => changes.push((event as CustomEvent).detail));
90
+ return { dd, changes };
91
+ }
92
+
15
93
  export const AdwDropDownTest = async () => {
94
+ await describe('adw-drop-down selection (libadwaita conformance vectors)', async () => {
95
+ for (const vector of COMBO_SELECTION_VECTORS) {
96
+ if (vector.name === DROP_DOWN_REJECTS) continue;
97
+
98
+ await it(`${vector.name} — ${vector.rule}`, async () => {
99
+ const { dd, changes } = mountRecording();
100
+ for (const step of vector.steps) applyStep(dd, step);
101
+
102
+ expect(dd.selected).toBe(vector.selected);
103
+ expect(dd.selectedValue).toBe(vector.value);
104
+ expect(labelText(dd)).toBe(vector.label);
105
+ // `change` is this element's DOM spelling of the `interactive`
106
+ // flag: it fires for a user pick and never for a programmatic
107
+ // set, so the interactive frames of the emission stream ARE the
108
+ // events a listener sees.
109
+ expect(changes).toStrictEqual(
110
+ vector.emitted
111
+ .filter((change) => change.interactive)
112
+ .map((change) => ({ index: change.selected, value: change.value, label: change.label })),
113
+ );
114
+ dd.remove();
115
+ });
116
+ }
117
+
118
+ await it(`${DROP_DOWN_REJECTS} — rejected here: the element's published contract, not the state's`, async () => {
119
+ const vector = vectorNamed(DROP_DOWN_REJECTS);
120
+ const { dd, changes } = mountRecording();
121
+
122
+ // Everything up to the out-of-range set…
123
+ for (const step of vector.steps.slice(0, -1)) applyStep(dd, step);
124
+ const before = { selected: dd.selected, value: dd.selectedValue, label: labelText(dd) };
125
+ // …and then the set itself, which the core state would accept and
126
+ // report as nothing-to-draw (`value`/`label` both empty below).
127
+ expect(vector.value).toBe('');
128
+ expect(vector.label).toBe('');
129
+ applyStep(dd, vector.steps[vector.steps.length - 1]);
130
+
131
+ // The element refused it: the selection did not move, and a refusal
132
+ // is not a change, so nothing was dispatched either.
133
+ expect({ selected: dd.selected, value: dd.selectedValue, label: labelText(dd) }).toStrictEqual(before);
134
+ expect(changes).toStrictEqual([]);
135
+ dd.remove();
136
+ });
137
+ });
138
+
16
139
  await describe('adw-drop-down options', async () => {
17
140
  await it('accepts a string[] (value === label)', async () => {
18
141
  const dd = makeDropDown();
@@ -0,0 +1,83 @@
1
+ // DOM-level tests for <adw-header-bar>'s title.
2
+ //
3
+ // `title` was read exactly once, in `connectedCallback`, and the element declared
4
+ // no `observedAttributes` at all — so every later write was a silent no-op. A
5
+ // header bar whose title tracks the open document kept whatever it was created
6
+ // with, and nothing in the build was in a position to notice: the attribute was
7
+ // still THERE, and the element still rendered A title.
8
+ //
9
+ // `Adw.HeaderBar` builds its centre from `title`/`subtitle` through a derived
10
+ // `AdwWindowTitle` bound to the properties, unless a `title-widget` replaces it
11
+ // — which is the either/or the `slot="center"` case below asserts.
12
+
13
+ import { describe, expect, it } from '@gjsify/unit';
14
+
15
+ /** The rendered centre text of a mounted header bar. */
16
+ function centerText(bar: HTMLElement): string {
17
+ return bar.querySelector('.adw-header-bar-center')?.textContent ?? '';
18
+ }
19
+
20
+ /** Mount a header bar with the given attributes and slotted children. */
21
+ function mountHeaderBar(attrs: Record<string, string>, slotted: readonly HTMLElement[] = []): HTMLElement {
22
+ const bar = document.createElement('adw-header-bar');
23
+ for (const [name, value] of Object.entries(attrs)) bar.setAttribute(name, value);
24
+ for (const child of slotted) bar.appendChild(child);
25
+ document.body.appendChild(bar);
26
+ return bar;
27
+ }
28
+
29
+ /** A `slot="center"` title-widget, the equivalent of `Adw.HeaderBar:title-widget`. */
30
+ function centerWidget(text: string): HTMLElement {
31
+ const widget = document.createElement('div');
32
+ widget.setAttribute('slot', 'center');
33
+ widget.id = 'url-entry';
34
+ widget.textContent = text;
35
+ return widget;
36
+ }
37
+
38
+ /** Remove every header bar a test mounted. */
39
+ function unmountAll(): void {
40
+ for (const bar of Array.from(document.querySelectorAll('adw-header-bar'))) bar.remove();
41
+ }
42
+
43
+ export const AdwHeaderBarTest = async () => {
44
+ await describe('<adw-header-bar> title', async () => {
45
+ await it('renders the title it was created with', () => {
46
+ const bar = mountHeaderBar({ title: 'Documents' });
47
+ expect(centerText(bar)).toBe('Documents');
48
+ unmountAll();
49
+ });
50
+
51
+ await it('follows a LATER write, instead of freezing at connect time', () => {
52
+ const bar = mountHeaderBar({ title: 'Documents' });
53
+ bar.setAttribute('title', 'Pictures');
54
+ expect(centerText(bar)).toBe('Pictures');
55
+ unmountAll();
56
+ });
57
+
58
+ await it('clears when the attribute is removed', () => {
59
+ const bar = mountHeaderBar({ title: 'Documents' });
60
+ bar.removeAttribute('title');
61
+ expect(centerText(bar)).toBe('');
62
+ unmountAll();
63
+ });
64
+
65
+ await it('a title set before the element is connected still lands', () => {
66
+ const bar = document.createElement('adw-header-bar');
67
+ bar.setAttribute('title', 'Early');
68
+ document.body.appendChild(bar);
69
+ expect(centerText(bar)).toBe('Early');
70
+ unmountAll();
71
+ });
72
+
73
+ await it('a slot="center" widget wins, and a title write does not overwrite it', () => {
74
+ const bar = mountHeaderBar({ title: 'Ignored' }, [centerWidget('example.org')]);
75
+ expect(bar.querySelector('#url-entry')).toBeTruthy();
76
+ expect(centerText(bar)).toBe('example.org');
77
+
78
+ bar.setAttribute('title', 'Still ignored');
79
+ expect(centerText(bar)).toBe('example.org');
80
+ unmountAll();
81
+ });
82
+ });
83
+ };