@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,435 @@
1
+ // DOM-level conformance tests for <adw-navigation-view>, driven by the SAME
2
+ // vectors the NativeScript renderer and the headless core assert against
3
+ // (`@gjsify/adwaita-core/conformance`).
4
+ //
5
+ // The two renderers used to carry independent copies of the whole stack machine,
6
+ // and both had drifted away from libadwaita in different directions. Four rows in
7
+ // the shared table fail against the pre-lift code here: `pop()` refused a
8
+ // `can-pop="false"` page (the C documents the opposite), `replace(['some-tag'])`
9
+ // blanked the view when the tag belonged to a dynamically-pushed page,
10
+ // `popToTag()` stopped early at a `can-pop="false"` page and emitted N separate
11
+ // transitions instead of one, and a duplicate `tag` was accepted silently.
12
+ // Nothing failed, because nothing compared them. This suite is that comparison.
13
+ import { describe, expect, it } from '@gjsify/unit';
14
+
15
+ import {
16
+ NAVIGATION_VIEW_VECTORS,
17
+ collectNavigationState,
18
+ navigationEventLog,
19
+ runNavigationSteps,
20
+ } from '@gjsify/adwaita-core/conformance';
21
+ import type {
22
+ NavigationEventRecord,
23
+ NavigationPageId,
24
+ NavigationVectorAdapter,
25
+ } from '@gjsify/adwaita-core/conformance';
26
+ import type { AdwNavigationPageProps } from '@gjsify/adwaita-core';
27
+
28
+ import type { AdwNavigationPage, AdwNavigationView } from './elements/adw-navigation-view.js';
29
+
30
+ /** Write vector props onto the element — the attributes ARE the authoring surface. */
31
+ function applyProps(page: AdwNavigationPage, props?: AdwNavigationPageProps): void {
32
+ if (props === undefined) return;
33
+ if ('tag' in props) page.tag = props.tag ?? null;
34
+ if (props.title !== undefined) page.title = props.title;
35
+ if (props.canPop !== undefined) page.canPop = props.canPop;
36
+ }
37
+
38
+ /** Mount an empty view and adapt it to the vector driver. */
39
+ function mountView() {
40
+ const host = document.createElement('div');
41
+ document.body.appendChild(host);
42
+ const view = document.createElement('adw-navigation-view') as AdwNavigationView;
43
+ host.appendChild(view);
44
+
45
+ const handles = new Map<NavigationPageId, AdwNavigationPage>();
46
+ const ids = new Map<AdwNavigationPage, NavigationPageId>();
47
+ const page = (id: NavigationPageId): AdwNavigationPage => {
48
+ let element = handles.get(id);
49
+ if (element === undefined) {
50
+ element = document.createElement('adw-navigation-page') as AdwNavigationPage;
51
+ handles.set(id, element);
52
+ ids.set(element, id);
53
+ }
54
+ return element;
55
+ };
56
+ const idOf = (element: AdwNavigationPage | null | undefined): NavigationPageId | null =>
57
+ element === null || element === undefined ? null : (ids.get(element) ?? null);
58
+
59
+ const events: NavigationEventRecord[] = [];
60
+ for (const type of ['pushed', 'popped', 'replaced', 'notify::visible-page'] as const) {
61
+ view.addEventListener(type, (event) => {
62
+ const detail = (event as CustomEvent<{ page: AdwNavigationPage | null }>).detail;
63
+ events.push({ type, page: idOf(detail?.page ?? null) });
64
+ });
65
+ }
66
+
67
+ const adapter: NavigationVectorAdapter<AdwNavigationPage> = {
68
+ page,
69
+ idOf,
70
+ // The slide is a CSS animation on the incoming page, so the element settles
71
+ // the deferred destroy inside its own change handler.
72
+ defersTransition: false,
73
+ add: (p, props) => {
74
+ applyProps(p, props);
75
+ return view.addPage(p);
76
+ },
77
+ remove: (p) => view.removePage(p),
78
+ push: (p, props) => {
79
+ applyProps(p, props);
80
+ return view.push(p);
81
+ },
82
+ pushByTag: (tag) => view.pushByTag(tag),
83
+ pop: () => view.pop(),
84
+ popToPage: (p) => view.popToPage(p),
85
+ popToTag: (tag) => view.popToTag(tag),
86
+ replace: (pages, props) => {
87
+ if (props !== undefined) {
88
+ for (const p of pages) {
89
+ if (p !== null) applyProps(p, props(p));
90
+ }
91
+ }
92
+ view.replace([...pages]);
93
+ },
94
+ replaceWithTags: (tags) => view.replaceWithTags(tags),
95
+ // The property setters go through `setAttribute`, so these rows also
96
+ // exercise the attribute → core sync that keeps the back button live.
97
+ // A rejected rename is rolled back, which is what makes the boolean right.
98
+ setTag: (p, tag) => {
99
+ const before = p.tag;
100
+ p.tag = tag;
101
+ return p.tag !== before;
102
+ },
103
+ setTitle: (p, title) => {
104
+ const before = p.title;
105
+ p.title = title;
106
+ return p.title !== before;
107
+ },
108
+ setCanPop: (p, canPop) => {
109
+ const before = p.canPop;
110
+ p.canPop = canPop;
111
+ return p.canPop !== before;
112
+ },
113
+ setAnimateTransitions: (value) => {
114
+ view.animateTransitions = value;
115
+ },
116
+ setPopOnEscape: (value) => {
117
+ view.popOnEscape = value;
118
+ },
119
+ popFromShortcut: () => (dispatchKey(view, 'ArrowLeft', true) ? 'stop' : 'propagate'),
120
+ popFromEscape: () => (dispatchKey(view, 'Escape', false) ? 'stop' : 'propagate'),
121
+ finishTransition: () => [],
122
+ stack: () => view.navigationStack,
123
+ pages: () => view.pages,
124
+ visiblePage: () => view.visiblePage,
125
+ visiblePageTag: () => view.visiblePageTag,
126
+ depth: () => view.depth,
127
+ animateTransitions: () => view.animateTransitions,
128
+ popOnEscape: () => view.popOnEscape,
129
+ canGoBack: () => view.canGoBack,
130
+ backButtonTooltip: () => view.backButtonTooltip,
131
+ findPage: (tag) => view.findPage(tag),
132
+ getPreviousPage: (p) => view.getPreviousPage(p),
133
+ // `removeOnPop` is core-only; its DOM-visible consequence is the page
134
+ // leaving `pages` (and the document), which every row still asserts.
135
+ pageState: (p) => ({
136
+ registered: view.pages.includes(p),
137
+ tag: p.tag,
138
+ title: p.title,
139
+ canPop: p.canPop,
140
+ }),
141
+ };
142
+
143
+ return { adapter, view, host, events, page, idOf };
144
+ }
145
+
146
+ /**
147
+ * Send a real key event and report whether the view consumed it. The keyboard is
148
+ * how `pop_shortcut_cb` / `escape_shortcut_cb` are reachable from the DOM, so the
149
+ * shortcut vectors drive the actual listener rather than a method behind it.
150
+ */
151
+ function dispatchKey(view: AdwNavigationView, key: string, altKey: boolean): boolean {
152
+ const event = new KeyboardEvent('keydown', { key, altKey, bubbles: true, cancelable: true });
153
+ view.dispatchEvent(event);
154
+ return event.defaultPrevented;
155
+ }
156
+
157
+ /** Which page element is currently shown. */
158
+ function renderedPage(view: AdwNavigationView): AdwNavigationPage | null {
159
+ const shown = Array.from(view.querySelectorAll('adw-navigation-page')).filter(
160
+ (page) => !(page as HTMLElement).hidden,
161
+ );
162
+ expect(shown.length <= 1).toBe(true);
163
+ return (shown[0] as AdwNavigationPage | undefined) ?? null;
164
+ }
165
+
166
+ /** Build a page carrying a header bar, so the automatic back button has somewhere to go. */
167
+ function pageWithHeaderBar(title: string, tag?: string): AdwNavigationPage {
168
+ const page = document.createElement('adw-navigation-page') as AdwNavigationPage;
169
+ page.setAttribute('title', title);
170
+ if (tag !== undefined) page.setAttribute('tag', tag);
171
+ page.appendChild(document.createElement('adw-header-bar'));
172
+ return page;
173
+ }
174
+
175
+ /** The injected back button on the visible page, if any. */
176
+ function backButton(view: AdwNavigationView): HTMLElement | null {
177
+ return view.querySelector('.adw-navigation-back-button') as HTMLElement | null;
178
+ }
179
+
180
+ export const AdwNavigationViewTest = async () => {
181
+ await describe('adw-navigation-view stack (libadwaita conformance vectors)', async () => {
182
+ for (const vector of NAVIGATION_VIEW_VECTORS) {
183
+ await it(`${vector.name} — ${vector.rule}`, () => {
184
+ const { adapter, host, events } = mountView();
185
+
186
+ for (const outcome of runNavigationSteps(vector.steps, adapter)) {
187
+ if (outcome.expected === undefined) continue;
188
+ expect({ at: outcome.index, result: outcome.result }).toStrictEqual({
189
+ at: outcome.index,
190
+ result: outcome.expected,
191
+ });
192
+ }
193
+
194
+ for (const check of collectNavigationState(vector.expect, adapter)) {
195
+ expect({ [check.label]: check.actual }).toStrictEqual({ [check.label]: check.expected });
196
+ }
197
+
198
+ if (vector.expect.changes !== undefined) {
199
+ expect(events).toStrictEqual(navigationEventLog(vector.expect.changes));
200
+ }
201
+
202
+ host.remove();
203
+ });
204
+ }
205
+ });
206
+
207
+ await describe('adw-navigation-view declared markup', async () => {
208
+ await it('auto-pushes the first declared page and announces it', () => {
209
+ const host = document.createElement('div');
210
+ const view = document.createElement('adw-navigation-view') as AdwNavigationView;
211
+ view.append(pageWithHeaderBar('Home', 'root'), pageWithHeaderBar('Detail', 'detail'));
212
+
213
+ // Subscribe BEFORE connecting: the auto-push happens at connect time and
214
+ // used to emit nothing at all, so a listener never saw the view fill.
215
+ const seen: string[] = [];
216
+ view.addEventListener('pushed', () => seen.push('pushed'));
217
+ view.addEventListener('notify::visible-page', (event) => {
218
+ seen.push(`visible:${(event as CustomEvent<{ tag: string | null }>).detail.tag}`);
219
+ });
220
+
221
+ host.appendChild(view);
222
+ document.body.appendChild(host);
223
+
224
+ expect(seen).toStrictEqual(['pushed', 'visible:root']);
225
+ expect(view.visiblePageTag).toBe('root');
226
+ expect(view.depth).toBe(1);
227
+ expect(view.pages).toHaveLength(2);
228
+ expect(renderedPage(view)).toBe(view.findPage('root'));
229
+ host.remove();
230
+ });
231
+
232
+ await it('shows only the top of the stack', () => {
233
+ const host = document.createElement('div');
234
+ const view = document.createElement('adw-navigation-view') as AdwNavigationView;
235
+ view.append(pageWithHeaderBar('Home', 'root'), pageWithHeaderBar('Detail', 'detail'));
236
+ host.appendChild(view);
237
+ document.body.appendChild(host);
238
+
239
+ view.pushByTag('detail');
240
+ expect(renderedPage(view)).toBe(view.findPage('detail'));
241
+ view.pop();
242
+ expect(renderedPage(view)).toBe(view.findPage('root'));
243
+ host.remove();
244
+ });
245
+
246
+ await it('drops a declared page whose tag collides', () => {
247
+ const host = document.createElement('div');
248
+ const view = document.createElement('adw-navigation-view') as AdwNavigationView;
249
+ const duplicate = pageWithHeaderBar('Other', 'root');
250
+ view.append(pageWithHeaderBar('Home', 'root'), duplicate);
251
+ host.appendChild(view);
252
+ document.body.appendChild(host);
253
+
254
+ expect(view.pages).toHaveLength(1);
255
+ expect(duplicate.isConnected).toBe(false);
256
+ expect(renderedPage(view)).toBe(view.findPage('root'));
257
+ host.remove();
258
+ });
259
+ });
260
+
261
+ await describe('adw-navigation-view back button', async () => {
262
+ await it('appears above the root and carries the PREVIOUS page title', () => {
263
+ const host = document.createElement('div');
264
+ const view = document.createElement('adw-navigation-view') as AdwNavigationView;
265
+ view.append(pageWithHeaderBar('Home', 'root'), pageWithHeaderBar('Detail', 'detail'));
266
+ host.appendChild(view);
267
+ document.body.appendChild(host);
268
+
269
+ expect(backButton(view)).toBe(null);
270
+ view.pushByTag('detail');
271
+ // The old element hardcoded "Back" here, where the C uses the title of
272
+ // the page the button reveals (adw-back-button.c query_tooltip:411-417).
273
+ expect(backButton(view)?.getAttribute('tooltip')).toBe('Home');
274
+
275
+ view.pop();
276
+ expect(backButton(view)).toBe(null);
277
+ host.remove();
278
+ });
279
+
280
+ await it('falls back to "Back" when the previous page has no title', () => {
281
+ const host = document.createElement('div');
282
+ const view = document.createElement('adw-navigation-view') as AdwNavigationView;
283
+ const root = pageWithHeaderBar('', 'root');
284
+ view.append(root, pageWithHeaderBar('Detail', 'detail'));
285
+ host.appendChild(view);
286
+ document.body.appendChild(host);
287
+
288
+ view.pushByTag('detail');
289
+ expect(backButton(view)?.getAttribute('tooltip')).toBe('Back');
290
+ host.remove();
291
+ });
292
+
293
+ await it('re-syncs when can-pop changes at runtime', () => {
294
+ // The regression: `can-pop` was in observedAttributes but nothing acted
295
+ // on it, so the button only reflected the last stack mutation's value.
296
+ const host = document.createElement('div');
297
+ const view = document.createElement('adw-navigation-view') as AdwNavigationView;
298
+ const detail = pageWithHeaderBar('Detail', 'detail');
299
+ view.append(pageWithHeaderBar('Home', 'root'), detail);
300
+ host.appendChild(view);
301
+ document.body.appendChild(host);
302
+
303
+ view.pushByTag('detail');
304
+ expect(backButton(view)).not.toBe(null);
305
+
306
+ detail.canPop = false;
307
+ expect(backButton(view)).toBe(null);
308
+ expect(view.canGoBack).toBe(false);
309
+
310
+ detail.canPop = true;
311
+ expect(backButton(view)).not.toBe(null);
312
+ host.remove();
313
+ });
314
+
315
+ await it('follows a runtime title change on the previous page', () => {
316
+ const host = document.createElement('div');
317
+ const view = document.createElement('adw-navigation-view') as AdwNavigationView;
318
+ const root = pageWithHeaderBar('Home', 'root');
319
+ view.append(root, pageWithHeaderBar('Detail', 'detail'));
320
+ host.appendChild(view);
321
+ document.body.appendChild(host);
322
+
323
+ view.pushByTag('detail');
324
+ root.title = 'Start';
325
+ expect(backButton(view)?.getAttribute('tooltip')).toBe('Start');
326
+ host.remove();
327
+ });
328
+
329
+ await it('is suppressed by no-back-button without disabling the pop', () => {
330
+ const host = document.createElement('div');
331
+ const view = document.createElement('adw-navigation-view') as AdwNavigationView;
332
+ const detail = pageWithHeaderBar('Detail', 'detail');
333
+ detail.setAttribute('no-back-button', '');
334
+ view.append(pageWithHeaderBar('Home', 'root'), detail);
335
+ host.appendChild(view);
336
+ document.body.appendChild(host);
337
+
338
+ view.pushByTag('detail');
339
+ expect(backButton(view)).toBe(null);
340
+ // AdwHeaderBar:show-back-button removes the BUTTON, not the shortcut.
341
+ expect(view.canGoBack).toBe(true);
342
+ expect(view.pop()).toBe(true);
343
+ host.remove();
344
+ });
345
+ });
346
+
347
+ await describe('adw-navigation-view DOM lifecycle', async () => {
348
+ await it('takes a dynamically-pushed page out of the document when it is popped', () => {
349
+ const { adapter, view, host, page } = mountView();
350
+ adapter.add(page('root'));
351
+ adapter.push(page('sheet'));
352
+ expect(page('sheet').isConnected).toBe(true);
353
+
354
+ expect(view.pop()).toBe(true);
355
+ expect(page('sheet').isConnected).toBe(false);
356
+ expect(view.pages).toHaveLength(1);
357
+ host.remove();
358
+ });
359
+
360
+ await it('keeps a statically-added page in the document across push and pop', () => {
361
+ const { adapter, view, host, page } = mountView();
362
+ adapter.add(page('root'));
363
+ adapter.add(page('detail'));
364
+ adapter.push(page('detail'));
365
+ expect(view.pop()).toBe(true);
366
+ expect(page('detail').isConnected).toBe(true);
367
+ expect((page('detail') as HTMLElement).hidden).toBe(true);
368
+ host.remove();
369
+ });
370
+
371
+ await it('mirrors animate-transitions onto the animated class and the core', () => {
372
+ const { adapter, view, host, page } = mountView();
373
+ adapter.add(page('root'));
374
+ expect(view.classList.contains('animated')).toBe(true);
375
+
376
+ view.animateTransitions = false;
377
+ expect(view.classList.contains('animated')).toBe(false);
378
+ expect(view.animateTransitions).toBe(false);
379
+ host.remove();
380
+ });
381
+ });
382
+
383
+ await describe('adw-navigation-view keyboard shortcuts', async () => {
384
+ await it('pops on Escape and stops the event', () => {
385
+ const { adapter, view, host, page } = mountView();
386
+ adapter.add(page('root'));
387
+ adapter.add(page('detail'));
388
+ adapter.push(page('detail'));
389
+
390
+ expect(dispatchKey(view, 'Escape', false)).toBe(true);
391
+ expect(view.depth).toBe(1);
392
+ host.remove();
393
+ });
394
+
395
+ await it('leaves Escape alone when pop-on-escape is off', () => {
396
+ const { adapter, view, host, page } = mountView();
397
+ adapter.add(page('root'));
398
+ adapter.add(page('detail'));
399
+ adapter.push(page('detail'));
400
+ view.popOnEscape = false;
401
+
402
+ expect(dispatchKey(view, 'Escape', false)).toBe(false);
403
+ expect(view.depth).toBe(2);
404
+ host.remove();
405
+ });
406
+
407
+ await it('stops Alt+Left WITHOUT popping a can-pop="false" page', () => {
408
+ // GDK_EVENT_STOP so the key never reaches an enclosing navigation view
409
+ // (pop_shortcut_cb:1150-1152).
410
+ const { adapter, view, host, page } = mountView();
411
+ adapter.add(page('root'));
412
+ adapter.add(page('detail'));
413
+ adapter.push(page('detail'));
414
+ page('detail').canPop = false;
415
+
416
+ expect(dispatchKey(view, 'ArrowLeft', true)).toBe(true);
417
+ expect(view.depth).toBe(2);
418
+ // …while a manual pop() still works, which is the whole point of can-pop.
419
+ expect(view.pop()).toBe(true);
420
+ expect(view.depth).toBe(1);
421
+ host.remove();
422
+ });
423
+
424
+ await it('ignores an unrelated key', () => {
425
+ const { adapter, view, host, page } = mountView();
426
+ adapter.add(page('root'));
427
+ adapter.add(page('detail'));
428
+ adapter.push(page('detail'));
429
+
430
+ expect(dispatchKey(view, 'ArrowLeft', false)).toBe(false);
431
+ expect(view.depth).toBe(2);
432
+ host.remove();
433
+ });
434
+ });
435
+ };
@@ -0,0 +1,135 @@
1
+ // Preferences search for the browser renderer — the DOM half only.
2
+ //
3
+ // The pipeline itself (case fold, markup parse, the three-filter corpus, the
4
+ // `page → group` subtitle) is headless and lives in `@gjsify/adwaita-core`;
5
+ // what belongs here is the one genuinely renderer-specific step: reading a
6
+ // `<adw-preferences-page>` subtree and describing it as the renderer-free tree
7
+ // the core reasons over.
8
+ //
9
+ // Reference: refs/libadwaita/src/adw-preferences-dialog.c
10
+ // (adw_preferences_dialog_init's page/group/row model, :617-641)
11
+ // Reference: refs/libadwaita/src/adw-action-row.c, adw-entry-row.c,
12
+ // adw-expander-row.c, adw-button-row.c (which rows ARE action rows)
13
+ // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
14
+
15
+ import {
16
+ searchPreferences,
17
+ type PreferencesSearchGroup,
18
+ type PreferencesSearchPage,
19
+ type PreferencesSearchResult,
20
+ type PreferencesSearchRow,
21
+ type SearchPreferencesOptions,
22
+ } from '@gjsify/adwaita-core';
23
+
24
+ /**
25
+ * The rows that derive from `AdwActionRow` and therefore have their SUBTITLE
26
+ * searched (`filter_search_results`, adw-preferences-dialog.c:140-146).
27
+ *
28
+ * A tag-name set rather than an `instanceof` chain, because the browser
29
+ * elements are all direct `HTMLElement` subclasses — the GObject hierarchy is
30
+ * not reproduced in the DOM, so the distinction has to be stated. It is not
31
+ * "has a subtitle": `adw-entry-row`, `adw-expander-row` and `adw-button-row`
32
+ * all derive from `AdwPreferencesRow` in C (adw-entry-row.c:90,
33
+ * adw-expander-row.c:72, adw-button-row.c:74), so the text typed into an entry
34
+ * row is deliberately NOT searchable.
35
+ */
36
+ const ACTION_ROW_TAGS: ReadonlySet<string> = new Set([
37
+ 'adw-action-row',
38
+ 'adw-switch-row',
39
+ 'adw-combo-row',
40
+ 'adw-spin-row',
41
+ ]);
42
+
43
+ /**
44
+ * `ADW_IS_PREFERENCES_ROW` for the browser elements — the first clause of
45
+ * `row_has_title` (adw-preferences-group.c:158-173).
46
+ *
47
+ * Spelled out rather than inferred from the `adw-` prefix, because a group's
48
+ * boxed list legitimately holds other things: a bare `<adw-button>` dropped in
49
+ * as a footer is not a preferences row and must not be searchable.
50
+ */
51
+ const PREFERENCES_ROW_TAGS: ReadonlySet<string> = new Set([
52
+ ...ACTION_ROW_TAGS,
53
+ 'adw-entry-row',
54
+ 'adw-password-entry-row',
55
+ 'adw-expander-row',
56
+ 'adw-button-row',
57
+ ]);
58
+
59
+ /** `gtk_widget_get_visible` for a DOM element. */
60
+ function isVisible(element: Element): boolean {
61
+ return !(element instanceof HTMLElement && element.hidden);
62
+ }
63
+
64
+ /** A group element's rows, whether or not it has upgraded into its listbox yet. */
65
+ function groupRows(group: Element): Element[] {
66
+ const listbox = group.querySelector(':scope > .adw-preferences-group-listbox');
67
+ return Array.from((listbox ?? group).children).filter((child) =>
68
+ PREFERENCES_ROW_TAGS.has(child.tagName.toLowerCase()),
69
+ );
70
+ }
71
+
72
+ /** Describe one `<adw-*-row>` element for the search corpus. */
73
+ function describeRow(row: Element): PreferencesSearchRow<Element> {
74
+ const subtitle = row.getAttribute('subtitle');
75
+ return {
76
+ title: row.getAttribute('title') ?? '',
77
+ subtitle,
78
+ visible: isVisible(row),
79
+ isActionRow: ACTION_ROW_TAGS.has(row.tagName.toLowerCase()),
80
+ // The browser rows paint their title as TEXT, not as Pango markup, so
81
+ // the string compared is the string shown. Rendering markup is a
82
+ // separate, still-open gap; closing it flips this one flag.
83
+ useMarkup: false,
84
+ useUnderline: false,
85
+ ref: row,
86
+ };
87
+ }
88
+
89
+ /** Describe one `<adw-preferences-group>` element. */
90
+ function describeGroup(group: Element): PreferencesSearchGroup<Element> {
91
+ return {
92
+ title: group.getAttribute('title') ?? '',
93
+ visible: isVisible(group),
94
+ rows: groupRows(group).map(describeRow),
95
+ ref: group,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Describe every `<adw-preferences-page>` under `root` as the tree
101
+ * `searchPreferences` indexes.
102
+ *
103
+ * Page identity is read off the element itself — which is why the dialog keeps
104
+ * the page elements in its subtree instead of hoisting their children out. A
105
+ * flattened dialog has no page titles, and `create_search_row_subtitle` needs
106
+ * them the moment a second page exists.
107
+ */
108
+ export function describePreferencesPages(root: ParentNode): PreferencesSearchPage<Element>[] {
109
+ return Array.from(root.querySelectorAll('adw-preferences-page')).map((page) => ({
110
+ name: page.getAttribute('name'),
111
+ title: page.getAttribute('title') ?? '',
112
+ iconName: page.getAttribute('icon-name'),
113
+ useUnderline: page.hasAttribute('use-underline'),
114
+ visible: isVisible(page),
115
+ description: page.getAttribute('description') ?? '',
116
+ groups: Array.from(page.querySelectorAll('adw-preferences-group')).map(describeGroup),
117
+ ref: page,
118
+ }));
119
+ }
120
+
121
+ /**
122
+ * Search the preferences pages under `root`, returning the result rows a
123
+ * `search_results` list box would be bound to.
124
+ *
125
+ * Each result carries the `<adw-preferences-page>` and the row element, so a
126
+ * caller can reproduce `search_result_activated_cb`: reveal the page, focus
127
+ * the row.
128
+ */
129
+ export function searchPreferencesDom(
130
+ root: ParentNode,
131
+ query: string,
132
+ options?: SearchPreferencesOptions,
133
+ ): PreferencesSearchResult<Element>[] {
134
+ return searchPreferences(describePreferencesPages(root), query, options);
135
+ }