@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 @@
1
+ export declare const AdwWidgetSizeTest: () => Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/adwaita-web",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "description": "Adwaita/Libadwaita web components for browser targets",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -49,16 +49,16 @@
49
49
  "@codemirror/language": "^6.12.4",
50
50
  "@codemirror/state": "^6.7.0",
51
51
  "@codemirror/view": "^6.43.4",
52
- "@gjsify/adwaita-core": "^0.55.0",
53
- "@gjsify/adwaita-fonts": "^0.55.0",
54
- "@gjsify/adwaita-icons": "^0.55.0",
52
+ "@gjsify/adwaita-core": "^0.56.0",
53
+ "@gjsify/adwaita-fonts": "^0.56.0",
54
+ "@gjsify/adwaita-icons": "^0.56.0",
55
55
  "@lezer/highlight": "^1.2.3"
56
56
  },
57
57
  "devDependencies": {
58
- "@gjsify/blueprint": "^0.55.0",
59
- "@gjsify/cli": "^0.55.0",
60
- "@gjsify/unit": "^0.55.0",
61
- "@gjsify/vite-plugin-blueprint": "^0.55.0",
58
+ "@gjsify/blueprint": "^0.56.0",
59
+ "@gjsify/cli": "^0.56.0",
60
+ "@gjsify/unit": "^0.56.0",
61
+ "@gjsify/vite-plugin-blueprint": "^0.56.0",
62
62
  "sass": "^1.101.0",
63
63
  "typescript": "^6.0.3"
64
64
  },
@@ -5,7 +5,14 @@
5
5
  // Copyright (c) 2025 csm (adwaita-web). MIT License.
6
6
  // Modifications: Adapted for @gjsify/adwaita-web Web Components.
7
7
 
8
- adw-action-row {
8
+ adw-action-row,
9
+ adw-switch-row,
10
+ adw-combo-row,
11
+ adw-spin-row {
12
+ // These row types inherit from AdwActionRow upstream and have
13
+ // title-lines/subtitle-lines properties. Match libadwaita: wrap by default
14
+ // (GtkLabel with wrap=true, lines=0), clamp via CSS custom property when
15
+ // title-lines/subtitle-lines > 0. See adw-action-row.ui lines 54-61, 70-81.
9
16
  display: flex;
10
17
  align-items: center;
11
18
  gap: var(--spacing-m);
@@ -31,36 +38,53 @@ adw-action-row {
31
38
  }
32
39
 
33
40
  .adw-action-row-prefix,
34
- .adw-action-row-suffix {
41
+ .adw-action-row-suffix,
42
+ .adw-switch-row-prefix {
35
43
  display: flex;
36
44
  align-items: center;
37
45
  gap: var(--spacing-xs);
38
46
  flex-shrink: 0;
39
47
  }
40
48
 
41
- .adw-action-row-text {
42
- display: flex;
43
- flex-direction: column;
44
- justify-content: center;
45
- flex: 1 1 auto;
46
- min-width: 0;
47
- }
49
+ // The text column's own rule is `_row.scss`'s shared `.adw-row-text` — one spelling
50
+ // for all five rows, so the clamp selectors below can name it too.
48
51
 
52
+ // Title/subtitle WRAP BY DEFAULT, matching libadwaita's GtkLabel wrap=true,
53
+ // lines=0. The typography is this row type's own (it was here before the wrap); only
54
+ // `white-space` moves, and `overflow`/`text-overflow` are left to the clamped state
55
+ // below, where the ellipsis has a line count to truncate to.
49
56
  .adw-row-title {
50
57
  font-size: var(--font-size-base);
51
58
  color: var(--card-fg-color);
52
- white-space: nowrap;
53
- overflow: hidden;
54
- text-overflow: ellipsis;
59
+ white-space: normal;
60
+ overflow-wrap: break-word;
55
61
  }
56
62
 
57
63
  .adw-row-subtitle {
58
64
  font-size: var(--font-size-small);
59
65
  opacity: var(--dim-opacity);
60
66
  color: var(--card-fg-color);
61
- white-space: nowrap;
62
- overflow: hidden;
63
- text-overflow: ellipsis;
67
+ white-space: normal;
68
+ overflow-wrap: break-word;
69
+ }
70
+
71
+ // Clamped state — enabled by JS toggling .adw-row-clamp on the text container.
72
+ .adw-row-text.adw-row-clamp {
73
+ .adw-row-title,
74
+ .adw-row-subtitle {
75
+ display: -webkit-box;
76
+ -webkit-box-orient: vertical;
77
+ overflow: hidden;
78
+ text-overflow: var(--adw-row-ellipsize-overflow, ellipsis);
79
+ }
80
+
81
+ .adw-row-title {
82
+ -webkit-line-clamp: var(--adw-row-title-lines, 1);
83
+ }
84
+
85
+ .adw-row-subtitle {
86
+ -webkit-line-clamp: var(--adw-row-subtitle-lines, 1);
87
+ }
64
88
  }
65
89
  }
66
90
 
@@ -34,6 +34,11 @@ adw-button-row {
34
34
  justify-content: center;
35
35
  gap: var(--spacing-xs);
36
36
  min-width: 0;
37
+ // The row centres its one button (`justify-content: center` above), so without this the
38
+ // contents box is exactly as wide as the button and `tooltip-text` reaches only that
39
+ // much of the row. Growing it and letting its own `justify-content: centre` do the
40
+ // centring draws the same button in the same place.
41
+ flex: 1 1 auto;
37
42
  }
38
43
 
39
44
  .adw-button-row-title {
@@ -32,12 +32,45 @@ adw-expander-row {
32
32
  }
33
33
  }
34
34
 
35
+ // Title/subtitle WRAP BY DEFAULT, matching libadwaita's GtkLabel wrap=true,
36
+ // lines=0 — stated HERE, at this row's own specificity, because `_row.scss` carries
37
+ // a `white-space: nowrap` default for `.adw-row-subtitle` and an inherited one reaches
38
+ // the title. A rule that only turns `white-space` on inside the clamp selector cannot
39
+ // make a row wrap by DEFAULT: the clamp is the minority state.
35
40
  .adw-row-title {
36
41
  font-size: var(--font-size-base);
37
42
  color: var(--card-fg-color);
38
- white-space: nowrap;
39
- overflow: hidden;
40
- text-overflow: ellipsis;
43
+ white-space: normal;
44
+ overflow-wrap: break-word;
45
+ }
46
+
47
+ .adw-row-subtitle {
48
+ font-size: var(--font-size-small);
49
+ opacity: var(--dim-opacity);
50
+ color: var(--card-fg-color);
51
+ white-space: normal;
52
+ overflow-wrap: break-word;
53
+ }
54
+
55
+ // Clamped state — JS toggles `.adw-row-clamp` on the TEXT COLUMN, the same node and
56
+ // the same class `_action_row.scss` keys off; reading it off the header instead never
57
+ // matched anything.
58
+ .adw-row-text.adw-row-clamp {
59
+ .adw-row-title,
60
+ .adw-row-subtitle {
61
+ display: -webkit-box;
62
+ -webkit-box-orient: vertical;
63
+ overflow: hidden;
64
+ text-overflow: var(--adw-row-ellipsize-overflow, ellipsis);
65
+ }
66
+
67
+ .adw-row-title {
68
+ -webkit-line-clamp: var(--adw-row-title-lines, 1);
69
+ }
70
+
71
+ .adw-row-subtitle {
72
+ -webkit-line-clamp: var(--adw-row-subtitle-lines, 1);
73
+ }
41
74
  }
42
75
 
43
76
  // The text column takes the free space so prefix/suffix widgets, the enable
@@ -119,7 +119,8 @@ adw-header-bar {
119
119
  // would paint over them. Washing the stripes out with the bar's own opaque
120
120
  // background colour renders identically and leaves the content alone.
121
121
  .devel adw-header-bar,
122
- adw-window.devel adw-header-bar {
122
+ adw-window.devel adw-header-bar,
123
+ adw-application-window.devel adw-header-bar {
123
124
  background-image:
124
125
  linear-gradient(to bottom, transparent 0%, var(--headerbar-bg-color) 100%),
125
126
  repeating-linear-gradient(
@@ -32,6 +32,15 @@ adw-preferences-row {
32
32
  // stylesheet that resets selection.
33
33
  .adw-row-title {
34
34
  user-select: none;
35
+
36
+ // A row with NO content section has a `hidden` one, and `[hidden]` is
37
+ // `display: none !important` — so the row's free space belongs to no part at all and
38
+ // `tooltip-text`, which rides a part (`src/row-tooltip.ts`), reaches only the label's
39
+ // own width. Taking that space moves it into a box that draws nothing: the label is
40
+ // start-aligned and the content's items are `flex-end`, so nothing on screen moves.
41
+ &:has(~ .adw-preferences-row-content[hidden]) {
42
+ flex: 1 1 auto;
43
+ }
35
44
  }
36
45
 
37
46
  // The slot the `child` property lands in: the preference's own controls, which sit
package/scss/_row.scss CHANGED
@@ -20,6 +20,16 @@ adw-combo-row {
20
20
  }
21
21
  }
22
22
 
23
+ // A `tooltip-text` part must cover the ROW, not just the label text inside it: the browser
24
+ // resolves a tooltip by walking UP from the hovered node, so a part that stops at the text's
25
+ // own height lets the walk reach the host — whose `title` is the row's LABEL — over the rest
26
+ // of the widget. Measured on a 54px `adw-action-row`: the label text is 18px of it. The
27
+ // marker is `applyRowTooltip`'s (`src/row-tooltip.ts`); stretching changes nothing drawn,
28
+ // because these columns centre their own children.
29
+ [data-row-tooltip] {
30
+ align-self: stretch;
31
+ }
32
+
23
33
  // Shared title/subtitle text column used by every row type (action/switch/
24
34
  // combo/spin). The title is the primary label; the subtitle is dimmed and
25
35
  // hidden when empty.
@@ -31,6 +41,12 @@ adw-combo-row {
31
41
  min-width: 0;
32
42
  }
33
43
 
44
+ // NO `.adw-row-title` default HERE, deliberately. `.adw-row-title` is also the class
45
+ // `<adw-entry-row>` and `<adw-preferences-row>` put on their titles, so a global
46
+ // `white-space: nowrap` here silently made those two rows un-wrapping too — a change
47
+ // to row types this feature was never scoped to. Each row type that wants a wrapping
48
+ // title says so in its own partial at its own specificity (see `_action_row.scss`,
49
+ // `_expander_row.scss`); a global default can only ever be the losing side of that.
34
50
  .adw-row-subtitle {
35
51
  font-size: var(--font-size-small);
36
52
  opacity: var(--dim-opacity);
@@ -53,7 +53,7 @@ adw-status-page {
53
53
 
54
54
  // `.compact` — the documented smaller status page, for a sidebar or a popover
55
55
  // (style-classes.md "Compact Status Page"): margins 24px 12px, the icon 96px with 12px
56
- // under it, `.title-2`, and 24px to the child (`_misc.scss`, statuspage.compact).
56
+ // under it, `.title-2`, and 12px to the child (`_misc.scss`, statuspage.compact).
57
57
  &.compact {
58
58
  padding: 24px 12px;
59
59
 
@@ -24,6 +24,13 @@ adw-toggle-group {
24
24
  border-radius: var(--button-radius);
25
25
  background-color: var(--button-bg-color);
26
26
 
27
+ // `toggle-group:disabled` (libadwaita `_toggle-group.scss:16`) — the whole group's
28
+ // background mixed toward transparent. Written against `[disabled]` and not `:disabled`:
29
+ // a custom element is never `:disabled`, the way `_expander.scss` records.
30
+ &[disabled] {
31
+ background-color: color-mix(in srgb, var(--button-bg-color) var(--disabled-opacity), transparent);
32
+ }
33
+
27
34
  .adw-toggle-group-inner {
28
35
  display: flex;
29
36
  align-items: stretch;
@@ -70,6 +77,20 @@ adw-toggle-group {
70
77
  outline-offset: 1px;
71
78
  }
72
79
 
80
+ // A `<button disabled>` carries GTK's `:disabled` dimming — `_buttons.scss:58`,
81
+ // `filter: Opacity(var(--disabled-opacity))` — and `cursor: default` with it
82
+ // (`_button.scss`). Hover and active are suppressed because a disabled button takes
83
+ // neither, so the hover fill would linger over a toggle that cannot be pressed.
84
+ &:disabled {
85
+ opacity: var(--disabled-opacity);
86
+ cursor: default;
87
+
88
+ &:hover,
89
+ &:active {
90
+ background-color: transparent;
91
+ }
92
+ }
93
+
73
94
  // The active toggle — a raised white chip, the way libadwaita draws it.
74
95
  &.active {
75
96
  background-color: var(--active-toggle-bg-color);
@@ -81,6 +102,13 @@ adw-toggle-group {
81
102
  &:hover {
82
103
  background-color: var(--active-toggle-bg-color);
83
104
  }
105
+
106
+ // The raised chip is what makes it read as raised; libadwaita drops the shadow on a
107
+ // disabled active toggle (`_toggle-group.scss:131`) so an unavailable choice does not
108
+ // look pressed.
109
+ &:disabled {
110
+ box-shadow: none;
111
+ }
84
112
  }
85
113
  }
86
114
 
package/scss/_widget.scss CHANGED
@@ -4,7 +4,8 @@
4
4
  // projected `.blp` hands them to `buildSharedTree` as attributes (`halign="center"`). Until
5
5
  // this partial nothing read them, so the gallery's bottom sheet stretched the button its
6
6
  // Blueprint centres. The margins are not here: an attribute cannot carry a length into CSS
7
- // portably, so `shared-tree-builder.ts` writes them as inline style.
7
+ // portably, so `shared-tree-builder.ts` writes them as inline style — and so are
8
+ // `width-request` / `height-request`, as the `min-width` / `min-height` they are.
8
9
  //
9
10
  // ALIGNMENT IS AN AUTO MARGIN, because that is the one CSS placement that means the same on
10
11
  // both axes of a flex container and of a grid: GTK allocates a non-FILL child its natural
@@ -49,7 +50,17 @@
49
50
  margin-block: auto;
50
51
  }
51
52
 
52
- gtk-box:not([orientation='vertical']) > [hexpand],
53
- gtk-box[orientation='vertical'] > [vexpand] {
53
+ gtk-box:not([orientation='vertical']) > [hexpand]:not([hexpand='false']),
54
+ gtk-box[orientation='vertical'] > [vexpand]:not([vexpand='false']) {
54
55
  flex-grow: 1;
55
56
  }
57
+
58
+ // An AUTHORED `hexpand: false` is the one case where absence is not enough: a widget that
59
+ // grows by default (`<gtk-scrolled-window>` is `flex: 1 1 0%`, the allocation of a window in a
60
+ // column) must stop, and GTK then gives it its NATURAL size rather than nothing — hence the
61
+ // `auto` basis, not just a zero grow. `width-request` (an inline `min-width`) is the floor.
62
+ gtk-box:not([orientation='vertical']) > [hexpand='false'],
63
+ gtk-box[orientation='vertical'] > [vexpand='false'] {
64
+ flex-grow: 0;
65
+ flex-basis: auto;
66
+ }
package/scss/_window.scss CHANGED
@@ -6,7 +6,11 @@
6
6
  // Copyright (c) 2025 csm (adwaita-web). MIT License.
7
7
  // Modifications: Adapted for @gjsify/adwaita-web Web Components.
8
8
 
9
- adw-window {
9
+ // `adw-application-window` is `adw-window` plus a menubar (it extends the class and adds nothing
10
+ // else), so it is the same frame: without this an application window was an inline box with no
11
+ // size at all, and a tree rooted at one measured 0 x 0.
12
+ adw-window,
13
+ adw-application-window {
10
14
  display: flex;
11
15
  flex-direction: column;
12
16
  background-color: var(--window-bg-color);
@@ -43,6 +47,14 @@ adw-window {
43
47
  min-width: 0;
44
48
  }
45
49
 
50
+ // A window hands its one child the WHOLE allocation, whatever that child's own expand flags say.
51
+ // On the web a box is only as tall as what it holds, so the `Gtk.Box` a `.blp` roots its content
52
+ // in stopped at its header bar and the split view below it drew a quarter of the window.
53
+ .adw-window-content > gtk-box {
54
+ flex: 1 1 auto;
55
+ min-height: 0;
56
+ }
57
+
46
58
  // <gtk-window> — the GTK frame, in the SAME file upstream styles `Adw.Window` in: libadwaita
47
59
  // keys `window { &.csd { … } }` off the GtkWindow CSS node
48
60
  // (refs/libadwaita/src/stylesheet/widgets/_window.scss:1-31), and this element IS a window
@@ -13,6 +13,8 @@
13
13
  // - libadwaita has no activatable opt-out for a button row, and
14
14
  // `<adw-action-row activatable="false">` reads by PRESENCE, so it means the
15
15
  // opposite of what it says.
16
+ // - the row `title-lines`/`subtitle-lines` clamp is observed as RENDERED GEOMETRY, not
17
+ // as computed style (see `renderedLineCount`).
16
18
  import { describe, expect, it } from '@gjsify/unit';
17
19
 
18
20
  import {
@@ -30,6 +32,9 @@ import { isIconAvailable } from './icon-registry.js';
30
32
  import { fallbackMask, maskOf } from './icon-registry.spec.js';
31
33
 
32
34
  import type { AdwActionRow } from './elements/adw-action-row.js';
35
+ import type { AdwComboRow } from './elements/adw-combo-row.js';
36
+ import type { AdwExpanderRow } from './elements/adw-expander-row.js';
37
+ import type { AdwSpinRow } from './elements/adw-spin-row.js';
33
38
  import type { AdwSwitchRow } from './elements/adw-switch-row.js';
34
39
  import type { AdwWindowTitle } from './elements/adw-window-title.js';
35
40
 
@@ -196,6 +201,134 @@ export const AdwActionRowsTest = async () => {
196
201
  });
197
202
  });
198
203
 
204
+ /**
205
+ * The number of lines a row title/subtitle element actually RENDERS, measured from its
206
+ * height divided by one line box's height.
207
+ *
208
+ * NOT the number of rects a `Range` over the element reports. Firefox reports a rect per
209
+ * laid-out line box and `-webkit-line-clamp` truncates the BOX without re-laying out the
210
+ * text, so a label clamped to one line still lists every line it would have taken:
211
+ * measured on a real `adw-switch-row`, `title-lines=1` gave 5 rects for a box that
212
+ * collapsed from 90px to 18px, and an isolated `display:-webkit-box;
213
+ * -webkit-line-clamp:1` probe gave 3 rects / 22px against 3 rects / 66px unclamped —
214
+ * identical rect counts, opposite line counts. Only the HEIGHT moves, so only the height
215
+ * can carry the claim; the rect count was never a line count and no `-webkit-line-clamp`
216
+ * implementation can satisfy `expect(rects.length).toBe(1)`.
217
+ *
218
+ * The step is the FIRST line box, not the computed `line-height`: the stylesheet sets no
219
+ * line-height on these labels, so `getComputedStyle().lineHeight` reads `normal` — which
220
+ * computes to no number at all.
221
+ */
222
+ function renderedLineCount(el: HTMLElement): number {
223
+ const range = document.createRange();
224
+ range.selectNodeContents(el);
225
+ const rects = range.getClientRects();
226
+ const lineBox = rects.length > 0 ? rects[0]!.height : 0;
227
+ if (!(lineBox > 0)) return Number.NaN;
228
+ return Math.round(el.getBoundingClientRect().height / lineBox);
229
+ }
230
+
231
+ // Default (0 = unlimited, the AdwActionRow pspec's sentinel): wraps freely.
232
+ await describe('<adw-action-row> title-lines / subtitle-lines (libadwaita conformance)', async () => {
233
+ await it('title-lines=0 (default) wraps to multiple lines in narrow container', () => {
234
+ const { el, host } = mount<AdwActionRow>('adw-action-row');
235
+ el.setAttribute(
236
+ 'title',
237
+ 'A very long title that should wrap across multiple lines when the container is narrow',
238
+ );
239
+ host.style.width = '200px';
240
+ const lines = renderedLineCount(el.querySelector('.adw-row-title')!);
241
+ expect(lines).toBeGreaterThan(1);
242
+ host.remove();
243
+ });
244
+
245
+ await it('subtitle-lines=0 (default) wraps to multiple lines in narrow container', () => {
246
+ const { el, host } = mount<AdwActionRow>('adw-action-row');
247
+ el.setAttribute(
248
+ 'subtitle',
249
+ 'A very long subtitle that should wrap across multiple lines when the container is narrow',
250
+ );
251
+ host.style.width = '200px';
252
+ const lines = renderedLineCount(el.querySelector('.adw-row-subtitle')!);
253
+ expect(lines).toBeGreaterThan(1);
254
+ host.remove();
255
+ });
256
+
257
+ // title-lines > 0: clamps to N lines
258
+ await it('title-lines=1 clamps to exactly 1 line', () => {
259
+ const { el, host } = mount<AdwActionRow>('adw-action-row');
260
+ el.setAttribute('title', 'Title that is long enough to wrap if allowed');
261
+ el.setAttribute('title-lines', '1');
262
+ host.style.width = '150px';
263
+ const lines = renderedLineCount(el.querySelector('.adw-row-title')!);
264
+ expect(lines).toBe(1);
265
+ host.remove();
266
+ });
267
+
268
+ await it('subtitle-lines=2 clamps to exactly 2 lines', () => {
269
+ const { el, host } = mount<AdwActionRow>('adw-action-row');
270
+ el.setAttribute('subtitle', 'Subtitle that is long enough to wrap into three lines if allowed');
271
+ el.setAttribute('subtitle-lines', '2');
272
+ host.style.width = '150px';
273
+ const lines = renderedLineCount(el.querySelector('.adw-row-subtitle')!);
274
+ expect(lines).toBe(2);
275
+ host.remove();
276
+ });
277
+
278
+ // Dynamic attribute change
279
+ await it('changing title-lines from 0 to 1 applies clamping visually', () => {
280
+ const { el, host } = mount<AdwActionRow>('adw-action-row');
281
+ el.setAttribute('title', 'Title that is long enough to wrap if allowed');
282
+ el.setAttribute('title-lines', '0');
283
+ host.style.width = '150px';
284
+ let lines = renderedLineCount(el.querySelector('.adw-row-title')!);
285
+ expect(lines).toBeGreaterThan(1);
286
+
287
+ el.setAttribute('title-lines', '1');
288
+ // Force reflow
289
+ el.getBoundingClientRect();
290
+ lines = renderedLineCount(el.querySelector('.adw-row-title')!);
291
+ expect(lines).toBe(1);
292
+ host.remove();
293
+ });
294
+
295
+ await it('changing title-lines from 1 to 0 removes clamping visually', () => {
296
+ const { el, host } = mount<AdwActionRow>('adw-action-row');
297
+ el.setAttribute('title', 'Title that is long enough to wrap if allowed');
298
+ el.setAttribute('title-lines', '1');
299
+ host.style.width = '150px';
300
+ let lines = renderedLineCount(el.querySelector('.adw-row-title')!);
301
+ expect(lines).toBe(1);
302
+
303
+ el.setAttribute('title-lines', '0');
304
+ el.getBoundingClientRect();
305
+ lines = renderedLineCount(el.querySelector('.adw-row-title')!);
306
+ expect(lines).toBeGreaterThan(1);
307
+ host.remove();
308
+ });
309
+
310
+ // Invalid/non-numeric values treated as 0 (unlimited) — AdwActionRow pspec range is 0..G_MAXINT
311
+ await it('title-lines="invalid" treated as unlimited (wraps)', () => {
312
+ const { el, host } = mount<AdwActionRow>('adw-action-row');
313
+ el.setAttribute('title', 'Title that is long enough to wrap if allowed');
314
+ el.setAttribute('title-lines', 'invalid');
315
+ host.style.width = '150px';
316
+ const lines = renderedLineCount(el.querySelector('.adw-row-title')!);
317
+ expect(lines).toBeGreaterThan(1);
318
+ host.remove();
319
+ });
320
+
321
+ await it('subtitle-lines="-1" treated as unlimited (wraps)', () => {
322
+ const { el, host } = mount<AdwActionRow>('adw-action-row');
323
+ el.setAttribute('subtitle', 'Subtitle that is long enough to wrap if allowed');
324
+ el.setAttribute('subtitle-lines', '-1');
325
+ host.style.width = '150px';
326
+ const lines = renderedLineCount(el.querySelector('.adw-row-subtitle')!);
327
+ expect(lines).toBeGreaterThan(1);
328
+ host.remove();
329
+ });
330
+ });
331
+
199
332
  await describe('<adw-switch-row> notify::active (libadwaita conformance vectors)', async () => {
200
333
  for (const vector of SWITCH_ROW_NOTIFY_VECTORS) {
201
334
  await it(`${vector.name} — ${vector.rule}`, () => {
@@ -244,6 +377,76 @@ export const AdwActionRowsTest = async () => {
244
377
  host.remove();
245
378
  });
246
379
  }
380
+
381
+ // title-lines / subtitle-lines tests (same behavior as adw-action-row)
382
+ await it('title-lines=0 (default) wraps to multiple lines in narrow container', () => {
383
+ const { el, host } = mount<AdwSwitchRow>('adw-switch-row');
384
+ el.setAttribute(
385
+ 'title',
386
+ 'A very long title that should wrap across multiple lines when the container is narrow',
387
+ );
388
+ host.style.width = '200px';
389
+ const lines = renderedLineCount(el.querySelector('.adw-row-title')!);
390
+ expect(lines).toBeGreaterThan(1);
391
+ host.remove();
392
+ });
393
+
394
+ await it('subtitle-lines=0 (default) wraps to multiple lines in narrow container', () => {
395
+ const { el, host } = mount<AdwSwitchRow>('adw-switch-row');
396
+ el.setAttribute(
397
+ 'subtitle',
398
+ 'A very long subtitle that should wrap across multiple lines when the container is narrow',
399
+ );
400
+ host.style.width = '200px';
401
+ const lines = renderedLineCount(el.querySelector('.adw-row-subtitle')!);
402
+ expect(lines).toBeGreaterThan(1);
403
+ host.remove();
404
+ });
405
+
406
+ await it('title-lines=1 clamps to exactly 1 line', () => {
407
+ const { el, host } = mount<AdwSwitchRow>('adw-switch-row');
408
+ el.setAttribute('title', 'Title that is long enough to wrap if allowed');
409
+ el.setAttribute('title-lines', '1');
410
+ host.style.width = '150px';
411
+ const lines = renderedLineCount(el.querySelector('.adw-row-title')!);
412
+ expect(lines).toBe(1);
413
+ host.remove();
414
+ });
415
+
416
+ await it('subtitle-lines=2 clamps to exactly 2 lines', () => {
417
+ const { el, host } = mount<AdwSwitchRow>('adw-switch-row');
418
+ el.setAttribute('subtitle', 'Subtitle that is long enough to wrap into three lines if allowed');
419
+ el.setAttribute('subtitle-lines', '2');
420
+ host.style.width = '150px';
421
+ const lines = renderedLineCount(el.querySelector('.adw-row-subtitle')!);
422
+ expect(lines).toBe(2);
423
+ host.remove();
424
+ });
425
+
426
+ await it('changing title-lines from 0 to 1 applies clamping visually', () => {
427
+ const { el, host } = mount<AdwSwitchRow>('adw-switch-row');
428
+ el.setAttribute('title', 'Title that is long enough to wrap if allowed');
429
+ el.setAttribute('title-lines', '0');
430
+ host.style.width = '150px';
431
+ let lines = renderedLineCount(el.querySelector('.adw-row-title')!);
432
+ expect(lines).toBeGreaterThan(1);
433
+
434
+ el.setAttribute('title-lines', '1');
435
+ el.getBoundingClientRect();
436
+ lines = renderedLineCount(el.querySelector('.adw-row-title')!);
437
+ expect(lines).toBe(1);
438
+ host.remove();
439
+ });
440
+
441
+ await it('title-lines="invalid" treated as unlimited (wraps)', () => {
442
+ const { el, host } = mount<AdwSwitchRow>('adw-switch-row');
443
+ el.setAttribute('title', 'Title that is long enough to wrap if allowed');
444
+ el.setAttribute('title-lines', 'invalid');
445
+ host.style.width = '150px';
446
+ const lines = renderedLineCount(el.querySelector('.adw-row-title')!);
447
+ expect(lines).toBeGreaterThan(1);
448
+ host.remove();
449
+ });
247
450
  });
248
451
 
249
452
  await describe('<adw-switch-row> prefix slot (inherited add_prefix)', async () => {
@@ -419,4 +622,56 @@ export const AdwActionRowsTest = async () => {
419
622
  host.remove();
420
623
  });
421
624
  });
625
+
626
+ // Line-clamping tests for other row types that inherit title-lines/subtitle-lines
627
+ await describe('<adw-combo-row> title-lines / subtitle-lines', async () => {
628
+ await it('title-lines=0 wraps, title-lines=1 clamps to 1 line', () => {
629
+ const { el, host } = mount<AdwComboRow>('adw-combo-row');
630
+ el.setAttribute('title', 'Title that is long enough to wrap if allowed');
631
+ el.setAttribute('title-lines', '0');
632
+ host.style.width = '150px';
633
+ let lines = renderedLineCount(el.querySelector('.adw-row-title')!);
634
+ expect(lines).toBeGreaterThan(1);
635
+
636
+ el.setAttribute('title-lines', '1');
637
+ el.getBoundingClientRect();
638
+ lines = renderedLineCount(el.querySelector('.adw-row-title')!);
639
+ expect(lines).toBe(1);
640
+ host.remove();
641
+ });
642
+ });
643
+
644
+ await describe('<adw-spin-row> title-lines / subtitle-lines', async () => {
645
+ await it('title-lines=0 wraps, title-lines=1 clamps to 1 line', () => {
646
+ const { el, host } = mount<AdwSpinRow>('adw-spin-row');
647
+ el.setAttribute('title', 'Title that is long enough to wrap if allowed');
648
+ el.setAttribute('title-lines', '0');
649
+ host.style.width = '150px';
650
+ let lines = renderedLineCount(el.querySelector('.adw-row-title')!);
651
+ expect(lines).toBeGreaterThan(1);
652
+
653
+ el.setAttribute('title-lines', '1');
654
+ el.getBoundingClientRect();
655
+ lines = renderedLineCount(el.querySelector('.adw-row-title')!);
656
+ expect(lines).toBe(1);
657
+ host.remove();
658
+ });
659
+ });
660
+
661
+ await describe('<adw-expander-row> title-lines / subtitle-lines', async () => {
662
+ await it('title-lines=0 wraps, title-lines=1 clamps to 1 line', () => {
663
+ const { el, host } = mount<AdwExpanderRow>('adw-expander-row');
664
+ el.setAttribute('title', 'Title that is long enough to wrap if allowed');
665
+ el.setAttribute('title-lines', '0');
666
+ host.style.width = '150px';
667
+ let lines = renderedLineCount(el.querySelector('.adw-row-title')!);
668
+ expect(lines).toBeGreaterThan(1);
669
+
670
+ el.setAttribute('title-lines', '1');
671
+ el.getBoundingClientRect();
672
+ lines = renderedLineCount(el.querySelector('.adw-row-title')!);
673
+ expect(lines).toBe(1);
674
+ host.remove();
675
+ });
676
+ });
422
677
  };
@@ -124,6 +124,24 @@ export const AdwHeaderBarTest = async () => {
124
124
  expect(centerText(bar)).toBe('example.org');
125
125
  unmountAll();
126
126
  });
127
+
128
+ // `update_title` (adw-header-bar.c:475) ends at the root window's title.
129
+ await it("a bar with no title of its own shows its window's, and follows it", async () => {
130
+ const window = document.createElement('adw-application-window');
131
+ window.setAttribute('title', 'Fireworks');
132
+ const bar = document.createElement('adw-header-bar');
133
+ window.appendChild(bar);
134
+ document.body.appendChild(window);
135
+ expect(centerText(bar)).toBe('Fireworks');
136
+
137
+ window.setAttribute('title', 'Sparks');
138
+ await Promise.resolve();
139
+ expect(centerText(bar)).toBe('Sparks');
140
+
141
+ bar.setAttribute('title', 'Own');
142
+ expect(centerText(bar)).toBe('Own');
143
+ window.remove();
144
+ });
127
145
  });
128
146
 
129
147
  // The centre is an `<adw-window-title>` — this port's divergence, not the bare