@gjsify/adwaita-web 0.56.0 → 0.58.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 (75) hide show
  1. package/dist/adwaita-web.css +8 -2
  2. package/dist/adwaita-web.css.map +1 -1
  3. package/lib/types/application.d.ts +24 -0
  4. package/lib/types/application.spec.d.ts +1 -0
  5. package/lib/types/elements/adw-application-window.d.ts +2 -0
  6. package/lib/types/elements/adw-view-stack.d.ts +3 -0
  7. package/lib/types/elements/checks.d.ts +6 -0
  8. package/lib/types/elements/gtk-application-window.d.ts +2 -0
  9. package/lib/types/elements/gtk-drop-down.d.ts +3 -0
  10. package/lib/types/elements/gtk-entry.d.ts +10 -0
  11. package/lib/types/elements/gtk-menu-button.d.ts +2 -0
  12. package/lib/types/elements/gtk-scrolled-window.d.ts +16 -0
  13. package/lib/types/elements/gtk-stack.d.ts +3 -0
  14. package/lib/types/elements/gtk-switch.d.ts +3 -0
  15. package/lib/types/gio.spec.d.ts +1 -0
  16. package/lib/types/glib.spec.d.ts +1 -0
  17. package/lib/types/gobject-door.d.ts +4 -0
  18. package/lib/types/gobject-door.spec.d.ts +1 -0
  19. package/lib/types/gobject-elements.d.ts +17 -0
  20. package/lib/types/gtksource/gtk-source-view.d.ts +78 -0
  21. package/lib/types/gtksource/gtk-source-view.spec.d.ts +2 -0
  22. package/lib/types/gtksource/web-editor-driver.d.ts +53 -0
  23. package/lib/types/index.d.ts +3 -0
  24. package/lib/types/menu-actions.spec.d.ts +1 -0
  25. package/lib/types/namespace/adw.d.ts +2 -0
  26. package/lib/types/namespace/gio.d.ts +2 -0
  27. package/lib/types/namespace/glib.d.ts +15 -0
  28. package/lib/types/namespace/gobject.d.ts +28 -0
  29. package/lib/types/namespace/gtk-enums.d.ts +22 -0
  30. package/lib/types/namespace/gtk.d.ts +2 -0
  31. package/lib/types/namespace/gtksource.d.ts +2 -0
  32. package/lib/types/shared-tree-builder.d.ts +20 -1
  33. package/lib/types/style-manager.d.ts +5 -0
  34. package/lib/types/style-manager.spec.d.ts +1 -0
  35. package/lib/types/styles.generated.d.ts +1 -1
  36. package/package.json +11 -9
  37. package/scss/_reset.scss +1 -1
  38. package/src/application.spec.ts +75 -0
  39. package/src/application.ts +92 -0
  40. package/src/construct-vectors.spec.ts +155 -0
  41. package/src/elements/adw-application-window.ts +5 -0
  42. package/src/elements/adw-split-button.ts +5 -1
  43. package/src/elements/adw-view-stack.ts +14 -0
  44. package/src/elements/checks.ts +25 -1
  45. package/src/elements/gtk-application-window.ts +5 -0
  46. package/src/elements/gtk-drop-down.ts +16 -0
  47. package/src/elements/gtk-entry.ts +45 -7
  48. package/src/elements/gtk-menu-button.ts +12 -1
  49. package/src/elements/gtk-popover-menu.ts +7 -2
  50. package/src/elements/gtk-scrolled-window.ts +58 -0
  51. package/src/elements/gtk-stack.ts +8 -0
  52. package/src/elements/gtk-switch.ts +8 -0
  53. package/src/gio.spec.ts +23 -0
  54. package/src/glib.spec.ts +49 -0
  55. package/src/gobject-door.spec.ts +119 -0
  56. package/src/gobject-door.ts +137 -0
  57. package/src/gobject-elements.ts +172 -0
  58. package/src/gtksource/gtk-source-view.spec.ts +141 -0
  59. package/src/gtksource/gtk-source-view.ts +386 -0
  60. package/src/gtksource/web-editor-driver.ts +346 -0
  61. package/src/index.ts +10 -0
  62. package/src/menu-actions.spec.ts +47 -0
  63. package/src/namespace/adw.ts +2 -0
  64. package/src/namespace/gio.ts +3 -1
  65. package/src/namespace/glib.ts +37 -0
  66. package/src/namespace/gobject.ts +38 -0
  67. package/src/namespace/gtk-enums.ts +15 -0
  68. package/src/namespace/gtk.ts +2 -0
  69. package/src/namespace/gtksource.ts +20 -0
  70. package/src/shared-tree-builder.ts +142 -7
  71. package/src/split-button.spec.ts +2 -0
  72. package/src/style-manager.spec.ts +36 -0
  73. package/src/style-manager.ts +27 -0
  74. package/src/styles.generated.ts +1 -1
  75. package/src/test.browser.mts +14 -0
@@ -22,6 +22,8 @@
22
22
 
23
23
  import { ENTRY_ROW_MAX_LENGTH_LIMIT, clampEntryText, entryTextLength } from '@gjsify/adwaita-core';
24
24
 
25
+ import type { DispatchedSignals } from '../signals.js';
26
+
25
27
  export class GtkEntry extends HTMLElement {
26
28
  /**
27
29
  * `protected`, not `private`: `GtkPasswordEntry` and `GtkSearchEntry` subclass this
@@ -32,9 +34,18 @@ export class GtkEntry extends HTMLElement {
32
34
  protected _input!: HTMLInputElement;
33
35
  private _initialized = false;
34
36
  private _maxLength = 0;
37
+ /** The text last announced, so `changed` fires on a real change only — as `gtk_editable_changed` does. */
38
+ private _announced = '';
39
+
40
+ /** The GTK signals this element dispatches, each with the DOM event it arrives as (ADR 0093). */
41
+ static readonly signals: DispatchedSignals = {
42
+ changed: 'changed',
43
+ 'notify::text': 'notify::text',
44
+ activate: 'activate',
45
+ };
35
46
 
36
47
  static get observedAttributes() {
37
- return ['value', 'placeholder-text', 'placeholder', 'width-request', 'type', 'disabled', 'maxlength'];
48
+ return ['value', 'text', 'placeholder-text', 'placeholder', 'width-request', 'type', 'disabled', 'maxlength'];
38
49
  }
39
50
 
40
51
  get value(): string {
@@ -43,8 +54,28 @@ export class GtkEntry extends HTMLElement {
43
54
 
44
55
  set value(v: string) {
45
56
  const clamped = clampEntryText(v ?? '', this._maxLength);
46
- if (this._input) this._input.value = clamped;
47
- else this.setAttribute('value', clamped);
57
+ if (this._input) {
58
+ this._input.value = clamped;
59
+ this._announceText();
60
+ } else this.setAttribute('value', clamped);
61
+ }
62
+
63
+ /** `Gtk.Editable:text`, the GObject name for {@link value}. */
64
+ get text(): string {
65
+ return this.value;
66
+ }
67
+
68
+ set text(v: string) {
69
+ this.value = v;
70
+ }
71
+
72
+ /** `changed` then `notify::text`, once per real change of the text, typed or set from code. */
73
+ private _announceText(): void {
74
+ const text = this._input.value;
75
+ if (text === this._announced) return;
76
+ this._announced = text;
77
+ this.dispatchEvent(new CustomEvent('changed', { bubbles: true }));
78
+ this.dispatchEvent(new CustomEvent('notify::text', { bubbles: true, detail: { text } }));
48
79
  }
49
80
 
50
81
  /** `Gtk.Entry:max-length` — 0 means unlimited. Counted in CODE POINTS. */
@@ -56,7 +87,10 @@ export class GtkEntry extends HTMLElement {
56
87
  this._maxLength = Number.isFinite(value)
57
88
  ? Math.min(ENTRY_ROW_MAX_LENGTH_LIMIT, Math.max(0, Math.trunc(value)))
58
89
  : 0;
59
- if (this._input) this._input.value = clampEntryText(this._input.value, this._maxLength);
90
+ if (this._input) {
91
+ this._input.value = clampEntryText(this._input.value, this._maxLength);
92
+ this._announceText();
93
+ }
60
94
  }
61
95
 
62
96
  /** `Gtk.Entry:text-length` — code points, not UTF-16 units. */
@@ -92,7 +126,7 @@ export class GtkEntry extends HTMLElement {
92
126
  const input = document.createElement('input');
93
127
  input.className = 'adw-entry';
94
128
  input.type = this.getAttribute('type') || 'text';
95
- input.value = clampEntryText(this.getAttribute('value') ?? '', this._maxLength);
129
+ input.value = clampEntryText(this.getAttribute('value') ?? this.getAttribute('text') ?? '', this._maxLength);
96
130
  input.placeholder = this.placeholderAttribute();
97
131
  this.applyWidthRequest();
98
132
  input.disabled = this.hasAttribute('disabled');
@@ -101,6 +135,7 @@ export class GtkEntry extends HTMLElement {
101
135
  input.addEventListener('input', () => {
102
136
  const clamped = clampEntryText(input.value, this._maxLength);
103
137
  if (clamped !== input.value) input.value = clamped;
138
+ this._announceText();
104
139
  });
105
140
  input.addEventListener('keydown', (e) => {
106
141
  if (e.key === 'Enter') {
@@ -109,6 +144,7 @@ export class GtkEntry extends HTMLElement {
109
144
  });
110
145
 
111
146
  this._input = input;
147
+ this._announced = input.value;
112
148
  this.replaceChildren(input);
113
149
  }
114
150
 
@@ -129,8 +165,10 @@ export class GtkEntry extends HTMLElement {
129
165
  return;
130
166
  }
131
167
  if (!this._input) return;
132
- if (name === 'value') this._input.value = clampEntryText(value ?? '', this._maxLength);
133
- else if (name === 'placeholder-text' || name === 'placeholder')
168
+ if (name === 'value' || name === 'text') {
169
+ this._input.value = clampEntryText(value ?? '', this._maxLength);
170
+ this._announceText();
171
+ } else if (name === 'placeholder-text' || name === 'placeholder')
134
172
  this._input.placeholder = this.placeholderAttribute();
135
173
  else if (name === 'type') this._input.type = value || 'text';
136
174
  else if (name === 'disabled') this._input.disabled = value !== null;
@@ -34,6 +34,7 @@ import {
34
34
  normalizeMenuModel,
35
35
  parseMenuModel,
36
36
  } from '@gjsify/adwaita-core';
37
+ import { activateElementMenuItem, menuActionsOfElement } from '../application.js';
37
38
  import type { AdwMenuActions, AdwMenuInput, AdwMenuModel, SplitButtonDirection } from '@gjsify/adwaita-core';
38
39
 
39
40
  import { PopoverMenuView } from './popover-menu.js';
@@ -197,6 +198,7 @@ export class GtkMenuButton extends HTMLElement {
197
198
  if (item === null) return;
198
199
  this._popoverEl.popdown();
199
200
  this._buttonEl.focus();
201
+ activateElementMenuItem(this, item);
200
202
  this.dispatchEvent(
201
203
  new CustomEvent('menu-item-activated', {
202
204
  bubbles: true,
@@ -246,7 +248,10 @@ export class GtkMenuButton extends HTMLElement {
246
248
  private _onPopoverToggled(open: boolean): void {
247
249
  this.classList.toggle('active', open);
248
250
  this._buttonEl.setAttribute('aria-expanded', String(open));
249
- if (open) this._menuView.focusableRows[0]?.focus();
251
+ if (open) {
252
+ this._refreshActions();
253
+ this._menuView.focusableRows[0]?.focus();
254
+ }
250
255
  // A dismissal returns the popup to its top page: a menu that reopens inside a
251
256
  // submenu is one the reader cannot get out of.
252
257
  else this._menuView.reset();
@@ -270,6 +275,12 @@ export class GtkMenuButton extends HTMLElement {
270
275
  this._renderMenu();
271
276
  }
272
277
 
278
+ /** The registry's answer, unless the application wrote `actions` itself (ADR 0097 § 2). */
279
+ private _refreshActions(): void {
280
+ this._menuView.setActions(this._actions ?? menuActionsOfElement(this, this._model));
281
+ this._menuView.render();
282
+ }
283
+
273
284
  private _renderMenu(): void {
274
285
  this._menuView.setMenuModel(this._model);
275
286
  this._menuView.setTitle(this.getAttribute('menu-title') ?? this._menuTitle);
@@ -81,6 +81,7 @@ import {
81
81
  normalizeMenuModel,
82
82
  parseMenuModel,
83
83
  } from '@gjsify/adwaita-core';
84
+ import { activateElementMenuItem, menuActionsOfElement } from '../application.js';
84
85
  import type { AdwMenuActions, AdwMenuInput, AdwMenuModel, AdwMenuPath } from '@gjsify/adwaita-core';
85
86
 
86
87
  import { GtkPopover } from './gtk-popover.js';
@@ -187,8 +188,11 @@ export class GtkPopoverMenu extends GtkPopover {
187
188
  // with `gtk_popover_menu_set_open_submenu (widget, NULL)` (gtkpopovermenu.c:573-579):
188
189
  // a menu that reopens three levels deep is one the reader cannot get out of.
189
190
  this.subscribe((open) => {
190
- if (open) this._menuView.focusableRows[0]?.focus();
191
- else this._menuView.reset();
191
+ if (open) {
192
+ this._menuView.setActions(this._actions ?? menuActionsOfElement(this, this._model));
193
+ this._menuView.render();
194
+ this._menuView.focusableRows[0]?.focus();
195
+ } else this._menuView.reset();
192
196
  });
193
197
  }
194
198
 
@@ -308,6 +312,7 @@ export class GtkPopoverMenu extends GtkPopover {
308
312
  this.popdown();
309
313
  this.anchor?.focus();
310
314
  if (target === null) return;
315
+ activateElementMenuItem(this, target);
311
316
  this.dispatchEvent(
312
317
  new CustomEvent('menu-item-activated', {
313
318
  bubbles: true,
@@ -86,6 +86,7 @@
86
86
  // Copyright (c) The GTK Team. LGPLv2.1+.
87
87
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
88
88
 
89
+ import { GtkAdjustment } from '@gjsify/adwaita-core';
89
90
  import type { AdwAdjustment } from '@gjsify/adwaita-core';
90
91
 
91
92
  import { AdwScrollShading } from '../scroll-shading.js';
@@ -112,6 +113,8 @@ export type GtkPositionType = 'left' | 'right' | 'top' | 'bottom';
112
113
  export type GtkDirectionType = 'forward' | 'backward';
113
114
 
114
115
  const POLICIES: readonly GtkPolicyType[] = ['always', 'never', 'automatic', 'external'];
116
+ /** The nicks in `Gtk.PolicyType`'s numeric order, which is not the order above. */
117
+ const POLICY_BY_VALUE: readonly GtkPolicyType[] = ['always', 'automatic', 'never', 'external'];
115
118
  const CORNERS: readonly GtkCornerType[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
116
119
 
117
120
  /** An unknown nick is the pspec's default, which is what GtkBuilder's rejection leaves behind. */
@@ -205,6 +208,9 @@ export class GtkScrolledWindow extends HTMLElement {
205
208
  private _hoverable: MediaQueryList | null = null;
206
209
  private _fadeTimer: ReturnType<typeof setTimeout> | null = null;
207
210
  private _built = false;
211
+ /** What an unbuilt window answers before it has a viewport to read. */
212
+ private readonly _standIn: { hadjustment?: AdwAdjustment; vadjustment?: AdwAdjustment } = {};
213
+ private readonly _adopted: { hadjustment?: AdwAdjustment; vadjustment?: AdwAdjustment } = {};
208
214
 
209
215
  static get observedAttributes() {
210
216
  return [
@@ -240,6 +246,58 @@ export class GtkScrolledWindow extends HTMLElement {
240
246
  this.setAttribute('vscrollbar-policy', value);
241
247
  }
242
248
 
249
+ /** `gtk_scrolled_window_get_policy`: `[hscrollbar, vscrollbar]` as `Gtk.PolicyType` constants. */
250
+ get_policy(): [number, number] {
251
+ return [POLICY_BY_VALUE.indexOf(this.hscrollbarPolicy), POLICY_BY_VALUE.indexOf(this.vscrollbarPolicy)];
252
+ }
253
+
254
+ /** `gtk_scrolled_window_set_policy`; a value that is not a `Gtk.PolicyType` throws, as GJS's enum marshalling does. */
255
+ set_policy(hscrollbar_policy: number, vscrollbar_policy: number): void {
256
+ const nick = (value: number, name: string): GtkPolicyType => {
257
+ const found = Number.isInteger(value) ? POLICY_BY_VALUE[value] : undefined;
258
+ if (found === undefined) throw new TypeError(`${value} is not a valid value for enum argument ${name}`);
259
+ return found;
260
+ };
261
+ const h = nick(hscrollbar_policy, 'hscrollbar_policy');
262
+ const v = nick(vscrollbar_policy, 'vscrollbar_policy');
263
+ this.hscrollbarPolicy = h;
264
+ this.vscrollbarPolicy = v;
265
+ }
266
+
267
+ /** `GtkScrollable:hadjustment` as the window reports it: the one it was given, else the viewport's. */
268
+ get hadjustment(): AdwAdjustment {
269
+ return (
270
+ this._adopted.hadjustment ??
271
+ (this._viewport as GtkViewport | undefined)?.hadjustment ??
272
+ (this._standIn.hadjustment ??= new GtkAdjustment())
273
+ );
274
+ }
275
+
276
+ get vadjustment(): AdwAdjustment {
277
+ return (
278
+ this._adopted.vadjustment ??
279
+ (this._viewport as GtkViewport | undefined)?.vadjustment ??
280
+ (this._standIn.vadjustment ??= new GtkAdjustment())
281
+ );
282
+ }
283
+
284
+ get_hadjustment(): AdwAdjustment {
285
+ return this.hadjustment;
286
+ }
287
+
288
+ get_vadjustment(): AdwAdjustment {
289
+ return this.vadjustment;
290
+ }
291
+
292
+ /** Adopts `adjustment` as what `get_hadjustment` answers; the scroll position keeps following the viewport. */
293
+ set_hadjustment(adjustment: AdwAdjustment): void {
294
+ this._adopted.hadjustment = adjustment;
295
+ }
296
+
297
+ set_vadjustment(adjustment: AdwAdjustment): void {
298
+ this._adopted.vadjustment = adjustment;
299
+ }
300
+
243
301
  /** `GtkScrolledWindow:window-placement` — where the contents sit against the bars. */
244
302
  get windowPlacement(): GtkCornerType {
245
303
  return normalizeCorner(this.getAttribute('window-placement'));
@@ -91,6 +91,8 @@
91
91
  // Copyright (c) The GTK Team. LGPLv2.1+.
92
92
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
93
93
 
94
+ import type { DispatchedSignals } from '../signals.js';
95
+
94
96
  /** `GtkStackTransitionType`, spelled as the GIR's enum nicks (gtkstack.c:95-121). */
95
97
  export type StackTransitionType =
96
98
  | 'none'
@@ -224,6 +226,12 @@ export interface GtkStackPageInfo {
224
226
  const PAGE_ATTRIBUTES = ['name', 'title', 'icon-name', 'use-underline', 'needs-attention'];
225
227
 
226
228
  export class GtkStack extends HTMLElement {
229
+ /** The GTK signals this element dispatches, each with the DOM event it arrives as (ADR 0093). */
230
+ static readonly signals: DispatchedSignals = {
231
+ 'notify::visible-child': 'notify::visible-child',
232
+ 'notify::visible-child-name': 'notify::visible-child-name',
233
+ };
234
+
227
235
  /** The child that is visible, or `null` — C's `priv->visible_child`. */
228
236
  private _visible: HTMLElement | null = null;
229
237
  /** C's `priv->last_visible_child`: the page being animated away. */
@@ -39,7 +39,15 @@
39
39
  // Copyright (c) 2025 csm (adwaita-web). MIT License.
40
40
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
41
41
 
42
+ import type { DispatchedSignals } from '../signals.js';
43
+
42
44
  export class GtkSwitch extends HTMLElement {
45
+ /** The GTK signals this element dispatches, each with the DOM event it arrives as (ADR 0093). */
46
+ static readonly signals: DispatchedSignals = {
47
+ 'notify::active': 'notify::active',
48
+ 'notify::state': 'notify::state',
49
+ };
50
+
43
51
  private _input!: HTMLInputElement;
44
52
  private _slider!: HTMLSpanElement;
45
53
  private _initialized = false;
@@ -0,0 +1,23 @@
1
+ // The `Gio.SimpleActionGroup` door of adwaita-web, held to the shared vectors (real GJS is their
2
+ // oracle, in adwaita-core's `simple-action-group.gjs.spec.ts`). The door is the namespace module itself.
3
+
4
+ import {
5
+ SIMPLE_ACTION_GROUP_VECTORS,
6
+ driveSimpleActionGroupVectors,
7
+ type GioActionsLike,
8
+ } from '@gjsify/adwaita-core/conformance';
9
+ import { describe, expect, it } from '@gjsify/unit';
10
+
11
+ import * as Gio from './namespace/gio.js';
12
+
13
+ export const GioActionsTest = async () => {
14
+ await driveSimpleActionGroupVectors(
15
+ { name: 'adwaita-web', isOracle: false, Gio: Gio as unknown as GioActionsLike },
16
+ { describe, it, expect },
17
+ );
18
+ await describe('adwaita-web: Gio door', async () => {
19
+ await it('has a vector row for every vector', () => {
20
+ expect(SIMPLE_ACTION_GROUP_VECTORS.length > 0).toBe(true);
21
+ });
22
+ });
23
+ };
@@ -0,0 +1,49 @@
1
+ // The GLib timers door of adwaita-web, held to the shared vectors (real GJS is their oracle, in
2
+ // adwaita-core's `glib-timers.gjs.spec.ts`). The door is the namespace module itself, so a member
3
+ // that stops being exported fails here.
4
+
5
+ import {
6
+ GLIB_PATH_VECTORS,
7
+ GLIB_TIMER_VECTORS,
8
+ driveGLibPathVectors,
9
+ driveGLibTimerVectors,
10
+ type GLibPathsLike,
11
+ type GLibTimersLike,
12
+ } from '@gjsify/adwaita-core/conformance';
13
+ import { describe, expect, it } from '@gjsify/unit';
14
+
15
+ import * as GLib from './namespace/glib.js';
16
+
17
+ export const GLibTest = async () => {
18
+ await driveGLibTimerVectors(
19
+ {
20
+ name: 'adwaita-web',
21
+ isOracle: false,
22
+ GLib: GLib as unknown as GLibTimersLike,
23
+ settle: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
24
+ quiet: async (body) => {
25
+ const original = console.error;
26
+ console.error = () => {};
27
+ try {
28
+ await body();
29
+ } finally {
30
+ console.error = original;
31
+ }
32
+ },
33
+ },
34
+ { describe, it, expect },
35
+ );
36
+ await driveGLibPathVectors(
37
+ { name: 'adwaita-web', isOracle: false, GLib: GLib as unknown as GLibPathsLike },
38
+ { describe, it, expect },
39
+ );
40
+ await describe('adwaita-web: GLib door', async () => {
41
+ await it('answers the host-bound path questions with the root and no data dirs', () => {
42
+ expect(GLib.get_current_dir()).toBe('/');
43
+ expect(GLib.get_system_data_dirs().length).toBe(0);
44
+ });
45
+ await it('has a vector row for every vector', () => {
46
+ expect(GLIB_TIMER_VECTORS.length > 0 && GLIB_PATH_VECTORS.length > 0).toBe(true);
47
+ });
48
+ });
49
+ };
@@ -0,0 +1,119 @@
1
+ // ADR 0096: the web door of the GObject subset, against the shared vectors and end to end.
2
+ import { parseBlueprint, projectToSharedNode } from '@gjsify/blueprint';
3
+ import { GOBJECT_VECTORS, driveGObjectVectors, type GObjectSubject } from '@gjsify/adwaita-core/conformance';
4
+ import { describe, expect, it } from '@gjsify/unit';
5
+
6
+ import { Adw, GObject } from './index.js';
7
+ import { tagForTypeName } from './gobject-door.js';
8
+ import type { SharedTreeNode } from '@gjsify/adwaita-core/conformance';
9
+ import { bindProperties } from '@gjsify/adwaita-core';
10
+
11
+ function tree(body: string): SharedTreeNode {
12
+ const { node, lost } = projectToSharedNode(
13
+ parseBlueprint(`using Gtk 4.0;\nusing Adw 1;\n\n${body}\n`, 'gobject-door.spec.blp'),
14
+ );
15
+ if (lost.length > 0) throw new Error(`the projection dropped ${lost.map((loss) => loss.kind).join(', ')}`);
16
+ return node as SharedTreeNode;
17
+ }
18
+
19
+ const subject: GObjectSubject = {
20
+ name: 'adwaita-web',
21
+ isOracle: false,
22
+ GObject: GObject as unknown as GObjectSubject['GObject'],
23
+ Widget: Adw.Bin as unknown as GObjectSubject['Widget'],
24
+ template: (source) => source.tree,
25
+ bind: (source, sourceProperty, target, targetProperty, flags) => {
26
+ bindProperties(source, sourceProperty, target, targetProperty, flags);
27
+ },
28
+ };
29
+
30
+ const Template = tree(`template $GoWebHexdump : Adw.Bin {
31
+ child: Gtk.Box {
32
+ Gtk.Switch sw { active: bind template.enabled bidirectional; }
33
+ Gtk.Button btn { clicked => $onClicked(); }
34
+ };
35
+ }`);
36
+
37
+ class GoWebHexdump extends Adw.Bin {
38
+ declare _sw: HTMLElement & { active: boolean };
39
+ declare _btn: HTMLElement;
40
+ declare enabled: boolean;
41
+ clicks = 0;
42
+ seenInConstructor = false;
43
+ constructor(params?: Record<string, unknown>) {
44
+ // @ts-expect-error the element's own constructor takes none; the registered class's takes the params
45
+ super(params);
46
+ this.seenInConstructor = this._btn !== undefined;
47
+ (this._btn as unknown as { connect(s: string, h: () => void): number }).connect('clicked', () => {
48
+ this.clicks += 10;
49
+ });
50
+ }
51
+ onClicked(): void {
52
+ this.clicks++;
53
+ }
54
+ }
55
+ GObject.registerClass(
56
+ {
57
+ GTypeName: 'GoWebHexdump',
58
+ Template,
59
+ InternalChildren: ['sw', 'btn'],
60
+ Properties: { enabled: GObject.ParamSpec.boolean('enabled', '', '', GObject.ParamFlags.READWRITE, false) },
61
+ },
62
+ GoWebHexdump as never,
63
+ );
64
+
65
+ export const GObjectDoorTest = async () => {
66
+ await driveGObjectVectors(subject, { describe, it, expect }, GOBJECT_VECTORS);
67
+ await describe('adwaita-web: GObject.registerClass end to end (ADR 0096)', async () => {
68
+ await it('derives the tag from the GTypeName', () => {
69
+ expect(tagForTypeName('Hexdump')).toBe('gjsify-hexdump');
70
+ expect(customElements.get('gjsify-go-web-hexdump')).toBe(GoWebHexdump);
71
+ });
72
+ await it('refuses a second class under the same tag', () => {
73
+ expect(() =>
74
+ GObject.registerClass({ GTypeName: 'GoWebHexdump' }, class extends Adw.Bin {} as never),
75
+ ).toThrow('already defines');
76
+ });
77
+ await it('MEASURED: a bare element dispatches no notify before its first connect, a connected-through-the-door one does', () => {
78
+ const bare = document.createElement('gtk-switch') as HTMLElement & { active: boolean };
79
+ let heard = 0;
80
+ bare.addEventListener('notify::active', () => heard++);
81
+ bare.active = true;
82
+ expect(heard).toBe(0);
83
+ const el = new GoWebHexdump();
84
+ let viaDoor = 0;
85
+ (el._sw as unknown as { connect(s: string, h: () => void): number }).connect(
86
+ 'notify::active',
87
+ () => viaDoor++,
88
+ );
89
+ el._sw.active = true;
90
+ expect(viaDoor).toBe(1);
91
+ });
92
+ for (const how of ['new', 'createElement'] as const) {
93
+ await it(`built with ${how}: this._x after super, children after connect, bind and handler work`, () => {
94
+ const el = (
95
+ how === 'new' ? new GoWebHexdump() : document.createElement('gjsify-go-web-hexdump')
96
+ ) as GoWebHexdump;
97
+ expect(el._sw !== undefined).toBe(true);
98
+ expect(el.children.length).toBe(0);
99
+ expect(el.seenInConstructor).toBe(true);
100
+ // notify on a not-yet-connected element reaches the binding (ADR 0096 UNVERIFIED point)
101
+ el._sw.active = true;
102
+ expect(el.enabled).toBe(true);
103
+ el.enabled = false;
104
+ expect(el._sw.active).toBe(false);
105
+ document.body.append(el);
106
+ try {
107
+ expect(el.querySelector('gtk-switch') !== null).toBe(true);
108
+ expect(el.contains(el._btn)).toBe(true);
109
+ el._btn.dispatchEvent(new Event('click'));
110
+ expect(el.clicks).toBe(11);
111
+ el._sw.active = true;
112
+ expect(el.enabled).toBe(true);
113
+ } finally {
114
+ el.remove();
115
+ }
116
+ });
117
+ }
118
+ });
119
+ };
@@ -0,0 +1,137 @@
1
+ // The web door of the GObject subset (ADR 0096 § 1): how custom elements dispatch and listen for a
2
+ // named event, build a template, and take the built children.
3
+ //
4
+ // A registered class is a custom element. `registerClass` defines it under a tag derived from
5
+ // its `GTypeName`, and the instance is built in the order of GJS's `Gtk.Widget._init` by a class
6
+ // inserted between the class and its widget base, because the base is an element that knows
7
+ // nothing of GObject and `super(params)` is the only place a subclass hands control down.
8
+ //
9
+ // CHILDREN ARE ATTACHED AT THE FIRST `connectedCallback`, never in the constructor: the custom
10
+ // element rules forbid a constructor to give the element children or attributes when
11
+ // `createElement` or the parser creates it. They are created, and wired to their handlers and
12
+ // binds, from the constructor on, so `this._x.connect(…)` in a subclass constructor works.
13
+
14
+ import {
15
+ constructRegistered,
16
+ type BuiltTemplate,
17
+ type ClassLike,
18
+ type GObjectDoor,
19
+ type RegisteredClass,
20
+ type TemplateScope,
21
+ } from '@gjsify/adwaita-core';
22
+ import { propertyOf } from '@gjsify/adwaita-core/tags';
23
+
24
+ import { dispatchElement, listenElement } from './gobject-elements.js';
25
+ import { buildTemplateTree, type BuiltTemplateTree } from './shared-tree-builder.js';
26
+ import { registerTemplateClass } from './template-classes.js';
27
+
28
+ type Ctor = new (...args: never[]) => HTMLElement;
29
+
30
+ const PENDING = new WeakMap<HTMLElement, BuiltTemplateTree[]>();
31
+ const CONSTRUCTED = new WeakSet<HTMLElement>();
32
+ const DEFINED_FOR = new Map<string, string>();
33
+
34
+ /** `Hexdump` → `gjsify-hexdump`, `GoVecNotify` → `gjsify-go-vec-notify`. */
35
+ export function tagForTypeName(typeName: string): string {
36
+ const kebab = typeName
37
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
38
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9]+/g, '-')
41
+ .replace(/^-+|-+$/g, '');
42
+ return `gjsify-${kebab}`;
43
+ }
44
+
45
+ function isElementClass(klass: ClassLike): boolean {
46
+ return (klass as { prototype: object }).prototype instanceof HTMLElement;
47
+ }
48
+
49
+ /** The signals a registered element declares: its widget base's, one `notify::` per property, its own. */
50
+ function declareSignals(klass: ClassLike, info: RegisteredClass): void {
51
+ const inherited = (klass as unknown as { signals?: Record<string, string> }).signals ?? {};
52
+ const signals: Record<string, string> = { ...inherited };
53
+ for (const spec of info.properties) signals[`notify::${spec.name}`] = `notify::${spec.name}`;
54
+ for (const name of Object.keys(info.signals)) signals[name] = name;
55
+ Object.defineProperty(klass, 'signals', { value: signals, configurable: true, writable: true });
56
+ }
57
+
58
+ function attachPending(host: HTMLElement): void {
59
+ const pending = PENDING.get(host);
60
+ if (pending === undefined) return;
61
+ PENDING.delete(host);
62
+ for (const built of pending) {
63
+ built.finish();
64
+ host.append(...built.children);
65
+ }
66
+ }
67
+
68
+ export const webDoor: GObjectDoor = {
69
+ name: 'adwaita-web',
70
+
71
+ dispatch(target, event, args) {
72
+ if (!(target instanceof HTMLElement)) throw new Error(`No signal '${event}' on object '${String(target)}'`);
73
+ dispatchElement(target, event, args);
74
+ },
75
+
76
+ listen(target, event, handler) {
77
+ if (!(target instanceof HTMLElement)) throw new Error(`No signal '${event}' on object '${String(target)}'`);
78
+ return listenElement(target, event, handler as never);
79
+ },
80
+
81
+ createFromTree(tree, scope: TemplateScope, host): BuiltTemplate {
82
+ return buildTemplateTree(tree, scope, host as HTMLElement);
83
+ },
84
+
85
+ attach(host, built) {
86
+ const pending = PENDING.get(host as HTMLElement) ?? [];
87
+ pending.push(built as BuiltTemplateTree);
88
+ PENDING.set(host as HTMLElement, pending);
89
+ },
90
+
91
+ register(klass, info) {
92
+ // A class with no widget model beneath it is built by `GObject.Object`, which is the core's.
93
+ if (!isElementClass(klass)) return;
94
+ const tag = tagForTypeName(info.typeName);
95
+ const known = DEFINED_FOR.get(tag) ?? (customElements.get(tag) === undefined ? undefined : 'another class');
96
+ if (known !== undefined) {
97
+ throw new Error(
98
+ `GObject.registerClass: ${info.typeName} would be <${tag}>, which ${known} already defines. ` +
99
+ 'Give the class a distinct GTypeName.',
100
+ );
101
+ }
102
+
103
+ const Parent = Object.getPrototypeOf(klass) as Ctor;
104
+ class Constructing extends Parent {
105
+ constructor(params?: Record<string, unknown>) {
106
+ super();
107
+ // A registered base ran the whole chain already, for the most derived class.
108
+ if (CONSTRUCTED.has(this)) return;
109
+ CONSTRUCTED.add(this);
110
+ const rest = constructRegistered(this, params);
111
+ for (const [key, value] of Object.entries(rest)) {
112
+ const member = propertyOf(key);
113
+ if (!(member in this)) throw new Error(`No property ${key} on <${tag}>`);
114
+ (this as unknown as Record<string, unknown>)[member] = value;
115
+ }
116
+ }
117
+ }
118
+ Object.setPrototypeOf(klass, Constructing);
119
+ Object.setPrototypeOf((klass as { prototype: object }).prototype, Constructing.prototype);
120
+
121
+ const proto = (klass as { prototype: Record<string, unknown> }).prototype;
122
+ const inherited = proto.connectedCallback as (() => void) | undefined;
123
+ Object.defineProperty(proto, 'connectedCallback', {
124
+ configurable: true,
125
+ writable: true,
126
+ value(this: HTMLElement) {
127
+ attachPending(this);
128
+ inherited?.call(this);
129
+ },
130
+ });
131
+
132
+ declareSignals(klass, info);
133
+ customElements.define(tag, klass as unknown as CustomElementConstructor);
134
+ DEFINED_FOR.set(tag, info.typeName);
135
+ registerTemplateClass(info.typeName, tag);
136
+ },
137
+ };