@gjsify/adwaita-web 0.31.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
@@ -1,90 +1,170 @@
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
+ // Aliased because `AdwViewSwitcherPage` is already this module's page ELEMENT —
60
+ // the custom element the package has always exported under that name.
61
+ import type {
62
+ AdwViewSwitcherPage as AdwViewSwitcherPageInfo,
63
+ AdwViewSwitcherPolicy,
64
+ ViewSwitcherStateChange,
65
+ } from '@gjsify/adwaita-core';
25
66
 
26
- interface PageInfo {
27
- name: string;
28
- title: string;
29
- icon: string;
30
- body: HTMLElement;
67
+ import {
68
+ applyDescription,
69
+ applyIndicator,
70
+ applySwitcherIcon,
71
+ domViewSwitcherScheduler,
72
+ readSwitcherPage,
73
+ } from './view-switcher-dom.js';
74
+ import { type AdwIcon, createAdwIcon } from './adw-icon.js';
75
+
76
+ /** The DOM nodes one page owns — the switcher paints the derived model onto them. */
77
+ interface PageNodes {
31
78
  button: HTMLButtonElement;
79
+ icon: AdwIcon;
80
+ label: HTMLSpanElement;
81
+ indicator: HTMLSpanElement;
82
+ body: HTMLDivElement;
32
83
  }
33
84
 
34
85
  /** A single page. Children of <adw-view-switcher>; consumed at connect time. */
35
86
  export class AdwViewSwitcherPage extends HTMLElement {
36
87
  static get observedAttributes() {
37
- return ['name', 'title', 'icon-name'];
88
+ return ['name', 'title', 'icon-name', 'badge-number', 'needs-attention', 'use-underline'];
38
89
  }
39
90
  }
40
91
 
41
92
  export class AdwViewSwitcher extends HTMLElement {
93
+ private readonly _state = new ViewSwitcherState({ scheduler: domViewSwitcherScheduler });
42
94
  private _barEl!: HTMLDivElement;
43
95
  private _contentEl!: HTMLDivElement;
44
- private _pages: PageInfo[] = [];
45
- private _active = 0;
46
- private _policy: Policy = 'narrow';
96
+ private _nodes: PageNodes[] = [];
97
+ private _policy: AdwViewSwitcherPolicy = 'narrow';
47
98
  private _initialized = false;
48
99
 
49
100
  static get observedAttributes() {
50
101
  return ['policy', 'active'];
51
102
  }
52
103
 
104
+ constructor() {
105
+ super();
106
+ // Subscribing touches no attributes and no children, so it is legal in a
107
+ // custom element constructor — unlike anything DOM-shaped.
108
+ this._state.subscribe((change) => this._onStateChange(change));
109
+ }
110
+
53
111
  /** The policy controlling the button layout ('wide' | 'narrow'). */
54
- get policy(): Policy {
112
+ get policy(): AdwViewSwitcherPolicy {
55
113
  return this._policy;
56
114
  }
57
115
 
58
- set policy(value: Policy) {
116
+ set policy(value: AdwViewSwitcherPolicy) {
59
117
  this.setAttribute('policy', value);
60
118
  }
61
119
 
62
- /** Zero-based index of the active page. */
120
+ /** Zero-based index of the selected page, `-1` when nothing is selected. */
63
121
  get active(): number {
64
- return this._active;
122
+ return this._state.selected;
65
123
  }
66
124
 
67
125
  set active(value: number) {
68
- this.setAttribute('active', String(value));
126
+ this._state.setSelected(value);
127
+ this._reflectActive();
69
128
  }
70
129
 
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;
130
+ /** Name of the selected page, `''` when nothing is selected. */
131
+ get visibleChildName(): string {
132
+ return this._state.selectedName;
133
+ }
134
+
135
+ set visibleChildName(name: string | null | undefined) {
136
+ this._state.selectName(name);
137
+ }
138
+
139
+ /** The resolved page records a bound layout can read. */
140
+ get pages(): readonly AdwViewSwitcherPageInfo[] {
141
+ return this._state.pages;
142
+ }
143
+
144
+ /**
145
+ * Show or hide a page (`AdwViewStackPage:visible`). Returns whether the
146
+ * SELECTION moved — hiding the selected page falls back to the first still
147
+ * visible one (adw-view-stack.c:1061-1082).
148
+ */
149
+ setPageVisible(name: string, visible: boolean): boolean {
150
+ const moved = this._state.setPageVisible(name, visible);
151
+ this._render();
152
+ return moved;
74
153
  }
75
154
 
76
155
  connectedCallback() {
77
156
  if (this._initialized) return;
78
- this._initialized = true;
79
157
 
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[];
158
+ // `:scope >` rather than a subtree scan: an unscoped query let an outer
159
+ // switcher adopt the pages of a nested one, since both use the same tag.
160
+ // Pages come from the stack's OWN children in C (adw-view-switcher.c:203).
161
+ const declared = Array.from(this.querySelectorAll(':scope > adw-view-switcher-page')) as HTMLElement[];
162
+ // Read the declared selection BEFORE adopting any page: the auto-pick
163
+ // reflects its own index back into this very attribute.
164
+ const declaredActive = this.getAttribute('active');
84
165
 
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).
166
+ // The single `viewswitcher` node holds the segmented button bar; a
167
+ // sibling content area shows the selected page.
88
168
  this._barEl = document.createElement('div');
89
169
  this._barEl.className = 'adw-view-switcher-bar';
90
170
  this._barEl.setAttribute('role', 'tablist');
@@ -92,107 +172,146 @@ export class AdwViewSwitcher extends HTMLElement {
92
172
  this._contentEl = document.createElement('div');
93
173
  this._contentEl.className = 'adw-view-switcher-content';
94
174
 
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));
120
-
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);
125
-
126
- return { name, title, icon, body, button };
127
- });
128
-
129
- this._barEl.append(...this._pages.map((p) => p.button));
130
- this._contentEl.append(...this._pages.map((p) => p.body));
175
+ this._nodes = declared.map((pageEl, index) => this._buildPage(pageEl, index));
176
+ this._barEl.append(...this._nodes.map((nodes) => nodes.button));
177
+ this._contentEl.append(...this._nodes.map((nodes) => nodes.body));
131
178
  this.replaceChildren(this._barEl, this._contentEl);
132
179
 
133
180
  this._policy = this._readPolicyAttr();
134
- this._active = this._readActiveAttr();
181
+ this._initialized = true;
182
+
183
+ this._state.setPages(declared.map((pageEl) => readSwitcherPage(pageEl)));
184
+ if (declaredActive !== null) {
185
+ const index = Number.parseInt(declaredActive, 10);
186
+ // A declared selection is not a user activation.
187
+ if (!Number.isNaN(index)) this._state.setSelected(index, false);
188
+ }
135
189
  this._render();
136
190
  }
137
191
 
192
+ disconnectedCallback() {
193
+ // A drag that leaves with the element would otherwise switch pages after
194
+ // the widget is gone; C drops the source in dispose the same way.
195
+ this._state.cancelDrag();
196
+ }
197
+
138
198
  attributeChangedCallback(name: string) {
139
199
  if (!this._initialized) return;
140
200
  if (name === 'policy') {
141
201
  const next = this._readPolicyAttr();
142
- if (next !== this._policy) {
143
- this._policy = next;
144
- this._render();
145
- }
202
+ if (next === this._policy) return;
203
+ this._policy = next;
204
+ this._render();
205
+ this.dispatchEvent(new CustomEvent('notify::policy', { bubbles: true, detail: { policy: next } }));
146
206
  return;
147
207
  }
148
208
  if (name === 'active') {
149
- const next = this._readActiveAttr();
150
- if (next !== this._active) {
151
- this._active = next;
152
- this._render();
153
- }
209
+ const raw = this.getAttribute('active');
210
+ const index = raw === null ? Number.NaN : Number.parseInt(raw, 10);
211
+ if (!Number.isNaN(index)) this._state.setSelected(index);
212
+ // A refused request must not leave the attribute claiming something
213
+ // the widget is not showing; re-reflecting is what makes "refused,
214
+ // not clamped" observable in the DOM.
215
+ this._reflectActive();
154
216
  }
155
217
  }
156
218
 
157
- private _readPolicyAttr(): Policy {
158
- return this.getAttribute('policy') === 'wide' ? 'wide' : 'narrow';
219
+ private _buildPage(pageEl: HTMLElement, index: number): PageNodes {
220
+ const button = document.createElement('button');
221
+ button.type = 'button';
222
+ button.className = 'adw-view-switcher-button';
223
+ button.setAttribute('role', 'tab');
224
+
225
+ // Both children always exist, exactly as AdwViewSwitcherButton's template
226
+ // does — the icon carries the `image-missing` fallback rather than
227
+ // disappearing (adw-view-switcher-button.ui).
228
+ const icon = createAdwIcon(null, 'adw-view-switcher-icon');
229
+
230
+ const label = document.createElement('span');
231
+ label.className = 'adw-view-switcher-label';
232
+
233
+ const indicator = document.createElement('span');
234
+ indicator.className = 'adw-view-switcher-indicator';
235
+ indicator.setAttribute('aria-hidden', 'true'); // announced via aria-description
236
+
237
+ button.append(icon, label, indicator);
238
+ button.addEventListener('click', () => {
239
+ this._state.setSelected(index);
240
+ });
241
+ // GtkDropControllerMotion's enter/leave, which drive TIMEOUT_EXPAND.
242
+ button.addEventListener('dragenter', () => this._state.dragEnter(index));
243
+ button.addEventListener('dragleave', () => this._state.dragLeave(index));
244
+
245
+ const body = document.createElement('div');
246
+ body.className = 'adw-view-switcher-page';
247
+ body.dataset.name = pageEl.getAttribute('name') ?? '';
248
+ for (const child of Array.from(pageEl.childNodes)) body.appendChild(child);
249
+
250
+ return { button, icon, label, indicator, body };
159
251
  }
160
252
 
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);
253
+ private _readPolicyAttr(): AdwViewSwitcherPolicy {
254
+ const raw = this.getAttribute('policy');
255
+ // A removed attribute falls back to the C default; an unrecognised value
256
+ // is refused and keeps the current policy (adw_view_switcher_set_policy
257
+ // is reached only through the enum, which rejects garbage).
258
+ if (raw === null) return 'narrow';
259
+ return isViewSwitcherPolicy(raw) ? raw : this._policy;
166
260
  }
167
261
 
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));
262
+ private _onStateChange(change: ViewSwitcherStateChange): void {
174
263
  this._render();
175
- const page = this._pages[index];
176
264
  this.dispatchEvent(
177
265
  new CustomEvent('notify::visible-child', {
178
266
  bubbles: true,
179
- detail: { name: page?.name ?? null, index },
267
+ detail: {
268
+ index: change.selected,
269
+ name: change.name,
270
+ title: change.title,
271
+ interactive: change.interactive,
272
+ },
180
273
  }),
181
274
  );
182
275
  }
183
276
 
184
277
  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);
278
+ if (!this._initialized) return;
279
+
280
+ // The single `viewswitcher` node carries the policy style class
281
+ // (adw-view-switcher.c:539-545).
282
+ for (const policy of VIEW_SWITCHER_POLICIES) this.classList.toggle(policy, policy === this._policy);
283
+
284
+ const models = buildViewSwitcherButtons(this._state.pages, this._state.selected, this._policy);
285
+ models.forEach((model, index) => {
286
+ const nodes = this._nodes[index];
287
+ if (!nodes) return;
288
+
289
+ nodes.button.hidden = !model.visible;
290
+ nodes.button.classList.toggle('active', model.selected);
291
+ nodes.button.setAttribute('aria-selected', String(model.selected));
292
+ nodes.button.tabIndex = model.selected ? 0 : -1;
293
+
294
+ applySwitcherIcon(nodes.icon, model.iconName);
295
+ nodes.label.textContent = model.label;
296
+ nodes.label.hidden = model.label.length === 0;
297
+ applyIndicator(nodes.indicator, model.badgeLabel, model.needsAttention);
298
+ applyDescription(nodes.button, model.description);
299
+
300
+ // A tab with no visible text still needs an accessible name; the
301
+ // icon name is the only thing left to build one from.
302
+ if (model.label) nodes.button.removeAttribute('aria-label');
303
+ else nodes.button.setAttribute('aria-label', model.iconName);
304
+
305
+ nodes.body.classList.toggle('active-view', model.selected);
306
+ nodes.body.hidden = !model.selected;
195
307
  });
308
+
309
+ this._reflectActive();
310
+ }
311
+
312
+ private _reflectActive(): void {
313
+ const current = String(this._state.selected);
314
+ if (this.getAttribute('active') !== current) this.setAttribute('active', current);
196
315
  }
197
316
  }
198
317
 
@@ -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