@gjsify/adwaita-web 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/dist/adwaita-web.css +835 -299
  2. package/dist/adwaita-web.css.map +1 -1
  3. package/lib/types/adw-about-dialog.spec.d.ts +1 -0
  4. package/lib/types/adw-action-rows.spec.d.ts +1 -0
  5. package/lib/types/adw-avatar.spec.d.ts +1 -0
  6. package/lib/types/adw-banner.spec.d.ts +1 -0
  7. package/lib/types/adw-button-content.spec.d.ts +1 -0
  8. package/lib/types/adw-carousel.spec.d.ts +1 -0
  9. package/lib/types/adw-checks.spec.d.ts +1 -0
  10. package/lib/types/adw-header-bar.spec.d.ts +1 -0
  11. package/lib/types/adw-icon.spec.d.ts +1 -0
  12. package/lib/types/adw-progress-bar.spec.d.ts +1 -0
  13. package/lib/types/adw-sidebar.spec.d.ts +1 -0
  14. package/lib/types/adw-switch.spec.d.ts +1 -0
  15. package/lib/types/adw-wrap-box.spec.d.ts +1 -0
  16. package/lib/types/bottom-sheet.spec.d.ts +1 -0
  17. package/lib/types/breakpoints.d.ts +22 -0
  18. package/lib/types/breakpoints.spec.d.ts +1 -0
  19. package/lib/types/chrome.spec.d.ts +1 -0
  20. package/lib/types/elements/adw-about-dialog.d.ts +47 -8
  21. package/lib/types/elements/adw-action-row.d.ts +35 -0
  22. package/lib/types/elements/adw-avatar.d.ts +11 -0
  23. package/lib/types/elements/adw-bottom-sheet.d.ts +33 -4
  24. package/lib/types/elements/adw-button-content.d.ts +12 -0
  25. package/lib/types/elements/adw-button-row.d.ts +10 -1
  26. package/lib/types/elements/adw-carousel.d.ts +111 -8
  27. package/lib/types/elements/adw-checks.d.ts +57 -0
  28. package/lib/types/elements/adw-clamp.d.ts +4 -1
  29. package/lib/types/elements/adw-combo-row.d.ts +10 -0
  30. package/lib/types/elements/adw-data-grid.d.ts +11 -31
  31. package/lib/types/elements/adw-drop-down.d.ts +17 -20
  32. package/lib/types/elements/adw-entry-row.d.ts +93 -3
  33. package/lib/types/elements/adw-expander-row.d.ts +7 -1
  34. package/lib/types/elements/adw-header-bar.d.ts +15 -0
  35. package/lib/types/elements/adw-icon.d.ts +25 -0
  36. package/lib/types/elements/adw-inline-view-switcher.d.ts +73 -11
  37. package/lib/types/elements/adw-menu-button.d.ts +6 -6
  38. package/lib/types/elements/adw-navigation-split-view.d.ts +48 -0
  39. package/lib/types/elements/adw-navigation-view.d.ts +63 -20
  40. package/lib/types/elements/adw-overlay-split-view.d.ts +94 -0
  41. package/lib/types/elements/adw-password-entry-row.d.ts +19 -10
  42. package/lib/types/elements/adw-popover.d.ts +58 -0
  43. package/lib/types/elements/adw-preferences-dialog.d.ts +57 -2
  44. package/lib/types/elements/adw-preferences-group.d.ts +31 -0
  45. package/lib/types/elements/adw-progress-bar.d.ts +38 -0
  46. package/lib/types/elements/adw-sidebar.d.ts +37 -5
  47. package/lib/types/elements/adw-spinner.d.ts +23 -0
  48. package/lib/types/elements/adw-split-button.d.ts +53 -7
  49. package/lib/types/elements/adw-switch-row.d.ts +11 -2
  50. package/lib/types/elements/adw-switch.d.ts +15 -0
  51. package/lib/types/elements/adw-tab-view.d.ts +129 -12
  52. package/lib/types/elements/adw-toolbar-view.d.ts +5 -0
  53. package/lib/types/elements/adw-view-stack.d.ts +39 -19
  54. package/lib/types/elements/adw-view-switcher-bar.d.ts +25 -7
  55. package/lib/types/elements/adw-view-switcher.d.ts +76 -13
  56. package/lib/types/elements/adw-window-title.d.ts +3 -1
  57. package/lib/types/elements/adw-wrap-box.d.ts +58 -3
  58. package/lib/types/elements/view-switcher-dom.d.ts +44 -0
  59. package/lib/types/entry-rows.spec.d.ts +1 -0
  60. package/lib/types/index.d.ts +7 -0
  61. package/lib/types/navigation-view.spec.d.ts +1 -0
  62. package/lib/types/preferences-search.d.ts +20 -0
  63. package/lib/types/preferences.spec.d.ts +1 -0
  64. package/lib/types/split-button.spec.d.ts +1 -0
  65. package/lib/types/split-views.spec.d.ts +1 -0
  66. package/lib/types/styles.generated.d.ts +1 -1
  67. package/lib/types/tab-view.spec.d.ts +1 -0
  68. package/lib/types/view-stack.spec.d.ts +1 -0
  69. package/lib/types/view-switcher.spec.d.ts +1 -0
  70. package/package.json +6 -6
  71. package/scss/_banner.scss +13 -0
  72. package/scss/_bottom_sheet.scss +5 -6
  73. package/scss/_button.scss +3 -26
  74. package/scss/_button_content.scss +28 -0
  75. package/scss/_checks.scss +228 -0
  76. package/scss/_clamp.scss +9 -3
  77. package/scss/_combo_row.scss +19 -0
  78. package/scss/_data_grid.scss +15 -7
  79. package/scss/_drop_down.scss +12 -55
  80. package/scss/_expander_row.scss +5 -50
  81. package/scss/_icon.scss +51 -0
  82. package/scss/_icons.generated.scss +6 -0
  83. package/scss/_inline_view_switcher.scss +34 -0
  84. package/scss/_labels.scss +135 -0
  85. package/scss/_menu_button.scss +10 -70
  86. package/scss/_navigation_split_view.scss +47 -8
  87. package/scss/_overlay_split_view.scss +117 -32
  88. package/scss/_popover.scss +186 -0
  89. package/scss/_preferences.scss +29 -0
  90. package/scss/_preferences_dialog.scss +17 -0
  91. package/scss/_progress_bar.scss +161 -0
  92. package/scss/_reset.scss +18 -7
  93. package/scss/_spinner.scss +37 -19
  94. package/scss/_split_button.scss +6 -50
  95. package/scss/_switch.scss +90 -0
  96. package/scss/_switch_row.scss +6 -52
  97. package/scss/_tab_view.scss +63 -0
  98. package/scss/_theme.scss +10 -0
  99. package/scss/_toolbar_view.scss +74 -0
  100. package/scss/_variables.scss +34 -0
  101. package/scss/_view_switcher.scss +40 -0
  102. package/scss/_view_switcher_bar.scss +37 -0
  103. package/scss/_wrap_box.scss +65 -6
  104. package/scss/adwaita-skin.scss +23 -0
  105. package/src/adw-about-dialog.spec.ts +350 -0
  106. package/src/adw-action-rows.spec.ts +342 -0
  107. package/src/adw-avatar.spec.ts +176 -0
  108. package/src/adw-banner.spec.ts +152 -0
  109. package/src/adw-button-content.spec.ts +207 -0
  110. package/src/adw-button-row.spec.ts +12 -6
  111. package/src/adw-carousel.spec.ts +389 -0
  112. package/src/adw-checks.spec.ts +368 -0
  113. package/src/adw-drop-down.spec.ts +123 -0
  114. package/src/adw-header-bar.spec.ts +83 -0
  115. package/src/adw-icon.spec.ts +167 -0
  116. package/src/adw-progress-bar.spec.ts +173 -0
  117. package/src/adw-row-state.spec.ts +49 -0
  118. package/src/adw-sidebar.spec.ts +332 -0
  119. package/src/adw-switch.spec.ts +242 -0
  120. package/src/adw-tab-view.spec.ts +12 -3
  121. package/src/adw-view-switcher-bar.spec.ts +61 -13
  122. package/src/adw-wrap-box.spec.ts +341 -0
  123. package/src/bottom-sheet.spec.ts +325 -0
  124. package/src/breakpoints.spec.ts +147 -0
  125. package/src/breakpoints.ts +67 -0
  126. package/src/chrome.spec.ts +400 -0
  127. package/src/elements/adw-about-dialog.ts +223 -163
  128. package/src/elements/adw-action-row.ts +131 -11
  129. package/src/elements/adw-avatar.ts +48 -54
  130. package/src/elements/adw-banner.ts +46 -10
  131. package/src/elements/adw-bottom-sheet.ts +145 -60
  132. package/src/elements/adw-button-content.ts +107 -33
  133. package/src/elements/adw-button-row.ts +61 -37
  134. package/src/elements/adw-button.ts +5 -7
  135. package/src/elements/adw-carousel.ts +467 -105
  136. package/src/elements/adw-checks.ts +299 -0
  137. package/src/elements/adw-clamp.ts +80 -10
  138. package/src/elements/adw-combo-row.ts +32 -7
  139. package/src/elements/adw-data-grid.ts +43 -91
  140. package/src/elements/adw-drop-down.ts +117 -128
  141. package/src/elements/adw-entry-row.ts +404 -36
  142. package/src/elements/adw-expander-row.ts +61 -29
  143. package/src/elements/adw-header-bar.ts +31 -6
  144. package/src/elements/adw-icon.ts +148 -0
  145. package/src/elements/adw-inline-view-switcher.ts +325 -131
  146. package/src/elements/adw-menu-button.ts +93 -70
  147. package/src/elements/adw-navigation-split-view.ts +220 -14
  148. package/src/elements/adw-navigation-view.ts +286 -148
  149. package/src/elements/adw-overlay-split-view.ts +536 -28
  150. package/src/elements/adw-password-entry-row.ts +111 -89
  151. package/src/elements/adw-popover.ts +283 -0
  152. package/src/elements/adw-preferences-dialog.ts +120 -21
  153. package/src/elements/adw-preferences-group.ts +128 -22
  154. package/src/elements/adw-progress-bar.ts +189 -0
  155. package/src/elements/adw-sidebar.ts +301 -142
  156. package/src/elements/adw-spin-row.ts +11 -5
  157. package/src/elements/adw-spinner.ts +168 -9
  158. package/src/elements/adw-split-button.ts +371 -92
  159. package/src/elements/adw-status-page.ts +9 -9
  160. package/src/elements/adw-switch-row.ts +100 -30
  161. package/src/elements/adw-switch.ts +129 -0
  162. package/src/elements/adw-tab-view.ts +700 -129
  163. package/src/elements/adw-toggle-group.ts +5 -7
  164. package/src/elements/adw-toolbar-view.ts +84 -21
  165. package/src/elements/adw-view-stack.ts +183 -85
  166. package/src/elements/adw-view-switcher-bar.ts +171 -81
  167. package/src/elements/adw-view-switcher.ts +328 -117
  168. package/src/elements/adw-window-title.ts +44 -11
  169. package/src/elements/adw-wrap-box.ts +329 -78
  170. package/src/elements/view-switcher-dom.ts +108 -0
  171. package/src/entry-rows.spec.ts +391 -0
  172. package/src/index.ts +23 -0
  173. package/src/navigation-view.spec.ts +435 -0
  174. package/src/preferences-search.ts +135 -0
  175. package/src/preferences.spec.ts +250 -0
  176. package/src/source-view/adw-source-view.ts +2 -5
  177. package/src/split-button.spec.ts +470 -0
  178. package/src/split-views.spec.ts +893 -0
  179. package/src/styles.generated.ts +1 -1
  180. package/src/tab-view.spec.ts +451 -0
  181. package/src/test.browser.mts +49 -0
  182. package/src/view-stack.spec.ts +272 -0
  183. package/src/view-switcher.spec.ts +665 -0
@@ -5,11 +5,21 @@
5
5
  // shows the dialog title and a trailing close button.
6
6
  //
7
7
  // Pages are declared as <adw-preferences-page> children (each with optional
8
- // `title` / `icon-name` attributes + arbitrary group content); the dialog
9
- // snapshots them at connect time and stacks their content in the scrolled body.
8
+ // `title` / `icon-name` / `name` / `use-underline` attributes + arbitrary group
9
+ // content) and are moved — as ELEMENTS, keeping their identity — into the
10
+ // scrolled body. `add(page)` accepts one at any later time, mirroring
11
+ // adw_preferences_dialog_add.
10
12
  // (Adw shows a view-switcher when there is more than one page and the window is
11
- // wide; the web port renders the pages stacked, matching the single-page common
12
- // case the storybook exercises.)
13
+ // wide, and shows exactly one page at a time; the web port renders the pages
14
+ // stacked, matching the single-page common case the storybook exercises. The
15
+ // page COLLECTION model — visible-page, visible-page-name, the auto-pick —
16
+ // belongs to Adw.ViewStack and is available as `ViewStackState` in
17
+ // @gjsify/adwaita-core when this port grows a switcher.)
18
+ //
19
+ // `search(query)` implements the dialog's search over those pages: the corpus,
20
+ // the filter and the `page → group` result subtitles all come from
21
+ // @gjsify/adwaita-core, so the browser and NativeScript dialogs return the same
22
+ // results for the same tree.
13
23
  //
14
24
  // Like Adw.Dialog the dialog starts hidden; `present()` / setting the `open`
15
25
  // attribute reveals it. It is dismissed by Escape, by clicking the scrim, or by
@@ -42,16 +52,57 @@
42
52
  // Copyright (c) 2023-2024 GNOME Foundation Inc. (libadwaita). LGPLv2.1+.
43
53
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
44
54
 
55
+ import type { PreferencesSearchResult, SearchPreferencesOptions } from '@gjsify/adwaita-core';
56
+ import { searchPreferencesDom } from '../preferences-search.js';
57
+
45
58
  const DEFAULT_CONTENT_WIDTH = 640;
46
59
 
47
60
  /**
48
- * A single preferences page. Child of <adw-preferences-dialog>; consumed at
49
- * connect time. Carries optional `title` / `icon-name` attributes and any
61
+ * A single preferences page. Child of <adw-preferences-dialog>. Carries optional
62
+ * `title` / `icon-name` / `name` / `use-underline` attributes and any
50
63
  * <adw-preferences-group> content — mirrors Adw.PreferencesPage.
64
+ *
65
+ * The dialog keeps the page ELEMENT rather than hoisting its children out,
66
+ * because the page's identity is load-bearing: `adw_preferences_dialog_add`
67
+ * binds title / icon-name / name / use-underline / visible onto the view-stack
68
+ * page (adw-preferences-dialog.c:707-711), and `create_search_row_subtitle`
69
+ * needs the title back the moment a second page is visible.
51
70
  */
52
71
  export class AdwPreferencesPage extends HTMLElement {
53
72
  static get observedAttributes() {
54
- return ['title', 'icon-name', 'open'];
73
+ return ['title', 'icon-name', 'name', 'use-underline', 'open'];
74
+ }
75
+
76
+ /** `adw_preferences_page_get_name` — the view-stack child name. */
77
+ get name(): string {
78
+ return this.getAttribute('name') ?? '';
79
+ }
80
+
81
+ set name(value: string) {
82
+ this.setAttribute('name', value);
83
+ }
84
+
85
+ /** `AdwPreferencesPage:icon-name`, shown by a view switcher. */
86
+ get iconName(): string {
87
+ return this.getAttribute('icon-name') ?? '';
88
+ }
89
+
90
+ set iconName(value: string) {
91
+ this.setAttribute('icon-name', value);
92
+ }
93
+
94
+ /** `AdwPreferencesPage:use-underline` — the title carries a mnemonic. */
95
+ get useUnderline(): boolean {
96
+ return this.hasAttribute('use-underline');
97
+ }
98
+
99
+ set useUnderline(value: boolean) {
100
+ this.toggleAttribute('use-underline', value);
101
+ }
102
+
103
+ /** The page's groups, in document order. */
104
+ get groups(): HTMLElement[] {
105
+ return Array.from(this.querySelectorAll('adw-preferences-group'));
55
106
  }
56
107
  }
57
108
 
@@ -61,6 +112,7 @@ export class AdwPreferencesDialog extends HTMLElement {
61
112
  private _dialogEl!: HTMLDivElement;
62
113
  private _titleEl!: HTMLSpanElement;
63
114
  private _bodyEl!: HTMLDivElement;
115
+ private _pagesEl!: HTMLDivElement;
64
116
  private _closeBtn!: HTMLButtonElement;
65
117
 
66
118
  static get observedAttributes() {
@@ -116,18 +168,66 @@ export class AdwPreferencesDialog extends HTMLElement {
116
168
  this.open = false;
117
169
  }
118
170
 
171
+ /**
172
+ * `adw_preferences_dialog_add` (adw-preferences-dialog.c:693-716) — add a
173
+ * page at ANY time.
174
+ *
175
+ * Before this the pages were snapshotted once, in `connectedCallback`; a
176
+ * page appended later stayed a sibling of the scrim and rendered OUTSIDE
177
+ * the dialog card. GTK and the NativeScript port both accept a page
178
+ * whenever it arrives.
179
+ */
180
+ add(page: AdwPreferencesPage): void {
181
+ this._pagesEl.appendChild(page);
182
+ }
183
+
184
+ /**
185
+ * `adw_preferences_dialog_remove` (:723-745). C logs
186
+ * `ADW_CRITICAL_CANNOT_REMOVE_CHILD` and no-ops for a page it does not own;
187
+ * returning `false` is the same contract without a console write.
188
+ *
189
+ * NOT named `remove`: `ChildNode.remove()` already exists on every element
190
+ * and takes no arguments, so overriding it with a different signature makes
191
+ * the class un-assignable to `HTMLElement` — which `customElements.define`
192
+ * requires.
193
+ */
194
+ removePage(page: AdwPreferencesPage): boolean {
195
+ if (page.parentNode !== this._pagesEl) return false;
196
+ this._pagesEl.removeChild(page);
197
+ return true;
198
+ }
199
+
200
+ /** The dialog's pages, in add order. */
201
+ get pages(): AdwPreferencesPage[] {
202
+ return Array.from(this._pagesEl.querySelectorAll('adw-preferences-page')) as AdwPreferencesPage[];
203
+ }
204
+
205
+ /**
206
+ * The preferences search (`filter_search_results` +
207
+ * `create_search_row_subtitle`), over this dialog's live page subtree.
208
+ *
209
+ * The whole pipeline is `@gjsify/adwaita-core`'s, so this dialog and the
210
+ * NativeScript one answer identically for the same tree — including the
211
+ * three corpus filters (visible page → visible group → titled visible row)
212
+ * and the case fold that makes `strasse` find `Straße`.
213
+ *
214
+ * An empty query returns the WHOLE corpus, which is what C does and what
215
+ * makes the results list non-empty before the user types.
216
+ */
217
+ search(query: string, options?: SearchPreferencesOptions): PreferencesSearchResult<Element>[] {
218
+ return searchPreferencesDom(this._pagesEl, query, options);
219
+ }
220
+
119
221
  connectedCallback() {
120
222
  if (this._initialized) return;
121
223
  this._initialized = true;
122
224
 
123
- // Snapshot the declared pages before taking over the subtree. Each page's
124
- // content (its groups) is stacked in the scrolled body; any unwrapped
125
- // children fall back to the body too (Adw.PreferencesDialog's buildable
126
- // default adds a child as a page).
127
- const pages = Array.from(this.querySelectorAll(':scope > adw-preferences-page')) as AdwPreferencesPage[];
128
- const pageContent = pages.flatMap((page) => Array.from(page.childNodes));
129
- const claimed = new Set<Node>([...pages, ...pageContent]);
130
- const unwrapped = Array.from(this.childNodes).filter((node) => !claimed.has(node));
225
+ // Snapshot the declared children before taking over the subtree. The
226
+ // page ELEMENTS move into the body intact — their title / name /
227
+ // icon-name are what a search result subtitle and a view switcher read
228
+ // — and any unwrapped children follow them (Adw.PreferencesDialog's
229
+ // buildable default adds a child as a page).
230
+ const declared = Array.from(this.childNodes);
131
231
 
132
232
  // Full-cover scrim — clicking it dismisses the dialog (or raises
133
233
  // close-attempt when locked).
@@ -169,15 +269,14 @@ export class AdwPreferencesDialog extends HTMLElement {
169
269
 
170
270
  header.append(leading, this._titleEl, trailing);
171
271
 
172
- // Scrolled body — a clamped column of the declared pages' content.
272
+ // Scrolled body — a clamped column of the declared pages.
173
273
  this._bodyEl = document.createElement('div');
174
274
  this._bodyEl.className = 'adw-preferences-dialog-body';
175
275
 
176
- const clamp = document.createElement('div');
177
- clamp.className = 'adw-preferences-dialog-pages';
178
- for (const child of pageContent) clamp.appendChild(child);
179
- for (const child of unwrapped) clamp.appendChild(child);
180
- this._bodyEl.appendChild(clamp);
276
+ this._pagesEl = document.createElement('div');
277
+ this._pagesEl.className = 'adw-preferences-dialog-pages';
278
+ for (const child of declared) this._pagesEl.appendChild(child);
279
+ this._bodyEl.appendChild(this._pagesEl);
181
280
 
182
281
  this._dialogEl.append(header, this._bodyEl);
183
282
 
@@ -6,19 +6,42 @@
6
6
  // Header-suffix: any child carrying `slot="header-suffix"` is hoisted into the
7
7
  // trailing edge of the header instead of the boxed list — mirroring
8
8
  // Adw.PreferencesGroup's `header-suffix` property / buildable type.
9
+ //
10
+ // The five visibility states (title, description, header, `single-line`,
11
+ // listbox) are NOT derived here: they come from
12
+ // `derivePreferencesGroupHeader` in `@gjsify/adwaita-core`, which both this
13
+ // element and the NativeScript group delegate to (ADR 0004). This element used
14
+ // to compute two of the five by hand and was missing the other three — no
15
+ // `single-line` class, and a `.boxed-list` card that stayed painted (with its
16
+ // full-width `box-shadow` hairline) over an empty group.
17
+ //
18
+ // Reference: refs/libadwaita/src/adw-preferences-group.c
19
+ // (update_title_visibility, update_listbox_visibility, is_single_line,
20
+ // update_header_visibility, adw_preferences_group_add)
9
21
  // Reference: refs/libadwaita/src/adw-preferences-group.ui (header_box layout)
10
22
  // Reference: refs/adwaita-web/adwaita-web/scss/_preferences.scss
11
23
  // Adapted from Adwaita Web UI Framework (https://github.com/mclellac/adwaita-web).
12
24
  // Copyright (c) 2025 csm. MIT License.
13
25
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
14
26
  // Modifications: Reimplemented as Web Component for @gjsify/adwaita-web;
15
- // description line + header-suffix slot added to match Adw.PreferencesGroup.
27
+ // description line + header-suffix slot added to match Adw.PreferencesGroup;
28
+ // header/listbox state delegated to @gjsify/adwaita-core.
29
+
30
+ import { derivePreferencesGroupHeader } from '@gjsify/adwaita-core';
31
+
32
+ /** A child that asked to sit in the header rather than in the boxed list. */
33
+ function isHeaderSuffix(node: Node): boolean {
34
+ return node instanceof Element && node.getAttribute('slot') === 'header-suffix';
35
+ }
16
36
 
17
37
  export class AdwPreferencesGroup extends HTMLElement {
38
+ private _headerEl!: HTMLDivElement;
18
39
  private _titleEl!: HTMLSpanElement;
19
40
  private _descriptionEl!: HTMLSpanElement;
20
41
  private _suffixEl!: HTMLDivElement;
42
+ private _listboxEl!: HTMLDivElement;
21
43
  private _initialized = false;
44
+ private _observer: MutationObserver | null = null;
22
45
 
23
46
  static get observedAttributes() {
24
47
  return ['title', 'description'];
@@ -33,22 +56,52 @@ export class AdwPreferencesGroup extends HTMLElement {
33
56
  else this.removeAttribute('description');
34
57
  }
35
58
 
59
+ /**
60
+ * `adw_preferences_group_add` (adw-preferences-group.c:396-411): a row goes
61
+ * into the boxed list, a `header-suffix` child into the header.
62
+ *
63
+ * Exposed as a method AND wired to a `MutationObserver` on the host, because
64
+ * both spellings occur: `group.addRow(row)` in code, `group.append(row)` in
65
+ * a framework's rendering. Before this, a child appended after connect
66
+ * stayed a bare child of the host — outside the card, and invisible to the
67
+ * row count that decides whether the card is painted at all.
68
+ */
69
+ addRow(child: Node): void {
70
+ if (isHeaderSuffix(child)) this._suffixEl.appendChild(child);
71
+ else this._listboxEl.appendChild(child);
72
+ this._renderHeader();
73
+ }
74
+
75
+ /**
76
+ * `adw_preferences_group_remove` (:421-440). C logs
77
+ * `ADW_CRITICAL_CANNOT_REMOVE_CHILD` and no-ops for a child it does not
78
+ * own; returning `false` is the same contract without a console write.
79
+ */
80
+ removeRow(child: Node): boolean {
81
+ const parent = child.parentNode;
82
+ if (parent !== this._listboxEl && parent !== this._suffixEl) return false;
83
+ parent.removeChild(child);
84
+ this._renderHeader();
85
+ return true;
86
+ }
87
+
36
88
  connectedCallback() {
37
- if (this._initialized) return;
89
+ if (this._initialized) {
90
+ this._observeHost();
91
+ return;
92
+ }
38
93
  this._initialized = true;
39
94
 
40
95
  const children = Array.from(this.childNodes);
41
96
  // Children opting into the header-suffix slot are hoisted to the
42
97
  // trailing edge of the header; everything else goes into the boxed list.
43
- const suffixChildren = children.filter(
44
- (child) => child instanceof Element && child.getAttribute('slot') === 'header-suffix',
45
- );
98
+ const suffixChildren = children.filter(isHeaderSuffix);
46
99
  const rowChildren = children.filter((child) => !suffixChildren.includes(child));
47
100
 
48
101
  // Header: a flex row with a title/description label column on the left
49
102
  // and the optional header-suffix on the trailing edge.
50
- const header = document.createElement('div');
51
- header.className = 'adw-preferences-group-header';
103
+ this._headerEl = document.createElement('div');
104
+ this._headerEl.className = 'adw-preferences-group-header';
52
105
 
53
106
  const labels = document.createElement('div');
54
107
  labels.className = 'adw-preferences-group-labels';
@@ -63,38 +116,91 @@ export class AdwPreferencesGroup extends HTMLElement {
63
116
 
64
117
  this._suffixEl = document.createElement('div');
65
118
  this._suffixEl.className = 'adw-preferences-group-suffix';
66
- suffixChildren.forEach((child) => this._suffixEl.appendChild(child));
119
+ for (const child of suffixChildren) this._suffixEl.appendChild(child);
67
120
 
68
- header.append(labels, this._suffixEl);
121
+ this._headerEl.append(labels, this._suffixEl);
69
122
 
70
123
  // Boxed list container — the remaining children move into it.
71
- const listbox = document.createElement('div');
72
- listbox.className = 'adw-preferences-group-listbox';
73
- rowChildren.forEach((child) => listbox.appendChild(child));
124
+ this._listboxEl = document.createElement('div');
125
+ this._listboxEl.className = 'adw-preferences-group-listbox';
126
+ for (const child of rowChildren) this._listboxEl.appendChild(child);
74
127
 
75
- this.replaceChildren(header, listbox);
128
+ this.replaceChildren(this._headerEl, this._listboxEl);
76
129
 
77
130
  this._renderHeader();
131
+ this._observeHost();
132
+ }
133
+
134
+ disconnectedCallback() {
135
+ // The observer holds this element alive through the host node; drop it
136
+ // with the connection that created it, not at some later "cleanup".
137
+ this._observer?.disconnect();
138
+ this._observer = null;
78
139
  }
79
140
 
80
141
  attributeChangedCallback() {
81
142
  if (this._initialized) this._renderHeader();
82
143
  }
83
144
 
145
+ /**
146
+ * Adopt children appended to the HOST after the subtree was built, so
147
+ * `group.append(row)` behaves like `adw_preferences_group_add`.
148
+ *
149
+ * C gets this for free — `gtk_widget_observe_children` on the listbox drives
150
+ * `update_listbox_visibility` through `items-changed`
151
+ * (adw-preferences-group.c:335-339) — and a DOM element needs the observer
152
+ * to have the same rule hold over time rather than only at connect.
153
+ */
154
+ private _observeHost(): void {
155
+ if (this._observer) return;
156
+ this._observer = new MutationObserver((records) => {
157
+ for (const record of records) {
158
+ // Only the HOST's own children need re-homing. Records from the
159
+ // listbox are the CONSEQUENCE of that move; re-adopting them
160
+ // would append each row a second time and reorder the list.
161
+ if (record.target !== this) continue;
162
+ for (const node of record.addedNodes) {
163
+ if (!(node instanceof Element)) continue;
164
+ if (node === this._headerEl || node === this._listboxEl) continue;
165
+ this.addRow(node);
166
+ }
167
+ }
168
+ // A removal straight out of the listbox still changes the row count.
169
+ this._renderHeader();
170
+ });
171
+ this._observer.observe(this, { childList: true });
172
+ this._observer.observe(this._listboxEl, { childList: true });
173
+ }
174
+
84
175
  private _renderHeader() {
85
176
  const title = this.getAttribute('title') ?? '';
86
- this._titleEl.textContent = title;
87
- this._titleEl.hidden = title.length === 0;
88
-
89
177
  const description = this.getAttribute('description') ?? '';
178
+
179
+ this._titleEl.textContent = title;
90
180
  this._descriptionEl.textContent = description;
91
- this._descriptionEl.hidden = description.length === 0;
92
181
 
93
- // The header only takes space when it carries a title, a description or
94
- // a suffix — an empty group has no header padding, like Adw's.
95
- const hasSuffix = this._suffixEl.childElementCount > 0;
96
- const headerEl = this._titleEl.parentElement?.parentElement;
97
- if (headerEl) headerEl.hidden = title.length === 0 && description.length === 0 && !hasSuffix;
182
+ const state = derivePreferencesGroupHeader({
183
+ title,
184
+ description,
185
+ hasHeaderSuffix: this._suffixEl.childElementCount > 0,
186
+ // The RAW child count, matching `gtk_widget_observe_children` — a
187
+ // row with an empty title still keeps the card painted.
188
+ rowCount: this._listboxEl.childElementCount,
189
+ // The labels are `textContent`, not markup: what is displayed IS the
190
+ // attribute value, so visibility must be judged on the raw string.
191
+ // libadwaita's labels are `use-markup=True`; rendering Pango markup
192
+ // in the browser is a separate, still-open gap, and half-closing it
193
+ // here would hide a title this element visibly renders.
194
+ useMarkup: false,
195
+ });
196
+
197
+ this._titleEl.hidden = !state.titleVisible;
198
+ this._descriptionEl.hidden = !state.descriptionVisible;
199
+ this._headerEl.hidden = !state.headerVisible;
200
+ // `single-line` is not cosmetic: the stylesheet keys the header's
201
+ // min-height / margin-bottom off it.
202
+ this._headerEl.classList.toggle('single-line', state.singleLine);
203
+ this._listboxEl.hidden = !state.listboxVisible;
98
204
  }
99
205
  }
100
206
 
@@ -0,0 +1,189 @@
1
+ // <adw-progress-bar> — the DETERMINATE progress indicator, and the partner to
2
+ // <adw-spinner>, which only ever covers "busy, no idea how long".
3
+ //
4
+ // NO CORE STATE MACHINE. The whole behaviour is `CLAMP(fraction, 0, 1)` plus a
5
+ // pulsing flag — ADR 0004 is explicit that a widget with genuinely trivial
6
+ // behaviour does not get a core class, and the clamp itself is already shared:
7
+ // `glibClamp` (GLib's CLAMP, high bound tested FIRST) comes from
8
+ // `@gjsify/adwaita-core`. Nothing was added there for this element.
9
+ //
10
+ // WHAT IS NOT VERIFIABLE IN THIS TREE. `GtkProgressBar` is a GTK widget:
11
+ // `refs/gtk` is EMPTY here and libadwaita vendors no `adw-progress-bar.c`, so
12
+ // `gtk_progress_bar_pulse()`'s step semantics — the `pulse-step` default, how
13
+ // the block reflects at the ends, whether it resets `fraction` — are NOT
14
+ // verifiable and are NOT reproduced. What ships instead is the web idiom the
15
+ // vendored port uses: a CSS keyframe animation that runs while `pulsing` is set
16
+ // (scss/_progress_bar.scss, which cites which half came from which source), so
17
+ // {@link AdwProgressBar.pulse} ENTERS that state and is idempotent afterwards
18
+ // rather than advancing an offset nothing here could check. The
19
+ // default `show-text` label — `NN%` — is likewise this port's, not a
20
+ // reproduction of GTK's format string.
21
+ //
22
+ // Attributes:
23
+ // fraction — 0…1, clamped. How much of the bar is filled.
24
+ // pulsing — boolean; indeterminate. The block bounces instead of filling.
25
+ // show-text — boolean; reveal the text node under the bar.
26
+ // text — the text to show. Defaults to the fraction as a percentage.
27
+ // inverted — boolean; fill from the far end.
28
+ // osd — boolean; libadwaita's `.osd` STYLE CLASS — a 2px troughless bar
29
+ // for under a header bar (_progress-bar.scss:59-83).
30
+ // disabled — boolean; dims the widget.
31
+ // Properties:
32
+ // fraction / pulsing / showText / text / inverted / osd / disabled (get/set).
33
+ // Methods:
34
+ // pulse() — switch to indeterminate. GtkProgressBar's own vocabulary; see
35
+ // the caveat above for how far the resemblance goes.
36
+ //
37
+ // A11Y: `role="progressbar"` with `aria-valuemin`/`aria-valuemax` fixed at 0/1
38
+ // and `aria-valuenow` carrying the clamped fraction. While pulsing,
39
+ // `aria-valuenow` is REMOVED — that is how ARIA spells "indeterminate", and a
40
+ // stale value there would be announced as real progress.
41
+ //
42
+ // Reference: refs/libadwaita/src/stylesheet/widgets/_progress-bar.scss
43
+ // Reference: refs/adwaita-web/adwaita-web/scss/_progressbar.scss (the indeterminate animation only)
44
+ // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
45
+ // Copyright (c) 2025 csm (adwaita-web). MIT License.
46
+ // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
47
+
48
+ import { glibClamp } from '@gjsify/adwaita-core';
49
+
50
+ export class AdwProgressBar extends HTMLElement {
51
+ private _trough!: HTMLSpanElement;
52
+ private _progress!: HTMLSpanElement;
53
+ private _text!: HTMLSpanElement;
54
+ private _initialized = false;
55
+
56
+ static get observedAttributes() {
57
+ return ['fraction', 'pulsing', 'show-text', 'text', 'inverted', 'osd', 'disabled'];
58
+ }
59
+
60
+ /** How much of the bar is filled, 0…1. Always the CLAMPED value. */
61
+ get fraction(): number {
62
+ const raw = Number.parseFloat(this.getAttribute('fraction') ?? '');
63
+ // A missing or unparseable attribute is 0, GtkProgressBar's default —
64
+ // NOT NaN, which would reach the width and the a11y value.
65
+ if (!Number.isFinite(raw)) return 0;
66
+ return glibClamp(raw, 0, 1);
67
+ }
68
+
69
+ set fraction(value: number) {
70
+ this.setAttribute('fraction', String(value));
71
+ }
72
+
73
+ /** Whether the bar is indeterminate. */
74
+ get pulsing(): boolean {
75
+ return this.hasAttribute('pulsing');
76
+ }
77
+
78
+ set pulsing(value: boolean) {
79
+ this.toggleAttribute('pulsing', !!value);
80
+ }
81
+
82
+ /** Whether the text node is shown. */
83
+ get showText(): boolean {
84
+ return this.hasAttribute('show-text');
85
+ }
86
+
87
+ set showText(value: boolean) {
88
+ this.toggleAttribute('show-text', !!value);
89
+ }
90
+
91
+ /** The shown text. Falls back to the fraction as a percentage. */
92
+ get text(): string {
93
+ const explicit = this.getAttribute('text');
94
+ if (explicit !== null) return explicit;
95
+ return `${Math.round(this.fraction * 100)}%`;
96
+ }
97
+
98
+ set text(value: string) {
99
+ this.setAttribute('text', value);
100
+ }
101
+
102
+ /** Whether the bar fills from the far end. */
103
+ get inverted(): boolean {
104
+ return this.hasAttribute('inverted');
105
+ }
106
+
107
+ set inverted(value: boolean) {
108
+ this.toggleAttribute('inverted', !!value);
109
+ }
110
+
111
+ /** Whether the `.osd` hairline variant is used. */
112
+ get osd(): boolean {
113
+ return this.hasAttribute('osd');
114
+ }
115
+
116
+ set osd(value: boolean) {
117
+ this.toggleAttribute('osd', !!value);
118
+ }
119
+
120
+ /** Whether the widget is dimmed. */
121
+ get disabled(): boolean {
122
+ return this.hasAttribute('disabled');
123
+ }
124
+
125
+ set disabled(value: boolean) {
126
+ this.toggleAttribute('disabled', !!value);
127
+ }
128
+
129
+ /**
130
+ * Switch the bar to indeterminate — GtkProgressBar's method name for the
131
+ * same intent. See the module header: the bouncing block is a CSS animation
132
+ * here, so this ENTERS the state and repeat calls are no-ops; GTK's
133
+ * `pulse-step` arithmetic is not verifiable in this tree and is not faked.
134
+ */
135
+ pulse(): void {
136
+ this.pulsing = true;
137
+ }
138
+
139
+ connectedCallback() {
140
+ if (this._initialized) return;
141
+ this._initialized = true;
142
+
143
+ this._trough = document.createElement('span');
144
+ this._trough.className = 'adw-progress-bar-trough';
145
+ this._progress = document.createElement('span');
146
+ this._progress.className = 'adw-progress-bar-progress';
147
+ this._trough.appendChild(this._progress);
148
+
149
+ this._text = document.createElement('span');
150
+ this._text.className = 'adw-progress-bar-text';
151
+
152
+ this.replaceChildren(this._trough, this._text);
153
+
154
+ this.setAttribute('role', 'progressbar');
155
+ this.setAttribute('aria-valuemin', '0');
156
+ this.setAttribute('aria-valuemax', '1');
157
+
158
+ this._render();
159
+ }
160
+
161
+ attributeChangedCallback() {
162
+ if (!this._initialized) return;
163
+ this._render();
164
+ }
165
+
166
+ private _render(): void {
167
+ const fraction = this.fraction;
168
+ const pulsing = this.pulsing;
169
+
170
+ // `> trough.empty > progress { all: unset }` (_progress-bar.scss:85) —
171
+ // the indicator has to VANISH at 0, not draw a rounded stub. A pulsing
172
+ // bar is never empty: its block is what says "working".
173
+ const empty = !pulsing && fraction === 0;
174
+ this._trough.classList.toggle('empty', empty);
175
+ // The inline width is dropped in both the pulsing and the empty case, so
176
+ // the rules that own the indicator there — the keyframes, and
177
+ // `all: unset` — are not fighting an inline declaration they cannot win
178
+ // against (inline styles beat any author rule, `all: unset` included).
179
+ this._progress.style.width = pulsing || empty ? '' : `${fraction * 100}%`;
180
+
181
+ this._text.textContent = this.text;
182
+ this._text.hidden = !this.showText;
183
+
184
+ if (pulsing) this.removeAttribute('aria-valuenow');
185
+ else this.setAttribute('aria-valuenow', String(fraction));
186
+ }
187
+ }
188
+
189
+ customElements.define('adw-progress-bar', AdwProgressBar);