@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,325 @@
1
+ // DOM-level conformance tests for <adw-bottom-sheet>, driven by the SAME vectors
2
+ // the core suite and the NativeScript renderer assert against
3
+ // (`@gjsify/adwaita-core/conformance`).
4
+ //
5
+ // `Adw.BottomSheet` has four dismissal paths and they are four different gates.
6
+ // This element used to route all of them through one `_attemptClose()`, which
7
+ // meant: the drag handle closed the sheet (in libadwaita it is `can_target =
8
+ // FALSE` and closes nothing), Escape on a closed-but-focused sheet did nothing
9
+ // (libadwaita still signals `close-attempt`), and the `sheet.close` action on an
10
+ // already-closed sheet did nothing (libadwaita forwards it to the parent).
11
+ // Nothing failed, because nothing compared the element to the C source.
12
+ //
13
+ // Two element-local bugs are pinned here too, both from the same root cause —
14
+ // the open STATE used to be read off the attribute rather than held:
15
+ // `open="false"` rendered OPEN, and re-spelling the attribute fired a
16
+ // `notify::open` carrying an unchanged payload.
17
+ import { describe, expect, it } from '@gjsify/unit';
18
+
19
+ import type { AdwBottomSheet } from './elements/adw-bottom-sheet.js';
20
+ import {
21
+ BOTTOM_SHEET_CLOSE_VECTORS,
22
+ BOTTOM_SHEET_PRESENTATION_VECTORS,
23
+ runBottomSheetSteps,
24
+ } from '@gjsify/adwaita-core/conformance';
25
+ import type { BottomSheetCloseOutcome } from '@gjsify/adwaita-core';
26
+
27
+ /** A mounted sheet with one child in each slot, ready to be driven. */
28
+ function mountSheet(markup = ''): AdwBottomSheet {
29
+ const sheet = document.createElement('adw-bottom-sheet') as AdwBottomSheet;
30
+ sheet.innerHTML =
31
+ markup ||
32
+ '<adw-bottom-sheet-content><button id="under">Under</button></adw-bottom-sheet-content>' +
33
+ '<adw-bottom-sheet-sheet><button id="inside">Inside</button></adw-bottom-sheet-sheet>';
34
+ document.body.appendChild(sheet);
35
+ return sheet;
36
+ }
37
+
38
+ function dimming(sheet: AdwBottomSheet): HTMLElement {
39
+ return sheet.querySelector('.adw-bottom-sheet-dimming') as HTMLElement;
40
+ }
41
+
42
+ function dragHandle(sheet: AdwBottomSheet): HTMLElement {
43
+ return sheet.querySelector('.adw-bottom-sheet-drag-handle') as HTMLElement;
44
+ }
45
+
46
+ function insideSheet(sheet: AdwBottomSheet): HTMLElement {
47
+ return sheet.querySelector('.adw-bottom-sheet-sheet-body > button') as HTMLElement;
48
+ }
49
+
50
+ /** Count the dismissal signals a sheet raises while `run` executes. */
51
+ function recordSignals(sheet: AdwBottomSheet, run: () => void): { attempts: number; delegated: number } {
52
+ let attempts = 0;
53
+ let delegated = 0;
54
+ const onAttempt = () => attempts++;
55
+ const onDelegate = () => delegated++;
56
+ sheet.addEventListener('close-attempt', onAttempt);
57
+ sheet.addEventListener('sheet.close', onDelegate);
58
+ run();
59
+ sheet.removeEventListener('close-attempt', onAttempt);
60
+ sheet.removeEventListener('sheet.close', onDelegate);
61
+ return { attempts, delegated };
62
+ }
63
+
64
+ export const AdwBottomSheetTest = async () => {
65
+ await describe('adw-bottom-sheet dismissal gate (libadwaita conformance vectors)', async () => {
66
+ for (const { source, open, canClose, outcome, rule } of BOTTOM_SHEET_CLOSE_VECTORS) {
67
+ await it(`${source} · open=${open} · canClose=${canClose} → ${outcome} — ${rule}`, () => {
68
+ const sheet = mountSheet();
69
+ sheet.canClose = canClose;
70
+ sheet.open = open;
71
+
72
+ let returned: BottomSheetCloseOutcome | null = null;
73
+ const { attempts, delegated } = recordSignals(sheet, () => {
74
+ returned = sheet.requestClose(source);
75
+ });
76
+
77
+ expect(returned).toBe(outcome);
78
+ // The verdict must be VISIBLE, not just returned: a renderer that
79
+ // returns the right word and closes anyway is still wrong.
80
+ expect(sheet.open).toBe(outcome === 'close' ? false : open);
81
+ expect(attempts).toBe(outcome === 'close-attempt' ? 1 : 0);
82
+ expect(delegated).toBe(outcome === 'delegate' ? 1 : 0);
83
+ sheet.remove();
84
+ });
85
+ }
86
+ });
87
+
88
+ await describe('adw-bottom-sheet presentation (libadwaita conformance vectors)', async () => {
89
+ for (const vector of BOTTOM_SHEET_PRESENTATION_VECTORS) {
90
+ await it(vector.rule, () => {
91
+ const sheet = mountSheet();
92
+ const notifications: boolean[] = [];
93
+ sheet.addEventListener('notify::open', (event) => {
94
+ notifications.push((event as CustomEvent).detail.open);
95
+ });
96
+
97
+ const outcomes = runBottomSheetSteps(
98
+ {
99
+ setOpen: (open) => {
100
+ sheet.open = open;
101
+ },
102
+ setCanClose: (canClose) => {
103
+ sheet.canClose = canClose;
104
+ },
105
+ requestClose: (source) => sheet.requestClose(source),
106
+ },
107
+ vector.steps,
108
+ );
109
+
110
+ expect(outcomes).toStrictEqual([...vector.outcomes]);
111
+ expect(notifications).toStrictEqual([...vector.notifications]);
112
+ expect(sheet.open).toBe(vector.open);
113
+ // The `open` attribute is the CSS hook, so it must track the state.
114
+ expect(sheet.hasAttribute('open')).toBe(vector.open);
115
+ expect(sheet.classList.contains('open')).toBe(vector.open);
116
+ sheet.remove();
117
+ });
118
+ }
119
+ });
120
+
121
+ await describe('adw-bottom-sheet open attribute semantics', async () => {
122
+ await it('open="false" renders CLOSED', () => {
123
+ // The getter used to be `hasAttribute('open')` while every other
124
+ // boolean used `!== 'false'`, so one element carried two conventions.
125
+ // adw-bottom-sheet.c:1670 — `open = !!open` on a strict gboolean.
126
+ const sheet = document.createElement('adw-bottom-sheet') as AdwBottomSheet;
127
+ sheet.setAttribute('open', 'false');
128
+ document.body.appendChild(sheet);
129
+ expect(sheet.open).toBe(false);
130
+ expect(sheet.classList.contains('open')).toBe(false);
131
+ sheet.remove();
132
+ });
133
+
134
+ await it('open="" and open="open" both render OPEN', () => {
135
+ const sheet = mountSheet();
136
+ sheet.setAttribute('open', '');
137
+ expect(sheet.open).toBe(true);
138
+ sheet.setAttribute('open', 'open');
139
+ expect(sheet.open).toBe(true);
140
+ sheet.remove();
141
+ });
142
+
143
+ await it('re-spelling the attribute fires no spurious notify::open', () => {
144
+ // The old callback guarded only `oldValue === newValue` and then
145
+ // dispatched unconditionally, so `open=""` → `open="open"` fired a
146
+ // second notification carrying an UNCHANGED payload.
147
+ // adw-bottom-sheet.c:1672-1682 returns before notifying.
148
+ const sheet = mountSheet();
149
+ const payloads: boolean[] = [];
150
+ sheet.addEventListener('notify::open', (event) => {
151
+ payloads.push((event as CustomEvent).detail.open);
152
+ });
153
+ sheet.setAttribute('open', '');
154
+ sheet.setAttribute('open', 'open');
155
+ sheet.setAttribute('open', 'false');
156
+ expect(payloads).toStrictEqual([true, false]);
157
+ sheet.remove();
158
+ });
159
+
160
+ await it('an attribute set before connect survives the upgrade', () => {
161
+ const sheet = document.createElement('adw-bottom-sheet') as AdwBottomSheet;
162
+ sheet.setAttribute('can-close', 'false');
163
+ sheet.setAttribute('open', '');
164
+ document.body.appendChild(sheet);
165
+ expect(sheet.open).toBe(true);
166
+ expect(sheet.canClose).toBe(false);
167
+ sheet.remove();
168
+ });
169
+ });
170
+
171
+ await describe('adw-bottom-sheet affordances', async () => {
172
+ await it('the drag handle is decorative — not focusable, not a button, closes nothing', () => {
173
+ // gtk_widget_set_can_focus/can_target (self->drag_handle, FALSE)
174
+ // — adw-bottom-sheet.c:1197-1198. This element used to give it
175
+ // role="button", tabindex=0 and a click-to-close handler.
176
+ const sheet = mountSheet();
177
+ sheet.open = true;
178
+ const handle = dragHandle(sheet);
179
+ expect(handle.getAttribute('role')).toBe(null);
180
+ expect(handle.hasAttribute('tabindex')).toBe(false);
181
+ expect(handle.getAttribute('aria-hidden')).toBe('true');
182
+
183
+ const { attempts } = recordSignals(sheet, () => {
184
+ handle.dispatchEvent(new MouseEvent('click', { bubbles: true }));
185
+ handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
186
+ handle.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
187
+ });
188
+ expect(sheet.open).toBe(true);
189
+ expect(attempts).toBe(0);
190
+ sheet.remove();
191
+ });
192
+
193
+ await it('a dimming click closes an unlocked sheet and signals a locked one', () => {
194
+ const sheet = mountSheet();
195
+ sheet.open = true;
196
+ dimming(sheet).dispatchEvent(new MouseEvent('click', { bubbles: true }));
197
+ expect(sheet.open).toBe(false);
198
+
199
+ sheet.open = true;
200
+ sheet.canClose = false;
201
+ const { attempts } = recordSignals(sheet, () => {
202
+ dimming(sheet).dispatchEvent(new MouseEvent('click', { bubbles: true }));
203
+ });
204
+ expect(sheet.open).toBe(true);
205
+ expect(attempts).toBe(1);
206
+ sheet.remove();
207
+ });
208
+
209
+ await it('a dimming click on a CLOSED sheet does nothing and signals nothing', () => {
210
+ // The scrim is `can_target = open` (adw-bottom-sheet.c:1692), so it
211
+ // is not reachable at all — unlike Escape, it does not signal.
212
+ const sheet = mountSheet();
213
+ sheet.canClose = false;
214
+ const { attempts } = recordSignals(sheet, () => {
215
+ dimming(sheet).dispatchEvent(new MouseEvent('click', { bubbles: true }));
216
+ });
217
+ expect(sheet.open).toBe(false);
218
+ expect(attempts).toBe(0);
219
+ sheet.remove();
220
+ });
221
+
222
+ await it('Escape closes an open sheet', () => {
223
+ const sheet = mountSheet();
224
+ sheet.open = true;
225
+ insideSheet(sheet).dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
226
+ expect(sheet.open).toBe(false);
227
+ sheet.remove();
228
+ });
229
+
230
+ await it('Escape on a CLOSED but focused sheet still raises close-attempt', () => {
231
+ // maybe_close_cb's emit is the fallthrough for every case that is not
232
+ // (can_close && open) — adw-bottom-sheet.c:393-399. This element
233
+ // guarded it away twice (once in the key handler, once in
234
+ // `_attemptClose`), so the corner was unreachable.
235
+ const sheet = mountSheet();
236
+ const { attempts } = recordSignals(sheet, () => {
237
+ insideSheet(sheet).dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
238
+ });
239
+ expect(sheet.open).toBe(false);
240
+ expect(attempts).toBe(1);
241
+ sheet.remove();
242
+ });
243
+
244
+ await it('Escape from the CONTENT of a closed sheet stays silent', () => {
245
+ // The shortcut lives on the sheet in GTK, so a key pressed outside it
246
+ // while the sheet is closed reaches nothing.
247
+ const sheet = mountSheet();
248
+ const under = sheet.querySelector('#under') as HTMLElement;
249
+ const { attempts } = recordSignals(sheet, () => {
250
+ under.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
251
+ });
252
+ expect(attempts).toBe(0);
253
+ sheet.remove();
254
+ });
255
+
256
+ await it('sheet.close on an already-closed sheet delegates upward', () => {
257
+ // sheet_close_cb activates the PARENT's `sheet.close` action instead
258
+ // of doing nothing — adw-bottom-sheet.c:382-385. A bubbling event is
259
+ // the DOM equivalent, so an enclosing sheet/dialog can act on it.
260
+ const sheet = mountSheet();
261
+ const host = sheet.parentElement as HTMLElement;
262
+ let seenOnAncestor = 0;
263
+ const onAncestor = () => seenOnAncestor++;
264
+ host.addEventListener('sheet.close', onAncestor);
265
+ expect(sheet.requestClose('close-button')).toBe('delegate');
266
+ host.removeEventListener('sheet.close', onAncestor);
267
+ expect(seenOnAncestor).toBe(1);
268
+ sheet.remove();
269
+ });
270
+ });
271
+
272
+ await describe('adw-bottom-sheet slots + flags', async () => {
273
+ await it('keeps the declared content and sheet children apart', () => {
274
+ const sheet = mountSheet();
275
+ expect((sheet.querySelector('.adw-bottom-sheet-content > button') as HTMLElement).id).toBe('under');
276
+ expect(insideSheet(sheet).id).toBe('inside');
277
+ sheet.remove();
278
+ });
279
+
280
+ await it('consumes the slot wrappers instead of leaving them in the content', () => {
281
+ // `<child type="sheet">` is GtkBuilder markup — nothing of it survives
282
+ // into the widget tree (adw-bottom-sheet.c:1278-1293). The emptied
283
+ // <adw-bottom-sheet-sheet> / <adw-bottom-sheet-content> wrappers used
284
+ // to fall through to `unslotted` and land in the content layer.
285
+ const sheet = mountSheet();
286
+ const content = sheet.querySelector('.adw-bottom-sheet-content') as HTMLElement;
287
+ expect(Array.from(content.children).map((el) => el.tagName.toLowerCase())).toStrictEqual(['button']);
288
+ sheet.remove();
289
+ });
290
+
291
+ await it('mixes wrapper and slot="…" children in document order', () => {
292
+ const sheet = mountSheet(
293
+ '<span slot="sheet" id="first">1</span>' +
294
+ '<adw-bottom-sheet-sheet><span id="second">2</span></adw-bottom-sheet-sheet>',
295
+ );
296
+ const body = sheet.querySelector('.adw-bottom-sheet-sheet-body') as HTMLElement;
297
+ expect(Array.from(body.children).map((el) => el.id)).toStrictEqual(['first', 'second']);
298
+ sheet.remove();
299
+ });
300
+
301
+ await it('modal and show-drag-handle default on and honour ="false"', () => {
302
+ const sheet = mountSheet();
303
+ expect(sheet.modal).toBe(true);
304
+ expect(sheet.showDragHandle).toBe(true);
305
+ expect(sheet.classList.contains('modal')).toBe(true);
306
+ expect(dragHandle(sheet).hidden).toBe(false);
307
+
308
+ sheet.showDragHandle = false;
309
+ expect(dragHandle(sheet).hidden).toBe(true);
310
+ sheet.modal = false;
311
+ expect(sheet.classList.contains('modal')).toBe(false);
312
+ sheet.remove();
313
+ });
314
+
315
+ await it('the dimming layer becomes interactive only while open AND modal', () => {
316
+ const sheet = mountSheet();
317
+ expect(dimming(sheet).classList.contains('visible')).toBe(false);
318
+ sheet.open = true;
319
+ expect(dimming(sheet).classList.contains('visible')).toBe(true);
320
+ sheet.modal = false;
321
+ expect(dimming(sheet).classList.contains('visible')).toBe(false);
322
+ sheet.remove();
323
+ });
324
+ });
325
+ };
@@ -0,0 +1,147 @@
1
+ // Breakpoint binding — the browser size source for `@gjsify/adwaita-core`.
2
+ //
3
+ // The behaviour under test is the one adwaita-web did not have: markup that
4
+ // adapts by itself, the way the same markup already adapts on GTK (Adw.Breakpoint)
5
+ // and on NativeScript (addBreakpoints). Sizes are driven by actually resizing a
6
+ // host element and awaiting a real ResizeObserver delivery, not by faking one.
7
+ import { describe, expect, it } from '@gjsify/unit';
8
+
9
+ import { AdwBreakpoint } from '@gjsify/adwaita-core';
10
+
11
+ import { addBreakpoints } from './breakpoints.js';
12
+ import type { AdwNavigationSplitView } from './elements/adw-navigation-split-view.js';
13
+ import type { AdwOverlaySplitView } from './elements/adw-overlay-split-view.js';
14
+
15
+ /** A fixed-width host appended to the document, plus a way to resize it. */
16
+ function mountSized(width: number): { host: HTMLElement; resize: (w: number) => Promise<void> } {
17
+ const host = document.createElement('div');
18
+ host.style.width = `${width}px`;
19
+ document.body.appendChild(host);
20
+ return {
21
+ host,
22
+ resize: async (w: number) => {
23
+ host.style.width = `${w}px`;
24
+ await settle();
25
+ },
26
+ };
27
+ }
28
+
29
+ /** Wait for a ResizeObserver delivery (it runs after layout, before paint). */
30
+ function settle(): Promise<void> {
31
+ return new Promise((resolve) => {
32
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
33
+ });
34
+ }
35
+
36
+ export const AdwBreakpointsTest = async () => {
37
+ await describe('addBreakpoints (ResizeObserver size source)', async () => {
38
+ await it('seeds from the initial observation, without a wrong-layout flash', async () => {
39
+ const { host } = mountSized(400);
40
+ const seen: boolean[] = [];
41
+ const dispose = addBreakpoints(host, [
42
+ new AdwBreakpoint('max-width: 720px', {
43
+ onApply: () => seen.push(true),
44
+ onUnapply: () => seen.push(false),
45
+ }),
46
+ ]);
47
+ await settle();
48
+ // Narrow at construction → applied once, never "unapplied first".
49
+ expect(seen).toStrictEqual([true]);
50
+ dispose();
51
+ host.remove();
52
+ });
53
+
54
+ await it('applies and unapplies on the transitions only', async () => {
55
+ const { host, resize } = mountSized(400);
56
+ const seen: boolean[] = [];
57
+ const dispose = addBreakpoints(host, [
58
+ new AdwBreakpoint('max-width: 720px', {
59
+ onApply: () => seen.push(true),
60
+ onUnapply: () => seen.push(false),
61
+ }),
62
+ ]);
63
+ await settle();
64
+ await resize(900); // crosses out
65
+ await resize(1000); // still wide — no second unapply
66
+ await resize(500); // crosses back in
67
+ expect(seen).toStrictEqual([true, false, true]);
68
+ dispose();
69
+ host.remove();
70
+ });
71
+
72
+ await it('stops evaluating once disposed', async () => {
73
+ const { host, resize } = mountSized(900);
74
+ const seen: boolean[] = [];
75
+ const dispose = addBreakpoints(host, [
76
+ new AdwBreakpoint('max-width: 720px', { onApply: () => seen.push(true) }),
77
+ ]);
78
+ await settle();
79
+ dispose();
80
+ await resize(300);
81
+ expect(seen).toStrictEqual([]);
82
+ host.remove();
83
+ });
84
+ });
85
+
86
+ await describe('split views collapse themselves from a breakpoint attribute', async () => {
87
+ await it('collapses <adw-navigation-split-view> below the condition', async () => {
88
+ const { host, resize } = mountSized(1000);
89
+ host.innerHTML =
90
+ '<adw-navigation-split-view breakpoint="max-width: 720px">' +
91
+ '<div slot="sidebar">s</div><div slot="content">c</div>' +
92
+ '</adw-navigation-split-view>';
93
+ const view = host.querySelector('adw-navigation-split-view') as AdwNavigationSplitView;
94
+ await settle();
95
+ expect(view.collapsed).toBe(false);
96
+
97
+ await resize(500);
98
+ expect(view.collapsed).toBe(true);
99
+ expect(view.classList.contains('collapsed')).toBe(true);
100
+
101
+ await resize(1000);
102
+ expect(view.collapsed).toBe(false);
103
+ host.remove();
104
+ });
105
+
106
+ await it('collapses <adw-overlay-split-view> below the condition', async () => {
107
+ const { host, resize } = mountSized(1000);
108
+ host.innerHTML =
109
+ '<adw-overlay-split-view breakpoint="max-width: 720px">' +
110
+ '<div slot="sidebar">s</div><div slot="content">c</div>' +
111
+ '</adw-overlay-split-view>';
112
+ const view = host.querySelector('adw-overlay-split-view') as AdwOverlaySplitView;
113
+ await settle();
114
+ expect(view.collapsed).toBe(false);
115
+
116
+ await resize(500);
117
+ expect(view.collapsed).toBe(true);
118
+ host.remove();
119
+ });
120
+
121
+ await it('leaves collapsed alone when no breakpoint is set', async () => {
122
+ const { host, resize } = mountSized(1000);
123
+ host.innerHTML =
124
+ '<adw-navigation-split-view collapsed>' +
125
+ '<div slot="sidebar">s</div><div slot="content">c</div>' +
126
+ '</adw-navigation-split-view>';
127
+ const view = host.querySelector('adw-navigation-split-view') as AdwNavigationSplitView;
128
+ await resize(1400);
129
+ // Without a condition the application owns the attribute — a wide
130
+ // viewport must not silently expand a deliberately collapsed view.
131
+ expect(view.collapsed).toBe(true);
132
+ host.remove();
133
+ });
134
+
135
+ await it('ignores an unparsable condition rather than flipping state', async () => {
136
+ const { host, resize } = mountSized(1000);
137
+ host.innerHTML =
138
+ '<adw-navigation-split-view breakpoint="not a condition">' +
139
+ '<div slot="sidebar">s</div><div slot="content">c</div>' +
140
+ '</adw-navigation-split-view>';
141
+ const view = host.querySelector('adw-navigation-split-view') as AdwNavigationSplitView;
142
+ await resize(300);
143
+ expect(view.collapsed).toBe(false);
144
+ host.remove();
145
+ });
146
+ });
147
+ };
@@ -0,0 +1,67 @@
1
+ // Breakpoint binding for the browser renderer.
2
+ //
3
+ // The condition grammar, the evaluator and the transition-only apply/unapply
4
+ // state machine are HEADLESS and live in `@gjsify/adwaita-core` (ADR 0004).
5
+ // What each renderer must supply is the SIZE SOURCE: GTK has
6
+ // `Adw.Window::notify::default-width`, NativeScript has a view's `layoutChanged`
7
+ // (`addBreakpoints` in `@gjsify/adwaita-nativescript`), and the browser has
8
+ // `ResizeObserver` — which this module supplies under the same name, so the two
9
+ // ports read alike.
10
+ //
11
+ // Until this existed, `adwaita-web` was the only one of the three renderers with
12
+ // no size source at all: `<adw-navigation-split-view collapsed>` was a manual
13
+ // attribute the application had to flip itself, so the same markup that adapts
14
+ // on GTK and on NativeScript stayed frozen in one layout in the browser.
15
+ //
16
+ // Reference: refs/libadwaita/src/adw-breakpoint.c
17
+ // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
18
+
19
+ import { AdwBreakpoint } from '@gjsify/adwaita-core';
20
+
21
+ /**
22
+ * Drive `breakpoints` from `element`'s own box, and return a dispose function.
23
+ *
24
+ * `ResizeObserver` delivers an initial observation on `observe()`, so the
25
+ * breakpoints settle on the correct state before first paint — no flash of the
26
+ * wrong layout, and no separate seeding pass.
27
+ *
28
+ * The size read is the BORDER box in CSS pixels, the browser's counterpart to
29
+ * the `sp`/DIP units an Adwaita condition is written in.
30
+ */
31
+ export function addBreakpoints(element: Element, breakpoints: readonly AdwBreakpoint[]): () => void {
32
+ const observer = new ResizeObserver((entries) => {
33
+ const entry = entries[entries.length - 1];
34
+ if (!entry) return;
35
+ // borderBoxSize is the spec'd path; contentRect is the fallback for the
36
+ // shape older engines report.
37
+ const box = entry.borderBoxSize?.[0];
38
+ const size = box
39
+ ? { width: box.inlineSize, height: box.blockSize }
40
+ : { width: entry.contentRect.width, height: entry.contentRect.height };
41
+ for (const breakpoint of breakpoints) breakpoint.evaluate(size);
42
+ });
43
+ observer.observe(element);
44
+ return () => observer.disconnect();
45
+ }
46
+
47
+ /**
48
+ * Wire a `breakpoint="<condition>"` attribute to a boolean setter, the browser
49
+ * equivalent of an `Adw.Breakpoint` with one `add_setter()` call.
50
+ *
51
+ * Returns a dispose function; call it before re-binding and on disconnect.
52
+ * A missing or unparsable condition binds nothing and returns a no-op, leaving
53
+ * whatever the attribute already says untouched — an element must not silently
54
+ * flip state because its condition had a typo.
55
+ */
56
+ export function bindBreakpointSetter(
57
+ element: Element,
58
+ condition: string | null,
59
+ setValue: (active: boolean) => void,
60
+ ): () => void {
61
+ if (!condition) return () => {};
62
+ const breakpoint = new AdwBreakpoint(condition, {
63
+ onApply: () => setValue(true),
64
+ onUnapply: () => setValue(false),
65
+ });
66
+ return addBreakpoints(element, [breakpoint]);
67
+ }