@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
@@ -1,90 +1,210 @@
1
1
  // <adw-view-switcher> — An adaptive, tab-style switcher driving a set of named
2
- // pages (the web counterpart of Adw.ViewSwitcher paired with an Adw.ViewStack).
3
- // Pages are declared as <adw-view-switcher-page> children with `name`, `title`
4
- // and `icon-name` attributes; their own children become the page body. The
5
- // switcher renders one toggle button per page (always icon + label) and shows
6
- // exactly one page at a time, exactly like libadwaita's single `viewswitcher`
7
- // CSS node with the `.wide` / `.narrow` policy style classes.
8
- // - wide: icon and label side by side (horizontal button layout)
9
- // - narrow: icon stacked above the label (vertical button layout)
2
+ // pages: the web counterpart of Adw.ViewSwitcher, bundled with the Adw.ViewStack
3
+ // libadwaita keeps separate. Pages are declared as <adw-view-switcher-page>
4
+ // children; their own children become the page body.
5
+ //
6
+ // Everything that decides WHAT to show is HEADLESS and lives in
7
+ // `@gjsify/adwaita-core` (ADR 0004): the button-visibility rule, the
8
+ // `image-missing` icon fallback, the mnemonic-stripped label, the badge label
9
+ // and its screen-reader description, and the SELECTION — which is
10
+ // `ViewSwitcherState` over the wave-1 `ViewStackState`, so this element and the
11
+ // NativeScript twin share one set of guards and one set of conformance vectors
12
+ // (`@gjsify/adwaita-core/conformance`). This file is the DOM half only.
13
+ //
14
+ // What the lift fixed here, all of it C-derived:
15
+ // - a page with neither title nor icon rendered an empty, clickable,
16
+ // space-consuming tab; libadwaita hides the button outright
17
+ // (adw-view-switcher.c:178). An EMPTY title is NOT that case — it keeps its
18
+ // button, which is why `getAttribute` is no longer flattened with `?? ''`.
19
+ // - a page with no icon rendered no icon at all; libadwaita substitutes
20
+ // `image-missing` (adw-view-switcher-button.c:399-405).
21
+ // - `active="-1"` / `active="99"` were CLAMPED into range, so an out-of-range
22
+ // request silently switched pages; `adw_view_stack_pages_select_item`
23
+ // refuses (adw-view-stack.c:682-684).
24
+ // - `notify::visible-child` fired only for a click, never for a property or
25
+ // attribute set; C notifies on every path (adw-view-stack.c:1038-1039).
26
+ // - `notify::policy` was documented in this header and never dispatched.
27
+ // - the declared pages were harvested with an UNSCOPED querySelectorAll, so a
28
+ // nested switcher inside a page had its pages stolen by the outer one.
29
+ // - the 500 ms drag-hover auto-switch did not exist
30
+ // (adw-view-switcher-button.c:14, :58-96).
31
+ //
10
32
  // Attributes:
11
- // policy — 'wide' | 'narrow' (default 'narrow', matching libadwaita's default).
12
- // active — zero-based index of the selected page (default 0).
33
+ // policy — 'wide' | 'narrow' (default 'narrow'). An unrecognised value is
34
+ // REJECTED and leaves the current policy alone, as `g_return_if_fail` does;
35
+ // removing the attribute restores the default.
36
+ // active — zero-based index of the selected PAGE. Out-of-range, negative and
37
+ // fractional values are refused and the attribute is reflected back to the
38
+ // selection that actually holds.
39
+ // Page attributes: name, title, icon-name, hidden (= AdwViewStackPage:visible),
40
+ // use-underline, badge-number, needs-attention.
13
41
  // Events:
14
- // `notify::visible-child` (CustomEvent, bubbles, detail = { name, index }) when
15
- // the selected page changes — mirrors Adw.ViewStack's `visible-child-name`.
16
- // `notify::policy` (CustomEvent, bubbles, detail = { policy }) when the policy
17
- // changes — mirrors the Adw.ViewSwitcher `policy` GObject property.
42
+ // `notify::visible-child` (CustomEvent, bubbles,
43
+ // detail = { index, name, title, interactive }) on every selection change,
44
+ // including the auto-pick; `interactive` is false for a model-driven one.
45
+ // `notify::policy` (CustomEvent, bubbles, detail = { policy }).
18
46
  // Reference: refs/libadwaita/src/adw-view-switcher.c (AdwViewSwitcher behaviour)
47
+ // Reference: refs/libadwaita/src/adw-view-switcher-button.c (AdwViewSwitcherButton)
19
48
  // Reference: refs/libadwaita/src/stylesheet/widgets/_view-switcher.scss
20
- // Reference: refs/adwaita-web/adwaita-web/scss/_viewswitcher.scss
49
+ // Reference: packages/nativescript-bridge/adwaita/src/widgets/view-switcher-model.ts (NS twin)
21
50
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
22
51
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
23
52
 
24
- type Policy = 'wide' | 'narrow';
53
+ import {
54
+ VIEW_SWITCHER_POLICIES,
55
+ ViewSwitcherState,
56
+ buildViewSwitcherButtons,
57
+ isViewSwitcherPolicy,
58
+ } from '@gjsify/adwaita-core';
59
+ import type { ViewSwitcherScheduler } from '@gjsify/adwaita-core';
60
+ // Aliased because `AdwViewSwitcherPage` is already this module's page ELEMENT —
61
+ // the custom element the package has always exported under that name.
62
+ import type {
63
+ AdwViewSwitcherPage as AdwViewSwitcherPageInfo,
64
+ AdwViewSwitcherPolicy,
65
+ ViewSwitcherStateChange,
66
+ } from '@gjsify/adwaita-core';
25
67
 
26
- interface PageInfo {
27
- name: string;
28
- title: string;
29
- icon: string;
30
- body: HTMLElement;
68
+ import {
69
+ applyDescription,
70
+ applyIndicator,
71
+ applySwitcherIcon,
72
+ domViewSwitcherScheduler,
73
+ readSwitcherPage,
74
+ } from './view-switcher-dom.js';
75
+ import { type AdwIcon, createAdwIcon } from './adw-icon.js';
76
+
77
+ /** The DOM nodes one page owns — the switcher paints the derived model onto them. */
78
+ interface PageNodes {
31
79
  button: HTMLButtonElement;
80
+ icon: AdwIcon;
81
+ label: HTMLSpanElement;
82
+ indicator: HTMLSpanElement;
83
+ body: HTMLDivElement;
32
84
  }
33
85
 
34
- /** A single page. Children of <adw-view-switcher>; consumed at connect time. */
35
- export class AdwViewSwitcherPage extends HTMLElement {
36
- static get observedAttributes() {
37
- return ['name', 'title', 'icon-name'];
38
- }
39
- }
86
+ /**
87
+ * A single page. Children of `<adw-view-switcher>`; consumed at connect time.
88
+ *
89
+ * Deliberately carries NO `observedAttributes`. It used to declare six of them
90
+ * and no `attributeChangedCallback`, which is a dead declaration — the browser
91
+ * computes the list and has nowhere to deliver to — so changing `badge-number`,
92
+ * `title`, `icon-name` or `needs-attention` after connect did nothing at all,
93
+ * where C rebinds on every page `notify` (adw-view-switcher.c:184-193, :223).
94
+ *
95
+ * A callback could not fix it either: the switcher consumes these elements at
96
+ * connect and `replaceChildren`s them away, so a detached page has no `closest`
97
+ * to reach its owner through. The switcher watches them with a `MutationObserver`
98
+ * instead, which keeps working after the detach and needs nothing from this class.
99
+ */
100
+ export class AdwViewSwitcherPage extends HTMLElement {}
40
101
 
41
102
  export class AdwViewSwitcher extends HTMLElement {
103
+ /**
104
+ * The dwell timer, injectable.
105
+ *
106
+ * It used to be `new ViewSwitcherState({ scheduler: domViewSwitcherScheduler })`
107
+ * inline, which left NOWHERE to hand a clock in — so
108
+ * `VIEW_SWITCHER_DRAG_VECTORS` was driven by the core suite alone, and the
109
+ * conformance header claimed all three suites drove it. `scheduler` is a
110
+ * property so a test can replace it before the element connects; the DOM one
111
+ * is the default, exactly as before.
112
+ */
113
+ scheduler: ViewSwitcherScheduler = domViewSwitcherScheduler;
114
+ private _stateInstance: ViewSwitcherState | null = null;
115
+
116
+ /** Built on first access, so a `scheduler` set before connect is the one used. */
117
+ private get _state(): ViewSwitcherState {
118
+ if (!this._stateInstance) {
119
+ this._stateInstance = new ViewSwitcherState({ scheduler: this.scheduler });
120
+ this._stateInstance.subscribe((change) => this._onStateChange(change));
121
+ }
122
+ return this._stateInstance;
123
+ }
42
124
  private _barEl!: HTMLDivElement;
43
125
  private _contentEl!: HTMLDivElement;
44
- private _pages: PageInfo[] = [];
45
- private _active = 0;
46
- private _policy: Policy = 'narrow';
126
+ private _nodes: PageNodes[] = [];
127
+ /** The page ELEMENTS, kept because the switcher detaches them from the tree. */
128
+ private _declared: HTMLElement[] = [];
129
+ private _pageObserver: MutationObserver | undefined;
130
+ private _policy: AdwViewSwitcherPolicy = 'narrow';
47
131
  private _initialized = false;
48
132
 
49
133
  static get observedAttributes() {
50
134
  return ['policy', 'active'];
51
135
  }
52
136
 
137
+ constructor() {
138
+ super();
139
+ // Subscribing touches no attributes and no children, so it is legal in a
140
+ // custom element constructor — unlike anything DOM-shaped.
141
+ }
142
+
53
143
  /** The policy controlling the button layout ('wide' | 'narrow'). */
54
- get policy(): Policy {
144
+ get policy(): AdwViewSwitcherPolicy {
55
145
  return this._policy;
56
146
  }
57
147
 
58
- set policy(value: Policy) {
148
+ set policy(value: AdwViewSwitcherPolicy) {
59
149
  this.setAttribute('policy', value);
60
150
  }
61
151
 
62
- /** Zero-based index of the active page. */
152
+ /** Zero-based index of the selected page, `-1` when nothing is selected. */
63
153
  get active(): number {
64
- return this._active;
154
+ return this._state.selected;
65
155
  }
66
156
 
67
157
  set active(value: number) {
68
- this.setAttribute('active', String(value));
158
+ this._state.setSelected(value);
159
+ this._reflectActive();
160
+ }
161
+
162
+ /** Name of the selected page, `''` when nothing is selected. */
163
+ get visibleChildName(): string {
164
+ return this._state.selectedName;
165
+ }
166
+
167
+ set visibleChildName(name: string | null | undefined) {
168
+ this._state.selectName(name);
169
+ }
170
+
171
+ /** The resolved page records a bound layout can read. */
172
+ get pages(): readonly AdwViewSwitcherPageInfo[] {
173
+ return this._state.pages;
69
174
  }
70
175
 
71
- /** The name of the currently visible page, or null when there are none. */
72
- get visibleChildName(): string | null {
73
- return this._pages[this._active]?.name ?? null;
176
+ /**
177
+ * Show or hide a page (`AdwViewStackPage:visible`). Returns whether the
178
+ * SELECTION moved — hiding the selected page falls back to the first still
179
+ * visible one (adw-view-stack.c:1061-1082).
180
+ */
181
+ setPageVisible(name: string, visible: boolean): boolean {
182
+ const moved = this._state.setPageVisible(name, visible);
183
+ this._render();
184
+ return moved;
74
185
  }
75
186
 
76
187
  connectedCallback() {
77
- if (this._initialized) return;
78
- this._initialized = true;
188
+ if (this._initialized) {
189
+ // Re-entering a document. `disconnectedCallback` drops the page
190
+ // observer on the way out, and this early return used to skip
191
+ // rebuilding it — so a switcher that had merely been MOVED (a
192
+ // slideshow slide, a client-side route change) stopped noticing
193
+ // page attribute changes for good, and did it silently.
194
+ this._watchPages();
195
+ return;
196
+ }
79
197
 
80
- // Snapshot the declared <adw-view-switcher-page> children before we take
81
- // over the subtree — their name / title / icon-name + their own child
82
- // nodes become the rendered tabs and page bodies.
83
- const declaredPages = Array.from(this.querySelectorAll('adw-view-switcher-page')) as AdwViewSwitcherPage[];
198
+ // `:scope >` rather than a subtree scan: an unscoped query let an outer
199
+ // switcher adopt the pages of a nested one, since both use the same tag.
200
+ // Pages come from the stack's OWN children in C (adw-view-switcher.c:203).
201
+ const declared = Array.from(this.querySelectorAll(':scope > adw-view-switcher-page')) as HTMLElement[];
202
+ // Read the declared selection BEFORE adopting any page: the auto-pick
203
+ // reflects its own index back into this very attribute.
204
+ const declaredActive = this.getAttribute('active');
84
205
 
85
- // The single `viewswitcher` node holds the segmented button bar; a sibling
86
- // content area shows the active page (the web stand-in for the paired
87
- // Adw.ViewStack the switcher would otherwise drive).
206
+ // The single `viewswitcher` node holds the segmented button bar; a
207
+ // sibling content area shows the selected page.
88
208
  this._barEl = document.createElement('div');
89
209
  this._barEl.className = 'adw-view-switcher-bar';
90
210
  this._barEl.setAttribute('role', 'tablist');
@@ -92,107 +212,198 @@ export class AdwViewSwitcher extends HTMLElement {
92
212
  this._contentEl = document.createElement('div');
93
213
  this._contentEl.className = 'adw-view-switcher-content';
94
214
 
95
- this._pages = declaredPages.map((pageEl, index) => {
96
- const name = pageEl.getAttribute('name') ?? `page-${index}`;
97
- const title = pageEl.getAttribute('title') ?? '';
98
- const icon = (pageEl.getAttribute('icon-name') ?? '').replace(/-symbolic$/, '');
99
-
100
- const button = document.createElement('button');
101
- button.type = 'button';
102
- button.className = 'adw-view-switcher-button';
103
- button.setAttribute('role', 'tab');
104
-
105
- const iconEl = document.createElement('span');
106
- iconEl.className = `adw-icon${icon ? ` adw-icon--${icon}` : ''}`;
107
- iconEl.setAttribute('aria-hidden', 'true'); // decorative (mask-image only)
108
- iconEl.hidden = icon.length === 0;
109
-
110
- const labelEl = document.createElement('span');
111
- labelEl.className = 'adw-view-switcher-label';
112
- labelEl.textContent = title;
113
- labelEl.hidden = title.length === 0;
114
-
115
- button.append(iconEl, labelEl);
116
- // An icon-only tab has no visible text — give it an accessible name.
117
- if (icon && !title) button.setAttribute('aria-label', icon);
118
-
119
- button.addEventListener('click', () => this._selectIndex(index));
215
+ this._declared = declared;
216
+ this._watchPages();
217
+ this._nodes = declared.map((pageEl, index) => this._buildPage(pageEl, index));
218
+ this._barEl.append(...this._nodes.map((nodes) => nodes.button));
219
+ this._contentEl.append(...this._nodes.map((nodes) => nodes.body));
220
+ this.replaceChildren(this._barEl, this._contentEl);
120
221
 
121
- const body = document.createElement('div');
122
- body.className = 'adw-view-switcher-page';
123
- body.dataset.name = name;
124
- for (const child of Array.from(pageEl.childNodes)) body.appendChild(child);
222
+ this._policy = this._readPolicyAttr();
223
+ this._initialized = true;
125
224
 
126
- return { name, title, icon, body, button };
127
- });
225
+ this._state.setPages(declared.map((pageEl) => readSwitcherPage(pageEl)));
226
+ if (declaredActive !== null) {
227
+ const index = Number.parseInt(declaredActive, 10);
228
+ // A declared selection is not a user activation.
229
+ if (!Number.isNaN(index)) this._state.setSelected(index, false);
230
+ }
231
+ this._render();
232
+ }
128
233
 
129
- this._barEl.append(...this._pages.map((p) => p.button));
130
- this._contentEl.append(...this._pages.map((p) => p.body));
131
- this.replaceChildren(this._barEl, this._contentEl);
234
+ disconnectedCallback() {
235
+ // A drag that leaves with the element would otherwise switch pages after
236
+ // the widget is gone; C drops the source in dispose the same way.
237
+ this._state.cancelDrag();
238
+ this._pageObserver?.disconnect();
239
+ this._pageObserver = undefined;
240
+ }
132
241
 
133
- this._policy = this._readPolicyAttr();
134
- this._active = this._readActiveAttr();
242
+ /**
243
+ * Re-read every declared page and push it into the state.
244
+ *
245
+ * Called by `AdwViewSwitcherPage.attributeChangedCallback`, which is what
246
+ * makes a runtime `badge-number` change reach the button. `setPages` keeps
247
+ * the selection where it can, so this is a repaint and not a reset.
248
+ */
249
+ refreshPages(): void {
250
+ if (!this._initialized) return;
251
+ this._state.setPages(this._declared.map((pageEl) => readSwitcherPage(pageEl)));
135
252
  this._render();
136
253
  }
137
254
 
255
+ /**
256
+ * Watch the declared pages for attribute changes.
257
+ *
258
+ * A `MutationObserver` rather than an `attributeChangedCallback` on the page:
259
+ * the switcher consumes those elements at connect and `replaceChildren`s them
260
+ * out of the tree, so a detached page cannot find its owner — but an observer
261
+ * bound to it keeps firing. This is the DOM's `notify::` on
262
+ * `AdwViewStackPage`, which the C's `update_button` handler binds per page
263
+ * (adw-view-switcher.c:184-193).
264
+ *
265
+ * It delivers on a MICROTASK where GTK's notify is synchronous. That is the
266
+ * DOM's contract for watching a node, it is invisible to a user, and nothing
267
+ * in this element reads the button back inside the setter.
268
+ */
269
+ private _watchPages(): void {
270
+ this._pageObserver?.disconnect();
271
+ this._pageObserver = new MutationObserver(() => this.refreshPages());
272
+ for (const pageEl of this._declared) this._pageObserver.observe(pageEl, { attributes: true });
273
+ }
274
+
138
275
  attributeChangedCallback(name: string) {
139
276
  if (!this._initialized) return;
140
277
  if (name === 'policy') {
141
278
  const next = this._readPolicyAttr();
142
- if (next !== this._policy) {
143
- this._policy = next;
144
- this._render();
145
- }
279
+ if (next === this._policy) return;
280
+ this._policy = next;
281
+ this._render();
282
+ this.dispatchEvent(new CustomEvent('notify::policy', { bubbles: true, detail: { policy: next } }));
146
283
  return;
147
284
  }
148
285
  if (name === 'active') {
149
- const next = this._readActiveAttr();
150
- if (next !== this._active) {
151
- this._active = next;
152
- this._render();
153
- }
286
+ const raw = this.getAttribute('active');
287
+ const index = raw === null ? Number.NaN : Number.parseInt(raw, 10);
288
+ if (!Number.isNaN(index)) this._state.setSelected(index);
289
+ // A refused request must not leave the attribute claiming something
290
+ // the widget is not showing; re-reflecting is what makes "refused,
291
+ // not clamped" observable in the DOM.
292
+ this._reflectActive();
154
293
  }
155
294
  }
156
295
 
157
- private _readPolicyAttr(): Policy {
158
- return this.getAttribute('policy') === 'wide' ? 'wide' : 'narrow';
296
+ private _buildPage(pageEl: HTMLElement, index: number): PageNodes {
297
+ const button = document.createElement('button');
298
+ button.type = 'button';
299
+ button.className = 'adw-view-switcher-button';
300
+ button.setAttribute('role', 'tab');
301
+
302
+ // Both children always exist, exactly as AdwViewSwitcherButton's template
303
+ // does — the icon carries the `image-missing` fallback rather than
304
+ // disappearing (adw-view-switcher-button.ui).
305
+ const icon = createAdwIcon(null, 'adw-view-switcher-icon');
306
+
307
+ const label = document.createElement('span');
308
+ label.className = 'adw-view-switcher-label';
309
+
310
+ const indicator = document.createElement('span');
311
+ indicator.className = 'adw-view-switcher-indicator';
312
+ indicator.setAttribute('aria-hidden', 'true'); // announced via aria-description
313
+
314
+ button.append(icon, label, indicator);
315
+ button.addEventListener('click', () => {
316
+ this._state.setSelected(index);
317
+ });
318
+ // GtkDropControllerMotion's enter/leave, which drive TIMEOUT_EXPAND.
319
+ //
320
+ // The `relatedTarget` test is the whole point: this button contains an
321
+ // icon and a label, and the DOM fires `dragleave` + `dragenter` when the
322
+ // pointer crosses BETWEEN them. `ViewSwitcherDragSwitch.leave` clears the
323
+ // dwell timer and `enter` re-arms a fresh 500ms, so a drag that merely
324
+ // slid from the icon to the label restarted the countdown and the page
325
+ // never switched. C has one `GtkDropControllerMotion` per BUTTON
326
+ // (adw-view-switcher-button.c:79-96), which has no such internal edges —
327
+ // `relatedTarget` containment is that same "did we really leave" test.
328
+ button.addEventListener('dragenter', (event) => {
329
+ if (button.contains((event as DragEvent).relatedTarget as Node | null)) return;
330
+ this._state.dragEnter(index);
331
+ });
332
+ button.addEventListener('dragleave', (event) => {
333
+ if (button.contains((event as DragEvent).relatedTarget as Node | null)) return;
334
+ this._state.dragLeave(index);
335
+ });
336
+
337
+ const body = document.createElement('div');
338
+ body.className = 'adw-view-switcher-page';
339
+ body.dataset.name = pageEl.getAttribute('name') ?? '';
340
+ for (const child of Array.from(pageEl.childNodes)) body.appendChild(child);
341
+
342
+ return { button, icon, label, indicator, body };
159
343
  }
160
344
 
161
- private _readActiveAttr(): number {
162
- const raw = Number.parseInt(this.getAttribute('active') ?? '0', 10);
163
- if (Number.isNaN(raw)) return 0;
164
- const max = Math.max(0, this._pages.length - 1);
165
- return Math.min(Math.max(raw, 0), max);
345
+ private _readPolicyAttr(): AdwViewSwitcherPolicy {
346
+ const raw = this.getAttribute('policy');
347
+ // A removed attribute falls back to the C default; an unrecognised value
348
+ // is refused and keeps the current policy (adw_view_switcher_set_policy
349
+ // is reached only through the enum, which rejects garbage).
350
+ if (raw === null) return 'narrow';
351
+ return isViewSwitcherPolicy(raw) ? raw : this._policy;
166
352
  }
167
353
 
168
- private _selectIndex(index: number): void {
169
- if (index === this._active) return;
170
- this._active = index;
171
- // Keep the attribute in sync without re-entering the guarded callback
172
- // (value already matches, so attributeChangedCallback no-ops).
173
- this.setAttribute('active', String(index));
354
+ private _onStateChange(change: ViewSwitcherStateChange): void {
174
355
  this._render();
175
- const page = this._pages[index];
176
356
  this.dispatchEvent(
177
357
  new CustomEvent('notify::visible-child', {
178
358
  bubbles: true,
179
- detail: { name: page?.name ?? null, index },
359
+ detail: {
360
+ index: change.selected,
361
+ name: change.name,
362
+ title: change.title,
363
+ interactive: change.interactive,
364
+ },
180
365
  }),
181
366
  );
182
367
  }
183
368
 
184
369
  private _render(): void {
185
- // Single `viewswitcher` node carries the `.wide` / `.narrow` policy class.
186
- this.classList.toggle('wide', this._policy === 'wide');
187
- this.classList.toggle('narrow', this._policy === 'narrow');
188
-
189
- this._pages.forEach((page, index) => {
190
- const isActive = index === this._active;
191
- page.button.classList.toggle('active', isActive);
192
- page.button.setAttribute('aria-selected', String(isActive));
193
- page.button.tabIndex = isActive ? 0 : -1;
194
- page.body.classList.toggle('active-view', isActive);
370
+ if (!this._initialized) return;
371
+
372
+ // The single `viewswitcher` node carries the policy style class
373
+ // (adw-view-switcher.c:539-545).
374
+ for (const policy of VIEW_SWITCHER_POLICIES) this.classList.toggle(policy, policy === this._policy);
375
+
376
+ const models = buildViewSwitcherButtons(this._state.pages, this._state.selected, this._policy);
377
+ models.forEach((model, index) => {
378
+ const nodes = this._nodes[index];
379
+ if (!nodes) return;
380
+
381
+ nodes.button.hidden = !model.visible;
382
+ nodes.button.classList.toggle('active', model.selected);
383
+ nodes.button.setAttribute('aria-selected', String(model.selected));
384
+ nodes.button.tabIndex = model.selected ? 0 : -1;
385
+
386
+ applySwitcherIcon(nodes.icon, model.iconName);
387
+ nodes.label.textContent = model.label;
388
+ nodes.label.hidden = model.label.length === 0;
389
+ applyIndicator(nodes.indicator, model.badgeLabel, model.needsAttention);
390
+ applyDescription(nodes.button, model.description);
391
+
392
+ // A tab with no visible text still needs an accessible name; the
393
+ // icon name is the only thing left to build one from.
394
+ if (model.label) nodes.button.removeAttribute('aria-label');
395
+ else nodes.button.setAttribute('aria-label', model.iconName);
396
+
397
+ nodes.body.classList.toggle('active-view', model.selected);
398
+ nodes.body.hidden = !model.selected;
195
399
  });
400
+
401
+ this._reflectActive();
402
+ }
403
+
404
+ private _reflectActive(): void {
405
+ const current = String(this._state.selected);
406
+ if (this.getAttribute('active') !== current) this.setAttribute('active', current);
196
407
  }
197
408
  }
198
409
 
@@ -1,15 +1,33 @@
1
1
  // <adw-window-title> — A composite title + subtitle for a header bar.
2
2
  // Attributes: title, subtitle.
3
- // Mirrors Adw.WindowTitle: two centered, ellipsized labels stacked vertically;
4
- // the subtitle is hidden when empty.
3
+ // Events: notify::title / notify::subtitle (CustomEvent, mirroring the GObject
4
+ // notify names Adw.WindowTitle emits).
5
+ //
6
+ // The two labels, their visibility and the change-detection are HEADLESS and
7
+ // live in `@gjsify/adwaita-core` (ADR 0004) as {@link WindowTitleState}; this
8
+ // element composes it and keeps only the DOM render half.
9
+ // `@gjsify/adwaita-nativescript` composes the same state, so both ports share one
10
+ // behaviour. Three rules from adw-window-title.c that neither renderer had:
11
+ // - the TITLE label hides when the title is empty (C:207-208; the template even
12
+ // starts it `visible=False`, adw-window-title.ui:15). Only the subtitle was
13
+ // ever hidden here, so a header bar with a subtitle and no title reserved a
14
+ // blank title line above it.
15
+ // - setting the value it already has returns early (C:203-204, :244-245).
16
+ // - a real change notifies (C:210, :251).
17
+ //
5
18
  // Reference: refs/adwaita-web/adwaita-web/docs/widgets/windowtitle.md
6
- // Reference: refs/libadwaita (Adw.WindowTitle)
19
+ // Reference: refs/libadwaita/src/adw-window-title.c, adw-window-title.ui
7
20
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
8
- // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
21
+ // Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
22
+ // title/subtitle state composed from @gjsify/adwaita-core.
23
+
24
+ import { WindowTitleState } from '@gjsify/adwaita-core';
9
25
 
10
26
  export class AdwWindowTitle extends HTMLElement {
11
27
  private _titleEl!: HTMLSpanElement;
12
28
  private _subtitleEl!: HTMLSpanElement;
29
+ /** The headless title/subtitle pair + change detection (ADR 0004). */
30
+ private readonly _state = new WindowTitleState();
13
31
  private _initialized = false;
14
32
 
15
33
  static get observedAttributes() {
@@ -27,21 +45,36 @@ export class AdwWindowTitle extends HTMLElement {
27
45
  this._subtitleEl.className = 'adw-window-title-subtitle';
28
46
 
29
47
  this.replaceChildren(this._titleEl, this._subtitleEl);
48
+ // Adopt the declared attributes without notifying: this is the initial
49
+ // value, not a change (`adw_window_title_new` sets both at construction).
50
+ this._state.setTitle(this.getAttribute('title'));
51
+ this._state.setSubtitle(this.getAttribute('subtitle'));
30
52
  this._render();
31
53
  }
32
54
 
33
- attributeChangedCallback() {
34
- if (this._initialized) this._render();
55
+ attributeChangedCallback(name: string, _old: string | null, value: string | null) {
56
+ if (!this._initialized) return;
57
+ const changed = name === 'title' ? this._state.setTitle(value) : this._state.setSubtitle(value);
58
+ // The early-return is the whole point: re-setting the same value paints
59
+ // nothing and notifies nothing (C:203-204, :244-245).
60
+ if (!changed) return;
61
+ this._render();
62
+ this.dispatchEvent(
63
+ new CustomEvent(`notify::${name}`, {
64
+ bubbles: true,
65
+ detail: { [name]: name === 'title' ? this._state.title : this._state.subtitle },
66
+ }),
67
+ );
35
68
  }
36
69
 
37
70
  private _render() {
38
- const title = this.getAttribute('title') ?? '';
39
- const subtitle = this.getAttribute('subtitle') ?? '';
71
+ const { title, titleVisible, subtitle, subtitleVisible } = this._state.state;
40
72
  this._titleEl.textContent = title;
73
+ this._titleEl.hidden = !titleVisible;
41
74
  this._subtitleEl.textContent = subtitle;
42
- // Hide the subtitle slot entirely when empty so the title stays
43
- // vertically centered (matches Adw.WindowTitle).
44
- this._subtitleEl.hidden = subtitle.length === 0;
75
+ // Hiding the empty label entirely keeps the other one vertically
76
+ // centered, which is what the GTK box does when a child is invisible.
77
+ this._subtitleEl.hidden = !subtitleVisible;
45
78
  }
46
79
  }
47
80