@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,50 +3,142 @@
3
3
  // halves are separated by a thin divider; the .suggested-action /
4
4
  // .destructive-action / .flat style classes recolor the whole control like the
5
5
  // matching Adwaita button variants.
6
- // Attributes: label, icon-name (symbolic name, e.g. "document-save"; an icon
7
- // without a label collapses the action half to icon-only), tooltip,
8
- // dropdown-tooltip, disabled, and the boolean variant flags
9
- // suggested / destructive / flat.
10
- // Menu entries are provided as a JSON array on the `menu` attribute, each
11
- // entry `{ "label": "Save as…", "action": "app.save-as" }`.
6
+ //
7
+ // Attributes:
8
+ // label — the action label. MUTUALLY EXCLUSIVE with icon-name.
9
+ // icon-name — symbolic name (with or without the `-symbolic` suffix).
10
+ // use-underline — boolean; an underline in the label marks a mnemonic.
11
+ // tooltip — tooltip/accessible name of the action half.
12
+ // dropdown-tooltip — tooltip of the dropdown half; empty restores "More Options".
13
+ // direction — none | up | down | left | right (default down).
14
+ // menu — JSON array of entries, each `{ "label", "action"? }`.
15
+ // disabled, flat, suggested, destructive — boolean flags.
12
16
  // Events (all CustomEvent, bubbles — mirror the Adw.SplitButton GObject signals):
13
- // `clicked` — the primary action half was activated.
14
- // `notify::active` — the dropdown menu was opened/closed (detail.active).
15
- // `menu-activated` — a menu entry was chosen (detail.action / detail.label).
17
+ // `clicked` — the primary action half was activated.
18
+ // `notify::active` — the dropdown menu was opened/closed (detail.active).
19
+ // `notify::<prop>` — one per GObject property the state machine notified,
20
+ // in libadwaita's emission order.
21
+ // `menu-activated` — a menu entry was chosen (detail.label / .action / .index).
22
+ //
23
+ // All of the behavior above lives in `@gjsify/adwaita-core` (ADR 0004) as
24
+ // {@link SplitButtonState}; this element only paints it and translates DOM
25
+ // events into state calls. It used to carry its own copy, and that copy rendered
26
+ // the label AND the icon together (libadwaita clears whichever slot it is not
27
+ // using), could not clear the label at all, hardcoded "Menu" as the dropdown's
28
+ // accessible name, and opened an empty popover when there was no menu. The
29
+ // shared vectors in `@gjsify/adwaita-core/conformance` now pin this element to
30
+ // the C source — see `src/split-button.spec.ts`.
31
+ //
32
+ // The last piece of that copy to go was the direction→arrow table, which
33
+ // outlived the lift and drew the `open-menu` hamburger for `direction="none"`.
34
+ // Only the glyph→mask-class mapping is a renderer concern; see ARROW_MASK_CLASSES.
35
+ //
36
+ // THE POPOVER WENT THE SAME WAY, and it was the worst of the three copies: a
37
+ // 9px (`--button-radius`) surface where libadwaita's is 15px, under a two-layer
38
+ // shadow nothing in the vendored stylesheet has, with NO Escape dismissal and NO
39
+ // arrow-key navigation at all — a menu you could open with the keyboard and then
40
+ // only leave with the mouse. It is now `<adw-popover>`, so those two behaviours
41
+ // arrive with the surface rather than having to be written a third time; the
42
+ // spec cases for exactly them are in `src/split-button.spec.ts`.
43
+ //
16
44
  // Reference: refs/libadwaita/src/adw-split-button.c (AdwSplitButton)
17
45
  // Reference: refs/adwaita-web/adwaita-web/scss/_split_button.scss
18
46
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
19
47
  // Copyright (c) 2025 csm (adwaita-web). MIT License.
20
48
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
21
49
 
50
+ import {
51
+ SplitButtonState,
52
+ isSplitButtonDirection,
53
+ parseMenuEntries,
54
+ resolveDropdownTooltip,
55
+ splitButtonArrowIcon,
56
+ splitButtonPopupDirection,
57
+ splitButtonRootState,
58
+ } from '@gjsify/adwaita-core';
59
+ import type { AdwArrowIcon, AdwMenuEntry, SplitButtonChange, SplitButtonDirection } from '@gjsify/adwaita-core';
60
+
61
+ // SIDE-EFFECT import, deliberately separate from the type import below: it is
62
+ // what guarantees `adw-popover` is defined before this module's own
63
+ // `customElements.define` can upgrade a server-rendered `<adw-split-button>` and
64
+ // build one. A combined `import { AdwPopover }` would NOT do it — the binding is
65
+ // only ever used in type position here, and this package compiles without
66
+ // `verbatimModuleSyntax`, so TypeScript would elide the whole statement and take
67
+ // the registration with it.
68
+ import './adw-popover.js';
69
+ import type { AdwPopover } from './adw-popover.js';
70
+
71
+ import { type AdwIcon, createAdwIcon } from './adw-icon.js';
72
+
22
73
  const VARIANT_CLASSES: Record<string, string> = {
23
74
  flat: 'flat',
24
75
  suggested: 'suggested-action',
25
76
  destructive: 'destructive-action',
26
77
  };
27
78
 
28
- interface MenuEntry {
29
- label: string;
30
- action?: string;
31
- }
79
+ /**
80
+ * libadwaita's arrow GLYPH NAMES mapped onto the mask classes
81
+ * `_icons.generated.scss` actually ships. Renderer-specific by nature: the icon
82
+ * set here is the `go-*` family, so no `pan-*` mask exists to map onto.
83
+ *
84
+ * WHICH glyph a direction gets is NOT decided here — {@link splitButtonArrowIcon}
85
+ * owns that, and this table is keyed by its output rather than by direction. That
86
+ * split is the point: keying by direction is how this element came to draw the
87
+ * `open-menu` hamburger for `direction="none"`, where a split button draws the
88
+ * down caret (`splitbutton > menubutton > button > arrow.none`,
89
+ * _buttons.scss:621-623). The mask mapping is a fact about our asset set; the
90
+ * glyph choice is a fact about libadwaita, and only one of them lives here.
91
+ *
92
+ * `pan-up-symbolic` has no mask at all, so it reuses `go-down` flipped by the
93
+ * caller — the alternative is a blank arrow.
94
+ */
95
+ const ARROW_MASK_CLASSES: Readonly<Record<AdwArrowIcon, string>> = {
96
+ 'open-menu-symbolic': 'open-menu',
97
+ 'pan-down-symbolic': 'go-down',
98
+ 'pan-up-symbolic': 'go-down',
99
+ 'pan-start-symbolic': 'go-previous',
100
+ 'pan-end-symbolic': 'go-next',
101
+ };
102
+
103
+ // The `-symbolic` strip AND the token guard that used to live here — as
104
+ // `iconClass`, the ONLY copy of the guard in the package — are
105
+ // `normalizeIconName`'s now, applied by <adw-icon>. Five other places built the
106
+ // same class without the guard; keeping the correct one private is what let
107
+ // them stay wrong.
108
+
109
+ /**
110
+ * Direction → the CSS axis `<adw-popover>` places the surface on. WHICH
111
+ * direction applies is not decided here — {@link splitButtonPopupDirection}
112
+ * folds `none` onto `down` (GtkMenuButton's rule, not the split button's), and
113
+ * this table is keyed by its output for the same reason ARROW_MASK_CLASSES is.
114
+ */
115
+ const POPOVER_POSITIONS = {
116
+ down: 'bottom',
117
+ up: 'top',
118
+ left: 'start',
119
+ right: 'end',
120
+ } as const;
32
121
 
33
122
  export class AdwSplitButton extends HTMLElement {
123
+ private readonly _state = new SplitButtonState();
34
124
  private _actionEl!: HTMLButtonElement;
35
125
  private _dropdownEl!: HTMLButtonElement;
36
- private _menuEl!: HTMLDivElement;
37
- private _label = '';
38
- private _active = false;
126
+ private _arrowEl!: AdwIcon;
127
+ private _menuEl!: AdwPopover;
128
+ /** Inline text content, captured before we take over the subtree. */
129
+ private _inlineLabel = '';
130
+ /** Last `open` value reflected as `notify::active`, so the event fires once per flip. */
131
+ private _openReflected = false;
39
132
  private _initialized = false;
40
- private _onDocumentPointerDown = (event: Event): void => {
41
- if (!this.contains(event.target as Node)) this._setActive(false);
42
- };
43
133
 
44
134
  static get observedAttributes() {
45
135
  return [
46
136
  'label',
47
137
  'icon-name',
138
+ 'use-underline',
48
139
  'tooltip',
49
140
  'dropdown-tooltip',
141
+ 'direction',
50
142
  'menu',
51
143
  'disabled',
52
144
  'flat',
@@ -57,15 +149,92 @@ export class AdwSplitButton extends HTMLElement {
57
149
 
58
150
  /** Whether the dropdown menu is currently open. */
59
151
  get active(): boolean {
60
- return this._active;
152
+ return this._state.open;
153
+ }
154
+
155
+ /** Whether the dropdown half is live (there is a menu to show). */
156
+ get dropdownEnabled(): boolean {
157
+ return this._state.dropdownEnabled;
158
+ }
159
+
160
+ /** The action label, or `null` when the action half shows an icon or nothing. */
161
+ get label(): string | null {
162
+ return this._state.label;
163
+ }
164
+
165
+ set label(value: string) {
166
+ this.setAttribute('label', value ?? '');
167
+ // Setting the label clears the icon (adw-split-button.c:658-659); mirror
168
+ // that in the DOM so the attributes never contradict the state.
169
+ this.removeAttribute('icon-name');
170
+ }
171
+
172
+ /** The symbolic icon name, or `null` when the action half shows a label or nothing. */
173
+ get iconName(): string | null {
174
+ return this._state.iconName;
175
+ }
176
+
177
+ set iconName(value: string) {
178
+ this.setAttribute('icon-name', value ?? '');
179
+ this.removeAttribute('label');
180
+ }
181
+
182
+ /** The dropdown menu entries. */
183
+ get menuItems(): readonly AdwMenuEntry[] {
184
+ return this._state.menuModel ?? [];
185
+ }
186
+
187
+ set menuItems(value: readonly AdwMenuEntry[]) {
188
+ this._state.setMenuModel(Array.isArray(value) ? value.map((entry) => ({ ...entry })) : null);
189
+ if (this._initialized) this._render();
190
+ }
191
+
192
+ /** The dropdown tooltip as set — `''` while unset. */
193
+ get dropdownTooltip(): string {
194
+ return this._state.dropdownTooltip;
195
+ }
196
+
197
+ set dropdownTooltip(value: string) {
198
+ this.setAttribute('dropdown-tooltip', value ?? '');
199
+ }
200
+
201
+ /** The direction the popup opens in, and the arrow points. */
202
+ get direction(): SplitButtonDirection {
203
+ return this._state.direction;
204
+ }
205
+
206
+ set direction(value: SplitButtonDirection) {
207
+ this.setAttribute('direction', value);
208
+ }
209
+
210
+ /** Whether an underline in the label marks a mnemonic. */
211
+ get useUnderline(): boolean {
212
+ return this._state.useUnderline;
213
+ }
214
+
215
+ set useUnderline(value: boolean) {
216
+ if (value) this.setAttribute('use-underline', '');
217
+ else this.removeAttribute('use-underline');
218
+ }
219
+
220
+ /** Pop the menu up — `adw_split_button_popup()`. */
221
+ popup(): void {
222
+ this._state.openMenu();
223
+ }
224
+
225
+ /** Dismiss the menu — `adw_split_button_popdown()`. */
226
+ popdown(): void {
227
+ this._state.closeMenu();
61
228
  }
62
229
 
63
230
  connectedCallback() {
64
231
  if (this._initialized) return;
65
232
  this._initialized = true;
66
233
 
67
- // Capture any inline text as the label before we take over the subtree.
68
- this._label = (this.getAttribute('label') ?? this.textContent ?? '').trim();
234
+ // Inline text is the label when no `label` attribute is given. This seed
235
+ // IS trimmed — markup indentation is not part of the value — while an
236
+ // explicit attribute never is, because C keys `text-button` off label[0].
237
+ this._inlineLabel = (this.textContent ?? '').trim();
69
238
 
70
239
  this.classList.add('adw-split-button');
71
240
 
@@ -76,16 +245,31 @@ export class AdwSplitButton extends HTMLElement {
76
245
  this._dropdownEl = document.createElement('button');
77
246
  this._dropdownEl.type = 'button';
78
247
  this._dropdownEl.className = 'adw-split-button-dropdown';
79
- const arrow = document.createElement('span');
80
- arrow.className = 'adw-icon adw-icon--go-down';
81
- arrow.setAttribute('aria-hidden', 'true');
82
- this._dropdownEl.appendChild(arrow);
248
+ this._dropdownEl.setAttribute('aria-haspopup', 'menu');
249
+ this._arrowEl = createAdwIcon(null);
250
+ this._dropdownEl.appendChild(this._arrowEl);
83
251
 
84
- this._menuEl = document.createElement('div');
85
- this._menuEl.className = 'adw-split-button-menu';
86
- this._menuEl.hidden = true;
252
+ this._menuEl = document.createElement('adw-popover') as AdwPopover;
253
+ this._menuEl.classList.add('adw-split-button-menu');
254
+ this._menuEl.setAttribute('role', 'menu');
255
+ // The dropdown half IS a GtkMenuButton (adw-split-button.c:997 passes
256
+ // straight through to it), so its popover is a `popover.menu`.
257
+ this._menuEl.setAttribute('menu', '');
258
+ this._menuEl.setAttribute('align', 'end');
87
259
 
88
260
  this.replaceChildren(this._actionEl, this._dropdownEl, this._menuEl);
261
+ // The DROPDOWN half is the anchor, not the whole control: the popover
262
+ // must not treat the half that toggles it as "outside" (it would close
263
+ // and immediately reopen), and Escape returns focus to the half that
264
+ // opened the menu.
265
+ this._menuEl.anchor = this._dropdownEl;
266
+ // The popover is a MIRROR of `SplitButtonState.open`, which stays the
267
+ // single source of truth (`dropdownEnabled`, the root `checked` fold and
268
+ // `notify::active` all read it). A dismissal the popover owns — outside
269
+ // click, Escape — is fed back in here rather than diverging.
270
+ this._menuEl.subscribe((open) => {
271
+ if (!open) this._state.closeMenu();
272
+ });
89
273
 
90
274
  this._actionEl.addEventListener('click', () => {
91
275
  if (this.hasAttribute('disabled')) return;
@@ -93,113 +277,208 @@ export class AdwSplitButton extends HTMLElement {
93
277
  });
94
278
  this._dropdownEl.addEventListener('click', () => {
95
279
  if (this.hasAttribute('disabled')) return;
96
- this._setActive(!this._active);
280
+ this._state.toggleMenu();
97
281
  });
98
282
 
283
+ // Seed the state from the attributes BEFORE subscribing, so connecting an
284
+ // element does not fire a burst of notify::* for properties that were
285
+ // never "changed" from an author's point of view.
286
+ this._syncStateFromAttributes();
287
+ this._state.subscribe((change) => this._onStateChange(change));
99
288
  this._render();
100
289
  }
101
290
 
102
- disconnectedCallback() {
103
- document.removeEventListener('pointerdown', this._onDocumentPointerDown);
291
+ attributeChangedCallback(name: string, _previous: string | null, value: string | null) {
292
+ if (!this._initialized) return;
293
+ switch (name) {
294
+ case 'label':
295
+ case 'icon-name':
296
+ this._applyContentAttribute(name, value);
297
+ break;
298
+ case 'use-underline':
299
+ this._state.setUseUnderline(value !== null);
300
+ break;
301
+ case 'direction':
302
+ this._state.setDirection(isSplitButtonDirection(value) ? value : 'down');
303
+ break;
304
+ case 'dropdown-tooltip':
305
+ this._state.setDropdownTooltip(value ?? '');
306
+ break;
307
+ case 'menu':
308
+ this._state.setMenuModel(parseMenuEntries(value));
309
+ break;
310
+ default:
311
+ break;
312
+ }
313
+ // A change that the state machine swallowed as a no-op still has to reach
314
+ // the DOM (`tooltip`, `disabled`, the variant flags have no state).
315
+ this._render();
104
316
  }
105
317
 
106
- attributeChangedCallback() {
107
- if (this._initialized) this._render();
318
+ /** Seed label/icon/menu/direction/tooltip from the attributes present at connect time. */
319
+ private _syncStateFromAttributes(): void {
320
+ const label = this.getAttribute('label') ?? (this._inlineLabel.length > 0 ? this._inlineLabel : null);
321
+ const iconName = this.getAttribute('icon-name');
322
+ // With both present the icon wins — exactly where `set_label();
323
+ // set_icon_name();` leaves the widget (adw-split-button.c:749-771).
324
+ if (label !== null) this._state.setLabel(label);
325
+ if (iconName !== null) this._state.setIconName(iconName);
326
+
327
+ this._state.setUseUnderline(this.hasAttribute('use-underline'));
328
+ const direction = this.getAttribute('direction');
329
+ if (isSplitButtonDirection(direction)) this._state.setDirection(direction);
330
+ this._state.setDropdownTooltip(this.getAttribute('dropdown-tooltip') ?? '');
331
+ // The `menuItems` property may have been set before the element upgraded;
332
+ // the attribute only seeds an otherwise empty menu.
333
+ if (this._state.menuModel === null) this._state.setMenuModel(parseMenuEntries(this.getAttribute('menu')));
334
+ }
335
+
336
+ /**
337
+ * Route a `label` / `icon-name` attribute mutation into the content machine.
338
+ *
339
+ * C has no independent "unset" for either slot — clearing one is a side
340
+ * effect of setting another — so a REMOVED attribute falls back to whichever
341
+ * sibling is still present, and to the empty child otherwise. That fallback
342
+ * is what makes `removeAttribute('label')` actually drop the label; it used
343
+ * to be a silent no-op, so the storybook's icon-only mode rendered the icon
344
+ * next to a stale "Save".
345
+ */
346
+ private _applyContentAttribute(name: 'label' | 'icon-name', value: string | null): void {
347
+ if (value !== null) {
348
+ if (name === 'label') this._state.setLabel(value);
349
+ else this._state.setIconName(value);
350
+ return;
351
+ }
352
+ const sibling = this.getAttribute(name === 'label' ? 'icon-name' : 'label');
353
+ if (sibling === null) this._state.setChild(null);
354
+ else if (name === 'label') this._state.setIconName(sibling);
355
+ else this._state.setLabel(sibling);
108
356
  }
109
357
 
110
- private _setActive(active: boolean): void {
111
- if (this._active === active) return;
112
- this._active = active;
113
- this._menuEl.hidden = !active;
114
- this.classList.toggle('active', active);
115
- this._dropdownEl.setAttribute('aria-expanded', String(active));
116
- if (active) {
117
- document.addEventListener('pointerdown', this._onDocumentPointerDown);
118
- } else {
119
- document.removeEventListener('pointerdown', this._onDocumentPointerDown);
358
+ private _onStateChange(change: SplitButtonChange): void {
359
+ for (const property of change.notified) {
360
+ this.dispatchEvent(new CustomEvent(`notify::${property}`, { bubbles: true }));
120
361
  }
121
- this.dispatchEvent(new CustomEvent('notify::active', { bubbles: true, detail: { active } }));
122
- }
123
-
124
- private _parseMenu(): MenuEntry[] {
125
- const raw = this.getAttribute('menu');
126
- if (!raw) return [];
127
- try {
128
- const parsed: unknown = JSON.parse(raw);
129
- if (!Array.isArray(parsed)) return [];
130
- return parsed
131
- .filter((entry): entry is MenuEntry => typeof entry === 'object' && entry !== null && 'label' in entry)
132
- .map((entry) => ({ label: String(entry.label), action: entry.action }));
133
- } catch {
134
- return [];
362
+ const opened = change.open && !this._openReflected;
363
+ if (change.open !== this._openReflected) {
364
+ this._openReflected = change.open;
365
+ this.dispatchEvent(new CustomEvent('notify::active', { bubbles: true, detail: { active: change.open } }));
135
366
  }
367
+ this._render();
368
+ // Focus lands in the menu on open, AFTER _render has built the rows —
369
+ // without it the arrow keys the popover now provides would have nothing
370
+ // to move from, which is half of why this element had none.
371
+ if (opened) this._menuEl.items[0]?.focus();
136
372
  }
137
373
 
138
- private _render() {
374
+ private _render(): void {
139
375
  const disabled = this.hasAttribute('disabled');
140
376
  this.classList.toggle('disabled', disabled);
141
377
  this._actionEl.disabled = disabled;
142
- this._dropdownEl.disabled = disabled;
378
+ // With neither a menu model nor a popover the dropdown is INSENSITIVE
379
+ // (adw-split-button.c:376-378, :394-396) — it must not open an empty
380
+ // popover, which is what the old unguarded click handler did.
381
+ this._dropdownEl.disabled = disabled || !this._state.dropdownEnabled;
143
382
 
144
383
  this.classList.remove('flat', 'suggested-action', 'destructive-action');
145
- for (const [attr, cls] of Object.entries(VARIANT_CLASSES)) {
146
- if (this.hasAttribute(attr)) this.classList.add(cls);
384
+ for (const [attribute, cls] of Object.entries(VARIANT_CLASSES)) {
385
+ if (this.hasAttribute(attribute)) this.classList.add(cls);
147
386
  }
148
387
 
149
- // --- Primary action half ---
150
- const icon = this.getAttribute('icon-name');
151
- const label = (this.getAttribute('label') ?? this._label).trim();
152
- this._actionEl.classList.toggle('icon-only', Boolean(icon) && !label);
388
+ this._renderContent();
389
+ this._renderDropdown();
390
+ this._renderMenu();
391
+ }
392
+
393
+ /** The action half + the root style classes libadwaita derives from it. */
394
+ private _renderContent(): void {
395
+ const { mode, label, iconName, styleClasses } = this._state;
396
+
397
+ // `splitbutton[.image-button][.text-button]` — the documented CSS node
398
+ // contract (adw-split-button.c:32-50, :145-165).
399
+ this.classList.toggle('image-button', styleClasses.includes('image-button'));
400
+ this.classList.toggle('text-button', styleClasses.includes('text-button'));
153
401
 
154
402
  this._actionEl.replaceChildren();
155
- if (icon) {
156
- const span = document.createElement('span');
157
- span.className = `adw-icon adw-icon--${icon}`;
158
- span.setAttribute('aria-hidden', 'true');
159
- this._actionEl.appendChild(span);
403
+ this._actionEl.classList.toggle('icon-only', mode === 'icon');
404
+
405
+ if (mode === 'icon' && iconName !== null) {
406
+ this._actionEl.appendChild(createAdwIcon(iconName));
407
+ } else if (mode === 'label' && label !== null) {
408
+ // Never trimmed: two spaces are a valid label and render as one.
409
+ this._actionEl.appendChild(document.createTextNode(label));
160
410
  }
161
- if (label) this._actionEl.appendChild(document.createTextNode(label));
162
411
 
163
412
  const tooltip = this.getAttribute('tooltip');
164
413
  this._actionEl.title = tooltip ?? '';
165
414
  // An icon-only action half has no text, so give it an accessible name —
166
415
  // prefer the tooltip, fall back to the symbolic icon name (WCAG 4.1.2).
167
- if (icon && !label) {
168
- this._actionEl.setAttribute('aria-label', tooltip ?? icon);
169
- } else {
170
- this._actionEl.removeAttribute('aria-label');
171
- }
416
+ if (mode === 'icon') this._actionEl.setAttribute('aria-label', tooltip ?? (iconName || 'Action'));
417
+ else this._actionEl.removeAttribute('aria-label');
418
+ }
172
419
 
173
- // --- Dropdown half ---
174
- const dropdownTooltip = this.getAttribute('dropdown-tooltip');
175
- this._dropdownEl.title = dropdownTooltip ?? '';
176
- this._dropdownEl.setAttribute('aria-label', dropdownTooltip ?? 'Menu');
177
- this._dropdownEl.setAttribute('aria-haspopup', 'menu');
178
- this._dropdownEl.setAttribute('aria-expanded', String(this._active));
420
+ /** The dropdown half: arrow glyph, tooltip/accessible name, expanded state. */
421
+ private _renderDropdown(): void {
422
+ const { direction, open } = this._state;
423
+ const glyph = splitButtonArrowIcon(direction);
424
+ this._arrowEl.iconName = ARROW_MASK_CLASSES[glyph];
425
+ // No pan-up mask ships yet, so it is the down arrow turned over. Keyed off
426
+ // the GLYPH, not the direction: the flip belongs to the mask substitution.
427
+ this._arrowEl.style.transform = glyph === 'pan-up-symbolic' ? 'rotate(180deg)' : '';
428
+ // Where the popup goes is core's call (`none` → `down`); only the
429
+ // direction → CSS-axis mapping is ours.
430
+ this._menuEl.position = POPOVER_POSITIONS[splitButtonPopupDirection(direction)];
179
431
 
180
- // --- Menu ---
181
- const entries = this._parseMenu();
432
+ // An empty value RESTORES the translated default (adw-split-button.c:1044-1051)
433
+ // instead of leaving the button without an accessible name.
434
+ const { text } = resolveDropdownTooltip(this._state.dropdownTooltip);
435
+ this._dropdownEl.title = text;
436
+ this._dropdownEl.setAttribute('aria-label', text);
437
+ this._dropdownEl.setAttribute('aria-expanded', String(open));
438
+
439
+ // update_state folds both halves onto the root (adw-split-button.c:118-143).
440
+ // The browser gives the ACTIVE half of that fold for free — CSS `:active`
441
+ // matches the ancestors of the pressed element — so only CHECKED, which
442
+ // the arrow half carries while the menu is open, has to be applied here.
443
+ const { checked } = splitButtonRootState(
444
+ { active: false, checked: false, keyboardActivating: false },
445
+ { active: false, checked: open, keyboardActivating: false },
446
+ );
447
+ this.classList.toggle('checked', checked);
448
+ // `.active` predates the lift and stays the open-state hook; libadwaita
449
+ // spells the same state `checked`.
450
+ this.classList.toggle('active', open);
451
+ }
452
+
453
+ /** The popover contents, one button per menu entry, dispatched BY POSITION. */
454
+ private _renderMenu(): void {
182
455
  this._menuEl.replaceChildren();
183
- this._menuEl.setAttribute('role', 'menu');
184
- for (const entry of entries) {
456
+ this._menuEl.open = this._state.open;
457
+
458
+ for (const [index, entry] of (this._state.menuModel ?? []).entries()) {
185
459
  const item = document.createElement('button');
186
460
  item.type = 'button';
187
- item.className = 'adw-split-button-menu-item';
461
+ // `.adw-popover-item` is what makes the row navigable: it is the
462
+ // selector `<adw-popover>` walks for arrow/Home/End/Enter, which this
463
+ // element never had.
464
+ item.className = 'adw-popover-item adw-split-button-menu-item';
188
465
  item.setAttribute('role', 'menuitem');
466
+ item.tabIndex = -1;
189
467
  item.textContent = entry.label;
190
468
  item.addEventListener('click', () => {
191
- this._setActive(false);
469
+ // By position, never by label: two entries called "Copy" are
470
+ // legal and must dispatch their own action (c:385-388).
471
+ const activated = this._state.activateMenuEntry(index);
472
+ if (activated === null) return;
192
473
  this.dispatchEvent(
193
474
  new CustomEvent('menu-activated', {
194
475
  bubbles: true,
195
- detail: { label: entry.label, action: entry.action },
476
+ detail: { label: activated.label, action: activated.action, index },
196
477
  }),
197
478
  );
198
479
  });
199
480
  this._menuEl.appendChild(item);
200
481
  }
201
- // A split button with no menu entries hides its (empty) popover entirely.
202
- if (entries.length === 0) this._setActive(false);
203
482
  }
204
483
  }
205
484
 
@@ -4,10 +4,13 @@
4
4
  // Reference: refs/adwaita-web/adwaita-web/scss/_status_page.scss
5
5
  // Reference: refs/libadwaita/src/stylesheet/widgets/_status-page.scss
6
6
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
7
- // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
7
+ // Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
8
+ // icon node is <adw-icon>.
9
+
10
+ import { type AdwIcon, createAdwIcon } from './adw-icon.js';
8
11
 
9
12
  export class AdwStatusPage extends HTMLElement {
10
- private _iconEl!: HTMLSpanElement;
13
+ private _iconEl!: AdwIcon;
11
14
  private _titleEl!: HTMLSpanElement;
12
15
  private _descEl!: HTMLSpanElement;
13
16
  private _childEl!: HTMLDivElement;
@@ -23,9 +26,7 @@ export class AdwStatusPage extends HTMLElement {
23
26
 
24
27
  const children = Array.from(this.childNodes);
25
28
 
26
- this._iconEl = document.createElement('span');
27
- this._iconEl.className = 'adw-status-page-icon adw-icon';
28
- this._iconEl.setAttribute('aria-hidden', 'true');
29
+ this._iconEl = createAdwIcon(null, 'adw-status-page-icon');
29
30
 
30
31
  this._titleEl = document.createElement('span');
31
32
  this._titleEl.className = 'adw-status-page-title';
@@ -46,10 +47,9 @@ export class AdwStatusPage extends HTMLElement {
46
47
  }
47
48
 
48
49
  private _render() {
49
- const icon = (this.getAttribute('icon') ?? '').replace(/-symbolic$/, '');
50
- // Swap the icon mask class (keep the base + size class).
51
- this._iconEl.className = `adw-status-page-icon adw-icon${icon ? ` adw-icon--${icon}` : ''}`;
52
- this._iconEl.hidden = icon.length === 0;
50
+ // The element swaps the mask class and keeps the size class.
51
+ this._iconEl.iconName = this.getAttribute('icon');
52
+ this._iconEl.hidden = this._iconEl.resolvedIconName === '';
53
53
 
54
54
  const title = this.getAttribute('title') ?? '';
55
55
  this._titleEl.textContent = title;