@gjsify/adwaita-web 0.41.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -3
- package/dist/adwaita-web.css +30 -23
- 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 +8 -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-toggle-group.d.ts +2 -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/elements/swipe-drag.d.ts +63 -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 +1 -1
- package/lib/types/keyboard-operable.spec.d.ts +1 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +10 -6
- package/scss/_banner.scss +1 -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/_icon.scss +47 -0
- package/scss/_icons.generated.scss +18 -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/_status_page.scss +1 -1
- package/scss/_tab_view.scss +1 -1
- package/scss/_toast.scss +6 -24
- package/scss/_variables.scss +23 -3
- package/src/adw-action-rows.spec.ts +21 -9
- package/src/adw-button-content.spec.ts +11 -6
- package/src/adw-fonts.spec.ts +188 -0
- package/src/adw-icon.spec.ts +17 -8
- package/src/adw-row-state.spec.ts +11 -1
- package/src/breakpoints.ts +18 -4
- 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 +86 -5
- 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 +7 -4
- 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 +6 -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/elements/swipe-drag.ts +450 -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 +24 -1
- package/src/keyboard-operable.spec.ts +755 -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
|
@@ -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() {
|
|
@@ -9,9 +9,14 @@
|
|
|
9
9
|
// position compensation. This element is the DOM half only — scroll-snap strip,
|
|
10
10
|
// offset↔position, DOM events.
|
|
11
11
|
//
|
|
12
|
-
// The
|
|
13
|
-
// `interactive` default TRUE, so `="false"` is the off switch;
|
|
14
|
-
// is an ordinary presence attribute.
|
|
12
|
+
// The four attribute defaults HTML cannot spell: `allow-scroll-wheel`,
|
|
13
|
+
// `allow-mouse-drag` and `interactive` default TRUE, so `="false"` is the off switch;
|
|
14
|
+
// `allow-long-swipes` is an ordinary presence attribute.
|
|
15
|
+
//
|
|
16
|
+
// A MOUSE DRAG is `elements/swipe-drag.ts`, and it is the one gesture a browser gives
|
|
17
|
+
// nothing for: touch and touchpad scroll this strip natively, a held mouse button does
|
|
18
|
+
// not. Upstream turns it on in `init` (:1209) and exposes `allow-mouse-drag`, default
|
|
19
|
+
// TRUE (:1091). Measured before: a 300 px drag moved `position` 0 → 0. `position` is the INITIAL page only —
|
|
15
20
|
// write-only, with the live value on the `position` property and `notify::position`.
|
|
16
21
|
//
|
|
17
22
|
// `page-changed` (`detail = { index }`) fires on every settle, including back
|
|
@@ -27,9 +32,11 @@
|
|
|
27
32
|
// Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
|
|
28
33
|
// Modifications: Implemented as Web Components for @gjsify/adwaita-web.
|
|
29
34
|
|
|
30
|
-
import { CarouselState, carouselPageAllocation } from '@gjsify/adwaita-core';
|
|
35
|
+
import { CarouselState, carouselClosestSnapPoint, carouselPageAllocation } from '@gjsify/adwaita-core';
|
|
31
36
|
import type { CarouselScrollRequest, CarouselScrollSource, CarouselStateChange } from '@gjsify/adwaita-core';
|
|
32
37
|
|
|
38
|
+
import { attachSwipeDrag, type AdwSwipeDrag } from './swipe-drag.js';
|
|
39
|
+
|
|
33
40
|
/** Common surface an indicator binds to. The concrete element is AdwCarousel. */
|
|
34
41
|
interface CarouselHost extends HTMLElement {
|
|
35
42
|
readonly nPages: number;
|
|
@@ -94,9 +101,11 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
|
|
|
94
101
|
* index while the target page stays the target.
|
|
95
102
|
*/
|
|
96
103
|
private _scrollTargetId: string | null = null;
|
|
104
|
+
/** The pointer-drag gesture, asked whether it owns the strip. See `_onWheel`. */
|
|
105
|
+
private _drag: AdwSwipeDrag | undefined;
|
|
97
106
|
|
|
98
107
|
static get observedAttributes() {
|
|
99
|
-
return ['allow-scroll-wheel', 'allow-long-swipes', 'interactive', 'spacing', 'position'];
|
|
108
|
+
return ['allow-scroll-wheel', 'allow-mouse-drag', 'allow-long-swipes', 'interactive', 'spacing', 'position'];
|
|
100
109
|
}
|
|
101
110
|
|
|
102
111
|
constructor() {
|
|
@@ -132,6 +141,18 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
|
|
|
132
141
|
return this._state.pageAt(this._state.position);
|
|
133
142
|
}
|
|
134
143
|
|
|
144
|
+
/**
|
|
145
|
+
* `AdwCarousel:allow-mouse-drag` — whether the strip can be dragged with the pointer.
|
|
146
|
+
* Defaults to TRUE, as in libadwaita, where FALSE leaves dragging to touch only.
|
|
147
|
+
*/
|
|
148
|
+
get allowMouseDrag(): boolean {
|
|
149
|
+
return this._state.allowMouseDrag;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
set allowMouseDrag(value: boolean) {
|
|
153
|
+
this.setAttribute('allow-mouse-drag', value ? 'true' : 'false');
|
|
154
|
+
}
|
|
155
|
+
|
|
135
156
|
/** Whether the scroll wheel navigates between pages. Defaults to TRUE, as in libadwaita. */
|
|
136
157
|
get allowScrollWheel(): boolean {
|
|
137
158
|
return this._state.allowScrollWheel;
|
|
@@ -187,6 +208,7 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
|
|
|
187
208
|
// Declared properties BEFORE children: `spacing` is part of the page pitch,
|
|
188
209
|
// so a page added first would be measured against the wrong distance.
|
|
189
210
|
this._applyAttribute('allow-scroll-wheel', this.getAttribute('allow-scroll-wheel'));
|
|
211
|
+
this._applyAttribute('allow-mouse-drag', this.getAttribute('allow-mouse-drag'));
|
|
190
212
|
this._applyAttribute('allow-long-swipes', this.getAttribute('allow-long-swipes'));
|
|
191
213
|
this._applyAttribute('interactive', this.getAttribute('interactive'));
|
|
192
214
|
this._applyAttribute('spacing', this.getAttribute('spacing'));
|
|
@@ -200,6 +222,52 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
|
|
|
200
222
|
this._trackEl.addEventListener('scroll', this._onScroll, { passive: true });
|
|
201
223
|
this._trackEl.addEventListener('wheel', this._onWheel, { passive: false });
|
|
202
224
|
|
|
225
|
+
this._drag = attachSwipeDrag({
|
|
226
|
+
// On the TRACK, not on the host: the track is the scroll container the drag
|
|
227
|
+
// writes `scrollLeft` on, and it is what the pages live in.
|
|
228
|
+
host: this._trackEl,
|
|
229
|
+
orientation: 'horizontal',
|
|
230
|
+
// `interactive` gates it because that is exactly what it does upstream —
|
|
231
|
+
// `adw_carousel_set_interactive` is `adw_swipe_tracker_set_enabled` (:1705).
|
|
232
|
+
enabled: () => this._state.interactive && this._state.allowMouseDrag,
|
|
233
|
+
pitch: () => this._distance(),
|
|
234
|
+
points: () => this._state.snapPoints,
|
|
235
|
+
progress: () => this._state.position,
|
|
236
|
+
allowLongSwipes: () => this._state.allowLongSwipes,
|
|
237
|
+
// `adw_carousel_get_cancel_progress` is `get_closest_snap_point`
|
|
238
|
+
// (adw-carousel.c:1294-1299) — the page nearest where the gesture was
|
|
239
|
+
// interrupted, NOT the one it started from.
|
|
240
|
+
//
|
|
241
|
+
// `carouselClosestSnapPoint` and NOT `_state.pageAt`, for two reasons that
|
|
242
|
+
// both make the answer a different page. `pageAt` searches the VISIBLE snap
|
|
243
|
+
// points (it filters out children being removed) and returns an index into
|
|
244
|
+
// that filtered array, so subscripting the unfiltered list with it shifts by
|
|
245
|
+
// one for every removing child ahead of the target. And it is the
|
|
246
|
+
// `count_removing = FALSE` variant, while `get_closest_snap_point` passes
|
|
247
|
+
// TRUE (adw-carousel.c:1298) — this is the one call site that counts them.
|
|
248
|
+
//
|
|
249
|
+
// The POINT, not an index: a page mid-reveal has a fractional size, so
|
|
250
|
+
// `carouselSnapPoints` (cumulative size − 1) hands back non-integers.
|
|
251
|
+
cancelProgress: () => {
|
|
252
|
+
const points = this._state.snapPoints;
|
|
253
|
+
return points[carouselClosestSnapPoint(this._state.position, points)] ?? this._state.position;
|
|
254
|
+
},
|
|
255
|
+
// The DRAG writes the offset directly and the existing scroll handler reads
|
|
256
|
+
// the position back off it — the same path a touch scroll takes, so there is
|
|
257
|
+
// no second route into `position` to drift from this one.
|
|
258
|
+
onUpdate: (progress) => {
|
|
259
|
+
const distance = this._distance();
|
|
260
|
+
if (distance > 0) this._trackEl.scrollLeft = progress * distance;
|
|
261
|
+
},
|
|
262
|
+
// `scroll_to` with the swipe's velocity is what `end_swipe_cb` does (:434).
|
|
263
|
+
// Here the settle is the browser's smooth scroll, so the velocity is unused
|
|
264
|
+
// — snap is already restored, and `to` is a snap point by construction.
|
|
265
|
+
onEnd: (progress) => {
|
|
266
|
+
const distance = this._distance();
|
|
267
|
+
if (distance > 0) this._trackEl.scrollTo({ left: progress * distance });
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
203
271
|
// Applied last, once the pages it names exist. Custom-element upgrade
|
|
204
272
|
// delivers attributes BEFORE `connectedCallback`, so reading it here is the
|
|
205
273
|
// only way `<adw-carousel position="2">` works at all.
|
|
@@ -246,6 +314,10 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
|
|
|
246
314
|
// usual "presence = on" spelling would invert it.
|
|
247
315
|
this._state.setAllowScrollWheel(value !== 'false');
|
|
248
316
|
return;
|
|
317
|
+
case 'allow-mouse-drag':
|
|
318
|
+
// Absent means TRUE, like `allow-scroll-wheel` and for the same reason.
|
|
319
|
+
this._state.setAllowMouseDrag(value !== 'false');
|
|
320
|
+
return;
|
|
249
321
|
case 'allow-long-swipes':
|
|
250
322
|
this._state.setAllowLongSwipes(value !== null && value !== 'false');
|
|
251
323
|
this._applySnapStop();
|
|
@@ -546,6 +618,15 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
|
|
|
546
618
|
};
|
|
547
619
|
|
|
548
620
|
private _onWheel = (event: WheelEvent): void => {
|
|
621
|
+
// A drag owns the offset while it is claimed. Without this a single wheel notch
|
|
622
|
+
// mid-drag ran the whole paging path and moved `scrollLeft` a full page in one
|
|
623
|
+
// frame — measured 80 → 440 — which the next pointer move then yanked back, and
|
|
624
|
+
// it left `_scrollTargetId` pointing at a page the drag overrode. `GDK_EVENT_STOP`
|
|
625
|
+
// rather than propagate, because the gesture IS handling this widget's scrolling.
|
|
626
|
+
if (this._drag?.dragging === true) {
|
|
627
|
+
event.preventDefault();
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
549
630
|
const step = this._state.handleWheel({
|
|
550
631
|
deltaX: event.deltaX,
|
|
551
632
|
deltaY: event.deltaY,
|
|
@@ -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 {
|
|
@@ -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/
|
|
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
|
}
|