@gjsify/adwaita-web 0.30.0 → 0.32.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 (176) hide show
  1. package/dist/adwaita-web.css +786 -272
  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-data-grid.d.ts +11 -31
  30. package/lib/types/elements/adw-drop-down.d.ts +17 -20
  31. package/lib/types/elements/adw-entry-row.d.ts +93 -3
  32. package/lib/types/elements/adw-expander-row.d.ts +7 -1
  33. package/lib/types/elements/adw-header-bar.d.ts +15 -0
  34. package/lib/types/elements/adw-icon.d.ts +25 -0
  35. package/lib/types/elements/adw-inline-view-switcher.d.ts +37 -11
  36. package/lib/types/elements/adw-menu-button.d.ts +6 -6
  37. package/lib/types/elements/adw-navigation-split-view.d.ts +6 -0
  38. package/lib/types/elements/adw-navigation-view.d.ts +63 -20
  39. package/lib/types/elements/adw-overlay-split-view.d.ts +38 -0
  40. package/lib/types/elements/adw-password-entry-row.d.ts +19 -10
  41. package/lib/types/elements/adw-popover.d.ts +58 -0
  42. package/lib/types/elements/adw-preferences-dialog.d.ts +57 -2
  43. package/lib/types/elements/adw-preferences-group.d.ts +31 -0
  44. package/lib/types/elements/adw-progress-bar.d.ts +38 -0
  45. package/lib/types/elements/adw-sidebar.d.ts +37 -5
  46. package/lib/types/elements/adw-split-button.d.ts +53 -7
  47. package/lib/types/elements/adw-switch-row.d.ts +11 -2
  48. package/lib/types/elements/adw-switch.d.ts +15 -0
  49. package/lib/types/elements/adw-tab-view.d.ts +129 -12
  50. package/lib/types/elements/adw-toolbar-view.d.ts +5 -0
  51. package/lib/types/elements/adw-view-stack.d.ts +27 -19
  52. package/lib/types/elements/adw-view-switcher-bar.d.ts +23 -7
  53. package/lib/types/elements/adw-view-switcher.d.ts +22 -11
  54. package/lib/types/elements/adw-window-title.d.ts +3 -1
  55. package/lib/types/elements/view-switcher-dom.d.ts +44 -0
  56. package/lib/types/entry-rows.spec.d.ts +1 -0
  57. package/lib/types/index.d.ts +7 -0
  58. package/lib/types/navigation-view.spec.d.ts +1 -0
  59. package/lib/types/preferences-search.d.ts +20 -0
  60. package/lib/types/preferences.spec.d.ts +1 -0
  61. package/lib/types/split-button.spec.d.ts +1 -0
  62. package/lib/types/split-views.spec.d.ts +1 -0
  63. package/lib/types/styles.generated.d.ts +1 -1
  64. package/lib/types/tab-view.spec.d.ts +1 -0
  65. package/lib/types/view-stack.spec.d.ts +1 -0
  66. package/lib/types/view-switcher.spec.d.ts +1 -0
  67. package/package.json +6 -6
  68. package/scss/_banner.scss +13 -0
  69. package/scss/_bottom_sheet.scss +5 -6
  70. package/scss/_button.scss +3 -26
  71. package/scss/_button_content.scss +28 -0
  72. package/scss/_checks.scss +228 -0
  73. package/scss/_clamp.scss +9 -3
  74. package/scss/_data_grid.scss +15 -7
  75. package/scss/_drop_down.scss +12 -55
  76. package/scss/_expander_row.scss +5 -50
  77. package/scss/_icon.scss +51 -0
  78. package/scss/_icons.generated.scss +6 -0
  79. package/scss/_inline_view_switcher.scss +34 -0
  80. package/scss/_labels.scss +135 -0
  81. package/scss/_menu_button.scss +10 -70
  82. package/scss/_popover.scss +186 -0
  83. package/scss/_preferences.scss +29 -0
  84. package/scss/_preferences_dialog.scss +17 -0
  85. package/scss/_progress_bar.scss +161 -0
  86. package/scss/_reset.scss +18 -7
  87. package/scss/_spinner.scss +7 -3
  88. package/scss/_split_button.scss +6 -50
  89. package/scss/_switch.scss +90 -0
  90. package/scss/_switch_row.scss +6 -52
  91. package/scss/_tab_view.scss +64 -0
  92. package/scss/_theme.scss +10 -0
  93. package/scss/_toolbar_view.scss +74 -0
  94. package/scss/_variables.scss +34 -0
  95. package/scss/_view_switcher.scss +40 -0
  96. package/scss/_view_switcher_bar.scss +37 -0
  97. package/scss/_wrap_box.scss +56 -6
  98. package/scss/adwaita-skin.scss +23 -0
  99. package/src/adw-about-dialog.spec.ts +350 -0
  100. package/src/adw-action-rows.spec.ts +342 -0
  101. package/src/adw-avatar.spec.ts +139 -0
  102. package/src/adw-banner.spec.ts +152 -0
  103. package/src/adw-button-content.spec.ts +183 -0
  104. package/src/adw-button-row.spec.ts +12 -6
  105. package/src/adw-carousel.spec.ts +389 -0
  106. package/src/adw-checks.spec.ts +368 -0
  107. package/src/adw-drop-down.spec.ts +123 -0
  108. package/src/adw-header-bar.spec.ts +83 -0
  109. package/src/adw-icon.spec.ts +167 -0
  110. package/src/adw-progress-bar.spec.ts +173 -0
  111. package/src/adw-sidebar.spec.ts +332 -0
  112. package/src/adw-switch.spec.ts +242 -0
  113. package/src/adw-tab-view.spec.ts +12 -3
  114. package/src/adw-view-switcher-bar.spec.ts +61 -13
  115. package/src/adw-wrap-box.spec.ts +194 -0
  116. package/src/bottom-sheet.spec.ts +325 -0
  117. package/src/breakpoints.spec.ts +147 -0
  118. package/src/breakpoints.ts +67 -0
  119. package/src/chrome.spec.ts +301 -0
  120. package/src/elements/adw-about-dialog.ts +223 -163
  121. package/src/elements/adw-action-row.ts +120 -10
  122. package/src/elements/adw-avatar.ts +48 -54
  123. package/src/elements/adw-banner.ts +46 -10
  124. package/src/elements/adw-bottom-sheet.ts +145 -60
  125. package/src/elements/adw-button-content.ts +107 -33
  126. package/src/elements/adw-button-row.ts +61 -37
  127. package/src/elements/adw-button.ts +5 -7
  128. package/src/elements/adw-carousel.ts +467 -105
  129. package/src/elements/adw-checks.ts +299 -0
  130. package/src/elements/adw-clamp.ts +80 -10
  131. package/src/elements/adw-combo-row.ts +15 -7
  132. package/src/elements/adw-data-grid.ts +43 -91
  133. package/src/elements/adw-drop-down.ts +117 -128
  134. package/src/elements/adw-entry-row.ts +404 -36
  135. package/src/elements/adw-expander-row.ts +61 -29
  136. package/src/elements/adw-header-bar.ts +31 -6
  137. package/src/elements/adw-icon.ts +148 -0
  138. package/src/elements/adw-inline-view-switcher.ts +240 -129
  139. package/src/elements/adw-menu-button.ts +93 -70
  140. package/src/elements/adw-navigation-split-view.ts +48 -7
  141. package/src/elements/adw-navigation-view.ts +286 -148
  142. package/src/elements/adw-overlay-split-view.ts +164 -14
  143. package/src/elements/adw-password-entry-row.ts +111 -89
  144. package/src/elements/adw-popover.ts +283 -0
  145. package/src/elements/adw-preferences-dialog.ts +120 -21
  146. package/src/elements/adw-preferences-group.ts +128 -22
  147. package/src/elements/adw-progress-bar.ts +189 -0
  148. package/src/elements/adw-sidebar.ts +301 -142
  149. package/src/elements/adw-spin-row.ts +11 -5
  150. package/src/elements/adw-spinner.ts +23 -8
  151. package/src/elements/adw-split-button.ts +371 -92
  152. package/src/elements/adw-status-page.ts +9 -9
  153. package/src/elements/adw-switch-row.ts +100 -30
  154. package/src/elements/adw-switch.ts +129 -0
  155. package/src/elements/adw-tab-view.ts +682 -129
  156. package/src/elements/adw-toggle-group.ts +5 -7
  157. package/src/elements/adw-toolbar-view.ts +84 -21
  158. package/src/elements/adw-view-stack.ts +162 -85
  159. package/src/elements/adw-view-switcher-bar.ts +163 -81
  160. package/src/elements/adw-view-switcher.ts +231 -112
  161. package/src/elements/adw-window-title.ts +44 -11
  162. package/src/elements/adw-wrap-box.ts +131 -35
  163. package/src/elements/view-switcher-dom.ts +108 -0
  164. package/src/entry-rows.spec.ts +391 -0
  165. package/src/index.ts +23 -0
  166. package/src/navigation-view.spec.ts +435 -0
  167. package/src/preferences-search.ts +135 -0
  168. package/src/preferences.spec.ts +250 -0
  169. package/src/source-view/adw-source-view.ts +2 -5
  170. package/src/split-button.spec.ts +470 -0
  171. package/src/split-views.spec.ts +309 -0
  172. package/src/styles.generated.ts +1 -1
  173. package/src/tab-view.spec.ts +451 -0
  174. package/src/test.browser.mts +49 -0
  175. package/src/view-stack.spec.ts +272 -0
  176. package/src/view-switcher.spec.ts +446 -0
@@ -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
  });
@@ -1,31 +1,79 @@
1
1
  // adw-view-switcher-bar visibility regression (browser axis). Guards that the
2
- // `revealed` flag actually shows/hides the bar. The bar's base scss sets
2
+ // derived reveal state actually shows/hides the bar. The bar's base scss sets
3
3
  // `display: block`, an AUTHOR rule that would defeat the UA `[hidden]{display:none}`
4
4
  // the component relies on (cascade origin beats specificity) — so an unrevealed
5
5
  // bar rendered visible until scss/_view_switcher_bar.scss re-asserted
6
6
  // `&[hidden] { display: none }`. This test pins that fix.
7
+ //
8
+ // It now stages the bar against a real two-page <adw-view-stack>, because the
9
+ // request alone is no longer enough: `update_bar_revealed` reveals the bar only
10
+ // when `reveal` is set AND MORE THAN ONE page is visible
11
+ // (refs/libadwaita/src/adw-view-switcher-bar.c:104-126), a rule the port used to
12
+ // skip entirely. The one-page case is asserted here too — it is the state the old
13
+ // bar rendered as an empty strip. The full derivation is driven from the shared
14
+ // conformance vectors in `view-switcher.spec.ts`.
7
15
  import { describe, expect, it } from '@gjsify/unit';
8
16
 
17
+ import type { AdwViewStack } from './elements/adw-view-stack.js';
18
+ import type { AdwViewSwitcherBar } from './elements/adw-view-switcher-bar.js';
19
+
20
+ /** A bar bound to a stack of `count` visible pages, both mounted. */
21
+ function mountBar(count: number): { bar: AdwViewSwitcherBar; host: HTMLElement } {
22
+ const host = document.createElement('div');
23
+ document.body.appendChild(host);
24
+
25
+ const stack = document.createElement('adw-view-stack') as AdwViewStack;
26
+ for (let index = 0; index < count; index++) {
27
+ const page = document.createElement('adw-view-stack-page');
28
+ page.setAttribute('name', `p${index}`);
29
+ page.setAttribute('title', `P${index}`);
30
+ stack.appendChild(page);
31
+ }
32
+ host.appendChild(stack);
33
+
34
+ const bar = document.createElement('adw-view-switcher-bar') as AdwViewSwitcherBar;
35
+ host.appendChild(bar);
36
+ bar.setStack(stack);
37
+ return { bar, host };
38
+ }
39
+
9
40
  export const AdwViewSwitcherBarTest = async () => {
10
- await describe('adw-view-switcher-bar revealed → visibility', async () => {
11
- await it('is display:none by default (not revealed) and block once revealed', async () => {
12
- const bar = document.createElement('adw-view-switcher-bar');
13
- document.body.appendChild(bar);
14
-
15
- // Default: `revealed` absent → the component sets `hidden`, and the
16
- // scss `[hidden]` author rule must win over the base `display: block`.
17
- expect(bar.hasAttribute('revealed')).toBe(false);
41
+ await describe('adw-view-switcher-bar reveal → visibility', async () => {
42
+ await it('is display:none by default and block once revealed', () => {
43
+ const { bar, host } = mountBar(2);
44
+
45
+ // Default: `reveal` off → the component sets `hidden`, and the scss
46
+ // `[hidden]` author rule must win over the base `display: block`.
47
+ expect(bar.reveal).toBe(false);
18
48
  expect(getComputedStyle(bar).display).toBe('none');
19
49
 
20
- // Reveal it → shown.
21
- bar.setAttribute('revealed', '');
50
+ bar.setAttribute('reveal', '');
22
51
  expect(getComputedStyle(bar).display).toBe('block');
23
52
 
24
53
  // Hide it again → back to none (the round-trip, not a one-way latch).
25
- bar.removeAttribute('revealed');
54
+ bar.removeAttribute('reveal');
26
55
  expect(getComputedStyle(bar).display).toBe('none');
27
56
 
28
- bar.remove();
57
+ host.remove();
58
+ });
59
+
60
+ await it('still accepts the legacy `revealed` attribute as the request', () => {
61
+ const { bar, host } = mountBar(2);
62
+ bar.setAttribute('revealed', '');
63
+ expect(bar.reveal).toBe(true);
64
+ expect(getComputedStyle(bar).display).toBe('block');
65
+ host.remove();
66
+ });
67
+
68
+ await it('stays collapsed for a ONE-page stack, however loudly the layout asks', () => {
69
+ // `count > 1` (adw-view-switcher-bar.c:125). The old bar showed an
70
+ // empty strip here because it never consulted the page count.
71
+ const { bar, host } = mountBar(1);
72
+ bar.reveal = true;
73
+ expect(bar.reveal).toBe(true);
74
+ expect(bar.revealed).toBe(false);
75
+ expect(getComputedStyle(bar).display).toBe('none');
76
+ host.remove();
29
77
  });
30
78
  });
31
79
  };
@@ -0,0 +1,194 @@
1
+ // DOM-level conformance tests for <adw-wrap-box>, driven by the SAME tables the
2
+ // NativeScript renderer asserts against (`@gjsify/adwaita-core/conformance`).
3
+ //
4
+ // There were no wrap-box specs at all, which is how sixteen divergences from
5
+ // `Adw.WrapBox` sat untouched (issue #1048). Three of them were wrong answers to
6
+ // the one question that IS portable — where a line's leftover space goes:
7
+ //
8
+ // - `align` was written to `align-items`, the CROSS axis, where libadwaita
9
+ // applies it as a MAIN-axis offset of the whole line block
10
+ // (adw-wrap-layout.c:717-725), so `align="1"` pushed children DOWN;
11
+ // - `JUSTIFY_MAP` mapped `fill` and `spread` to the same `space-between`,
12
+ // where C grows the CHILDREN for one and the GAPS for the other
13
+ // (adw-wrap-layout.c:317-341);
14
+ // - `justify-last-line` was observed and never read, so the final line was
15
+ // always justified — the opposite of the C default (adw-wrap-box.c:379-381).
16
+ //
17
+ // The element publishes its resolved decision as `data-line-justify` (what
18
+ // governs a complete line) and `data-last-line-justify` (what governs the final
19
+ // one), which is both what the stylesheet keys off and what these vectors read:
20
+ // the rendered `justify-content` / `flex-grow` are asserted alongside, so a
21
+ // correct data attribute with a stylesheet that ignores it still fails.
22
+
23
+ import { describe, expect, it } from '@gjsify/unit';
24
+
25
+ import {
26
+ WRAP_BOX_LINE_VECTORS,
27
+ WRAP_BOX_SPACING_NOTIFY_VECTORS,
28
+ WRAP_BOX_SPACING_VECTORS,
29
+ } from '@gjsify/adwaita-core/conformance';
30
+
31
+ /** Mount a wrap box with `n` block children and the given attributes. */
32
+ function mountWrapBox(attrs: Record<string, string>, n = 3): { box: HTMLElement; children: HTMLElement[] } {
33
+ const box = document.createElement('adw-wrap-box');
34
+ for (const [name, value] of Object.entries(attrs)) box.setAttribute(name, value);
35
+ const children: HTMLElement[] = [];
36
+ for (let i = 0; i < n; i++) {
37
+ const child = document.createElement('div');
38
+ child.textContent = `item ${i}`;
39
+ box.appendChild(child);
40
+ children.push(child);
41
+ }
42
+ document.body.appendChild(box);
43
+ return { box, children };
44
+ }
45
+
46
+ /** Remove every wrap box a test mounted. */
47
+ function unmountAll(): void {
48
+ for (const box of Array.from(document.querySelectorAll('adw-wrap-box'))) box.remove();
49
+ }
50
+
51
+ /** A vector value as it reads in a test name (`JSON.stringify` flattens NaN to null). */
52
+ function label(value: number | string | null): string {
53
+ return typeof value === 'string' ? JSON.stringify(value) : String(value);
54
+ }
55
+
56
+ /**
57
+ * `justify-content` for an un-justified line at `align`.
58
+ *
59
+ * C offsets the line block by `roundf (length_delta * align)`, a continuum;
60
+ * flexbox has three positions, so the port takes the nearest one. That
61
+ * approximation lives here rather than in the vectors because it is the browser
62
+ * renderer's, not libadwaita's.
63
+ */
64
+ function expectedJustifyContent(align: number): string {
65
+ if (align < 0.25) return 'flex-start';
66
+ if (align < 0.75) return 'center';
67
+ return 'flex-end';
68
+ }
69
+
70
+ export const AdwWrapBoxTest = async () => {
71
+ await describe('<adw-wrap-box> line layout (Adw.WrapBox justify / align / justify-last-line)', async () => {
72
+ for (const vector of WRAP_BOX_LINE_VECTORS) {
73
+ const title =
74
+ `justify=${vector.justify} last-line=${vector.justifyLastLine} align=${vector.align} ` +
75
+ `${vector.lastLine ? 'final' : 'complete'} line of ${vector.childrenInLine}`;
76
+
77
+ await it(`${title}: ${vector.rule}`, () => {
78
+ const attrs: Record<string, string> = { justify: vector.justify, align: String(vector.align) };
79
+ if (vector.justifyLastLine) attrs['justify-last-line'] = '';
80
+ // A box with ONE child has exactly one line, and that line is the
81
+ // final one — the only line membership CSS can know without a
82
+ // layout pass, and the reason the stylesheet has an `:only-child`
83
+ // rule at all.
84
+ const childCount = vector.childrenInLine === 1 ? 1 : 3;
85
+ const { box, children } = mountWrapBox(attrs, childCount);
86
+
87
+ const resolved = vector.lastLine ? box.dataset.lastLineJustify : box.dataset.lineJustify;
88
+ expect(resolved).toBe(vector.layout.justify);
89
+
90
+ if (!vector.lastLine && vector.childrenInLine > 1) {
91
+ // `justify-content` is the container knob, and a flex
92
+ // container applies it to every line — so it carries the
93
+ // COMPLETE-line rule.
94
+ expect(getComputedStyle(box).justifyContent).toBe(
95
+ vector.layout.growGaps ? 'space-between' : expectedJustifyContent(vector.layout.align),
96
+ );
97
+ }
98
+
99
+ if (vector.lastLine && vector.childrenInLine === 1) {
100
+ expect(getComputedStyle(children[0]).flexGrow).toBe(vector.layout.growChildren ? '1' : '0');
101
+ }
102
+
103
+ unmountAll();
104
+ });
105
+ }
106
+
107
+ await it('align is a MAIN-axis offset, never a cross-axis one', () => {
108
+ const { box } = mountWrapBox({ align: '1' });
109
+ const style = getComputedStyle(box);
110
+ expect(style.justifyContent).toBe('flex-end');
111
+ // The cross axis is libadwaita's `h = line_size` — every child takes
112
+ // the full line extent (adw-wrap-layout.c:746-751).
113
+ expect(style.alignItems).toBe('stretch');
114
+ unmountAll();
115
+ });
116
+
117
+ await it('fill grows the children and keeps the gaps; spread does the opposite', () => {
118
+ const filled = mountWrapBox({ justify: 'fill', 'justify-last-line': '', 'child-spacing': '10' });
119
+ expect(getComputedStyle(filled.children[0]).flexGrow).toBe('1');
120
+ expect(getComputedStyle(filled.box).justifyContent).toBe('flex-start');
121
+ expect(getComputedStyle(filled.box).columnGap).toBe('10px');
122
+ unmountAll();
123
+
124
+ const spread = mountWrapBox({ justify: 'spread', 'justify-last-line': '', 'child-spacing': '10' });
125
+ expect(getComputedStyle(spread.children[0]).flexGrow).toBe('0');
126
+ expect(getComputedStyle(spread.box).justifyContent).toBe('space-between');
127
+ unmountAll();
128
+ });
129
+
130
+ await it('a spread box whose last line is not justified gets the filler that packs it', () => {
131
+ const { box } = mountWrapBox({ justify: 'spread' });
132
+ // The only per-line hook flexbox offers: a generated flex item on the
133
+ // final line that absorbs its leftover, so the line packs at the start
134
+ // the way an un-justified line does.
135
+ expect(getComputedStyle(box, '::after').flexGrow).toBe('1');
136
+ unmountAll();
137
+
138
+ const justified = mountWrapBox({ justify: 'spread', 'justify-last-line': '' });
139
+ expect(getComputedStyle(justified.box, '::after').flexGrow).toBe('0');
140
+ unmountAll();
141
+ });
142
+
143
+ await it('an unknown justify value falls back to the default, as an invalid enum does', () => {
144
+ const { box } = mountWrapBox({ justify: 'stretch-everything' });
145
+ expect(box.dataset.lineJustify).toBe('none');
146
+ unmountAll();
147
+ });
148
+ });
149
+
150
+ await describe('<adw-wrap-box> spacing (adw_wrap_box_set_child_spacing)', async () => {
151
+ for (const vector of WRAP_BOX_SPACING_VECTORS) {
152
+ await it(`child-spacing ${label(vector.value)} → ${vector.spacing}: ${vector.rule}`, () => {
153
+ const attrs: Record<string, string> = {};
154
+ if (vector.value !== null) attrs['child-spacing'] = String(vector.value);
155
+ const { box } = mountWrapBox(attrs);
156
+
157
+ expect((box as HTMLElement & { childSpacing: number }).childSpacing).toBe(vector.spacing);
158
+ expect(getComputedStyle(box).columnGap).toBe(`${vector.spacing}px`);
159
+ unmountAll();
160
+ });
161
+
162
+ await it(`line-spacing ${label(vector.value)} → ${vector.spacing}: ${vector.rule}`, () => {
163
+ const attrs: Record<string, string> = {};
164
+ if (vector.value !== null) attrs['line-spacing'] = String(vector.value);
165
+ const { box } = mountWrapBox(attrs);
166
+
167
+ expect((box as HTMLElement & { lineSpacing: number }).lineSpacing).toBe(vector.spacing);
168
+ expect(getComputedStyle(box).rowGap).toBe(`${vector.spacing}px`);
169
+ unmountAll();
170
+ });
171
+ }
172
+
173
+ await it('a vertical box swaps which gap the two spacings drive', () => {
174
+ const { box } = mountWrapBox({ orientation: 'vertical', 'child-spacing': '8', 'line-spacing': '4' });
175
+ const style = getComputedStyle(box);
176
+ expect(style.rowGap).toBe('8px');
177
+ expect(style.columnGap).toBe('4px');
178
+ unmountAll();
179
+ });
180
+
181
+ for (const vector of WRAP_BOX_SPACING_NOTIFY_VECTORS) {
182
+ await it(`notify::child-spacing ${vector.from} → ${label(vector.value)} = ${vector.notifies}: ${vector.rule}`, () => {
183
+ const { box } = mountWrapBox({ 'child-spacing': String(vector.from) });
184
+ let notified = 0;
185
+ box.addEventListener('notify::child-spacing', () => {
186
+ notified++;
187
+ });
188
+ box.setAttribute('child-spacing', String(vector.value));
189
+ expect(notified).toBe(vector.notifies ? 1 : 0);
190
+ unmountAll();
191
+ });
192
+ }
193
+ });
194
+ };