@gjsify/adwaita-web 0.41.0 → 0.43.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 (97) 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-carousel.d.ts +8 -0
  11. package/lib/types/elements/adw-dialog.d.ts +2 -7
  12. package/lib/types/elements/adw-icon.d.ts +17 -1
  13. package/lib/types/elements/adw-navigation-split-view.d.ts +7 -0
  14. package/lib/types/elements/adw-overlay-split-view.d.ts +6 -0
  15. package/lib/types/elements/adw-popover.d.ts +1 -0
  16. package/lib/types/elements/adw-preferences-dialog.d.ts +1 -0
  17. package/lib/types/elements/adw-switch.d.ts +10 -0
  18. package/lib/types/elements/adw-toggle-group.d.ts +2 -0
  19. package/lib/types/elements/modal-surface.d.ts +59 -0
  20. package/lib/types/elements/roving-focus.d.ts +26 -0
  21. package/lib/types/elements/row-activation.d.ts +26 -0
  22. package/lib/types/elements/swipe-drag.d.ts +63 -0
  23. package/lib/types/empty-sections.d.ts +15 -0
  24. package/lib/types/empty-sections.spec.d.ts +1 -0
  25. package/lib/types/fonts.d.ts +1 -0
  26. package/lib/types/icon-registry.d.ts +35 -0
  27. package/lib/types/icon-registry.spec.d.ts +18 -0
  28. package/lib/types/index.d.ts +1 -1
  29. package/lib/types/keyboard-operable.spec.d.ts +1 -0
  30. package/lib/types/styles.generated.d.ts +1 -1
  31. package/package.json +10 -6
  32. package/scss/_banner.scss +1 -1
  33. package/scss/_checks.scss +8 -8
  34. package/scss/_combo_row.scss +1 -1
  35. package/scss/_drop_down.scss +1 -1
  36. package/scss/_expander_row.scss +1 -1
  37. package/scss/_icon.scss +47 -0
  38. package/scss/_icons.generated.scss +18 -6
  39. package/scss/_password_entry_row.scss +1 -1
  40. package/scss/_preferences_dialog.scss +3 -2
  41. package/scss/_progress_bar.scss +1 -1
  42. package/scss/_row.scss +1 -1
  43. package/scss/_status_page.scss +1 -1
  44. package/scss/_tab_view.scss +1 -1
  45. package/scss/_toast.scss +6 -24
  46. package/scss/_variables.scss +23 -3
  47. package/src/adw-action-rows.spec.ts +21 -9
  48. package/src/adw-button-content.spec.ts +11 -6
  49. package/src/adw-fonts.spec.ts +188 -0
  50. package/src/adw-icon.spec.ts +17 -8
  51. package/src/adw-row-state.spec.ts +11 -1
  52. package/src/breakpoints.ts +18 -4
  53. package/src/connect-lifecycle.spec.ts +351 -0
  54. package/src/elements/adw-about-dialog.ts +24 -18
  55. package/src/elements/adw-action-row.ts +14 -2
  56. package/src/elements/adw-alert-dialog.ts +39 -20
  57. package/src/elements/adw-button-row.ts +3 -0
  58. package/src/elements/adw-carousel.ts +86 -5
  59. package/src/elements/adw-clamp.ts +3 -2
  60. package/src/elements/adw-data-grid.ts +5 -2
  61. package/src/elements/adw-dialog.ts +16 -63
  62. package/src/elements/adw-expander-row.ts +18 -4
  63. package/src/elements/adw-header-bar.ts +2 -0
  64. package/src/elements/adw-icon.ts +32 -6
  65. package/src/elements/adw-inline-view-switcher.ts +12 -0
  66. package/src/elements/adw-menu-button.ts +17 -10
  67. package/src/elements/adw-navigation-split-view.ts +30 -7
  68. package/src/elements/adw-overlay-split-view.ts +15 -3
  69. package/src/elements/adw-popover.ts +11 -0
  70. package/src/elements/adw-preferences-dialog.ts +18 -16
  71. package/src/elements/adw-preferences-group.ts +7 -0
  72. package/src/elements/adw-sidebar.ts +15 -0
  73. package/src/elements/adw-status-page.ts +12 -4
  74. package/src/elements/adw-switch-row.ts +7 -0
  75. package/src/elements/adw-switch.ts +19 -1
  76. package/src/elements/adw-tab-view.ts +7 -4
  77. package/src/elements/adw-toast-overlay.ts +8 -1
  78. package/src/elements/adw-toggle-group.ts +94 -2
  79. package/src/elements/adw-toolbar-view.ts +6 -2
  80. package/src/elements/adw-view-switcher-bar.ts +18 -0
  81. package/src/elements/adw-view-switcher.ts +14 -0
  82. package/src/elements/modal-surface.ts +180 -0
  83. package/src/elements/roving-focus.ts +154 -0
  84. package/src/elements/row-activation.ts +92 -0
  85. package/src/elements/swipe-drag.ts +450 -0
  86. package/src/empty-sections.spec.ts +81 -0
  87. package/src/empty-sections.ts +54 -0
  88. package/src/fonts.ts +25 -0
  89. package/src/icon-registry.spec.ts +240 -0
  90. package/src/icon-registry.ts +157 -0
  91. package/src/index.ts +24 -1
  92. package/src/keyboard-operable.spec.ts +755 -0
  93. package/src/source-view/theme.ts +15 -3
  94. package/src/split-button.spec.ts +6 -2
  95. package/src/style-classes.spec.ts +6 -0
  96. package/src/styles.generated.ts +1 -1
  97. package/src/test.browser.mts +14 -0
@@ -9,6 +9,8 @@
9
9
  // subtitle at all. So a header bar could not show what its own NativeScript twin
10
10
  // could, and the fix is delegation rather than a fourth copy of the derivation.
11
11
  //
12
+ // CORE-VIA: ./adw-window-title.js — the derived centre IS that element, so the three rules run in its WindowTitleState.
13
+ //
12
14
  // Reference: refs/libadwaita/src/adw-header-bar.c
13
15
  // Adapted from Adwaita Web UI Framework (https://github.com/mclellac/adwaita-web).
14
16
  // Copyright (c) 2025 csm. MIT License.
@@ -7,7 +7,8 @@
7
7
  // Attributes:
8
8
  // icon-name — symbolic name, with or without the `-symbolic` suffix. A name that is
9
9
  // not a single CSS token (a space, a quote, a reverse-DNS application
10
- // id) resolves to NO icon rather than to injected markup.
10
+ // id) never reaches a class — it draws {@link MISSING_ICON_NAME}, which
11
+ // is what GTK draws for it, rather than injecting markup.
11
12
  // size — rendered edge length in px. Absent leaves the stylesheet's 16px in
12
13
  // charge; a context that sizes its own icons through CSS
13
14
  // (`.adw-about-dialog-icon`, the split button's 14px arrows) keeps doing
@@ -29,10 +30,21 @@
29
30
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
30
31
  // name derivation composed from @gjsify/adwaita-core.
31
32
 
32
- import { normalizeIconName } from '@gjsify/adwaita-core';
33
+ import { normalizeIconName, stringIsNotEmpty } from '@gjsify/adwaita-core';
33
34
 
34
- /** The generated mask-class prefix — the one place the `--` spelling appears. */
35
- const MASK_CLASS_PREFIX = 'adw-icon--';
35
+ /**
36
+ * The generated mask-class prefix — the one place the `--` spelling appears. `icon-registry`
37
+ * builds the same class for a runtime registration and imports it from here rather than
38
+ * spelling it twice.
39
+ */
40
+ export const MASK_CLASS_PREFIX = 'adw-icon--';
41
+
42
+ /**
43
+ * The glyph an icon falls back to — the same name `_icon.scss` masks with, the same one
44
+ * libadwaita's C substitutes for a NULL icon-name, and the same one GTK's icon theme
45
+ * returns for a name it cannot find.
46
+ */
47
+ export const MISSING_ICON_NAME = 'image-missing';
36
48
 
37
49
  export class AdwIcon extends HTMLElement {
38
50
  static get observedAttributes() {
@@ -52,7 +64,11 @@ export class AdwIcon extends HTMLElement {
52
64
  /**
53
65
  * The name that actually reached the mask class — `''` when the icon-name is
54
66
  * absent, empty, or not usable as one CSS token. Consumers that need to know
55
- * whether an icon is being drawn ask this, not the raw attribute.
67
+ * whether the DECLARED name is drawable ask this, not the raw attribute.
68
+ *
69
+ * `''` does NOT mean the box draws nothing: an unusable name that was given still
70
+ * draws {@link MISSING_ICON_NAME}. Whether an icon was ASKED FOR is the raw
71
+ * attribute's emptiness, which is the predicate a host's visibility follows.
56
72
  */
57
73
  get resolvedIconName(): string {
58
74
  return normalizeIconName(this.getAttribute('icon-name'));
@@ -87,7 +103,17 @@ export class AdwIcon extends HTMLElement {
87
103
  const stale = [...this.classList].filter((existing) => existing.startsWith(MASK_CLASS_PREFIX));
88
104
  this.classList.remove(...stale);
89
105
  const name = this.resolvedIconName;
90
- if (name !== '') this.classList.add(`${MASK_CLASS_PREFIX}${name}`);
106
+ if (name !== '') {
107
+ this.classList.add(`${MASK_CLASS_PREFIX}${name}`);
108
+ } else if (stringIsNotEmpty(this.getAttribute('icon-name'))) {
109
+ // A name was GIVEN and cannot be one CSS token (a space, a quote, a reverse-DNS
110
+ // application id). GTK has no such state: `gtk_icon_theme_lookup_icon` never
111
+ // returns NULL, it returns the always-available `image-missing`
112
+ // (refs/gtk/gtk/gtkicontheme.c — `gtk_icon_paintable_get_icon_name`'s own docs
113
+ // say so). So the same glyph is drawn here rather than an invisible 16px hole,
114
+ // which is what `<adw-button-row start-icon-name="a b">` reserved and showed.
115
+ this.classList.add(`${MASK_CLASS_PREFIX}${MISSING_ICON_NAME}`);
116
+ }
91
117
 
92
118
  // A masked box has no text, so assistive tech has nothing to announce — the
93
119
  // accessible name belongs to the control that HOSTS the icon.
@@ -55,6 +55,7 @@ import {
55
55
  domViewSwitcherScheduler,
56
56
  readSwitcherPage,
57
57
  } from './view-switcher-dom.js';
58
+ import { attachRovingFocus } from './roving-focus.js';
58
59
 
59
60
  /** A single page. Children of <adw-inline-view-switcher>; consumed at connect time. */
60
61
  export class AdwViewStackPage extends HTMLElement {
@@ -166,6 +167,17 @@ export class AdwInlineViewSwitcher extends HTMLElement {
166
167
  this._groupEl = document.createElement('div');
167
168
  this._groupEl.className = 'adw-inline-view-switcher-group';
168
169
  this._groupEl.setAttribute('role', 'tablist');
170
+ // What makes the roving tabindex in `_render` navigable: without it the
171
+ // unselected toggles are reachable by no key at all. `_toggles` is already the
172
+ // COMPACTED list — a hidden page produces no toggle — so it needs no filter.
173
+ attachRovingFocus({
174
+ host: this,
175
+ orientation: 'horizontal',
176
+ items: () => this._toggles,
177
+ select: (item) => {
178
+ this._state.setSelected(Number(item.dataset.pageIndex));
179
+ },
180
+ });
169
181
 
170
182
  this._pagesEl = document.createElement('div');
171
183
  this._pagesEl.className = 'adw-inline-view-switcher-pages';
@@ -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