@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
@@ -68,6 +68,7 @@ import {
68
68
  } from '@gjsify/adwaita-core';
69
69
 
70
70
  import { createAdwIcon } from './adw-icon.js';
71
+ import { AdwModalSurface } from './modal-surface.js';
71
72
 
72
73
  /** A template label with its mnemonic marker removed — this renderer has no accelerator layer. */
73
74
  function rowLabel(key: keyof typeof ADW_ABOUT_DIALOG_LABELS): string {
@@ -80,6 +81,7 @@ export class AdwAboutDialog extends HTMLElement {
80
81
  // Layout nodes (rebuilt from attributes on each render).
81
82
  private _scrimEl!: HTMLDivElement;
82
83
  private _sheetEl!: HTMLDivElement;
84
+ private _modal!: AdwModalSurface;
83
85
  private _navView!: HTMLElement;
84
86
  private _scrollEl!: HTMLDivElement;
85
87
  private _mainToolbarEl!: HTMLDivElement;
@@ -260,8 +262,6 @@ export class AdwAboutDialog extends HTMLElement {
260
262
 
261
263
  this._sheetEl = document.createElement('div');
262
264
  this._sheetEl.className = 'adw-about-dialog-sheet';
263
- this._sheetEl.setAttribute('role', 'dialog');
264
- this._sheetEl.setAttribute('aria-modal', 'true');
265
265
  // AdwDialog:title, whose template default is the bare word. The application
266
266
  // name is announced by the title-1 label inside, exactly as the header
267
267
  // revealer shows it separately in GTK.
@@ -269,12 +269,15 @@ export class AdwAboutDialog extends HTMLElement {
269
269
 
270
270
  this.replaceChildren(this._scrimEl, this._sheetEl);
271
271
 
272
- // Escape dismisses while open (mirrors AdwDialog's close shortcut).
273
- this.addEventListener('keydown', (event) => {
274
- if (event.key === 'Escape' && this.open) {
275
- event.stopPropagation();
276
- this.close();
277
- }
272
+ // The role, `aria-modal`, Escape, the Tab trap and the return-focus. No
273
+ // `initialFocus`: the sheet itself is the fallback, which is what this element
274
+ // used to focus by hand, and the navigation view's first control is better.
275
+ this._modal = new AdwModalSurface({
276
+ host: this,
277
+ surface: this._sheetEl,
278
+ role: 'dialog',
279
+ isOpen: () => this.open,
280
+ onEscape: () => this.close(),
278
281
  });
279
282
 
280
283
  this._render();
@@ -301,8 +304,9 @@ export class AdwAboutDialog extends HTMLElement {
301
304
  nav.replace?.(['main']);
302
305
  this._scrollEl.scrollTop = 0;
303
306
  this._updateHeaderTitle();
304
- // Move focus into the sheet for keyboard dismissal.
305
- requestAnimationFrame(() => this._sheetEl.focus());
307
+ this._modal.present();
308
+ } else {
309
+ this._modal.dismiss();
306
310
  }
307
311
  }
308
312
 
@@ -352,7 +356,6 @@ export class AdwAboutDialog extends HTMLElement {
352
356
  if (visibility.legalRow) this._navView.append(this._buildLegalPage());
353
357
 
354
358
  this._sheetEl.replaceChildren(this._navView);
355
- this._sheetEl.tabIndex = -1;
356
359
 
357
360
  this.classList.toggle('open', this.open);
358
361
  }
@@ -382,10 +385,11 @@ export class AdwAboutDialog extends HTMLElement {
382
385
  header.setAttribute('title', headerTitle);
383
386
 
384
387
  if (showClose) {
385
- // The close button — drawn with a CSS "×" glyph, the same approach as
386
- // adw-tab-view's close affordance: `window-close` has no mask class,
387
- // because it is not in the ICONS map in `scripts/build-scss.mjs` that
388
- // generates them (`@gjsify/adwaita-icons` does export the SVG).
388
+ // The close button — drawn with a CSS "×" glyph, the same approach and the
389
+ // same reason as adw-tab-view's close affordance: `window-close` IS compiled
390
+ // and does have a mask class; what differs from upstream's
391
+ // `window-close-symbolic` (adw-sheet-controls.c:118) is the button size this
392
+ // header budgets for it.
389
393
  const close = document.createElement('button');
390
394
  close.type = 'button';
391
395
  close.className = 'adw-button flat circular adw-about-dialog-close';
@@ -430,9 +434,11 @@ export class AdwAboutDialog extends HTMLElement {
430
434
 
431
435
  // Only when one was given: GTK hides the image outright for an empty icon
432
436
  // name, and a generic glyph would show an icon for an application that asked
433
- // for none. A reverse-DNS application id (`org.gnome.Builder`) is not one CSS
434
- // token and resolves to no glyph rather than to three stray classes — see
435
- // `normalizeIconName`.
437
+ // for none. A reverse-DNS application id (`org.gnome.Builder`) — what
438
+ // `adw_about_dialog_new_from_appdata` hands to `set_application_icon` in
439
+ // refs/libadwaita/src/adw-about-dialog.c — is not one CSS token, so it draws
440
+ // `image-missing` rather than three stray classes; that is also what GTK draws for
441
+ // it on a machine without the app installed.
436
442
  if (visibility.appIcon) {
437
443
  body.appendChild(createAdwIcon(this.applicationIcon, 'adw-about-dialog-icon'));
438
444
  }
@@ -16,6 +16,9 @@
16
16
 
17
17
  import { ActionRowState } from '@gjsify/adwaita-core';
18
18
 
19
+ import { bindEmptySections } from '../empty-sections.js';
20
+ import { type AdwRowActivation, attachRowActivation } from './row-activation.js';
21
+
19
22
  /**
20
23
  * Attributes whose change means the activatable widget's sensitivity may have
21
24
  * changed. `disabled` is the DOM's `GtkWidget:sensitive` for form controls;
@@ -39,6 +42,8 @@ export class AdwActionRow extends HTMLElement {
39
42
  /** Watches the activatable widget's `disabled`/`aria-disabled` — the live binding. */
40
43
  private _sensitivityObserver: MutationObserver | null = null;
41
44
  private _initialized = false;
45
+ /** Tab stop + Enter/Space, re-derived whenever `activatable` moves. */
46
+ private _activation?: AdwRowActivation;
42
47
 
43
48
  static get observedAttributes() {
44
49
  return ['title', 'subtitle', 'activatable'];
@@ -105,12 +110,20 @@ export class AdwActionRow extends HTMLElement {
105
110
  for (const child of suffixChildren) this._suffixEl.appendChild(child);
106
111
 
107
112
  this.replaceChildren(this._prefixEl, textEl, this._suffixEl);
113
+ // `_render` runs from `attributeChangedCallback`, which a control appended into
114
+ // `suffixSection` never triggers — the section stayed `hidden` and the control
115
+ // measured 0x0. The observer is what keeps the two sections in step with what
116
+ // they actually hold.
117
+ bindEmptySections(this._prefixEl, this._suffixEl);
108
118
  // `attributeChangedCallback` was guarded until now, so a parsed row would
109
119
  // otherwise start with a stale state.
110
120
  this._adoptAttributes();
111
121
  this._render();
112
122
 
113
123
  this.addEventListener('click', (event) => this._onClick(event));
124
+ // Only while `activatable` — a plain action row is a label, and a label that
125
+ // takes Tab makes every static row in a group a stop.
126
+ this._activation = attachRowActivation({ row: this, activatable: () => this._state.activatable });
114
127
  }
115
128
 
116
129
  /** Copy the declarative attributes into the headless state. */
@@ -178,9 +191,8 @@ export class AdwActionRow extends HTMLElement {
178
191
  this._titleEl.hidden = !titleVisible;
179
192
  this._subtitleEl.textContent = subtitle;
180
193
  this._subtitleEl.hidden = !subtitleVisible;
181
- this._prefixEl.hidden = this._prefixEl.childElementCount === 0;
182
- this._suffixEl.hidden = this._suffixEl.childElementCount === 0;
183
194
  this.classList.toggle('activatable', this._state.activatable);
195
+ this._activation?.sync();
184
196
  }
185
197
  }
186
198
 
@@ -44,6 +44,9 @@
44
44
  import { AdwAlertResponses } from '@gjsify/adwaita-core';
45
45
  import type { AdwResponseAppearance } from '@gjsify/adwaita-core';
46
46
 
47
+ import { bindEmptySections } from '../empty-sections.js';
48
+ import { AdwModalSurface } from './modal-surface.js';
49
+
47
50
  /** Response button appearance — mirrors Adw.ResponseAppearance. */
48
51
  export type { AdwResponseAppearance } from '@gjsify/adwaita-core';
49
52
 
@@ -67,6 +70,7 @@ export class AdwAlertDialog extends HTMLElement {
67
70
  private _bodyEl!: HTMLParagraphElement;
68
71
  private _childEl!: HTMLDivElement;
69
72
  private _responseAreaEl!: HTMLDivElement;
73
+ private _modal!: AdwModalSurface;
70
74
 
71
75
  /** The headless response registry + default/close semantics + resolution (ADR 0004). */
72
76
  private _model = new AdwAlertResponses();
@@ -149,10 +153,17 @@ export class AdwAlertDialog extends HTMLElement {
149
153
 
150
154
  this._dialogEl = document.createElement('div');
151
155
  this._dialogEl.className = 'adw-alert-dialog-box';
152
- this._dialogEl.setAttribute('role', 'alertdialog');
153
- this._dialogEl.setAttribute('aria-modal', 'true');
154
156
  // Clicks inside the box must not bubble to the scrim's dismiss handler.
155
157
  this._dialogEl.addEventListener('click', (event) => event.stopPropagation());
158
+ // The role, `aria-modal`, Escape, the Tab trap and the return-focus.
159
+ this._modal = new AdwModalSurface({
160
+ host: this,
161
+ surface: this._dialogEl,
162
+ role: 'alertdialog',
163
+ isOpen: () => this.open,
164
+ onEscape: () => this._dismiss(),
165
+ initialFocus: (focusables) => this._initialFocusTarget(focusables),
166
+ });
156
167
 
157
168
  const messageArea = document.createElement('div');
158
169
  messageArea.className = 'adw-alert-dialog-message';
@@ -168,6 +179,9 @@ export class AdwAlertDialog extends HTMLElement {
168
179
  for (const child of extraChildren) this._childEl.appendChild(child);
169
180
 
170
181
  messageArea.append(this._headingEl, this._bodyEl, this._childEl);
182
+ // `_render` runs from `attributeChangedCallback` and from the response API, and
183
+ // an extra child appended after connect triggers neither.
184
+ bindEmptySections(this._childEl);
171
185
 
172
186
  this._responseAreaEl = document.createElement('div');
173
187
  this._responseAreaEl.className = 'adw-alert-dialog-responses';
@@ -175,15 +189,6 @@ export class AdwAlertDialog extends HTMLElement {
175
189
  this._dialogEl.append(messageArea, this._responseAreaEl);
176
190
  this.replaceChildren(this._scrimEl, this._dialogEl);
177
191
 
178
- // Escape dismisses the dialog when open (the AdwDialog Escape shortcut →
179
- // close-response).
180
- this.addEventListener('keydown', (event) => {
181
- if (event.key === 'Escape' && this.open) {
182
- event.stopPropagation();
183
- this._dismiss();
184
- }
185
- });
186
-
187
192
  for (const el of declared) {
188
193
  const id = el.getAttribute('id');
189
194
  if (!id) continue;
@@ -217,7 +222,8 @@ export class AdwAlertDialog extends HTMLElement {
217
222
  this.dispatchEvent(new CustomEvent('notify::body', { bubbles: true, detail: { body: this.body } }));
218
223
  } else if (name === 'open') {
219
224
  this.dispatchEvent(new CustomEvent('notify::open', { bubbles: true, detail: { open: this.open } }));
220
- if (this.open) this._focusInitial();
225
+ if (this.open) this._modal.present();
226
+ else this._modal.dismiss();
221
227
  }
222
228
  }
223
229
 
@@ -351,14 +357,29 @@ export class AdwAlertDialog extends HTMLElement {
351
357
  this.dispatchEvent(new CustomEvent('response', { bubbles: true, detail: { response } }));
352
358
  }
353
359
 
354
- /** Move focus to the default response button (or the first enabled one). */
355
- private _focusInitial(): void {
360
+ /**
361
+ * Where a presented alert puts focus, in the three steps `adw_alert_dialog_grab_focus`
362
+ * takes (refs/libadwaita/src/adw-alert-dialog.c:382): the CONTENT first
363
+ * (`adw_widget_grab_focus_child (priv->scrolled_window)`, :397), then the default
364
+ * widget (:406), then the first response whose `enabled` is set (:413). The default
365
+ * response is rarely the first button — a destructive alert opens on Cancel — and
366
+ * content beats it: an alert carrying an entry is answered by typing into it.
367
+ *
368
+ * `focusables` is the surface's own list, so what "focusable" means is decided once.
369
+ */
370
+ private _initialFocusTarget(focusables: readonly HTMLElement[]): HTMLElement | undefined {
371
+ return focusables.find((el) => this._childEl.contains(el)) ?? this._defaultResponseButton();
372
+ }
373
+
374
+ /** The default response button, or the first enabled one. */
375
+ private _defaultResponseButton(): HTMLElement | undefined {
356
376
  const defaultId = this._model.defaultResponse;
357
377
  const firstEnabledId = this._model.responses.find((response) => response.enabled)?.id;
358
- const target =
359
- (defaultId ? this._buttons.get(defaultId) : undefined) ??
360
- (firstEnabledId ? this._buttons.get(firstEnabledId) : undefined);
361
- target?.focus();
378
+ // The enabled check covers the DEFAULT too: `focus()` on a disabled button is a
379
+ // no-op, so handing one back left the dialog open with focus outside itself.
380
+ const defaultButton =
381
+ defaultId !== null && this._model.getResponseEnabled(defaultId) ? this._buttons.get(defaultId) : undefined;
382
+ return defaultButton ?? (firstEnabledId ? this._buttons.get(firstEnabledId) : undefined);
362
383
  }
363
384
 
364
385
  private _render(): void {
@@ -372,8 +393,6 @@ export class AdwAlertDialog extends HTMLElement {
372
393
  this._bodyEl.textContent = body;
373
394
  this._bodyEl.hidden = body.length === 0;
374
395
 
375
- this._childEl.hidden = this._childEl.childElementCount === 0;
376
-
377
396
  // Horizontal only when prefer-wide-layout is set AND there are at most two
378
397
  // responses — the AdwAlertDialog heuristic for keeping wide buttons readable.
379
398
  const horizontal = this.preferWideLayout && this._model.responses.length <= 2;
@@ -22,6 +22,7 @@
22
22
  import { BUTTON_ROW_ACTIVATABLE, ButtonRowState } from '@gjsify/adwaita-core';
23
23
 
24
24
  import { type AdwIcon, createAdwIcon } from './adw-icon.js';
25
+ import { attachRowActivation } from './row-activation.js';
25
26
 
26
27
  export class AdwButtonRow extends HTMLElement {
27
28
  private _contentsEl!: HTMLDivElement;
@@ -58,6 +59,8 @@ export class AdwButtonRow extends HTMLElement {
58
59
  this.addEventListener('click', () => {
59
60
  this.dispatchEvent(new CustomEvent('activated', { bubbles: true }));
60
61
  });
62
+ // `BUTTON_ROW_ACTIVATABLE`, not an attribute read: there is no opt-out to read.
63
+ attachRowActivation({ row: this, activatable: () => BUTTON_ROW_ACTIVATABLE });
61
64
  }
62
65
 
63
66
  attributeChangedCallback() {
@@ -40,8 +40,9 @@ export class AdwClamp extends HTMLElement {
40
40
  this._resize = new ResizeObserver(() => this._allocate());
41
41
  this._resize.observe(this);
42
42
  // Children appended AFTER connect are clamped too. Without this they
43
- // would keep whatever width they came with — the same "evaluated once at
44
- // connect time" bug the toolbar view's empty-bar handling still has.
43
+ // would keep whatever width they came with — the "evaluated once at
44
+ // connect time" bug that cost six container getters their layout;
45
+ // `src/empty-sections.ts` is the same technique for the `hidden` half.
45
46
  this._mutations = new MutationObserver(() => this._allocate());
46
47
  this._mutations.observe(this, { childList: true });
47
48
  this._allocate();
@@ -52,8 +52,11 @@
52
52
  // column tracks, the cell classes, the two normalisers, the cell text and the activation
53
53
  // rule. `@gjsify/adwaita-nativescript` renders the same widget over `GridLayout`
54
54
  // `ItemSpec`s — NativeScript has no subgrid — so the TRACK rule is a renderer-neutral
55
- // descriptor and only the mapping onto `grid-template-columns` stays here. Both ports
56
- // are held to `@gjsify/adwaita-core/conformance`'s `DATA_GRID_*_VECTORS`.
55
+ // descriptor and only the mapping onto `grid-template-columns` stays here. The NativeScript
56
+ // suite is held to DATA_GRID_TRACK_VECTORS, DATA_GRID_COLUMN_CLASS_VECTORS,
57
+ // DATA_GRID_VARIANT_VECTORS, DATA_GRID_CELL_TEXT_VECTORS and DATA_GRID_INTERACTIVE_VECTORS.
58
+ // This element is held to none of them — the browser half of the shared derivation is
59
+ // asserted only against itself, which is the gap, not the arrangement.
57
60
  // Reference: Gtk.Grid usage in the Buchhaltung BWA view (native financial grid).
58
61
  // Reference: refs/libadwaita/src/stylesheet/_colors.scss (separator / card tokens).
59
62
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
@@ -24,21 +24,14 @@
24
24
  // Copyright (c) 2023-2024 GNOME Foundation Inc. (libadwaita). LGPLv2.1+.
25
25
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
26
26
 
27
+ import { AdwModalSurface } from './modal-surface.js';
28
+
27
29
  /** Adaptive presentation of the dialog — mirrors Adw.DialogPresentationMode. */
28
30
  export type AdwDialogPresentationMode = 'auto' | 'floating' | 'bottom-sheet';
29
31
 
30
32
  const DEFAULT_CONTENT_WIDTH = 360;
31
33
  const PRESENTATION_MODES: readonly AdwDialogPresentationMode[] = ['auto', 'floating', 'bottom-sheet'];
32
34
 
33
- const FOCUSABLE_SELECTOR = [
34
- 'a[href]',
35
- 'button:not([disabled])',
36
- 'input:not([disabled])',
37
- 'select:not([disabled])',
38
- 'textarea:not([disabled])',
39
- '[tabindex]:not([tabindex="-1"])',
40
- ].join(',');
41
-
42
35
  export class AdwDialog extends HTMLElement {
43
36
  private _initialized = false;
44
37
  private _scrimEl!: HTMLDivElement;
@@ -47,8 +40,7 @@ export class AdwDialog extends HTMLElement {
47
40
  private _titleEl!: HTMLSpanElement;
48
41
  private _closeBtn!: HTMLButtonElement;
49
42
  private _contentEl!: HTMLDivElement;
50
- // The element focused before present(), restored on close (return-focus).
51
- private _previouslyFocused: HTMLElement | null = null;
43
+ private _modal!: AdwModalSurface;
52
44
 
53
45
  static get observedAttributes() {
54
46
  return ['title', 'open', 'can-close', 'content-width', 'content-height', 'presentation-mode', 'show-header'];
@@ -157,15 +149,19 @@ export class AdwDialog extends HTMLElement {
157
149
  this._scrimEl.className = 'adw-dialog-scrim';
158
150
  this._scrimEl.addEventListener('click', () => this._attemptClose());
159
151
 
160
- // Focusable so the box receives keys even when the content has none, and clicks
161
- // inside must not reach the scrim's dismiss handler.
152
+ // Clicks inside must not reach the scrim's dismiss handler. The role, `aria-modal`
153
+ // and the -1 tabindex that lets the box hold focus when its content has none all
154
+ // come from the modal surface below.
162
155
  this._boxEl = document.createElement('div');
163
156
  this._boxEl.className = 'adw-dialog-box';
164
- this._boxEl.setAttribute('role', 'dialog');
165
- this._boxEl.setAttribute('aria-modal', 'true');
166
- this._boxEl.tabIndex = -1;
167
157
  this._boxEl.addEventListener('click', (event) => event.stopPropagation());
168
- this._boxEl.addEventListener('keydown', (event) => this._onBoxKeyDown(event));
158
+ this._modal = new AdwModalSurface({
159
+ host: this,
160
+ surface: this._boxEl,
161
+ role: 'dialog',
162
+ isOpen: () => this.open,
163
+ onEscape: () => this._attemptClose(),
164
+ });
169
165
 
170
166
  this._headerEl = document.createElement('div');
171
167
  this._headerEl.className = 'adw-dialog-header';
@@ -206,7 +202,7 @@ export class AdwDialog extends HTMLElement {
206
202
  this._render();
207
203
  if (name === 'open') {
208
204
  this.dispatchEvent(new CustomEvent('notify::open', { bubbles: true, detail: { open: this.open } }));
209
- if (this.open) this._onPresented();
205
+ if (this.open) this._modal.present();
210
206
  else this._onClosed();
211
207
  }
212
208
  }
@@ -222,53 +218,10 @@ export class AdwDialog extends HTMLElement {
222
218
  this.open = false;
223
219
  }
224
220
 
225
- /** Focus trap + Escape while the box has focus. */
226
- private _onBoxKeyDown(event: KeyboardEvent): void {
227
- if (event.key === 'Escape' && this.open) {
228
- event.stopPropagation();
229
- this._attemptClose();
230
- return;
231
- }
232
- if (event.key !== 'Tab') return;
233
- const focusables = this._focusables();
234
- if (focusables.length === 0) {
235
- // Nothing tabbable inside — keep focus on the box.
236
- event.preventDefault();
237
- this._boxEl.focus();
238
- return;
239
- }
240
- const first = focusables[0];
241
- const last = focusables[focusables.length - 1];
242
- const active = document.activeElement;
243
- if (event.shiftKey && (active === first || active === this._boxEl)) {
244
- event.preventDefault();
245
- last.focus();
246
- } else if (!event.shiftKey && active === last) {
247
- event.preventDefault();
248
- first.focus();
249
- }
250
- }
251
-
252
- private _focusables(): HTMLElement[] {
253
- return Array.from(this._boxEl.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
254
- (el) => !el.hasAttribute('disabled') && !el.hidden,
255
- );
256
- }
257
-
258
- /** On present: remember focus, then move it into the dialog. */
259
- private _onPresented(): void {
260
- const active = document.activeElement;
261
- this._previouslyFocused = active instanceof HTMLElement && active !== this ? active : null;
262
- const target = this._focusables()[0] ?? this._boxEl;
263
- target.focus();
264
- }
265
-
266
- /** On close: emit `closed` and return focus to where it was before present. */
221
+ /** On close: emit `closed`, then let the surface return focus to where it was. */
267
222
  private _onClosed(): void {
268
223
  this.dispatchEvent(new CustomEvent('closed', { bubbles: true }));
269
- const restore = this._previouslyFocused;
270
- this._previouslyFocused = null;
271
- if (restore && restore.isConnected) restore.focus();
224
+ this._modal.dismiss();
272
225
  }
273
226
 
274
227
  private _render(): void {
@@ -13,7 +13,7 @@
13
13
  // the `expanded` attribute reflection and the event.
14
14
  //
15
15
  // Reference: refs/adwaita-web/adwaita-web/scss/_expander_row.scss
16
- // Reference: refs/libadwaita/src/stylesheet/widgets/_expander-row.scss
16
+ // Reference: refs/libadwaita/src/stylesheet/widgets/_lists.scss (AdwExpanderRow)
17
17
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
18
18
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
19
19
  // disclosure state machine composed from @gjsify/adwaita-core, the enable
@@ -21,6 +21,8 @@
21
21
 
22
22
  import { ExpanderState, deriveRowLabels } from '@gjsify/adwaita-core';
23
23
 
24
+ import { bindEmptySections } from '../empty-sections.js';
25
+
24
26
  // SIDE-EFFECT import, deliberately separate from the type import below: it guarantees
25
27
  // `adw-switch` is defined before this module's `customElements.define` can upgrade a
26
28
  // server-rendered `<adw-expander-row>` and build one. A combined
@@ -30,6 +32,7 @@ import { ExpanderState, deriveRowLabels } from '@gjsify/adwaita-core';
30
32
  import './adw-switch.js';
31
33
  import type { AdwSwitch } from './adw-switch.js';
32
34
  import { type AdwIcon, createAdwIcon } from './adw-icon.js';
35
+ import { attachRowActivation } from './row-activation.js';
33
36
 
34
37
  export class AdwExpanderRow extends HTMLElement {
35
38
  private _headerEl!: HTMLDivElement;
@@ -133,6 +136,12 @@ export class AdwExpanderRow extends HTMLElement {
133
136
  this._suffixEl.className = 'adw-expander-row-suffix';
134
137
  for (const child of suffixChildren) this._suffixEl.appendChild(child);
135
138
 
139
+ // Same reason as the action row's: `_render` is attribute-driven, and a widget
140
+ // appended into `prefixSection` after connect is a childList change nothing else
141
+ // hears. `contentSection` is NOT in here — an empty disclosure is still the
142
+ // disclosure, and `.expanded` alone decides whether it shows.
143
+ bindEmptySections(this._prefixEl, this._suffixEl);
144
+
136
145
  this._headerEl.append(this._prefixEl, textEl, this._suffixEl, this._enableSwitch, this._chevronEl);
137
146
 
138
147
  this._contentEl = document.createElement('div');
@@ -154,6 +163,11 @@ export class AdwExpanderRow extends HTMLElement {
154
163
  }
155
164
  });
156
165
 
166
+ // adw-expander-row.c:123 — `grab_focus` forwards to the header row, so the header
167
+ // is both the focus target and the disclosure control. Enter and Space go through
168
+ // its click handler, which is what a pointer already uses.
169
+ attachRowActivation({ row: this._headerEl, activatable: () => true });
170
+
157
171
  this._enableSwitch.addEventListener('notify::active', (event) => {
158
172
  // The switch's own notify stops at the row: the row publishes
159
173
  // `notify::enable-expansion`, and letting `notify::active` past would give
@@ -201,10 +215,10 @@ export class AdwExpanderRow extends HTMLElement {
201
215
  this._enableSwitch.active = this.enableExpansion;
202
216
  this._reflectingEnable = false;
203
217
 
204
- this._prefixEl.hidden = this._prefixEl.childElementCount === 0;
205
- this._suffixEl.hidden = this._suffixEl.childElementCount === 0;
206
-
207
218
  this.classList.toggle('expanded', this.expanded);
219
+ // adw-expander-row.c:657 keeps GTK_ACCESSIBLE_STATE_EXPANDED on the header row in
220
+ // step with the flag; `aria-expanded` is the same statement to the same reader.
221
+ this._headerEl.setAttribute('aria-expanded', String(this.expanded));
208
222
  this._contentEl.classList.toggle('expanded', this.expanded);
209
223
  }
210
224
  }
@@ -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';