@gjsify/adwaita-web 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/dist/adwaita-web.css +835 -299
  2. package/dist/adwaita-web.css.map +1 -1
  3. package/lib/types/adw-about-dialog.spec.d.ts +1 -0
  4. package/lib/types/adw-action-rows.spec.d.ts +1 -0
  5. package/lib/types/adw-avatar.spec.d.ts +1 -0
  6. package/lib/types/adw-banner.spec.d.ts +1 -0
  7. package/lib/types/adw-button-content.spec.d.ts +1 -0
  8. package/lib/types/adw-carousel.spec.d.ts +1 -0
  9. package/lib/types/adw-checks.spec.d.ts +1 -0
  10. package/lib/types/adw-header-bar.spec.d.ts +1 -0
  11. package/lib/types/adw-icon.spec.d.ts +1 -0
  12. package/lib/types/adw-progress-bar.spec.d.ts +1 -0
  13. package/lib/types/adw-sidebar.spec.d.ts +1 -0
  14. package/lib/types/adw-switch.spec.d.ts +1 -0
  15. package/lib/types/adw-wrap-box.spec.d.ts +1 -0
  16. package/lib/types/bottom-sheet.spec.d.ts +1 -0
  17. package/lib/types/breakpoints.d.ts +22 -0
  18. package/lib/types/breakpoints.spec.d.ts +1 -0
  19. package/lib/types/chrome.spec.d.ts +1 -0
  20. package/lib/types/elements/adw-about-dialog.d.ts +47 -8
  21. package/lib/types/elements/adw-action-row.d.ts +35 -0
  22. package/lib/types/elements/adw-avatar.d.ts +11 -0
  23. package/lib/types/elements/adw-bottom-sheet.d.ts +33 -4
  24. package/lib/types/elements/adw-button-content.d.ts +12 -0
  25. package/lib/types/elements/adw-button-row.d.ts +10 -1
  26. package/lib/types/elements/adw-carousel.d.ts +111 -8
  27. package/lib/types/elements/adw-checks.d.ts +57 -0
  28. package/lib/types/elements/adw-clamp.d.ts +4 -1
  29. package/lib/types/elements/adw-combo-row.d.ts +10 -0
  30. package/lib/types/elements/adw-data-grid.d.ts +11 -31
  31. package/lib/types/elements/adw-drop-down.d.ts +17 -20
  32. package/lib/types/elements/adw-entry-row.d.ts +93 -3
  33. package/lib/types/elements/adw-expander-row.d.ts +7 -1
  34. package/lib/types/elements/adw-header-bar.d.ts +15 -0
  35. package/lib/types/elements/adw-icon.d.ts +25 -0
  36. package/lib/types/elements/adw-inline-view-switcher.d.ts +73 -11
  37. package/lib/types/elements/adw-menu-button.d.ts +6 -6
  38. package/lib/types/elements/adw-navigation-split-view.d.ts +48 -0
  39. package/lib/types/elements/adw-navigation-view.d.ts +63 -20
  40. package/lib/types/elements/adw-overlay-split-view.d.ts +94 -0
  41. package/lib/types/elements/adw-password-entry-row.d.ts +19 -10
  42. package/lib/types/elements/adw-popover.d.ts +58 -0
  43. package/lib/types/elements/adw-preferences-dialog.d.ts +57 -2
  44. package/lib/types/elements/adw-preferences-group.d.ts +31 -0
  45. package/lib/types/elements/adw-progress-bar.d.ts +38 -0
  46. package/lib/types/elements/adw-sidebar.d.ts +37 -5
  47. package/lib/types/elements/adw-spinner.d.ts +23 -0
  48. package/lib/types/elements/adw-split-button.d.ts +53 -7
  49. package/lib/types/elements/adw-switch-row.d.ts +11 -2
  50. package/lib/types/elements/adw-switch.d.ts +15 -0
  51. package/lib/types/elements/adw-tab-view.d.ts +129 -12
  52. package/lib/types/elements/adw-toolbar-view.d.ts +5 -0
  53. package/lib/types/elements/adw-view-stack.d.ts +39 -19
  54. package/lib/types/elements/adw-view-switcher-bar.d.ts +25 -7
  55. package/lib/types/elements/adw-view-switcher.d.ts +76 -13
  56. package/lib/types/elements/adw-window-title.d.ts +3 -1
  57. package/lib/types/elements/adw-wrap-box.d.ts +58 -3
  58. package/lib/types/elements/view-switcher-dom.d.ts +44 -0
  59. package/lib/types/entry-rows.spec.d.ts +1 -0
  60. package/lib/types/index.d.ts +7 -0
  61. package/lib/types/navigation-view.spec.d.ts +1 -0
  62. package/lib/types/preferences-search.d.ts +20 -0
  63. package/lib/types/preferences.spec.d.ts +1 -0
  64. package/lib/types/split-button.spec.d.ts +1 -0
  65. package/lib/types/split-views.spec.d.ts +1 -0
  66. package/lib/types/styles.generated.d.ts +1 -1
  67. package/lib/types/tab-view.spec.d.ts +1 -0
  68. package/lib/types/view-stack.spec.d.ts +1 -0
  69. package/lib/types/view-switcher.spec.d.ts +1 -0
  70. package/package.json +6 -6
  71. package/scss/_banner.scss +13 -0
  72. package/scss/_bottom_sheet.scss +5 -6
  73. package/scss/_button.scss +3 -26
  74. package/scss/_button_content.scss +28 -0
  75. package/scss/_checks.scss +228 -0
  76. package/scss/_clamp.scss +9 -3
  77. package/scss/_combo_row.scss +19 -0
  78. package/scss/_data_grid.scss +15 -7
  79. package/scss/_drop_down.scss +12 -55
  80. package/scss/_expander_row.scss +5 -50
  81. package/scss/_icon.scss +51 -0
  82. package/scss/_icons.generated.scss +6 -0
  83. package/scss/_inline_view_switcher.scss +34 -0
  84. package/scss/_labels.scss +135 -0
  85. package/scss/_menu_button.scss +10 -70
  86. package/scss/_navigation_split_view.scss +47 -8
  87. package/scss/_overlay_split_view.scss +117 -32
  88. package/scss/_popover.scss +186 -0
  89. package/scss/_preferences.scss +29 -0
  90. package/scss/_preferences_dialog.scss +17 -0
  91. package/scss/_progress_bar.scss +161 -0
  92. package/scss/_reset.scss +18 -7
  93. package/scss/_spinner.scss +37 -19
  94. package/scss/_split_button.scss +6 -50
  95. package/scss/_switch.scss +90 -0
  96. package/scss/_switch_row.scss +6 -52
  97. package/scss/_tab_view.scss +63 -0
  98. package/scss/_theme.scss +10 -0
  99. package/scss/_toolbar_view.scss +74 -0
  100. package/scss/_variables.scss +34 -0
  101. package/scss/_view_switcher.scss +40 -0
  102. package/scss/_view_switcher_bar.scss +37 -0
  103. package/scss/_wrap_box.scss +65 -6
  104. package/scss/adwaita-skin.scss +23 -0
  105. package/src/adw-about-dialog.spec.ts +350 -0
  106. package/src/adw-action-rows.spec.ts +342 -0
  107. package/src/adw-avatar.spec.ts +176 -0
  108. package/src/adw-banner.spec.ts +152 -0
  109. package/src/adw-button-content.spec.ts +207 -0
  110. package/src/adw-button-row.spec.ts +12 -6
  111. package/src/adw-carousel.spec.ts +389 -0
  112. package/src/adw-checks.spec.ts +368 -0
  113. package/src/adw-drop-down.spec.ts +123 -0
  114. package/src/adw-header-bar.spec.ts +83 -0
  115. package/src/adw-icon.spec.ts +167 -0
  116. package/src/adw-progress-bar.spec.ts +173 -0
  117. package/src/adw-row-state.spec.ts +49 -0
  118. package/src/adw-sidebar.spec.ts +332 -0
  119. package/src/adw-switch.spec.ts +242 -0
  120. package/src/adw-tab-view.spec.ts +12 -3
  121. package/src/adw-view-switcher-bar.spec.ts +61 -13
  122. package/src/adw-wrap-box.spec.ts +341 -0
  123. package/src/bottom-sheet.spec.ts +325 -0
  124. package/src/breakpoints.spec.ts +147 -0
  125. package/src/breakpoints.ts +67 -0
  126. package/src/chrome.spec.ts +400 -0
  127. package/src/elements/adw-about-dialog.ts +223 -163
  128. package/src/elements/adw-action-row.ts +131 -11
  129. package/src/elements/adw-avatar.ts +48 -54
  130. package/src/elements/adw-banner.ts +46 -10
  131. package/src/elements/adw-bottom-sheet.ts +145 -60
  132. package/src/elements/adw-button-content.ts +107 -33
  133. package/src/elements/adw-button-row.ts +61 -37
  134. package/src/elements/adw-button.ts +5 -7
  135. package/src/elements/adw-carousel.ts +467 -105
  136. package/src/elements/adw-checks.ts +299 -0
  137. package/src/elements/adw-clamp.ts +80 -10
  138. package/src/elements/adw-combo-row.ts +32 -7
  139. package/src/elements/adw-data-grid.ts +43 -91
  140. package/src/elements/adw-drop-down.ts +117 -128
  141. package/src/elements/adw-entry-row.ts +404 -36
  142. package/src/elements/adw-expander-row.ts +61 -29
  143. package/src/elements/adw-header-bar.ts +31 -6
  144. package/src/elements/adw-icon.ts +148 -0
  145. package/src/elements/adw-inline-view-switcher.ts +325 -131
  146. package/src/elements/adw-menu-button.ts +93 -70
  147. package/src/elements/adw-navigation-split-view.ts +220 -14
  148. package/src/elements/adw-navigation-view.ts +286 -148
  149. package/src/elements/adw-overlay-split-view.ts +536 -28
  150. package/src/elements/adw-password-entry-row.ts +111 -89
  151. package/src/elements/adw-popover.ts +283 -0
  152. package/src/elements/adw-preferences-dialog.ts +120 -21
  153. package/src/elements/adw-preferences-group.ts +128 -22
  154. package/src/elements/adw-progress-bar.ts +189 -0
  155. package/src/elements/adw-sidebar.ts +301 -142
  156. package/src/elements/adw-spin-row.ts +11 -5
  157. package/src/elements/adw-spinner.ts +168 -9
  158. package/src/elements/adw-split-button.ts +371 -92
  159. package/src/elements/adw-status-page.ts +9 -9
  160. package/src/elements/adw-switch-row.ts +100 -30
  161. package/src/elements/adw-switch.ts +129 -0
  162. package/src/elements/adw-tab-view.ts +700 -129
  163. package/src/elements/adw-toggle-group.ts +5 -7
  164. package/src/elements/adw-toolbar-view.ts +84 -21
  165. package/src/elements/adw-view-stack.ts +183 -85
  166. package/src/elements/adw-view-switcher-bar.ts +171 -81
  167. package/src/elements/adw-view-switcher.ts +328 -117
  168. package/src/elements/adw-window-title.ts +44 -11
  169. package/src/elements/adw-wrap-box.ts +329 -78
  170. package/src/elements/view-switcher-dom.ts +108 -0
  171. package/src/entry-rows.spec.ts +391 -0
  172. package/src/index.ts +23 -0
  173. package/src/navigation-view.spec.ts +435 -0
  174. package/src/preferences-search.ts +135 -0
  175. package/src/preferences.spec.ts +250 -0
  176. package/src/source-view/adw-source-view.ts +2 -5
  177. package/src/split-button.spec.ts +470 -0
  178. package/src/split-views.spec.ts +893 -0
  179. package/src/styles.generated.ts +1 -1
  180. package/src/tab-view.spec.ts +451 -0
  181. package/src/test.browser.mts +49 -0
  182. package/src/view-stack.spec.ts +272 -0
  183. package/src/view-switcher.spec.ts +665 -0
@@ -1,79 +1,188 @@
1
1
  // <adw-inline-view-switcher> — A compact, toolbar-friendly view switcher (the
2
- // web counterpart of Adw.InlineViewSwitcher). It pairs a toggle-group-style
3
- // switcher bar with a stack of pages: each declared <adw-view-stack-page> child
4
- // (with a `title` and/or `icon-name` attribute plus arbitrary content) becomes a
5
- // toggle in the bar and a panel in the stack, and exactly one page is visible at
6
- // a time. The toggles can show labels, icons, or both via `display-mode`, just
7
- // like the native widget's AdwInlineViewSwitcherDisplayMode.
2
+ // web counterpart of Adw.InlineViewSwitcher). Each declared <adw-view-stack-page>
3
+ // child becomes a toggle in a toggle-group-style bar and a panel in the stack,
4
+ // and exactly one page is visible at a time.
5
+ //
6
+ // Everything that decides WHAT to show is HEADLESS and lives in
7
+ // `@gjsify/adwaita-core` (ADR 0004) — including the TWO INDEX SPACES this widget
8
+ // is built on: a hidden page produces NO toggle, so the compacted toggle index
9
+ // and the page index diverge, and libadwaita reconciles them through the
10
+ // `child-index` it stashes on every toggle (adw-inline-view-switcher.c:346,
11
+ // :114-129, :434-453). `buildInlineToggles` carries both indices, so this file
12
+ // never has to reconcile them itself.
13
+ //
14
+ // What the lift fixed here, all of it C-derived:
15
+ // - hidden pages were not filtered at all, so the two index spaces collapsed
16
+ // into one and `AdwViewStackPage:visible` had no spelling (:370-378).
17
+ // - the display-mode default was 'both'; libadwaita's is 'labels' (:657, :715).
18
+ // - an unrecognised display-mode value REPLACED the current mode with 'both';
19
+ // `g_return_if_fail (mode <= …_BOTH)` rejects it and keeps the current one
20
+ // (:819).
21
+ // - the icons-mode tooltip used the RAW title, so `title="_Files"` showed the
22
+ // underscore; C strips the mnemonic when use-underline is set (:180-188).
23
+ // - a page with no icon rendered no icon; C substitutes `image-missing`
24
+ // (:137-142) in every mode that builds an image at all.
25
+ // - `active` was CLAMPED into range instead of refused, `notify::display-mode`
26
+ // was documented but never dispatched, the page query was unscoped (so a
27
+ // nested switcher lost its pages to the outer one), and the 500 ms
28
+ // drag-hover auto-switch did not exist (:80, :194-236).
29
+ //
8
30
  // Attributes:
9
- // active (zero-based index of the visible page, default 0)
10
- // display-mode (labels | icons | both, default both) — what the toggles show
11
- // flat / round (mirroring the Adw.InlineViewSwitcher `.flat` / `.round` style
12
- // classes, which it inherits from Adw.ToggleGroup)
31
+ // active — zero-based index of the visible PAGE (not of the toggle). Refused,
32
+ // never clamped, and reflected back to what actually holds.
33
+ // display-mode — labels | icons | both (default 'labels'). An unrecognised
34
+ // value is rejected; removing the attribute restores the default.
35
+ // flat / round — the Adw.ToggleGroup style classes.
36
+ // Page attributes: name, title, icon-name, hidden (= AdwViewStackPage:visible),
37
+ // use-underline, badge-number, needs-attention.
13
38
  // Events:
14
- // `notify::active` (CustomEvent, bubbles, `detail = { active }`) when the
15
- // visible page changes — mirrors selecting a page in the paired Adw.ViewStack.
16
- // `notify::display-mode` (CustomEvent, bubbles, `detail = { displayMode }`)
17
- // when the display mode changes — mirrors the Adw.InlineViewSwitcher
18
- // `display-mode` GObject property.
19
- // Reference: refs/libadwaita/src/adw-inline-view-switcher.c (AdwInlineViewSwitcher behaviour)
20
- // Reference: refs/libadwaita/src/stylesheet/widgets/_toggle-group.scss (inline-view-switcher > toggle-group)
21
- // Reference: refs/adwaita-web/adwaita-web/scss/_viewswitcher.scss
39
+ // `notify::active` (CustomEvent, bubbles,
40
+ // detail = { active, name, toggleIndex, interactive }) on every selection
41
+ // change, including the auto-pick.
42
+ // `notify::display-mode` (CustomEvent, bubbles, detail = { displayMode }).
43
+ // Reference: refs/libadwaita/src/adw-inline-view-switcher.c (AdwInlineViewSwitcher)
44
+ // Reference: refs/libadwaita/src/stylesheet/widgets/_toggle-group.scss
22
45
  // Copyright (c) 2023 Maximiliano Sandoval / 2024 GNOME Foundation Inc. (libadwaita). LGPLv2.1+.
23
46
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
24
47
 
25
- /** The three display modes, mirroring AdwInlineViewSwitcherDisplayMode. */
26
- const DISPLAY_MODES = ['labels', 'icons', 'both'] as const;
27
- type DisplayMode = (typeof DISPLAY_MODES)[number];
48
+ import {
49
+ INLINE_VIEW_SWITCHER_DISPLAY_MODES,
50
+ VIEW_SWITCHER_NO_SELECTION,
51
+ ViewSwitcherState,
52
+ buildInlineToggles,
53
+ isInlineViewSwitcherDisplayMode,
54
+ toggleIndexForPage,
55
+ } from '@gjsify/adwaita-core';
56
+ import type { ViewSwitcherScheduler } from '@gjsify/adwaita-core';
57
+ import type {
58
+ AdwInlineViewSwitcherDisplayMode,
59
+ AdwViewSwitcherPage,
60
+ ViewSwitcherStateChange,
61
+ } from '@gjsify/adwaita-core';
62
+
63
+ import {
64
+ applyDescription,
65
+ applyIndicator,
66
+ createSwitcherIcon,
67
+ domViewSwitcherScheduler,
68
+ readSwitcherPage,
69
+ } from './view-switcher-dom.js';
28
70
 
29
71
  /** A single page. Children of <adw-inline-view-switcher>; consumed at connect time. */
30
72
  export class AdwViewStackPage extends HTMLElement {
31
73
  static get observedAttributes() {
32
- return ['title', 'icon-name'];
74
+ return ['name', 'title', 'icon-name', 'badge-number', 'needs-attention', 'use-underline'];
33
75
  }
34
76
  }
35
77
 
36
78
  export class AdwInlineViewSwitcher extends HTMLElement {
79
+ /**
80
+ * The dwell timer, injectable.
81
+ *
82
+ * It used to be `new ViewSwitcherState({ scheduler: domViewSwitcherScheduler })`
83
+ * inline, which left NOWHERE to hand a clock in — so
84
+ * `VIEW_SWITCHER_DRAG_VECTORS` was driven by the core suite alone, and the
85
+ * conformance header claimed all three suites drove it. `scheduler` is a
86
+ * property so a test can replace it before the element connects; the DOM one
87
+ * is the default, exactly as before.
88
+ */
89
+ scheduler: ViewSwitcherScheduler = domViewSwitcherScheduler;
90
+ private _stateInstance: ViewSwitcherState | null = null;
91
+
92
+ /** Built on first access, so a `scheduler` set before connect is the one used. */
93
+ private get _state(): ViewSwitcherState {
94
+ if (!this._stateInstance) {
95
+ this._stateInstance = new ViewSwitcherState({ scheduler: this.scheduler });
96
+ this._stateInstance.subscribe((change) => this._onStateChange(change));
97
+ }
98
+ return this._stateInstance;
99
+ }
37
100
  private _groupEl!: HTMLDivElement;
38
101
  private _pagesEl!: HTMLDivElement;
102
+ private _panels: HTMLDivElement[] = [];
39
103
  private _toggles: HTMLButtonElement[] = [];
40
- private _pages: HTMLDivElement[] = [];
41
- private _active = 0;
42
- private _displayMode: DisplayMode = 'both';
104
+ private _displayMode: AdwInlineViewSwitcherDisplayMode = 'labels';
43
105
  private _initialized = false;
106
+ /** The page ELEMENTS, kept because this element detaches them from the tree. */
107
+ private _declared: HTMLElement[] = [];
108
+ private _pageObserver: MutationObserver | undefined;
44
109
 
45
110
  static get observedAttributes() {
46
- return ['active', 'display-mode', 'flat', 'round'];
111
+ return ['active', 'display-mode', 'flat', 'round', 'osd'];
47
112
  }
48
113
 
49
- /** Zero-based index of the visible page. */
114
+ constructor() {
115
+ super();
116
+ }
117
+
118
+ /** Zero-based index of the visible PAGE, `-1` when nothing is selected. */
50
119
  get active(): number {
51
- return this._active;
120
+ return this._state.selected;
52
121
  }
53
122
 
54
123
  set active(value: number) {
55
- this.setAttribute('active', String(value));
124
+ this._state.setSelected(value);
125
+ this._reflectActive();
126
+ }
127
+
128
+ /**
129
+ * Index of the ACTIVE TOGGLE, `-1` when the visible page has none because it
130
+ * is hidden — the port-safe `GTK_INVALID_LIST_POSITION`
131
+ * (adw-inline-view-switcher.c:441-446).
132
+ */
133
+ get activeToggle(): number {
134
+ return toggleIndexForPage(this._state.pages, this._state.selected);
56
135
  }
57
136
 
58
137
  /** What the toggles display: 'labels', 'icons' or 'both'. */
59
- get displayMode(): DisplayMode {
138
+ get displayMode(): AdwInlineViewSwitcherDisplayMode {
60
139
  return this._displayMode;
61
140
  }
62
141
 
63
- set displayMode(value: DisplayMode) {
142
+ set displayMode(value: AdwInlineViewSwitcherDisplayMode) {
64
143
  this.setAttribute('display-mode', value);
65
144
  }
66
145
 
146
+ /** Name of the visible page, `''` when nothing is selected. */
147
+ get visibleChildName(): string {
148
+ return this._state.selectedName;
149
+ }
150
+
151
+ set visibleChildName(name: string | null | undefined) {
152
+ this._state.selectName(name);
153
+ }
154
+
155
+ /** The resolved page records. */
156
+ get pages(): readonly AdwViewSwitcherPage[] {
157
+ return this._state.pages;
158
+ }
159
+
160
+ /**
161
+ * Show or hide a page. Returns whether the SELECTION moved. Rebuilds the
162
+ * toggle row, because a hidden page has no toggle at all — C reacts to a
163
+ * page's `notify::visible` with `recreate_toggles`
164
+ * (adw-inline-view-switcher.c:425, :471-477).
165
+ */
166
+ setPageVisible(name: string, visible: boolean): boolean {
167
+ const moved = this._state.setPageVisible(name, visible);
168
+ this._rebuildToggles();
169
+ return moved;
170
+ }
171
+
67
172
  connectedCallback() {
68
- if (this._initialized) return;
69
- this._initialized = true;
173
+ if (this._initialized) {
174
+ // Re-entering a document — same reason as `adw-view-switcher.ts`:
175
+ // the page observer is dropped on disconnect, so it has to be
176
+ // rebuilt here or a moved switcher goes deaf permanently.
177
+ this._watchPages();
178
+ return;
179
+ }
70
180
 
71
- // Snapshot the declared <adw-view-stack-page> children before we take
72
- // over the subtree — their title / icon-name + content become the
73
- // rendered toggles and panels.
74
- const pages = Array.from(this.querySelectorAll('adw-view-stack-page')) as AdwViewStackPage[];
181
+ // `:scope >` rather than a subtree scan — `<adw-view-stack>` uses the
182
+ // same tag for its pages, so an unscoped query adopted a nested stack's.
183
+ const declared = Array.from(this.querySelectorAll(':scope > adw-view-stack-page')) as HTMLElement[];
184
+ const declaredActive = this.getAttribute('active');
75
185
 
76
- // The switcher bar — a toggle-group of one toggle per page.
77
186
  this._groupEl = document.createElement('div');
78
187
  this._groupEl.className = 'adw-inline-view-switcher-group';
79
188
  this._groupEl.setAttribute('role', 'tablist');
@@ -81,140 +190,225 @@ export class AdwInlineViewSwitcher extends HTMLElement {
81
190
  this._pagesEl = document.createElement('div');
82
191
  this._pagesEl.className = 'adw-inline-view-switcher-pages';
83
192
 
84
- this._toggles = [];
85
- this._pages = [];
86
-
87
- pages.forEach((page, index) => {
88
- const title = page.getAttribute('title') ?? '';
89
- const icon = (page.getAttribute('icon-name') ?? '').replace(/-symbolic$/, '');
90
-
91
- const toggle = document.createElement('button');
92
- toggle.type = 'button';
93
- toggle.className = 'adw-inline-view-switcher-toggle';
94
- toggle.setAttribute('role', 'tab');
95
- // The toggle stores both title + icon; _render() shows the parts the
96
- // current display mode calls for (mirrors libadwaita rebuilding the
97
- // toggle child on a display-mode change).
98
- toggle.dataset.title = title;
99
- toggle.dataset.icon = icon;
100
-
101
- const iconEl = document.createElement('span');
102
- iconEl.className = `adw-icon${icon ? ` adw-icon--${icon}` : ''} adw-inline-view-switcher-icon`;
103
- iconEl.setAttribute('aria-hidden', 'true'); // decorative (mask-image only)
104
- toggle.appendChild(iconEl);
105
-
106
- const labelEl = document.createElement('span');
107
- labelEl.className = 'adw-inline-view-switcher-label';
108
- labelEl.textContent = title;
109
- toggle.appendChild(labelEl);
110
-
111
- toggle.addEventListener('click', () => this._selectIndex(index));
112
- this._groupEl.appendChild(toggle);
113
- this._toggles.push(toggle);
114
-
115
- // The page content area — the declared element's children are moved
116
- // into a panel that the switcher toggles visible.
193
+ this._panels = declared.map((pageEl) => {
117
194
  const panel = document.createElement('div');
118
195
  panel.className = 'adw-inline-view-switcher-page';
119
196
  panel.setAttribute('role', 'tabpanel');
120
- for (const child of Array.from(page.childNodes)) panel.appendChild(child);
197
+ panel.dataset.name = pageEl.getAttribute('name') ?? '';
198
+ for (const child of Array.from(pageEl.childNodes)) panel.appendChild(child);
121
199
  this._pagesEl.appendChild(panel);
122
- this._pages.push(panel);
200
+ return panel;
123
201
  });
124
202
 
125
203
  this.replaceChildren(this._groupEl, this._pagesEl);
126
204
 
127
- this._active = this._readActiveAttr();
128
205
  this._displayMode = this._readDisplayModeAttr();
129
- this._render();
206
+ this._initialized = true;
207
+
208
+ this._declared = declared;
209
+ this._watchPages();
210
+ this._state.setPages(declared.map((pageEl) => readSwitcherPage(pageEl)));
211
+ if (declaredActive !== null) {
212
+ const index = Number.parseInt(declaredActive, 10);
213
+ if (!Number.isNaN(index)) this._state.setSelected(index, false);
214
+ }
215
+ this._rebuildToggles();
216
+ }
217
+
218
+ disconnectedCallback() {
219
+ this._state.cancelDrag();
220
+ this._pageObserver?.disconnect();
221
+ this._pageObserver = undefined;
222
+ }
223
+
224
+ /**
225
+ * Re-read every declared page and push it into the state.
226
+ *
227
+ * Driven by {@link _watchPages}, which is what makes a runtime
228
+ * `badge-number` or `icon-name` change reach the toggle — C rebinds on every
229
+ * page `notify` (adw-view-switcher.c:184-193) and this element did not.
230
+ */
231
+ refreshPages(): void {
232
+ if (!this._initialized) return;
233
+ this._state.setPages(this._declared.map((pageEl) => readSwitcherPage(pageEl)));
234
+ this._rebuildToggles();
235
+ }
236
+
237
+ /**
238
+ * Watch the declared pages for attribute changes — the DOM's `notify::` on
239
+ * `AdwViewStackPage`, which C binds per page (adw-view-switcher.c:184-193).
240
+ *
241
+ * A `MutationObserver` rather than an `attributeChangedCallback`: this
242
+ * element consumes the page elements at connect and `replaceChildren`s them
243
+ * out of the tree, so a detached page cannot find its owner. It delivers on
244
+ * a MICROTASK where GTK's notify is synchronous — invisible to a user, and
245
+ * nothing here reads the toggle back inside the setter.
246
+ */
247
+ private _watchPages(): void {
248
+ this._pageObserver?.disconnect();
249
+ this._pageObserver = new MutationObserver(() => this.refreshPages());
250
+ for (const pageEl of this._declared) this._pageObserver.observe(pageEl, { attributes: true });
130
251
  }
131
252
 
132
253
  attributeChangedCallback(name: string) {
133
254
  if (!this._initialized) return;
134
255
  if (name === 'active') {
135
- const next = this._readActiveAttr();
136
- if (next !== this._active) {
137
- this._active = next;
138
- this._render();
139
- }
256
+ const raw = this.getAttribute('active');
257
+ const index = raw === null ? Number.NaN : Number.parseInt(raw, 10);
258
+ if (!Number.isNaN(index)) this._state.setSelected(index);
259
+ this._reflectActive();
140
260
  return;
141
261
  }
142
262
  if (name === 'display-mode') {
143
263
  const next = this._readDisplayModeAttr();
144
- if (next !== this._displayMode) {
145
- this._displayMode = next;
146
- this._render();
147
- }
264
+ if (next === this._displayMode) return;
265
+ this._displayMode = next;
266
+ // C rebuilds every toggle's child on a mode change rather than
267
+ // toggling visibility on the existing ones (:852-854).
268
+ this._rebuildToggles();
269
+ this.dispatchEvent(
270
+ new CustomEvent('notify::display-mode', { bubbles: true, detail: { displayMode: next } }),
271
+ );
148
272
  return;
149
273
  }
150
274
  // flat / round are styling-only.
151
- this._render();
275
+ this._applyStyleClasses();
152
276
  }
153
277
 
154
- private _readActiveAttr(): number {
155
- const raw = Number.parseInt(this.getAttribute('active') ?? '0', 10);
156
- if (Number.isNaN(raw)) return 0;
157
- const max = Math.max(0, this._pages.length - 1);
158
- return Math.min(Math.max(raw, 0), max);
278
+ private _readDisplayModeAttr(): AdwInlineViewSwitcherDisplayMode {
279
+ const raw = this.getAttribute('display-mode');
280
+ if (raw === null) return 'labels';
281
+ return isInlineViewSwitcherDisplayMode(raw) ? raw : this._displayMode;
159
282
  }
160
283
 
161
- private _readDisplayModeAttr(): DisplayMode {
162
- const raw = this.getAttribute('display-mode') ?? 'both';
163
- return (DISPLAY_MODES as readonly string[]).includes(raw) ? (raw as DisplayMode) : 'both';
284
+ /**
285
+ * Rebuild the toggle row from the derived models. One node per VISIBLE page,
286
+ * in compacted order — the structural half of `recreate_toggles`. Selection
287
+ * alone never lands here: `selection_changed_cb` only sets the active toggle
288
+ * (adw-inline-view-switcher.c:434-453), and rebuilding mid-drag would drop
289
+ * the dwell the drag has already armed.
290
+ */
291
+ private _rebuildToggles(): void {
292
+ if (!this._initialized) return;
293
+
294
+ const models = buildInlineToggles(this._state.pages, this._displayMode);
295
+ this._groupEl.replaceChildren();
296
+ this._toggles = models.map((model) => {
297
+ const toggle = document.createElement('button');
298
+ toggle.type = 'button';
299
+ toggle.className = 'adw-inline-view-switcher-toggle';
300
+ toggle.setAttribute('role', 'tab');
301
+ // Both index spaces, so a consumer never has to recompute either.
302
+ toggle.dataset.pageIndex = String(model.pageIndex);
303
+ toggle.dataset.toggleIndex = String(model.toggleIndex);
304
+
305
+ if (model.showIcon) {
306
+ toggle.appendChild(createSwitcherIcon(model.iconName, 'adw-inline-view-switcher-icon'));
307
+ }
308
+ if (model.showLabel) {
309
+ const label = document.createElement('span');
310
+ label.className = 'adw-inline-view-switcher-label';
311
+ label.textContent = model.label;
312
+ toggle.appendChild(label);
313
+ }
314
+
315
+ const indicator = document.createElement('span');
316
+ indicator.className = 'adw-inline-view-switcher-indicator';
317
+ indicator.setAttribute('aria-hidden', 'true');
318
+ applyIndicator(indicator, model.badgeLabel, model.needsAttention);
319
+ toggle.appendChild(indicator);
320
+
321
+ if (model.tooltip) toggle.title = model.tooltip;
322
+ // An icon-only toggle has no visible text; the tooltip is also its
323
+ // accessible name, which is exactly what C's toggle does.
324
+ if (!model.showLabel) toggle.setAttribute('aria-label', model.tooltip || model.iconName);
325
+ applyDescription(toggle, model.description);
326
+
327
+ toggle.addEventListener('click', () => {
328
+ this._state.setSelected(model.pageIndex);
329
+ });
330
+ // The `relatedTarget` test is load-bearing: a toggle holds an icon,
331
+ // a label and an indicator, and the DOM fires `dragleave` +
332
+ // `dragenter` when the pointer crosses BETWEEN them — which cleared
333
+ // the dwell timer and re-armed a fresh 500ms, so a drag that merely
334
+ // slid across the toggle never switched the page. C has one
335
+ // `GtkDropControllerMotion` per BUTTON widget
336
+ // (adw-view-switcher-button.c:79-96), which has no internal edges.
337
+ toggle.addEventListener('dragenter', (event) => {
338
+ if (toggle.contains((event as DragEvent).relatedTarget as Node | null)) return;
339
+ this._state.dragEnter(model.pageIndex);
340
+ });
341
+ toggle.addEventListener('dragleave', (event) => {
342
+ if (toggle.contains((event as DragEvent).relatedTarget as Node | null)) return;
343
+ this._state.dragLeave(model.pageIndex);
344
+ });
345
+
346
+ this._groupEl.appendChild(toggle);
347
+ return toggle;
348
+ });
349
+
350
+ this._render();
164
351
  }
165
352
 
166
- private _selectIndex(index: number): void {
167
- if (index === this._active) return;
168
- this._active = index;
169
- // Keep the attribute in sync without re-entering via the guarded
170
- // attributeChangedCallback (value already matches, so it no-ops).
171
- this.setAttribute('active', String(index));
353
+ private _onStateChange(change: ViewSwitcherStateChange): void {
172
354
  this._render();
173
- this.dispatchEvent(new CustomEvent('notify::active', { bubbles: true, detail: { active: index } }));
355
+ this.dispatchEvent(
356
+ new CustomEvent('notify::active', {
357
+ bubbles: true,
358
+ detail: {
359
+ active: change.selected,
360
+ name: change.name,
361
+ toggleIndex: this.activeToggle,
362
+ interactive: change.interactive,
363
+ },
364
+ }),
365
+ );
174
366
  }
175
367
 
176
368
  private _render(): void {
177
- this.classList.toggle('flat', this.hasAttribute('flat'));
178
- this.classList.toggle('round', this.hasAttribute('round'));
179
-
180
- // The bar carries the active display mode as a class — mirrors
181
- // libadwaita's inline-view-switcher > toggle-group.{labels,icons,both}.
182
- for (const mode of DISPLAY_MODES) this._groupEl.classList.toggle(mode, mode === this._displayMode);
369
+ if (!this._initialized) return;
183
370
 
184
- const showIcon = this._displayMode !== 'labels';
185
- const showLabel = this._displayMode !== 'icons';
371
+ this._applyStyleClasses();
186
372
 
187
- this._toggles.forEach((toggle, index) => {
188
- const isActive = index === this._active;
373
+ const activeToggle = this.activeToggle;
374
+ this._toggles.forEach((toggle, toggleIndex) => {
375
+ const isActive = toggleIndex === activeToggle && activeToggle !== VIEW_SWITCHER_NO_SELECTION;
189
376
  toggle.classList.toggle('active', isActive);
190
377
  toggle.setAttribute('aria-selected', String(isActive));
191
378
  toggle.tabIndex = isActive ? 0 : -1;
192
-
193
- const iconEl = toggle.querySelector('.adw-inline-view-switcher-icon') as HTMLElement | null;
194
- const labelEl = toggle.querySelector('.adw-inline-view-switcher-label') as HTMLElement | null;
195
- const hasIcon = (toggle.dataset.icon ?? '').length > 0;
196
- const title = toggle.dataset.title ?? '';
197
-
198
- if (iconEl) iconEl.hidden = !showIcon || !hasIcon;
199
- if (labelEl) labelEl.hidden = !showLabel || title.length === 0;
200
-
201
- // In icons-only mode the toggle has no visible text — surface the
202
- // title as an accessible name + a hover tooltip (the native widget
203
- // sets the toggle tooltip to the page title in ICONS mode).
204
- if (!showLabel && title) {
205
- toggle.setAttribute('aria-label', title);
206
- toggle.title = title;
207
- } else {
208
- toggle.removeAttribute('aria-label');
209
- toggle.removeAttribute('title');
210
- }
211
379
  });
212
380
 
213
- this._pages.forEach((panel, index) => {
214
- const isActive = index === this._active;
381
+ const selected = this._state.selected;
382
+ this._panels.forEach((panel, index) => {
383
+ const isActive = index === selected;
215
384
  panel.classList.toggle('active-view', isActive);
216
385
  panel.hidden = !isActive;
217
386
  });
387
+
388
+ this._reflectActive();
389
+ }
390
+
391
+ private _applyStyleClasses(): void {
392
+ // `css_classes_changed_cb` (adw-inline-view-switcher.c:528-545) forwards
393
+ // THREE classes onto the toggle group, not two: `flat`, `round` and
394
+ // `osd`. This element carried the first two and only onto the host, so an
395
+ // `osd` inline switcher — the one in a floating overlay, where the class
396
+ // exists to make it legible — styled nothing at all.
397
+ for (const name of ['flat', 'round', 'osd'] as const) {
398
+ const present = this.hasAttribute(name);
399
+ this.classList.toggle(name, present);
400
+ this._groupEl?.classList.toggle(name, present);
401
+ }
402
+ // The group carries the display-mode class, mirroring libadwaita's
403
+ // inline-view-switcher > toggle-group.{labels,icons,both} (:826-850).
404
+ for (const mode of INLINE_VIEW_SWITCHER_DISPLAY_MODES) {
405
+ this._groupEl.classList.toggle(mode, mode === this._displayMode);
406
+ }
407
+ }
408
+
409
+ private _reflectActive(): void {
410
+ const current = String(this._state.selected);
411
+ if (this.getAttribute('active') !== current) this.setAttribute('active', current);
218
412
  }
219
413
  }
220
414