@gjsify/adwaita-web 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/dist/adwaita-web.css +786 -272
  2. package/dist/adwaita-web.css.map +1 -1
  3. package/lib/types/adw-about-dialog.spec.d.ts +1 -0
  4. package/lib/types/adw-action-rows.spec.d.ts +1 -0
  5. package/lib/types/adw-avatar.spec.d.ts +1 -0
  6. package/lib/types/adw-banner.spec.d.ts +1 -0
  7. package/lib/types/adw-button-content.spec.d.ts +1 -0
  8. package/lib/types/adw-carousel.spec.d.ts +1 -0
  9. package/lib/types/adw-checks.spec.d.ts +1 -0
  10. package/lib/types/adw-header-bar.spec.d.ts +1 -0
  11. package/lib/types/adw-icon.spec.d.ts +1 -0
  12. package/lib/types/adw-progress-bar.spec.d.ts +1 -0
  13. package/lib/types/adw-sidebar.spec.d.ts +1 -0
  14. package/lib/types/adw-switch.spec.d.ts +1 -0
  15. package/lib/types/adw-wrap-box.spec.d.ts +1 -0
  16. package/lib/types/bottom-sheet.spec.d.ts +1 -0
  17. package/lib/types/breakpoints.d.ts +22 -0
  18. package/lib/types/breakpoints.spec.d.ts +1 -0
  19. package/lib/types/chrome.spec.d.ts +1 -0
  20. package/lib/types/elements/adw-about-dialog.d.ts +47 -8
  21. package/lib/types/elements/adw-action-row.d.ts +35 -0
  22. package/lib/types/elements/adw-avatar.d.ts +11 -0
  23. package/lib/types/elements/adw-bottom-sheet.d.ts +33 -4
  24. package/lib/types/elements/adw-button-content.d.ts +12 -0
  25. package/lib/types/elements/adw-button-row.d.ts +10 -1
  26. package/lib/types/elements/adw-carousel.d.ts +111 -8
  27. package/lib/types/elements/adw-checks.d.ts +57 -0
  28. package/lib/types/elements/adw-clamp.d.ts +4 -1
  29. package/lib/types/elements/adw-data-grid.d.ts +11 -31
  30. package/lib/types/elements/adw-drop-down.d.ts +17 -20
  31. package/lib/types/elements/adw-entry-row.d.ts +93 -3
  32. package/lib/types/elements/adw-expander-row.d.ts +7 -1
  33. package/lib/types/elements/adw-header-bar.d.ts +15 -0
  34. package/lib/types/elements/adw-icon.d.ts +25 -0
  35. package/lib/types/elements/adw-inline-view-switcher.d.ts +37 -11
  36. package/lib/types/elements/adw-menu-button.d.ts +6 -6
  37. package/lib/types/elements/adw-navigation-split-view.d.ts +6 -0
  38. package/lib/types/elements/adw-navigation-view.d.ts +63 -20
  39. package/lib/types/elements/adw-overlay-split-view.d.ts +38 -0
  40. package/lib/types/elements/adw-password-entry-row.d.ts +19 -10
  41. package/lib/types/elements/adw-popover.d.ts +58 -0
  42. package/lib/types/elements/adw-preferences-dialog.d.ts +57 -2
  43. package/lib/types/elements/adw-preferences-group.d.ts +31 -0
  44. package/lib/types/elements/adw-progress-bar.d.ts +38 -0
  45. package/lib/types/elements/adw-sidebar.d.ts +37 -5
  46. package/lib/types/elements/adw-split-button.d.ts +53 -7
  47. package/lib/types/elements/adw-switch-row.d.ts +11 -2
  48. package/lib/types/elements/adw-switch.d.ts +15 -0
  49. package/lib/types/elements/adw-tab-view.d.ts +129 -12
  50. package/lib/types/elements/adw-toolbar-view.d.ts +5 -0
  51. package/lib/types/elements/adw-view-stack.d.ts +27 -19
  52. package/lib/types/elements/adw-view-switcher-bar.d.ts +23 -7
  53. package/lib/types/elements/adw-view-switcher.d.ts +22 -11
  54. package/lib/types/elements/adw-window-title.d.ts +3 -1
  55. package/lib/types/elements/view-switcher-dom.d.ts +44 -0
  56. package/lib/types/entry-rows.spec.d.ts +1 -0
  57. package/lib/types/index.d.ts +7 -0
  58. package/lib/types/navigation-view.spec.d.ts +1 -0
  59. package/lib/types/preferences-search.d.ts +20 -0
  60. package/lib/types/preferences.spec.d.ts +1 -0
  61. package/lib/types/split-button.spec.d.ts +1 -0
  62. package/lib/types/split-views.spec.d.ts +1 -0
  63. package/lib/types/styles.generated.d.ts +1 -1
  64. package/lib/types/tab-view.spec.d.ts +1 -0
  65. package/lib/types/view-stack.spec.d.ts +1 -0
  66. package/lib/types/view-switcher.spec.d.ts +1 -0
  67. package/package.json +6 -6
  68. package/scss/_banner.scss +13 -0
  69. package/scss/_bottom_sheet.scss +5 -6
  70. package/scss/_button.scss +3 -26
  71. package/scss/_button_content.scss +28 -0
  72. package/scss/_checks.scss +228 -0
  73. package/scss/_clamp.scss +9 -3
  74. package/scss/_data_grid.scss +15 -7
  75. package/scss/_drop_down.scss +12 -55
  76. package/scss/_expander_row.scss +5 -50
  77. package/scss/_icon.scss +51 -0
  78. package/scss/_icons.generated.scss +6 -0
  79. package/scss/_inline_view_switcher.scss +34 -0
  80. package/scss/_labels.scss +135 -0
  81. package/scss/_menu_button.scss +10 -70
  82. package/scss/_popover.scss +186 -0
  83. package/scss/_preferences.scss +29 -0
  84. package/scss/_preferences_dialog.scss +17 -0
  85. package/scss/_progress_bar.scss +161 -0
  86. package/scss/_reset.scss +18 -7
  87. package/scss/_spinner.scss +7 -3
  88. package/scss/_split_button.scss +6 -50
  89. package/scss/_switch.scss +90 -0
  90. package/scss/_switch_row.scss +6 -52
  91. package/scss/_tab_view.scss +64 -0
  92. package/scss/_theme.scss +10 -0
  93. package/scss/_toolbar_view.scss +74 -0
  94. package/scss/_variables.scss +34 -0
  95. package/scss/_view_switcher.scss +40 -0
  96. package/scss/_view_switcher_bar.scss +37 -0
  97. package/scss/_wrap_box.scss +56 -6
  98. package/scss/adwaita-skin.scss +23 -0
  99. package/src/adw-about-dialog.spec.ts +350 -0
  100. package/src/adw-action-rows.spec.ts +342 -0
  101. package/src/adw-avatar.spec.ts +139 -0
  102. package/src/adw-banner.spec.ts +152 -0
  103. package/src/adw-button-content.spec.ts +183 -0
  104. package/src/adw-button-row.spec.ts +12 -6
  105. package/src/adw-carousel.spec.ts +389 -0
  106. package/src/adw-checks.spec.ts +368 -0
  107. package/src/adw-drop-down.spec.ts +123 -0
  108. package/src/adw-header-bar.spec.ts +83 -0
  109. package/src/adw-icon.spec.ts +167 -0
  110. package/src/adw-progress-bar.spec.ts +173 -0
  111. package/src/adw-sidebar.spec.ts +332 -0
  112. package/src/adw-switch.spec.ts +242 -0
  113. package/src/adw-tab-view.spec.ts +12 -3
  114. package/src/adw-view-switcher-bar.spec.ts +61 -13
  115. package/src/adw-wrap-box.spec.ts +194 -0
  116. package/src/bottom-sheet.spec.ts +325 -0
  117. package/src/breakpoints.spec.ts +147 -0
  118. package/src/breakpoints.ts +67 -0
  119. package/src/chrome.spec.ts +301 -0
  120. package/src/elements/adw-about-dialog.ts +223 -163
  121. package/src/elements/adw-action-row.ts +120 -10
  122. package/src/elements/adw-avatar.ts +48 -54
  123. package/src/elements/adw-banner.ts +46 -10
  124. package/src/elements/adw-bottom-sheet.ts +145 -60
  125. package/src/elements/adw-button-content.ts +107 -33
  126. package/src/elements/adw-button-row.ts +61 -37
  127. package/src/elements/adw-button.ts +5 -7
  128. package/src/elements/adw-carousel.ts +467 -105
  129. package/src/elements/adw-checks.ts +299 -0
  130. package/src/elements/adw-clamp.ts +80 -10
  131. package/src/elements/adw-combo-row.ts +15 -7
  132. package/src/elements/adw-data-grid.ts +43 -91
  133. package/src/elements/adw-drop-down.ts +117 -128
  134. package/src/elements/adw-entry-row.ts +404 -36
  135. package/src/elements/adw-expander-row.ts +61 -29
  136. package/src/elements/adw-header-bar.ts +31 -6
  137. package/src/elements/adw-icon.ts +148 -0
  138. package/src/elements/adw-inline-view-switcher.ts +240 -129
  139. package/src/elements/adw-menu-button.ts +93 -70
  140. package/src/elements/adw-navigation-split-view.ts +48 -7
  141. package/src/elements/adw-navigation-view.ts +286 -148
  142. package/src/elements/adw-overlay-split-view.ts +164 -14
  143. package/src/elements/adw-password-entry-row.ts +111 -89
  144. package/src/elements/adw-popover.ts +283 -0
  145. package/src/elements/adw-preferences-dialog.ts +120 -21
  146. package/src/elements/adw-preferences-group.ts +128 -22
  147. package/src/elements/adw-progress-bar.ts +189 -0
  148. package/src/elements/adw-sidebar.ts +301 -142
  149. package/src/elements/adw-spin-row.ts +11 -5
  150. package/src/elements/adw-spinner.ts +23 -8
  151. package/src/elements/adw-split-button.ts +371 -92
  152. package/src/elements/adw-status-page.ts +9 -9
  153. package/src/elements/adw-switch-row.ts +100 -30
  154. package/src/elements/adw-switch.ts +129 -0
  155. package/src/elements/adw-tab-view.ts +682 -129
  156. package/src/elements/adw-toggle-group.ts +5 -7
  157. package/src/elements/adw-toolbar-view.ts +84 -21
  158. package/src/elements/adw-view-stack.ts +162 -85
  159. package/src/elements/adw-view-switcher-bar.ts +163 -81
  160. package/src/elements/adw-view-switcher.ts +231 -112
  161. package/src/elements/adw-window-title.ts +44 -11
  162. package/src/elements/adw-wrap-box.ts +131 -35
  163. package/src/elements/view-switcher-dom.ts +108 -0
  164. package/src/entry-rows.spec.ts +391 -0
  165. package/src/index.ts +23 -0
  166. package/src/navigation-view.spec.ts +435 -0
  167. package/src/preferences-search.ts +135 -0
  168. package/src/preferences.spec.ts +250 -0
  169. package/src/source-view/adw-source-view.ts +2 -5
  170. package/src/split-button.spec.ts +470 -0
  171. package/src/split-views.spec.ts +309 -0
  172. package/src/styles.generated.ts +1 -1
  173. package/src/tab-view.spec.ts +451 -0
  174. package/src/test.browser.mts +49 -0
  175. package/src/view-stack.spec.ts +272 -0
  176. package/src/view-switcher.spec.ts +446 -0
@@ -3,12 +3,28 @@
3
3
  // a navigation stack controlled with push()/pop()/replace(); the visible page
4
4
  // fills the view. Only <adw-navigation-page> children are managed.
5
5
  //
6
+ // The stack machine itself is NOT here: it lives in `@gjsify/adwaita-core`'s
7
+ // `NavigationViewState` (ADR 0004), ported from the C and shared with the
8
+ // NativeScript renderer, which used to carry its own near-identical copy. This
9
+ // element is the DOM half — mount/unmount pages, toggle `hidden`, grow the
10
+ // automatic back button, translate stack changes into CustomEvents, and route
11
+ // Escape / Alt+Left into the core's shortcut handlers.
12
+ //
13
+ // The copy this replaces had drifted from libadwaita in ways nothing compared:
14
+ // `pop()` refused to pop a `can-pop="false"` page (the C documents the exact
15
+ // opposite at adw-navigation-view.c:2559-2561), `replace()` purged dynamic pages
16
+ // BEFORE resolving string entries so `replace(['tag-of-a-pushed-page'])` blanked
17
+ // the view outright, `popToTag()` was a loop of single pops that stopped early at
18
+ // a `can-pop="false"` page, and the back-button tooltip was hardcoded to "Back"
19
+ // where the C uses the previous page's title. See `navigation-view.spec.ts` for
20
+ // the vectors this element is now held to.
21
+ //
6
22
  // Pages are declared as <adw-navigation-page> children (each with a `title` and
7
23
  // optional `tag` attribute). The view snapshots them at connect time. Like
8
24
  // Adw.NavigationView, a statically-added page is kept around but not shown until
9
- // pushed (the first added page is pushed automatically). push() accepts a page
10
- // element OR an existing page's tag (push-by-tag); a dynamically-pushed page is
11
- // removed from the DOM again once it is popped.
25
+ // pushed, and adding a page while the stack is EMPTY pushes it automatically.
26
+ // push() accepts a page element OR an existing page's tag (push-by-tag); a
27
+ // dynamically-pushed page is removed from the DOM again once it is popped.
12
28
  //
13
29
  // Header Bar integration: mirroring libadwaita, when the visible page is above
14
30
  // the root the view surfaces an automatic back button at the start of that
@@ -19,28 +35,42 @@
19
35
  // Attributes (on <adw-navigation-view>):
20
36
  // animate-transitions (boolean, default present — slide animation on push/pop;
21
37
  // mirrors Adw.NavigationView:animate-transitions)
38
+ // pop-on-escape ("false" disables Escape-to-pop; mirrors
39
+ // Adw.NavigationView:pop-on-escape, which defaults to TRUE)
22
40
  // Attributes (on <adw-navigation-page>):
23
- // title (the page title — shown in the header bar's window title)
24
- // tag (a unique identifier for push-by-tag)
25
- // can-pop ("false" disables the back button / popping; mirrors
26
- // AdwNavigationPage:can-pop)
41
+ // title (the page title — shown in the header bar's window title, and used as
42
+ // the NEXT page's back-button tooltip)
43
+ // tag (a unique identifier for push-by-tag; a duplicate is rejected)
44
+ // can-pop ("false" disables the back button and the keyboard shortcuts;
45
+ // mirrors AdwNavigationPage:can-pop — it does NOT disable pop())
27
46
  // no-back-button (boolean — suppresses the automatic back button only)
28
- // Events (CustomEvent, bubbles):
29
- // `pushed` (detail = { tag }) after a page is pushed — mirrors
30
- // AdwNavigationView::pushed.
31
- // `popped` (detail = { tag }) after a page is popped — mirrors
47
+ // Events (CustomEvent, bubbles, detail = { page, tag }):
48
+ // `pushed` after a page is pushed — mirrors AdwNavigationView::pushed.
49
+ // `popped` after a page is popped, once per popped page, top-first — mirrors
32
50
  // AdwNavigationView::popped.
33
- // `replaced` after the whole stack is replaced — mirrors
34
- // AdwNavigationView::replaced.
35
- // `notify::visible-page` (detail = { tag }) whenever the visible page changes
36
- // — mirrors the Adw.NavigationView:visible-page property.
51
+ // `replaced` (detail page/tag null) after the whole stack is replaced —
52
+ // mirrors AdwNavigationView::replaced.
53
+ // `notify::visible-page` whenever the visible page changes — mirrors the
54
+ // Adw.NavigationView:visible-page property.
37
55
  // Reference: refs/libadwaita/src/adw-navigation-view.c (AdwNavigationView behaviour)
56
+ // Reference: refs/libadwaita/src/adw-back-button.c (the back button derivation)
38
57
  // Reference: refs/adwaita-web/adwaita-web/docs/widgets/navigationview.md
39
58
  // Copyright (c) 2022-2023 Purism SPC / GNOME contributors (libadwaita). LGPLv2.1+.
40
59
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
41
60
 
61
+ import { BACK_BUTTON_FALLBACK_TOOLTIP, NavigationViewState, describeNavigationDiagnostic } from '@gjsify/adwaita-core';
62
+ import type { AdwNavigationPageProps, NavigationDiagnostic, NavigationStackChange } from '@gjsify/adwaita-core';
63
+
64
+ /** The page properties the core owns, read off the element's attributes. */
65
+ function readPageProps(page: AdwNavigationPage): AdwNavigationPageProps {
66
+ return { tag: page.tag, title: page.title, canPop: page.canPop };
67
+ }
68
+
42
69
  /** A single navigation page. Children of <adw-navigation-view>. */
43
70
  export class AdwNavigationPage extends HTMLElement {
71
+ /** Set while the owning view writes an attribute back, so the sync cannot recurse. */
72
+ private _syncing = false;
73
+
44
74
  static get observedAttributes() {
45
75
  return ['title', 'tag', 'can-pop', 'no-back-button'];
46
76
  }
@@ -62,7 +92,7 @@ export class AdwNavigationPage extends HTMLElement {
62
92
  else this.setAttribute('tag', value);
63
93
  }
64
94
 
65
- /** Whether the page can be popped (and thus shows a back button). */
95
+ /** Whether the page can be popped by a shortcut or the back button. */
66
96
  get canPop(): boolean {
67
97
  return this.getAttribute('can-pop') !== 'false';
68
98
  }
@@ -70,22 +100,42 @@ export class AdwNavigationPage extends HTMLElement {
70
100
  set canPop(value: boolean) {
71
101
  this.setAttribute('can-pop', value ? 'true' : 'false');
72
102
  }
103
+
104
+ attributeChangedCallback(name: string, previous: string | null): void {
105
+ if (this._syncing) return;
106
+ // The attributes are the authoring surface, but the view's tag index and
107
+ // back-button derivation read the CORE's copy — so a runtime change has to
108
+ // be pushed into it. Without this the back button only ever reflected the
109
+ // `can-pop` value it had at the last stack mutation.
110
+ const view = this.closest('adw-navigation-view') as AdwNavigationView | null;
111
+ view?.syncPageProperty(this, name, previous);
112
+ }
113
+
114
+ /** Put an attribute back the way it was, without re-entering the sync. */
115
+ revertAttribute(name: string, value: string | null): void {
116
+ this._syncing = true;
117
+ if (value === null) this.removeAttribute(name);
118
+ else this.setAttribute(name, value);
119
+ this._syncing = false;
120
+ }
73
121
  }
74
122
 
75
123
  export class AdwNavigationView extends HTMLElement {
76
124
  private _pagesEl!: HTMLDivElement;
77
- // Every page known to the view (static + dynamically pushed), in DOM order.
78
- private _pages: AdwNavigationPage[] = [];
79
- // The navigation stack — references into _pages, bottom-first.
80
- private _stack: AdwNavigationPage[] = [];
81
- // Pages added dynamically via push() (destroyed when popped off).
82
- private _dynamic = new Set<AdwNavigationPage>();
83
125
  // The back button injected into each visible page (so we can remove it).
84
- private _backButtons = new WeakMap<AdwNavigationPage, HTMLElement>();
126
+ private readonly _backButtons = new WeakMap<AdwNavigationPage, HTMLElement>();
85
127
  private _initialized = false;
86
128
 
129
+ private readonly _diagnose = (diagnostic: NavigationDiagnostic): void => {
130
+ console.warn(`[adw-navigation-view] ${describeNavigationDiagnostic(diagnostic)}`);
131
+ };
132
+
133
+ // The whole stack machine. Declared AFTER `_diagnose` — class fields run in
134
+ // declaration order, so the sink has to exist first.
135
+ private readonly _state = new NavigationViewState<AdwNavigationPage>({ onDiagnostic: this._diagnose });
136
+
87
137
  static get observedAttributes() {
88
- return ['animate-transitions'];
138
+ return ['animate-transitions', 'pop-on-escape'];
89
139
  }
90
140
 
91
141
  /** Whether push/pop transitions are animated. */
@@ -98,14 +148,48 @@ export class AdwNavigationView extends HTMLElement {
98
148
  this.setAttribute('animate-transitions', value ? 'true' : 'false');
99
149
  }
100
150
 
151
+ /** Whether pressing Escape pops the visible page (Adw.NavigationView:pop-on-escape). */
152
+ get popOnEscape(): boolean {
153
+ return this.getAttribute('pop-on-escape') !== 'false';
154
+ }
155
+
156
+ set popOnEscape(value: boolean) {
157
+ this.setAttribute('pop-on-escape', value ? 'true' : 'false');
158
+ }
159
+
101
160
  /** The currently-visible page (top of the stack), or null when empty. */
102
161
  get visiblePage(): AdwNavigationPage | null {
103
- return this._stack.length > 0 ? this._stack[this._stack.length - 1] : null;
162
+ return this._state.visiblePage;
104
163
  }
105
164
 
106
165
  /** The tag of the visible page, or null. */
107
166
  get visiblePageTag(): string | null {
108
- return this.visiblePage?.tag ?? null;
167
+ return this._state.visiblePageTag;
168
+ }
169
+
170
+ /** The number of pages on the navigation stack. */
171
+ get depth(): number {
172
+ return this._state.depth;
173
+ }
174
+
175
+ /** The navigation stack, bottom-first (Adw.NavigationView:navigation-stack). */
176
+ get navigationStack(): readonly AdwNavigationPage[] {
177
+ return this._state.stack;
178
+ }
179
+
180
+ /** Every page this view knows — static and dynamically pushed. */
181
+ get pages(): readonly AdwNavigationPage[] {
182
+ return this._state.pages;
183
+ }
184
+
185
+ /** Whether the automatic back button is shown (AdwBackButton's visibility rule). */
186
+ get canGoBack(): boolean {
187
+ return this._state.canGoBack();
188
+ }
189
+
190
+ /** The back button's tooltip — the previous page's title, or null when there is no button. */
191
+ get backButtonTooltip(): string | null {
192
+ return this._state.backButtonTooltip();
109
193
  }
110
194
 
111
195
  connectedCallback() {
@@ -113,187 +197,223 @@ export class AdwNavigationView extends HTMLElement {
113
197
  this._initialized = true;
114
198
 
115
199
  // Snapshot the declared <adw-navigation-page> children — they become the
116
- // statically-added pages. The first one is pushed automatically.
117
- const pages = Array.from(this.querySelectorAll(':scope > adw-navigation-page')) as AdwNavigationPage[];
200
+ // statically-added pages. Moving them into the stack wrapper BEFORE they
201
+ // are registered keeps their DOM order and avoids a detach/attach flash.
202
+ const declared = Array.from(this.querySelectorAll(':scope > adw-navigation-page')) as AdwNavigationPage[];
118
203
 
119
204
  this._pagesEl = document.createElement('div');
120
205
  this._pagesEl.className = 'adw-navigation-view-pages';
121
-
122
- this._pages = [];
123
- this._stack = [];
124
-
125
- for (const page of pages) {
206
+ for (const page of declared) {
126
207
  page.classList.add('adw-navigation-page');
127
208
  this._pagesEl.appendChild(page);
128
- this._pages.push(page);
129
209
  }
130
-
131
210
  this.replaceChildren(this._pagesEl);
132
211
 
133
- // Auto-push the first added page (Adw.NavigationView behaviour).
134
- if (this._pages.length > 0) {
135
- this._stack.push(this._pages[0]);
212
+ this._state.subscribe((change) => this._onChange(change));
213
+ this._state.setAnimateTransitions(this.animateTransitions);
214
+ this._state.setPopOnEscape(this.popOnEscape);
215
+ this.addEventListener('keydown', this._onKeyDown);
216
+
217
+ // add_page:1284 auto-pushes into an EMPTY stack, so the first declared page
218
+ // becomes visible — and it does so through push_to_stack, which is why the
219
+ // `pushed` / `notify::visible-page` events fire for it.
220
+ for (const page of declared) this._state.add(page, readPageProps(page));
221
+ // A declared page the core REJECTED (a duplicate tag) is not one of ours:
222
+ // it must leave the DOM, or it would sit in the stack wrapper unmanaged and
223
+ // therefore permanently visible.
224
+ for (const page of declared) {
225
+ if (!this._state.isRegistered(page)) this._detach(page);
136
226
  }
137
227
 
138
228
  this._syncClasses();
139
229
  this._render();
140
230
  }
141
231
 
142
- attributeChangedCallback() {
232
+ attributeChangedCallback(name: string) {
143
233
  if (!this._initialized) return;
144
- // animate-transitions only affects future transitions; just keep the
145
- // class in sync so CSS can gate the slide animation.
234
+ if (name === 'pop-on-escape') {
235
+ this._state.setPopOnEscape(this.popOnEscape);
236
+ return;
237
+ }
238
+ this._state.setAnimateTransitions(this.animateTransitions);
146
239
  this._syncClasses();
147
240
  }
148
241
 
242
+ /**
243
+ * Push a page property change into the core. Called by
244
+ * {@link AdwNavigationPage.attributeChangedCallback}; a rename the core rejects
245
+ * (the tag is taken) is rolled back, because the attribute must not disagree
246
+ * with the tag index `push-by-tag` resolves against.
247
+ */
248
+ syncPageProperty(page: AdwNavigationPage, name: string, previous: string | null): void {
249
+ if (!this._state.isRegistered(page)) return;
250
+ if (name === 'tag') {
251
+ this._state.setTag(page, page.tag);
252
+ if (this._state.tagOf(page) !== page.tag) page.revertAttribute('tag', previous);
253
+ } else if (name === 'title') {
254
+ this._state.setTitle(page, page.title);
255
+ } else if (name === 'can-pop') {
256
+ this._state.setCanPop(page, page.canPop);
257
+ }
258
+ this._render();
259
+ }
260
+
149
261
  /**
150
262
  * Add a static page without pushing it (mirrors AdwNavigationView.add). The
151
263
  * view keeps a reference; the page becomes reachable via push-by-tag. Named
152
264
  * `addPage` rather than `add` to avoid clashing with any DOM method.
153
265
  */
154
- addPage(page: AdwNavigationPage): void {
155
- if (this._pages.includes(page)) return;
156
- page.classList.add('adw-navigation-page');
157
- this._pagesEl.appendChild(page);
158
- this._pages.push(page);
266
+ addPage(page: AdwNavigationPage): boolean {
267
+ const added = this._state.add(page, readPageProps(page));
159
268
  this._render();
269
+ return added;
160
270
  }
161
271
 
162
272
  /**
163
- * Remove a static page (mirrors AdwNavigationView.remove). Named `removePage`
164
- * to avoid overriding HTMLElement.remove() (which takes no arguments).
273
+ * Remove a page (mirrors AdwNavigationView.remove). A page that is on the
274
+ * stack is removed once it is POPPED, not now. Named `removePage` to avoid
275
+ * overriding HTMLElement.remove() (which takes no arguments).
165
276
  */
166
- removePage(page: AdwNavigationPage): void {
167
- if (this._stack.includes(page)) return; // can't remove a page on the stack
168
- const index = this._pages.indexOf(page);
169
- if (index === -1) return;
170
- this._pages.splice(index, 1);
171
- this._dynamic.delete(page);
172
- if (page.parentNode === this._pagesEl) this._pagesEl.removeChild(page);
277
+ removePage(page: AdwNavigationPage): boolean {
278
+ const removed = this._state.remove(page);
279
+ if (removed && !this._state.isRegistered(page)) this._detach(page);
173
280
  this._render();
281
+ return removed;
174
282
  }
175
283
 
176
284
  /**
177
285
  * Push a page onto the navigation stack (mirrors AdwNavigationView.push).
178
286
  * Accepts a page element or, when given a string, the tag of a known page
179
- * (push-by-tag, mirrors AdwNavigationView.push_by_tag). A page that is not
180
- * already known is added dynamically and destroyed when it is popped.
287
+ * (push-by-tag). A page that is not already known is added dynamically and
288
+ * destroyed when it is popped.
181
289
  */
182
- push(pageOrTag: AdwNavigationPage | string): void {
183
- const page = this._resolvePage(pageOrTag);
184
- if (!page) {
185
- console.warn(`[adw-navigation-view] No page found for "${String(pageOrTag)}"`);
186
- return;
187
- }
188
- if (this._stack.includes(page)) {
189
- console.warn('[adw-navigation-view] Page is already in the navigation stack');
190
- return;
191
- }
192
- if (!this._pages.includes(page)) {
193
- // A brand-new dynamic page — keep a reference + mark for cleanup.
194
- page.classList.add('adw-navigation-page');
195
- this._pagesEl.appendChild(page);
196
- this._pages.push(page);
197
- this._dynamic.add(page);
198
- }
199
- this._stack.push(page);
290
+ push(pageOrTag: AdwNavigationPage | string): boolean {
291
+ if (typeof pageOrTag === 'string') return this.pushByTag(pageOrTag);
292
+ const pushed = this._state.push(pageOrTag, readPageProps(pageOrTag));
200
293
  this._render();
201
- this.dispatchEvent(new CustomEvent('pushed', { bubbles: true, detail: { tag: page.tag } }));
202
- this.dispatchEvent(new CustomEvent('notify::visible-page', { bubbles: true, detail: { tag: page.tag } }));
294
+ return pushed;
295
+ }
296
+
297
+ /** Push the page carrying `tag` (mirrors AdwNavigationView.push_by_tag). */
298
+ pushByTag(tag: string): boolean {
299
+ const pushed = this._state.pushByTag(tag);
300
+ this._render();
301
+ return pushed;
203
302
  }
204
303
 
205
304
  /**
206
305
  * Pop the visible page off the stack (mirrors AdwNavigationView.pop).
207
- * Returns true when a page was popped. A dynamically-pushed page is removed
208
- * from the DOM. The root page cannot be popped.
306
+ * Returns true when a page was popped. The root page cannot be popped.
307
+ * `can-pop` does NOT gate this — it gates the shortcuts and the back button.
209
308
  */
210
309
  pop(): boolean {
211
- if (this._stack.length <= 1) return false;
212
- const visible = this.visiblePage;
213
- if (visible && !visible.canPop) return false;
214
-
215
- const popped = this._stack.pop() as AdwNavigationPage;
216
- // Destroy dynamically-pushed pages once they leave the stack.
217
- if (this._dynamic.has(popped)) {
218
- this._dynamic.delete(popped);
219
- const index = this._pages.indexOf(popped);
220
- if (index !== -1) this._pages.splice(index, 1);
221
- if (popped.parentNode === this._pagesEl) this._pagesEl.removeChild(popped);
222
- }
310
+ const popped = this._state.pop();
223
311
  this._render();
224
- this.dispatchEvent(new CustomEvent('popped', { bubbles: true, detail: { tag: popped.tag } }));
225
- const next = this.visiblePage;
226
- this.dispatchEvent(
227
- new CustomEvent('notify::visible-page', { bubbles: true, detail: { tag: next?.tag ?? null } }),
228
- );
229
- return true;
312
+ return popped;
230
313
  }
231
314
 
232
- /** Pop every page down to (and including pushing) the given tag's page. */
315
+ /** Pop until `page` is visible, in ONE transition (mirrors AdwNavigationView.pop_to_page). */
316
+ popToPage(page: AdwNavigationPage): boolean {
317
+ const popped = this._state.popToPage(page);
318
+ this._render();
319
+ return popped;
320
+ }
321
+
322
+ /** Pop until the page carrying `tag` is visible (mirrors AdwNavigationView.pop_to_tag). */
233
323
  popToTag(tag: string): boolean {
234
- const target = this._pages.find((p) => p.tag === tag);
235
- if (!target) return false;
236
- const index = this._stack.indexOf(target);
237
- if (index === -1 || index === this._stack.length - 1) return false;
238
- let popped = false;
239
- while (this._stack.length - 1 > index) {
240
- if (!this.pop()) break;
241
- popped = true;
242
- }
324
+ const popped = this._state.popToTag(tag);
325
+ this._render();
243
326
  return popped;
244
327
  }
245
328
 
246
329
  /**
247
- * Replace the whole navigation stack with the given pages (mirrors
248
- * AdwNavigationView.replace). The last page becomes visible.
330
+ * Replace the whole navigation stack (mirrors AdwNavigationView.replace). The
331
+ * last page becomes visible; the transition is never animated.
332
+ *
333
+ * String entries are resolved BEFORE anything is mutated — the C does the same
334
+ * in replace_with_tags:3136-3147, and resolving late is what used to lose a
335
+ * dynamically-pushed page and blank the view.
249
336
  */
250
- replace(pages: Array<AdwNavigationPage | string>): void {
251
- // Clear the current stack first (without firing popped events — replace
252
- // is its own signal in libadwaita).
253
- for (const page of this._stack) {
254
- if (this._dynamic.has(page)) {
255
- this._dynamic.delete(page);
256
- const index = this._pages.indexOf(page);
257
- if (index !== -1) this._pages.splice(index, 1);
258
- if (page.parentNode === this._pagesEl) this._pagesEl.removeChild(page);
259
- }
260
- }
261
- this._stack = [];
262
-
263
- for (const entry of pages) {
264
- const page = this._resolvePage(entry);
265
- if (!page) continue;
266
- if (!this._pages.includes(page)) {
267
- page.classList.add('adw-navigation-page');
268
- this._pagesEl.appendChild(page);
269
- this._pages.push(page);
270
- this._dynamic.add(page);
271
- }
272
- this._stack.push(page);
273
- }
337
+ replace(pages: ReadonlyArray<AdwNavigationPage | string | null>): void {
338
+ const resolved = pages.map((entry) => {
339
+ if (typeof entry !== 'string') return entry ?? null;
340
+ const page = this._state.findPage(entry);
341
+ if (page === null) this._diagnose({ code: 'tag-not-found', tag: entry });
342
+ return page;
343
+ });
344
+ this._state.replace(resolved, readPageProps);
345
+ this._render();
346
+ }
274
347
 
348
+ /** Replace the stack with the pages carrying `tags` (mirrors AdwNavigationView.replace_with_tags). */
349
+ replaceWithTags(tags: readonly string[]): void {
350
+ this._state.replaceWithTags(tags);
275
351
  this._render();
276
- this.dispatchEvent(new CustomEvent('replaced', { bubbles: true }));
277
- this.dispatchEvent(
278
- new CustomEvent('notify::visible-page', { bubbles: true, detail: { tag: this.visiblePageTag } }),
279
- );
280
352
  }
281
353
 
282
- private _resolvePage(pageOrTag: AdwNavigationPage | string): AdwNavigationPage | null {
283
- if (typeof pageOrTag === 'string') {
284
- return this._pages.find((p) => p.tag === pageOrTag) ?? null;
354
+ /** The page with this tag, or null (mirrors AdwNavigationView.find_page). */
355
+ findPage(tag: string): AdwNavigationPage | null {
356
+ return this._state.findPage(tag);
357
+ }
358
+
359
+ /** The page popping `page` would reveal (mirrors AdwNavigationView.get_previous_page). */
360
+ getPreviousPage(page: AdwNavigationPage): AdwNavigationPage | null {
361
+ return this._state.getPreviousPage(page);
362
+ }
363
+
364
+ private _onChange(change: NavigationStackChange<AdwNavigationPage>): void {
365
+ for (const page of change.removed) this._detach(page);
366
+ // The slide is a CSS animation on the incoming page, so there is no JS
367
+ // transition to wait for: settle the deferred destroy at once, exactly as
368
+ // adw_animation_skip does in the C when `animate` is FALSE.
369
+ for (const page of this._state.finishTransition()) this._detach(page);
370
+ this._render();
371
+
372
+ if (change.reason === 'add' || change.reason === 'push') {
373
+ this._dispatch('pushed', change.visiblePage);
374
+ } else if (change.reason === 'pop') {
375
+ for (const page of change.popped) this._dispatch('popped', page);
376
+ } else {
377
+ this._dispatch('replaced', null);
378
+ }
379
+ if (change.previousVisiblePage !== change.visiblePage) {
380
+ this._dispatch('notify::visible-page', change.visiblePage);
285
381
  }
286
- return pageOrTag;
287
382
  }
288
383
 
384
+ private _dispatch(type: string, page: AdwNavigationPage | null): void {
385
+ this.dispatchEvent(new CustomEvent(type, { bubbles: true, detail: { page, tag: page?.tag ?? null } }));
386
+ }
387
+
388
+ private readonly _onKeyDown = (event: KeyboardEvent): void => {
389
+ if (event.defaultPrevented) return;
390
+ let result: 'stop' | 'propagate';
391
+ if (event.key === 'Escape') {
392
+ result = this._state.popFromEscape();
393
+ } else if (event.altKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
394
+ // back_forward_shortcut_cb:1192-1193 swaps the arrows under RTL; only
395
+ // the BACK direction is handled, since forward needs ::get-next-page.
396
+ const back = getComputedStyle(this).direction === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
397
+ if (event.key !== back) return;
398
+ result = this._state.popFromShortcut();
399
+ } else {
400
+ return;
401
+ }
402
+ if (result === 'stop') {
403
+ event.preventDefault();
404
+ event.stopPropagation();
405
+ }
406
+ };
407
+
289
408
  private _syncClasses(): void {
290
409
  this.classList.toggle('animated', this.animateTransitions);
291
410
  }
292
411
 
293
412
  private _render(): void {
294
- const visible = this.visiblePage;
413
+ const visible = this._state.visiblePage;
295
414
 
296
- for (const page of this._pages) {
415
+ for (const page of this._state.pages) {
416
+ this._attach(page);
297
417
  const isVisible = page === visible;
298
418
  page.classList.toggle('visible-page', isVisible);
299
419
  page.hidden = !isVisible;
@@ -305,15 +425,33 @@ export class AdwNavigationView extends HTMLElement {
305
425
  if (visible) this._syncBackButton(visible);
306
426
  }
307
427
 
428
+ private _attach(page: AdwNavigationPage): void {
429
+ if (page.parentNode === this._pagesEl) return;
430
+ page.classList.add('adw-navigation-page');
431
+ this._pagesEl.appendChild(page);
432
+ }
433
+
434
+ private _detach(page: AdwNavigationPage): void {
435
+ this._removeBackButton(page);
436
+ page.classList.remove('visible-page');
437
+ if (page.parentNode === this._pagesEl) this._pagesEl.removeChild(page);
438
+ }
439
+
308
440
  /** Inject (or remove) the automatic back button on the visible page. */
309
441
  private _syncBackButton(page: AdwNavigationPage): void {
310
- // Pop is possible only when there is a page below this one on the stack.
311
- const poppable = this._stack.indexOf(page) > 0 && page.canPop && !page.hasAttribute('no-back-button');
312
- if (!poppable) {
442
+ if (!this._state.canGoBack() || page.hasAttribute('no-back-button')) {
313
443
  this._removeBackButton(page);
314
444
  return;
315
445
  }
316
- if (this._backButtons.has(page)) return;
446
+
447
+ // The tooltip is the title of the page the button REVEALS, so it can change
448
+ // while the button itself stays put (adw-back-button.c query_tooltip:411-417).
449
+ const tooltip = this._state.backButtonTooltip() ?? BACK_BUTTON_FALLBACK_TOOLTIP;
450
+ const existing = this._backButtons.get(page);
451
+ if (existing) {
452
+ existing.setAttribute('tooltip', tooltip);
453
+ return;
454
+ }
317
455
 
318
456
  // Find the page's header bar's start section. We surface the button into
319
457
  // the first <adw-header-bar> inside the page content, the way
@@ -327,7 +465,7 @@ export class AdwNavigationView extends HTMLElement {
327
465
  back.classList.add('adw-navigation-back-button');
328
466
  back.setAttribute('icon', 'go-previous');
329
467
  back.setAttribute('flat', '');
330
- back.setAttribute('tooltip', 'Back');
468
+ back.setAttribute('tooltip', tooltip);
331
469
  back.addEventListener('click', () => this.pop());
332
470
  // Place the back button at the very start of the header.
333
471
  start.insertBefore(back, start.firstChild);