@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.
- package/README.md +98 -3
- package/dist/adwaita-web.css +142 -63
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/adw-fonts.spec.d.ts +2 -0
- package/lib/types/breakpoints.d.ts +10 -1
- package/lib/types/connect-lifecycle.spec.d.ts +1 -0
- package/lib/types/elements/adw-about-dialog.d.ts +1 -0
- package/lib/types/elements/adw-action-row.d.ts +2 -0
- package/lib/types/elements/adw-alert-dialog.d.ts +14 -2
- package/lib/types/elements/adw-carousel.d.ts +48 -0
- package/lib/types/elements/adw-dialog.d.ts +2 -7
- package/lib/types/elements/adw-icon.d.ts +17 -1
- package/lib/types/elements/adw-navigation-split-view.d.ts +7 -0
- package/lib/types/elements/adw-overlay-split-view.d.ts +6 -0
- package/lib/types/elements/adw-popover.d.ts +1 -0
- package/lib/types/elements/adw-preferences-dialog.d.ts +1 -0
- package/lib/types/elements/adw-switch.d.ts +10 -0
- package/lib/types/elements/adw-tab-view.d.ts +23 -0
- package/lib/types/elements/adw-toggle-group.d.ts +2 -0
- package/lib/types/elements/adw-toolbar-view.d.ts +8 -0
- package/lib/types/elements/modal-surface.d.ts +59 -0
- package/lib/types/elements/roving-focus.d.ts +26 -0
- package/lib/types/elements/row-activation.d.ts +26 -0
- package/lib/types/empty-sections.d.ts +15 -0
- package/lib/types/empty-sections.spec.d.ts +1 -0
- package/lib/types/fonts.d.ts +1 -0
- package/lib/types/icon-registry.d.ts +35 -0
- package/lib/types/icon-registry.spec.d.ts +18 -0
- package/lib/types/index.d.ts +3 -1
- package/lib/types/keyboard-operable.spec.d.ts +1 -0
- package/lib/types/scroll-shading.d.ts +56 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +10 -6
- package/scss/_banner.scss +1 -1
- package/scss/_carousel.scss +15 -1
- package/scss/_checks.scss +8 -8
- package/scss/_combo_row.scss +1 -1
- package/scss/_drop_down.scss +1 -1
- package/scss/_expander_row.scss +1 -1
- package/scss/_headerbar.scss +35 -6
- package/scss/_icon.scss +47 -0
- package/scss/_icons.generated.scss +36 -6
- package/scss/_password_entry_row.scss +1 -1
- package/scss/_preferences_dialog.scss +3 -2
- package/scss/_progress_bar.scss +1 -1
- package/scss/_row.scss +1 -1
- package/scss/_scrolling.scss +107 -0
- package/scss/_status_page.scss +1 -1
- package/scss/_style_classes.scss +21 -8
- package/scss/_tab_view.scss +1 -1
- package/scss/_theme.scss +1 -0
- package/scss/_toast.scss +6 -24
- package/scss/_toolbar_view.scss +52 -22
- package/scss/_variables.scss +30 -3
- package/scss/_view_switcher.scss +80 -20
- package/scss/adwaita-skin.scss +1 -0
- package/src/adw-action-rows.spec.ts +21 -9
- package/src/adw-button-content.spec.ts +11 -6
- package/src/adw-carousel.spec.ts +141 -1
- package/src/adw-fonts.spec.ts +188 -0
- package/src/adw-header-bar.spec.ts +92 -0
- package/src/adw-icon.spec.ts +17 -8
- package/src/adw-row-state.spec.ts +11 -1
- package/src/adw-tab-view.spec.ts +151 -0
- package/src/bottom-sheet.spec.ts +87 -0
- package/src/breakpoints.ts +18 -4
- package/src/chrome.spec.ts +233 -0
- package/src/connect-lifecycle.spec.ts +351 -0
- package/src/elements/adw-about-dialog.ts +24 -18
- package/src/elements/adw-action-row.ts +14 -2
- package/src/elements/adw-alert-dialog.ts +39 -20
- package/src/elements/adw-button-row.ts +3 -0
- package/src/elements/adw-carousel.ts +121 -4
- package/src/elements/adw-clamp.ts +3 -2
- package/src/elements/adw-data-grid.ts +5 -2
- package/src/elements/adw-dialog.ts +16 -63
- package/src/elements/adw-expander-row.ts +18 -4
- package/src/elements/adw-header-bar.ts +2 -0
- package/src/elements/adw-icon.ts +32 -6
- package/src/elements/adw-inline-view-switcher.ts +12 -0
- package/src/elements/adw-menu-button.ts +17 -10
- package/src/elements/adw-navigation-split-view.ts +30 -7
- package/src/elements/adw-overlay-split-view.ts +15 -3
- package/src/elements/adw-popover.ts +11 -0
- package/src/elements/adw-preferences-dialog.ts +18 -16
- package/src/elements/adw-preferences-group.ts +7 -0
- package/src/elements/adw-sidebar.ts +15 -0
- package/src/elements/adw-status-page.ts +12 -4
- package/src/elements/adw-switch-row.ts +7 -0
- package/src/elements/adw-switch.ts +19 -1
- package/src/elements/adw-tab-view.ts +70 -7
- package/src/elements/adw-toast-overlay.ts +8 -1
- package/src/elements/adw-toggle-group.ts +94 -2
- package/src/elements/adw-toolbar-view.ts +28 -2
- package/src/elements/adw-view-switcher-bar.ts +18 -0
- package/src/elements/adw-view-switcher.ts +14 -0
- package/src/elements/modal-surface.ts +180 -0
- package/src/elements/roving-focus.ts +154 -0
- package/src/elements/row-activation.ts +92 -0
- package/src/empty-sections.spec.ts +81 -0
- package/src/empty-sections.ts +54 -0
- package/src/fonts.ts +25 -0
- package/src/icon-registry.spec.ts +240 -0
- package/src/icon-registry.ts +157 -0
- package/src/index.ts +30 -1
- package/src/keyboard-operable.spec.ts +755 -0
- package/src/scroll-shading.ts +255 -0
- package/src/source-view/theme.ts +15 -3
- package/src/split-button.spec.ts +6 -2
- package/src/style-classes.spec.ts +6 -0
- package/src/styles.generated.ts +1 -1
- package/src/test.browser.mts +14 -0
- 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
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
|
386
|
-
// adw-tab-view's close affordance: `window-close`
|
|
387
|
-
//
|
|
388
|
-
//
|
|
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`)
|
|
434
|
-
//
|
|
435
|
-
//
|
|
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.
|
|
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
|
-
/**
|
|
355
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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)
|
|
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.
|
|
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())
|
|
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
|
|
44
|
-
// connect time" bug
|
|
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.
|
|
56
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
161
|
-
//
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
270
|
-
this._previouslyFocused = null;
|
|
271
|
-
if (restore && restore.isConnected) restore.focus();
|
|
224
|
+
this._modal.dismiss();
|
|
272
225
|
}
|
|
273
226
|
|
|
274
227
|
private _render(): void {
|