@gjsify/adwaita-web 0.55.0 → 0.56.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.
Files changed (71) hide show
  1. package/README.md +8 -0
  2. package/dist/adwaita-web.css +129 -27
  3. package/dist/adwaita-web.css.map +1 -1
  4. package/lib/types/adw-status-page.spec.d.ts +1 -0
  5. package/lib/types/attributes.d.ts +19 -0
  6. package/lib/types/elements/adw-action-row.d.ts +4 -0
  7. package/lib/types/elements/adw-combo-row.d.ts +4 -0
  8. package/lib/types/elements/adw-entry-row.d.ts +6 -0
  9. package/lib/types/elements/adw-expander-row.d.ts +2 -0
  10. package/lib/types/elements/adw-header-bar.d.ts +6 -0
  11. package/lib/types/elements/adw-overlay-split-view.d.ts +13 -0
  12. package/lib/types/elements/adw-sidebar.d.ts +3 -2
  13. package/lib/types/elements/adw-spin-row.d.ts +6 -0
  14. package/lib/types/elements/adw-switch-row.d.ts +4 -0
  15. package/lib/types/elements/adw-toggle-group.d.ts +17 -0
  16. package/lib/types/elements/gtk-scrolled-window.d.ts +10 -2
  17. package/lib/types/elements/gtk-window.d.ts +8 -2
  18. package/lib/types/index.d.ts +1 -1
  19. package/lib/types/row-line-clamp.d.ts +20 -0
  20. package/lib/types/row-tooltip.d.ts +8 -0
  21. package/lib/types/row-tooltip.spec.d.ts +1 -0
  22. package/lib/types/shared-tree-builder.d.ts +49 -19
  23. package/lib/types/shared-tree-mount.spec.d.ts +1 -0
  24. package/lib/types/styles.generated.d.ts +1 -1
  25. package/lib/types/widget-size.spec.d.ts +1 -0
  26. package/package.json +8 -8
  27. package/scss/_action_row.scss +39 -15
  28. package/scss/_button_row.scss +5 -0
  29. package/scss/_expander_row.scss +36 -3
  30. package/scss/_headerbar.scss +2 -1
  31. package/scss/_preferences_row.scss +9 -0
  32. package/scss/_row.scss +16 -0
  33. package/scss/_status_page.scss +1 -1
  34. package/scss/_toggle_group.scss +28 -0
  35. package/scss/_widget.scss +14 -3
  36. package/scss/_window.scss +13 -1
  37. package/src/adw-action-rows.spec.ts +255 -0
  38. package/src/adw-header-bar.spec.ts +18 -0
  39. package/src/adw-status-page.spec.ts +124 -0
  40. package/src/attributes.ts +52 -0
  41. package/src/capabilities.mjs +3 -0
  42. package/src/construct-vectors.spec.ts +1 -1
  43. package/src/elements/adw-action-row.ts +31 -2
  44. package/src/elements/adw-alert-dialog.ts +4 -1
  45. package/src/elements/adw-button-row.ts +7 -1
  46. package/src/elements/adw-combo-row.ts +26 -1
  47. package/src/elements/adw-entry-row.ts +22 -12
  48. package/src/elements/adw-expander-row.ts +34 -1
  49. package/src/elements/adw-header-bar.ts +18 -4
  50. package/src/elements/adw-overlay-split-view.ts +29 -6
  51. package/src/elements/adw-preferences-row.ts +5 -2
  52. package/src/elements/adw-sidebar.ts +33 -5
  53. package/src/elements/adw-spin-row.ts +37 -2
  54. package/src/elements/adw-status-page.ts +10 -2
  55. package/src/elements/adw-switch-row.ts +25 -1
  56. package/src/elements/adw-toggle-group.ts +119 -10
  57. package/src/elements/adw-window.ts +9 -4
  58. package/src/elements/gtk-scrolled-window.ts +18 -7
  59. package/src/elements/gtk-window.ts +9 -3
  60. package/src/gtk-window.spec.ts +12 -0
  61. package/src/index.ts +1 -1
  62. package/src/keyboard-operable.spec.ts +47 -11
  63. package/src/row-line-clamp.ts +74 -0
  64. package/src/row-tooltip.spec.ts +313 -0
  65. package/src/row-tooltip.ts +52 -0
  66. package/src/shared-tree-builder.ts +88 -35
  67. package/src/shared-tree-mount.spec.ts +130 -0
  68. package/src/shared-trees.spec.ts +15 -0
  69. package/src/styles.generated.ts +1 -1
  70. package/src/test.browser.mts +8 -0
  71. package/src/widget-size.spec.ts +117 -0
@@ -26,6 +26,7 @@ import {
26
26
  } from '@gjsify/adwaita-core';
27
27
 
28
28
  import { bindBreakpointSetter } from '../breakpoints.js';
29
+ import type { DispatchedSignals } from '../signals.js';
29
30
  import { bindSlottedChildren } from '../slotted-children.js';
30
31
 
31
32
  /**
@@ -80,6 +81,9 @@ function readTriStateAttr(element: Element, name: string, fallback: boolean): bo
80
81
  }
81
82
 
82
83
  export class AdwOverlaySplitView extends HTMLElement {
84
+ /** The GTK signals this element dispatches, which is what a `.blp` `bind` of `show-sidebar` follows. */
85
+ static readonly signals: DispatchedSignals = { 'notify::show-sidebar': 'notify::show-sidebar' };
86
+
83
87
  private _initialized = false;
84
88
  private _sidebarEl!: HTMLDivElement;
85
89
  private _contentEl!: HTMLDivElement;
@@ -97,6 +101,8 @@ export class AdwOverlaySplitView extends HTMLElement {
97
101
  * `show-sidebar` + `pin-sidebar` mean together, held to `OVERLAY_COLLAPSE_VECTORS`.
98
102
  */
99
103
  private _state = new OverlaySplitViewState();
104
+ /** The `show-sidebar` the last `notify::show-sidebar` (or the first read) settled on. */
105
+ private _notifiedShowSidebar: boolean | null = null;
100
106
  /** Re-entrancy guard for {@link _reflectShowSidebar}. */
101
107
  private _reflecting = false;
102
108
  /** The view's own width in CSS px — the size the sidebar fraction is OF. */
@@ -329,13 +335,30 @@ export class AdwOverlaySplitView extends HTMLElement {
329
335
  private _reflectShowSidebar() {
330
336
  if (this._reflecting) return;
331
337
  const wanted = this._state.showSidebar ? '' : 'false';
332
- if (this.getAttribute('show-sidebar') === wanted) return;
333
- this._reflecting = true;
334
- try {
335
- this.setAttribute('show-sidebar', wanted);
336
- } finally {
337
- this._reflecting = false;
338
+ if (this.getAttribute('show-sidebar') !== wanted) {
339
+ this._reflecting = true;
340
+ try {
341
+ this.setAttribute('show-sidebar', wanted);
342
+ } finally {
343
+ this._reflecting = false;
344
+ }
338
345
  }
346
+ this._notifyShowSidebar();
347
+ }
348
+
349
+ /**
350
+ * `notify::show-sidebar`, raised on the EFFECTIVE value — the state's, after an auto-hide on
351
+ * collapse — and only when it changed, the way GObject notifies. Every path that moves it
352
+ * (the attribute, the property, a breakpoint, a gesture) ends in {@link _reflectShowSidebar},
353
+ * so this is the one place it is raised. The first read only records the baseline: a bind
354
+ * reads the current value itself.
355
+ */
356
+ private _notifyShowSidebar() {
357
+ const shown = this._state.showSidebar;
358
+ const previous = this._notifiedShowSidebar;
359
+ this._notifiedShowSidebar = shown;
360
+ if (previous === null || previous === shown) return;
361
+ this.dispatchEvent(new CustomEvent('notify::show-sidebar', { bubbles: true, detail: { showSidebar: shown } }));
339
362
  }
340
363
 
341
364
  private _syncBreakpoint() {
@@ -5,7 +5,8 @@
5
5
  // FOUR PROPERTIES, AND EVERY ONE OF THEM IS ABOUT THE TITLE LABEL:
6
6
  // `title` (Pango markup unless `use-markup` is FALSE), `use-markup`, `use-underline`
7
7
  // (an embedded `_` marks a mnemonic) and `title-selectable` (the user may copy it) —
8
- // the property docs are at adw-preferences-row.c:118-170.
8
+ // the property docs are at adw-preferences-row.c:118-170. Inherited from `GtkWidget`,
9
+ // every row also takes `tooltip-text` (`src/row-tooltip.ts`).
9
10
  //
10
11
  // ONE DELIBERATE PORT DECISION, named here because C says the opposite in so many
11
12
  // words: "It doesn't present the title in any way and lets you present the preference
@@ -43,6 +44,7 @@
43
44
 
44
45
  import { labelDisplayText, stringIsNotEmpty } from '@gjsify/adwaita-core';
45
46
 
47
+ import { applyRowTooltip } from '../row-tooltip.js';
46
48
  import { bindEmptySections } from '../empty-sections.js';
47
49
  import { bindSlottedChildren } from '../slotted-children.js';
48
50
 
@@ -52,7 +54,7 @@ export class AdwPreferencesRow extends HTMLElement {
52
54
  private _initialized = false;
53
55
 
54
56
  static get observedAttributes() {
55
- return ['title', 'title-selectable', 'use-markup', 'use-underline'];
57
+ return ['title', 'title-selectable', 'use-markup', 'use-underline', 'tooltip-text'];
56
58
  }
57
59
 
58
60
  /** `Adw.PreferencesRow:title` — Pango markup unless {@link useMarkup} is false. */
@@ -144,6 +146,7 @@ export class AdwPreferencesRow extends HTMLElement {
144
146
  // `Gtk.Label:selectable` puts a label in GTK_SELECTION_TEXT; the DOM's spelling
145
147
  // of "this text may be copied" is `user-select: text`.
146
148
  this._titleEl.style.userSelect = this.titleSelectable ? 'text' : '';
149
+ applyRowTooltip(this, [this._titleEl, this._contentEl]);
147
150
  }
148
151
  }
149
152
 
@@ -64,6 +64,7 @@ import {
64
64
 
65
65
  import { createGtkImage } from './gtk-image.js';
66
66
  import { attachRovingFocus } from './roving-focus.js';
67
+ import { booleanAttribute } from '../attributes.js';
67
68
 
68
69
  // The sidebar consumes its declared children and drops them from the tree, so a later
69
70
  // `setAttribute` on one cannot find its sidebar with `closest()`. These keep the link —
@@ -75,28 +76,51 @@ const sectionBindings = new WeakMap<AdwSidebarSection, { sidebar: AdwSidebar; sp
75
76
  /** A single sidebar item. Child of <adw-sidebar-section>; consumed at connect time. */
76
77
  export class AdwSidebarItem extends HTMLElement {
77
78
  static get observedAttributes() {
78
- return ['title', 'subtitle', 'icon-name', 'disabled', 'hidden', 'needs-attention', 'badge-number'];
79
+ return [
80
+ 'title',
81
+ 'subtitle',
82
+ 'icon-name',
83
+ 'disabled',
84
+ 'hidden',
85
+ 'enabled',
86
+ 'visible',
87
+ 'needs-attention',
88
+ 'badge-number',
89
+ ];
79
90
  }
80
91
 
81
92
  /**
82
- * `AdwSidebarItem:enabled`, under the GIR name — the `disabled` attribute inverted. The
83
- * property is what lets an authored `enabled: false` reach this item through the
93
+ * `AdwSidebarItem:enabled`, under the GIR name — the `disabled` attribute inverted.
94
+ * The property is what lets an authored `enabled: false` reach this item through the
84
95
  * shared-tree builder, which writes a `false` through the element's own property.
96
+ * Reads both the presence-based `disabled` and value-based `enabled` attributes.
85
97
  */
86
98
  get enabled(): boolean {
99
+ // Value-based `enabled` attribute takes precedence (shared-tree builder writes it).
100
+ const enabledAttr = this.getAttribute('enabled');
101
+ if (enabledAttr !== null) return booleanAttribute(enabledAttr, true);
102
+ // Fall back to presence-based `disabled` attribute.
87
103
  return !this.hasAttribute('disabled');
88
104
  }
89
105
 
90
106
  set enabled(value: boolean) {
107
+ // Write both for compatibility: value-based `enabled` and presence-based `disabled`.
108
+ this.setAttribute('enabled', value ? 'true' : 'false');
91
109
  this.toggleAttribute('disabled', !value);
92
110
  }
93
111
 
94
112
  /** `AdwSidebarItem:visible` — the `hidden` attribute inverted, for the same reason. */
95
113
  get visible(): boolean {
114
+ // Value-based `visible` attribute takes precedence (shared-tree builder writes it).
115
+ const visibleAttr = this.getAttribute('visible');
116
+ if (visibleAttr !== null) return booleanAttribute(visibleAttr, true);
117
+ // Fall back to presence-based `hidden` attribute.
96
118
  return !this.hasAttribute('hidden');
97
119
  }
98
120
 
99
121
  set visible(value: boolean) {
122
+ // Write both for compatibility: value-based `visible` and presence-based `hidden`.
123
+ this.setAttribute('visible', value ? 'true' : 'false');
100
124
  this.toggleAttribute('hidden', !value);
101
125
  }
102
126
 
@@ -109,7 +133,9 @@ export class AdwSidebarItem extends HTMLElement {
109
133
  else if (name === 'subtitle') binding.spec.subtitle = value;
110
134
  else if (name === 'icon-name') binding.spec.iconName = value;
111
135
  else if (name === 'disabled') binding.spec.enabled = newValue === null;
136
+ else if (name === 'enabled') binding.spec.enabled = booleanAttribute(newValue, true);
112
137
  else if (name === 'hidden') binding.spec.visible = newValue === null;
138
+ else if (name === 'visible') binding.spec.visible = booleanAttribute(newValue, true);
113
139
  else if (name === 'needs-attention') binding.spec.needsAttention = newValue === null;
114
140
  else if (name === 'badge-number') binding.spec.badgeNumber = Number.parseInt(value, 10) || 0;
115
141
 
@@ -263,8 +289,10 @@ export class AdwSidebar extends HTMLElement {
263
289
  title: itemEl.getAttribute('title') ?? '',
264
290
  subtitle: itemEl.getAttribute('subtitle') ?? '',
265
291
  iconName: itemEl.getAttribute('icon-name') ?? '',
266
- enabled: !itemEl.hasAttribute('disabled'),
267
- visible: !itemEl.hasAttribute('hidden'),
292
+ // Use the element's own property getters, which read both value-based
293
+ // (`enabled`/`visible`) and presence-based (`disabled`/`hidden`) attributes.
294
+ enabled: itemEl.enabled,
295
+ visible: itemEl.visible,
268
296
  badgeNumber: Number.parseInt(itemEl.getAttribute('badge-number') ?? '0', 10) || 0,
269
297
  needsAttention: itemEl.hasAttribute('needs-attention'),
270
298
  };
@@ -24,6 +24,11 @@
24
24
  // `adjustment:` sets the property to a whole new object: an unwritten field takes the
25
25
  // adjustment's own default, not what the row held (`ADJUSTMENT_AUTHORED_VECTORS`).
26
26
  //
27
+ // ATTRIBUTES: `adjustment` (the range, JSON), `value`, `title`, `subtitle`, and
28
+ // `title-lines` / `subtitle-lines` — clamp either label to N lines, `0` = unlimited,
29
+ // the AdwActionRow pspec's sentinel (`@girs/adw-1`, min 0, default 0).
30
+ // `tooltip-text` (`Gtk.Widget:tooltip-text`) — the row's tooltip (`src/row-tooltip.ts`).
31
+
27
32
  // Adapted from Adwaita Web UI Framework (https://github.com/mclellac/adwaita-web).
28
33
  // Copyright (c) 2025 csm. MIT License.
29
34
  // Modifications: Reimplemented as Web Component for @gjsify/adwaita-web;
@@ -34,7 +39,9 @@ import { SpinState, deriveRowLabels, normalizeAdjustment, parseAdjustment } from
34
39
  import type { AdwAdjustment, AdwAdjustmentInput } from '@gjsify/adwaita-core';
35
40
  import { attributeOf } from '@gjsify/adwaita-core/tags';
36
41
 
42
+ import { applyRowTooltip } from '../row-tooltip.js';
37
43
  import { bindSlottedChildren } from '../slotted-children.js';
44
+ import { applyRowLineClamp, parseRowLinesAttribute } from '../row-line-clamp.js';
38
45
 
39
46
  /** `Gtk.Adjustment`'s six properties, as `AdwAdjustmentInput` names them. */
40
47
  const ADJUSTMENT_FIELDS = ['value', 'lower', 'upper', 'stepIncrement', 'pageIncrement', 'pageSize'] as const;
@@ -58,6 +65,10 @@ function authoredAdjustment(node: Node): AdwAdjustmentInput {
58
65
 
59
66
  export class AdwSpinRow extends HTMLElement {
60
67
  private _input!: HTMLInputElement;
68
+ /** The label column — kept for `tooltip-text`, which the browser reads off a PART. */
69
+ private _textEl!: HTMLDivElement;
70
+ /** The −/input/+ box — the other half of the row's surface. */
71
+ private _controlEl!: HTMLDivElement;
61
72
  private _titleEl!: HTMLSpanElement;
62
73
  private _subtitleEl!: HTMLSpanElement;
63
74
  /** The headless adjustment: the range, the clamp and the two signals (ADR 0004, ADR 0047). */
@@ -65,7 +76,7 @@ export class AdwSpinRow extends HTMLElement {
65
76
  private _initialized = false;
66
77
 
67
78
  static get observedAttributes() {
68
- return ['title', 'subtitle', 'value', 'adjustment'];
79
+ return ['title', 'subtitle', 'value', 'adjustment', 'tooltip-text', 'title-lines', 'subtitle-lines'];
69
80
  }
70
81
 
71
82
  get value(): number {
@@ -107,6 +118,7 @@ export class AdwSpinRow extends HTMLElement {
107
118
 
108
119
  const text = document.createElement('div');
109
120
  text.className = 'adw-row-text';
121
+ this._textEl = text;
110
122
  this._titleEl = document.createElement('span');
111
123
  this._titleEl.className = 'adw-row-title';
112
124
  this._subtitleEl = document.createElement('span');
@@ -115,6 +127,7 @@ export class AdwSpinRow extends HTMLElement {
115
127
 
116
128
  const control = document.createElement('div');
117
129
  control.className = 'adw-spin-control';
130
+ this._controlEl = control;
118
131
 
119
132
  const decBtn = document.createElement('button');
120
133
  decBtn.className = 'adw-spin-dec';
@@ -164,7 +177,16 @@ export class AdwSpinRow extends HTMLElement {
164
177
 
165
178
  attributeChangedCallback(name: string, _old: string | null, val: string | null) {
166
179
  if (!this._initialized) return;
167
- if (name === 'title' || name === 'subtitle') {
180
+ if (
181
+ name === 'title' ||
182
+ name === 'subtitle' ||
183
+ name === 'tooltip-text' ||
184
+ // The two `*-lines` attributes are LABEL geometry, not the adjustment: without
185
+ // this branch they fell through to the `setValue` below, so `title-lines="1"`
186
+ // set the numeric value to 1 and clamped nothing.
187
+ name === 'title-lines' ||
188
+ name === 'subtitle-lines'
189
+ ) {
168
190
  this._renderText();
169
191
  return;
170
192
  }
@@ -186,6 +208,19 @@ export class AdwSpinRow extends HTMLElement {
186
208
  this._titleEl.hidden = !labels.titleVisible;
187
209
  this._subtitleEl.textContent = labels.subtitle;
188
210
  this._subtitleEl.hidden = !labels.subtitleVisible;
211
+ applyRowTooltip(this, [this._textEl, this._controlEl]);
212
+ this._applyLineClamping();
213
+ }
214
+
215
+ /** Apply CSS line-clamping based on title-lines/subtitle-lines attributes. */
216
+ private _applyLineClamping(): void {
217
+ applyRowLineClamp({
218
+ titleEl: this._titleEl,
219
+ subtitleEl: this._subtitleEl,
220
+ textContainerEl: this._titleEl.parentElement!,
221
+ getTitleLines: () => parseRowLinesAttribute(this.getAttribute('title-lines')),
222
+ getSubtitleLines: () => parseRowLinesAttribute(this.getAttribute('subtitle-lines')),
223
+ });
189
224
  }
190
225
 
191
226
  /** A stepper press — the interactive path, so it emits `notify::value`. */
@@ -1,9 +1,10 @@
1
1
  // <adw-status-page> — A centered empty/placeholder state: a large symbolic
2
2
  // icon, a title, a description and an optional action (slotted child).
3
3
  // Attributes: icon (symbolic name, with or without -symbolic) or icon-name, the GObject
4
- // spelling a projected `.blp` writes; title, description.
4
+ // spelling a projected `.blp` writes; title, description, and the boolean compact.
5
5
  // Reference: refs/adwaita-web/adwaita-web/scss/_status_page.scss
6
6
  // Reference: refs/libadwaita/src/stylesheet/widgets/_misc.scss (AdwStatusPage)
7
+ // Reference: refs/libadwaita/doc/style-classes.md (Compact Status Page)
7
8
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
8
9
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
9
10
  // icon node is <gtk-image>.
@@ -22,7 +23,7 @@ export class AdwStatusPage extends HTMLElement {
22
23
  private _initialized = false;
23
24
 
24
25
  static get observedAttributes() {
25
- return ['icon', 'icon-name', 'title', 'description'];
26
+ return ['icon', 'icon-name', 'title', 'description', 'compact'];
26
27
  }
27
28
 
28
29
  connectedCallback() {
@@ -64,6 +65,13 @@ export class AdwStatusPage extends HTMLElement {
64
65
  }
65
66
 
66
67
  private _render() {
68
+ // `.compact` (style-classes.md "Compact Status Page") is keyed off the HOST by
69
+ // `_status_page.scss`, so this door writes the class itself. ADDED, never toggled:
70
+ // `shared-tree-builder.ts` puts a `.blp`'s `styles ["compact"]` on this same class,
71
+ // and a `toggle` would strip it on the first attribute change — `title`, on any page
72
+ // that sets one. Additive, the way `gtk_button_add_css_class` is.
73
+ if (this.hasAttribute('compact')) this.classList.add('compact');
74
+
67
75
  const icon = this.getAttribute('icon') ?? this.getAttribute('icon-name');
68
76
  // The element swaps the mask class and keeps the size class.
69
77
  this._iconEl.iconName = icon;
@@ -14,6 +14,10 @@
14
14
  // `<adw-action-row>` — libadwaita's AdwSwitchRow IS an AdwActionRow
15
15
  // (adw-switch-row.c:50), so `add_prefix` is inherited, not invented here.
16
16
  //
17
+ // ATTRIBUTES: `title`, `subtitle`, `active`, `tooltip-text` (`Gtk.Widget:tooltip-text`),
18
+ // and `title-lines` / `subtitle-lines` —
19
+ // clamp either label to N lines, `0` = unlimited, the AdwActionRow pspec's sentinel
20
+ // (`@girs/adw-1`, min 0, default 0).
17
21
  // Adapted from Adwaita Web UI Framework (https://github.com/mclellac/adwaita-web).
18
22
  // Copyright (c) 2025 csm. MIT License.
19
23
  // Modifications: Reimplemented as Web Component for @gjsify/adwaita-web;
@@ -23,7 +27,9 @@
23
27
  import { SwitchRowState, deriveRowLabels } from '@gjsify/adwaita-core';
24
28
 
25
29
  import { bindEmptySections } from '../empty-sections.js';
30
+ import { applyRowTooltip } from '../row-tooltip.js';
26
31
  import { bindSlottedChildren } from '../slotted-children.js';
32
+ import { applyRowLineClamp, parseRowLinesAttribute } from '../row-line-clamp.js';
27
33
 
28
34
  // SIDE-EFFECT import, deliberately separate from the type import below: it guarantees
29
35
  // `gtk-switch` is defined before this module's `customElements.define` can upgrade a
@@ -38,6 +44,8 @@ import type { GtkSwitch } from './gtk-switch.js';
38
44
  export class AdwSwitchRow extends HTMLElement {
39
45
  private _switchEl!: GtkSwitch;
40
46
  private _prefixEl!: HTMLDivElement;
47
+ /** The label column — kept for `tooltip-text`, which the browser reads off a PART. */
48
+ private _textEl!: HTMLDivElement;
41
49
  private _titleEl!: HTMLSpanElement;
42
50
  private _subtitleEl!: HTMLSpanElement;
43
51
  /** The headless active flag + its notify rule (ADR 0004). */
@@ -45,7 +53,7 @@ export class AdwSwitchRow extends HTMLElement {
45
53
  private _initialized = false;
46
54
 
47
55
  static get observedAttributes() {
48
- return ['title', 'subtitle', 'active'];
56
+ return ['title', 'subtitle', 'active', 'tooltip-text', 'title-lines', 'subtitle-lines'];
49
57
  }
50
58
 
51
59
  /** The start (prefix) section — append icons/widgets here imperatively. */
@@ -69,6 +77,7 @@ export class AdwSwitchRow extends HTMLElement {
69
77
 
70
78
  const text = document.createElement('div');
71
79
  text.className = 'adw-row-text';
80
+ this._textEl = text;
72
81
  this._titleEl = document.createElement('span');
73
82
  this._titleEl.className = 'adw-row-title';
74
83
  this._subtitleEl = document.createElement('span');
@@ -160,6 +169,21 @@ export class AdwSwitchRow extends HTMLElement {
160
169
  this._titleEl.hidden = !titleVisible;
161
170
  this._subtitleEl.textContent = subtitle;
162
171
  this._subtitleEl.hidden = !subtitleVisible;
172
+ // The switch is NOT a part here: `unfocusable` and consumer-facing, and a
173
+ // tooltip the consumer put on it has to keep winning over the row's.
174
+ applyRowTooltip(this, [this._prefixEl, this._textEl]);
175
+ this._applyLineClamping();
176
+ }
177
+
178
+ /** Apply CSS line-clamping based on title-lines/subtitle-lines attributes. */
179
+ private _applyLineClamping(): void {
180
+ applyRowLineClamp({
181
+ titleEl: this._titleEl,
182
+ subtitleEl: this._subtitleEl,
183
+ textContainerEl: this._titleEl.parentElement!,
184
+ getTitleLines: () => parseRowLinesAttribute(this.getAttribute('title-lines')),
185
+ getSubtitleLines: () => parseRowLinesAttribute(this.getAttribute('subtitle-lines')),
186
+ });
163
187
  }
164
188
  }
165
189
 
@@ -48,6 +48,28 @@
48
48
  // C does for the only layout this element has, it matches the three sibling tab lists,
49
49
  // and it leaves ArrowUp/ArrowDown to the page instead of swallowing a scroll.
50
50
  //
51
+ // SENSITIVITY HAS TWO DOORS, and both are the GTK ones. `GtkWidget:sensitive` on the GROUP
52
+ // is the `sensitive` attribute, read by VALUE rather than by presence because the property
53
+ // is TRUE by default and `<adw-toggle-group>` must not read as insensitive (`src/attributes.ts`).
54
+ // `AdwToggle:enabled` is per toggle.
55
+ //
56
+ // PER-TOGGLE `enabled` IS REACHABLE, and the issue's warning does not close it. `add_toggle`
57
+ // spends it at add time — `gtk_widget_set_sensitive (toggle->button, toggle->enabled)`
58
+ // (adw-toggle-group.c:871) — and `adw_toggle_set_enabled` applies it to that same button
59
+ // afterwards (:1663), so nothing about the order is load-bearing. What makes the door awkward
60
+ // is not the order but the TYPE: `AdwToggle` is not a widget and has no `set_sensitive`, so
61
+ // `add_toggle` is the only public way in and it wants an `AdwToggle` this element never
62
+ // builds. Here the door is the rendered `<button>` — which is exactly what `add_toggle` spends
63
+ // the flag on — so the element reads `enabled` where `add_toggle` would have read it and lands
64
+ // in the same state. One consequence is ported with it: an insensitive toggle cannot be the
65
+ // active one (`set_active_toggle`, :720), which is also what makes a disabled `<button>`
66
+ // agree with the selection rather than sitting checked and unreachable.
67
+ //
68
+ // The roving walk is FILTERED, which `roving-focus.ts` requires of every caller: a disabled
69
+ // `<button>` cannot take focus, and leaving one in strands the user on a `focus()` the browser
70
+ // refuses. `status/open-todos/adwaita-web.md` made adding that filter the price of the
71
+ // attribute; this is that change.
72
+ //
51
73
  // Reference: refs/libadwaita/src/adw-toggle-group.c (AdwToggleGroup behaviour)
52
74
  // Reference: refs/libadwaita/src/stylesheet/widgets/_toggle-group.scss
53
75
  // Reference: refs/adwaita-web/adwaita-web/scss/_toggle_group.scss
@@ -58,6 +80,7 @@
58
80
 
59
81
  import { ToggleGroupState, keptToggles, toggleIndexOfName } from '@gjsify/adwaita-core';
60
82
 
83
+ import { booleanAttribute } from '../attributes.js';
61
84
  import { createGtkImage } from './gtk-image.js';
62
85
  import { attachRovingFocus } from './roving-focus.js';
63
86
 
@@ -67,8 +90,24 @@ export class AdwToggle extends HTMLElement {
67
90
  // icon-only toggle has no other text — `tooltip` is what NAMES it, so it is not a
68
91
  // decoration. It produces no disabled or hidden button, which is the invariant
69
92
  // `keyboard-operable.spec.ts` pins this list for.
93
+ //
94
+ // `enabled` is the one that DOES change the invariant, and the pinned list is what
95
+ // forced the roving filter to land in the same change.
70
96
  static get observedAttributes() {
71
- return ['label', 'icon-name', 'tooltip'];
97
+ return ['label', 'icon-name', 'tooltip', 'enabled'];
98
+ }
99
+
100
+ attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
101
+ if (name === 'enabled' && oldValue !== newValue) {
102
+ // Notify the parent group that this toggle's enabled state changed.
103
+ this.dispatchEvent(
104
+ new CustomEvent('toggle-enabled-changed', {
105
+ bubbles: true,
106
+ composed: true,
107
+ detail: { enabled: newValue !== 'false' },
108
+ }),
109
+ );
110
+ }
72
111
  }
73
112
  }
74
113
 
@@ -82,9 +121,32 @@ export class AdwToggleGroup extends HTMLElement {
82
121
  private _stateAttr: 'aria-checked' | 'aria-selected' = 'aria-checked';
83
122
  /** Each toggle's `name`, in order — what `active-name` resolves against. */
84
123
  private _names: (string | null)[] = [];
124
+ /** `AdwToggle:enabled` per rendered button, in order — `add_toggle` spends this at add time. */
125
+ private _enabled: boolean[] = [];
126
+ /** The authored <adw-toggle> elements, kept to observe live `enabled` changes. */
127
+ private _toggles: AdwToggle[] = [];
85
128
 
86
129
  static get observedAttributes() {
87
- return ['active', 'active-name', 'flat', 'round'];
130
+ return ['active', 'active-name', 'flat', 'round', 'sensitive'];
131
+ }
132
+
133
+ /**
134
+ * `GtkWidget:sensitive` — whether the whole group is insensitive.
135
+ *
136
+ * TRUE unless the attribute says `false`, because the property defaults to TRUE and a
137
+ * boolean attribute read by PRESENCE would leave every group on a page insensitive.
138
+ */
139
+ get sensitive(): boolean {
140
+ return booleanAttribute(this.getAttribute('sensitive'), true);
141
+ }
142
+
143
+ set sensitive(value: boolean) {
144
+ this.setAttribute('sensitive', value ? 'true' : 'false');
145
+ }
146
+
147
+ /** Whether the toggle at `index` is enabled (`AdwToggle:enabled`) — false once disabled. */
148
+ isToggleEnabled(index: number): boolean {
149
+ return this._enabled[index] ?? false;
88
150
  }
89
151
 
90
152
  /** Zero-based index of the active toggle. */
@@ -159,11 +221,15 @@ export class AdwToggleGroup extends HTMLElement {
159
221
  // in the C.
160
222
  this._innerEl.setAttribute('role', 'none');
161
223
 
224
+ this._toggles = toggles;
162
225
  this._buttons = toggles.map((toggle, index) => {
163
226
  const btn = document.createElement('button');
164
227
  btn.type = 'button';
165
228
  btn.className = 'adw-toggle';
166
229
  btn.setAttribute('role', toggleRole);
230
+ // `add_toggle` reads `enabled` while building the button (:871), and a toggle
231
+ // with no `enabled` attribute is the TRUE default (:513).
232
+ this._enabled[index] = booleanAttribute(toggle.getAttribute('enabled'), true);
167
233
 
168
234
  const label = toggle.getAttribute('label') ?? '';
169
235
  const icon = toggle.getAttribute('icon-name') ?? '';
@@ -190,6 +256,21 @@ export class AdwToggleGroup extends HTMLElement {
190
256
  this._innerEl.append(...this._buttons);
191
257
  this.replaceChildren(this._innerEl);
192
258
 
259
+ // Observe live `enabled` changes on the authored <adw-toggle> elements.
260
+ // Upstream's `adw_toggle_set_enabled` is a live setter (:1663); the port
261
+ // snapshots at connect, so a later `toggle.setAttribute('enabled', 'false')`
262
+ // had no effect. The `AdwToggle` element fires `toggle-enabled-changed`
263
+ // from its attributeChangedCallback; we update `_enabled` and re-render.
264
+ for (let i = 0; i < this._toggles.length; i++) {
265
+ this._toggles[i].addEventListener('toggle-enabled-changed', (event: Event) => {
266
+ const { enabled } = (event as CustomEvent<{ enabled: boolean }>).detail;
267
+ if (this._enabled[i] !== enabled) {
268
+ this._enabled[i] = enabled;
269
+ this._render();
270
+ }
271
+ });
272
+ }
273
+
193
274
  // Hand the segments to the headless state machine (it needs the count to
194
275
  // bound the selection), then seed the active index from the attribute.
195
276
  this._state.setLabels(
@@ -203,16 +284,13 @@ export class AdwToggleGroup extends HTMLElement {
203
284
  this._applyActiveName();
204
285
  this._render();
205
286
 
206
- // No `disabled`/`hidden` filter, because no `<adw-toggle>` attribute can produce
207
- // either — upstream's per-toggle `enabled` (adw-toggle-group.c:871) has no web
208
- // counterpart yet, and a filter for a state this element cannot reach would be
209
- // untestable. `AdwToggle.observedAttributes` is pinned in
210
- // `keyboard-operable.spec.ts` so the day it grows, this decision has to be
211
- // revisited instead of silently becoming a focus trap.
287
+ // No `hidden` filter, because no `<adw-toggle>` attribute can produce one — and now
288
+ // there IS a `disabled` one, so the filter `roving-focus.ts` asks of every caller is
289
+ // here. `status/open-todos/adwaita-web.md` made that the price of `enabled`.
212
290
  attachRovingFocus({
213
291
  host: this,
214
292
  orientation: 'horizontal',
215
- items: () => this._buttons,
293
+ items: () => this._buttons.filter((btn) => !btn.disabled),
216
294
  // Same path a click takes, so an arrow key cannot drift from a press.
217
295
  select: (item) => this._selectIndex(this._buttons.findIndex((btn) => btn === item)),
218
296
  });
@@ -229,7 +307,7 @@ export class AdwToggleGroup extends HTMLElement {
229
307
  this._applyActiveName();
230
308
  return;
231
309
  }
232
- // flat / round are styling-only.
310
+ // `sensitive`, flat and round: styling and per-button state, both `_render`.
233
311
  this._render();
234
312
  }
235
313
 
@@ -253,6 +331,10 @@ export class AdwToggleGroup extends HTMLElement {
253
331
  }
254
332
 
255
333
  private _selectIndex(index: number): void {
334
+ // `set_active_toggle` refuses a toggle that is not enabled (:720), so an insensitive
335
+ // one can never become the active one — the check is the core state machine's guard
336
+ // plus this one, in C's order.
337
+ if (!this._enabled[index]) return;
256
338
  // The core state machine guards the no-op/out-of-range cases and notifies
257
339
  // the subscriber (which re-renders) only on a real change.
258
340
  if (!this._state.setSelected(index)) return;
@@ -266,14 +348,41 @@ export class AdwToggleGroup extends HTMLElement {
266
348
  this.classList.toggle('flat', this.hasAttribute('flat'));
267
349
  this.classList.toggle('round', this.hasAttribute('round'));
268
350
  const active = this._state.selected;
351
+ const sensitive = this.sensitive;
352
+ // The group node's own `:disabled` rule (_toggle-group.scss:16) needs the UA's
353
+ // `:disabled`, which a custom element never is — `_expander.scss` records why, and
354
+ // writes the rule against the attribute for the same reason.
355
+ this.toggleAttribute('disabled', !sensitive);
269
356
  this._buttons.forEach((btn, index) => {
270
357
  const isActive = index === active;
271
358
  btn.classList.toggle('active', isActive);
272
359
  btn.setAttribute(this._stateAttr, String(isActive));
360
+ // `GtkWidget:sensitive` on the GROUP reaches every child in GTK, because
361
+ // `gtk_widget_is_sensitive` ANDs the parent chain; a `<button disabled>` is the
362
+ // browser's half of that, and it is also what takes the toggle out of the tab
363
+ // order and out of the roving walk.
364
+ btn.disabled = !sensitive || !this._enabled[index];
273
365
  // The roving tabindex: Tab enters on the ACTIVE toggle, the way
274
366
  // `adw_toggle_group_grab_focus` grabs it, and leaves the group from there.
275
367
  btn.tabIndex = isActive ? 0 : -1;
276
368
  });
369
+ // An insensitive toggle can never BE the active one, so an active-but-disabled toggle
370
+ // — `active="1"` authored next to `<adw-toggle enabled="false">` — would be a
371
+ // checked radio nothing can reach. Upstream cannot express the pair at all
372
+ // (`set_active_toggle` clears the selection instead, :720), so the selection moves
373
+ // to the first enabled toggle, which is the one Tab then enters on.
374
+ if (!this._enabled[active]) this._activateFirstEnabled();
375
+ }
376
+
377
+ /** Select the first enabled toggle, if there is one — the C's "no active" repair. */
378
+ private _activateFirstEnabled(): void {
379
+ const first = this._enabled.findIndex((enabled) => enabled);
380
+ if (first === -1) return;
381
+ this._state.setSelected(first);
382
+ this.setAttribute('active', String(first));
383
+ // The state change came from _render, not user interaction, but the active
384
+ // index changed — emit notify::active so consumers stay in sync.
385
+ this.dispatchEvent(new CustomEvent('notify::active', { bubbles: true, detail: { active: first } }));
277
386
  }
278
387
  }
279
388
 
@@ -20,6 +20,9 @@
20
20
  // width, height — the size REQUEST in px, the one `gtk_widget_set_size_request
21
21
  // (GTK_WIDGET (self), 360, 200)` makes in adw_window_init (C:344). Absent, the
22
22
  // element keeps that default; an explicit attribute overrides it.
23
+ // default-width, default-height — `Gtk.Window:default-width` / `:default-height`, the size a
24
+ // `.blp` authors (`default-width: 1100`). A page has no window manager to open the window at
25
+ // that size, so it IS the box's size here, and it beats the request where both are present.
23
26
  //
24
27
  // Properties: `content` (the Adw.Window:content child), `breakpoints`,
25
28
  // `currentBreakpoint` (Adw.Window:current-breakpoint), `dialogs` and
@@ -51,7 +54,7 @@ export class AdwWindow extends HTMLElement {
51
54
  private _openDialogs: HTMLElement[] = [];
52
55
 
53
56
  static get observedAttributes() {
54
- return ['width', 'height'];
57
+ return ['width', 'height', 'default-width', 'default-height'];
55
58
  }
56
59
 
57
60
  /** The content area — the Adw.Window:content property. */
@@ -128,7 +131,9 @@ export class AdwWindow extends HTMLElement {
128
131
  // The size request is read from the ATTRIBUTE, so it is applied on every
129
132
  // change — including one that arrived before the element was connected, which
130
133
  // is the ordinary case for markup already sitting in a document.
131
- if (name === 'width' || name === 'height') this._applySizeRequest();
134
+ if (name === 'width' || name === 'height' || name === 'default-width' || name === 'default-height') {
135
+ this._applySizeRequest();
136
+ }
132
137
  }
133
138
 
134
139
  private _buildOnce() {
@@ -182,8 +187,8 @@ export class AdwWindow extends HTMLElement {
182
187
 
183
188
  /** The window's size request — GTK's, not CSS's, so an absent attribute means the default. */
184
189
  private _applySizeRequest() {
185
- const width = this.getAttribute('width');
186
- const height = this.getAttribute('height');
190
+ const width = this.getAttribute('default-width') ?? this.getAttribute('width');
191
+ const height = this.getAttribute('default-height') ?? this.getAttribute('height');
187
192
  this.style.width = width ? `${width}px` : `${DEFAULT_WIDTH}px`;
188
193
  this.style.height = height ? `${height}px` : `${DEFAULT_HEIGHT}px`;
189
194
  }