@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
@@ -5,6 +5,20 @@
5
5
  // real Adwaita-styled popover positioned under the button; the popover is
6
6
  // dismissed on an outside click or Escape and supports arrow-key navigation.
7
7
  //
8
+ // The popover is `<adw-popover>` and the dismissal/keyboard machine is
9
+ // `@gjsify/adwaita-core`'s (ADR 0004). This element used to build both by hand,
10
+ // as did `<adw-drop-down>` and `<adw-split-button>` — three surfaces that
11
+ // disagreed with libadwaita and with each other on radius, shadow and padding,
12
+ // and two copies of the same `(current ± 1 + n) % n` arithmetic. This element
13
+ // keeps only what is a menu button: the icon, the item model, the title.
14
+ //
15
+ // THE ICON WENT THE SAME WAY, and it is where the drift actually bit: this
16
+ // element interpolated `class="adw-icon adw-menu-button-icon adw-icon--<name>"`
17
+ // while `<adw-split-button>` — alone among six copies — first checked the name
18
+ // was one CSS token. So `icon-name="a b"` shipped a stray `b` class here, and so
19
+ // did every JSON menu entry's `icon`. Both are `<adw-icon>` now, and the guard
20
+ // is `normalizeIconName`'s.
21
+ //
8
22
  // Attributes:
9
23
  // icon-name — symbolic icon (no `-symbolic` suffix), default `open-menu`.
10
24
  // menu-title — optional heading shown atop the popover.
@@ -12,6 +26,10 @@
12
26
  // disabled — boolean; a disabled button does not open the menu.
13
27
  // flat — boolean style class (default: flat, like the header app menu).
14
28
  // circular — boolean style class (round 34×34 button).
29
+ // direction — none | up | down | left | right (default down); where the
30
+ // popover opens. `none` behaves as `down`, which is
31
+ // Gtk.MenuButton's own rule, not ours — see
32
+ // {@link menuButtonPopupDirection}.
15
33
  // Properties (mirroring Adw.MenuButton / the NS twin):
16
34
  // menuItems — the menu entries ({ id?, label, icon? }[]) (get/set).
17
35
  // menuTitle — the popover heading (get/set).
@@ -26,6 +44,21 @@
26
44
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
27
45
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
28
46
 
47
+ import { isSplitButtonDirection, menuButtonPopupDirection } from '@gjsify/adwaita-core';
48
+ import type { SplitButtonDirection } from '@gjsify/adwaita-core';
49
+
50
+ // SIDE-EFFECT import, deliberately separate from the type import below: it is
51
+ // what guarantees `adw-popover` is defined before this module's own
52
+ // `customElements.define` can upgrade a server-rendered `<adw-menu-button>` and
53
+ // build one. A combined `import { AdwPopover }` would NOT do it — the binding is
54
+ // only ever used in type position here, and this package compiles without
55
+ // `verbatimModuleSyntax`, so TypeScript would elide the whole statement and take
56
+ // the registration with it.
57
+ import './adw-popover.js';
58
+ import type { AdwPopover } from './adw-popover.js';
59
+
60
+ import { createAdwIcon } from './adw-icon.js';
61
+
29
62
  /** A menu entry. `id` defaults to `label` on activation; `icon` is optional. */
30
63
  export interface AdwMenuItem {
31
64
  id?: string;
@@ -33,31 +66,38 @@ export interface AdwMenuItem {
33
66
  icon?: string;
34
67
  }
35
68
 
69
+ /**
70
+ * Where the popover sits, per `GtkArrowType`. `menuButtonPopupDirection` folds
71
+ * `none` onto `down` (adw-split-button.c:415 — a pass-through to
72
+ * `gtk_menu_button_set_direction`, so it is GtkMenuButton's rule); this table is
73
+ * only the direction→CSS-axis mapping, which is a renderer fact.
74
+ *
75
+ * `left`/`right` become `start`/`end` because the surface is placed with
76
+ * logical properties, so it follows the writing direction the way GTK's
77
+ * `:dir(rtl)` popovers do.
78
+ */
79
+ const POPOVER_POSITIONS = {
80
+ down: 'bottom',
81
+ up: 'top',
82
+ left: 'start',
83
+ right: 'end',
84
+ } as const;
85
+
36
86
  export class AdwMenuButton extends HTMLElement {
37
87
  private _buttonEl!: HTMLButtonElement;
38
- private _popoverEl!: HTMLDivElement;
88
+ private _popoverEl!: AdwPopover;
39
89
  private _items: AdwMenuItem[] = [];
40
90
  private _menuTitle = '';
41
- private _active = false;
42
91
  private _initialized = false;
43
92
  private _itemButtons: HTMLButtonElement[] = [];
44
- private _onDocumentPointerDown = (event: Event): void => {
45
- if (!this.contains(event.target as Node)) this._setActive(false);
46
- };
47
- private _onDocumentKeyDown = (event: KeyboardEvent): void => {
48
- if (event.key === 'Escape') {
49
- this._setActive(false);
50
- this._buttonEl.focus();
51
- }
52
- };
53
93
 
54
94
  static get observedAttributes() {
55
- return ['icon-name', 'menu-title', 'menu', 'disabled', 'flat', 'circular'];
95
+ return ['icon-name', 'menu-title', 'menu', 'disabled', 'flat', 'circular', 'direction'];
56
96
  }
57
97
 
58
98
  /** Whether the popover menu is currently open. */
59
99
  get active(): boolean {
60
- return this._active;
100
+ return this._popoverEl?.open ?? false;
61
101
  }
62
102
 
63
103
  /** The menu entries. Setting rebuilds the popover. */
@@ -80,6 +120,16 @@ export class AdwMenuButton extends HTMLElement {
80
120
  this.setAttribute('menu-title', this._menuTitle);
81
121
  }
82
122
 
123
+ /** Where the popover opens, and (via core) which way `none` folds. */
124
+ get direction(): SplitButtonDirection {
125
+ const value = this.getAttribute('direction');
126
+ return isSplitButtonDirection(value) ? value : 'down';
127
+ }
128
+
129
+ set direction(value: SplitButtonDirection) {
130
+ this.setAttribute('direction', value);
131
+ }
132
+
83
133
  connectedCallback() {
84
134
  if (this._initialized) return;
85
135
  this._initialized = true;
@@ -92,17 +142,23 @@ export class AdwMenuButton extends HTMLElement {
92
142
  this._buttonEl.setAttribute('aria-haspopup', 'menu');
93
143
  this._buttonEl.setAttribute('aria-expanded', 'false');
94
144
 
95
- this._popoverEl = document.createElement('div');
96
- this._popoverEl.className = 'adw-menu-button-popover';
145
+ this._popoverEl = document.createElement('adw-popover') as AdwPopover;
146
+ this._popoverEl.classList.add('adw-menu-button-popover');
97
147
  this._popoverEl.setAttribute('role', 'menu');
98
- this._popoverEl.hidden = true;
99
- this._popoverEl.addEventListener('keydown', (e) => this._onMenuKeyDown(e));
148
+ // `gtk_menu_button_set_menu_model` builds a GtkPopoverMenu, whose CSS
149
+ // node is `popover.menu` — the surface _menus.scss styles throughout.
150
+ this._popoverEl.setAttribute('menu', '');
151
+ this._popoverEl.setAttribute('align', 'end'); // the header-end app menu idiom
100
152
 
101
153
  this.replaceChildren(this._buttonEl, this._popoverEl);
154
+ this._popoverEl.anchor = this._buttonEl;
155
+ this._popoverEl.subscribe((open) => this._onPopoverToggled(open));
102
156
 
103
157
  this._buttonEl.addEventListener('click', () => {
104
158
  if (this.hasAttribute('disabled')) return;
105
- this._setActive(!this._active);
159
+ // Nothing to show is not a menu — never open an empty popover.
160
+ if (!this._popoverEl.open && this._items.length === 0) return;
161
+ this._popoverEl.open = !this._popoverEl.open;
106
162
  });
107
163
 
108
164
  // Seed items from the `menu` attribute if the property was not set.
@@ -114,11 +170,6 @@ export class AdwMenuButton extends HTMLElement {
114
170
  this._render();
115
171
  }
116
172
 
117
- disconnectedCallback() {
118
- document.removeEventListener('pointerdown', this._onDocumentPointerDown);
119
- document.removeEventListener('keydown', this._onDocumentKeyDown);
120
- }
121
-
122
173
  attributeChangedCallback(name: string) {
123
174
  if (!this._initialized || !this._buttonEl) return;
124
175
  if (name === 'menu') {
@@ -143,52 +194,26 @@ export class AdwMenuButton extends HTMLElement {
143
194
  }
144
195
  }
145
196
 
146
- private _setActive(active: boolean): void {
147
- if (this._active === active) return;
148
- // Only open when there is something to show.
149
- if (active && this._items.length === 0) return;
150
- this._active = active;
151
- this._popoverEl.hidden = !active;
152
- this.classList.toggle('active', active);
153
- this._buttonEl.setAttribute('aria-expanded', String(active));
154
- if (active) {
155
- document.addEventListener('pointerdown', this._onDocumentPointerDown);
156
- document.addEventListener('keydown', this._onDocumentKeyDown);
157
- // Move focus into the menu for keyboard navigation.
158
- this._itemButtons[0]?.focus();
159
- } else {
160
- document.removeEventListener('pointerdown', this._onDocumentPointerDown);
161
- document.removeEventListener('keydown', this._onDocumentKeyDown);
162
- }
163
- }
164
-
165
- private _onMenuKeyDown(event: KeyboardEvent): void {
166
- const buttons = this._itemButtons;
167
- if (buttons.length === 0) return;
168
- const current = buttons.indexOf(document.activeElement as HTMLButtonElement);
169
- let next = -1;
170
- if (event.key === 'ArrowDown') next = (current + 1) % buttons.length;
171
- else if (event.key === 'ArrowUp') next = (current - 1 + buttons.length) % buttons.length;
172
- else if (event.key === 'Home') next = 0;
173
- else if (event.key === 'End') next = buttons.length - 1;
174
- else return;
175
- event.preventDefault();
176
- buttons[next]?.focus();
197
+ private _onPopoverToggled(open: boolean): void {
198
+ this.classList.toggle('active', open);
199
+ this._buttonEl.setAttribute('aria-expanded', String(open));
200
+ // Move focus into the menu for keyboard navigation.
201
+ if (open) this._itemButtons[0]?.focus();
177
202
  }
178
203
 
179
204
  private _render(): void {
180
205
  const disabled = this.hasAttribute('disabled');
181
206
  this.classList.toggle('disabled', disabled);
182
207
  this._buttonEl.disabled = disabled;
208
+ if (disabled) this._popoverEl.popdown();
183
209
  this.classList.toggle('flat', this.hasAttribute('flat'));
184
210
  this.classList.toggle('circular', this.hasAttribute('circular'));
211
+ this._popoverEl.position = POPOVER_POSITIONS[menuButtonPopupDirection(this.direction)];
185
212
 
186
- const icon = (this.getAttribute('icon-name') ?? 'open-menu').replace(/-symbolic$/, '');
187
213
  this._buttonEl.replaceChildren();
188
- const iconEl = document.createElement('span');
189
- iconEl.className = `adw-icon adw-menu-button-icon${icon ? ` adw-icon--${icon}` : ''}`;
190
- iconEl.setAttribute('aria-hidden', 'true'); // decorative (mask-image only)
191
- this._buttonEl.appendChild(iconEl);
214
+ this._buttonEl.appendChild(
215
+ createAdwIcon(this.getAttribute('icon-name') ?? 'open-menu', 'adw-menu-button-icon'),
216
+ );
192
217
  this._buttonEl.setAttribute('aria-label', this.getAttribute('menu-title') || 'Menu');
193
218
 
194
219
  this._renderMenu();
@@ -201,7 +226,7 @@ export class AdwMenuButton extends HTMLElement {
201
226
  const title = this.getAttribute('menu-title') ?? this._menuTitle;
202
227
  if (title) {
203
228
  const titleEl = document.createElement('div');
204
- titleEl.className = 'adw-menu-button-title';
229
+ titleEl.className = 'adw-popover-title adw-menu-button-title';
205
230
  titleEl.textContent = title;
206
231
  this._popoverEl.appendChild(titleEl);
207
232
  }
@@ -209,24 +234,22 @@ export class AdwMenuButton extends HTMLElement {
209
234
  for (const [index, entry] of this._items.entries()) {
210
235
  const item = document.createElement('button');
211
236
  item.type = 'button';
212
- item.className = 'adw-menu-button-item';
237
+ item.className = 'adw-popover-item adw-menu-button-item';
213
238
  item.setAttribute('role', 'menuitem');
214
239
  item.tabIndex = -1;
215
240
 
216
- const iconName = (entry.icon ?? '').replace(/-symbolic$/, '');
217
- if (iconName) {
218
- const iconEl = document.createElement('span');
219
- iconEl.className = `adw-icon adw-menu-button-item-icon adw-icon--${iconName}`;
220
- iconEl.setAttribute('aria-hidden', 'true');
221
- item.appendChild(iconEl);
222
- }
241
+ // An entry with no USABLE icon gets no icon node at all, rather than
242
+ // an empty 16px box — the element itself answers whether the name
243
+ // resolved, so the `-symbolic`-only and bad-token cases agree.
244
+ const entryIcon = createAdwIcon(entry.icon ?? null, 'adw-menu-button-item-icon');
245
+ if (entryIcon.resolvedIconName !== '') item.appendChild(entryIcon);
223
246
  const labelEl = document.createElement('span');
224
247
  labelEl.className = 'adw-menu-button-item-label';
225
248
  labelEl.textContent = entry.label;
226
249
  item.appendChild(labelEl);
227
250
 
228
251
  item.addEventListener('click', () => {
229
- this._setActive(false);
252
+ this._popoverEl.popdown();
230
253
  this._buttonEl.focus();
231
254
  this.dispatchEvent(
232
255
  new CustomEvent('menu-item-activated', {
@@ -240,7 +263,7 @@ export class AdwMenuButton extends HTMLElement {
240
263
  }
241
264
 
242
265
  // A menu-button with no entries can never open.
243
- if (this._items.length === 0) this._setActive(false);
266
+ if (this._items.length === 0) this._popoverEl.popdown();
244
267
  }
245
268
  }
246
269
 
@@ -2,24 +2,74 @@
2
2
  // visible when expanded; when `collapsed` it shows one pane at a time
3
3
  // (`show-content` decides which), mirroring Adw.NavigationSplitView's
4
4
  // push-navigation on narrow widths.
5
- // Attributes: collapsed, show-content, min-sidebar-width, max-sidebar-width.
5
+ //
6
+ // Attributes: collapsed, show-content, sidebar-position, min-sidebar-width,
7
+ // max-sidebar-width, breakpoint (an Adwaita condition, e.g. "max-width: 720px",
8
+ // that collapses the view by itself — the counterpart to an Adw.Breakpoint
9
+ // add_setter()). A slotted pane may carry `tag`, which is
10
+ // `Adw.NavigationPage:tag`.
6
11
  // Slots: slot="sidebar", slot="content".
12
+ //
13
+ // The element used to toggle two CSS classes and nothing else, so THREE rules
14
+ // from the C had no expression here at all: the navigation-stack ordering table
15
+ // (a LONE child stays visible whatever `show-content` says; with
16
+ // `sidebar-position: end` the CONTENT is the root page), the duplicate-tag
17
+ // guard, and the `navigation.push` / `navigation.pop` routing that lets a nested
18
+ // split view forward a push outwards. All three are `NavigationSplitViewState`
19
+ // in `@gjsify/adwaita-core`, held to NAVIGATION_STACK_VECTORS,
20
+ // NAVIGATION_SPLIT_VIEW_CRITICALS and NAVIGATION_ACTION_VECTORS — the last of
21
+ // which had no consumer at all before this.
22
+ //
7
23
  // Reference: refs/adwaita-web/adwaita-web/docs/widgets/navigationsplitview.md
24
+ // Reference: refs/libadwaita/src/adw-navigation-split-view.c
8
25
  // Reference: refs/libadwaita/src/stylesheet/widgets/_sidebars.scss
9
26
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
10
27
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
11
28
 
29
+ import {
30
+ DEFAULT_MAX_SIDEBAR_WIDTH,
31
+ DEFAULT_MIN_SIDEBAR_WIDTH,
32
+ NavigationSplitViewState,
33
+ isSidebarAtVisualStart,
34
+ resolveSidebarBounds,
35
+ type AdwPackType,
36
+ type AdwTextDirection,
37
+ type NavigationActionResult,
38
+ } from '@gjsify/adwaita-core';
39
+
40
+ import { bindBreakpointSetter } from '../breakpoints.js';
41
+
42
+ /** Detail of the `navigation-push` / `navigation-pop` delegation event. */
43
+ export interface NavigationDelegateDetail {
44
+ /** The tag a `push` names; absent for a `pop`. */
45
+ tag?: string;
46
+ /** Set by an ancestor that handled it — the `return TRUE` of the C's routing. */
47
+ handled: boolean;
48
+ }
49
+
12
50
  export class AdwNavigationSplitView extends HTMLElement {
13
51
  private _sidebarEl!: HTMLDivElement;
14
52
  private _contentEl!: HTMLDivElement;
15
53
  private _initialized = false;
54
+ private _disposeBreakpoint: (() => void) | undefined;
55
+ /** The ordering table, the tag guard and the action routing (ADR 0004). */
56
+ private _state = new NavigationSplitViewState();
57
+ /** Re-entrancy guard for the `show-content` reflection. */
58
+ private _reflecting = false;
16
59
 
17
60
  static get observedAttributes() {
18
- return ['collapsed', 'show-content', 'min-sidebar-width', 'max-sidebar-width'];
61
+ return [
62
+ 'collapsed',
63
+ 'show-content',
64
+ 'sidebar-position',
65
+ 'min-sidebar-width',
66
+ 'max-sidebar-width',
67
+ 'breakpoint',
68
+ ];
19
69
  }
20
70
 
21
71
  get collapsed(): boolean {
22
- return this.hasAttribute('collapsed');
72
+ return this._initialized ? this._state.collapsed : this.hasAttribute('collapsed');
23
73
  }
24
74
 
25
75
  set collapsed(v: boolean) {
@@ -28,13 +78,49 @@ export class AdwNavigationSplitView extends HTMLElement {
28
78
 
29
79
  /** When collapsed, whether the content pane (true) or sidebar (false) shows. */
30
80
  get showContent(): boolean {
31
- return this.hasAttribute('show-content');
81
+ return this._initialized ? this._state.showContent : this.hasAttribute('show-content');
32
82
  }
33
83
 
34
84
  set showContent(v: boolean) {
35
85
  this.toggleAttribute('show-content', v);
36
86
  }
37
87
 
88
+ /** Which side the sidebar is packed on — `start` (default) or `end`. */
89
+ get sidebarPosition(): AdwPackType {
90
+ return this.getAttribute('sidebar-position') === 'end' ? 'end' : 'start';
91
+ }
92
+
93
+ set sidebarPosition(value: AdwPackType) {
94
+ this.setAttribute('sidebar-position', value);
95
+ }
96
+
97
+ /** The sidebar page's tag of record, or `null`. */
98
+ get sidebarTag(): string | null {
99
+ return this._state.sidebarTag;
100
+ }
101
+
102
+ /** The content page's tag of record, or `null`. */
103
+ get contentTag(): string | null {
104
+ return this._state.contentTag;
105
+ }
106
+
107
+ /**
108
+ * `navigation.push` with `tag` — `navigation_push_cb` (:644-685).
109
+ *
110
+ * An unmatched tag DELEGATES to the parent before it may become a critical,
111
+ * which is how nested split views forward a push outwards; on this port that
112
+ * is a bubbling `navigation-push` event whose `detail.handled` an ancestor
113
+ * sets. Returns what the routing decided.
114
+ */
115
+ push(tag: string): NavigationActionResult {
116
+ return this._state.push(tag);
117
+ }
118
+
119
+ /** `navigation.pop` — `navigation_pop_cb` (:687-702). */
120
+ pop(): NavigationActionResult {
121
+ return this._state.pop();
122
+ }
123
+
38
124
  connectedCallback() {
39
125
  if (this._initialized) return;
40
126
  this._initialized = true;
@@ -51,18 +137,89 @@ export class AdwNavigationSplitView extends HTMLElement {
51
137
  for (const child of contentChildren) this._contentEl.appendChild(child);
52
138
 
53
139
  this.replaceChildren(this._sidebarEl, this._contentEl);
140
+
141
+ // Parse-time attributes are the CONSTRUCTION properties, for the same
142
+ // reason the overlay view treats them so: applying them as sequential
143
+ // setters would emit a transition for a state the markup simply declared.
144
+ this._state = new NavigationSplitViewState({
145
+ sidebarPosition: this.sidebarPosition,
146
+ collapsed: this.hasAttribute('collapsed'),
147
+ showContent: this.hasAttribute('show-content'),
148
+ // `g_critical` has no browser counterpart that a consumer can see;
149
+ // `console.error` is the one a developer reads, and the TEXT is the
150
+ // C's verbatim so a GTK bug report and a browser one say the same.
151
+ onCritical: (message) => console.error(message),
152
+ onDelegate: (action, tag) => {
153
+ const detail: NavigationDelegateDetail = { tag, handled: false };
154
+ this.dispatchEvent(new CustomEvent(`navigation-${action}`, { bubbles: true, detail }));
155
+ return detail.handled;
156
+ },
157
+ });
158
+ // Which children EXIST decides the stack, so the panes are mounted into
159
+ // the state, not merely into the DOM — a lone child stays visible
160
+ // whatever `show-content` says (:389-401), which two CSS classes cannot
161
+ // express.
162
+ this._state.setSidebar(sidebarChildren.length > 0 ? { tag: this._tagOf(sidebarChildren) } : null);
163
+ this._state.setContent(contentChildren.length > 0 ? { tag: this._tagOf(contentChildren) } : null);
164
+ this._state.subscribe(() => {
165
+ this._reflectShowContent();
166
+ this._syncClasses();
167
+ });
168
+
54
169
  this._syncWidth();
55
170
  this._syncClasses();
171
+ this._syncBreakpoint();
172
+ }
173
+
174
+ /** `Adw.NavigationPage:tag` off the slotted pane, or `null` when untagged. */
175
+ private _tagOf(children: readonly Element[]): string | null {
176
+ for (const child of children) {
177
+ const tag = child.getAttribute('tag');
178
+ if (tag !== null) return tag;
179
+ }
180
+ return null;
181
+ }
182
+
183
+ /**
184
+ * Mirror the state's `showContent` onto the attribute, so the DOM keeps
185
+ * telling the truth after a `navigation.push` moved it.
186
+ *
187
+ * Guarded the way the overlay view's reflection is: writing the attribute
188
+ * re-enters `attributeChangedCallback`, which without the flag is a loop.
189
+ */
190
+ private _reflectShowContent() {
191
+ if (this._reflecting) return;
192
+ const wanted = this._state.showContent;
193
+ if (this.hasAttribute('show-content') === wanted) return;
194
+ this._reflecting = true;
195
+ try {
196
+ this.toggleAttribute('show-content', wanted);
197
+ } finally {
198
+ this._reflecting = false;
199
+ }
200
+ }
201
+
202
+ disconnectedCallback() {
203
+ this._disposeBreakpoint?.();
204
+ this._disposeBreakpoint = undefined;
56
205
  }
57
206
 
58
207
  attributeChangedCallback(name: string) {
59
208
  if (!this._initialized) return;
60
- if (name === 'min-sidebar-width' || name === 'max-sidebar-width') this._syncWidth();
61
- else {
62
- this._syncClasses();
63
- // Collapsing flips whether the sidebar is width-capped, so re-apply.
209
+ if (name === 'breakpoint') {
210
+ this._syncBreakpoint();
211
+ return;
212
+ }
213
+ if (name === 'min-sidebar-width' || name === 'max-sidebar-width') {
64
214
  this._syncWidth();
215
+ return;
65
216
  }
217
+ if (name === 'collapsed') this._state.setCollapsed(this.hasAttribute('collapsed'));
218
+ if (name === 'show-content') this._state.setShowContent(this.hasAttribute('show-content'));
219
+ if (name === 'sidebar-position') this._state.setSidebarPosition(this.sidebarPosition);
220
+ this._syncClasses();
221
+ // Collapsing flips whether the sidebar is width-capped, so re-apply.
222
+ this._syncWidth();
66
223
  }
67
224
 
68
225
  private _syncWidth() {
@@ -74,15 +231,64 @@ export class AdwNavigationSplitView extends HTMLElement {
74
231
  this._sidebarEl.style.maxWidth = '';
75
232
  return;
76
233
  }
77
- const min = this.getAttribute('min-sidebar-width');
78
- const max = this.getAttribute('max-sidebar-width');
79
- this._sidebarEl.style.minWidth = min ? `${parseFloat(min)}px` : '';
80
- this._sidebarEl.style.maxWidth = max ? `${parseFloat(max)}px` : '';
234
+ // An ABSENT attribute used to mean "no bound at all", where
235
+ // Adw.NavigationSplitView always has 180 / 280
236
+ // (adw-navigation-split-view.c min-sidebar-width / max-sidebar-width).
237
+ // The core also normalises the pair — libadwaita never lets max fall
238
+ // below min, and CSS resolves that conflict the other way round.
239
+ const bounds = resolveSidebarBounds(
240
+ {
241
+ minSidebarWidth: this._widthAttr('min-sidebar-width', DEFAULT_MIN_SIDEBAR_WIDTH),
242
+ maxSidebarWidth: this._widthAttr('max-sidebar-width', DEFAULT_MAX_SIDEBAR_WIDTH),
243
+ },
244
+ 0,
245
+ { ceil: true },
246
+ );
247
+ this._sidebarEl.style.minWidth = `${bounds.min}px`;
248
+ this._sidebarEl.style.maxWidth = `${bounds.max}px`;
249
+ }
250
+
251
+ /** A pixel attribute, falling back to the widget's own default. */
252
+ private _widthAttr(name: string, fallback: number): number {
253
+ const raw = this.getAttribute(name);
254
+ if (raw === null) return fallback;
255
+ const value = parseFloat(raw);
256
+ return Number.isFinite(value) ? value : fallback;
257
+ }
258
+
259
+ /** The reading direction `start` / `end` resolve against (`get_start_or_end`, :220-227). */
260
+ private get _direction(): AdwTextDirection {
261
+ return getComputedStyle(this).direction === 'rtl' ? 'rtl' : 'ltr';
81
262
  }
82
263
 
83
264
  private _syncClasses() {
84
- this.classList.toggle('collapsed', this.collapsed);
85
- this.classList.toggle('show-content', this.showContent);
265
+ const state = this._state;
266
+ this.classList.toggle('collapsed', state.collapsed);
267
+ this.classList.toggle('show-content', state.showContent);
268
+ this.classList.toggle('sidebar-end', state.sidebarPosition === 'end');
269
+ // The VISUAL side, which under RTL is the opposite of the logical one —
270
+ // it decides where the pane is drawn AND which edge its divider is on.
271
+ this.classList.toggle(
272
+ 'sidebar-at-visual-start',
273
+ isSidebarAtVisualStart(state.sidebarPosition, this._direction),
274
+ );
275
+
276
+ // The STACK, not the two flags: `update_navigation_stack` (:342-405)
277
+ // keeps a LONE child visible whatever `show-content` says, and with
278
+ // `sidebar-position: end` the CONTENT is the root page — so the sidebar
279
+ // is PUSHED ON TOP of it rather than replacing it.
280
+ const { stack } = state.stack;
281
+ const visible = stack[stack.length - 1] ?? null;
282
+ if (this._sidebarEl) this._sidebarEl.dataset.paneVisible = String(!state.collapsed || visible === 'sidebar');
283
+ if (this._contentEl) this._contentEl.dataset.paneVisible = String(!state.collapsed || visible === 'content');
284
+ }
285
+
286
+ /** (Re)bind the `breakpoint` condition to `collapsed`. */
287
+ private _syncBreakpoint() {
288
+ this._disposeBreakpoint?.();
289
+ this._disposeBreakpoint = bindBreakpointSetter(this, this.getAttribute('breakpoint'), (active) => {
290
+ this.collapsed = active;
291
+ });
86
292
  }
87
293
  }
88
294