@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.
- package/README.md +8 -0
- package/dist/adwaita-web.css +137 -29
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/adw-status-page.spec.d.ts +1 -0
- package/lib/types/application.d.ts +24 -0
- package/lib/types/application.spec.d.ts +1 -0
- package/lib/types/attributes.d.ts +19 -0
- package/lib/types/elements/adw-action-row.d.ts +4 -0
- package/lib/types/elements/adw-application-window.d.ts +2 -0
- package/lib/types/elements/adw-combo-row.d.ts +4 -0
- package/lib/types/elements/adw-entry-row.d.ts +6 -0
- package/lib/types/elements/adw-expander-row.d.ts +2 -0
- package/lib/types/elements/adw-header-bar.d.ts +6 -0
- package/lib/types/elements/adw-overlay-split-view.d.ts +13 -0
- package/lib/types/elements/adw-sidebar.d.ts +3 -2
- package/lib/types/elements/adw-spin-row.d.ts +6 -0
- package/lib/types/elements/adw-switch-row.d.ts +4 -0
- package/lib/types/elements/adw-toggle-group.d.ts +17 -0
- package/lib/types/elements/adw-view-stack.d.ts +3 -0
- package/lib/types/elements/checks.d.ts +6 -0
- package/lib/types/elements/gtk-application-window.d.ts +2 -0
- package/lib/types/elements/gtk-drop-down.d.ts +3 -0
- package/lib/types/elements/gtk-entry.d.ts +10 -0
- package/lib/types/elements/gtk-menu-button.d.ts +2 -0
- package/lib/types/elements/gtk-scrolled-window.d.ts +26 -2
- package/lib/types/elements/gtk-stack.d.ts +3 -0
- package/lib/types/elements/gtk-switch.d.ts +3 -0
- package/lib/types/elements/gtk-window.d.ts +8 -2
- package/lib/types/glib.spec.d.ts +1 -0
- package/lib/types/gobject-door.d.ts +4 -0
- package/lib/types/gobject-door.spec.d.ts +1 -0
- package/lib/types/gobject-elements.d.ts +17 -0
- package/lib/types/gtksource/gtk-source-view.d.ts +77 -0
- package/lib/types/gtksource/gtk-source-view.spec.d.ts +2 -0
- package/lib/types/gtksource/web-editor-driver.d.ts +44 -0
- package/lib/types/index.d.ts +4 -1
- package/lib/types/menu-actions.spec.d.ts +1 -0
- package/lib/types/namespace/adw.d.ts +1 -0
- package/lib/types/namespace/gio.d.ts +2 -0
- package/lib/types/namespace/glib.d.ts +10 -0
- package/lib/types/namespace/gobject.d.ts +27 -0
- package/lib/types/namespace/gtk-enums.d.ts +22 -0
- package/lib/types/namespace/gtk.d.ts +2 -0
- package/lib/types/namespace/gtksource.d.ts +2 -0
- package/lib/types/row-line-clamp.d.ts +20 -0
- package/lib/types/row-tooltip.d.ts +8 -0
- package/lib/types/row-tooltip.spec.d.ts +1 -0
- package/lib/types/shared-tree-builder.d.ts +69 -20
- package/lib/types/shared-tree-mount.spec.d.ts +1 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/lib/types/widget-size.spec.d.ts +1 -0
- package/package.json +11 -9
- package/scss/_action_row.scss +39 -15
- package/scss/_button_row.scss +5 -0
- package/scss/_expander_row.scss +36 -3
- package/scss/_headerbar.scss +2 -1
- package/scss/_preferences_row.scss +9 -0
- package/scss/_reset.scss +1 -1
- package/scss/_row.scss +16 -0
- package/scss/_status_page.scss +1 -1
- package/scss/_toggle_group.scss +28 -0
- package/scss/_widget.scss +14 -3
- package/scss/_window.scss +13 -1
- package/src/adw-action-rows.spec.ts +255 -0
- package/src/adw-header-bar.spec.ts +18 -0
- package/src/adw-status-page.spec.ts +124 -0
- package/src/application.spec.ts +75 -0
- package/src/application.ts +92 -0
- package/src/attributes.ts +52 -0
- package/src/capabilities.mjs +3 -0
- package/src/construct-vectors.spec.ts +156 -1
- package/src/elements/adw-action-row.ts +31 -2
- package/src/elements/adw-alert-dialog.ts +4 -1
- package/src/elements/adw-application-window.ts +5 -0
- package/src/elements/adw-button-row.ts +7 -1
- package/src/elements/adw-combo-row.ts +26 -1
- package/src/elements/adw-entry-row.ts +22 -12
- package/src/elements/adw-expander-row.ts +34 -1
- package/src/elements/adw-header-bar.ts +18 -4
- package/src/elements/adw-overlay-split-view.ts +29 -6
- package/src/elements/adw-preferences-row.ts +5 -2
- package/src/elements/adw-sidebar.ts +33 -5
- package/src/elements/adw-spin-row.ts +37 -2
- package/src/elements/adw-split-button.ts +5 -1
- package/src/elements/adw-status-page.ts +10 -2
- package/src/elements/adw-switch-row.ts +25 -1
- package/src/elements/adw-toggle-group.ts +119 -10
- package/src/elements/adw-view-stack.ts +14 -0
- package/src/elements/adw-window.ts +9 -4
- package/src/elements/checks.ts +25 -1
- package/src/elements/gtk-application-window.ts +5 -0
- package/src/elements/gtk-drop-down.ts +16 -0
- package/src/elements/gtk-entry.ts +45 -7
- package/src/elements/gtk-menu-button.ts +12 -1
- package/src/elements/gtk-popover-menu.ts +7 -2
- package/src/elements/gtk-scrolled-window.ts +76 -7
- package/src/elements/gtk-stack.ts +8 -0
- package/src/elements/gtk-switch.ts +8 -0
- package/src/elements/gtk-window.ts +9 -3
- package/src/glib.spec.ts +34 -0
- package/src/gobject-door.spec.ts +119 -0
- package/src/gobject-door.ts +137 -0
- package/src/gobject-elements.ts +172 -0
- package/src/gtk-window.spec.ts +12 -0
- package/src/gtksource/gtk-source-view.spec.ts +69 -0
- package/src/gtksource/gtk-source-view.ts +379 -0
- package/src/gtksource/web-editor-driver.ts +260 -0
- package/src/index.ts +11 -1
- package/src/keyboard-operable.spec.ts +47 -11
- package/src/menu-actions.spec.ts +47 -0
- package/src/namespace/adw.ts +1 -0
- package/src/namespace/gio.ts +2 -0
- package/src/namespace/glib.ts +22 -0
- package/src/namespace/gobject.ts +37 -0
- package/src/namespace/gtk-enums.ts +15 -0
- package/src/namespace/gtk.ts +2 -0
- package/src/namespace/gtksource.ts +9 -0
- package/src/row-line-clamp.ts +74 -0
- package/src/row-tooltip.spec.ts +313 -0
- package/src/row-tooltip.ts +52 -0
- package/src/shared-tree-builder.ts +230 -42
- package/src/shared-tree-mount.spec.ts +130 -0
- package/src/shared-trees.spec.ts +15 -0
- package/src/split-button.spec.ts +2 -0
- package/src/styles.generated.ts +1 -1
- package/src/test.browser.mts +18 -0
- package/src/widget-size.spec.ts +117 -0
|
@@ -34,15 +34,32 @@
|
|
|
34
34
|
// package's `exports` map ships only `.`, and a new subpath would buy nothing for a module
|
|
35
35
|
// this small, most of which (the `SharedTreeNode` type) is erased at build anyway.
|
|
36
36
|
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
createBreakpointDriver,
|
|
39
|
+
menuAssignments,
|
|
40
|
+
parseBreakpointCondition,
|
|
41
|
+
type BindingFlag,
|
|
42
|
+
type BreakpointSize,
|
|
43
|
+
type GObjectInstance,
|
|
44
|
+
type TemplateScope,
|
|
45
|
+
} from '@gjsify/adwaita-core';
|
|
38
46
|
import { assertTreeConstructs, type SharedTreeNode } from '@gjsify/adwaita-core/conformance';
|
|
39
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
GTK_WIDGET_EXPAND,
|
|
49
|
+
attributeOf,
|
|
50
|
+
hostTagOf,
|
|
51
|
+
isValueBasedBooleanAttr,
|
|
52
|
+
propertyOf,
|
|
53
|
+
widgetLengthStyle,
|
|
54
|
+
} from '@gjsify/adwaita-core/tags';
|
|
40
55
|
|
|
41
56
|
import { observeAdaptiveSize } from './breakpoints.js';
|
|
42
57
|
import { capabilities } from './capabilities.mjs';
|
|
58
|
+
import { endpointOf, hasElementApi, installElementApi } from './gobject-elements.js';
|
|
43
59
|
import { dispatchedSignalsOf } from './signals.js';
|
|
44
60
|
import { slottedChildrenOf } from './slotted-children.js';
|
|
45
61
|
import { templateTagFor } from './template-classes.js';
|
|
62
|
+
import { writeBooleanAttribute } from './attributes.js';
|
|
46
63
|
|
|
47
64
|
/** One authored placement, kept so {@link mountSharedTree} can hold the renderer to it. */
|
|
48
65
|
interface PlacedChild {
|
|
@@ -73,6 +90,10 @@ interface BuildRecord {
|
|
|
73
90
|
observeSize?: SizeSource;
|
|
74
91
|
/** The object a signal handler's NAME is resolved against (ADR 0093 § 3), as `Gtk.BuilderScope` does. */
|
|
75
92
|
scope?: Readonly<Record<string, unknown>>;
|
|
93
|
+
/** The root menus of the tree (ADR 0097), which a `menu-model: id` anywhere below resolves against. */
|
|
94
|
+
menus?: SharedTreeNode['menus'];
|
|
95
|
+
/** Present when the tree is the template of a registered class (ADR 0096): handlers and binds go through it. */
|
|
96
|
+
template?: TemplateScope;
|
|
76
97
|
}
|
|
77
98
|
|
|
78
99
|
/** Calls `onSize` with the host's measured size, now and on every change; returns its disposer. */
|
|
@@ -104,21 +125,22 @@ function isWritable(el: object, member: string): boolean {
|
|
|
104
125
|
* A `SharedTreeNode`, realised as a DETACHED element tree: a tag, its authored properties as
|
|
105
126
|
* attributes, its style classes as classes, its extensions (ADR 0072) as the markup the element
|
|
106
127
|
* reads, its placement as `slot=`, its children, in that order — recursive and total,
|
|
107
|
-
* no tag list, no per-block case.
|
|
108
|
-
* (`toggleAttribute`), which is what every element in the corpus reads
|
|
109
|
-
* (`hasAttribute('revealed')`, `hasAttribute('expanded')`); spelling `"true"` would set a
|
|
110
|
-
* present attribute for `false` as well.
|
|
128
|
+
* no tag list, no per-block case.
|
|
111
129
|
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
130
|
+
* BOOLEAN ATTRIBUTES are written per (widget, property), not per attribute name. The same
|
|
131
|
+
* attribute name (e.g. `revealed`) can be VALUE-BASED for one widget (`gtk-action-bar`,
|
|
132
|
+
* default TRUE) and PRESENCE-BASED for another (`adw-banner`, default FALSE). The
|
|
133
|
+
* per-widget map in `@gjsify/adwaita-core/tags` ({@link isValueBasedBooleanAttr}) records this.
|
|
134
|
+
*
|
|
135
|
+
* - VALUE-BASED: `attr="false"` means false, absent means true (the GTK default).
|
|
136
|
+
* The builder writes `attr="true"|"false"` directly via {@link writeBooleanAttribute}.
|
|
137
|
+
* - PRESENCE-BASED (style classes like `flat`, `round`, `compact`): `toggleAttribute`
|
|
138
|
+
* writes the bare attribute for `true`, removes it for `false`.
|
|
139
|
+
*
|
|
140
|
+
* EXCEPT an authored `false` on a NON-value-based, WRITABLE property: the property is set
|
|
141
|
+
* directly (bypassing the attribute) because the element's setter will reflect it correctly.
|
|
142
|
+
* A getter-only property of the same name (e.g. `AdwSplitButton.active`) falls back to the
|
|
143
|
+
* presence rule since assignment would throw.
|
|
122
144
|
*
|
|
123
145
|
* THE SLOT IS WRITTEN AS THE ATTRIBUTE THIS RENDERER ALREADY ROUTES ON, not translated:
|
|
124
146
|
* `src/slotted-children.ts` reads `slot=` off every light-DOM child and keeps the routing
|
|
@@ -135,6 +157,7 @@ export function buildSharedTree(node: SharedTreeNode, record: BuildRecord = newR
|
|
|
135
157
|
// ADR 0093 § 2: the whole tree is checked against the capability table before an element is
|
|
136
158
|
// created. A refused `layout` is named with its node and the reason the table gives.
|
|
137
159
|
assertTreeConstructs('adwaita-web', capabilities, node);
|
|
160
|
+
if (node.menus !== undefined) record.menus = node.menus;
|
|
138
161
|
const root = buildNode(node, record);
|
|
139
162
|
for (const bind of record.binds) bindProperty(bind, record);
|
|
140
163
|
for (const host of record.breakpointHosts) bindBreakpoints(host.el, host.node, record);
|
|
@@ -142,7 +165,11 @@ export function buildSharedTree(node: SharedTreeNode, record: BuildRecord = newR
|
|
|
142
165
|
}
|
|
143
166
|
|
|
144
167
|
/** A fresh record, with the scope when the caller has one. */
|
|
145
|
-
function newRecord(
|
|
168
|
+
function newRecord(
|
|
169
|
+
scope?: Readonly<Record<string, unknown>>,
|
|
170
|
+
observeSize?: SizeSource,
|
|
171
|
+
template?: TemplateScope,
|
|
172
|
+
): BuildRecord {
|
|
146
173
|
return {
|
|
147
174
|
placed: [],
|
|
148
175
|
extended: [],
|
|
@@ -152,6 +179,7 @@ function newRecord(scope?: Readonly<Record<string, unknown>>, observeSize?: Size
|
|
|
152
179
|
disposers: [],
|
|
153
180
|
...(scope === undefined ? {} : { scope }),
|
|
154
181
|
...(observeSize === undefined ? {} : { observeSize }),
|
|
182
|
+
...(template === undefined ? {} : { template }),
|
|
155
183
|
};
|
|
156
184
|
}
|
|
157
185
|
|
|
@@ -223,6 +251,7 @@ function bindBreakpoints(el: HTMLElement, node: SharedTreeNode, record: BuildRec
|
|
|
223
251
|
* `notify::…` only while connected, so a tree built but never attached follows once, at build.
|
|
224
252
|
*/
|
|
225
253
|
function bindProperty(bind: PendingBind, record: BuildRecord): void {
|
|
254
|
+
if (record.template !== undefined) return bindThroughEngine(bind, record.template, record);
|
|
226
255
|
const { el, property, source, sourceProperty, flags } = bind;
|
|
227
256
|
const where = `<${el.localName}> \`${property}: bind ${source}.${sourceProperty}\``;
|
|
228
257
|
const from = record.ids.get(source);
|
|
@@ -258,27 +287,103 @@ function bindProperty(bind: PendingBind, record: BuildRecord): void {
|
|
|
258
287
|
from.addEventListener(event, follow);
|
|
259
288
|
}
|
|
260
289
|
|
|
261
|
-
/**
|
|
262
|
-
|
|
290
|
+
/**
|
|
291
|
+
* ADR 0096's `bind` inside the template of a registered class: the `template` source, the flags
|
|
292
|
+
* `bidirectional`, `inverted` and `no-sync-create`, all through the core's engine, which is
|
|
293
|
+
* per-instance and does not care whether an element is connected. An element end is wrapped so
|
|
294
|
+
* a GTK property name reaches its camelCase member ({@link endpointOf}), and a `notify::` on it
|
|
295
|
+
* is made to fire before the first connect (`ensureNotifying`).
|
|
296
|
+
*/
|
|
297
|
+
function bindThroughEngine(bind: PendingBind, template: TemplateScope, record: BuildRecord): void {
|
|
298
|
+
const { el, property, source, sourceProperty, flags } = bind;
|
|
299
|
+
const where = `<${el.localName}> \`${property}: bind ${source}.${sourceProperty}\``;
|
|
300
|
+
const from = source === 'template' ? template.instance : record.ids.get(source);
|
|
301
|
+
if (from === undefined) {
|
|
302
|
+
throw new Error(`${where} names no object: nothing in this tree has the id '${source}'.`);
|
|
303
|
+
}
|
|
304
|
+
const known = ['bidirectional', 'inverted', 'no-sync-create'];
|
|
305
|
+
const unknown = flags.filter((flag) => !known.includes(flag));
|
|
306
|
+
if (unknown.length > 0) throw new Error(`${where} carries ${unknown.join(', ')}, which is not a bind flag.`);
|
|
307
|
+
if (hasElementApi(el) && !isWritable(el, propertyOf(property))) {
|
|
308
|
+
throw new Error(`${where}: <${el.localName}> has no writable property '${propertyOf(property)}'.`);
|
|
309
|
+
}
|
|
310
|
+
const engineFlags: BindingFlag[] = [];
|
|
311
|
+
if (flags.includes('bidirectional')) engineFlags.push('bidirectional');
|
|
312
|
+
if (flags.includes('inverted')) engineFlags.push('invert-boolean');
|
|
313
|
+
if (!flags.includes('no-sync-create')) engineFlags.push('sync-create');
|
|
314
|
+
template.bind(
|
|
315
|
+
endpointOf(from as HTMLElement) as unknown as GObjectInstance,
|
|
316
|
+
sourceProperty,
|
|
317
|
+
endpointOf(el) as unknown as GObjectInstance,
|
|
318
|
+
property,
|
|
319
|
+
engineFlags,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* One authored property, written as the element reads it; a breakpoint setter takes the same door.
|
|
325
|
+
*
|
|
326
|
+
* BOOLEAN ATTRIBUTES are written per (widget, property), not per attribute name. The same
|
|
327
|
+
* attribute name (e.g. `revealed`) can be VALUE-BASED for one widget (`gtk-action-bar`,
|
|
328
|
+
* default TRUE) and PRESENCE-BASED for another (`adw-banner`, default FALSE). The
|
|
329
|
+
* per-widget map in `@gjsify/adwaita-core/tags` ({@link isValueBasedBooleanAttr}) records this.
|
|
330
|
+
*
|
|
331
|
+
* - VALUE-BASED: `attr="false"` means false, absent means true (the GTK default).
|
|
332
|
+
* The builder writes `attr="true"|"false"` directly via {@link writeBooleanAttribute}.
|
|
333
|
+
* - PRESENCE-BASED (style classes like `flat`, `round`, `compact`): `toggleAttribute`
|
|
334
|
+
* writes the bare attribute for `true`, removes it for `false`.
|
|
335
|
+
*
|
|
336
|
+
* EXCEPT an authored `false` on a NON-value-based, WRITABLE property: the property is set
|
|
337
|
+
* directly (bypassing the attribute) because the element's setter will reflect it correctly.
|
|
338
|
+
* A getter-only property of the same name (e.g. `AdwSplitButton.active`) falls back to the
|
|
339
|
+
* presence rule since assignment would throw. `hexpand` / `vexpand` keep an authored `false`
|
|
340
|
+
* as the attribute `"false"` and a later `true` rewrites it — `GTK_WIDGET_EXPAND` says why.
|
|
341
|
+
*/
|
|
342
|
+
export function writeProp(el: HTMLElement, prop: string, value: string | number | boolean): void {
|
|
343
|
+
const attr = attributeOf(prop);
|
|
263
344
|
const member = propertyOf(prop);
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
else el.
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
345
|
+
const expand = GTK_WIDGET_EXPAND.find((name) => name === attr);
|
|
346
|
+
if (expand !== undefined && typeof value === 'boolean') el.setAttribute(expand, value ? '' : 'false');
|
|
347
|
+
else if (typeof value === 'boolean' && isValueBasedBooleanAttr(el.localName, attr)) {
|
|
348
|
+
writeBooleanAttribute(el, attr, value);
|
|
349
|
+
} else if (value === false && isWritable(el, member)) {
|
|
350
|
+
// Non-value-based, writable property: set the property directly (bypasses attribute).
|
|
351
|
+
(el as unknown as Record<string, unknown>)[member] = false;
|
|
352
|
+
} else if (typeof value === 'boolean') {
|
|
353
|
+
// Presence-based (style classes) or true for non-value-based: use toggleAttribute.
|
|
354
|
+
el.toggleAttribute(attr, value);
|
|
355
|
+
} else {
|
|
356
|
+
el.setAttribute(attr, String(value));
|
|
357
|
+
}
|
|
358
|
+
// A margin or a size request is also inline style (`GTK_WIDGET_MARGIN_CSS` says why); the
|
|
359
|
+
// attribute stays, since it is what the tree authored and what a reader of the DOM looks for.
|
|
360
|
+
const length = widgetLengthStyle(attr, value);
|
|
361
|
+
if (length === undefined) return;
|
|
362
|
+
if (length[1] === null) el.style.removeProperty(length[0]);
|
|
363
|
+
else el.style.setProperty(length[0], length[1]);
|
|
271
364
|
}
|
|
272
365
|
|
|
273
366
|
function buildNode(node: SharedTreeNode, record: BuildRecord): HTMLElement {
|
|
274
367
|
// An `extern` node is built by the class the application registered under its name (ADR 0093).
|
|
275
368
|
const el = document.createElement(node.extern === true ? templateTagFor(node.tag) : hostTagOf(node.tag));
|
|
369
|
+
// A template's children talk GObject to the template: `connect`, `emit`, `notify` (ADR 0096).
|
|
370
|
+
if (record.template !== undefined) installElementApi(el);
|
|
276
371
|
// The id is how the TypeScript beside a `.blp` reaches this element
|
|
277
372
|
// (`root.querySelector('#…')`), the counterpart of `InternalChildren` on GTK.
|
|
278
373
|
if (node.id !== undefined) {
|
|
279
374
|
el.id = node.id;
|
|
280
375
|
record.ids.set(node.id, el);
|
|
281
376
|
}
|
|
377
|
+
collectNode(el, node, record);
|
|
378
|
+
writeLook(el, node, record.menus);
|
|
379
|
+
bindSignals(el, node, record);
|
|
380
|
+
if (node.extensions !== undefined) record.extended.push({ el, node });
|
|
381
|
+
for (const child of buildChildren(el, node, record)) el.append(child);
|
|
382
|
+
return el;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** What a node asks to be wired once every id exists: its binds and its breakpoints. */
|
|
386
|
+
function collectNode(el: HTMLElement, node: SharedTreeNode, record: BuildRecord): void {
|
|
282
387
|
for (const [property, binding] of Object.entries(node.bindings ?? {})) {
|
|
283
388
|
record.binds.push({
|
|
284
389
|
el,
|
|
@@ -288,15 +393,32 @@ function buildNode(node: SharedTreeNode, record: BuildRecord): HTMLElement {
|
|
|
288
393
|
flags: binding.flags ?? [],
|
|
289
394
|
});
|
|
290
395
|
}
|
|
291
|
-
for (const [prop, value] of Object.entries(node.props ?? {})) writeProp(el, prop, value);
|
|
292
396
|
if (node.breakpoints !== undefined) record.breakpointHosts.push({ el, node });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* What a node looks like: its authored properties, style classes and extensions. Separate from the
|
|
401
|
+
* rest because a custom element may not gain attributes or children in its constructor, which is
|
|
402
|
+
* where a registered class's host is built (see {@link buildTemplateTree}).
|
|
403
|
+
*/
|
|
404
|
+
function writeLook(el: HTMLElement, node: SharedTreeNode, menus?: SharedTreeNode['menus']): void {
|
|
405
|
+
// A menu is a value, not an attribute: the id the projection keeps would parse as no menu.
|
|
406
|
+
for (const [prop, value] of Object.entries(node.props ?? {})) {
|
|
407
|
+
if (prop !== 'menu-model') writeProp(el, prop, value);
|
|
408
|
+
}
|
|
409
|
+
for (const [prop, model] of menuAssignments(node, menus)) {
|
|
410
|
+
(el as unknown as Record<string, unknown>)[propertyOf(prop)] = model;
|
|
411
|
+
}
|
|
293
412
|
// `styleClasses` is `GtkWidget:css-classes`, and this renderer's door for it is the
|
|
294
413
|
// `class` attribute — what `.title-1`, `.dimmed` and `.card` select on. Unread, a
|
|
295
414
|
// `.blp`'s `styles ["title-1"]` reached the tree and never the page.
|
|
296
415
|
if (node.styleClasses !== undefined && node.styleClasses.length > 0) el.classList.add(...node.styleClasses);
|
|
297
416
|
writeExtensions(el, node);
|
|
298
|
-
|
|
299
|
-
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** The built children of `node`, placed (`page`, `slot`) but not yet appended to `el`. */
|
|
420
|
+
function buildChildren(el: HTMLElement, node: SharedTreeNode, record: BuildRecord): HTMLElement[] {
|
|
421
|
+
const built: HTMLElement[] = [];
|
|
300
422
|
for (const child of node.children ?? []) {
|
|
301
423
|
const childEl = buildNode(child, record);
|
|
302
424
|
if (child.page !== undefined) writePage(el, childEl, child);
|
|
@@ -304,9 +426,39 @@ function buildNode(node: SharedTreeNode, record: BuildRecord): HTMLElement {
|
|
|
304
426
|
childEl.setAttribute('slot', child.slot);
|
|
305
427
|
record.placed.push({ parent: el, child: childEl, slot: child.slot });
|
|
306
428
|
}
|
|
307
|
-
|
|
429
|
+
built.push(childEl);
|
|
308
430
|
}
|
|
309
|
-
return
|
|
431
|
+
return built;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** A template tree built for a registered class: its children are not attached, its look is not written. */
|
|
435
|
+
export interface BuiltTemplateTree {
|
|
436
|
+
readonly children: HTMLElement[];
|
|
437
|
+
/** Every object the template names with an `id`. */
|
|
438
|
+
readonly objects: Record<string, HTMLElement>;
|
|
439
|
+
/** Writes the root's own properties, classes and extensions onto the host — at its first connect. */
|
|
440
|
+
finish(): void;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* The template of a registered class (ADR 0096 § 3), built into `host`: children created and wired
|
|
445
|
+
* to their handlers and binds through `scope`, none appended and nothing written to `host`'s
|
|
446
|
+
* attributes, because `host` is still inside its constructor. `finish` and the caller's own append
|
|
447
|
+
* belong to the first `connectedCallback`.
|
|
448
|
+
*/
|
|
449
|
+
export function buildTemplateTree(tree: SharedTreeNode, scope: TemplateScope, host: HTMLElement): BuiltTemplateTree {
|
|
450
|
+
assertTreeConstructs('adwaita-web', capabilities, tree);
|
|
451
|
+
const record = newRecord(undefined, undefined, scope);
|
|
452
|
+
record.menus = tree.menus;
|
|
453
|
+
if (tree.id !== undefined) record.ids.set(tree.id, host);
|
|
454
|
+
collectNode(host, tree, record);
|
|
455
|
+
bindSignals(host, tree, record);
|
|
456
|
+
const children = buildChildren(host, tree, record);
|
|
457
|
+
for (const bind of record.binds) bindProperty(bind, record);
|
|
458
|
+
for (const breakpointHost of record.breakpointHosts) {
|
|
459
|
+
bindBreakpoints(breakpointHost.el, breakpointHost.node, record);
|
|
460
|
+
}
|
|
461
|
+
return { children, objects: Object.fromEntries(record.ids), finish: () => writeLook(host, tree, tree.menus) };
|
|
310
462
|
}
|
|
311
463
|
|
|
312
464
|
/**
|
|
@@ -328,6 +480,29 @@ function bindSignals(el: HTMLElement, node: SharedTreeNode, record: BuildRecord)
|
|
|
328
480
|
`run. It dispatches: ${Object.keys(declared).join(', ') || 'none'}.`,
|
|
329
481
|
);
|
|
330
482
|
}
|
|
483
|
+
if (record.template !== undefined) {
|
|
484
|
+
if (signal.flags?.includes('after') === true) {
|
|
485
|
+
throw new Error(
|
|
486
|
+
`the handler '${signal.handler}' for '${name}' uses after, which is not in the GObject subset.`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
const object =
|
|
490
|
+
signal.object === undefined
|
|
491
|
+
? undefined
|
|
492
|
+
: signal.object === 'template'
|
|
493
|
+
? record.template.instance
|
|
494
|
+
: record.ids.get(signal.object);
|
|
495
|
+
if (signal.object !== undefined && object === undefined) {
|
|
496
|
+
throw new Error(
|
|
497
|
+
`the handler '${signal.handler}' for '${name}' names the object '${signal.object}', which nothing in this tree has.`,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
(el as unknown as GObjectInstance).connect(
|
|
501
|
+
name,
|
|
502
|
+
record.template.handler(signal.handler, { object, flags: signal.flags }),
|
|
503
|
+
);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
331
506
|
if (signal.object !== undefined || (signal.flags?.length ?? 0) > 0) {
|
|
332
507
|
throw new Error(
|
|
333
508
|
`the handler '${signal.handler}' for '${name}' uses ${signal.object === undefined ? '' : 'an object '}` +
|
|
@@ -452,24 +627,37 @@ function refuseUnknownSlots(placed: readonly PlacedChild[]): void {
|
|
|
452
627
|
export interface MountedSharedTree {
|
|
453
628
|
/** The authored root — connected, so every custom element under it has upgraded and run. */
|
|
454
629
|
root: HTMLElement;
|
|
455
|
-
/** Disconnects and discards the mount point. */
|
|
630
|
+
/** Disconnects the tree, and discards the mount point when this call made one. */
|
|
456
631
|
unmount: () => void;
|
|
457
632
|
}
|
|
458
633
|
|
|
634
|
+
/** What {@link mountSharedTree} takes beside the tree. */
|
|
635
|
+
export interface MountOptions {
|
|
636
|
+
/** The object a handler NAME is looked up on (ADR 0093 § 3). */
|
|
637
|
+
scope?: Readonly<Record<string, unknown>>;
|
|
638
|
+
/** The size source a `breakpoints` node is driven from. */
|
|
639
|
+
observeSize?: SizeSource;
|
|
640
|
+
/**
|
|
641
|
+
* Where the root is appended — a showcase's own container, which then lays the window out.
|
|
642
|
+
* Without it the tree gets a fresh host `<div>` in `document.body`, discarded on `unmount`.
|
|
643
|
+
*/
|
|
644
|
+
into?: HTMLElement;
|
|
645
|
+
}
|
|
646
|
+
|
|
459
647
|
/**
|
|
460
648
|
* {@link buildSharedTree}, attached under a fresh host `<div>` in `document.body` so the tree
|
|
461
649
|
* — and every custom element in it — is REAL rather than merely constructed. This is the
|
|
462
650
|
* instantiation half a caller reading the corpus's elements normally wants; a bare
|
|
463
651
|
* `buildSharedTree` is for a caller that already has somewhere of its own to attach it.
|
|
464
652
|
*/
|
|
465
|
-
export function mountSharedTree(
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
): MountedSharedTree {
|
|
469
|
-
const host = document.createElement('div');
|
|
653
|
+
export function mountSharedTree(node: SharedTreeNode, options: MountOptions = {}): MountedSharedTree {
|
|
654
|
+
const { into } = options;
|
|
655
|
+
const host = into ?? document.createElement('div');
|
|
470
656
|
const record = newRecord(options.scope, options.observeSize);
|
|
471
|
-
|
|
472
|
-
|
|
657
|
+
const root = buildSharedTree(node, record);
|
|
658
|
+
host.append(root);
|
|
659
|
+
if (into === undefined) document.body.append(host);
|
|
660
|
+
const discard = () => (into === undefined ? host : root).remove();
|
|
473
661
|
// After the append, because that is what upgrades the elements and runs the binds the
|
|
474
662
|
// refusal reads; before the return, because a caller handed a tree back has no way left
|
|
475
663
|
// to tell a placement that was honoured from one that was dropped.
|
|
@@ -478,14 +666,14 @@ export function mountSharedTree(
|
|
|
478
666
|
refuseUnheldExtensions(record.extended);
|
|
479
667
|
} catch (error) {
|
|
480
668
|
for (const dispose of record.disposers) dispose();
|
|
481
|
-
|
|
669
|
+
discard();
|
|
482
670
|
throw error;
|
|
483
671
|
}
|
|
484
672
|
return {
|
|
485
|
-
root
|
|
673
|
+
root,
|
|
486
674
|
unmount: () => {
|
|
487
675
|
for (const dispose of record.disposers) dispose();
|
|
488
|
-
|
|
676
|
+
discard();
|
|
489
677
|
},
|
|
490
678
|
};
|
|
491
679
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// A showcase window mounted from its tree (ADR 0093): the shape every showcase `.blp` writes — a
|
|
2
|
+
// toggle button and a split view that bind to each other, two breakpoints that collapse the split
|
|
3
|
+
// view, and a window sized by `default-width`. What is held here is the exact cycle the showcases
|
|
4
|
+
// rely on, and `into`, the container a showcase mounts its window in.
|
|
5
|
+
import { describe, expect, it } from '@gjsify/unit';
|
|
6
|
+
import type { SharedTreeNode } from '@gjsify/adwaita-core/conformance';
|
|
7
|
+
|
|
8
|
+
import { mountSharedTree } from './shared-tree-builder.js';
|
|
9
|
+
|
|
10
|
+
/** Wait for a ResizeObserver delivery (it runs after layout, before paint). */
|
|
11
|
+
function settle(): Promise<void> {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The showcase window, sized by `default-width` and adapting at 800sp. */
|
|
18
|
+
const tree = (width: number): SharedTreeNode => ({
|
|
19
|
+
tag: 'AdwApplicationWindow',
|
|
20
|
+
props: { 'default-width': width, 'default-height': 400 },
|
|
21
|
+
breakpoints: [
|
|
22
|
+
{
|
|
23
|
+
condition: 'max-width: 799sp',
|
|
24
|
+
setters: [
|
|
25
|
+
{ object: 'splitView', property: 'collapsed', value: true },
|
|
26
|
+
{ object: 'splitView', property: 'show-sidebar', value: false },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
condition: 'min-width: 800sp',
|
|
31
|
+
setters: [
|
|
32
|
+
{ object: 'splitView', property: 'collapsed', value: false },
|
|
33
|
+
{ object: 'splitView', property: 'show-sidebar', value: true },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
children: [
|
|
38
|
+
{
|
|
39
|
+
tag: 'GtkBox',
|
|
40
|
+
slot: 'content',
|
|
41
|
+
children: [
|
|
42
|
+
{
|
|
43
|
+
tag: 'GtkToggleButton',
|
|
44
|
+
id: 'sidebarToggleButton',
|
|
45
|
+
bindings: { active: { source: 'splitView', property: 'show-sidebar' } },
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
tag: 'AdwOverlaySplitView',
|
|
49
|
+
id: 'splitView',
|
|
50
|
+
bindings: { 'show-sidebar': { source: 'sidebarToggleButton', property: 'active' } },
|
|
51
|
+
children: [
|
|
52
|
+
{ tag: 'GtkBox', slot: 'sidebar' },
|
|
53
|
+
{ tag: 'GtkBox', slot: 'content' },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
type SplitView = HTMLElement & { showSidebar: boolean; collapsed: boolean };
|
|
62
|
+
type Toggle = HTMLElement & { active: boolean };
|
|
63
|
+
|
|
64
|
+
export const AdwSharedTreeMountTest = async () => {
|
|
65
|
+
await describe('adwaita-web: a showcase window mounted from its tree', async () => {
|
|
66
|
+
await it('<adw-overlay-split-view> raises notify::show-sidebar once per real change', () => {
|
|
67
|
+
const view = document.createElement('adw-overlay-split-view') as SplitView;
|
|
68
|
+
document.body.append(view);
|
|
69
|
+
const seen: boolean[] = [];
|
|
70
|
+
view.addEventListener('notify::show-sidebar', (event) => {
|
|
71
|
+
seen.push((event as CustomEvent<{ showSidebar: boolean }>).detail.showSidebar);
|
|
72
|
+
});
|
|
73
|
+
view.showSidebar = true;
|
|
74
|
+
view.showSidebar = false;
|
|
75
|
+
view.showSidebar = false;
|
|
76
|
+
view.showSidebar = true;
|
|
77
|
+
view.remove();
|
|
78
|
+
expect(seen).toStrictEqual([false, true]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await it('a wide window keeps the sidebar and the toggle follows the split view', async () => {
|
|
82
|
+
const { root, unmount } = mountSharedTree(tree(1100));
|
|
83
|
+
await settle();
|
|
84
|
+
const split = root.querySelector('#splitView') as SplitView;
|
|
85
|
+
expect(root.getBoundingClientRect().width).toBe(1100);
|
|
86
|
+
expect(split.collapsed).toBe(false);
|
|
87
|
+
expect(split.showSidebar).toBe(true);
|
|
88
|
+
expect((root.querySelector('#sidebarToggleButton') as Toggle).active).toBe(true);
|
|
89
|
+
unmount();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await it('a narrow window collapses the split view and the toggle follows it', async () => {
|
|
93
|
+
const { root, unmount } = mountSharedTree(tree(500));
|
|
94
|
+
await settle();
|
|
95
|
+
const split = root.querySelector('#splitView') as SplitView;
|
|
96
|
+
expect(split.collapsed).toBe(true);
|
|
97
|
+
expect(split.showSidebar).toBe(false);
|
|
98
|
+
expect((root.querySelector('#sidebarToggleButton') as Toggle).active).toBe(false);
|
|
99
|
+
unmount();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await it('the toggle drives the split view back, and the cycle settles', async () => {
|
|
103
|
+
const { root, unmount } = mountSharedTree(tree(1100));
|
|
104
|
+
await settle();
|
|
105
|
+
const toggle = root.querySelector('#sidebarToggleButton') as Toggle;
|
|
106
|
+
const split = root.querySelector('#splitView') as SplitView;
|
|
107
|
+
toggle.active = false;
|
|
108
|
+
expect(split.showSidebar).toBe(false);
|
|
109
|
+
split.showSidebar = true;
|
|
110
|
+
expect(toggle.active).toBe(true);
|
|
111
|
+
toggle.active = false;
|
|
112
|
+
toggle.active = true;
|
|
113
|
+
expect(split.showSidebar).toBe(true);
|
|
114
|
+
expect(split.getAttribute('show-sidebar')).toBe('');
|
|
115
|
+
unmount();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await it('a tree mounted into a container lives in it, and unmount takes only the tree', () => {
|
|
119
|
+
const container = document.createElement('div');
|
|
120
|
+
document.body.append(container);
|
|
121
|
+
const { root, unmount } = mountSharedTree(tree(1100), { into: container });
|
|
122
|
+
expect(root.parentElement).toBe(container);
|
|
123
|
+
expect(container.querySelector('adw-application-window')).toBe(root);
|
|
124
|
+
unmount();
|
|
125
|
+
expect(container.childElementCount).toBe(0);
|
|
126
|
+
expect(container.isConnected).toBe(true);
|
|
127
|
+
container.remove();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
};
|
package/src/shared-trees.spec.ts
CHANGED
|
@@ -295,6 +295,21 @@ export const AdwSharedTreesTest = async () => {
|
|
|
295
295
|
});
|
|
296
296
|
});
|
|
297
297
|
|
|
298
|
+
await it('Adw.Banner with revealed: false stays closed — attribute must be absent, not "false"', () => {
|
|
299
|
+
const tree = blueprintTree(`Adw.Banner {
|
|
300
|
+
title: "Test";
|
|
301
|
+
revealed: false;
|
|
302
|
+
}`);
|
|
303
|
+
mounted(tree, (root) => {
|
|
304
|
+
const banner = root as HTMLElement;
|
|
305
|
+
// The banner reads 'revealed' by PRESENCE (hasAttribute), so the attribute must be absent.
|
|
306
|
+
// Currently the builder writes revealed="false", which makes hasAttribute return true.
|
|
307
|
+
expect(banner.hasAttribute('revealed')).toBe(false);
|
|
308
|
+
expect(banner.classList.contains('revealed')).toBe(false);
|
|
309
|
+
expect(getComputedStyle(banner).display).toBe('none');
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
298
313
|
await it('an authored false on a getter-only property keeps the presence rule instead of throwing', () => {
|
|
299
314
|
// `<adw-split-button>` exposes `active` as a read-only getter; assigning through it
|
|
300
315
|
// threw a bare TypeError out of the builder before the writability check.
|
package/src/split-button.spec.ts
CHANGED
|
@@ -291,6 +291,8 @@ export const AdwSplitButtonTest = async () => {
|
|
|
291
291
|
const { el, host } = mount({
|
|
292
292
|
'menu-model': '[{"label":"Copy","action":"app.copy"},{"label":"Copy","action":"app.copy-special"}]',
|
|
293
293
|
});
|
|
294
|
+
// GTK dims an item whose action is missing (0097 § 3), so the app declares both.
|
|
295
|
+
el.actions = { 'app.copy': {}, 'app.copy-special': {} };
|
|
294
296
|
const activated: Array<{ label: string; action?: string; id: string; path: number[] }> = [];
|
|
295
297
|
el.addEventListener('menu-activated', (event) => activated.push((event as CustomEvent).detail));
|
|
296
298
|
|