@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
@@ -0,0 +1,124 @@
1
+ // <adw-status-page> compact — the documented smaller status page for a narrow surface.
2
+ //
3
+ // libadwaita ships ONE style class on this widget, `.compact`
4
+ // (style-classes.md "Compact Status Page"), and `_misc.scss` gives it four differences from the
5
+ // full page: margin 24px 12px not 36px 12px, icon 96px not 128px, 12px not 24px under the icon,
6
+ // `.title-2` not `.title-1`. The child keeps libadwaita's 24px spacing, of which this renderer's
7
+ // flex `gap` supplies 12 — hence `margin-top: 12px`.
8
+ //
9
+ // Each is asserted as a COMPUTED size on the node the rule selects, not as the class alone: the
10
+ // class would pass against the pre-#1826 element whenever an author wrote `class="compact"` by
11
+ // hand, so a class-only suite would have left the variant unmeasured.
12
+ import { describe, expect, it } from '@gjsify/unit';
13
+
14
+ import type { AdwStatusPage } from './elements/adw-status-page.js';
15
+
16
+ /** Mount a status page wide enough for the full variant to differ from the compact one. */
17
+ function mount(
18
+ attributes: Record<string, string> = {},
19
+ width = 400,
20
+ ): {
21
+ el: AdwStatusPage;
22
+ host: HTMLElement;
23
+ } {
24
+ const host = document.createElement('div');
25
+ host.style.width = `${width}px`;
26
+ document.body.appendChild(host);
27
+ const el = document.createElement('adw-status-page') as AdwStatusPage;
28
+ el.setAttribute('icon', 'dialog-error-symbolic');
29
+ el.setAttribute('title', 'Could not connect');
30
+ el.setAttribute('description', 'Check your network connection and try again.');
31
+ for (const [name, value] of Object.entries(attributes)) el.setAttribute(name, value);
32
+ host.appendChild(el);
33
+ return { el, host };
34
+ }
35
+
36
+ /** Tolerance for a computed ratio: Firefox rounds a percentage-derived px size. */
37
+ const near = (actual: number, expected: number, tolerance = 0.005): boolean => Math.abs(actual - expected) <= tolerance;
38
+
39
+ const iconOf = (el: AdwStatusPage): HTMLElement => el.querySelector('.adw-status-page-icon') as HTMLElement;
40
+ const titleOf = (el: AdwStatusPage): HTMLElement => el.querySelector('.adw-status-page-title') as HTMLElement;
41
+ const childOf = (el: AdwStatusPage): HTMLElement => el.querySelector('.adw-status-page-child') as HTMLElement;
42
+
43
+ export const AdwStatusPageTest = async () => {
44
+ await describe('adw-status-page compact (style-classes.md "Compact Status Page")', async () => {
45
+ await it('the compact attribute puts the documented style class on the host', async () => {
46
+ const { el, host } = mount({ compact: '' });
47
+ // The selector is `adw-status-page.compact` — the HOST, not an inner node, which
48
+ // is why this element writes the class itself instead of leaving it to a wrapper.
49
+ expect(el.classList.contains('compact')).toBe(true);
50
+ host.remove();
51
+ });
52
+
53
+ await it('a full status page carries no compact class and keeps the 128px icon', async () => {
54
+ const { el, host } = mount();
55
+ expect(el.classList.contains('compact')).toBe(false);
56
+ expect(getComputedStyle(iconOf(el)).width).toBe('128px');
57
+ expect(getComputedStyle(el).paddingTop).toBe('36px');
58
+ host.remove();
59
+ });
60
+
61
+ await it('compact shrinks the icon to 96px and the margin to 24px (_misc.scss, statuspage.compact)', async () => {
62
+ const { el, host } = mount({ compact: '' });
63
+ const icon = getComputedStyle(iconOf(el));
64
+ expect(icon.width).toBe('96px');
65
+ expect(icon.height).toBe('96px');
66
+ expect(icon.marginBottom).toBe('12px');
67
+ expect(getComputedStyle(el).paddingTop).toBe('24px');
68
+ expect(getComputedStyle(el).paddingBottom).toBe('24px');
69
+ host.remove();
70
+ });
71
+
72
+ await it('compact sets .title-2 (136%), the full page .title-1 (181%) (_labels.scss)', async () => {
73
+ const compact = mount({ compact: '' });
74
+ const full = mount();
75
+ try {
76
+ // The RATIO, not a pixel size: `_misc.scss` picks between `.title-1` and
77
+ // `.title-2`, which are 181% and 136% of the SAME inherited size — the
78
+ // boundary reset re-roots it (`_reset.scss`, ADR 0010), and a px assertion
79
+ // would pin this suite to one font instead of to the ratio the class encodes.
80
+ const compactTitle = parseFloat(getComputedStyle(titleOf(compact.el)).fontSize);
81
+ const fullTitle = parseFloat(getComputedStyle(titleOf(full.el)).fontSize);
82
+ expect(compactTitle < fullTitle).toBe(true);
83
+ expect(near(compactTitle / fullTitle, 136 / 181)).toBe(true);
84
+ // …and the weights both `.title-1`/`.title-2` carry, so this is the
85
+ // heading scale rather than a body label that happens to be smaller.
86
+ expect(getComputedStyle(titleOf(compact.el)).fontWeight).toBe('800');
87
+ } finally {
88
+ compact.host.remove();
89
+ full.host.remove();
90
+ }
91
+ });
92
+
93
+ await it('compact leaves 12px to the child beyond the 12px flex gap (_misc.scss, 24px spacing)', async () => {
94
+ const { el, host } = mount({ compact: '' });
95
+ const button = document.createElement('button');
96
+ button.className = 'adw-button suggested-action';
97
+ button.textContent = 'Try Again';
98
+ el.appendChild(button);
99
+
100
+ expect(getComputedStyle(childOf(el)).marginTop).toBe('12px');
101
+ // The full page's own 24px, so the pair cannot both be the compact rule.
102
+ const full = mount();
103
+ try {
104
+ expect(getComputedStyle(childOf(full.el)).marginTop).toBe('24px');
105
+ } finally {
106
+ host.remove();
107
+ full.host.remove();
108
+ }
109
+ });
110
+
111
+ await it('a .blp\'s styles ["compact"] survives a later attribute change', async () => {
112
+ const { el, host } = mount({ compact: '' });
113
+ el.classList.add('compact');
114
+ el.removeAttribute('compact');
115
+ // `title` is the attribute every real page sets, and setting it re-renders. A
116
+ // `toggle` here would strip the class the shared-tree builder had put there —
117
+ // `shared-tree-builder.ts:296` writes a `.blp`'s style classes onto this host.
118
+ el.setAttribute('title', 'Still compact');
119
+ expect(el.classList.contains('compact')).toBe(true);
120
+ expect(getComputedStyle(iconOf(el)).width).toBe('96px');
121
+ host.remove();
122
+ });
123
+ });
124
+ };
@@ -0,0 +1,52 @@
1
+ // The one reading of an attribute that spells a GObject BOOLEAN property.
2
+ //
3
+ // An HTML boolean attribute can only spell TRUE by PRESENCE, and many GTK properties
4
+ // default to TRUE — `GtkWidget:sensitive`, `AdwToggle:enabled`, `GtkEditable:editable`.
5
+ // Reading `sensitive` by presence would leave every `<adw-toggle-group>` on a page
6
+ // insensitive. So a TRUE-default property is read by VALUE (`sensitive="false"` is the
7
+ // only way to say it), and the DEFAULT is passed in at the call site rather than assumed.
8
+ // The default varies by widget and property: `show-apply-button` defaults FALSE,
9
+ // `AdwBanner:revealed` defaults FALSE, but `GtkActionBar:revealed` defaults TRUE.
10
+ // The per-widget map in `@gjsify/adwaita-core/tags` ({@link isValueBasedBooleanAttr})
11
+ // records which (widget, property) pairs are value-based.
12
+ //
13
+ // TWO CATEGORIES, NOT THREE CONVENTIONS (ADR 0049, ADR 0034):
14
+ // - STYLE CLASS → presence-based: `flat`, `round`, `compact` — the attribute's PRESENCE
15
+ // toggles the CSS class. `booleanAttribute` is NOT used for these.
16
+ // - GTK PROPERTY → value-based per (widget, property): the attribute's VALUE
17
+ // (`"false"` vs anything else) sets the property. `booleanAttribute` IS used for these.
18
+ // A declarative `false` MUST write `attr="false"`, not remove the attribute.
19
+ //
20
+ // It was a private helper in `elements/adw-entry-row.ts` until a second caller needed it.
21
+
22
+ import { VALUE_BASED_BOOLEAN_ATTRS } from '@gjsify/adwaita-core/tags';
23
+
24
+ /** Re-export for consumers that need the list (e.g. `shared-tree-builder.ts`). */
25
+ export { VALUE_BASED_BOOLEAN_ATTRS };
26
+
27
+ /**
28
+ * Whether an attribute spells a boolean property, defaulting to `whenAbsent`.
29
+ *
30
+ * Absent is the property's default; present, the literal `"false"` is FALSE and anything
31
+ * else is TRUE.
32
+ */
33
+ export function booleanAttribute(value: string | null, whenAbsent: boolean): boolean {
34
+ if (value === null) return whenAbsent;
35
+ return value !== 'false';
36
+ }
37
+
38
+ /**
39
+ * Write a value-based boolean attribute (GTK property defaulting to TRUE).
40
+ *
41
+ * `true` → sets `attr="true"` (present, not "false", reads as true)
42
+ * `false` → sets `attr="false"` (present, reads as false)
43
+ * This avoids `toggleAttribute` which removes the attribute for `false`,
44
+ * causing value-based readers to fall back to the default (true).
45
+ */
46
+ export function writeBooleanAttribute(el: HTMLElement, attr: string, value: boolean): void {
47
+ if (value) {
48
+ el.setAttribute(attr, 'true');
49
+ } else {
50
+ el.setAttribute(attr, 'false');
51
+ }
52
+ }
@@ -15,4 +15,7 @@ export const capabilities = {
15
15
  signal: 'implemented',
16
16
  bind: 'implemented',
17
17
  breakpoint: 'implemented',
18
+ 'sibling-object': {
19
+ refused: 'the web builder mounts one root element and has no consumer for a second root yet (ADR 0093)',
20
+ },
18
21
  };
@@ -23,7 +23,7 @@ customElements.define('corpus-extern', CorpusExtern);
23
23
  registerTemplateClass(EXTERN_VECTOR_CLASS, 'corpus-extern');
24
24
 
25
25
  function observe(vector: ConstructVector): unknown {
26
- if (vector.kind === 'layout') {
26
+ if (vector.kind === 'layout' || vector.kind === 'sibling-object') {
27
27
  buildSharedTree(vector.tree);
28
28
  return undefined;
29
29
  }
@@ -7,6 +7,14 @@
7
7
  // in `@gjsify/adwaita-core` (ADR 0004) as {@link ActionRowState}; this element keeps
8
8
  // only the DOM render half.
9
9
  //
10
+ // ATTRIBUTES:
11
+ // title, subtitle — the text column
12
+ // activatable — whether the row activates on click (boolean presence)
13
+ // tooltip-text — `Gtk.Widget:tooltip-text` (`src/row-tooltip.ts` carries why it lands
14
+ // on the row's parts and not on the host)
15
+ // title-lines — clamp title to N lines (0 = unlimited, default)
16
+ // subtitle-lines — clamp subtitle to N lines (0 = unlimited, default)
17
+ //
10
18
  // Reference: refs/adwaita-web/adwaita-web/docs/widgets/actionrow.md
11
19
  // Reference: refs/libadwaita/src/adw-action-row.c, adw-action-row.ui
12
20
  // Reference: refs/libadwaita/src/stylesheet/widgets/_lists.scss (.row styling)
@@ -17,8 +25,10 @@
17
25
  import { ActionRowState } from '@gjsify/adwaita-core';
18
26
 
19
27
  import { bindEmptySections } from '../empty-sections.js';
28
+ import { applyRowTooltip } from '../row-tooltip.js';
20
29
  import { bindSlottedChildren } from '../slotted-children.js';
21
30
  import { type AdwRowActivation, attachRowActivation } from './row-activation.js';
31
+ import { applyRowLineClamp, parseRowLinesAttribute } from '../row-line-clamp.js';
22
32
 
23
33
  /**
24
34
  * Attributes whose change means the activatable widget's sensitivity may have
@@ -38,6 +48,8 @@ export class AdwActionRow extends HTMLElement {
38
48
  private _subtitleEl!: HTMLSpanElement;
39
49
  private _suffixEl!: HTMLDivElement;
40
50
  private _prefixEl!: HTMLDivElement;
51
+ /** The label column — kept for `tooltip-text`, which the browser reads off a PART. */
52
+ private _textEl!: HTMLDivElement;
41
53
  /** The headless labels + activatable-widget coupling (ADR 0004). */
42
54
  private readonly _state = new ActionRowState<HTMLElement>();
43
55
  /** Watches the activatable widget's `disabled`/`aria-disabled` — the live binding. */
@@ -47,7 +59,7 @@ export class AdwActionRow extends HTMLElement {
47
59
  private _activation?: AdwRowActivation;
48
60
 
49
61
  static get observedAttributes() {
50
- return ['title', 'subtitle', 'activatable'];
62
+ return ['title', 'subtitle', 'activatable', 'tooltip-text', 'title-lines', 'subtitle-lines'];
51
63
  }
52
64
 
53
65
  /** The end (suffix) section — append controls here imperatively. */
@@ -95,7 +107,11 @@ export class AdwActionRow extends HTMLElement {
95
107
  this._prefixEl.className = 'adw-action-row-prefix';
96
108
 
97
109
  const textEl = document.createElement('div');
98
- textEl.className = 'adw-action-row-text';
110
+ // `.adw-row-text`, the ONE spelling every row type's text column carries —
111
+ // switch, combo, spin and expander all build it under that name, and so do the
112
+ // shared `.adw-row-clamp` rules in `_action_row.scss`/`_expander_row.scss`.
113
+ textEl.className = 'adw-row-text';
114
+ this._textEl = textEl;
99
115
  this._titleEl = document.createElement('span');
100
116
  this._titleEl.className = 'adw-row-title';
101
117
  this._subtitleEl = document.createElement('span');
@@ -133,6 +149,7 @@ export class AdwActionRow extends HTMLElement {
133
149
  this._state.setTitle(this.getAttribute('title'));
134
150
  this._state.setSubtitle(this.getAttribute('subtitle'));
135
151
  this._state.setActivatable(this.hasAttribute('activatable'));
152
+ this._applyLineClamping();
136
153
  }
137
154
 
138
155
  disconnectedCallback() {
@@ -194,8 +211,20 @@ export class AdwActionRow extends HTMLElement {
194
211
  this._subtitleEl.textContent = subtitle;
195
212
  this._subtitleEl.hidden = !subtitleVisible;
196
213
  this.classList.toggle('activatable', this._state.activatable);
214
+ applyRowTooltip(this, [this._prefixEl, this._textEl, this._suffixEl]);
197
215
  this._activation?.sync();
198
216
  }
217
+
218
+ /** Apply CSS line-clamping based on title-lines/subtitle-lines attributes. */
219
+ private _applyLineClamping(): void {
220
+ applyRowLineClamp({
221
+ titleEl: this._titleEl,
222
+ subtitleEl: this._subtitleEl,
223
+ textContainerEl: this._titleEl.parentElement!,
224
+ getTitleLines: () => parseRowLinesAttribute(this.getAttribute('title-lines')),
225
+ getSubtitleLines: () => parseRowLinesAttribute(this.getAttribute('subtitle-lines')),
226
+ });
227
+ }
199
228
  }
200
229
 
201
230
  customElements.define('adw-action-row', AdwActionRow);
@@ -59,6 +59,7 @@ import type { AdwResponseAppearance } from '@gjsify/adwaita-core';
59
59
  import { bindEmptySections } from '../empty-sections.js';
60
60
  import { bindSlottedChildren } from '../slotted-children.js';
61
61
  import { AdwModalSurface } from './modal-surface.js';
62
+ import { booleanAttribute } from '../attributes.js';
62
63
 
63
64
  /** Response button appearance — mirrors Adw.ResponseAppearance. */
64
65
  export type { AdwResponseAppearance } from '@gjsify/adwaita-core';
@@ -269,7 +270,9 @@ export class AdwAlertDialog extends HTMLElement {
269
270
  const appearance = (el.getAttribute('appearance') ?? 'default') as AdwResponseAppearance;
270
271
  this.addResponse(id, (el.textContent ?? '').trim());
271
272
  if (appearance !== 'default') this.setResponseAppearance(id, appearance);
272
- if (el.getAttribute('enabled') === 'false') this.setResponseEnabled(id, false);
273
+ // `enabled` is a value-based boolean attribute (GTK property defaulting to TRUE).
274
+ // Use the shared reader instead of a hand-rolled `=== 'false'` comparison.
275
+ if (!booleanAttribute(el.getAttribute('enabled'), true)) this.setResponseEnabled(id, false);
273
276
  }
274
277
 
275
278
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
@@ -7,6 +7,9 @@
7
7
  // The label/icon visibility rules are HEADLESS and live in `@gjsify/adwaita-core`
8
8
  // (ADR 0004) as {@link ButtonRowState}; this element keeps only the DOM render half.
9
9
  //
10
+ // Attributes: title, start-icon-name, end-icon-name, tooltip-text
11
+ // (`Gtk.Widget:tooltip-text`).
12
+ //
10
13
  // THERE IS NO `activatable` OPT-OUT, and inventing one would be wrong twice over: the
11
14
  // upstream template hardcodes `activatable=True` and the class docs say "AdwButtonRow is
12
15
  // always activatable", while `<adw-action-row>` in this same package reads `activatable`
@@ -21,6 +24,7 @@
21
24
 
22
25
  import { BUTTON_ROW_ACTIVATABLE, ButtonRowState } from '@gjsify/adwaita-core';
23
26
 
27
+ import { applyRowTooltip } from '../row-tooltip.js';
24
28
  import { type GtkImage, createGtkImage } from './gtk-image.js';
25
29
  import { attachRowActivation } from './row-activation.js';
26
30
 
@@ -34,7 +38,7 @@ export class AdwButtonRow extends HTMLElement {
34
38
  private _initialized = false;
35
39
 
36
40
  static get observedAttributes() {
37
- return ['title', 'start-icon-name', 'end-icon-name'];
41
+ return ['title', 'start-icon-name', 'end-icon-name', 'tooltip-text'];
38
42
  }
39
43
 
40
44
  connectedCallback() {
@@ -81,6 +85,8 @@ export class AdwButtonRow extends HTMLElement {
81
85
 
82
86
  // Unconditional: an AdwButtonRow has no way to not be activatable.
83
87
  this.classList.toggle('activatable', BUTTON_ROW_ACTIVATABLE);
88
+ // The contents box is the whole row here, so it carries the tooltip for all of it.
89
+ applyRowTooltip(this, [this._contentsEl]);
84
90
  }
85
91
 
86
92
  /**
@@ -29,6 +29,9 @@
29
29
  // title / subtitle — the text column.
30
30
  // model — JSON array: `["a","b"]` or `[{"value":"a","label":"A"}]`.
31
31
  // selected — the selected index (number).
32
+ // tooltip-text — `Gtk.Widget:tooltip-text` (`src/row-tooltip.ts`).
33
+ // title-lines — clamp title to N lines (0 = unlimited, default)
34
+ // subtitle-lines — clamp subtitle to N lines (0 = unlimited, default)
32
35
  // Properties — the `<gtk-drop-down>` set minus `enableSearch` and `active`, which are that
33
36
  // element's own popover chrome and have no counterpart on a row that opens a native
34
37
  // <select>:
@@ -58,12 +61,16 @@
58
61
  import { ComboState, deriveRowLabels, normalizeComboOptions, parseListModel } from '@gjsify/adwaita-core';
59
62
  import type { AdwComboOption, AdwListItemsChanged, AdwListModelInput } from '@gjsify/adwaita-core';
60
63
 
64
+ import { applyRowTooltip } from '../row-tooltip.js';
61
65
  import { bindSlottedChildren } from '../slotted-children.js';
62
66
  import { stringListSlot } from '../string-list-slot.js';
67
+ import { applyRowLineClamp, parseRowLinesAttribute } from '../row-line-clamp.js';
63
68
 
64
69
  export class AdwComboRow extends HTMLElement {
65
70
  private _select!: HTMLSelectElement;
66
71
  private _valueEl!: HTMLSpanElement;
72
+ /** The label column — kept for `tooltip-text`, which the browser reads off a PART. */
73
+ private _textEl!: HTMLDivElement;
67
74
  private _titleEl!: HTMLSpanElement;
68
75
  private _subtitleEl!: HTMLSpanElement;
69
76
  /** The headless options list + selectedIndex↔selectedValue state machine (ADR 0004). */
@@ -71,7 +78,7 @@ export class AdwComboRow extends HTMLElement {
71
78
  private _initialized = false;
72
79
 
73
80
  static get observedAttributes() {
74
- return ['title', 'subtitle', 'model', 'selected'];
81
+ return ['title', 'subtitle', 'model', 'selected', 'tooltip-text', 'title-lines', 'subtitle-lines'];
75
82
  }
76
83
 
77
84
  /** The list model (`Adw.ComboRow:model`). Setting it splices the <select> and clamps the selection. */
@@ -141,6 +148,7 @@ export class AdwComboRow extends HTMLElement {
141
148
  // survive being attached, and an absent attribute must not blank it.
142
149
  const text = document.createElement('div');
143
150
  text.className = 'adw-row-text';
151
+ this._textEl = text;
144
152
  this._titleEl = document.createElement('span');
145
153
  this._titleEl.className = 'adw-row-title';
146
154
  this._subtitleEl = document.createElement('span');
@@ -283,6 +291,23 @@ export class AdwComboRow extends HTMLElement {
283
291
  this._titleEl.hidden = !labels.titleVisible;
284
292
  this._subtitleEl.textContent = labels.subtitle;
285
293
  this._subtitleEl.hidden = !labels.subtitleVisible;
294
+ // The <select> is stretched over the whole row (`position: absolute; inset: 0`,
295
+ // `_combo_row.scss`), so it is what the pointer actually lands on — the label column
296
+ // has `pointer-events: none`. A tooltip on the label parts alone would therefore
297
+ // never be reached over the title half of the row.
298
+ applyRowTooltip(this, [this._textEl, this._valueEl, this._select]);
299
+ this._applyLineClamping();
300
+ }
301
+
302
+ /** Apply CSS line-clamping based on title-lines/subtitle-lines attributes. */
303
+ private _applyLineClamping(): void {
304
+ applyRowLineClamp({
305
+ titleEl: this._titleEl,
306
+ subtitleEl: this._subtitleEl,
307
+ textContainerEl: this._titleEl.parentElement!,
308
+ getTitleLines: () => parseRowLinesAttribute(this.getAttribute('title-lines')),
309
+ getSubtitleLines: () => parseRowLinesAttribute(this.getAttribute('subtitle-lines')),
310
+ });
286
311
  }
287
312
  }
288
313
 
@@ -10,8 +10,8 @@
10
10
  // held to one table (`@gjsify/adwaita-core/conformance`).
11
11
  //
12
12
  // Attributes: title, text, editable, max-length, show-apply-button,
13
- // activates-default. `text-length` is REFLECTED (read-only, mirrors
14
- // `Adw.EntryRow:text-length`).
13
+ // activates-default, tooltip-text (`Gtk.Widget:tooltip-text`). `text-length` is
14
+ // REFLECTED (read-only, mirrors `Adw.EntryRow:text-length`).
15
15
  // Properties: text, editable, maxLength, textLength, showApplyButton,
16
16
  // activatesDefault, editing.
17
17
  // Events:
@@ -37,6 +37,8 @@ import {
37
37
  type EntryRowRenderState,
38
38
  } from '@gjsify/adwaita-core';
39
39
 
40
+ import { booleanAttribute } from '../attributes.js';
41
+ import { applyRowTooltip } from '../row-tooltip.js';
40
42
  import { bindSlottedChildren, type AdwSlottedChildren } from '../slotted-children.js';
41
43
  import { type GtkImage, createGtkImage } from './gtk-image.js';
42
44
 
@@ -51,11 +53,6 @@ function setPartVisible(part: HTMLElement, visible: boolean): void {
51
53
  part.style.display = visible ? '' : 'none';
52
54
  }
53
55
 
54
- /** Read a boolean attribute the HTML way, with an explicit `="false"` opt-out. */
55
- function boolAttribute(value: string | null): boolean {
56
- return value !== null && value !== 'false';
57
- }
58
-
59
56
  export class AdwEntryRow extends HTMLElement {
60
57
  /** The headless `update_empty` derivation + apply latch (ADR 0004). */
61
58
  protected readonly _state = new EntryRowState();
@@ -81,7 +78,7 @@ export class AdwEntryRow extends HTMLElement {
81
78
  private _lastLength = 0;
82
79
 
83
80
  static get observedAttributes(): string[] {
84
- return ['title', 'text', 'editable', 'max-length', 'show-apply-button', 'activates-default'];
81
+ return ['title', 'text', 'editable', 'max-length', 'show-apply-button', 'activates-default', 'tooltip-text'];
85
82
  }
86
83
 
87
84
  /**
@@ -158,8 +155,8 @@ export class AdwEntryRow extends HTMLElement {
158
155
  // before the element was connected survives.
159
156
  this._state.setMaxLength(Number.parseInt(this.getAttribute('max-length') ?? '', 10));
160
157
  this._state.setEditable(this.getAttribute('editable') !== 'false');
161
- this._state.setShowApplyButton(boolAttribute(this.getAttribute('show-apply-button')));
162
- this._state.setActivatesDefault(boolAttribute(this.getAttribute('activates-default')));
158
+ this._state.setShowApplyButton(booleanAttribute(this.getAttribute('show-apply-button'), false));
159
+ this._state.setActivatesDefault(booleanAttribute(this.getAttribute('activates-default'), false));
163
160
  if (this.hasAttribute('text')) this._state.setText(this.getAttribute('text') ?? '');
164
161
  this._renderTitle();
165
162
 
@@ -181,6 +178,7 @@ export class AdwEntryRow extends HTMLElement {
181
178
  this._lastText = this._state.text;
182
179
  this._lastLength = this._state.textLength;
183
180
  this._render(this._state.state);
181
+ applyRowTooltip(this, this._tooltipParts());
184
182
  this._state.subscribe((state) => this._onStateChanged(state));
185
183
  }
186
184
 
@@ -200,14 +198,26 @@ export class AdwEntryRow extends HTMLElement {
200
198
  this._state.setMaxLength(Number.parseInt(value ?? '', 10));
201
199
  break;
202
200
  case 'show-apply-button':
203
- this._state.setShowApplyButton(boolAttribute(value));
201
+ this._state.setShowApplyButton(booleanAttribute(value, false));
204
202
  break;
205
203
  case 'activates-default':
206
- this._state.setActivatesDefault(boolAttribute(value));
204
+ this._state.setActivatesDefault(booleanAttribute(value, false));
205
+ break;
206
+ case 'tooltip-text':
207
+ applyRowTooltip(this, this._tooltipParts());
207
208
  break;
208
209
  }
209
210
  }
210
211
 
212
+ /**
213
+ * The parts `tooltip-text` lands on: the two slots and the editable area — NOT the
214
+ * apply button or the indicator, which carry tooltips of their own that the consumer
215
+ * reads for what they are.
216
+ */
217
+ private _tooltipParts(): readonly Element[] {
218
+ return [this._prefixes, this._area, this._suffixes];
219
+ }
220
+
211
221
  /**
212
222
  * Add a widget after the editable area — `adw_entry_row_add_suffix`, which APPENDS
213
223
  * into a box, so several suffixes coexist and the password row's own peek toggle
@@ -1,6 +1,12 @@
1
1
  // <adw-expander-row> — a boxed-list row that discloses nested rows when expanded,
2
2
  // with an optional enable switch. Mirrors Adw.ExpanderRow.
3
3
  //
4
+ // ATTRIBUTES: `title`, `subtitle`, `expanded`, `enable-expansion`, `show-enable-switch`,
5
+ // and `title-lines` / `subtitle-lines` — clamp either label to N lines, `0` = unlimited,
6
+ // the AdwActionRow pspec's sentinel (`@girs/adw-1`, min 0, default 0).
7
+ // `tooltip-text` (`Gtk.Widget:tooltip-text`) — on the HEADER row alone, which is the
8
+ // part that is the row libadwaita draws.
9
+ //
4
10
  // Slots: `slot="prefix"` / `slot="suffix"` children sit in the HEADER row beside the
5
11
  // title (before the enable switch and disclosure chevron), like Adw.ExpanderRow's
6
12
  // add_prefix/add_suffix; every other child is moved into the disclosed content
@@ -22,7 +28,9 @@
22
28
  import { ExpanderState, deriveRowLabels } from '@gjsify/adwaita-core';
23
29
 
24
30
  import { bindEmptySections } from '../empty-sections.js';
31
+ import { applyRowTooltip } from '../row-tooltip.js';
25
32
  import { bindSlottedChildren } from '../slotted-children.js';
33
+ import { applyRowLineClamp, parseRowLinesAttribute } from '../row-line-clamp.js';
26
34
 
27
35
  // SIDE-EFFECT import, deliberately separate from the type import below: it guarantees
28
36
  // `gtk-switch` is defined before this module's `customElements.define` can upgrade a
@@ -68,7 +76,16 @@ export class AdwExpanderRow extends HTMLElement {
68
76
  }
69
77
 
70
78
  static get observedAttributes() {
71
- return ['title', 'subtitle', 'expanded', 'enable-expansion', 'show-enable-switch'];
79
+ return [
80
+ 'title',
81
+ 'subtitle',
82
+ 'expanded',
83
+ 'enable-expansion',
84
+ 'show-enable-switch',
85
+ 'tooltip-text',
86
+ 'title-lines',
87
+ 'subtitle-lines',
88
+ ];
72
89
  }
73
90
 
74
91
  get expanded(): boolean {
@@ -224,6 +241,22 @@ export class AdwExpanderRow extends HTMLElement {
224
241
  // step with the flag; `aria-expanded` is the same statement to the same reader.
225
242
  this._headerEl.setAttribute('aria-expanded', String(this.expanded));
226
243
  this._contentEl.classList.toggle('expanded', this.expanded);
244
+ // The HEADER only, not the disclosure: libadwaita's tooltip belongs to the row, and
245
+ // the content box is where the disclosed rows live — a child row's own tooltip is
246
+ // what the browser should offer there.
247
+ applyRowTooltip(this, [this._headerEl]);
248
+ this._applyLineClamping();
249
+ }
250
+
251
+ /** Apply CSS line-clamping based on title-lines/subtitle-lines attributes. */
252
+ private _applyLineClamping(): void {
253
+ applyRowLineClamp({
254
+ titleEl: this._titleEl,
255
+ subtitleEl: this._subtitleEl,
256
+ textContainerEl: this._titleEl.parentElement!,
257
+ getTitleLines: () => parseRowLinesAttribute(this.getAttribute('title-lines')),
258
+ getSubtitleLines: () => parseRowLinesAttribute(this.getAttribute('subtitle-lines')),
259
+ });
227
260
  }
228
261
  }
229
262
 
@@ -53,6 +53,12 @@ export class AdwHeaderBar extends HTMLElement {
53
53
  * bare `Adw.HeaderBar {}` in a `.blp` page shows "Mailboxes" with no title of its own.
54
54
  */
55
55
  private _page: Element | null = null;
56
+ /**
57
+ * The window the bar sits in: `update_title`'s last stop before the application name
58
+ * (`gtk_window_get_title` of the root), so a bar with no title of its own, under no titled
59
+ * page, shows the window's.
60
+ */
61
+ private _window: Element | null = null;
56
62
  private _pageObserver: MutationObserver | null = null;
57
63
 
58
64
  static get observedAttributes() {
@@ -136,6 +142,7 @@ export class AdwHeaderBar extends HTMLElement {
136
142
  this._pageObserver?.disconnect();
137
143
  this._pageObserver = null;
138
144
  this._page = null;
145
+ this._window = null;
139
146
  }
140
147
 
141
148
  /**
@@ -146,9 +153,12 @@ export class AdwHeaderBar extends HTMLElement {
146
153
  */
147
154
  private _followPage() {
148
155
  this._page = this.closest('adw-navigation-page');
149
- if (this._page === null) return;
156
+ this._window = this.closest('adw-window, adw-application-window');
150
157
  this._pageObserver ??= new MutationObserver(() => this._renderTitle());
151
- this._pageObserver.observe(this._page, { attributes: true, attributeFilter: ['title'] });
158
+ for (const ancestor of [this._page, this._window]) {
159
+ if (ancestor !== null)
160
+ this._pageObserver.observe(ancestor, { attributes: true, attributeFilter: ['title'] });
161
+ }
152
162
  }
153
163
 
154
164
  /**
@@ -188,9 +198,13 @@ export class AdwHeaderBar extends HTMLElement {
188
198
  // keeps "unset" distinguishable from "set to empty" on the child.
189
199
  for (const name of ['title', 'subtitle']) {
190
200
  // The bar's own `title` is this port's declarative divergence (see the header) and
191
- // wins; without one, the page's title is the first answer `update_title` has.
201
+ // wins; without one, the page's title is the first answer `update_title` has, then
202
+ // the window's.
192
203
  const value =
193
- this.getAttribute(name) ?? (name === 'title' ? (this._page?.getAttribute('title') ?? null) : null);
204
+ this.getAttribute(name) ??
205
+ (name === 'title'
206
+ ? (this._page?.getAttribute('title') ?? this._window?.getAttribute('title') ?? null)
207
+ : null);
194
208
  if (value === null) this._titleEl.removeAttribute(name);
195
209
  else this._titleEl.setAttribute(name, value);
196
210
  }