@gjsify/adwaita-web 0.21.0 → 0.23.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.
@@ -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 = 'default' | 'suggested' | 'destructive';
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
- private _responses: ResponseInfo[] = [];
72
- private _byId = new Map<string, ResponseInfo>();
73
- private _defaultResponse: string | null = null;
74
- private _closeResponse = 'close';
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._defaultResponse;
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._closeResponse;
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
- if (this._byId.has(id)) {
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
- const info: ResponseInfo = { id, label, appearance: 'default', enabled: true, button };
243
- button.addEventListener('click', () => this._activateResponse(info));
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
- const info = this._byId.get(id);
261
- if (!info) return;
262
- info.button.remove();
263
- this._responses = this._responses.filter((r) => r !== info);
264
- this._byId.delete(id);
265
- if (this._defaultResponse === id) this._defaultResponse = null;
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._byId.has(id);
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._byId.get(id)?.label ?? null;
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 info = this._byId.get(id);
282
- if (!info) return;
283
- info.label = label;
284
- info.button.textContent = label;
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._byId.get(id)?.appearance ?? null;
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 info = this._byId.get(id);
295
- if (!info) return;
296
- info.appearance = appearance;
297
- info.button.classList.toggle('suggested-action', appearance === 'suggested');
298
- info.button.classList.toggle('destructive-action', appearance === 'destructive');
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._byId.get(id)?.enabled ?? false;
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 info = this._byId.get(id);
309
- if (!info) return;
310
- info.enabled = enabled;
311
- info.button.disabled = !enabled;
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._defaultResponse = id;
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._closeResponse = id;
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(info: ResponseInfo): void {
329
- if (!info.enabled) return;
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
- this._emitResponse(info.id);
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
- this._emitResponse(this._closeResponse);
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
- (this._defaultResponse ? this._byId.get(this._defaultResponse)?.button : undefined) ??
352
- this._responses.find((r) => r.enabled)?.button;
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._responses.length <= 2;
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
- for (const info of this._responses) {
378
- const isDefault = info.id === this._defaultResponse && info.appearance === 'default';
379
- info.button.classList.toggle('default-response', isDefault);
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
- private _items: string[] = [];
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._select ? this._select.selectedIndex : parseInt(this.getAttribute('selected') || '0', 10);
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
- this._items = JSON.parse(this.getAttribute('items') || '[]');
35
- const selectedIdx = parseInt(this.getAttribute('selected') || '0', 10);
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._items[selectedIdx] ?? '';
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
- this._items.forEach((item, i) => {
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
- this._select.addEventListener('change', () => {
64
- const idx = this._select.selectedIndex;
65
- this._valueEl.textContent = this._items[idx] ?? '';
66
- this.setAttribute('selected', String(idx));
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: idx },
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
- const idx = parseInt(value || '0', 10);
82
- this._select.selectedIndex = idx;
83
- this._valueEl.textContent = this._items[idx] ?? '';
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.hasAttribute('expanded');
61
+ return this._state.expanded;
36
62
  }
37
63
 
38
64
  set expanded(value: boolean) {
39
- if (value) this.setAttribute('expanded', '');
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
- this.expanded = !this.expanded;
126
- this.dispatchEvent(
127
- new CustomEvent('notify::expanded', { bubbles: true, detail: { expanded: this.expanded } }),
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) this._render();
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() {