@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
@@ -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,12 +86,13 @@
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';
92
93
  import { bindSlottedChildren } from '../slotted-children.js';
93
94
  import type { GtkScrollType } from './gtk-scrollbar.js';
94
- import { GtkViewport } from './gtk-viewport.js';
95
+ import type { GtkViewport } from './gtk-viewport.js';
95
96
 
96
97
  /**
97
98
  * `INDICATOR_FADE_OUT_DELAY` — how long an indicator waits, after the last scroll, before
@@ -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. */
@@ -198,11 +201,16 @@ function resolveScrollKey(event: KeyboardEvent): { scroll: GtkScrollType; horizo
198
201
  export class GtkScrolledWindow extends HTMLElement {
199
202
  private _viewport!: GtkViewport;
200
203
  private _bars: Record<'horizontal' | 'vertical', HTMLElement> | null = null;
204
+ /** The size clamps `_writeSize` last wrote, which are the only ones it may clear. */
205
+ private readonly _wroteSizes = new Set<string>();
201
206
  private _shading: AdwScrollShading | null = null;
202
207
  private _observer: ResizeObserver | null = null;
203
208
  private _hoverable: MediaQueryList | null = null;
204
209
  private _fadeTimer: ReturnType<typeof setTimeout> | null = null;
205
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 } = {};
206
214
 
207
215
  static get observedAttributes() {
208
216
  return [
@@ -238,6 +246,58 @@ export class GtkScrolledWindow extends HTMLElement {
238
246
  this.setAttribute('vscrollbar-policy', value);
239
247
  }
240
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
+
241
301
  /** `GtkScrolledWindow:window-placement` — where the contents sit against the bars. */
242
302
  get windowPlacement(): GtkCornerType {
243
303
  return normalizeCorner(this.getAttribute('window-placement'));
@@ -447,10 +507,10 @@ export class GtkScrolledWindow extends HTMLElement {
447
507
  this.classList.toggle('overlay-scrolling', indicators);
448
508
  // `gtk_scrolled_window_measure`'s four clamps, as the four CSS size requests they
449
509
  // are: an unwritten one (-1) sets nothing at all.
450
- this.style.minWidth = this._size('min-content-width');
451
- this.style.minHeight = this._size('min-content-height');
452
- this.style.maxWidth = this._size('max-content-width');
453
- this.style.maxHeight = this._size('max-content-height');
510
+ this._writeSize('min-width', 'min-content-width');
511
+ this._writeSize('min-height', 'min-content-height');
512
+ this._writeSize('max-width', 'max-content-width');
513
+ this._writeSize('max-height', 'max-content-height');
454
514
  for (const axis of AXES) {
455
515
  const bar = bars[axis.bar];
456
516
  const policy = normalizePolicy(this.getAttribute(axis.policy));
@@ -481,9 +541,18 @@ export class GtkScrolledWindow extends HTMLElement {
481
541
  if (!indicators) this._clearFade();
482
542
  }
483
543
 
484
- private _size(attribute: string): string {
544
+ /**
545
+ * One of the four clamps as inline style. An unwritten clamp clears only what THIS element
546
+ * wrote: `min-width` / `min-height` are also where a tree's `width-request` /
547
+ * `height-request` land (`GTK_WIDGET_SIZE_CSS`), and assigning '' unconditionally erased it.
548
+ * Where both are set the content clamp wins, which is the one the widget measures itself.
549
+ */
550
+ private _writeSize(property: string, attribute: string): void {
485
551
  const size = sizeRequest(this.getAttribute(attribute));
486
- return size === null ? '' : `${size}px`;
552
+ if (size !== null) this.style.setProperty(property, `${size}px`);
553
+ else if (this._wroteSizes.has(property)) this.style.removeProperty(property);
554
+ if (size !== null) this._wroteSizes.add(property);
555
+ else this._wroteSizes.delete(property);
487
556
  }
488
557
 
489
558
  /** The viewport's adjustment for one axis, which is where GTK reads the scrollbar's. */
@@ -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;
@@ -106,9 +106,15 @@ export class GtkWindow extends HTMLElement {
106
106
  ];
107
107
  }
108
108
 
109
- /** `Gtk.Window:title` — the window's title. */
110
- get title(): string | null {
111
- return this.getAttribute('title');
109
+ /**
110
+ * `Gtk.Window:title` — the window's title.
111
+ *
112
+ * The getter answers `''` for no title, not `null`: a `string | null` getter is not assignable to
113
+ * `HTMLElement.title`, and the emitted `.d.ts` then fails every consumer that does not set
114
+ * `skipLibCheck`. The setter still takes `null` to clear it, as GTK does.
115
+ */
116
+ get title(): string {
117
+ return this.getAttribute('title') ?? '';
112
118
  }
113
119
 
114
120
  set title(value: string | null) {
@@ -0,0 +1,34 @@
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 { GLIB_TIMER_VECTORS, driveGLibTimerVectors, type GLibTimersLike } from '@gjsify/adwaita-core/conformance';
6
+ import { describe, expect, it } from '@gjsify/unit';
7
+
8
+ import * as GLib from './namespace/glib.js';
9
+
10
+ export const GLibTest = async () => {
11
+ await driveGLibTimerVectors(
12
+ {
13
+ name: 'adwaita-web',
14
+ isOracle: false,
15
+ GLib: GLib as unknown as GLibTimersLike,
16
+ settle: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
17
+ quiet: async (body) => {
18
+ const original = console.error;
19
+ console.error = () => {};
20
+ try {
21
+ await body();
22
+ } finally {
23
+ console.error = original;
24
+ }
25
+ },
26
+ },
27
+ { describe, it, expect },
28
+ );
29
+ await describe('adwaita-web: GLib door', async () => {
30
+ await it('has a vector row for every vector', () => {
31
+ expect(GLIB_TIMER_VECTORS.length > 0).toBe(true);
32
+ });
33
+ });
34
+ };
@@ -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
+ };