@gjsify/adwaita-web 0.41.0 → 0.42.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 (93) hide show
  1. package/README.md +98 -3
  2. package/dist/adwaita-web.css +30 -23
  3. package/dist/adwaita-web.css.map +1 -1
  4. package/lib/types/adw-fonts.spec.d.ts +2 -0
  5. package/lib/types/breakpoints.d.ts +10 -1
  6. package/lib/types/connect-lifecycle.spec.d.ts +1 -0
  7. package/lib/types/elements/adw-about-dialog.d.ts +1 -0
  8. package/lib/types/elements/adw-action-row.d.ts +2 -0
  9. package/lib/types/elements/adw-alert-dialog.d.ts +14 -2
  10. package/lib/types/elements/adw-dialog.d.ts +2 -7
  11. package/lib/types/elements/adw-icon.d.ts +17 -1
  12. package/lib/types/elements/adw-navigation-split-view.d.ts +7 -0
  13. package/lib/types/elements/adw-overlay-split-view.d.ts +6 -0
  14. package/lib/types/elements/adw-popover.d.ts +1 -0
  15. package/lib/types/elements/adw-preferences-dialog.d.ts +1 -0
  16. package/lib/types/elements/adw-switch.d.ts +10 -0
  17. package/lib/types/elements/adw-toggle-group.d.ts +2 -0
  18. package/lib/types/elements/modal-surface.d.ts +59 -0
  19. package/lib/types/elements/roving-focus.d.ts +26 -0
  20. package/lib/types/elements/row-activation.d.ts +26 -0
  21. package/lib/types/empty-sections.d.ts +15 -0
  22. package/lib/types/empty-sections.spec.d.ts +1 -0
  23. package/lib/types/fonts.d.ts +1 -0
  24. package/lib/types/icon-registry.d.ts +35 -0
  25. package/lib/types/icon-registry.spec.d.ts +18 -0
  26. package/lib/types/index.d.ts +1 -1
  27. package/lib/types/keyboard-operable.spec.d.ts +1 -0
  28. package/lib/types/styles.generated.d.ts +1 -1
  29. package/package.json +10 -6
  30. package/scss/_banner.scss +1 -1
  31. package/scss/_checks.scss +8 -8
  32. package/scss/_combo_row.scss +1 -1
  33. package/scss/_drop_down.scss +1 -1
  34. package/scss/_expander_row.scss +1 -1
  35. package/scss/_icon.scss +47 -0
  36. package/scss/_icons.generated.scss +18 -6
  37. package/scss/_password_entry_row.scss +1 -1
  38. package/scss/_preferences_dialog.scss +3 -2
  39. package/scss/_progress_bar.scss +1 -1
  40. package/scss/_row.scss +1 -1
  41. package/scss/_status_page.scss +1 -1
  42. package/scss/_tab_view.scss +1 -1
  43. package/scss/_toast.scss +6 -24
  44. package/scss/_variables.scss +23 -3
  45. package/src/adw-action-rows.spec.ts +21 -9
  46. package/src/adw-button-content.spec.ts +11 -6
  47. package/src/adw-fonts.spec.ts +188 -0
  48. package/src/adw-icon.spec.ts +17 -8
  49. package/src/adw-row-state.spec.ts +11 -1
  50. package/src/breakpoints.ts +18 -4
  51. package/src/connect-lifecycle.spec.ts +351 -0
  52. package/src/elements/adw-about-dialog.ts +24 -18
  53. package/src/elements/adw-action-row.ts +14 -2
  54. package/src/elements/adw-alert-dialog.ts +39 -20
  55. package/src/elements/adw-button-row.ts +3 -0
  56. package/src/elements/adw-clamp.ts +3 -2
  57. package/src/elements/adw-data-grid.ts +5 -2
  58. package/src/elements/adw-dialog.ts +16 -63
  59. package/src/elements/adw-expander-row.ts +18 -4
  60. package/src/elements/adw-header-bar.ts +2 -0
  61. package/src/elements/adw-icon.ts +32 -6
  62. package/src/elements/adw-inline-view-switcher.ts +12 -0
  63. package/src/elements/adw-menu-button.ts +17 -10
  64. package/src/elements/adw-navigation-split-view.ts +30 -7
  65. package/src/elements/adw-overlay-split-view.ts +15 -3
  66. package/src/elements/adw-popover.ts +11 -0
  67. package/src/elements/adw-preferences-dialog.ts +18 -16
  68. package/src/elements/adw-preferences-group.ts +7 -0
  69. package/src/elements/adw-sidebar.ts +15 -0
  70. package/src/elements/adw-status-page.ts +12 -4
  71. package/src/elements/adw-switch-row.ts +7 -0
  72. package/src/elements/adw-switch.ts +19 -1
  73. package/src/elements/adw-tab-view.ts +7 -4
  74. package/src/elements/adw-toast-overlay.ts +8 -1
  75. package/src/elements/adw-toggle-group.ts +94 -2
  76. package/src/elements/adw-toolbar-view.ts +6 -2
  77. package/src/elements/adw-view-switcher-bar.ts +18 -0
  78. package/src/elements/adw-view-switcher.ts +14 -0
  79. package/src/elements/modal-surface.ts +180 -0
  80. package/src/elements/roving-focus.ts +154 -0
  81. package/src/elements/row-activation.ts +92 -0
  82. package/src/empty-sections.spec.ts +81 -0
  83. package/src/empty-sections.ts +54 -0
  84. package/src/fonts.ts +25 -0
  85. package/src/icon-registry.spec.ts +240 -0
  86. package/src/icon-registry.ts +157 -0
  87. package/src/index.ts +24 -1
  88. package/src/keyboard-operable.spec.ts +755 -0
  89. package/src/source-view/theme.ts +15 -3
  90. package/src/split-button.spec.ts +6 -2
  91. package/src/style-classes.spec.ts +6 -0
  92. package/src/styles.generated.ts +1 -1
  93. package/src/test.browser.mts +14 -0
@@ -1,7 +1,7 @@
1
- // <adw-menu-button> — the web counterpart of Adw.MenuButton / Gtk.MenuButton: by
2
- // default the flat `open-menu-symbolic` app-menu button used at the end of a header
3
- // bar. Clicking it toggles an Adwaita-styled popover under the button, dismissed on
4
- // an outside click or Escape, with arrow-key navigation.
1
+ // <adw-menu-button> — the web counterpart of Gtk.MenuButton, which libadwaita styles
2
+ // but never subclassed: by default the flat `open-menu-symbolic` app-menu button used
3
+ // at the end of a header bar. Clicking it toggles an Adwaita-styled popover under the
4
+ // button, dismissed on an outside click or Escape, with arrow-key navigation.
5
5
  //
6
6
  // The popover is `<adw-popover>` and the dismissal/keyboard machine is
7
7
  // `@gjsify/adwaita-core`'s (ADR 0004); this element keeps only what is a menu button:
@@ -14,13 +14,19 @@
14
14
  // detail `{ id, label, index }`) with `id` falling back to `label`, matching the NS
15
15
  // twin.
16
16
  //
17
- // Reference: refs/libadwaita/src/adw-menu-button.c (AdwMenuButton)
17
+ // Reference: refs/gtk/gtk/gtkmenubutton.c (GtkMenuButton)
18
+ // Reference: refs/libadwaita/src/stylesheet/widgets/_buttons.scss (menubutton)
18
19
  // Reference: refs/libadwaita/src/stylesheet/widgets/_menus.scss (popover.menu / modelbutton)
19
20
  // Reference: packages/nativescript-bridge/adwaita/src/widgets/adw-menu-button.ts (NS twin)
20
21
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
21
22
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
22
23
 
23
- import { isSplitButtonDirection, menuButtonPopupDirection, parseMenuEntries } from '@gjsify/adwaita-core';
24
+ import {
25
+ isSplitButtonDirection,
26
+ menuButtonPopupDirection,
27
+ parseMenuEntries,
28
+ stringIsNotEmpty,
29
+ } from '@gjsify/adwaita-core';
24
30
  import type { AdwMenuEntry, SplitButtonDirection } from '@gjsify/adwaita-core';
25
31
 
26
32
  // SIDE-EFFECT import, deliberately separate from the type import below: it guarantees
@@ -210,11 +216,12 @@ export class AdwMenuButton extends HTMLElement {
210
216
  item.setAttribute('role', 'menuitem');
211
217
  item.tabIndex = -1;
212
218
 
213
- // An entry with no USABLE icon gets no icon node at all rather than an empty
214
- // 16px box; `<adw-icon>` itself answers whether the name resolved, so the
215
- // `-symbolic`-only and bad-token cases agree.
219
+ // An entry that asked for NO icon gets no icon node — the emptiness of the
220
+ // declared name, not whether it resolved. Testing `resolvedIconName` dropped
221
+ // the node for a name that was given and merely undrawable, which is the one
222
+ // case a reader needs to SEE; `<adw-icon>` draws `image-missing` there.
216
223
  const entryIcon = createAdwIcon(entry.icon ?? null, 'adw-menu-button-item-icon');
217
- if (entryIcon.resolvedIconName !== '') item.appendChild(entryIcon);
224
+ if (stringIsNotEmpty(entry.icon)) item.appendChild(entryIcon);
218
225
  const labelEl = document.createElement('span');
219
226
  labelEl.className = 'adw-menu-button-item-label';
220
227
  labelEl.textContent = entry.label;
@@ -50,6 +50,12 @@ export class AdwNavigationSplitView extends HTMLElement {
50
50
  private _contentEl!: HTMLDivElement;
51
51
  private _initialized = false;
52
52
  private _disposeBreakpoint: (() => void) | undefined;
53
+ /**
54
+ * What the breakpoint last SET, carried across every rebind. Not read off the
55
+ * attribute: a `collapsed` the markup declared was never this breakpoint's doing,
56
+ * and unapplying it at connect would undo the author.
57
+ */
58
+ private _breakpointApplied = false;
53
59
  /** The ordering table, the tag guard and the action routing (ADR 0004). */
54
60
  private _state = new NavigationSplitViewState();
55
61
  /** Re-entrancy guard for the `show-content` reflection. */
@@ -118,6 +124,21 @@ export class AdwNavigationSplitView extends HTMLElement {
118
124
  }
119
125
 
120
126
  connectedCallback() {
127
+ this._buildOnce();
128
+ // `_syncBreakpoint` is the one that HAS to run on EVERY connect: it re-establishes
129
+ // the ResizeObserver `disconnectedCallback` disposed, and without it a view MOVED
130
+ // between parents (a slideshow slide, a client-side route change) stops tracking
131
+ // its condition for good — `<adw-navigation-split-view breakpoint="max-width: 720px">`
132
+ // never collapses again however narrow the page gets. The three above it are the
133
+ // post-build derivations, idempotent on a later connect and left in the order
134
+ // `<adw-overlay-split-view>` runs them so the two views read alike.
135
+ this._reflectShowContent();
136
+ this._syncWidth();
137
+ this._syncClasses();
138
+ this._syncBreakpoint();
139
+ }
140
+
141
+ private _buildOnce() {
121
142
  if (this._initialized) return;
122
143
  this._initialized = true;
123
144
 
@@ -160,10 +181,6 @@ export class AdwNavigationSplitView extends HTMLElement {
160
181
  this._reflectShowContent();
161
182
  this._syncClasses();
162
183
  });
163
-
164
- this._syncWidth();
165
- this._syncClasses();
166
- this._syncBreakpoint();
167
184
  }
168
185
 
169
186
  /** `Adw.NavigationPage:tag` off the slotted pane, or `null` when untagged. */
@@ -284,9 +301,15 @@ export class AdwNavigationSplitView extends HTMLElement {
284
301
 
285
302
  private _syncBreakpoint() {
286
303
  this._disposeBreakpoint?.();
287
- this._disposeBreakpoint = bindBreakpointSetter(this, this.getAttribute('breakpoint'), (active) => {
288
- this.collapsed = active;
289
- });
304
+ this._disposeBreakpoint = bindBreakpointSetter(
305
+ this,
306
+ this.getAttribute('breakpoint'),
307
+ (active) => {
308
+ this._breakpointApplied = active;
309
+ this.collapsed = active;
310
+ },
311
+ this._breakpointApplied,
312
+ );
290
313
  }
291
314
  }
292
315
 
@@ -84,6 +84,12 @@ export class AdwOverlaySplitView extends HTMLElement {
84
84
  private _contentEl!: HTMLDivElement;
85
85
  private _backdropEl!: HTMLDivElement;
86
86
  private _disposeBreakpoint: (() => void) | undefined;
87
+ /**
88
+ * What the breakpoint last SET, carried across every rebind. Not read off the
89
+ * attribute: a `collapsed` the markup declared was never this breakpoint's doing,
90
+ * and unapplying it at connect would undo the author.
91
+ */
92
+ private _breakpointApplied = false;
87
93
  /**
88
94
  * The property interplay, shared with the NativeScript renderer (ADR 0004): the
89
95
  * element owns the attributes and the painting, core owns what `collapsed` +
@@ -335,9 +341,15 @@ export class AdwOverlaySplitView extends HTMLElement {
335
341
 
336
342
  private _syncBreakpoint() {
337
343
  this._disposeBreakpoint?.();
338
- this._disposeBreakpoint = bindBreakpointSetter(this, this.getAttribute('breakpoint'), (active) => {
339
- this.collapsed = active;
340
- });
344
+ this._disposeBreakpoint = bindBreakpointSetter(
345
+ this,
346
+ this.getAttribute('breakpoint'),
347
+ (active) => {
348
+ this._breakpointApplied = active;
349
+ this.collapsed = active;
350
+ },
351
+ this._breakpointApplied,
352
+ );
341
353
  }
342
354
 
343
355
  openSidebar() {
@@ -160,6 +160,17 @@ export class AdwPopover extends HTMLElement {
160
160
  }
161
161
 
162
162
  connectedCallback() {
163
+ this._buildOnce();
164
+ // EVERY connect, not only the first. The document listeners are bound from
165
+ // `_onStateChange` and released by `disconnectedCallback`, so a popover that was
166
+ // OPEN when it moved — a slideshow slide, a client-side route change — comes back
167
+ // visible with neither outside-click nor Escape dismissal, and no state change
168
+ // left to re-arm them: the only ways out are the two that just died. Idempotent
169
+ // by construction, the same function references and capture flag the unbind uses.
170
+ if (this._state.open) this._bindDocument();
171
+ }
172
+
173
+ private _buildOnce() {
163
174
  if (this._initialized) return;
164
175
  this._initialized = true;
165
176
 
@@ -50,6 +50,7 @@
50
50
 
51
51
  import type { PreferencesSearchResult, SearchPreferencesOptions } from '@gjsify/adwaita-core';
52
52
  import { searchPreferencesDom } from '../preferences-search.js';
53
+ import { AdwModalSurface } from './modal-surface.js';
53
54
 
54
55
  const DEFAULT_CONTENT_WIDTH = 640;
55
56
 
@@ -106,6 +107,7 @@ export class AdwPreferencesDialog extends HTMLElement {
106
107
  private _bodyEl!: HTMLDivElement;
107
108
  private _pagesEl!: HTMLDivElement;
108
109
  private _closeBtn!: HTMLButtonElement;
110
+ private _modal!: AdwModalSurface;
109
111
 
110
112
  static get observedAttributes() {
111
113
  return ['title', 'open', 'can-close', 'content-width'];
@@ -218,8 +220,6 @@ export class AdwPreferencesDialog extends HTMLElement {
218
220
 
219
221
  this._dialogEl = document.createElement('div');
220
222
  this._dialogEl.className = 'adw-preferences-dialog-box';
221
- this._dialogEl.setAttribute('role', 'dialog');
222
- this._dialogEl.setAttribute('aria-modal', 'true');
223
223
  // Clicks inside the dialog must not bubble to the scrim's dismiss handler.
224
224
  this._dialogEl.addEventListener('click', (event) => event.stopPropagation());
225
225
 
@@ -241,8 +241,8 @@ export class AdwPreferencesDialog extends HTMLElement {
241
241
  this._closeBtn.type = 'button';
242
242
  this._closeBtn.className = 'adw-preferences-dialog-close';
243
243
  this._closeBtn.setAttribute('aria-label', 'Close');
244
- // The "×" is a CSS glyph: `window-close` has no mask class, because it is not in
245
- // the ICONS map in `scripts/build-scss.mjs` that generates them.
244
+ // The "×" is a CSS glyph, for the sizing reason adw-tab-view's close affordance
245
+ // records — not for want of a mask class, which `window-close` has.
246
246
  this._closeBtn.addEventListener('click', () => this._attemptClose());
247
247
  trailing.appendChild(this._closeBtn);
248
248
 
@@ -260,14 +260,14 @@ export class AdwPreferencesDialog extends HTMLElement {
260
260
 
261
261
  this.replaceChildren(this._scrimEl, this._dialogEl);
262
262
 
263
- // Escape closes the dialog while open (Adw.Dialog's Escape shortcut →
264
- // `maybe_close_cb`). The element is focusable so it can receive the key event.
265
- if (!this.hasAttribute('tabindex')) this.tabIndex = -1;
266
- this.addEventListener('keydown', (event) => {
267
- if (event.key === 'Escape' && this.open) {
268
- event.stopPropagation();
269
- this._attemptClose();
270
- }
263
+ // The role, `aria-modal`, Escape (Adw.Dialog's shortcut → `maybe_close_cb`), the
264
+ // Tab trap and the return-focus.
265
+ this._modal = new AdwModalSurface({
266
+ host: this,
267
+ surface: this._dialogEl,
268
+ role: 'dialog',
269
+ isOpen: () => this.open,
270
+ onEscape: () => this._attemptClose(),
271
271
  });
272
272
 
273
273
  this._render();
@@ -279,7 +279,12 @@ export class AdwPreferencesDialog extends HTMLElement {
279
279
  this._render();
280
280
  if (name === 'open') {
281
281
  this.dispatchEvent(new CustomEvent('notify::open', { bubbles: true, detail: { open: this.open } }));
282
- if (!this.open) this.dispatchEvent(new CustomEvent('closed', { bubbles: true }));
282
+ if (this.open) {
283
+ this._modal.present();
284
+ } else {
285
+ this.dispatchEvent(new CustomEvent('closed', { bubbles: true }));
286
+ this._modal.dismiss();
287
+ }
283
288
  }
284
289
  }
285
290
 
@@ -304,9 +309,6 @@ export class AdwPreferencesDialog extends HTMLElement {
304
309
  const locked = !this.canClose;
305
310
  this._closeBtn.disabled = locked;
306
311
  this._closeBtn.classList.toggle('locked', locked);
307
-
308
- // Move focus into the dialog on present so Escape works immediately.
309
- if (this.open) this.focus();
310
312
  }
311
313
  }
312
314
 
@@ -115,6 +115,10 @@ export class AdwPreferencesGroup extends HTMLElement {
115
115
  for (const child of rowChildren) this._listboxEl.appendChild(child);
116
116
 
117
117
  this.replaceChildren(this._headerEl, this._listboxEl);
118
+ // adw-preferences-group.c:319 — GTK_ACCESSIBLE_ROLE_GROUP. A GROUP, not a list:
119
+ // that is why the rows inside carry no `listitem` role, which outside a list
120
+ // would be worse than none.
121
+ this.setAttribute('role', 'group');
118
122
 
119
123
  this._renderHeader();
120
124
  this._observeHost();
@@ -158,6 +162,9 @@ export class AdwPreferencesGroup extends HTMLElement {
158
162
  });
159
163
  this._observer.observe(this, { childList: true });
160
164
  this._observer.observe(this._listboxEl, { childList: true });
165
+ // The suffix too: `hasHeaderSuffix` is an INPUT to the header derivation, so a
166
+ // suffix appended after connect left the header hidden on a group with no title.
167
+ this._observer.observe(this._suffixEl, { childList: true });
161
168
  }
162
169
 
163
170
  private _renderHeader() {
@@ -57,6 +57,7 @@ import {
57
57
  } from '@gjsify/adwaita-core';
58
58
 
59
59
  import { createAdwIcon } from './adw-icon.js';
60
+ import { attachRovingFocus } from './roving-focus.js';
60
61
 
61
62
  // The sidebar consumes its declared children and drops them from the tree, so a later
62
63
  // `setAttribute` on one cannot find its sidebar with `closest()`. These keep the link —
@@ -161,6 +162,20 @@ export class AdwSidebar extends HTMLElement {
161
162
  this._listEl = document.createElement('div');
162
163
  this._listEl.className = 'adw-sidebar-list';
163
164
  this._listEl.setAttribute('role', 'listbox');
165
+ // What makes the roving tabindex in `_applySelection` navigable: without it the
166
+ // unselected rows are reachable by no key at all. `setSelected` and NOT
167
+ // `_activate`: an arrow key is `GtkListBox`'s `row-selected`, and firing
168
+ // `activated` on every press would navigate a split view on each keystroke.
169
+ // Enter and Space still activate — the row IS a <button>.
170
+ attachRovingFocus({
171
+ host: this,
172
+ orientation: 'vertical',
173
+ items: () => this._rows.filter((row) => !row.el.hidden && !row.el.disabled).map((row) => row.el),
174
+ select: (item) => {
175
+ const row = this._rows.find((candidate) => candidate.el === item);
176
+ if (row !== undefined) this._state.setSelected(row.index);
177
+ },
178
+ });
164
179
 
165
180
  // Seed the model BEFORE subscribing: connecting is not a change anyone
166
181
  // asked to hear about, and `setSections` runs the 0 → n auto-select.
@@ -2,13 +2,14 @@
2
2
  // icon, a title, a description and an optional action (slotted child).
3
3
  // Attributes: icon (symbolic name, with or without -symbolic), title, description.
4
4
  // Reference: refs/adwaita-web/adwaita-web/scss/_status_page.scss
5
- // Reference: refs/libadwaita/src/stylesheet/widgets/_status-page.scss
5
+ // Reference: refs/libadwaita/src/stylesheet/widgets/_misc.scss (AdwStatusPage)
6
6
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
7
7
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
8
8
  // icon node is <adw-icon>.
9
9
 
10
10
  import { stringIsNotEmpty } from '@gjsify/adwaita-core';
11
11
 
12
+ import { bindEmptySections } from '../empty-sections.js';
12
13
  import { type AdwIcon, createAdwIcon } from './adw-icon.js';
13
14
 
14
15
  export class AdwStatusPage extends HTMLElement {
@@ -41,6 +42,9 @@ export class AdwStatusPage extends HTMLElement {
41
42
  for (const child of children) this._childEl.appendChild(child);
42
43
 
43
44
  this.replaceChildren(this._iconEl, this._titleEl, this._descEl, this._childEl);
45
+ // `_render` only ever runs from `attributeChangedCallback`, which an action
46
+ // appended into the child slot after connect does not trigger.
47
+ bindEmptySections(this._childEl);
44
48
  this._render();
45
49
  }
46
50
 
@@ -51,7 +55,13 @@ export class AdwStatusPage extends HTMLElement {
51
55
  private _render() {
52
56
  // The element swaps the mask class and keeps the size class.
53
57
  this._iconEl.iconName = this.getAttribute('icon');
54
- this._iconEl.hidden = this._iconEl.resolvedIconName === '';
58
+ // `has_image`: `paintable || (icon_name && icon_name[0])` — a name that was GIVEN
59
+ // shows the image whether or not the theme can resolve it, and GTK draws
60
+ // `image-missing` in it. Reading `resolvedIconName` instead hid the slot for every
61
+ // name that is not one CSS token, a branch the C does not have.
62
+ // Reference: refs/libadwaita/src/adw-status-page.c:88 (has_image), bound from
63
+ // `<binding name="visible">` in adw-status-page.ui:27-31.
64
+ this._iconEl.hidden = !stringIsNotEmpty(this.getAttribute('icon'));
55
65
 
56
66
  // `string_is_not_empty` from the core, not a local `.length === 0`: the same
57
67
  // closure the NativeScript port binds its labels to, and the reason a title of
@@ -63,8 +73,6 @@ export class AdwStatusPage extends HTMLElement {
63
73
  const description = this.getAttribute('description') ?? '';
64
74
  this._descEl.textContent = description;
65
75
  this._descEl.hidden = !stringIsNotEmpty(description);
66
-
67
- this._childEl.hidden = this._childEl.childElementCount === 0;
68
76
  }
69
77
  }
70
78
 
@@ -25,6 +25,7 @@ import { SwitchRowState, deriveRowLabels } from '@gjsify/adwaita-core';
25
25
  // without `verbatimModuleSyntax`, so TypeScript would elide the statement and take the
26
26
  // registration with it.
27
27
  import './adw-switch.js';
28
+ import { attachRowActivation } from './row-activation.js';
28
29
  import type { AdwSwitch } from './adw-switch.js';
29
30
 
30
31
  export class AdwSwitchRow extends HTMLElement {
@@ -62,6 +63,8 @@ export class AdwSwitchRow extends HTMLElement {
62
63
  text.append(this._titleEl, this._subtitleEl);
63
64
 
64
65
  this._switchEl = document.createElement('adw-switch') as AdwSwitch;
66
+ // adw-switch-row.c:159 — the slider is not a focus target; the ROW is.
67
+ this._switchEl.unfocusable = true;
65
68
 
66
69
  this.replaceChildren(text, this._switchEl);
67
70
 
@@ -86,6 +89,10 @@ export class AdwSwitchRow extends HTMLElement {
86
89
  this._apply(this._state.activate());
87
90
  });
88
91
 
92
+ // adw-switch-row.c:160 makes the row itself activatable. Enter and Space run the
93
+ // same transition a click does, through the row's own click handler.
94
+ attachRowActivation({ row: this, activatable: () => true });
95
+
89
96
  this._renderText();
90
97
  }
91
98
 
@@ -35,7 +35,23 @@ export class AdwSwitch extends HTMLElement {
35
35
  private _initialized = false;
36
36
 
37
37
  static get observedAttributes() {
38
- return ['active', 'disabled'];
38
+ return ['active', 'disabled', 'unfocusable'];
39
+ }
40
+
41
+ /**
42
+ * `Gtk.Widget:can-focus` on the slider, inverted so the default stays "yes".
43
+ *
44
+ * `adw_switch_row_init` does `gtk_widget_set_can_focus (self->slider, FALSE)`
45
+ * (adw-switch-row.c:159) and makes the ROW activatable instead, so a switch row is ONE
46
+ * tab stop announced as its own title — not a bare checkbox followed by a label that
47
+ * cannot be reached. A standalone `<adw-switch>` keeps its checkbox focusable.
48
+ */
49
+ get unfocusable(): boolean {
50
+ return this.hasAttribute('unfocusable');
51
+ }
52
+
53
+ set unfocusable(value: boolean) {
54
+ this.toggleAttribute('unfocusable', !!value);
39
55
  }
40
56
 
41
57
  /** Whether the switch is on. */
@@ -103,6 +119,8 @@ export class AdwSwitch extends HTMLElement {
103
119
  const disabled = this.disabled;
104
120
  this._input.checked = this.active;
105
121
  this._input.disabled = disabled;
122
+ if (this.unfocusable) this._input.tabIndex = -1;
123
+ else this._input.removeAttribute('tabindex');
106
124
  this.classList.toggle('disabled', disabled);
107
125
  }
108
126
  }
@@ -500,10 +500,13 @@ export class AdwTabView extends HTMLElement {
500
500
 
501
501
  tab.appendChild(createAdwIcon(null, 'adw-tab-indicator'));
502
502
 
503
- // Close affordance — a small flat button drawn with a CSS "×" glyph, because
504
- // `window-close` has no mask class: it is not in the ICONS map in
505
- // `scripts/build-scss.mjs` that generates them. `can-focus=False` in C, so it
506
- // stays out of the tab order here too.
503
+ // Close affordance — a small flat button drawn with a CSS "×" glyph. NOT because
504
+ // the glyph is unavailable: `window-close` is in the ICONS map and has a mask class
505
+ // (the comment that used to stand here said otherwise, and was read by two more
506
+ // widgets). It is a SIZING difference — upstream's close button is 24px around a
507
+ // 16px symbolic (adw-tab.ui:77), this strip's chip budgets 18px, and a 16px-grid
508
+ // symbolic scaled into it is a design change, not a rename.
509
+ // `can-focus=False` in C, so it stays out of the tab order here too.
507
510
  const close = document.createElement('button');
508
511
  close.type = 'button';
509
512
  close.className = 'adw-tab-close';
@@ -18,6 +18,8 @@
18
18
  // queue/auto-dismiss state machine composed from @gjsify/adwaita-core.
19
19
 
20
20
  import { AdwToast, AdwToastQueue } from '@gjsify/adwaita-core';
21
+
22
+ import { createAdwIcon } from './adw-icon.js';
21
23
  import type { ToastScheduler, ToastTimerHandle } from '@gjsify/adwaita-core';
22
24
 
23
25
  // The timing seam is re-exported so a consumer can type a custom scheduler for
@@ -183,11 +185,16 @@ export class AdwToastOverlay extends HTMLElement {
183
185
  el.appendChild(button);
184
186
  }
185
187
 
186
- // Close affordance: a CSS-drawn "×", as in adw-tab-view's close button.
188
+ // Close affordance — the real `window-close` glyph, which is what upstream puts
189
+ // here (adw-toast-widget.ui:64) and what this port drew with two rotated CSS bars
190
+ // under a comment claiming no such icon shipped. It does, and the toast is the one
191
+ // close button in the package already sized the way upstream sizes it: a 16px
192
+ // symbolic in a 24px circle, so the swap is the name and nothing else.
187
193
  const close = document.createElement('button');
188
194
  close.type = 'button';
189
195
  close.className = 'adw-toast-close-button';
190
196
  close.setAttribute('aria-label', 'Close');
197
+ close.appendChild(createAdwIcon('window-close'));
191
198
  close.addEventListener('click', () => this._queue.dismiss());
192
199
  el.appendChild(close);
193
200
 
@@ -7,7 +7,44 @@
7
7
  // The SELECTION state machine (the segment list plus the guarded, no-op-on-same active
8
8
  // index) is HEADLESS and lives in `@gjsify/adwaita-core` (ADR 0004) as
9
9
  // {@link ToggleGroupState}; this element keeps only the DOM half — the buttons, the active
10
- // pill / `aria-pressed` reflection and the event.
10
+ // pill / checked reflection, the keyboard and the event.
11
+ //
12
+ // THE ROLE IS A RADIO GROUP, not a tab list, and the element had neither. Upstream
13
+ // declares `GTK_ACCESSIBLE_ROLE_RADIO_GROUP` (adw-toggle-group.c:1191) and reads the
14
+ // group's CURRENT role when it builds each toggle: a group already declared a tab list
15
+ // gets `GTK_ACCESSIBLE_ROLE_TAB` children, everything else `GTK_ACCESSIBLE_ROLE_RADIO`
16
+ // (:857-865, under the comment "Special case for AdwInlineViewSwitcher" — that switcher
17
+ // builds exactly this widget with `TAB_LIST`, adw-inline-view-switcher.c:702). Both
18
+ // branches are ported, in the same order, because a consumer that declares the tab list
19
+ // upstream sanctions would otherwise get radios inside it.
20
+ //
21
+ // `aria-pressed` was the state before, and it belongs to NEITHER role — it is the
22
+ // toolbar toggle-button pattern, where each button is independent. The state of one of
23
+ // N mutually exclusive choices is `aria-checked` under `radio` and `aria-selected` under
24
+ // `tab`, so a screen reader was told these were three separate on/off buttons.
25
+ //
26
+ // THE KEYBOARD is one tab stop and arrows inside, which is the roving tabindex
27
+ // `elements/roving-focus.ts` implements — and `adw_toggle_group_focus`
28
+ // (adw-toggle-group.c:1045) is the citation that module is built on. Tab in either
29
+ // direction is PROPAGATED (:1059-1060), i.e. focus leaves the group; every other
30
+ // direction goes to `adw_widget_focus_child` (:1062), i.e. moves within it. Which button
31
+ // Tab enters on is `adw_toggle_group_grab_focus` (:1066): the ACTIVE toggle's button,
32
+ // never the first. The web element had three plain tab stops and no arrow keys at all,
33
+ // so it stayed operable — this change turns those three stops into one, which is visible
34
+ // to anything that tabs through a toggle group.
35
+ //
36
+ // HORIZONTAL ONLY, and the reason is not the missing `orientation` property. The C looks
37
+ // orientation-blind — `adw_toggle_group_focus` filters the two Tab directions and hands
38
+ // every other one on without consulting `self->orientation` — but the axis is decided
39
+ // one level down, GEOMETRICALLY. `adw_widget_focus_child` is `focus_move`
40
+ // (adw-widget-utils.c:428), which sorts through `focus_sort` (:388), which dispatches
41
+ // UP/DOWN into `focus_sort_up_down` (:298). That sorter DELETES every sibling with no
42
+ // horizontal overlap with the focused child (:339-342) — which, in a row of toggles, is
43
+ // all of them. The list is left holding only the focused button, `gtk_widget_child_focus`
44
+ // on a leaf that already has focus returns FALSE, and the press PROPAGATES: upstream,
45
+ // ArrowDown in a horizontal toggle group leaves the group. So `'horizontal'` is what the
46
+ // C does for the only layout this element has, it matches the three sibling tab lists,
47
+ // and it leaves ArrowUp/ArrowDown to the page instead of swallowing a scroll.
11
48
  //
12
49
  // Reference: refs/libadwaita/src/adw-toggle-group.c (AdwToggleGroup behaviour)
13
50
  // Reference: refs/libadwaita/src/stylesheet/widgets/_toggle-group.scss
@@ -20,6 +57,7 @@
20
57
  import { ToggleGroupState } from '@gjsify/adwaita-core';
21
58
 
22
59
  import { createAdwIcon } from './adw-icon.js';
60
+ import { attachRovingFocus } from './roving-focus.js';
23
61
 
24
62
  /** A single toggle. Children of <adw-toggle-group>; consumed at connect time. */
25
63
  export class AdwToggle extends HTMLElement {
@@ -34,6 +72,8 @@ export class AdwToggleGroup extends HTMLElement {
34
72
  /** The headless segment list + guarded selection state machine (ADR 0004). */
35
73
  private readonly _state = new ToggleGroupState();
36
74
  private _initialized = false;
75
+ /** `aria-checked` under `radio`, `aria-selected` under `tab` — decided at connect. */
76
+ private _stateAttr: 'aria-checked' | 'aria-selected' = 'aria-checked';
37
77
 
38
78
  static get observedAttributes() {
39
79
  return ['active', 'flat', 'round'];
@@ -56,13 +96,48 @@ export class AdwToggleGroup extends HTMLElement {
56
96
  // subtree — their label / icon-name become the rendered buttons.
57
97
  const toggles = Array.from(this.querySelectorAll('adw-toggle')) as AdwToggle[];
58
98
 
99
+ // `gtk_widget_class_set_accessible_role` sets a CLASS DEFAULT that an instance's
100
+ // construct-time `accessible-role` overrides, and `add_toggle` then reads
101
+ // whatever the instance actually has. So a declared role is kept — only an
102
+ // undeclared one is filled in — and `tablist` is the single branch, exactly as
103
+ // in the C: any other role still gets radio children.
104
+ // `|| null`, not `?? null`: `role=""` is not a declared role, and treating it as
105
+ // one left the group with no role at all while its children were radios.
106
+ const declared = this.getAttribute('role')?.trim() || null;
107
+ if (declared === null) this.setAttribute('role', 'radiogroup');
108
+ const isTabList = declared === 'tablist';
109
+ const toggleRole = isTabList ? 'tab' : 'radio';
110
+ this._stateAttr = isTabList ? 'aria-selected' : 'aria-checked';
111
+ // Read ONCE, which is what upstream allows: `GtkAccessible:accessible-role` is
112
+ // writable but documented "The accessible role cannot be changed once set", and
113
+ // the only public setter is the CLASS-level `gtk_widget_class_set_accessible_role`
114
+ // — there is no instance setter at all. So a later `role=` change has no upstream
115
+ // meaning, and re-labelling the toggles on it would be behaviour this port
116
+ // invented. Declare it in markup, or before the element is connected.
117
+ //
118
+ // (It is NOT construct-only, which an earlier draft of this comment claimed:
119
+ // `Gtk-4.0.gir` marks the property `writable` with no construct flag at all. The
120
+ // rule is the documented one above, which is the stronger citation anyway.)
121
+
59
122
  this._innerEl = document.createElement('div');
60
123
  this._innerEl.className = 'adw-toggle-group-inner';
124
+ // The toggles are this element's own children upstream; the wrapper is a web-only
125
+ // flex box with no counterpart in the C at all. Without a role it is a `generic`
126
+ // sitting between a radio group and its radios, so it declares itself away.
127
+ //
128
+ // NO upstream precedent is claimed for this, and an earlier draft of this comment
129
+ // invented one: every `GTK_ACCESSIBLE_ROLE_PRESENTATION` in libadwaita is a leaf
130
+ // decoration (an icon, a gizmo), never a layout container between a role-bearing
131
+ // parent and role-bearing children — because GTK has no such container here to
132
+ // annotate. That is exactly why the attribute is needed on this side and nowhere
133
+ // in the C.
134
+ this._innerEl.setAttribute('role', 'none');
61
135
 
62
136
  this._buttons = toggles.map((toggle, index) => {
63
137
  const btn = document.createElement('button');
64
138
  btn.type = 'button';
65
139
  btn.className = 'adw-toggle';
140
+ btn.setAttribute('role', toggleRole);
66
141
 
67
142
  const label = toggle.getAttribute('label') ?? '';
68
143
  const icon = toggle.getAttribute('icon-name') ?? '';
@@ -87,6 +162,20 @@ export class AdwToggleGroup extends HTMLElement {
87
162
  this._state.subscribe(() => this._render());
88
163
  this._state.setSelected(this._readActiveAttr());
89
164
  this._render();
165
+
166
+ // No `disabled`/`hidden` filter, because no `<adw-toggle>` attribute can produce
167
+ // either — upstream's per-toggle `enabled` (adw-toggle-group.c:871) has no web
168
+ // counterpart yet, and a filter for a state this element cannot reach would be
169
+ // untestable. `AdwToggle.observedAttributes` is pinned in
170
+ // `keyboard-operable.spec.ts` so the day it grows, this decision has to be
171
+ // revisited instead of silently becoming a focus trap.
172
+ attachRovingFocus({
173
+ host: this,
174
+ orientation: 'horizontal',
175
+ items: () => this._buttons,
176
+ // Same path a click takes, so an arrow key cannot drift from a press.
177
+ select: (item) => this._selectIndex(this._buttons.findIndex((btn) => btn === item)),
178
+ });
90
179
  }
91
180
 
92
181
  attributeChangedCallback(name: string) {
@@ -124,7 +213,10 @@ export class AdwToggleGroup extends HTMLElement {
124
213
  this._buttons.forEach((btn, index) => {
125
214
  const isActive = index === active;
126
215
  btn.classList.toggle('active', isActive);
127
- btn.setAttribute('aria-pressed', String(isActive));
216
+ btn.setAttribute(this._stateAttr, String(isActive));
217
+ // The roving tabindex: Tab enters on the ACTIVE toggle, the way
218
+ // `adw_toggle_group_grab_focus` grabs it, and leaves the group from there.
219
+ btn.tabIndex = isActive ? 0 : -1;
128
220
  });
129
221
  }
130
222
  }
@@ -30,6 +30,7 @@ import {
30
30
  parseToolbarStyle,
31
31
  toolbarViewClasses,
32
32
  } from '@gjsify/adwaita-core';
33
+ import { bindEmptySections } from '../empty-sections.js';
33
34
  import { AdwScrollShading } from '../scroll-shading.js';
34
35
 
35
36
  export class AdwToolbarView extends HTMLElement {
@@ -88,8 +89,11 @@ export class AdwToolbarView extends HTMLElement {
88
89
  this._bottomEl.className = 'adw-toolbar-view-bottom';
89
90
  for (const child of bottomChildren) this._bottomEl.appendChild(child);
90
91
 
91
- this._topEl.hidden = this._topEl.childElementCount === 0;
92
- this._bottomEl.hidden = this._bottomEl.childElementCount === 0;
92
+ // A bar appended imperatively — which `topBar` invites — is a childList
93
+ // change, not an attribute one, so the empty/non-empty decision cannot be a
94
+ // one-shot read here. The ResizeObserver below then sees the bar go 0 → 48
95
+ // and the undershoot classes follow on their own.
96
+ bindEmptySections(this._topEl, this._bottomEl);
93
97
 
94
98
  this.replaceChildren(this._topEl, this._contentEl, this._bottomEl);
95
99
  }