@gjsify/adwaita-web 0.55.0 → 0.57.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 (127) hide show
  1. package/README.md +8 -0
  2. package/dist/adwaita-web.css +137 -29
  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/application.d.ts +24 -0
  6. package/lib/types/application.spec.d.ts +1 -0
  7. package/lib/types/attributes.d.ts +19 -0
  8. package/lib/types/elements/adw-action-row.d.ts +4 -0
  9. package/lib/types/elements/adw-application-window.d.ts +2 -0
  10. package/lib/types/elements/adw-combo-row.d.ts +4 -0
  11. package/lib/types/elements/adw-entry-row.d.ts +6 -0
  12. package/lib/types/elements/adw-expander-row.d.ts +2 -0
  13. package/lib/types/elements/adw-header-bar.d.ts +6 -0
  14. package/lib/types/elements/adw-overlay-split-view.d.ts +13 -0
  15. package/lib/types/elements/adw-sidebar.d.ts +3 -2
  16. package/lib/types/elements/adw-spin-row.d.ts +6 -0
  17. package/lib/types/elements/adw-switch-row.d.ts +4 -0
  18. package/lib/types/elements/adw-toggle-group.d.ts +17 -0
  19. package/lib/types/elements/adw-view-stack.d.ts +3 -0
  20. package/lib/types/elements/checks.d.ts +6 -0
  21. package/lib/types/elements/gtk-application-window.d.ts +2 -0
  22. package/lib/types/elements/gtk-drop-down.d.ts +3 -0
  23. package/lib/types/elements/gtk-entry.d.ts +10 -0
  24. package/lib/types/elements/gtk-menu-button.d.ts +2 -0
  25. package/lib/types/elements/gtk-scrolled-window.d.ts +26 -2
  26. package/lib/types/elements/gtk-stack.d.ts +3 -0
  27. package/lib/types/elements/gtk-switch.d.ts +3 -0
  28. package/lib/types/elements/gtk-window.d.ts +8 -2
  29. package/lib/types/glib.spec.d.ts +1 -0
  30. package/lib/types/gobject-door.d.ts +4 -0
  31. package/lib/types/gobject-door.spec.d.ts +1 -0
  32. package/lib/types/gobject-elements.d.ts +17 -0
  33. package/lib/types/gtksource/gtk-source-view.d.ts +77 -0
  34. package/lib/types/gtksource/gtk-source-view.spec.d.ts +2 -0
  35. package/lib/types/gtksource/web-editor-driver.d.ts +44 -0
  36. package/lib/types/index.d.ts +4 -1
  37. package/lib/types/menu-actions.spec.d.ts +1 -0
  38. package/lib/types/namespace/adw.d.ts +1 -0
  39. package/lib/types/namespace/gio.d.ts +2 -0
  40. package/lib/types/namespace/glib.d.ts +10 -0
  41. package/lib/types/namespace/gobject.d.ts +27 -0
  42. package/lib/types/namespace/gtk-enums.d.ts +22 -0
  43. package/lib/types/namespace/gtk.d.ts +2 -0
  44. package/lib/types/namespace/gtksource.d.ts +2 -0
  45. package/lib/types/row-line-clamp.d.ts +20 -0
  46. package/lib/types/row-tooltip.d.ts +8 -0
  47. package/lib/types/row-tooltip.spec.d.ts +1 -0
  48. package/lib/types/shared-tree-builder.d.ts +69 -20
  49. package/lib/types/shared-tree-mount.spec.d.ts +1 -0
  50. package/lib/types/styles.generated.d.ts +1 -1
  51. package/lib/types/widget-size.spec.d.ts +1 -0
  52. package/package.json +11 -9
  53. package/scss/_action_row.scss +39 -15
  54. package/scss/_button_row.scss +5 -0
  55. package/scss/_expander_row.scss +36 -3
  56. package/scss/_headerbar.scss +2 -1
  57. package/scss/_preferences_row.scss +9 -0
  58. package/scss/_reset.scss +1 -1
  59. package/scss/_row.scss +16 -0
  60. package/scss/_status_page.scss +1 -1
  61. package/scss/_toggle_group.scss +28 -0
  62. package/scss/_widget.scss +14 -3
  63. package/scss/_window.scss +13 -1
  64. package/src/adw-action-rows.spec.ts +255 -0
  65. package/src/adw-header-bar.spec.ts +18 -0
  66. package/src/adw-status-page.spec.ts +124 -0
  67. package/src/application.spec.ts +75 -0
  68. package/src/application.ts +92 -0
  69. package/src/attributes.ts +52 -0
  70. package/src/capabilities.mjs +3 -0
  71. package/src/construct-vectors.spec.ts +156 -1
  72. package/src/elements/adw-action-row.ts +31 -2
  73. package/src/elements/adw-alert-dialog.ts +4 -1
  74. package/src/elements/adw-application-window.ts +5 -0
  75. package/src/elements/adw-button-row.ts +7 -1
  76. package/src/elements/adw-combo-row.ts +26 -1
  77. package/src/elements/adw-entry-row.ts +22 -12
  78. package/src/elements/adw-expander-row.ts +34 -1
  79. package/src/elements/adw-header-bar.ts +18 -4
  80. package/src/elements/adw-overlay-split-view.ts +29 -6
  81. package/src/elements/adw-preferences-row.ts +5 -2
  82. package/src/elements/adw-sidebar.ts +33 -5
  83. package/src/elements/adw-spin-row.ts +37 -2
  84. package/src/elements/adw-split-button.ts +5 -1
  85. package/src/elements/adw-status-page.ts +10 -2
  86. package/src/elements/adw-switch-row.ts +25 -1
  87. package/src/elements/adw-toggle-group.ts +119 -10
  88. package/src/elements/adw-view-stack.ts +14 -0
  89. package/src/elements/adw-window.ts +9 -4
  90. package/src/elements/checks.ts +25 -1
  91. package/src/elements/gtk-application-window.ts +5 -0
  92. package/src/elements/gtk-drop-down.ts +16 -0
  93. package/src/elements/gtk-entry.ts +45 -7
  94. package/src/elements/gtk-menu-button.ts +12 -1
  95. package/src/elements/gtk-popover-menu.ts +7 -2
  96. package/src/elements/gtk-scrolled-window.ts +76 -7
  97. package/src/elements/gtk-stack.ts +8 -0
  98. package/src/elements/gtk-switch.ts +8 -0
  99. package/src/elements/gtk-window.ts +9 -3
  100. package/src/glib.spec.ts +34 -0
  101. package/src/gobject-door.spec.ts +119 -0
  102. package/src/gobject-door.ts +137 -0
  103. package/src/gobject-elements.ts +172 -0
  104. package/src/gtk-window.spec.ts +12 -0
  105. package/src/gtksource/gtk-source-view.spec.ts +69 -0
  106. package/src/gtksource/gtk-source-view.ts +379 -0
  107. package/src/gtksource/web-editor-driver.ts +260 -0
  108. package/src/index.ts +11 -1
  109. package/src/keyboard-operable.spec.ts +47 -11
  110. package/src/menu-actions.spec.ts +47 -0
  111. package/src/namespace/adw.ts +1 -0
  112. package/src/namespace/gio.ts +2 -0
  113. package/src/namespace/glib.ts +22 -0
  114. package/src/namespace/gobject.ts +37 -0
  115. package/src/namespace/gtk-enums.ts +15 -0
  116. package/src/namespace/gtk.ts +2 -0
  117. package/src/namespace/gtksource.ts +9 -0
  118. package/src/row-line-clamp.ts +74 -0
  119. package/src/row-tooltip.spec.ts +313 -0
  120. package/src/row-tooltip.ts +52 -0
  121. package/src/shared-tree-builder.ts +230 -42
  122. package/src/shared-tree-mount.spec.ts +130 -0
  123. package/src/shared-trees.spec.ts +15 -0
  124. package/src/split-button.spec.ts +2 -0
  125. package/src/styles.generated.ts +1 -1
  126. package/src/test.browser.mts +18 -0
  127. package/src/widget-size.spec.ts +117 -0
@@ -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
@@ -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,75 @@
1
+ // ADR 0098: the web port of `Gtk.Application`, held to the shared vectors (real GJS is their oracle,
2
+ // in adwaita-core's `application.gjs.spec.ts`) through real custom elements.
3
+ import {
4
+ APPLICATION_VECTORS,
5
+ driveApplicationVectors,
6
+ unprovenVfuncs,
7
+ type ApplicationScene,
8
+ type ApplicationSubject,
9
+ } from '@gjsify/adwaita-core/conformance';
10
+ import { describe, expect, it } from '@gjsify/unit';
11
+
12
+ import { activateWidgetAction } from './application.js';
13
+ import { Adw, Gio, GObject, Gtk } from './index.js';
14
+ import type { GtkMenuButton } from './elements/gtk-menu-button.js';
15
+
16
+ const place = <T extends Element>(parent: Element, child: T): T => {
17
+ parent.append(child);
18
+ return child;
19
+ };
20
+
21
+ const made: Element[] = [];
22
+
23
+ const scene: ApplicationScene = {
24
+ window(app) {
25
+ const window = document.createElement('adw-application-window') as Adw.ApplicationWindow;
26
+ window.application = app;
27
+ document.body.append(window);
28
+ made.push(window);
29
+ return window;
30
+ },
31
+ buttonIn: (window) => place(window as Element, document.createElement('gtk-button')),
32
+ popoverButtonIn(window) {
33
+ const menuButton = place(window as Element, document.createElement('gtk-menu-button')) as GtkMenuButton;
34
+ const popover = menuButton.querySelector('gtk-popover') as Element;
35
+ return place(popover, document.createElement('gtk-button'));
36
+ },
37
+ loose: () => document.createElement('gtk-button'),
38
+ activate: (widget, name) => activateWidgetAction(widget as Element, name),
39
+ addWindowAction(window, action) {
40
+ (window as unknown as { add_action(a: object): void }).add_action(action);
41
+ },
42
+ };
43
+
44
+ const subject: ApplicationSubject = {
45
+ name: 'adwaita-web',
46
+ isOracle: false,
47
+ GObject: GObject as unknown as ApplicationSubject['GObject'],
48
+ Application: Gtk.Application as unknown as ApplicationSubject['Application'],
49
+ SimpleAction: Gio.SimpleAction as unknown as ApplicationSubject['SimpleAction'],
50
+ handlesOpen: Gio.ApplicationFlags.HANDLES_OPEN,
51
+ scene,
52
+ };
53
+
54
+ export const ApplicationTest = async () => {
55
+ await driveApplicationVectors(subject, { describe, it, expect });
56
+ // The page is shared with every other suite: a window left in it moves their layout.
57
+ for (const element of made.splice(0)) element.remove();
58
+
59
+ await describe('adwaita-web: Application (ADR 0098 § 3)', async () => {
60
+ await it('has a vector for every unlocked vfunc', () => {
61
+ expect(unprovenVfuncs(APPLICATION_VECTORS).length).toBe(0);
62
+ });
63
+ await it('Adw.Application is a Gtk.Application', () => {
64
+ expect(Adw.Application.prototype instanceof Gtk.Application).toBe(true);
65
+ });
66
+ await it('quit() takes the windows it added off the page', async () => {
67
+ const app = new Adw.Application({ application_id: 'org.gjsify.vec.WebQuit' });
68
+ const window = scene.window(app as never) as Element;
69
+ app.connect('activate', () => app.quit());
70
+ await app.runAsync([]);
71
+ expect(window.isConnected).toBe(false);
72
+ made.length = 0;
73
+ });
74
+ });
75
+ };
@@ -0,0 +1,92 @@
1
+ // `Gio.Application`, `Gtk.Application` and `Adw.Application` on the browser (ADR 0098 §§ 1, 3).
2
+ //
3
+ // The behaviour is the core's (`@gjsify/adwaita-core`); this file is the HOST: when the platform is
4
+ // ready (`DOMContentLoaded`), how a window leaves it, and the walk that resolves `app.` / `win.`
5
+ // from an element. A web page has no process, so `quit()` removes the windows the application
6
+ // added and the tab stays.
7
+
8
+ import {
9
+ GioApplicationBase,
10
+ withGtkApplication,
11
+ activateWidgetAction as activateIn,
12
+ activateMenuItem,
13
+ applicationOfWindow,
14
+ menuActionsFor,
15
+ installActionMap,
16
+ type AdwMenuItem,
17
+ type AdwMenuActions,
18
+ type AdwMenuModel,
19
+ type ApplicationHost,
20
+ } from '@gjsify/adwaita-core';
21
+
22
+ export const webHost: ApplicationHost = {
23
+ name: 'adwaita-web',
24
+ requiresWindow: false,
25
+ start(body) {
26
+ return new Promise<void>((resolve) => {
27
+ const go = (): void => {
28
+ body();
29
+ resolve();
30
+ };
31
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', go, { once: true });
32
+ else go();
33
+ });
34
+ },
35
+ detach(window) {
36
+ (window as Element).remove();
37
+ },
38
+ exit() {},
39
+ };
40
+
41
+ /** An element's parent for action resolution; a popover is rooted at the widget that owns it. */
42
+ export function parentOfElement(node: object): object | null {
43
+ if (!(node instanceof Element)) return null;
44
+ const anchor = (node as { anchor?: unknown }).anchor;
45
+ return anchor instanceof Element ? anchor : node.parentElement;
46
+ }
47
+
48
+ /** What a `Gtk.Actionable` does on activation: the action `prefix.name` found from `widget`. */
49
+ export function activateWidgetAction(widget: Element, fullName: string, parameter: unknown = null): boolean {
50
+ return activateIn(widget, fullName, parentOfElement, parameter);
51
+ }
52
+
53
+ /** What the items of `model` read from the actions registered above `el` (ADR 0097 § 3). */
54
+ export function menuActionsOfElement(el: Element, model: AdwMenuModel): AdwMenuActions {
55
+ return menuActionsFor(model, el, parentOfElement);
56
+ }
57
+
58
+ /** What choosing `item` in a menu owned by `el` does: the action it names fires. */
59
+ export function activateElementMenuItem(el: Element, item: AdwMenuItem): boolean {
60
+ return activateMenuItem(item, el, parentOfElement);
61
+ }
62
+
63
+ export class GioApplication extends GioApplicationBase {
64
+ static override host = webHost;
65
+ }
66
+
67
+ export class GtkApplication extends withGtkApplication(GioApplication) {}
68
+
69
+ export class AdwApplication extends GtkApplication {}
70
+
71
+ type WindowApplication = { add_window(window: object): void; remove_window(window: object): void };
72
+
73
+ /**
74
+ * `Gtk.Window:application` and the window's `Gio.ActionMap` (`win.` actions) on an element class.
75
+ * Setting the property is `add_window`, as on GTK; setting it to `null` is `remove_window`.
76
+ */
77
+ export function defineWindowApplication(proto: object): void {
78
+ installActionMap(proto);
79
+ Object.defineProperty(proto, 'application', {
80
+ configurable: true,
81
+ enumerable: true,
82
+ get(this: object) {
83
+ return applicationOfWindow(this);
84
+ },
85
+ set(this: object, value: WindowApplication | null) {
86
+ const current = applicationOfWindow(this) as unknown as WindowApplication | null;
87
+ if (current === value) return;
88
+ current?.remove_window(this);
89
+ value?.add_window(this);
90
+ },
91
+ });
92
+ }
@@ -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
  }
@@ -138,6 +138,161 @@ export const AdwConstructVectorsTest = async () => {
138
138
  'plain handlers only',
139
139
  );
140
140
  });
141
+ await it('runs a CheckButton `toggled` handler on a click and on a programmatic change', () => {
142
+ let calls = 0;
143
+ const { root, unmount } = mountSharedTree(
144
+ {
145
+ tag: 'GtkBox',
146
+ children: [
147
+ { tag: 'GtkCheckButton', id: 'box', signals: [{ name: 'toggled', handler: 'onToggled' }] },
148
+ ],
149
+ },
150
+ { scope: { onToggled: () => calls++ } },
151
+ );
152
+ try {
153
+ const box = root.querySelector('#box') as unknown as HTMLElement & { active: boolean };
154
+ box.click();
155
+ expect(box.active).toBe(true);
156
+ box.active = false;
157
+ expect(calls).toBe(2);
158
+ } finally {
159
+ unmount();
160
+ }
161
+ });
162
+ await it('makes a bind follow a CheckButton `active`', () => {
163
+ const { root, unmount } = mountSharedTree({
164
+ tag: 'GtkBox',
165
+ children: [
166
+ { tag: 'GtkCheckButton', id: 'box' },
167
+ {
168
+ tag: 'GtkToggleButton',
169
+ id: 'target',
170
+ bindings: { active: { source: 'box', property: 'active' } },
171
+ },
172
+ ],
173
+ });
174
+ try {
175
+ const target = root.querySelector('#target') as unknown as { active: boolean };
176
+ expect(target.active).toBe(false);
177
+ (root.querySelector('#box') as HTMLElement).click();
178
+ expect(target.active).toBe(true);
179
+ } finally {
180
+ unmount();
181
+ }
182
+ });
183
+ await it('makes a bind follow a Switch `active`', () => {
184
+ const { root, unmount } = mountSharedTree({
185
+ tag: 'GtkBox',
186
+ children: [
187
+ { tag: 'GtkSwitch', id: 'source' },
188
+ { tag: 'GtkSwitch', id: 'target', bindings: { active: { source: 'source', property: 'active' } } },
189
+ ],
190
+ });
191
+ try {
192
+ const target = root.querySelector('#target') as unknown as { active: boolean };
193
+ (root.querySelector('#source') as unknown as { active: boolean }).active = true;
194
+ expect(target.active).toBe(true);
195
+ } finally {
196
+ unmount();
197
+ }
198
+ });
199
+ await it('runs an Entry `changed` and `notify::text` handler on typing and on a programmatic set', () => {
200
+ const seen: string[] = [];
201
+ const { root, unmount } = mountSharedTree(
202
+ {
203
+ tag: 'GtkBox',
204
+ children: [
205
+ {
206
+ tag: 'GtkEntry',
207
+ id: 'entry',
208
+ signals: [
209
+ { name: 'changed', handler: 'onChanged' },
210
+ { name: 'notify::text', handler: 'onText' },
211
+ ],
212
+ },
213
+ ],
214
+ },
215
+ { scope: { onChanged: () => seen.push('changed'), onText: () => seen.push('notify::text') } },
216
+ );
217
+ try {
218
+ const entry = root.querySelector('#entry') as unknown as HTMLElement & {
219
+ text: string;
220
+ input: HTMLInputElement;
221
+ };
222
+ entry.input.value = 'typed';
223
+ entry.input.dispatchEvent(new Event('input'));
224
+ entry.text = 'set';
225
+ entry.text = 'set';
226
+ expect(seen.join()).toBe('changed,notify::text,changed,notify::text');
227
+ } finally {
228
+ unmount();
229
+ }
230
+ });
231
+ await it('dispatches ViewStack `notify::visible-child-name` when the visible page changes', () => {
232
+ const stack = document.createElement('adw-view-stack') as HTMLElement & {
233
+ addTitled(c: HTMLElement, name: string, title: string): unknown;
234
+ visibleChildName: string;
235
+ };
236
+ const names: string[] = [];
237
+ stack.addEventListener('notify::visible-child-name', (e) =>
238
+ names.push((e as CustomEvent).detail['visible-child-name']),
239
+ );
240
+ document.body.append(stack);
241
+ try {
242
+ stack.addTitled(document.createElement('div'), 'a', 'A');
243
+ stack.addTitled(document.createElement('div'), 'b', 'B');
244
+ stack.visibleChildName = 'b';
245
+ expect(names.join()).toBe('a,b');
246
+ } finally {
247
+ stack.remove();
248
+ }
249
+ });
250
+ await it('dispatches DropDown `notify::selected-item` after `notify::selected`, on a real change only', () => {
251
+ const dropDown = document.createElement('gtk-drop-down') as HTMLElement & {
252
+ model: string[];
253
+ selected: number;
254
+ };
255
+ const seen: string[] = [];
256
+ for (const name of ['notify::selected', 'notify::selected-item'])
257
+ dropDown.addEventListener(name, () => seen.push(name));
258
+ document.body.append(dropDown);
259
+ try {
260
+ dropDown.model = ['a', 'b'];
261
+ seen.length = 0;
262
+ dropDown.selected = 1;
263
+ dropDown.selected = 1;
264
+ expect(seen.join()).toBe('notify::selected,notify::selected-item');
265
+ } finally {
266
+ dropDown.remove();
267
+ }
268
+ });
269
+ await it('dispatches Stack `notify::visible-child` before `notify::visible-child-name`', () => {
270
+ const stack = document.createElement('gtk-stack') as HTMLElement & { visibleChildName: string | null };
271
+ const a = document.createElement('div');
272
+ a.setAttribute('name', 'a');
273
+ const b = document.createElement('div');
274
+ b.setAttribute('name', 'b');
275
+ stack.append(a, b);
276
+ document.body.append(stack);
277
+ try {
278
+ const seen: string[] = [];
279
+ for (const name of ['notify::visible-child', 'notify::visible-child-name'])
280
+ stack.addEventListener(name, () => seen.push(name));
281
+ stack.visibleChildName = 'b';
282
+ expect(seen.join()).toBe('notify::visible-child,notify::visible-child-name');
283
+ } finally {
284
+ stack.remove();
285
+ }
286
+ });
287
+ await it('declares the notify each of DropDown, Stack and Switch dispatches', () => {
288
+ const declared = (tag: string) =>
289
+ (customElements.get(tag) as unknown as { signals: Record<string, string> }).signals;
290
+ expect(declared('gtk-drop-down')['notify::selected']).toBe('notify::selected');
291
+ expect(declared('gtk-drop-down')['notify::selected-item']).toBe('notify::selected-item');
292
+ expect(declared('gtk-stack')['notify::visible-child']).toBe('notify::visible-child');
293
+ expect(declared('gtk-stack')['notify::visible-child-name']).toBe('notify::visible-child-name');
294
+ expect(declared('gtk-switch')['notify::active']).toBe('notify::active');
295
+ });
141
296
  await it('refuses a bind to an id nothing has, to a source that dispatches no notify, and the flags', () => {
142
297
  const bound = (binding: NonNullable<SharedTreeNode['bindings']>[string], sourceTag = 'GtkToggleButton') =>
143
298
  ({
@@ -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) {