@gjsify/adwaita-web 0.22.0 → 0.24.1
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 +48 -0
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/adw-row-state.spec.d.ts +1 -0
- package/lib/types/adw-toast-overlay.spec.d.ts +1 -0
- package/lib/types/elements/adw-alert-dialog.d.ts +6 -5
- package/lib/types/elements/adw-combo-row.d.ts +2 -1
- package/lib/types/elements/adw-expander-row.d.ts +4 -1
- package/lib/types/elements/adw-spin-row.d.ts +6 -4
- package/lib/types/elements/adw-toast-overlay.d.ts +42 -1
- package/lib/types/elements/adw-toggle-group.d.ts +2 -1
- package/package.json +6 -5
- package/scss/_toast.scss +5 -2
- package/src/adw-row-state.spec.ts +217 -0
- package/src/adw-toast-overlay.spec.ts +172 -0
- package/src/elements/adw-alert-dialog.ts +88 -57
- package/src/elements/adw-combo-row.ts +42 -15
- package/src/elements/adw-expander-row.ts +46 -10
- package/src/elements/adw-spin-row.ts +56 -32
- package/src/elements/adw-toast-overlay.ts +151 -39
- package/src/elements/adw-toggle-group.ts +30 -14
- package/src/test.browser.mts +4 -0
|
@@ -29,23 +29,27 @@
|
|
|
29
29
|
// `notify::heading` / `notify::body` / `notify::open` (CustomEvent, bubbles)
|
|
30
30
|
// mirroring the matching GObject properties.
|
|
31
31
|
//
|
|
32
|
+
// The RESPONSE MODEL — the response registry (id/label/appearance/enabled), the
|
|
33
|
+
// default/close-response semantics and the resolve-to-chosen-id contract — is
|
|
34
|
+
// HEADLESS and lives in `@gjsify/adwaita-core` (ADR 0004) as
|
|
35
|
+
// {@link AdwAlertResponses}; this element composes it and keeps only the DOM
|
|
36
|
+
// render half: the scrim + floating sheet, one `<button>` per registered
|
|
37
|
+
// response, and the `response` event. `@gjsify/adwaita-nativescript` composes the
|
|
38
|
+
// same model, so both ports share one behaviour.
|
|
39
|
+
//
|
|
32
40
|
// Reference: refs/libadwaita/src/adw-alert-dialog.c (AdwAlertDialog behaviour)
|
|
33
41
|
// Reference: refs/libadwaita/src/adw-dialog.c (shared dialog base — Escape/close)
|
|
34
42
|
// Reference: refs/libadwaita/src/stylesheet/widgets/_dialogs.scss (floating-sheet)
|
|
35
43
|
// Reference: refs/adwaita-web/adwaita-web/scss/_dialog.scss (alert layout)
|
|
36
44
|
// Copyright (c) 2022 Purism SPC / 2024 GNOME Foundation Inc. (libadwaita). LGPLv2.1+.
|
|
37
|
-
// Modifications: Implemented as a Web Component for @gjsify/adwaita-web
|
|
45
|
+
// Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
|
|
46
|
+
// response model composed from @gjsify/adwaita-core.
|
|
47
|
+
|
|
48
|
+
import { AdwAlertResponses } from '@gjsify/adwaita-core';
|
|
49
|
+
import type { AdwResponseAppearance } from '@gjsify/adwaita-core';
|
|
38
50
|
|
|
39
51
|
/** Response button appearance — mirrors Adw.ResponseAppearance. */
|
|
40
|
-
export type AdwResponseAppearance
|
|
41
|
-
|
|
42
|
-
interface ResponseInfo {
|
|
43
|
-
id: string;
|
|
44
|
-
label: string;
|
|
45
|
-
appearance: AdwResponseAppearance;
|
|
46
|
-
enabled: boolean;
|
|
47
|
-
button: HTMLButtonElement;
|
|
48
|
-
}
|
|
52
|
+
export type { AdwResponseAppearance } from '@gjsify/adwaita-core';
|
|
49
53
|
|
|
50
54
|
/**
|
|
51
55
|
* A single response declared in markup. Child of <adw-alert-dialog>; its `id`
|
|
@@ -68,10 +72,10 @@ export class AdwAlertDialog extends HTMLElement {
|
|
|
68
72
|
private _childEl!: HTMLDivElement;
|
|
69
73
|
private _responseAreaEl!: HTMLDivElement;
|
|
70
74
|
|
|
71
|
-
|
|
72
|
-
private
|
|
73
|
-
|
|
74
|
-
private
|
|
75
|
+
/** The headless response registry + default/close semantics + resolution (ADR 0004). */
|
|
76
|
+
private _model = new AdwAlertResponses();
|
|
77
|
+
/** The rendered button per registered response id (the DOM half of the model). */
|
|
78
|
+
private readonly _buttons = new Map<string, HTMLButtonElement>();
|
|
75
79
|
// Set while a response is being emitted from a button so the close handler
|
|
76
80
|
// does not also fire the close-response (mirrors AdwAlertDialog's
|
|
77
81
|
// block_close_response flag).
|
|
@@ -121,7 +125,7 @@ export class AdwAlertDialog extends HTMLElement {
|
|
|
121
125
|
|
|
122
126
|
/** The ID of the default response (its button is highlighted). */
|
|
123
127
|
get defaultResponse(): string | null {
|
|
124
|
-
return this.
|
|
128
|
+
return this._model.defaultResponse;
|
|
125
129
|
}
|
|
126
130
|
|
|
127
131
|
set defaultResponse(value: string | null) {
|
|
@@ -130,7 +134,7 @@ export class AdwAlertDialog extends HTMLElement {
|
|
|
130
134
|
|
|
131
135
|
/** The ID passed to `response` when the dialog is dismissed. */
|
|
132
136
|
get closeResponse(): string {
|
|
133
|
-
return this.
|
|
137
|
+
return this._model.closeResponse;
|
|
134
138
|
}
|
|
135
139
|
|
|
136
140
|
set closeResponse(value: string) {
|
|
@@ -202,6 +206,11 @@ export class AdwAlertDialog extends HTMLElement {
|
|
|
202
206
|
if (el.getAttribute('enabled') === 'false') this.setResponseEnabled(id, false);
|
|
203
207
|
}
|
|
204
208
|
|
|
209
|
+
// Seed the headless model's text from the parsed markup (the attributes
|
|
210
|
+
// stay the source of truth for what is rendered).
|
|
211
|
+
this._model.heading = this.heading;
|
|
212
|
+
this._model.body = this.body;
|
|
213
|
+
|
|
205
214
|
this._render();
|
|
206
215
|
}
|
|
207
216
|
|
|
@@ -210,8 +219,12 @@ export class AdwAlertDialog extends HTMLElement {
|
|
|
210
219
|
if (oldValue === newValue) return;
|
|
211
220
|
this._render();
|
|
212
221
|
if (name === 'heading') {
|
|
222
|
+
// Keep the headless model a complete description of the dialog; the
|
|
223
|
+
// attribute stays the source of truth for the rendered heading.
|
|
224
|
+
this._model.heading = this.heading;
|
|
213
225
|
this.dispatchEvent(new CustomEvent('notify::heading', { bubbles: true, detail: { heading: this.heading } }));
|
|
214
226
|
} else if (name === 'body') {
|
|
227
|
+
this._model.body = this.body;
|
|
215
228
|
this.dispatchEvent(new CustomEvent('notify::body', { bubbles: true, detail: { body: this.body } }));
|
|
216
229
|
} else if (name === 'open') {
|
|
217
230
|
this.dispatchEvent(new CustomEvent('notify::open', { bubbles: true, detail: { open: this.open } }));
|
|
@@ -226,7 +239,9 @@ export class AdwAlertDialog extends HTMLElement {
|
|
|
226
239
|
|
|
227
240
|
/** Adds a response button with the given ID and label. */
|
|
228
241
|
addResponse(id: string, label: string): void {
|
|
229
|
-
|
|
242
|
+
// The core registry UPDATES a duplicate id in place; AdwAlertDialog
|
|
243
|
+
// refuses it, so the guard stays here.
|
|
244
|
+
if (this._model.hasResponse(id)) {
|
|
230
245
|
// Mirror AdwAlertDialog's g_critical without throwing.
|
|
231
246
|
console.warn(
|
|
232
247
|
`[adw-alert-dialog] Trying to add a response with id '${id}', but such a response already exists`,
|
|
@@ -238,12 +253,10 @@ export class AdwAlertDialog extends HTMLElement {
|
|
|
238
253
|
button.type = 'button';
|
|
239
254
|
button.className = 'adw-button adw-alert-dialog-response';
|
|
240
255
|
button.textContent = label;
|
|
256
|
+
button.addEventListener('click', () => this._activateResponse(id));
|
|
241
257
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
this._responses.push(info);
|
|
246
|
-
this._byId.set(id, info);
|
|
258
|
+
this._model.addResponse(id, label);
|
|
259
|
+
this._buttons.set(id, button);
|
|
247
260
|
if (this._responseAreaEl) this._responseAreaEl.appendChild(button);
|
|
248
261
|
this._render();
|
|
249
262
|
}
|
|
@@ -257,80 +270,94 @@ export class AdwAlertDialog extends HTMLElement {
|
|
|
257
270
|
|
|
258
271
|
/** Removes a response. */
|
|
259
272
|
removeResponse(id: string): void {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
273
|
+
if (!this._model.hasResponse(id)) return;
|
|
274
|
+
this._buttons.get(id)?.remove();
|
|
275
|
+
this._buttons.delete(id);
|
|
276
|
+
// `AdwAlertResponses` exposes no removal (a gap against
|
|
277
|
+
// adw_alert_dialog_remove_response), so the registry is rebuilt from the
|
|
278
|
+
// survivors — core stays the single owner of the response state.
|
|
279
|
+
const survivors = this._model.responses.filter((response) => response.id !== id);
|
|
280
|
+
const rebuilt = new AdwAlertResponses(this._model.heading, this._model.body);
|
|
281
|
+
for (const response of survivors) {
|
|
282
|
+
rebuilt.addResponse(response.id, response.label, {
|
|
283
|
+
appearance: response.appearance,
|
|
284
|
+
enabled: response.enabled,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
rebuilt.closeResponse = this._model.closeResponse;
|
|
288
|
+
rebuilt.defaultResponse = this._model.defaultResponse === id ? null : this._model.defaultResponse;
|
|
289
|
+
this._model = rebuilt;
|
|
266
290
|
this._render();
|
|
267
291
|
}
|
|
268
292
|
|
|
269
293
|
/** Whether a response with the given ID exists. */
|
|
270
294
|
hasResponse(id: string): boolean {
|
|
271
|
-
return this.
|
|
295
|
+
return this._model.hasResponse(id);
|
|
272
296
|
}
|
|
273
297
|
|
|
274
298
|
/** Gets a response's label. */
|
|
275
299
|
getResponseLabel(id: string): string | null {
|
|
276
|
-
return this.
|
|
300
|
+
return this._model.responses.find((response) => response.id === id)?.label ?? null;
|
|
277
301
|
}
|
|
278
302
|
|
|
279
303
|
/** Sets a response's label. */
|
|
280
304
|
setResponseLabel(id: string, label: string): void {
|
|
281
|
-
const
|
|
282
|
-
if (!
|
|
283
|
-
|
|
284
|
-
|
|
305
|
+
const button = this._buttons.get(id);
|
|
306
|
+
if (!button) return;
|
|
307
|
+
// Re-adding a known id updates it in place (the core registry contract).
|
|
308
|
+
this._model.addResponse(id, label);
|
|
309
|
+
button.textContent = label;
|
|
285
310
|
}
|
|
286
311
|
|
|
287
312
|
/** Gets a response's appearance. */
|
|
288
313
|
getResponseAppearance(id: string): AdwResponseAppearance | null {
|
|
289
|
-
return this.
|
|
314
|
+
return this._model.hasResponse(id) ? this._model.getResponseAppearance(id) : null;
|
|
290
315
|
}
|
|
291
316
|
|
|
292
317
|
/** Sets a response's appearance (default / suggested / destructive). */
|
|
293
318
|
setResponseAppearance(id: string, appearance: AdwResponseAppearance): void {
|
|
294
|
-
const
|
|
295
|
-
if (!
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
319
|
+
const button = this._buttons.get(id);
|
|
320
|
+
if (!button) return;
|
|
321
|
+
this._model.setResponseAppearance(id, appearance);
|
|
322
|
+
button.classList.toggle('suggested-action', appearance === 'suggested');
|
|
323
|
+
button.classList.toggle('destructive-action', appearance === 'destructive');
|
|
299
324
|
}
|
|
300
325
|
|
|
301
326
|
/** Gets whether a response is enabled. */
|
|
302
327
|
getResponseEnabled(id: string): boolean {
|
|
303
|
-
return this.
|
|
328
|
+
return this._model.hasResponse(id) ? this._model.getResponseEnabled(id) : false;
|
|
304
329
|
}
|
|
305
330
|
|
|
306
331
|
/** Sets whether a response is enabled (disabled buttons can't be activated). */
|
|
307
332
|
setResponseEnabled(id: string, enabled: boolean): void {
|
|
308
|
-
const
|
|
309
|
-
if (!
|
|
310
|
-
|
|
311
|
-
|
|
333
|
+
const button = this._buttons.get(id);
|
|
334
|
+
if (!button) return;
|
|
335
|
+
this._model.setResponseEnabled(id, enabled);
|
|
336
|
+
button.disabled = !enabled;
|
|
312
337
|
}
|
|
313
338
|
|
|
314
339
|
/** Sets the default response — its button is highlighted as the suggested one. */
|
|
315
340
|
setDefaultResponse(id: string | null): void {
|
|
316
|
-
this.
|
|
341
|
+
this._model.defaultResponse = id;
|
|
317
342
|
this._render();
|
|
318
343
|
}
|
|
319
344
|
|
|
320
345
|
/** Sets the close response — emitted on dismissal (Escape / scrim click). */
|
|
321
346
|
setCloseResponse(id: string): void {
|
|
322
|
-
this.
|
|
347
|
+
this._model.closeResponse = id;
|
|
323
348
|
}
|
|
324
349
|
|
|
325
350
|
// ── internals ──────────────────────────────────────────────────────────
|
|
326
351
|
|
|
327
352
|
/** A response button was activated: close, then emit `response`. */
|
|
328
|
-
private _activateResponse(
|
|
329
|
-
if (!
|
|
353
|
+
private _activateResponse(id: string): void {
|
|
354
|
+
if (!this._model.getResponseEnabled(id)) return;
|
|
330
355
|
this._blockCloseResponse = true;
|
|
331
356
|
this.open = false;
|
|
332
357
|
this._blockCloseResponse = false;
|
|
333
|
-
|
|
358
|
+
// resolveById validates against the registry (the core resolve-to-chosen-id
|
|
359
|
+
// contract) and falls back to the close response.
|
|
360
|
+
this._emitResponse(this._model.resolveById(id));
|
|
334
361
|
}
|
|
335
362
|
|
|
336
363
|
/** Dismissal (Escape / scrim) — close, then emit the close-response. */
|
|
@@ -338,7 +365,8 @@ export class AdwAlertDialog extends HTMLElement {
|
|
|
338
365
|
if (!this.open) return;
|
|
339
366
|
this.open = false;
|
|
340
367
|
if (this._blockCloseResponse) return;
|
|
341
|
-
|
|
368
|
+
// No choice was made — the core model resolves that to the close response.
|
|
369
|
+
this._emitResponse(this._model.resolveById(null));
|
|
342
370
|
}
|
|
343
371
|
|
|
344
372
|
private _emitResponse(response: string): void {
|
|
@@ -347,9 +375,11 @@ export class AdwAlertDialog extends HTMLElement {
|
|
|
347
375
|
|
|
348
376
|
/** Move focus to the default response button (or the first enabled one). */
|
|
349
377
|
private _focusInitial(): void {
|
|
378
|
+
const defaultId = this._model.defaultResponse;
|
|
379
|
+
const firstEnabledId = this._model.responses.find((response) => response.enabled)?.id;
|
|
350
380
|
const target =
|
|
351
|
-
(
|
|
352
|
-
this.
|
|
381
|
+
(defaultId ? this._buttons.get(defaultId) : undefined) ??
|
|
382
|
+
(firstEnabledId ? this._buttons.get(firstEnabledId) : undefined);
|
|
353
383
|
target?.focus();
|
|
354
384
|
}
|
|
355
385
|
|
|
@@ -369,14 +399,15 @@ export class AdwAlertDialog extends HTMLElement {
|
|
|
369
399
|
// Layout: stack vertically by default; lay out horizontally only when
|
|
370
400
|
// prefer-wide-layout is set and there are at most two responses (the
|
|
371
401
|
// AdwAlertDialog heuristic for keeping wide buttons readable).
|
|
372
|
-
const horizontal = this.preferWideLayout && this.
|
|
402
|
+
const horizontal = this.preferWideLayout && this._model.responses.length <= 2;
|
|
373
403
|
this._responseAreaEl.classList.toggle('horizontal', horizontal);
|
|
374
404
|
|
|
375
405
|
// Reflect the default response on its button (suggested highlight) unless
|
|
376
406
|
// the response already carries an explicit appearance.
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
407
|
+
const defaultId = this._model.defaultResponse;
|
|
408
|
+
for (const response of this._model.responses) {
|
|
409
|
+
const isDefault = response.id === defaultId && response.appearance === 'default';
|
|
410
|
+
this._buttons.get(response.id)?.classList.toggle('default-response', isDefault);
|
|
380
411
|
}
|
|
381
412
|
}
|
|
382
413
|
}
|
|
@@ -2,17 +2,30 @@
|
|
|
2
2
|
// Attributes: title, subtitle, items (JSON string[]), selected (index number)
|
|
3
3
|
// Events: notify::selected (CustomEvent, mirrors GJS GObject signal naming)
|
|
4
4
|
// The native <select> is stretched invisibly over the row so clicking anywhere opens it.
|
|
5
|
+
//
|
|
6
|
+
// The SELECTION state machine (the options list, the two-way index↔value mapping,
|
|
7
|
+
// the empty/out-of-range guards and the programmatic-vs-interactive notify split)
|
|
8
|
+
// is HEADLESS and lives in `@gjsify/adwaita-core` (ADR 0004) as {@link ComboState};
|
|
9
|
+
// this element composes it and keeps only the DOM render half — the <select>, the
|
|
10
|
+
// inline value label and the `notify::selected` event.
|
|
11
|
+
// `@gjsify/adwaita-nativescript` composes the same state machine, so both ports
|
|
12
|
+
// share one behaviour.
|
|
13
|
+
//
|
|
5
14
|
// Adapted from Adwaita Web UI Framework (https://github.com/mclellac/adwaita-web).
|
|
6
15
|
// Copyright (c) 2025 csm. MIT License.
|
|
7
16
|
// Modifications: Reimplemented as Web Component for @gjsify/adwaita-web;
|
|
8
|
-
// title/subtitle text column added to match Adw.ComboRow
|
|
17
|
+
// title/subtitle text column added to match Adw.ComboRow; the selection state
|
|
18
|
+
// machine composed from @gjsify/adwaita-core.
|
|
19
|
+
|
|
20
|
+
import { ComboState } from '@gjsify/adwaita-core';
|
|
9
21
|
|
|
10
22
|
export class AdwComboRow extends HTMLElement {
|
|
11
23
|
private _select!: HTMLSelectElement;
|
|
12
24
|
private _valueEl!: HTMLSpanElement;
|
|
13
25
|
private _titleEl!: HTMLSpanElement;
|
|
14
26
|
private _subtitleEl!: HTMLSpanElement;
|
|
15
|
-
|
|
27
|
+
/** The headless options list + selectedIndex↔selectedValue state machine (ADR 0004). */
|
|
28
|
+
private readonly _state = new ComboState();
|
|
16
29
|
private _initialized = false;
|
|
17
30
|
|
|
18
31
|
static get observedAttributes() {
|
|
@@ -20,7 +33,7 @@ export class AdwComboRow extends HTMLElement {
|
|
|
20
33
|
}
|
|
21
34
|
|
|
22
35
|
get selected(): number {
|
|
23
|
-
return this.
|
|
36
|
+
return this._initialized ? this._state.selectedIndex : parseInt(this.getAttribute('selected') || '0', 10);
|
|
24
37
|
}
|
|
25
38
|
|
|
26
39
|
set selected(value: number) {
|
|
@@ -31,8 +44,12 @@ export class AdwComboRow extends HTMLElement {
|
|
|
31
44
|
if (this._initialized) return;
|
|
32
45
|
this._initialized = true;
|
|
33
46
|
|
|
34
|
-
|
|
35
|
-
|
|
47
|
+
const items: string[] = JSON.parse(this.getAttribute('items') || '[]');
|
|
48
|
+
// Seed the headless state BEFORE subscribing, so building the initial DOM
|
|
49
|
+
// below is not driven by a change notification.
|
|
50
|
+
this._state.setOptions(items.map((item) => ({ label: item, value: item })));
|
|
51
|
+
this._state.setSelectedIndex(parseInt(this.getAttribute('selected') || '0', 10));
|
|
52
|
+
const selectedIdx = this._state.selectedIndex;
|
|
36
53
|
|
|
37
54
|
const text = document.createElement('div');
|
|
38
55
|
text.className = 'adw-row-text';
|
|
@@ -45,11 +62,11 @@ export class AdwComboRow extends HTMLElement {
|
|
|
45
62
|
// Visible selected value display
|
|
46
63
|
this._valueEl = document.createElement('span');
|
|
47
64
|
this._valueEl.className = 'adw-row-value';
|
|
48
|
-
this._valueEl.textContent = this.
|
|
65
|
+
this._valueEl.textContent = this._state.selectedLabel;
|
|
49
66
|
|
|
50
67
|
// Hidden select overlaying the entire row
|
|
51
68
|
const select = document.createElement('select');
|
|
52
|
-
|
|
69
|
+
items.forEach((item, i) => {
|
|
53
70
|
const option = document.createElement('option');
|
|
54
71
|
option.value = String(i);
|
|
55
72
|
option.textContent = item;
|
|
@@ -60,27 +77,37 @@ export class AdwComboRow extends HTMLElement {
|
|
|
60
77
|
this.replaceChildren(text, this._valueEl, select);
|
|
61
78
|
|
|
62
79
|
this._select = select;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
|
|
81
|
+
// The core state drives the inline label + the <select> on every change,
|
|
82
|
+
// and the notify::selected event only on an interactive pick.
|
|
83
|
+
this._state.subscribe((change) => {
|
|
84
|
+
this._valueEl.textContent = change.label;
|
|
85
|
+
if (this._select.selectedIndex !== change.selected) this._select.selectedIndex = change.selected;
|
|
86
|
+
if (!change.interactive) return;
|
|
87
|
+
this.setAttribute('selected', String(change.selected));
|
|
67
88
|
this.dispatchEvent(
|
|
68
89
|
new CustomEvent('notify::selected', {
|
|
69
90
|
bubbles: true,
|
|
70
|
-
detail: { selected:
|
|
91
|
+
detail: { selected: change.selected },
|
|
71
92
|
}),
|
|
72
93
|
);
|
|
73
94
|
});
|
|
74
95
|
|
|
96
|
+
this._select.addEventListener('change', () => {
|
|
97
|
+
// A user pick — `select()` guards the no-op case and notifies
|
|
98
|
+
// `interactive`, so the subscriber emits notify::selected.
|
|
99
|
+
this._state.select(this._select.selectedIndex);
|
|
100
|
+
});
|
|
101
|
+
|
|
75
102
|
this._renderText();
|
|
76
103
|
}
|
|
77
104
|
|
|
78
105
|
attributeChangedCallback(name: string, _old: string | null, value: string | null) {
|
|
79
106
|
if (!this._initialized) return;
|
|
80
107
|
if (name === 'selected') {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
this.
|
|
108
|
+
// A programmatic set — the core state notifies `interactive: false`,
|
|
109
|
+
// so the subscriber re-syncs the display without emitting an event.
|
|
110
|
+
this._state.setSelectedIndex(parseInt(value || '0', 10));
|
|
84
111
|
} else {
|
|
85
112
|
this._renderText();
|
|
86
113
|
}
|
|
@@ -10,10 +10,21 @@
|
|
|
10
10
|
// Events: notify::expanded (CustomEvent), notify::enable-expansion (CustomEvent)
|
|
11
11
|
// — mirror Adw.ExpanderRow's GObject signal names so the same code path drives
|
|
12
12
|
// both the web and GTK renderers.
|
|
13
|
+
//
|
|
14
|
+
// The DISCLOSURE state (the expanded flag and its idempotent, notify-on-change
|
|
15
|
+
// transitions) is HEADLESS and lives in `@gjsify/adwaita-core` (ADR 0004) as
|
|
16
|
+
// {@link ExpanderState}; this element composes it and keeps only the DOM render
|
|
17
|
+
// half — the header/content markup, the `expanded` attribute reflection and the
|
|
18
|
+
// `notify::expanded` event. `@gjsify/adwaita-nativescript` composes the same
|
|
19
|
+
// state machine, so both ports share one behaviour.
|
|
20
|
+
//
|
|
13
21
|
// Reference: refs/adwaita-web/adwaita-web/scss/_expander_row.scss
|
|
14
22
|
// Reference: refs/libadwaita/src/stylesheet/widgets/_expander-row.scss
|
|
15
23
|
// Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
|
|
16
|
-
// Modifications: Implemented as a Web Component for @gjsify/adwaita-web
|
|
24
|
+
// Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
|
|
25
|
+
// disclosure state machine composed from @gjsify/adwaita-core.
|
|
26
|
+
|
|
27
|
+
import { ExpanderState } from '@gjsify/adwaita-core';
|
|
17
28
|
|
|
18
29
|
export class AdwExpanderRow extends HTMLElement {
|
|
19
30
|
private _headerEl!: HTMLDivElement;
|
|
@@ -25,19 +36,33 @@ export class AdwExpanderRow extends HTMLElement {
|
|
|
25
36
|
private _contentEl!: HTMLDivElement;
|
|
26
37
|
private _prefixEl!: HTMLDivElement;
|
|
27
38
|
private _suffixEl!: HTMLDivElement;
|
|
39
|
+
/** The headless expanded/collapsed disclosure state machine (ADR 0004). */
|
|
40
|
+
private readonly _state = new ExpanderState();
|
|
28
41
|
private _initialized = false;
|
|
29
42
|
|
|
43
|
+
constructor() {
|
|
44
|
+
super();
|
|
45
|
+
// Subscribed in the constructor (NOT connectedCallback) so a disclosure
|
|
46
|
+
// set before the element is connected still reflects to the attribute.
|
|
47
|
+
// The custom-element constructor rules allow this — nothing touches the
|
|
48
|
+
// DOM until the state actually changes.
|
|
49
|
+
this._state.subscribe((expanded) => {
|
|
50
|
+
if (expanded) this.setAttribute('expanded', '');
|
|
51
|
+
else this.removeAttribute('expanded');
|
|
52
|
+
if (this._initialized) this._render();
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
30
56
|
static get observedAttributes() {
|
|
31
57
|
return ['title', 'subtitle', 'expanded', 'enable-expansion', 'show-enable-switch'];
|
|
32
58
|
}
|
|
33
59
|
|
|
34
60
|
get expanded(): boolean {
|
|
35
|
-
return this.
|
|
61
|
+
return this._state.expanded;
|
|
36
62
|
}
|
|
37
63
|
|
|
38
64
|
set expanded(value: boolean) {
|
|
39
|
-
|
|
40
|
-
else this.removeAttribute('expanded');
|
|
65
|
+
this._state.setExpanded(value);
|
|
41
66
|
}
|
|
42
67
|
|
|
43
68
|
get enableExpansion(): boolean {
|
|
@@ -122,10 +147,14 @@ export class AdwExpanderRow extends HTMLElement {
|
|
|
122
147
|
this._headerEl.addEventListener('click', (event) => {
|
|
123
148
|
// Clicks on the enable switch toggle expansion-enable, not disclosure.
|
|
124
149
|
if (this._enableLabel.contains(event.target as Node)) return;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
)
|
|
150
|
+
// The core state machine flips + reflects + re-renders; the event is
|
|
151
|
+
// emitted here so only a USER disclosure notifies (a programmatic
|
|
152
|
+
// `expanded = …` stays silent, as it always has on this port).
|
|
153
|
+
if (this._state.toggle()) {
|
|
154
|
+
this.dispatchEvent(
|
|
155
|
+
new CustomEvent('notify::expanded', { bubbles: true, detail: { expanded: this.expanded } }),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
129
158
|
});
|
|
130
159
|
|
|
131
160
|
this._enableSwitch.addEventListener('change', () => {
|
|
@@ -138,11 +167,18 @@ export class AdwExpanderRow extends HTMLElement {
|
|
|
138
167
|
);
|
|
139
168
|
});
|
|
140
169
|
|
|
170
|
+
// Seed the disclosure from the parsed markup — attributeChangedCallback
|
|
171
|
+
// is guarded until the element is initialized, so a declarative
|
|
172
|
+
// `expanded` attribute has to be adopted here.
|
|
173
|
+
this._state.setExpanded(this.hasAttribute('expanded'));
|
|
141
174
|
this._render();
|
|
142
175
|
}
|
|
143
176
|
|
|
144
|
-
attributeChangedCallback() {
|
|
145
|
-
if (this._initialized)
|
|
177
|
+
attributeChangedCallback(name: string) {
|
|
178
|
+
if (!this._initialized) return;
|
|
179
|
+
// The `expanded` attribute is the declarative face of the core state.
|
|
180
|
+
if (name === 'expanded') this._state.setExpanded(this.hasAttribute('expanded'));
|
|
181
|
+
this._render();
|
|
146
182
|
}
|
|
147
183
|
|
|
148
184
|
private _render() {
|