@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
@@ -21,10 +21,13 @@
21
21
  // Reference: refs/adwaita-web/adwaita-web/scss/_toggle_group.scss
22
22
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
23
23
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
24
- // selection state machine composed from @gjsify/adwaita-core.
24
+ // selection state machine composed from @gjsify/adwaita-core, the icon node
25
+ // from <adw-icon>.
25
26
 
26
27
  import { ToggleGroupState } from '@gjsify/adwaita-core';
27
28
 
29
+ import { createAdwIcon } from './adw-icon.js';
30
+
28
31
  /** A single toggle. Children of <adw-toggle-group>; consumed at connect time. */
29
32
  export class AdwToggle extends HTMLElement {
30
33
  static get observedAttributes() {
@@ -70,12 +73,7 @@ export class AdwToggleGroup extends HTMLElement {
70
73
 
71
74
  const label = toggle.getAttribute('label') ?? '';
72
75
  const icon = toggle.getAttribute('icon-name') ?? '';
73
- if (icon) {
74
- const span = document.createElement('span');
75
- span.className = `adw-icon adw-icon--${icon}`;
76
- span.setAttribute('aria-hidden', 'true'); // decorative (mask-image only)
77
- btn.appendChild(span);
78
- }
76
+ if (icon) btn.appendChild(createAdwIcon(icon));
79
77
  if (label) btn.appendChild(document.createTextNode(label));
80
78
 
81
79
  // An icon-only toggle has no text — give it an accessible name.
@@ -1,17 +1,46 @@
1
- // <adw-toolbar-view> — A content area framed by flat top and/or bottom bars
1
+ // <adw-toolbar-view> — A content area framed by top and/or bottom bars
2
2
  // (typically header bars). Mirrors Adw.ToolbarView.
3
3
  // Slots: slot="top" (top bars, stacked), slot="bottom" (bottom bars), and any
4
4
  // remaining children become the scrollable content.
5
- // Reference: refs/adwaita-web/adwaita-web/docs/widgets/toolbarview.md
5
+ // Attributes: top-bar-style / bottom-bar-style (flat | raised | raised-border),
6
+ // extend-content-to-top-edge, extend-content-to-bottom-edge (boolean).
7
+ //
8
+ // The four style classes libadwaita derives — `raised` and `border` on a bar box,
9
+ // `undershoot-top` / `undershoot-bottom` on the view — come from
10
+ // `@gjsify/adwaita-core`'s `toolbarViewClasses`. Neither renderer had any of them,
11
+ // so a toolbar view looked identical in all three styles and the scroll fade under
12
+ // a flat bar was missing entirely.
13
+ //
14
+ // The undershoot decision reads the ALLOCATED bar heights, exactly as
15
+ // `update_undershoots` reads `gtk_widget_get_height (self->top_bar)` — so the
16
+ // classes are recomputed whenever a bar box resizes, not once at connect time.
17
+ // The allocation itself stays with the browser: flexbox is the layout manager
18
+ // here, and `toolbarViewAllocate` is the specification it is measured against
19
+ // (see `@gjsify/adwaita-core/conformance`), not a second layout pass fighting it.
20
+ //
21
+ // Reference: refs/libadwaita/src/adw-toolbar-view.c (update_undershoots, style setters)
6
22
  // Reference: refs/libadwaita/src/stylesheet/widgets/_toolbars.scss
23
+ // Reference: refs/adwaita-web/adwaita-web/docs/widgets/toolbarview.md
7
24
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
8
25
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
9
26
 
27
+ import {
28
+ ADW_TOOLBAR_BAR_CLASSES,
29
+ ADW_TOOLBAR_VIEW_CLASSES,
30
+ parseToolbarStyle,
31
+ toolbarViewClasses,
32
+ } from '@gjsify/adwaita-core';
33
+
10
34
  export class AdwToolbarView extends HTMLElement {
11
35
  private _topEl!: HTMLDivElement;
12
36
  private _contentEl!: HTMLDivElement;
13
37
  private _bottomEl!: HTMLDivElement;
14
38
  private _initialized = false;
39
+ private _resize: ResizeObserver | null = null;
40
+
41
+ static get observedAttributes() {
42
+ return ['top-bar-style', 'bottom-bar-style', 'extend-content-to-top-edge', 'extend-content-to-bottom-edge'];
43
+ }
15
44
 
16
45
  /** The top-bar container — append header bars here imperatively. */
17
46
  get topBar(): HTMLDivElement {
@@ -29,31 +58,65 @@ export class AdwToolbarView extends HTMLElement {
29
58
  }
30
59
 
31
60
  connectedCallback() {
32
- if (this._initialized) return;
33
- this._initialized = true;
61
+ if (!this._initialized) {
62
+ this._initialized = true;
63
+
64
+ const topChildren = Array.from(this.querySelectorAll(':scope > [slot="top"]'));
65
+ const bottomChildren = Array.from(this.querySelectorAll(':scope > [slot="bottom"]'));
66
+ const contentChildren = Array.from(this.childNodes).filter(
67
+ (n) => !topChildren.includes(n as Element) && !bottomChildren.includes(n as Element),
68
+ );
69
+
70
+ this._topEl = document.createElement('div');
71
+ this._topEl.className = 'adw-toolbar-view-top';
72
+ for (const child of topChildren) this._topEl.appendChild(child);
34
73
 
35
- const topChildren = Array.from(this.querySelectorAll(':scope > [slot="top"]'));
36
- const bottomChildren = Array.from(this.querySelectorAll(':scope > [slot="bottom"]'));
37
- const contentChildren = Array.from(this.childNodes).filter(
38
- (n) => !topChildren.includes(n as Element) && !bottomChildren.includes(n as Element),
39
- );
74
+ this._contentEl = document.createElement('div');
75
+ this._contentEl.className = 'adw-toolbar-view-content';
76
+ for (const child of contentChildren) this._contentEl.appendChild(child);
40
77
 
41
- this._topEl = document.createElement('div');
42
- this._topEl.className = 'adw-toolbar-view-top';
43
- for (const child of topChildren) this._topEl.appendChild(child);
78
+ this._bottomEl = document.createElement('div');
79
+ this._bottomEl.className = 'adw-toolbar-view-bottom';
80
+ for (const child of bottomChildren) this._bottomEl.appendChild(child);
44
81
 
45
- this._contentEl = document.createElement('div');
46
- this._contentEl.className = 'adw-toolbar-view-content';
47
- for (const child of contentChildren) this._contentEl.appendChild(child);
82
+ this._topEl.hidden = this._topEl.childElementCount === 0;
83
+ this._bottomEl.hidden = this._bottomEl.childElementCount === 0;
48
84
 
49
- this._bottomEl = document.createElement('div');
50
- this._bottomEl.className = 'adw-toolbar-view-bottom';
51
- for (const child of bottomChildren) this._bottomEl.appendChild(child);
85
+ this.replaceChildren(this._topEl, this._contentEl, this._bottomEl);
86
+ }
87
+
88
+ // `update_undershoots` runs from size_allocate, so the classes have to
89
+ // follow the bars' real heights rather than a one-shot read.
90
+ this._resize = new ResizeObserver(() => this._syncClasses());
91
+ this._resize.observe(this._topEl);
92
+ this._resize.observe(this._bottomEl);
93
+ this._syncClasses();
94
+ }
95
+
96
+ disconnectedCallback() {
97
+ this._resize?.disconnect();
98
+ this._resize = null;
99
+ }
100
+
101
+ attributeChangedCallback() {
102
+ if (this._initialized) this._syncClasses();
103
+ }
52
104
 
53
- this._topEl.hidden = this._topEl.childElementCount === 0;
54
- this._bottomEl.hidden = this._bottomEl.childElementCount === 0;
105
+ private _syncClasses(): void {
106
+ const { view, topBar, bottomBar } = toolbarViewClasses({
107
+ topBarStyle: parseToolbarStyle(this.getAttribute('top-bar-style')),
108
+ bottomBarStyle: parseToolbarStyle(this.getAttribute('bottom-bar-style')),
109
+ extendContentToTopEdge: this.hasAttribute('extend-content-to-top-edge'),
110
+ extendContentToBottomEdge: this.hasAttribute('extend-content-to-bottom-edge'),
111
+ topBarHeight: this._topEl.offsetHeight,
112
+ bottomBarHeight: this._bottomEl.offsetHeight,
113
+ });
55
114
 
56
- this.replaceChildren(this._topEl, this._contentEl, this._bottomEl);
115
+ for (const cls of ADW_TOOLBAR_VIEW_CLASSES) this.classList.toggle(cls, view.includes(cls));
116
+ for (const cls of ADW_TOOLBAR_BAR_CLASSES) {
117
+ this._topEl.classList.toggle(cls, topBar.includes(cls));
118
+ this._bottomEl.classList.toggle(cls, bottomBar.includes(cls));
119
+ }
57
120
  }
58
121
  }
59
122
 
@@ -6,139 +6,168 @@
6
6
  // their own pages (<adw-view-switcher>, <adw-inline-view-switcher>) but no
7
7
  // standalone stack a separate switcher can drive.
8
8
  //
9
- // Pages are declared as <adw-view-stack-page> children with `name`, `title` and
10
- // `icon-name` attributes (their own children become the page body), OR added
11
- // imperatively with `add(content, name, title?, icon?)`. The first page added
12
- // is shown automatically.
9
+ // The SELECTION itself — the name lookup, the guards on an index change, the
10
+ // first-visible-page auto-pick and which paths notify — is HEADLESS and lives in
11
+ // `@gjsify/adwaita-core` (ADR 0004) as {@link ViewStackState}, shared with the
12
+ // NativeScript twin and specced by the conformance vectors in
13
+ // `@gjsify/adwaita-core/conformance`. This element is the DOM half only: it
14
+ // turns declared markup into pages, toggles their visibility, reflects the
15
+ // selection to an attribute, and re-emits the change as a DOM event.
16
+ //
17
+ // Pages are declared as <adw-view-stack-page> children with `name`, `title`,
18
+ // `icon-name` and `hidden` attributes (their own children become the page body),
19
+ // OR added imperatively with `add(content, name, title?, icon?)`. The first
20
+ // VISIBLE page is shown automatically.
13
21
  //
14
22
  // Attributes:
15
23
  // visible-child-name — the name of the page to show. Reading/writing it stays
16
24
  // in sync with the `visibleChildName` property so an adaptive layout can
17
25
  // drive the stack declaratively (mirrors the Adw.ViewStack GObject prop).
26
+ // An EMPTY value is a legal name lookup, not "no name".
18
27
  // Properties (mirroring Adw.ViewStack):
19
- // pages — readonly page descriptors ({ name, title, icon, element }).
20
- // visibleChildName — name of the visible page ('' when empty).
21
- // visibleChildIndex — zero-based index of the visible page (bounds-guarded).
28
+ // pages — readonly page descriptors ({ name, title, icon, content, visible }).
29
+ // visibleChildName — name of the visible page ('' when nothing is selected).
30
+ // visibleChildIndex — zero-based index of the visible page, -1 for none (guarded).
22
31
  // visibleChild — the visible page's content element (or null).
23
32
  // Methods:
24
33
  // add(content, name, title?, icon?) — append a page; returns its descriptor.
25
34
  // addTitled(content, name, title) — Adw.ViewStack.add_titled convenience.
35
+ // removePage(name) — Adw.ViewStack.remove; clears the selection
36
+ // when the removed page was the visible one.
37
+ // setPageVisible(name, visible) — AdwViewStackPage:visible; hiding the visible
38
+ // page falls back to the next visible one.
26
39
  // Events:
27
- // `notify::visible-child` (CustomEvent, bubbles, detail = { index, name, title })
28
- // when the visible page CHANGES — mirrors Adw.ViewStack's visible-child
29
- // notify. It does NOT fire for the initial/auto-shown page or on add().
40
+ // `notify::visible-child` (CustomEvent, bubbles,
41
+ // detail = { index, name, title, interactive }) whenever the visible page
42
+ // changes — including the auto-shown first page, exactly as
43
+ // `set_visible_child` notifies there (adw-view-stack.c:1038-1039). It used to
44
+ // stay silent on that path, which is why a bound switcher needed a manual
45
+ // `refresh()`. `interactive` is false for an auto-pick.
30
46
  // Reference: refs/libadwaita/src/adw-view-stack.c (AdwViewStack behaviour)
31
47
  // Reference: packages/nativescript-bridge/adwaita/src/widgets/adw-view-stack.ts (NS twin)
32
48
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
33
49
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
34
50
 
51
+ import { ViewStackState } from '@gjsify/adwaita-core';
52
+ import type { AdwViewStackPageInfo as CorePageInfo, ViewStackStateChange } from '@gjsify/adwaita-core';
53
+
35
54
  /** A page descriptor exposed via `pages` (consumed by a bound switcher bar). */
36
- export interface AdwViewStackPageInfo {
37
- /** Stable id used by visibleChildName + switcher binding. */
38
- name: string;
39
- /** Human-facing title shown by a bound switcher. */
40
- title: string;
41
- /** Symbolic icon name (no `-symbolic` suffix), or '' for none. */
42
- icon: string;
43
- /** The page's content container (the visibility-toggled node). */
44
- element: HTMLElement;
45
- }
55
+ export type AdwViewStackPageInfo = CorePageInfo<HTMLElement>;
46
56
 
47
57
  export class AdwViewStack extends HTMLElement {
48
- private _pages: AdwViewStackPageInfo[] = [];
49
- private _visibleIndex = 0;
58
+ private readonly _state = new ViewStackState<HTMLElement>();
50
59
  private _initialized = false;
51
60
 
52
61
  static get observedAttributes() {
53
62
  return ['visible-child-name'];
54
63
  }
55
64
 
65
+ constructor() {
66
+ super();
67
+ // Subscribing touches no attributes and no children, so it is legal in a
68
+ // custom element constructor — unlike anything DOM-shaped.
69
+ this._state.subscribe((change) => this._onStateChange(change));
70
+ }
71
+
56
72
  /** All pages in declaration/add order. A bound switcher reads this. */
57
73
  get pages(): readonly AdwViewStackPageInfo[] {
58
- return this._pages;
74
+ return this._state.pages;
59
75
  }
60
76
 
61
- /** Zero-based index of the visible page. Out-of-range/same values no-op. */
77
+ /** Zero-based index of the visible page, `-1` when nothing is selected. */
62
78
  get visibleChildIndex(): number {
63
- return this._visibleIndex;
79
+ return this._state.visibleIndex;
64
80
  }
65
81
 
66
82
  set visibleChildIndex(value: number) {
67
- if (!Number.isFinite(value) || value < 0 || value >= this._pages.length) return;
68
- if (value === this._visibleIndex) return;
69
- this._visibleIndex = value;
70
- this._apply(true);
83
+ this._state.setVisibleIndex(value);
71
84
  }
72
85
 
73
- /** Name of the visible page, or '' when the stack is empty. */
86
+ /** Name of the visible page, or '' when nothing is selected. */
74
87
  get visibleChildName(): string {
75
- return this._pages[this._visibleIndex]?.name ?? '';
88
+ return this._state.visibleName;
76
89
  }
77
90
 
78
- set visibleChildName(name: string) {
79
- const index = this._pages.findIndex((p) => p.name === name);
80
- if (index >= 0) this.visibleChildIndex = index;
91
+ set visibleChildName(name: string | null | undefined) {
92
+ this._state.setVisibleName(name);
81
93
  }
82
94
 
83
- /** The visible page's content element, or null when empty. */
95
+ /** The visible page's content element, or null when nothing is selected. */
84
96
  get visibleChild(): HTMLElement | null {
85
- return this._pages[this._visibleIndex]?.element ?? null;
97
+ return this._state.visiblePage?.content ?? null;
86
98
  }
87
99
 
88
100
  connectedCallback() {
89
101
  if (this._initialized) return;
90
102
  this._initialized = true;
91
103
 
92
- // Snapshot the declared <adw-view-stack-page> children before we take
93
- // over the subtree — their name / title / icon-name + child nodes become
94
- // the pages. Wrapping each page's content lets us toggle exactly one.
95
- const declared = Array.from(this.querySelectorAll(':scope > adw-view-stack-page')) as HTMLElement[];
96
- const wrappers = declared.map((pageEl, index) => {
97
- const name = pageEl.getAttribute('name') ?? `page-${index}`;
98
- const title = pageEl.getAttribute('title') ?? name;
99
- const icon = (pageEl.getAttribute('icon-name') ?? '').replace(/-symbolic$/, '');
104
+ // Read the declared selection BEFORE adopting any page: the auto-pick
105
+ // reflects its own name back into this very attribute, which would
106
+ // otherwise overwrite the author's value with page 0's name.
107
+ const initialName = this.getAttribute('visible-child-name');
108
+
109
+ // Adopt the declared <adw-view-stack-page> children by APPENDING them —
110
+ // never by replacing the page list. The old code assigned the parsed
111
+ // wrappers wholesale and then `replaceChildren()`d the subtree away, so a
112
+ // stack built imperatively before it entered the document
113
+ // (`createElement` → `add()` → `body.append()`) lost every page it had
114
+ // been given. libadwaita has no parse-once phase that can discard
115
+ // children: the buildable adds them one at a time
116
+ // (adw_view_stack_buildable_add_child, adw-view-stack.c:1666-1680) and
117
+ // `adw_view_stack_add` works at any point in the widget's life.
118
+ for (const pageEl of Array.from(this.querySelectorAll(':scope > adw-view-stack-page')) as HTMLElement[]) {
119
+ // A declared page with no `name` is the page named '' — C stores the
120
+ // name verbatim (adw-view-stack.c:1170), and an invented `page-<index>`
121
+ // is addressable by a name the author never wrote.
122
+ const name = pageEl.getAttribute('name') ?? '';
123
+ // Wrapping each page's content lets us toggle exactly one.
100
124
  const element = document.createElement('div');
101
125
  element.className = 'adw-view-stack-child';
102
126
  element.dataset.name = name;
103
127
  for (const child of Array.from(pageEl.childNodes)) element.appendChild(child);
104
- return { name, title, icon, element };
105
- });
106
128
 
107
- this._pages = wrappers;
108
- this.replaceChildren(...wrappers.map((p) => p.element));
129
+ this._state.addPage({
130
+ name,
131
+ title: pageEl.getAttribute('title'),
132
+ icon: pageEl.getAttribute('icon-name'),
133
+ content: element,
134
+ // `hidden` is the DOM spelling of AdwViewStackPage:visible, which
135
+ // gates the auto-pick and refuses selection (adw-view-stack.c:1149-1151, :2415).
136
+ visible: !pageEl.hasAttribute('hidden'),
137
+ });
138
+ this.replaceChild(element, pageEl);
139
+ }
109
140
 
110
- // A declared `visible-child-name` selects the initial page; otherwise the
111
- // first page is auto-visible (matching Adw.ViewStack + the NS twin).
112
- const initialName = this.getAttribute('visible-child-name');
113
- const initialIndex = initialName ? this._pages.findIndex((p) => p.name === initialName) : -1;
114
- this._visibleIndex = initialIndex >= 0 ? initialIndex : 0;
115
- this._apply(false);
141
+ // An explicit `visible-child-name` selects the initial page. Only a
142
+ // MISSING attribute falls through to the auto-pick — an empty value is a
143
+ // legal lookup for a page named '' (g_strcmp0, adw-view-stack.c:901),
144
+ // which the old `initialName ? … : -1` falsy check silently dropped.
145
+ if (initialName !== null) this._state.setVisibleName(initialName, false);
146
+ this._applyVisibility();
147
+ this._reflectName();
116
148
  }
117
149
 
118
150
  attributeChangedCallback(name: string, _old: string | null, value: string | null) {
119
151
  if (!this._initialized) return;
120
- if (name === 'visible-child-name' && value !== null && value !== this.visibleChildName) {
121
- this.visibleChildName = value;
152
+ // Only a REMOVAL is ignored (there is no name to select). An empty value
153
+ // goes through the same lookup as the property setter — the declarative
154
+ // and imperative paths must not disagree on the same input.
155
+ if (name === 'visible-child-name' && value !== null && value !== this._state.visibleName) {
156
+ this._state.setVisibleName(value);
122
157
  }
123
158
  }
124
159
 
125
160
  /**
126
- * Append a page. The FIRST page added becomes visible automatically; later
127
- * pages are added hidden. Mirrors Adw.ViewStack.add()/the NS twin's add().
161
+ * Append a page. It becomes visible when nothing is selected yet, matching
162
+ * `Adw.ViewStack.add()` — and, unlike before, that auto-pick NOTIFIES.
128
163
  */
129
164
  add(content: HTMLElement, name: string, title?: string, icon?: string): AdwViewStackPageInfo {
130
165
  content.classList.add('adw-view-stack-child');
131
166
  content.dataset.name = name;
132
- const page: AdwViewStackPageInfo = {
133
- name,
134
- title: title ?? name,
135
- icon: (icon ?? '').replace(/-symbolic$/, ''),
136
- element: content,
137
- };
138
- this._pages.push(page);
167
+ const page = this._state.addPage({ name, title, icon, content });
139
168
  this.appendChild(content);
140
- // Keep exactly one page shown without emitting (add is not a user change).
141
- this._apply(false);
169
+ this._applyVisibility();
170
+ this._reflectName();
142
171
  return page;
143
172
  }
144
173
 
@@ -147,26 +176,74 @@ export class AdwViewStack extends HTMLElement {
147
176
  return this.add(content, name, title);
148
177
  }
149
178
 
150
- private _apply(emit: boolean): void {
151
- this._pages.forEach((page, index) => {
152
- const isVisible = index === this._visibleIndex;
153
- page.element.classList.toggle('active-view', isVisible);
154
- page.element.hidden = !isVisible;
179
+ /**
180
+ * Remove the first page named `name` and detach its content. Returns whether
181
+ * anything was removed. When the removed page was the visible one the stack
182
+ * ends up showing NOTHING and emits no event — `stack_remove` clears
183
+ * `visible_child` without re-picking (adw-view-stack.c:1202-1203).
184
+ */
185
+ removePage(name: string): boolean {
186
+ const content = this._state.pages[this._state.indexOfName(name)]?.content;
187
+ if (!this._state.removePage(name)) return false;
188
+ content?.remove();
189
+ this._applyVisibility();
190
+ this._reflectName();
191
+ return true;
192
+ }
193
+
194
+ /**
195
+ * Show or hide a page (`AdwViewStackPage:visible`). Returns whether the
196
+ * SELECTION moved: hiding the visible page falls back to the next visible
197
+ * one, and making a page visible while nothing is selected selects it
198
+ * (`update_child_visible`, adw-view-stack.c:1061-1082).
199
+ */
200
+ setPageVisible(name: string, visible: boolean): boolean {
201
+ const moved = this._state.setPageVisible(name, visible);
202
+ this._applyVisibility();
203
+ return moved;
204
+ }
205
+
206
+ private _onStateChange(change: ViewStackStateChange<HTMLElement>): void {
207
+ this._applyVisibility();
208
+ this._reflectName();
209
+ this.dispatchEvent(
210
+ new CustomEvent('notify::visible-child', {
211
+ bubbles: true,
212
+ detail: {
213
+ index: change.index,
214
+ name: change.name,
215
+ title: change.title,
216
+ interactive: change.interactive,
217
+ },
218
+ }),
219
+ );
220
+ }
221
+
222
+ private _applyVisibility(): void {
223
+ const selected = this._state.visibleIndex;
224
+ this._state.pages.forEach((page, index) => {
225
+ const element = page.content;
226
+ if (!element) return;
227
+ const isVisible = index === selected;
228
+ element.classList.toggle('active-view', isVisible);
229
+ element.hidden = !isVisible;
155
230
  });
156
- // Reflect the current selection to the attribute (without re-entering the
157
- // guarded callback — the value already matches, so it no-ops).
158
- const current = this.visibleChildName;
159
- if (current && this.getAttribute('visible-child-name') !== current) {
160
- this.setAttribute('visible-child-name', current);
231
+ }
232
+
233
+ private _reflectName(): void {
234
+ // With nothing selected there is no name to mirror — C's
235
+ // `adw_view_stack_get_visible_child_name` returns NULL there
236
+ // (adw-view-stack.c:2384), and a missing attribute is the DOM spelling of
237
+ // that. An EMPTY name is still a name and IS reflected: the old
238
+ // `if (current && …)` guard meant a stack of nameless pages never
239
+ // reflected its selection at all.
240
+ if (this._state.visibleIndex < 0) {
241
+ this.removeAttribute('visible-child-name');
242
+ return;
161
243
  }
162
- if (emit) {
163
- const page = this._pages[this._visibleIndex];
164
- this.dispatchEvent(
165
- new CustomEvent('notify::visible-child', {
166
- bubbles: true,
167
- detail: { index: this._visibleIndex, name: page?.name ?? '', title: page?.title ?? '' },
168
- }),
169
- );
244
+ const current = this._state.visibleName;
245
+ if (this.getAttribute('visible-child-name') !== current) {
246
+ this.setAttribute('visible-child-name', current);
170
247
  }
171
248
  }
172
249
  }