@gjsify/adwaita-web 0.40.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 (113) hide show
  1. package/README.md +98 -3
  2. package/dist/adwaita-web.css +142 -63
  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 +48 -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-tab-view.d.ts +23 -0
  19. package/lib/types/elements/adw-toggle-group.d.ts +2 -0
  20. package/lib/types/elements/adw-toolbar-view.d.ts +8 -0
  21. package/lib/types/elements/modal-surface.d.ts +59 -0
  22. package/lib/types/elements/roving-focus.d.ts +26 -0
  23. package/lib/types/elements/row-activation.d.ts +26 -0
  24. package/lib/types/empty-sections.d.ts +15 -0
  25. package/lib/types/empty-sections.spec.d.ts +1 -0
  26. package/lib/types/fonts.d.ts +1 -0
  27. package/lib/types/icon-registry.d.ts +35 -0
  28. package/lib/types/icon-registry.spec.d.ts +18 -0
  29. package/lib/types/index.d.ts +3 -1
  30. package/lib/types/keyboard-operable.spec.d.ts +1 -0
  31. package/lib/types/scroll-shading.d.ts +56 -0
  32. package/lib/types/styles.generated.d.ts +1 -1
  33. package/package.json +10 -6
  34. package/scss/_banner.scss +1 -1
  35. package/scss/_carousel.scss +15 -1
  36. package/scss/_checks.scss +8 -8
  37. package/scss/_combo_row.scss +1 -1
  38. package/scss/_drop_down.scss +1 -1
  39. package/scss/_expander_row.scss +1 -1
  40. package/scss/_headerbar.scss +35 -6
  41. package/scss/_icon.scss +47 -0
  42. package/scss/_icons.generated.scss +36 -6
  43. package/scss/_password_entry_row.scss +1 -1
  44. package/scss/_preferences_dialog.scss +3 -2
  45. package/scss/_progress_bar.scss +1 -1
  46. package/scss/_row.scss +1 -1
  47. package/scss/_scrolling.scss +107 -0
  48. package/scss/_status_page.scss +1 -1
  49. package/scss/_style_classes.scss +21 -8
  50. package/scss/_tab_view.scss +1 -1
  51. package/scss/_theme.scss +1 -0
  52. package/scss/_toast.scss +6 -24
  53. package/scss/_toolbar_view.scss +52 -22
  54. package/scss/_variables.scss +30 -3
  55. package/scss/_view_switcher.scss +80 -20
  56. package/scss/adwaita-skin.scss +1 -0
  57. package/src/adw-action-rows.spec.ts +21 -9
  58. package/src/adw-button-content.spec.ts +11 -6
  59. package/src/adw-carousel.spec.ts +141 -1
  60. package/src/adw-fonts.spec.ts +188 -0
  61. package/src/adw-header-bar.spec.ts +92 -0
  62. package/src/adw-icon.spec.ts +17 -8
  63. package/src/adw-row-state.spec.ts +11 -1
  64. package/src/adw-tab-view.spec.ts +151 -0
  65. package/src/bottom-sheet.spec.ts +87 -0
  66. package/src/breakpoints.ts +18 -4
  67. package/src/chrome.spec.ts +233 -0
  68. package/src/connect-lifecycle.spec.ts +351 -0
  69. package/src/elements/adw-about-dialog.ts +24 -18
  70. package/src/elements/adw-action-row.ts +14 -2
  71. package/src/elements/adw-alert-dialog.ts +39 -20
  72. package/src/elements/adw-button-row.ts +3 -0
  73. package/src/elements/adw-carousel.ts +121 -4
  74. package/src/elements/adw-clamp.ts +3 -2
  75. package/src/elements/adw-data-grid.ts +5 -2
  76. package/src/elements/adw-dialog.ts +16 -63
  77. package/src/elements/adw-expander-row.ts +18 -4
  78. package/src/elements/adw-header-bar.ts +2 -0
  79. package/src/elements/adw-icon.ts +32 -6
  80. package/src/elements/adw-inline-view-switcher.ts +12 -0
  81. package/src/elements/adw-menu-button.ts +17 -10
  82. package/src/elements/adw-navigation-split-view.ts +30 -7
  83. package/src/elements/adw-overlay-split-view.ts +15 -3
  84. package/src/elements/adw-popover.ts +11 -0
  85. package/src/elements/adw-preferences-dialog.ts +18 -16
  86. package/src/elements/adw-preferences-group.ts +7 -0
  87. package/src/elements/adw-sidebar.ts +15 -0
  88. package/src/elements/adw-status-page.ts +12 -4
  89. package/src/elements/adw-switch-row.ts +7 -0
  90. package/src/elements/adw-switch.ts +19 -1
  91. package/src/elements/adw-tab-view.ts +70 -7
  92. package/src/elements/adw-toast-overlay.ts +8 -1
  93. package/src/elements/adw-toggle-group.ts +94 -2
  94. package/src/elements/adw-toolbar-view.ts +28 -2
  95. package/src/elements/adw-view-switcher-bar.ts +18 -0
  96. package/src/elements/adw-view-switcher.ts +14 -0
  97. package/src/elements/modal-surface.ts +180 -0
  98. package/src/elements/roving-focus.ts +154 -0
  99. package/src/elements/row-activation.ts +92 -0
  100. package/src/empty-sections.spec.ts +81 -0
  101. package/src/empty-sections.ts +54 -0
  102. package/src/fonts.ts +25 -0
  103. package/src/icon-registry.spec.ts +240 -0
  104. package/src/icon-registry.ts +157 -0
  105. package/src/index.ts +30 -1
  106. package/src/keyboard-operable.spec.ts +755 -0
  107. package/src/scroll-shading.ts +255 -0
  108. package/src/source-view/theme.ts +15 -3
  109. package/src/split-button.spec.ts +6 -2
  110. package/src/style-classes.spec.ts +6 -0
  111. package/src/styles.generated.ts +1 -1
  112. package/src/test.browser.mts +14 -0
  113. package/src/view-switcher.spec.ts +149 -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() {
@@ -27,7 +27,7 @@
27
27
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
28
28
  // Modifications: Implemented as Web Components for @gjsify/adwaita-web.
29
29
 
30
- import { CarouselState } from '@gjsify/adwaita-core';
30
+ import { CarouselState, carouselPageAllocation } from '@gjsify/adwaita-core';
31
31
  import type { CarouselScrollRequest, CarouselScrollSource, CarouselStateChange } from '@gjsify/adwaita-core';
32
32
 
33
33
  /** Common surface an indicator binds to. The concrete element is AdwCarousel. */
@@ -79,12 +79,21 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
79
79
  /** A declared `position` waiting for the track to have a width. */
80
80
  private _pendingPosition: number | null = null;
81
81
  private _pendingLayout: ResizeObserver | undefined;
82
+ /** Watches the carousel's own width, which caps the page size. */
83
+ private _layoutObserver: ResizeObserver | undefined;
82
84
  /**
83
85
  * `interactive` for the scroll now in flight: the flag of the request that
84
86
  * started it, or `null` for a scroll we did not start — which is a user drag,
85
87
  * and therefore interactive.
86
88
  */
87
89
  private _pendingInteractive: boolean | null = null;
90
+ /**
91
+ * The page an issued scroll is travelling TO, `null` once it has arrived:
92
+ * `self->animation_target_child`. Held as the page's ID and not its index, for the
93
+ * same reason C holds a widget, since an insert or a reorder in between moves the
94
+ * index while the target page stays the target.
95
+ */
96
+ private _scrollTargetId: string | null = null;
88
97
 
89
98
  static get observedAttributes() {
90
99
  return ['allow-scroll-wheel', 'allow-long-swipes', 'interactive', 'spacing', 'position'];
@@ -159,7 +168,13 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
159
168
  }
160
169
 
161
170
  connectedCallback() {
162
- if (this._initialized) return;
171
+ if (!this._initialized) this._build();
172
+ // Re-armed on every connect, because `disconnectedCallback` drops it: a
173
+ // carousel moved into another parent has a new width to follow.
174
+ this._observeLayout();
175
+ }
176
+
177
+ private _build(): void {
163
178
  this._initialized = true;
164
179
 
165
180
  this._trackEl = document.createElement('div');
@@ -200,6 +215,28 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
200
215
  // A pending declared position watches the track for its first layout; a
201
216
  // carousel removed before that must not keep the observer alive.
202
217
  this._cancelPendingPosition();
218
+ this._layoutObserver?.disconnect();
219
+ this._layoutObserver = undefined;
220
+ }
221
+
222
+ /**
223
+ * Re-run the page allocation whenever the carousel's own width changes: the page
224
+ * size is capped by it, so a resize can turn a peek into a full-width page and back.
225
+ */
226
+ private _observeLayout(): void {
227
+ if (this._layoutObserver) return;
228
+ this._layoutObserver = new ResizeObserver(() => {
229
+ // Only when the PITCH changed. The scroll offset is `position * distance`,
230
+ // so a resize that leaves the page size alone leaves the offset meaning
231
+ // exactly what it meant. Re-applying it unconditionally rewound every scroll
232
+ // still in flight: the observer's first delivery lands in the same frame as
233
+ // the wheel or drag that started one, and the model only learns the new
234
+ // offset from the scroll event that has not arrived yet.
235
+ if (this._allocatePages()) this._applyScrollFromModel();
236
+ });
237
+ // The BORDER box: the inset is written as track padding, and observing the
238
+ // content box would report that write back to us as a resize.
239
+ this._layoutObserver.observe(this._trackEl, { box: 'border-box' });
203
240
  }
204
241
 
205
242
  private _applyAttribute(name: string, value: string | null): void {
@@ -378,9 +415,64 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
378
415
  }
379
416
  });
380
417
  this._applySnapStop();
418
+ this._allocatePages();
381
419
  this._applyScrollFromModel();
382
420
  }
383
421
 
422
+ /**
423
+ * Size the pages as `adw_carousel_size_allocate` does, and centre the strip in the
424
+ * carousel.
425
+ *
426
+ * A page is NOT the carousel's width. Every page is allocated the same size, the
427
+ * largest natural one capped at the carousel (adw-carousel.c:748-765), and the strip
428
+ * is drawn at an offset of `-(width - child_width) / 2` (:801), so whatever is left
429
+ * over shows the previous and next page at the two edges. The `flex: 0 0 100%` this
430
+ * replaces reproduced only the case where nothing is left over: the 440px pages of
431
+ * the Carousel story sat in a 480px carousel with both neighbours entirely off
432
+ * screen, where GTK shows 20px of each.
433
+ *
434
+ * The inset is track PADDING paired with `scroll-snap-align: center`, which is what
435
+ * keeps `scrollLeft = position * distance` true either way: page i then snaps at
436
+ * `inset + i * distance + pageSize / 2 - available / 2`, and the two insets cancel.
437
+ *
438
+ * Returns whether the page size, and with it the scroll pitch, actually moved.
439
+ */
440
+ private _allocatePages(): boolean {
441
+ const slots = [...this._slots.values()];
442
+ if (slots.length === 0) return false;
443
+
444
+ // What the pages are laid out at RIGHT NOW, read before the reset below. "Did
445
+ // the pitch change" has to be answered from the DOM rather than from a
446
+ // remembered write, or the first pass over an already-correct layout counts as
447
+ // a change and moves a scroll that had no reason to move.
448
+ const before = slots[0]!.getBoundingClientRect().width;
449
+
450
+ // Measure against the FULL-width allocation: a page whose content is
451
+ // `width: 100%` reports back whatever the slot currently is, so measuring it
452
+ // inside an already narrowed slot would shrink the pages again on every pass.
453
+ this._trackEl.style.removeProperty('padding-inline');
454
+ for (const slot of slots) slot.style.removeProperty('flex-basis');
455
+
456
+ const available = this._trackEl.clientWidth;
457
+ if (!(available > 0)) return false;
458
+
459
+ // `natural` only: CSS has already resolved a page's own `min-width` and every
460
+ // spelling of "fill the carousel" into the width it renders at, so there is no
461
+ // separate minimum or hexpand left for the renderer to pass.
462
+ const measurements = slots.map((slot) => ({
463
+ natural: (slot.firstElementChild as HTMLElement | null)?.getBoundingClientRect().width ?? 0,
464
+ }));
465
+ const { pageSize, leadingInset } = carouselPageAllocation(available, measurements);
466
+ // A page that measures nothing yet (an image still loading, a font not
467
+ // applied) would collapse the whole strip; the full-width fallback stands
468
+ // until the resize that gives it a size.
469
+ if (!(pageSize > 0)) return false;
470
+
471
+ for (const slot of slots) slot.style.flexBasis = `${pageSize}px`;
472
+ if (leadingInset > 0) this._trackEl.style.paddingInline = `${leadingInset}px`;
473
+ return Math.round(pageSize) !== Math.round(before);
474
+ }
475
+
384
476
  /**
385
477
  * Put the scroll offset back where the model says it is, instantly. A page revealed
386
478
  * at or before the current one moves the position with it (`shift_position`)
@@ -391,7 +483,29 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
391
483
  private _applyScrollFromModel(): void {
392
484
  const distance = this._distance();
393
485
  if (!(distance > 0)) return;
394
- this._trackEl.scrollTo({ left: this._state.position * distance, behavior: 'instant' });
486
+ this._trackEl.scrollTo({ left: this._restorePosition() * distance, behavior: 'instant' });
487
+ }
488
+
489
+ /**
490
+ * Which position that write must aim at. Normally the model's, but a scroll still
491
+ * in flight has not reached the model yet: `scrollTo` only ASKS, and `position`
492
+ * catches up from the `scroll` event a frame later. Re-applying the stale position
493
+ * there parks the carousel back where it started and swallows the scroll, which is
494
+ * how `<adw-carousel position="2">` lost its page: the pending-position observer
495
+ * and the layout observer are delivered in ONE resize pass, so the very first
496
+ * allocation landed on a scroll it had just issued.
497
+ *
498
+ * `size_allocate` reaches the same moment and re-aims rather than rewinds: it
499
+ * pushes the recomputed snap point of `animation_target_child` into the running
500
+ * animation (adw-carousel.c:786-788).
501
+ */
502
+ private _restorePosition(): number {
503
+ if (this._scrollTargetId === null) return this._state.position;
504
+ const index = this._state.ids.indexOf(this._scrollTargetId);
505
+ // The target page was removed while the scroll ran; the model's position is
506
+ // then the only thing left that means anything.
507
+ if (index < 0) return this._state.position;
508
+ return this._state.snapPointOf(index) ?? this._state.position;
395
509
  }
396
510
 
397
511
  /**
@@ -416,6 +530,7 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
416
530
  const distance = this._distance();
417
531
  if (!(distance > 0)) return;
418
532
  this._pendingInteractive = request.interactive;
533
+ this._scrollTargetId = this._state.ids[request.index] ?? null;
419
534
  // `offset = distance * position`, NOT `slot.offsetLeft` — that is measured from
420
535
  // the nearest POSITIONED ancestor and coincides with the scroll offset only by
421
536
  // accident.
@@ -454,7 +569,9 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
454
569
  // `scroll_animation_done_cb` has an animation to tell it the scroll
455
570
  // finished; scroll-snap has only the offset, so the core stands ARRIVAL
456
571
  // at a snap point in for it.
457
- if (this._state.settleIfArrived()) this._pendingInteractive = null;
572
+ if (!this._state.settleIfArrived()) return;
573
+ this._pendingInteractive = null;
574
+ this._scrollTargetId = null;
458
575
  }
459
576
 
460
577
  private _onStateChange(change: CarouselStateChange): void {
@@ -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 {