@gjsify/adwaita-web 0.64.12 → 0.64.13

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.
@@ -9,7 +9,13 @@ export declare class WidgetElement extends HTMLElement {
9
9
  get_visible(): boolean;
10
10
  /** `gtk_widget_set_visible`. */
11
11
  set_visible(visible: boolean): void;
12
- /** `gtk_widget_get_first_child`: the first child element, `null` for a leaf. */
12
+ /**
13
+ * `gtk_widget_get_first_child`: the first child WIDGET, `null` for a leaf.
14
+ *
15
+ * The five walkers skip the elements a port builds for its own insides — `isWidgetNode`
16
+ * in `@gjsify/adwaita-core` carries the rule and the incident behind it, and the
17
+ * registered-class copy in `widgetTreeMembers()` answers from the same function.
18
+ */
13
19
  get_first_child(): Element | null;
14
20
  /** `gtk_widget_get_last_child`. */
15
21
  get_last_child(): Element | null;
@@ -17,7 +23,7 @@ export declare class WidgetElement extends HTMLElement {
17
23
  get_next_sibling(): Element | null;
18
24
  /** `gtk_widget_get_prev_sibling`. */
19
25
  get_prev_sibling(): Element | null;
20
- /** `gtk_widget_get_parent`. */
26
+ /** `gtk_widget_get_parent`: the nearest ancestor that is a widget, not a port's own wrapper. */
21
27
  get_parent(): Element | null;
22
28
  /** `gtk_widget_get_width`: the layout box in CSS px; 0 until the widget is rendered. */
23
29
  get_width(): number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/adwaita-web",
3
- "version": "0.64.12",
3
+ "version": "0.64.13",
4
4
  "description": "Adwaita/Libadwaita web components for browser targets",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -58,14 +58,14 @@
58
58
  "@codemirror/language": "^6.12.4",
59
59
  "@codemirror/state": "^6.7.0",
60
60
  "@codemirror/view": "^6.43.4",
61
- "@gjsify/adwaita-core": "^0.64.12",
62
- "@gjsify/adwaita-fonts": "^0.64.12",
63
- "@gjsify/adwaita-icons": "^0.64.12",
64
- "@gjsify/gtksource-core": "^0.64.12",
61
+ "@gjsify/adwaita-core": "^0.64.13",
62
+ "@gjsify/adwaita-fonts": "^0.64.13",
63
+ "@gjsify/adwaita-icons": "^0.64.13",
64
+ "@gjsify/gtksource-core": "^0.64.13",
65
65
  "@lezer/highlight": "^1.2.3"
66
66
  },
67
67
  "peerDependencies": {
68
- "@gjsify/app-settings": "^0.64.12"
68
+ "@gjsify/app-settings": "^0.64.13"
69
69
  },
70
70
  "peerDependenciesMeta": {
71
71
  "@gjsify/app-settings": {
@@ -73,11 +73,11 @@
73
73
  }
74
74
  },
75
75
  "devDependencies": {
76
- "@gjsify/app-settings": "^0.64.12",
77
- "@gjsify/blueprint": "^0.64.12",
78
- "@gjsify/cli": "^0.64.12",
79
- "@gjsify/unit": "^0.64.12",
80
- "@gjsify/vite-plugin-blueprint": "^0.64.12",
76
+ "@gjsify/app-settings": "^0.64.13",
77
+ "@gjsify/blueprint": "^0.64.13",
78
+ "@gjsify/cli": "^0.64.13",
79
+ "@gjsify/unit": "^0.64.13",
80
+ "@gjsify/vite-plugin-blueprint": "^0.64.13",
81
81
  "sass": "^1.101.0",
82
82
  "typescript": "^6.0.3"
83
83
  },
package/scss/_labels.scss CHANGED
@@ -109,6 +109,25 @@ gtk-label {
109
109
  max-width: 100%;
110
110
  }
111
111
 
112
+ // `gtk-label.ts`'s run-render path, SINGLE-LINE: the markup's runs move into this span
113
+ // because this element is a flex container (the two spacers above), and a node directly
114
+ // under one is a FLEX ITEM, not text — `vertical-align` does not apply to a flex item,
115
+ // so `<sup>`/`<sub>` stop being raised, and `align-items` centres each run on its own
116
+ // instead of the line sharing one baseline. Plain text never needed a box: a bare text
117
+ // node is ONE anonymous item, where a run list is one item per run.
118
+ //
119
+ // NO `max-width` HERE, unlike `.adw-label-text`: this box only ever holds ONE line (the
120
+ // host's `white-space: pre`, and this path is the not-wrapping, not-ellipsizing case),
121
+ // so there is no shrink-to-fit collapse to measure around — a single-line label as wide
122
+ // as its text, overflowing a narrower container, is what GtkLabel does and what
123
+ // `gtk-label.spec.ts` already pins for the plain-text path. The box's own size is its
124
+ // text's: `flex-basis: auto` over `width: auto` is the item's max-content width, which
125
+ // leaves the two spacers the free space `xalign` splits.
126
+ .adw-label-runs {
127
+ // Blockified by spec as a flex item; spelled out as `.adw-label-text` spells it out.
128
+ display: block;
129
+ }
130
+
112
131
  // `wrap-mode` — Pango's break-opportunity choice — is a real `word-break`/
113
132
  // `overflow-wrap` mapping, not a declared gap: CSS's `break-all` is Pango's CHAR (break
114
133
  // anywhere), and `overflow-wrap: anywhere` is Pango's WORD_CHAR (word boundaries, falling
package/scss/_link.scss CHANGED
@@ -1,12 +1,26 @@
1
1
  // _link.scss — `link` and `button.link`, libadwaita's link treatment.
2
2
  //
3
- // ONLY `button.link`, not a bare `.link`. Upstream's rule names the `link` NODE and
4
- // `button.link` (`_links.scss:1-2`); the `link` node is the inner node of a `Gtk.Label`'s
5
- // `<a>` markup, which no element here renders. The `.link` style CLASS on anything but a
6
- // button is unstyled upstream: `AdwLinkRow` adds it to its row (adw-link-row.c:377), and
7
- // no rule matches `row.link`, so its title stays in the row's own colour. A bare `.link`
8
- // here painted the About dialog's link rows blue and underlined. `<gtk-link-button>`
9
- // renders an inner `<button class="adw-button link">`, which the selector below reads.
3
+ // TWO NODES, NEVER A BARE `.link`. Upstream's rule names the `link` NODE and
4
+ // `button.link` (`_links.scss:1-2`). `button.link` is `<gtk-link-button>`'s inner
5
+ // `<button class="adw-button link">`, which the first selector below reads. The `link`
6
+ // node is the inner node of a `Gtk.Label`'s `<a>` markup, and `<gtk-label>` now DRAWS
7
+ // that — an `<a href>` per link in the markup (`label-markup.ts`) — so the second
8
+ // selector is upstream's `link` rule, ported onto the element that finally exists.
9
+ //
10
+ // The `.link` style CLASS on anything but those two is unstyled upstream: `AdwLinkRow`
11
+ // adds it to its row (adw-link-row.c:377), and no rule matches `row.link`, so its title
12
+ // stays in the row's own colour. A bare `.link` here painted the About dialog's link rows
13
+ // blue and underlined. It would now also hit `<gtk-label>` ITSELF, which gains the class
14
+ // whenever its markup has a link (gtklabel.c:3896) — the label is not a link, its anchors
15
+ // are. So both selectors name a node, and neither matches a class alone.
16
+ //
17
+ // THE LABEL'S ANCHORS ARE NOT STYLED THROUGH `:visited`, the CSS pseudo-class, even
18
+ // though they are real `<a href>` elements that would match it. A browser's `:visited` is
19
+ // the user's BROWSING HISTORY: a link followed in another tab, or last year, would come
20
+ // up visited in a label that has never been clicked, which `Gtk.Label` never does. Its
21
+ // `visited` is per-label state carried by URI across a re-parse
22
+ // (`labelLinksInheritVisited`), painted as `.visited` by `_paintVisited` — the same class
23
+ // `<gtk-link-button>` writes for the same reason one paragraph down.
10
24
  //
11
25
  // THE TWO COLOURS ARE THE PALETTE'S, not new tokens: `$link_color: var(--accent-color)`
12
26
  // and `$link_visited_color: color-mix(in srgb, var(--accent-color) 80%, var(--view-fg-color))`
@@ -36,6 +50,7 @@
36
50
  $link-color: var(--accent-color);
37
51
  $link-visited-color: color-mix(in srgb, var(--accent-color) 80%, var(--view-fg-color));
38
52
  $osd-link-color: color-mix(in srgb, var(--accent-bg-color) 50%, white);
53
+ $osd-link-visited-color: color-mix(in srgb, var(--accent-bg-color) 75%, white);
39
54
 
40
55
  .adw-button.link {
41
56
  color: $link-color;
@@ -82,4 +97,55 @@ $osd-link-color: color-mix(in srgb, var(--accent-bg-color) 50%, white);
82
97
  &.checked {
83
98
  background-color: transparent;
84
99
  }
100
+ }
101
+
102
+ // Upstream's bare `link` node — a `Gtk.Label`'s `<a href>` markup, which `<gtk-label>`
103
+ // draws as a real anchor per link (`label-markup.ts`, `buildAnchor`).
104
+ //
105
+ // THE SELECTOR IS THE ATTRIBUTE, not the class. `data-gtk-link` is written by the renderer
106
+ // and by nothing else, so it names the node the way upstream's `link` names a GTK node;
107
+ // the `.link` class on the same anchor is GTK's own style class, which a consumer may also
108
+ // put on anything (and `<gtk-label>` ITSELF carries it whenever its markup has a link —
109
+ // see the header). Scoped under `gtk-label` so an `<a>` a consumer put in a label's slot
110
+ // for its own reasons is untouched.
111
+ gtk-label a[data-gtk-link] {
112
+ color: $link-color;
113
+ text-decoration: underline;
114
+ font-weight: inherit;
115
+
116
+ &:hover {
117
+ color: hsl(from $link-color h calc(s * 1.1) calc(l * 1.1));
118
+ }
119
+
120
+ &:active {
121
+ color: $link-color;
122
+ }
123
+
124
+ // `.visited` and NOT `:visited` — the pseudo-class is the user's browsing history, where
125
+ // `Gtk.Label`'s `visited` is per-label state carried by URI (`_paintVisited`, and the
126
+ // paragraph in the header).
127
+ &.visited {
128
+ color: $link-visited-color;
129
+ }
130
+
131
+ .osd & {
132
+ color: $osd-link-color;
133
+
134
+ &.visited {
135
+ color: $osd-link-visited-color;
136
+ }
137
+
138
+ &:active {
139
+ color: $osd-link-color;
140
+ }
141
+ }
142
+
143
+ // `link { @include focus-ring() }` (_links.scss:33-35) — the mixin is a 2px outline at
144
+ // offset -2px (`_drawing.scss`), spelled out as `_expander.scss` and `_checks.scss`
145
+ // spell it out. `:focus-visible` rather than `:focus`: GTK draws the ring on keyboard
146
+ // focus, and a click on a link must not leave one behind.
147
+ &:focus-visible {
148
+ outline: 2px solid var(--accent-color);
149
+ outline-offset: -2px;
150
+ }
85
151
  }
@@ -19,6 +19,7 @@ import { describe, expect, it } from '@gjsify/unit';
19
19
 
20
20
  import {
21
21
  ACTION_ROW_ACTIVATION_VECTORS,
22
+ ACTION_ROW_SUBTITLE_VECTORS,
22
23
  BUTTON_ROW_ACTIVATABLE_VECTORS,
23
24
  BUTTON_ROW_ICON_VECTORS,
24
25
  LABEL_VISIBILITY_VECTORS,
@@ -98,6 +99,24 @@ export const AdwActionRowsTest = async () => {
98
99
  });
99
100
  });
100
101
 
102
+ await describe('<adw-action-row> subtitle (libadwaita conformance vectors)', async () => {
103
+ // The element is attribute-driven and publishes no `notify::subtitle`, so only the
104
+ // value the vectors hold afterwards is observable; `null` is an absent attribute.
105
+ for (const vector of ACTION_ROW_SUBTITLE_VECTORS) {
106
+ await it(`${vector.name} — ${vector.rule}`, () => {
107
+ const { el: row, host } = mount<AdwActionRow>('adw-action-row');
108
+ for (const step of vector.steps) {
109
+ if (step.write === null) row.removeAttribute('subtitle');
110
+ else row.setAttribute('subtitle', step.write);
111
+ const label = row.querySelector('.adw-row-subtitle') as HTMLElement;
112
+ expect(label.textContent).toBe(step.subtitle);
113
+ expect(label.hidden).toBe(step.subtitle === '');
114
+ }
115
+ host.remove();
116
+ });
117
+ }
118
+ });
119
+
101
120
  await describe('<adw-action-row> activatable-widget (libadwaita conformance vectors)', async () => {
102
121
  for (const vector of ACTION_ROW_ACTIVATION_VECTORS) {
103
122
  await it(`${vector.name} — ${vector.rule}`, async () => {
@@ -61,6 +61,26 @@ export const AdwBinTest = async () => {
61
61
  other.remove();
62
62
  });
63
63
 
64
+ await it('answers `get_child()` / `set_child()` too, the spelling GI-built code uses', () => {
65
+ // `Adw.Bin:child` has two doors, and markup only takes one of them: code written
66
+ // against GI calls the methods, which is what a `.blp`-driven widget shared with
67
+ // GJS and NativeScript does. Both of those renderers answer them; the web did not,
68
+ // and shared code died here with `get_child is not a function`.
69
+ const { el, host } = mount();
70
+ expect(el.get_child()).toBeNull();
71
+ const child = document.createElement('div');
72
+ el.set_child(child);
73
+ expect(el.get_child()).toBe(child);
74
+ expect(el.child).toBe(child);
75
+ const next = document.createElement('span');
76
+ el.set_child(next);
77
+ expect(el.get_child()).toBe(next);
78
+ expect(el.childElementCount).toBe(1);
79
+ el.set_child(null);
80
+ expect(el.get_child()).toBeNull();
81
+ host.remove();
82
+ });
83
+
64
84
  await it('a bin is a column flex box, and its only child fills it', () => {
65
85
  const { el, host } = mount([document.createElement('div')]);
66
86
  // A bin takes the size it is GIVEN and hands all of it to the child, which
@@ -15,6 +15,9 @@ import {
15
15
  COMBO_CHOOSER_VECTORS,
16
16
  COMBO_ITEMS_CHANGED_VECTORS,
17
17
  COMBO_SELECTION_VECTORS,
18
+ EXPANDER_ROW_CONSTRUCT_VECTORS,
19
+ EXPANDER_ROW_INITIAL,
20
+ EXPANDER_ROW_VECTORS,
18
21
  LIST_ITEMS_CHANGED_VECTORS,
19
22
  LIST_MODEL_OWNERSHIP_VECTORS,
20
23
  LIST_NORMALIZE_VECTORS,
@@ -779,6 +782,83 @@ export const AdwRowStateTest = async () => {
779
782
  host.remove();
780
783
  });
781
784
 
785
+ for (const vector of EXPANDER_ROW_VECTORS) {
786
+ await it(`holds ${vector.name} (shared conformance vectors)`, async () => {
787
+ const { el: row, host } = parse<AdwExpanderRow>(
788
+ '<adw-expander-row title="More"><adw-action-row title="Nested"></adw-action-row></adw-expander-row>',
789
+ 'adw-expander-row',
790
+ );
791
+ expect(row.expanded).toBe(EXPANDER_ROW_INITIAL.expanded);
792
+ expect(row.enableExpansion).toBe(EXPANDER_ROW_INITIAL.enableExpansion);
793
+ for (const step of vector.steps) {
794
+ if (step.op === 'expanded') row.expanded = step.value;
795
+ else row.enableExpansion = step.value;
796
+ // A DOM element publishes only the user's notifications, so `notified` is
797
+ // not observable here — the held values and what they reflect to are.
798
+ expect(row.expanded).toBe(step.expanded);
799
+ expect(row.enableExpansion).toBe(step.enableExpansion);
800
+ expect(row.hasAttribute('expanded')).toBe(step.expanded);
801
+ expect(row.classList.contains('expanded')).toBe(step.expanded);
802
+ }
803
+ host.remove();
804
+ });
805
+ }
806
+
807
+ for (const vector of EXPANDER_ROW_CONSTRUCT_VECTORS) {
808
+ await it(`parses expanded=${vector.expanded}, enable-expansion=${vector.enableExpansion} — ${vector.rule}`, async () => {
809
+ const enable = `enable-expansion="${vector.enableExpansion}"`;
810
+ const expanded = vector.expanded ? 'expanded' : '';
811
+ // Both attribute orders: the parser delivers them in source order.
812
+ for (const attributes of [`${enable} ${expanded}`, `${expanded} ${enable}`]) {
813
+ const { el: row, host } = parse<AdwExpanderRow>(
814
+ `<adw-expander-row title="x" ${attributes}></adw-expander-row>`,
815
+ 'adw-expander-row',
816
+ );
817
+ expect(row.enableExpansion).toBe(vector.enableExpansion);
818
+ expect(row.expanded).toBe(vector.resultExpanded);
819
+ expect(row.hasAttribute('expanded')).toBe(vector.resultExpanded);
820
+ host.remove();
821
+ }
822
+ });
823
+ }
824
+
825
+ await it('does not open on a header click while expansion is disabled', async () => {
826
+ const { el: row, host } = parse<AdwExpanderRow>(
827
+ '<adw-expander-row title="More" enable-expansion="false"></adw-expander-row>',
828
+ 'adw-expander-row',
829
+ );
830
+ const header = row.querySelector('.adw-expander-row-header') as HTMLDivElement;
831
+ const events = record(row, 'notify::expanded');
832
+ header.click();
833
+ expect(row.expanded).toBe(false);
834
+ expect(events.details.length).toBe(0);
835
+ events.stop();
836
+ host.remove();
837
+ });
838
+
839
+ await it('the enable switch publishes expanded BEFORE enable-expansion, as libadwaita emits them', async () => {
840
+ const { el: row, host } = parse<AdwExpanderRow>(
841
+ '<adw-expander-row title="More" show-enable-switch enable-expansion="false"></adw-expander-row>',
842
+ 'adw-expander-row',
843
+ );
844
+ const seen: string[] = [];
845
+ row.addEventListener('notify::expanded', () => seen.push('expanded'));
846
+ row.addEventListener('notify::enable-expansion', () => seen.push('enable-expansion'));
847
+ const toggle = row.querySelector('.adw-expander-row-enable-switch') as HTMLElement & { active: boolean };
848
+
849
+ toggle.active = true;
850
+ expect(seen.join()).toBe('expanded,enable-expansion');
851
+ expect(row.expanded).toBe(true);
852
+ expect(row.hasAttribute('expanded')).toBe(true);
853
+
854
+ seen.length = 0;
855
+ toggle.active = false;
856
+ expect(seen.join()).toBe('expanded,enable-expansion');
857
+ expect(row.expanded).toBe(false);
858
+ expect(row.hasAttribute('expanded')).toBe(false);
859
+ host.remove();
860
+ });
861
+
782
862
  await it('adopts a declarative expanded attribute', async () => {
783
863
  const { el: row, host } = parse<AdwExpanderRow>(
784
864
  '<adw-expander-row title="Open" expanded></adw-expander-row>',
@@ -24,7 +24,7 @@
24
24
  // default `GENERIC` and a bin contributes nothing to the accessibility tree of its own.
25
25
  //
26
26
  // KNOWN_GAPS: none — `AdwBin`'s only property is `child`, a widget, which is a SLOT on
27
- // every renderer.
27
+ // every renderer, and it is reachable BOTH ways: `child` and `get_child()`/`set_child()`.
28
28
  //
29
29
  // Reference: refs/libadwaita/src/adw-bin.c (adw_bin_set_child, class_init)
30
30
  // Copyright (c) 2021 Purism SPC / GNOME contributors (libadwaita). LGPLv2.1+.
@@ -46,6 +46,25 @@ export class AdwBin extends WidgetElement {
46
46
  this.setChild(value);
47
47
  }
48
48
 
49
+ /**
50
+ * `adw_bin_get_child` / `adw_bin_set_child` — the METHOD spelling of {@link child}.
51
+ *
52
+ * The property alone is not the whole door. `Adw.Bin:child` is reached two ways and only
53
+ * one of them is a property: markup writes `child:`, but code built against GI calls
54
+ * `bin.get_child()` / `bin.set_child(w)`, which is the spelling a `.blp`-driven widget
55
+ * shared across renderers uses — GJS and the NativeScript bridge both answer it
56
+ * (`AdwSingleChildBase`), and here it was missing, so the shared code died with
57
+ * `get_child is not a function` on the web and nowhere else.
58
+ */
59
+ get_child(): Element | null {
60
+ return this.child;
61
+ }
62
+
63
+ /** `adw_bin_set_child`. Refuses a child that already has a parent, as {@link setChild} does. */
64
+ set_child(child: Element | null): void {
65
+ this.setChild(child);
66
+ }
67
+
49
68
  connectedCallback() {
50
69
  // The NAME only. Every child of a bin already IS its content — there is no
51
70
  // internal structure to route into — so `.install()` is deliberately not called
@@ -13,10 +13,10 @@
13
13
  // listbox. The `notify::expanded` / `notify::enable-expansion` events are named after
14
14
  // Adw.ExpanderRow's GObject signals so one code path drives the web and GTK renderers.
15
15
  //
16
- // The DISCLOSURE state (the expanded flag and its idempotent, notify-on-change
17
- // transitions) is HEADLESS and lives in `@gjsify/adwaita-core` (ADR 0004) as
18
- // {@link ExpanderState}; this element keeps only the DOM half — header/content markup,
19
- // the `expanded` attribute reflection and the event.
16
+ // The DISCLOSURE state (the expanded flag, the `enable-expansion` gate over it and their
17
+ // idempotent, notify-on-change transitions) is HEADLESS and lives in `@gjsify/adwaita-core`
18
+ // (ADR 0004) as {@link ExpanderState}; this element keeps only the DOM half — header/content
19
+ // markup, the `expanded` / `enable-expansion` attribute reflection and the events.
20
20
  //
21
21
  // Reference: refs/adwaita-web/adwaita-web/scss/_expander_row.scss
22
22
  // Reference: refs/libadwaita/src/stylesheet/widgets/_lists.scss (AdwExpanderRow)
@@ -70,8 +70,7 @@ export class AdwExpanderRow extends TooltipElement {
70
70
  // custom-element constructor rules allow it: nothing touches the DOM until the
71
71
  // state actually changes.
72
72
  this._state.subscribe((expanded) => {
73
- if (expanded) this.setAttribute('expanded', '');
74
- else this.removeAttribute('expanded');
73
+ this._reflectExpanded(expanded);
75
74
  if (this._initialized) this._render();
76
75
  });
77
76
  }
@@ -98,7 +97,7 @@ export class AdwExpanderRow extends TooltipElement {
98
97
  }
99
98
 
100
99
  get enableExpansion(): boolean {
101
- return !this.hasAttribute('enable-expansion') || this.getAttribute('enable-expansion') !== 'false';
100
+ return this._state.enableExpansion;
102
101
  }
103
102
 
104
103
  set enableExpansion(value: boolean) {
@@ -138,7 +137,7 @@ export class AdwExpanderRow extends TooltipElement {
138
137
  // Clicking the enable switch does NOT toggle the disclosure.
139
138
  this._enableSwitch = document.createElement('gtk-switch') as GtkSwitch;
140
139
  this._enableSwitch.classList.add('adw-expander-row-enable-switch');
141
- this._enableSwitch.active = this.enableExpansion;
140
+ this._enableSwitch.active = this._state.enableExpansion;
142
141
 
143
142
  this._chevronEl = createGtkImage('go-down', 'adw-expander-row-chevron');
144
143
 
@@ -197,27 +196,58 @@ export class AdwExpanderRow extends TooltipElement {
197
196
  event.stopPropagation();
198
197
  if (this._reflectingEnable) return;
199
198
  const enabled = this._enableSwitch.active;
199
+ // The state drives `expanded` BEFORE it notifies `enable-expansion`, and reports
200
+ // both in that order, so the events leave in the order libadwaita emits them.
201
+ const notified = this._state.setEnableExpansion(enabled);
200
202
  this.setAttribute('enable-expansion', enabled ? 'true' : 'false');
201
- this.dispatchEvent(
202
- new CustomEvent('notify::enable-expansion', {
203
- bubbles: true,
204
- detail: { enableExpansion: enabled },
205
- }),
206
- );
203
+ for (const property of notified) {
204
+ this.dispatchEvent(
205
+ new CustomEvent(`notify::${property}`, {
206
+ bubbles: true,
207
+ detail: property === 'expanded' ? { expanded: this.expanded } : { enableExpansion: enabled },
208
+ }),
209
+ );
210
+ }
207
211
  });
208
212
 
209
213
  // Seed the disclosure from the parsed markup: attributeChangedCallback is guarded
210
214
  // until the element is initialized, so a declarative `expanded` lands here.
215
+ // `enable-expansion` is already in the state (see attributeChangedCallback), so a
216
+ // markup `expanded` on a disabled row is refused as libadwaita refuses it.
211
217
  this._state.setExpanded(this.hasAttribute('expanded'));
218
+ this._reflectExpanded(this._state.expanded);
212
219
  this._render();
213
220
  }
214
221
 
215
222
  attributeChangedCallback(name: string) {
223
+ if (name === 'enable-expansion') {
224
+ // Taken before the init guard so `enableExpansion` answers a parsed attribute
225
+ // before the element connects. A programmatic write: the state drives `expanded`
226
+ // with it and the subscriber reflects that, silently. Only the switch publishes
227
+ // `notify::enable-expansion`.
228
+ this._state.setEnableExpansion(this._enableAttribute());
229
+ }
216
230
  if (!this._initialized) return;
217
- if (name === 'expanded') this._state.setExpanded(this.hasAttribute('expanded'));
231
+ if (name === 'expanded') {
232
+ this._state.setExpanded(this.hasAttribute('expanded'));
233
+ // A refusal (disabled row) changes nothing, so no subscriber would take the
234
+ // attribute back down: the state is the truth, the attribute its reflection.
235
+ this._reflectExpanded(this._state.expanded);
236
+ }
218
237
  this._render();
219
238
  }
220
239
 
240
+ /** The `enable-expansion` attribute as a flag: on unless it says `false`. */
241
+ private _enableAttribute(): boolean {
242
+ return this.getAttribute('enable-expansion') !== 'false';
243
+ }
244
+
245
+ private _reflectExpanded(expanded: boolean): void {
246
+ if (this.hasAttribute('expanded') === expanded) return;
247
+ if (expanded) this.setAttribute('expanded', '');
248
+ else this.removeAttribute('expanded');
249
+ }
250
+
221
251
  private _render() {
222
252
  // The `string_is_not_empty` label rule, from core — it hides the TITLE too, not
223
253
  // just the subtitle.
@@ -234,7 +264,7 @@ export class AdwExpanderRow extends TooltipElement {
234
264
  this._enableSwitch.hidden = !showSwitch;
235
265
  // OUR write, not the user's — see `_reflectingEnable`.
236
266
  this._reflectingEnable = true;
237
- this._enableSwitch.active = this.enableExpansion;
267
+ this._enableSwitch.active = this._state.enableExpansion;
238
268
  this._reflectingEnable = false;
239
269
 
240
270
  this.classList.toggle('expanded', this.expanded);