@gjsify/adwaita-web 0.43.0 → 0.44.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 CHANGED
@@ -53,6 +53,33 @@ Vite preset (or any Vite/bundler setup). Type declarations are shipped
53
53
  pre-built at `lib/types/index.d.ts`, so `gjsify tsc` consumers resolve the
54
54
  package's types without compiling its source.
55
55
 
56
+ ## Slots are live
57
+
58
+ These are light-DOM custom elements, so `slot="…"` is emulated rather than native
59
+ (see [ADR 0010](../../../docs/adr/0010-adwaita-web-style-isolation.md) for why the
60
+ package stays out of the shadow DOM). The emulation is **live**: a child appended
61
+ after the element is in the document lands in the same internal box as the
62
+ identical child written in the markup.
63
+
64
+ ```typescript
65
+ const row = document.querySelector('adw-action-row')!;
66
+ const toggle = document.createElement('adw-switch');
67
+ toggle.setAttribute('slot', 'suffix');
68
+ row.append(toggle); // lands in the suffix section, exactly as a declared one does
69
+ ```
70
+
71
+ Two limits are worth knowing:
72
+
73
+ - **Append, not reorder.** A late child goes to the end of its slot's box. Once a
74
+ child has been adopted it is no longer a child of the host, so
75
+ `host.insertBefore(next, adopted)` throws in the DOM before this package sees
76
+ it — reorder inside the box you were handed (`row.prefixSection`,
77
+ `view.topBar`, …). Native `<slot>` is what would lift this, and it is part of
78
+ the shadow-DOM path ADR 0010 defers.
79
+ - **An unknown slot name is assigned nowhere**, as in the native algorithm: a
80
+ `slot="sufix"` typo stays where you put it instead of quietly becoming default
81
+ content in the wrong box.
82
+
56
83
  ## Shared behaviour (`@gjsify/adwaita-core`)
57
84
 
58
85
  The widget *behaviour* — as opposed to the DOM rendering — comes from
@@ -0,0 +1 @@
1
+ export declare const AdwAlertDialogTest: () => Promise<void>;
@@ -31,6 +31,12 @@ export declare class AdwAlertDialog extends HTMLElement {
31
31
  set body(value: string);
32
32
  get open(): boolean;
33
33
  set open(value: boolean);
34
+ /** Whether {@link heading} is read as markup rather than plain text. */
35
+ get headingUseMarkup(): boolean;
36
+ set headingUseMarkup(value: boolean);
37
+ /** Whether {@link body} is read as markup rather than plain text. */
38
+ get bodyUseMarkup(): boolean;
39
+ set bodyUseMarkup(value: boolean);
34
40
  /** Whether to prefer horizontal button layout when the buttons fit. */
35
41
  get preferWideLayout(): boolean;
36
42
  set preferWideLayout(value: boolean);
@@ -41,6 +47,12 @@ export declare class AdwAlertDialog extends HTMLElement {
41
47
  get closeResponse(): string;
42
48
  set closeResponse(value: string);
43
49
  connectedCallback(): void;
50
+ /**
51
+ * Take one declared `<adw-alert-response>` as a response. An entry with no `id` is
52
+ * skipped rather than given a generated one: the id is what `response` events carry, so
53
+ * inventing one would hand the consumer a name it never wrote.
54
+ */
55
+ private _adoptResponse;
44
56
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
45
57
  /** Present the dialog. `parent` is accepted for API parity; unused. */
46
58
  present(_parent?: unknown): void;
@@ -16,8 +16,11 @@ export declare class AdwEntryRow extends HTMLElement {
16
16
  protected _prefixes: HTMLDivElement;
17
17
  protected _suffixes: HTMLDivElement;
18
18
  protected _initialized: boolean;
19
- /** Light-DOM children captured at build time, adopted after `_onConnected`. */
20
- private _authored;
19
+ /**
20
+ * The two slots, installed after `_onConnected` rather than inside `_build`: the order
21
+ * IS the contract — see the `install` call in `connectedCallback`.
22
+ */
23
+ private _slots;
21
24
  private _lastText;
22
25
  private _lastLength;
23
26
  static get observedAttributes(): string[];
@@ -76,11 +79,6 @@ export declare class AdwEntryRow extends HTMLElement {
76
79
  /** Subclass hook: runs after the DOM exists and the state is seeded, before the first render. */
77
80
  protected _onConnected(): void;
78
81
  private _build;
79
- /**
80
- * Re-home the children the author wrote. libadwaita's buildable maps an untyped
81
- * `<child>` to a suffix; `slot="prefix"` is the web spelling of `type="prefix"`.
82
- */
83
- private _adoptAuthored;
84
82
  /**
85
83
  * `pressed_cb`: a click landing on the row, the editable area, the indicator or
86
84
  * either affordance box grabs focus; anything else (a suffix control, the apply
@@ -26,5 +26,12 @@ export declare class AdwHeaderBar extends HTMLElement {
26
26
  * is bound to the property and re-renders on every change.
27
27
  */
28
28
  attributeChangedCallback(): void;
29
+ /**
30
+ * Give the centre up to a title-widget. `adw_header_bar_set_title_widget` destroys the
31
+ * derived `AdwWindowTitle` rather than stacking the two, so the late case has to remove
32
+ * it too — a bar showing both would be a shape neither GTK nor the declared markup can
33
+ * produce.
34
+ */
35
+ private _dropDerivedTitle;
29
36
  private _renderTitle;
30
37
  }
@@ -45,6 +45,15 @@ export declare class AdwNavigationSplitView extends HTMLElement {
45
45
  pop(): NavigationActionResult;
46
46
  connectedCallback(): void;
47
47
  private _buildOnce;
48
+ /**
49
+ * Mount whatever a pane box currently HOLDS into the state — `null` while it is empty.
50
+ *
51
+ * Re-mounting an unchanged pane re-emits the plan, because the state compares the page
52
+ * ref by identity. Both subscribers are idempotent reflections (an attribute and a
53
+ * class list), so a second child in the same pane costs a repaint decision and never a
54
+ * navigation event.
55
+ */
56
+ private _mountPane;
48
57
  /** `Adw.NavigationPage:tag` off the slotted pane, or `null` when untagged. */
49
58
  private _tagOf;
50
59
  /**
@@ -5,6 +5,12 @@ export declare class AdwPreferencesGroup extends HTMLElement {
5
5
  private _suffixEl;
6
6
  private _listboxEl;
7
7
  private _initialized;
8
+ /**
9
+ * Watches the two BOXES, not the host: the routing of a host child is
10
+ * `src/slotted-children.ts`'s job now, and what is left here is the header derivation,
11
+ * whose inputs are the row count and whether a suffix exists. A removal straight out of
12
+ * the listbox changes both and reaches no other callback.
13
+ */
8
14
  private _observer;
9
15
  static get observedAttributes(): string[];
10
16
  get description(): string;
@@ -13,10 +19,11 @@ export declare class AdwPreferencesGroup extends HTMLElement {
13
19
  * `adw_preferences_group_add`: a row goes into the boxed list, a `header-suffix`
14
20
  * child into the header.
15
21
  *
16
- * Exposed as a method AND wired to a `MutationObserver` on the host, because both
17
- * spellings occur — `group.addRow(row)` in code, `group.append(row)` from a
18
- * framework's rendering — and a bare child of the host would sit outside the card,
19
- * invisible to the row count that decides whether the card is painted at all.
22
+ * Kept as a method as well as a slot, because both spellings occur —
23
+ * `group.addRow(row)` in code, `group.append(row)` from a framework's rendering — and a
24
+ * bare child of the host would sit outside the card, invisible to the row count that
25
+ * decides whether the card is painted at all. The `append` spelling is routed by
26
+ * `src/slotted-children.ts`, which every other slotted element now shares.
20
27
  */
21
28
  addRow(child: Node): void;
22
29
  /**
@@ -29,13 +36,15 @@ export declare class AdwPreferencesGroup extends HTMLElement {
29
36
  disconnectedCallback(): void;
30
37
  attributeChangedCallback(): void;
31
38
  /**
32
- * Adopt children appended to the HOST after the subtree was built, so
33
- * `group.append(row)` behaves like `adw_preferences_group_add`.
39
+ * Re-derive the header whenever the two boxes change, in EITHER direction.
34
40
  *
35
41
  * C gets this for free: `gtk_widget_observe_children` on the listbox drives
36
- * `update_listbox_visibility` through `items-changed`. A DOM element needs the
37
- * observer for the same rule to hold over time rather than only at connect.
42
+ * `update_listbox_visibility` through `items-changed`. A DOM element needs the observer
43
+ * for the same rule to hold over time rather than only at connect. Adoption is not what
44
+ * this watches — a row appended to the host reaches the listbox through the shared slot
45
+ * routing, and lands here as the listbox record it causes. A row REMOVED straight out
46
+ * of the listbox reaches no other callback at all, and it still changes the row count.
38
47
  */
39
- private _observeHost;
48
+ private _observeBoxes;
40
49
  private _renderHeader;
41
50
  }
@@ -0,0 +1,65 @@
1
+ export interface AdwSlotClaim {
2
+ /** The `slot="…"` value claimed. Omit for the DEFAULT slot — everything else. */
3
+ readonly name?: string;
4
+ /**
5
+ * Claim by NODE as well as by slot name, for the typed-child spelling
6
+ * (`<adw-alert-response>` IS the slot) and for a default that takes only some node
7
+ * kinds (`<adw-entry-row>` adopts elements and drops stray whitespace, as GtkBuildable
8
+ * does). Tried after an explicit `slot=` name and before the default, so a claim can
9
+ * never swallow a child that named a different slot.
10
+ */
11
+ readonly claims?: (node: Node) => boolean;
12
+ }
13
+ /** A destination that RE-HOMES what it claims — the ordinary slot. */
14
+ export interface AdwSlotInto extends AdwSlotClaim {
15
+ readonly into: HTMLElement;
16
+ }
17
+ /**
18
+ * A slot whose child is consumed by a CALL rather than by a move: either data —
19
+ * `<adw-alert-response id="ok">` is read for its id, label and appearance and then gone,
20
+ * exactly as GtkBuilder leaves nothing of a `<responses>` entry in the widget tree — or a
21
+ * widget whose insertion rule is the element's own (`addPrefix` PREPENDS, mirroring
22
+ * `gtk_box_prepend`). A node the call did not move out of the host is dropped, so a
23
+ * data-only child cannot linger as a stray sibling of the internal structure.
24
+ */
25
+ export interface AdwSlotConsume extends AdwSlotClaim {
26
+ readonly consume: (node: Node) => void;
27
+ }
28
+ export type AdwSlot = AdwSlotInto | AdwSlotConsume;
29
+ export interface AdwSlottedChildren {
30
+ /** The declared slots, so a driver can probe them without a hand-written ledger. */
31
+ readonly slots: readonly AdwSlot[];
32
+ /**
33
+ * Route the host's CURRENT children into their slots, then make `structure` the
34
+ * host's own children, then stay live.
35
+ *
36
+ * The three steps are one call because their ORDER is the invariant: the author's
37
+ * children have to leave the host before it is emptied, or `replaceChildren` destroys
38
+ * them — the bug `<adw-entry-row>` records above its own `_authored` snapshot. Passing
39
+ * `structure` here also means the set this module must never route is DERIVED from the
40
+ * call that installs it, rather than a second list to keep in sync.
41
+ *
42
+ * Call once per built subtree; a second call replaces the observer rather than
43
+ * stacking one.
44
+ */
45
+ install(...structure: readonly Node[]): void;
46
+ /** The slot `node` belongs to, or `null` when none claims it. */
47
+ slotFor(node: Node): AdwSlot | null;
48
+ }
49
+ /**
50
+ * The slot binding `host` installed, if it has one.
51
+ *
52
+ * This is how `slotted-children.spec.ts` drives EVERY registered element rather than a
53
+ * list of the ones someone remembered: an element that binds a slot enrols itself in the
54
+ * placement driver, and one that stops binding drops out of it visibly.
55
+ */
56
+ export declare function slottedChildrenOf(host: Element): AdwSlottedChildren | undefined;
57
+ /**
58
+ * Bind `host`'s light-DOM children to `slots`.
59
+ *
60
+ * `onAdopt` fires for a child adopted AFTER {@link AdwSlottedChildren.install} and not
61
+ * during it: the install pass IS the element's own build, which seeds its state on the
62
+ * next line with everything that landed, so firing there would make every element's build
63
+ * order depend on how many children the author wrote.
64
+ */
65
+ export declare function bindSlottedChildren(host: HTMLElement, slots: readonly AdwSlot[], onAdopt?: (node: Node, slot: AdwSlot) => void): AdwSlottedChildren;
@@ -0,0 +1 @@
1
+ export declare const AdwSlottedChildrenTest: () => Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/adwaita-web",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "description": "Adwaita/Libadwaita web components for browser targets",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -45,14 +45,14 @@
45
45
  "@codemirror/language": "^6.12.4",
46
46
  "@codemirror/state": "^6.7.0",
47
47
  "@codemirror/view": "^6.43.4",
48
- "@gjsify/adwaita-core": "^0.43.0",
49
- "@gjsify/adwaita-fonts": "^0.43.0",
50
- "@gjsify/adwaita-icons": "^0.43.0",
48
+ "@gjsify/adwaita-core": "^0.44.0",
49
+ "@gjsify/adwaita-fonts": "^0.44.0",
50
+ "@gjsify/adwaita-icons": "^0.44.0",
51
51
  "@lezer/highlight": "^1.2.3"
52
52
  },
53
53
  "devDependencies": {
54
- "@gjsify/cli": "^0.43.0",
55
- "@gjsify/unit": "^0.43.0",
54
+ "@gjsify/cli": "^0.44.0",
55
+ "@gjsify/unit": "^0.44.0",
56
56
  "sass": "^1.101.0",
57
57
  "typescript": "^6.0.3"
58
58
  },
@@ -0,0 +1,146 @@
1
+ // <adw-alert-dialog> against `Adw.AlertDialog`'s own property list.
2
+ //
3
+ // This element shipped with FOUR of its eight GIR properties reachable from markup —
4
+ // `heading`, `body`, `open` (a web convention with no GIR counterpart) and
5
+ // `prefer-wide-layout`. `close-response` and `default-response` existed as JS
6
+ // properties but no attribute fed them, and `heading-use-markup`/`body-use-markup`
7
+ // were absent entirely. The website's generated widget table read
8
+ // `observedAttributes` and truthfully reported "takes 4 attributes", which is how the
9
+ // gap became visible: the DOC was right and the ELEMENT was short.
10
+ //
11
+ // The markup pair defaults to FALSE here and in libadwaita alike — unlike
12
+ // `Adw.Banner:use-markup`, whose C default is TRUE and which `adw-banner.ts` therefore
13
+ // departs from on purpose. So these vectors pin a MATCH, not a departure, and the
14
+ // negative case is the one that matters: tags must show literally until asked for.
15
+ import { describe, expect, it } from '@gjsify/unit';
16
+
17
+ import type { AdwAlertDialog } from './elements/adw-alert-dialog.js';
18
+
19
+ /** Mount via `setAttribute` so an attribute value's exact text survives. */
20
+ function mount(attributes: Record<string, string> = {}): { el: AdwAlertDialog; host: HTMLElement } {
21
+ const host = document.createElement('div');
22
+ document.body.appendChild(host);
23
+ const el = document.createElement('adw-alert-dialog') as AdwAlertDialog;
24
+ for (const [name, value] of Object.entries(attributes)) el.setAttribute(name, value);
25
+ host.appendChild(el);
26
+ return { el, host };
27
+ }
28
+
29
+ const headingEl = (el: AdwAlertDialog): HTMLElement => el.querySelector('.adw-alert-dialog-heading') as HTMLElement;
30
+ const bodyEl = (el: AdwAlertDialog): HTMLElement => el.querySelector('.adw-alert-dialog-body') as HTMLElement;
31
+
32
+ export const AdwAlertDialogTest = async () => {
33
+ await describe('adw-alert-dialog observes every Adw.AlertDialog property it can render', async () => {
34
+ // Named rather than counted: a count passes again the moment someone swaps one
35
+ // attribute for another, which is the drift this file exists to catch.
36
+ for (const name of [
37
+ 'heading',
38
+ 'body',
39
+ 'heading-use-markup',
40
+ 'body-use-markup',
41
+ 'close-response',
42
+ 'default-response',
43
+ 'prefer-wide-layout',
44
+ ]) {
45
+ await it(`observes \`${name}\``, () => {
46
+ expect(
47
+ (
48
+ customElements.get('adw-alert-dialog') as typeof HTMLElement & {
49
+ observedAttributes: string[];
50
+ }
51
+ ).observedAttributes,
52
+ ).toContain(name);
53
+ });
54
+ }
55
+ });
56
+
57
+ await describe('adw-alert-dialog markup is opt-in, matching the libadwaita default', async () => {
58
+ await it('a heading with tags shows them literally by default', () => {
59
+ const { el, host } = mount({ heading: '<b>Delete</b> file?' });
60
+ expect(headingEl(el).textContent).toBe('<b>Delete</b> file?');
61
+ expect(headingEl(el).querySelector('b')).toBe(null);
62
+ host.remove();
63
+ });
64
+
65
+ await it('heading-use-markup renders it, which is the author asserting it is trusted', () => {
66
+ const { el, host } = mount({ heading: '<b>Delete</b> file?', 'heading-use-markup': '' });
67
+ expect(headingEl(el).querySelector('b')?.textContent).toBe('Delete');
68
+ host.remove();
69
+ });
70
+
71
+ await it('a body with tags shows them literally by default', () => {
72
+ const { el, host } = mount({ body: 'Really delete <i>notes.txt</i>?' });
73
+ expect(bodyEl(el).textContent).toBe('Really delete <i>notes.txt</i>?');
74
+ expect(bodyEl(el).querySelector('i')).toBe(null);
75
+ host.remove();
76
+ });
77
+
78
+ await it('body-use-markup renders it', () => {
79
+ const { el, host } = mount({ body: 'Really delete <i>notes.txt</i>?', 'body-use-markup': '' });
80
+ expect(bodyEl(el).querySelector('i')?.textContent).toBe('notes.txt');
81
+ host.remove();
82
+ });
83
+
84
+ await it('toggling the attribute off returns to text', () => {
85
+ const { el, host } = mount({ heading: '<b>Delete</b>', 'heading-use-markup': '' });
86
+ el.headingUseMarkup = false;
87
+ expect(headingEl(el).textContent).toBe('<b>Delete</b>');
88
+ expect(headingEl(el).querySelector('b')).toBe(null);
89
+ host.remove();
90
+ });
91
+ });
92
+
93
+ await describe('adw-alert-dialog response IDs are reachable from markup', async () => {
94
+ await it('close-response defaults to `close`, as in Adw.AlertDialog', () => {
95
+ const { el, host } = mount();
96
+ expect(el.closeResponse).toBe('close');
97
+ host.remove();
98
+ });
99
+
100
+ await it('the attribute feeds the headless model', () => {
101
+ const { el, host } = mount({ 'close-response': 'cancel' });
102
+ expect(el.closeResponse).toBe('cancel');
103
+ host.remove();
104
+ });
105
+
106
+ await it('default-response defaults to null and the attribute sets it', () => {
107
+ const { el, host } = mount();
108
+ expect(el.defaultResponse).toBe(null);
109
+ el.setAttribute('default-response', 'delete');
110
+ expect(el.defaultResponse).toBe('delete');
111
+ host.remove();
112
+ });
113
+
114
+ await it('removing default-response clears it', () => {
115
+ const { el, host } = mount({ 'default-response': 'delete' });
116
+ el.removeAttribute('default-response');
117
+ expect(el.defaultResponse).toBe(null);
118
+ host.remove();
119
+ });
120
+
121
+ await it('a dismissal emits the close response the attribute named', () => {
122
+ // The scrim click, not `open = false`. `_dismiss()` SETS `open = false` and
123
+ // then emits, so the property is the lower-level state change and driving it
124
+ // directly would assert a contract this element does not have.
125
+ const { el, host } = mount({ 'close-response': 'cancel', open: '' });
126
+ let seen: string | null = null;
127
+ el.addEventListener('response', (event) => {
128
+ seen = (event as CustomEvent<{ response: string }>).detail.response;
129
+ });
130
+ (el.querySelector('.adw-alert-dialog-scrim') as HTMLElement).click();
131
+ expect(seen).toBe('cancel');
132
+ expect(el.open).toBe(false);
133
+ host.remove();
134
+ });
135
+
136
+ await it('removing close-response keeps the model value, unlike default-response', () => {
137
+ // An ASYMMETRY worth pinning rather than discovering: `close-response` has a
138
+ // non-null GIR default (`close`), so an absent attribute means "leave the
139
+ // model alone", while `default-response` defaults to null and clears.
140
+ const { el, host } = mount({ 'close-response': 'cancel' });
141
+ el.removeAttribute('close-response');
142
+ expect(el.closeResponse).toBe('cancel');
143
+ host.remove();
144
+ });
145
+ });
146
+ };
@@ -68,8 +68,10 @@ function mountSizedHeaderBar(width: number): HTMLElement {
68
68
  bar.appendChild(button);
69
69
  }
70
70
  }
71
- // Appended LAST: the sections are built in `connectedCallback`, so the slotted
72
- // children have to be in place before the bar enters the document.
71
+ // The bar enters the document last, so this geometry is measured on the PARSE-TIME
72
+ // path. It used to be the only path that worked — the sections were built in
73
+ // `connectedCallback` from a one-shot snapshot — and that is no longer a constraint:
74
+ // `slotted-children.spec.ts` requires the appended path to place a child identically.
73
75
  host.appendChild(bar);
74
76
  return bar;
75
77
  }
@@ -17,6 +17,7 @@
17
17
  import { ActionRowState } from '@gjsify/adwaita-core';
18
18
 
19
19
  import { bindEmptySections } from '../empty-sections.js';
20
+ import { bindSlottedChildren } from '../slotted-children.js';
20
21
  import { type AdwRowActivation, attachRowActivation } from './row-activation.js';
21
22
 
22
23
  /**
@@ -90,12 +91,8 @@ export class AdwActionRow extends HTMLElement {
90
91
  }
91
92
  this._initialized = true;
92
93
 
93
- const prefixChildren = Array.from(this.querySelectorAll(':scope > [slot="prefix"]'));
94
- const suffixChildren = Array.from(this.querySelectorAll(':scope > [slot="suffix"]'));
95
-
96
94
  this._prefixEl = document.createElement('div');
97
95
  this._prefixEl.className = 'adw-action-row-prefix';
98
- for (const child of prefixChildren) this._prefixEl.appendChild(child);
99
96
 
100
97
  const textEl = document.createElement('div');
101
98
  textEl.className = 'adw-action-row-text';
@@ -107,9 +104,14 @@ export class AdwActionRow extends HTMLElement {
107
104
 
108
105
  this._suffixEl = document.createElement('div');
109
106
  this._suffixEl.className = 'adw-action-row-suffix';
110
- for (const child of suffixChildren) this._suffixEl.appendChild(child);
111
107
 
112
- this.replaceChildren(this._prefixEl, textEl, this._suffixEl);
108
+ // The two slots stay LIVE — a control appended with `slot="suffix"` after connect
109
+ // has to land in the same section the declared one does, which a snapshot taken
110
+ // here cannot do. `src/slotted-children.ts` carries the incident.
111
+ bindSlottedChildren(this, [
112
+ { name: 'prefix', into: this._prefixEl },
113
+ { name: 'suffix', into: this._suffixEl },
114
+ ]).install(this._prefixEl, textEl, this._suffixEl);
113
115
  // `_render` runs from `attributeChangedCallback`, which a control appended into
114
116
  // `suffixSection` never triggers — the section stayed `hidden` and the control
115
117
  // measured 0x0. The observer is what keeps the two sections in step with what