@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,332 @@
1
+ // DOM-level conformance tests for <adw-sidebar>, driven by the SAME vectors the
2
+ // NativeScript renderer asserts against (`@gjsify/adwaita-core/conformance`).
3
+ //
4
+ // The two renderers used to carry independent selection logic, and it had
5
+ // drifted apart AND away from libadwaita: `selected = 5` on a 3-item sidebar
6
+ // gave "no selection" in GTK, `2` here (it clamped to the last row) and `0` on
7
+ // NativeScript (it dropped the write). This element also keyed section headers
8
+ // off the DECLARATION index, so an empty leading section drew a stray separator
9
+ // and cost the next section its `.first` flush-to-top padding. Nothing failed,
10
+ // because nothing compared them. This suite is that comparison.
11
+ import { describe, expect, it } from '@gjsify/unit';
12
+
13
+ import type { AdwSidebarItemSpec, AdwSidebarSectionSpec } from '@gjsify/adwaita-core';
14
+ import {
15
+ SIDEBAR_ACTIVATION_VECTORS,
16
+ SIDEBAR_CLAMP_VECTORS,
17
+ SIDEBAR_FILTER_VECTORS,
18
+ SIDEBAR_ITEM_FLAG_VECTORS,
19
+ SIDEBAR_MODEL_VECTORS,
20
+ SIDEBAR_MODE_VECTORS,
21
+ } from '@gjsify/adwaita-core/conformance';
22
+ import type { AdwSidebar } from './elements/adw-sidebar.js';
23
+
24
+ /**
25
+ * Mount a sidebar built from DECLARED children, the way an author writes it —
26
+ * the element consumes them at connect time, so they all have to exist before
27
+ * the host is appended.
28
+ */
29
+ function mountSidebar(
30
+ sections: ReadonlyArray<AdwSidebarSectionSpec>,
31
+ attributes: { mode?: string; selected?: string } = {},
32
+ ): { sidebar: AdwSidebar; host: HTMLElement } {
33
+ const host = document.createElement('div');
34
+ document.body.appendChild(host);
35
+
36
+ const sidebar = document.createElement('adw-sidebar') as AdwSidebar;
37
+ if (attributes.mode !== undefined) sidebar.setAttribute('mode', attributes.mode);
38
+ if (attributes.selected !== undefined) sidebar.setAttribute('selected', attributes.selected);
39
+
40
+ for (const section of sections) {
41
+ const sectionEl = document.createElement('adw-sidebar-section');
42
+ if (section.title !== undefined) sectionEl.setAttribute('title', section.title);
43
+
44
+ for (const item of section.items) {
45
+ sectionEl.appendChild(createItem(item));
46
+ }
47
+ sidebar.appendChild(sectionEl);
48
+ }
49
+
50
+ host.appendChild(sidebar);
51
+ return { sidebar, host };
52
+ }
53
+
54
+ function createItem(item: AdwSidebarItemSpec): HTMLElement {
55
+ const el = document.createElement('adw-sidebar-item');
56
+ el.setAttribute('title', item.title);
57
+ if (item.subtitle !== undefined) el.setAttribute('subtitle', item.subtitle);
58
+ if (item.iconName !== undefined) el.setAttribute('icon-name', item.iconName);
59
+ if (item.enabled === false) el.setAttribute('disabled', '');
60
+ if (item.visible === false) el.setAttribute('hidden', '');
61
+ return el;
62
+ }
63
+
64
+ /** The rendered rows, in render order. */
65
+ function rowsOf(sidebar: AdwSidebar): HTMLButtonElement[] {
66
+ return Array.from(sidebar.querySelectorAll('.adw-sidebar-item')) as HTMLButtonElement[];
67
+ }
68
+
69
+ /** The rendered headers, reduced to the three things the C source derives. */
70
+ function headersOf(sidebar: AdwSidebar): { kind: string; title: string; first: boolean }[] {
71
+ return Array.from(sidebar.querySelectorAll('.adw-sidebar-section-header')).map((el) => ({
72
+ kind: el.classList.contains('has-title') ? 'title' : 'separator',
73
+ title: el.querySelector('.adw-sidebar-section-heading')?.textContent ?? '',
74
+ first: el.classList.contains('first'),
75
+ }));
76
+ }
77
+
78
+ /** A sidebar of `count` plain rows — the shape the clamp vectors are stated over. */
79
+ function countedSections(count: number): AdwSidebarSectionSpec[] {
80
+ if (count === 0) return [];
81
+ return [{ items: Array.from({ length: count }, (_, i) => ({ title: `Item ${i}` })) }];
82
+ }
83
+
84
+ export const AdwSidebarTest = async () => {
85
+ await describe('adw-sidebar selection bounds (libadwaita conformance vectors)', async () => {
86
+ for (const { index, count, selected, rule } of SIDEBAR_CLAMP_VECTORS) {
87
+ await it(`selected="${index}" on ${count} rows → ${selected} — ${rule}`, () => {
88
+ const { sidebar, host } = mountSidebar(countedSections(count), { selected: String(index) });
89
+
90
+ expect(sidebar.selected).toBe(selected);
91
+ // The attribute is normalised back, so it never claims an index
92
+ // the property does not report.
93
+ expect(sidebar.getAttribute('selected')).toBe(String(selected));
94
+ expect(rowsOf(sidebar).filter((row) => row.classList.contains('selected'))).toHaveLength(
95
+ selected === -1 ? 0 : 1,
96
+ );
97
+
98
+ host.remove();
99
+ });
100
+ }
101
+
102
+ await it('auto-selects the first row when no index is declared', () => {
103
+ const { sidebar, host } = mountSidebar(countedSections(3));
104
+ expect(sidebar.selected).toBe(0);
105
+ expect(rowsOf(sidebar)[0]!.classList.contains('selected')).toBe(true);
106
+ host.remove();
107
+ });
108
+
109
+ await it('notifies on a programmatic change, not only on a click', () => {
110
+ const { sidebar, host } = mountSidebar(countedSections(3));
111
+ const seen: { selected: number; interactive: boolean }[] = [];
112
+ sidebar.addEventListener('notify::selected', (event) => {
113
+ seen.push((event as CustomEvent).detail as { selected: number; interactive: boolean });
114
+ });
115
+
116
+ sidebar.selected = 2;
117
+ expect(seen).toHaveLength(1);
118
+ expect(seen[0]!.selected).toBe(2);
119
+ expect(seen[0]!.interactive).toBe(false);
120
+
121
+ host.remove();
122
+ });
123
+ });
124
+
125
+ await describe('adw-sidebar rows + headers (libadwaita conformance vectors)', async () => {
126
+ for (const vector of SIDEBAR_MODEL_VECTORS) {
127
+ await it(`${vector.name} — ${vector.rule}`, () => {
128
+ const { sidebar, host } = mountSidebar(vector.sections);
129
+
130
+ expect(rowsOf(sidebar)).toHaveLength(vector.count);
131
+ expect(
132
+ rowsOf(sidebar).map((row) => row.querySelector('.adw-sidebar-item-title')?.textContent ?? ''),
133
+ ).toStrictEqual(vector.flat.map((entry) => entry.title));
134
+ expect(headersOf(sidebar)).toStrictEqual(
135
+ vector.headers.map((header) => ({
136
+ kind: header.kind as string,
137
+ title: header.title,
138
+ first: header.first,
139
+ })),
140
+ );
141
+
142
+ host.remove();
143
+ });
144
+ }
145
+
146
+ await it('draws no section container for a section that renders no rows', () => {
147
+ // The old element appended a header AND an empty `.adw-sidebar-section`
148
+ // per DECLARED section, which in page mode is a visible empty card.
149
+ const { sidebar, host } = mountSidebar([{ title: 'Empty', items: [] }, { items: [{ title: 'A' }] }]);
150
+ expect(sidebar.querySelectorAll('.adw-sidebar-section')).toHaveLength(1);
151
+ expect(headersOf(sidebar)).toHaveLength(0);
152
+ host.remove();
153
+ });
154
+ });
155
+
156
+ await describe('adw-sidebar item labels (string_is_not_empty bindings)', async () => {
157
+ for (const vector of SIDEBAR_ITEM_FLAG_VECTORS) {
158
+ await it(`${JSON.stringify(vector.item)} — ${vector.rule}`, () => {
159
+ const { sidebar, host } = mountSidebar([{ items: [vector.item] }]);
160
+ const row = rowsOf(sidebar)[0]!;
161
+
162
+ expect((row.querySelector('.adw-sidebar-item-title') as HTMLElement).hidden).toBe(!vector.titleVisible);
163
+ expect((row.querySelector('.adw-sidebar-item-subtitle') as HTMLElement).hidden).toBe(
164
+ !vector.subtitleVisible,
165
+ );
166
+ expect((row.querySelector('.adw-sidebar-item-icon') as HTMLElement).hidden).toBe(!vector.iconVisible);
167
+
168
+ host.remove();
169
+ });
170
+ }
171
+ });
172
+
173
+ await describe('adw-sidebar activation (row-selected then row-activated)', async () => {
174
+ for (const vector of SIDEBAR_ACTIVATION_VECTORS) {
175
+ await it(`${vector.name} — ${vector.rule}`, () => {
176
+ const { sidebar, host } = mountSidebar(vector.sections);
177
+ sidebar.selected = vector.initialSelected;
178
+
179
+ const activations: number[] = [];
180
+ const notifications: number[] = [];
181
+ sidebar.addEventListener('activated', (event) => {
182
+ activations.push(((event as CustomEvent).detail as { index: number }).index);
183
+ });
184
+ sidebar.addEventListener('notify::selected', (event) => {
185
+ notifications.push(((event as CustomEvent).detail as { selected: number }).selected);
186
+ });
187
+
188
+ const row = rowsOf(sidebar)[vector.activate];
189
+ if (row) row.click();
190
+ else expect(vector.activated).toBe(false);
191
+
192
+ expect(activations).toHaveLength(vector.activated ? 1 : 0);
193
+ expect(notifications).toHaveLength(vector.selectionChanged ? 1 : 0);
194
+ expect(sidebar.selected).toBe(vector.selected);
195
+
196
+ host.remove();
197
+ });
198
+ }
199
+
200
+ await it('re-clicking the selected row still fires activated — the split-view reveal', () => {
201
+ const { sidebar, host } = mountSidebar([{ items: [{ title: 'A' }, { title: 'B' }] }]);
202
+ const activations: number[] = [];
203
+ sidebar.addEventListener('activated', () => activations.push(1));
204
+
205
+ rowsOf(sidebar)[0]!.click();
206
+ rowsOf(sidebar)[0]!.click();
207
+ expect(activations).toHaveLength(2);
208
+ expect(sidebar.selected).toBe(0);
209
+
210
+ host.remove();
211
+ });
212
+
213
+ await it('tags a click as interactive and a property write as not', () => {
214
+ const { sidebar, host } = mountSidebar([{ items: [{ title: 'A' }, { title: 'B' }] }]);
215
+ const flags: boolean[] = [];
216
+ sidebar.addEventListener('notify::selected', (event) => {
217
+ flags.push(((event as CustomEvent).detail as { interactive: boolean }).interactive);
218
+ });
219
+
220
+ rowsOf(sidebar)[1]!.click();
221
+ sidebar.selected = 0;
222
+ expect(flags).toStrictEqual([true, false]);
223
+
224
+ host.remove();
225
+ });
226
+ });
227
+
228
+ await describe('adw-sidebar filter + empty state (Adw.Sidebar:filter / update_placeholder)', async () => {
229
+ for (const vector of SIDEBAR_FILTER_VECTORS) {
230
+ await it(`${vector.name} — ${vector.rule}`, () => {
231
+ const { sidebar, host } = mountSidebar(vector.sections);
232
+ sidebar.filter = (item) => vector.keepTitles.includes(item.title);
233
+
234
+ expect(rowsOf(sidebar)).toHaveLength(vector.visibleIndices.length);
235
+ expect(headersOf(sidebar)).toStrictEqual(
236
+ vector.headers.map((header) => ({
237
+ kind: header.kind as string,
238
+ title: header.title,
239
+ first: header.first,
240
+ })),
241
+ );
242
+ expect(sidebar.classList.contains('empty')).toBe(vector.isEmpty);
243
+
244
+ host.remove();
245
+ });
246
+ }
247
+
248
+ await it('keeps the selection index in the UNFILTERED space', () => {
249
+ const { sidebar, host } = mountSidebar([{ items: [{ title: 'A' }, { title: 'B' }, { title: 'C' }] }]);
250
+ sidebar.selected = 2;
251
+ sidebar.filter = (item) => item.title !== 'A';
252
+
253
+ expect(sidebar.selected).toBe(2);
254
+ expect(sidebar.selectedItem?.title).toBe('C');
255
+ expect(rowsOf(sidebar)).toHaveLength(2);
256
+ expect(rowsOf(sidebar)[1]!.classList.contains('selected')).toBe(true);
257
+
258
+ host.remove();
259
+ });
260
+ });
261
+
262
+ await describe('adw-sidebar mode (adw_sidebar_set_mode)', async () => {
263
+ for (const { mode, selectionVisible, rule } of SIDEBAR_MODE_VECTORS) {
264
+ await it(`${mode} paints the selection: ${selectionVisible} — ${rule}`, () => {
265
+ const { sidebar, host } = mountSidebar(countedSections(3), { mode, selected: '1' });
266
+
267
+ expect(sidebar.mode).toBe(mode);
268
+ expect(sidebar.selected).toBe(1);
269
+ expect(rowsOf(sidebar)[1]!.classList.contains('selected')).toBe(selectionVisible);
270
+ // Tracked either way — aria still reports which item is current.
271
+ expect(rowsOf(sidebar)[1]!.getAttribute('aria-selected')).toBe('true');
272
+
273
+ host.remove();
274
+ });
275
+ }
276
+
277
+ await it('keeps the selection across a mode switch and back', () => {
278
+ const { sidebar, host } = mountSidebar(countedSections(3), { selected: '2' });
279
+
280
+ sidebar.mode = 'page';
281
+ expect(sidebar.selected).toBe(2);
282
+ expect(rowsOf(sidebar)[2]!.classList.contains('selected')).toBe(false);
283
+
284
+ sidebar.mode = 'sidebar';
285
+ expect(sidebar.selected).toBe(2);
286
+ expect(rowsOf(sidebar)[2]!.classList.contains('selected')).toBe(true);
287
+
288
+ host.remove();
289
+ });
290
+ });
291
+
292
+ await describe('adw-sidebar live model changes', async () => {
293
+ await it('rebuilds when the sections are replaced, re-running the 0 → n auto-select', () => {
294
+ const { sidebar, host } = mountSidebar([{ items: [{ title: 'A' }, { title: 'B' }] }]);
295
+ sidebar.selected = 1;
296
+
297
+ sidebar.sections = [{ title: 'New', items: [{ title: 'X' }, { title: 'Y' }, { title: 'Z' }] }];
298
+ expect(rowsOf(sidebar)).toHaveLength(3);
299
+ expect(sidebar.selected).toBe(0);
300
+ expect(headersOf(sidebar)).toStrictEqual([{ kind: 'title', title: 'New', first: true }]);
301
+
302
+ host.remove();
303
+ });
304
+
305
+ await it('follows a declared item attribute change without touching the selection', () => {
306
+ // <adw-sidebar-item> declared `observedAttributes` with no
307
+ // attributeChangedCallback behind it, so every post-construction
308
+ // change was silently lost. The declared element is detached once
309
+ // consumed, so the link back to its spec cannot be a DOM lookup.
310
+ const declared = createItem({ title: 'A' });
311
+ const sectionEl = document.createElement('adw-sidebar-section');
312
+ sectionEl.append(declared, createItem({ title: 'B' }));
313
+
314
+ const host = document.createElement('div');
315
+ document.body.appendChild(host);
316
+ const sidebar = document.createElement('adw-sidebar') as AdwSidebar;
317
+ sidebar.appendChild(sectionEl);
318
+ host.appendChild(sidebar);
319
+
320
+ sidebar.selected = 1;
321
+ declared.setAttribute('title', 'Renamed');
322
+ declared.setAttribute('icon-name', 'folder-symbolic');
323
+
324
+ const row = rowsOf(sidebar)[0]!;
325
+ expect(row.querySelector('.adw-sidebar-item-title')?.textContent).toBe('Renamed');
326
+ expect((row.querySelector('.adw-sidebar-item-icon') as HTMLElement).hidden).toBe(false);
327
+ expect(sidebar.selected).toBe(1);
328
+
329
+ host.remove();
330
+ });
331
+ });
332
+ };
@@ -0,0 +1,242 @@
1
+ // DOM-level tests for <adw-switch>, plus the REGRESSION PROOF that lifting it
2
+ // out of <adw-switch-row> and <adw-expander-row> left both rows working.
3
+ //
4
+ // What the extraction removed: `_expander_row.scss:73-121` was a
5
+ // character-for-character copy of `_switch_row.scss:15-63` (same 44×24 box, same
6
+ // hidden input, same 20px knob, same `translateX(20px)`, same focus ring),
7
+ // because `_switch_row.scss:9` opened `adw-switch-row {` and nested `.adw-switch`
8
+ // inside it — so the class did not exist anywhere else and the expander row could
9
+ // not reuse it. Two consequences this suite pins down:
10
+ //
11
+ // - the GEOMETRY now comes from ONE unscoped partial. `measure()` compares the
12
+ // computed box of the row's switch against the expander's; before the lift
13
+ // they were two independently-maintained blocks that happened to agree, and
14
+ // deleting either one silently unstyled its widget.
15
+ // - the EVENTS are unchanged. Both rows compose an element that emits its own
16
+ // `notify::active`, so each row stops it at the switch and publishes its own
17
+ // name — a row listener must still see exactly one event per toggle.
18
+ import { describe, expect, it } from '@gjsify/unit';
19
+
20
+ import type { AdwExpanderRow } from './elements/adw-expander-row.js';
21
+ import type { AdwSwitch } from './elements/adw-switch.js';
22
+ import type { AdwSwitchRow } from './elements/adw-switch-row.js';
23
+
24
+ function mount<T extends HTMLElement>(tag: string): { el: T; host: HTMLElement } {
25
+ const host = document.createElement('div');
26
+ document.body.appendChild(host);
27
+ const el = document.createElement(tag) as T;
28
+ host.appendChild(el);
29
+ return { el, host };
30
+ }
31
+
32
+ /** Collect the `detail` of every `event` dispatched on (or bubbling to) `el`. */
33
+ function record(el: HTMLElement, event: string): unknown[] {
34
+ const details: unknown[] = [];
35
+ el.addEventListener(event, (e) => details.push((e as CustomEvent).detail));
36
+ return details;
37
+ }
38
+
39
+ /** The switch's own parts, by the markup contract the stylesheet selects on. */
40
+ function parts(sw: AdwSwitch): { input: HTMLInputElement; slider: HTMLElement } {
41
+ return {
42
+ input: sw.querySelector('input[type="checkbox"]') as HTMLInputElement,
43
+ slider: sw.querySelector('.adw-switch-slider') as HTMLElement,
44
+ };
45
+ }
46
+
47
+ /** The rendered track box — the thing the two copies used to declare separately. */
48
+ function measure(sw: HTMLElement): { width: string; height: string; radius: string } {
49
+ const box = getComputedStyle(sw);
50
+ const slider = getComputedStyle(sw.querySelector('.adw-switch-slider') as HTMLElement);
51
+ return { width: box.width, height: box.height, radius: slider.borderTopLeftRadius };
52
+ }
53
+
54
+ export const AdwSwitchTest = async () => {
55
+ await describe('<adw-switch> state', async () => {
56
+ await it('starts off and builds the input + slider markup', () => {
57
+ const { el, host } = mount<AdwSwitch>('adw-switch');
58
+ const { input, slider } = parts(el);
59
+ expect(el.active).toBe(false);
60
+ expect(input).toBeTruthy();
61
+ expect(input.checked).toBe(false);
62
+ // The focus ring is `input:focus-visible + .adw-switch-slider`, so the
63
+ // slider MUST be the input's immediate next sibling.
64
+ expect(input.nextElementSibling).toBe(slider);
65
+ expect(el.classList.contains('adw-switch')).toBe(true);
66
+ host.remove();
67
+ });
68
+
69
+ await it('adopts a declarative active attribute without emitting', () => {
70
+ const host = document.createElement('div');
71
+ document.body.appendChild(host);
72
+ const el = document.createElement('adw-switch') as AdwSwitch;
73
+ el.setAttribute('active', '');
74
+ const events = record(el, 'notify::active');
75
+ host.appendChild(el);
76
+ expect(el.active).toBe(true);
77
+ expect(parts(el).input.checked).toBe(true);
78
+ expect(events.length).toBe(0);
79
+ host.remove();
80
+ });
81
+
82
+ await it('a programmatic set notifies, and re-setting the same value does not', () => {
83
+ const { el, host } = mount<AdwSwitch>('adw-switch');
84
+ const events = record(el, 'notify::active');
85
+ el.active = true;
86
+ expect(events).toStrictEqual([{ active: true }]);
87
+ el.active = true;
88
+ expect(events.length).toBe(1);
89
+ el.active = false;
90
+ expect(events.length).toBe(2);
91
+ expect(parts(el).input.checked).toBe(false);
92
+ host.remove();
93
+ });
94
+
95
+ await it('a click on the track toggles exactly once', () => {
96
+ const { el, host } = mount<AdwSwitch>('adw-switch');
97
+ const events = record(el, 'notify::active');
98
+ parts(el).slider.click();
99
+ expect(el.active).toBe(true);
100
+ expect(el.hasAttribute('active')).toBe(true);
101
+ expect(events.length).toBe(1);
102
+ host.remove();
103
+ });
104
+
105
+ await it('a click on the hidden checkbox toggles exactly once', () => {
106
+ // The checkbox is 0×0, so a pointer never reaches it — but keyboard
107
+ // activation and `input.click()` do, and the track listener must not
108
+ // toggle a second time on the way out.
109
+ const { el, host } = mount<AdwSwitch>('adw-switch');
110
+ const events = record(el, 'notify::active');
111
+ parts(el).input.click();
112
+ expect(el.active).toBe(true);
113
+ expect(events.length).toBe(1);
114
+ host.remove();
115
+ });
116
+
117
+ await it('a disabled switch does not toggle', () => {
118
+ const { el, host } = mount<AdwSwitch>('adw-switch');
119
+ el.disabled = true;
120
+ const events = record(el, 'notify::active');
121
+ parts(el).slider.click();
122
+ expect(el.active).toBe(false);
123
+ expect(events.length).toBe(0);
124
+ expect(parts(el).input.disabled).toBe(true);
125
+ expect(el.classList.contains('disabled')).toBe(true);
126
+ // …but it is not frozen: a programmatic set still applies, matching
127
+ // GObject property semantics (sensitivity gates INPUT, not the value).
128
+ el.active = true;
129
+ expect(events).toStrictEqual([{ active: true }]);
130
+ host.remove();
131
+ });
132
+ });
133
+
134
+ await describe('<adw-switch-row> still toggles and emits through <adw-switch>', async () => {
135
+ await it('a programmatic row.active drives the switch and emits once', () => {
136
+ const { el: row, host } = mount<AdwSwitchRow>('adw-switch-row');
137
+ const events = record(row, 'notify::active');
138
+ const sw = row.querySelector('adw-switch') as AdwSwitch;
139
+ expect(sw).toBeTruthy();
140
+
141
+ row.active = true;
142
+
143
+ expect(sw.active).toBe(true);
144
+ expect(parts(sw).input.checked).toBe(true);
145
+ expect(row.getAttribute('aria-checked')).toBe('true');
146
+ // ONE event: the composed switch's identically-named notify is
147
+ // stopped at the switch so it cannot reach a row listener as a second.
148
+ expect(events).toStrictEqual([{ active: true }]);
149
+ host.remove();
150
+ });
151
+
152
+ await it('a click on the row toggles and emits once', () => {
153
+ const { el: row, host } = mount<AdwSwitchRow>('adw-switch-row');
154
+ const events = record(row, 'notify::active');
155
+ row.click();
156
+ expect(row.active).toBe(true);
157
+ expect((row.querySelector('adw-switch') as AdwSwitch).active).toBe(true);
158
+ expect(events.length).toBe(1);
159
+ host.remove();
160
+ });
161
+
162
+ await it('a click on the switch toggles the row once, not twice', () => {
163
+ const { el: row, host } = mount<AdwSwitchRow>('adw-switch-row');
164
+ const events = record(row, 'notify::active');
165
+ const sw = row.querySelector('adw-switch') as AdwSwitch;
166
+ parts(sw).slider.click();
167
+ expect(row.active).toBe(true);
168
+ expect(events.length).toBe(1);
169
+ host.remove();
170
+ });
171
+ });
172
+
173
+ await describe('<adw-expander-row> still toggles its enable switch', async () => {
174
+ await it('a user toggle flips enable-expansion and emits once', () => {
175
+ const { el: row, host } = mount<AdwExpanderRow>('adw-expander-row');
176
+ row.setAttribute('show-enable-switch', '');
177
+ const events = record(row, 'notify::enable-expansion');
178
+ const sw = row.querySelector('adw-switch') as AdwSwitch;
179
+ expect(sw.active).toBe(true); // enable-expansion defaults to true
180
+
181
+ parts(sw).slider.click();
182
+
183
+ expect(row.enableExpansion).toBe(false);
184
+ expect(row.getAttribute('enable-expansion')).toBe('false');
185
+ expect(events).toStrictEqual([{ enableExpansion: false }]);
186
+ host.remove();
187
+ });
188
+
189
+ await it('a programmatic enableExpansion re-syncs the switch silently', () => {
190
+ // The row's published event is for USER changes, as it always was —
191
+ // the composed switch notifies on every change, so the row has to
192
+ // know which writes are its own.
193
+ const { el: row, host } = mount<AdwExpanderRow>('adw-expander-row');
194
+ row.setAttribute('show-enable-switch', '');
195
+ const enableEvents = record(row, 'notify::enable-expansion');
196
+ const activeEvents = record(row, 'notify::active');
197
+
198
+ row.enableExpansion = false;
199
+
200
+ expect((row.querySelector('adw-switch') as AdwSwitch).active).toBe(false);
201
+ expect(enableEvents.length).toBe(0);
202
+ // The switch's own notify never escapes the row either.
203
+ expect(activeEvents.length).toBe(0);
204
+ host.remove();
205
+ });
206
+
207
+ await it('a click on the enable switch does not disclose the row', () => {
208
+ const { el: row, host } = mount<AdwExpanderRow>('adw-expander-row');
209
+ row.setAttribute('show-enable-switch', '');
210
+ const sw = row.querySelector('adw-switch') as AdwSwitch;
211
+
212
+ parts(sw).slider.click();
213
+ expect(row.expanded).toBe(false);
214
+
215
+ (row.querySelector('.adw-expander-row-header') as HTMLElement).click();
216
+ expect(row.expanded).toBe(true);
217
+ host.remove();
218
+ });
219
+ });
220
+
221
+ await describe('one switch stylesheet reaches both rows', async () => {
222
+ await it('the row and the expander render the SAME track box', () => {
223
+ const { el: row, host: rowHost } = mount<AdwSwitchRow>('adw-switch-row');
224
+ const { el: expander, host: expanderHost } = mount<AdwExpanderRow>('adw-expander-row');
225
+ expander.setAttribute('show-enable-switch', '');
226
+
227
+ const rowBox = measure(row.querySelector('adw-switch') as HTMLElement);
228
+ const expanderBox = measure(expander.querySelector('adw-switch') as HTMLElement);
229
+
230
+ expect(rowBox).toStrictEqual(expanderBox);
231
+ // Pin the actual numbers too, so "both unstyled" cannot pass as
232
+ // "both agree". This is refs/adwaita-web's geometry, NOT libadwaita's
233
+ // (which is a 3px-padded trough around a 20px slider) — see the
234
+ // fidelity note in scss/_switch.scss.
235
+ expect(rowBox.width).toBe('44px');
236
+ expect(rowBox.height).toBe('24px');
237
+
238
+ rowHost.remove();
239
+ expanderHost.remove();
240
+ });
241
+ });
242
+ };
@@ -2,7 +2,11 @@
2
2
  // @gjsify/adwaita-web browser test axis (the entry self-applies the compiled
3
3
  // stylesheet, so computed-style assertions are valid). Asserts page building,
4
4
  // selected↔click sync, notify/close events, and the expand-tabs / no-close
5
- // attributes.
5
+ // attributes — i.e. the DOM/CSS surface.
6
+ //
7
+ // The MODEL is asserted in `tab-view.spec.ts`, against the shared libadwaita
8
+ // conformance vectors. Keep the split: this file is about markup and computed
9
+ // style, that one is about behaviour both renderers must agree on.
6
10
  import { describe, expect, it } from '@gjsify/unit';
7
11
 
8
12
  import type { AdwTabView } from './elements/adw-tab-view.js';
@@ -51,7 +55,7 @@ export const AdwTabViewTest = async () => {
51
55
  view.parentElement?.remove();
52
56
  });
53
57
 
54
- await it('emits close-page from the close affordance', async () => {
58
+ await it('emits close-page from the close affordance AND removes the page', async () => {
55
59
  const view = makeTabView();
56
60
  let closed = -1;
57
61
  view.addEventListener('close-page', (event) => {
@@ -59,7 +63,12 @@ export const AdwTabViewTest = async () => {
59
63
  });
60
64
  (view.querySelectorAll('.adw-tab-close')[1] as HTMLButtonElement).click();
61
65
  expect(closed).toBe(1);
62
- // Closing must not also select the tab.
66
+ // The element used to emit and remove NOTHING, so every close was
67
+ // permanently denied; the default handler now confirms a non-pinned
68
+ // page (close_page_cb, adw-tab-view.c:1990-1991).
69
+ expect(view.querySelectorAll('.adw-tab').length).toBe(2);
70
+ // Closing a page BEFORE the selection must not also select the tab,
71
+ // and must leave the selected PAGE selected (adw-tab-view.c:1864-1865).
63
72
  expect(view.selected).toBe(0);
64
73
  view.parentElement?.remove();
65
74
  });