@gjsify/adwaita-web 0.64.1-next.241 → 0.64.1-next.243

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/adwaita-web",
3
- "version": "0.64.1-next.241",
3
+ "version": "0.64.1-next.243",
4
4
  "description": "Adwaita/Libadwaita web components for browser targets",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -58,14 +58,14 @@
58
58
  "@codemirror/language": "^6.12.4",
59
59
  "@codemirror/state": "^6.7.0",
60
60
  "@codemirror/view": "^6.43.4",
61
- "@gjsify/adwaita-core": "0.64.1-next.241",
62
- "@gjsify/adwaita-fonts": "0.64.1-next.241",
63
- "@gjsify/adwaita-icons": "0.64.1-next.241",
64
- "@gjsify/gtksource-core": "0.64.1-next.241",
61
+ "@gjsify/adwaita-core": "0.64.1-next.243",
62
+ "@gjsify/adwaita-fonts": "0.64.1-next.243",
63
+ "@gjsify/adwaita-icons": "0.64.1-next.243",
64
+ "@gjsify/gtksource-core": "0.64.1-next.243",
65
65
  "@lezer/highlight": "^1.2.3"
66
66
  },
67
67
  "peerDependencies": {
68
- "@gjsify/app-settings": "0.64.1-next.241"
68
+ "@gjsify/app-settings": "0.64.1-next.243"
69
69
  },
70
70
  "peerDependenciesMeta": {
71
71
  "@gjsify/app-settings": {
@@ -73,11 +73,11 @@
73
73
  }
74
74
  },
75
75
  "devDependencies": {
76
- "@gjsify/app-settings": "0.64.1-next.241",
77
- "@gjsify/blueprint": "0.64.1-next.241",
78
- "@gjsify/cli": "0.64.1-next.241",
79
- "@gjsify/unit": "0.64.1-next.241",
80
- "@gjsify/vite-plugin-blueprint": "0.64.1-next.241",
76
+ "@gjsify/app-settings": "0.64.1-next.243",
77
+ "@gjsify/blueprint": "0.64.1-next.243",
78
+ "@gjsify/cli": "0.64.1-next.243",
79
+ "@gjsify/unit": "0.64.1-next.243",
80
+ "@gjsify/vite-plugin-blueprint": "0.64.1-next.243",
81
81
  "sass": "^1.101.0",
82
82
  "typescript": "^6.0.3"
83
83
  },
@@ -115,5 +115,5 @@
115
115
  "url": "https://github.com/gjsify/gjsify/issues"
116
116
  },
117
117
  "homepage": "https://github.com/gjsify/gjsify/tree/main/packages/web/adwaita-web#readme",
118
- "gitHead": "9937e8d27d18d159e35471b6669bdca6dff0cd05"
118
+ "gitHead": "f88c7b1074d454e211094963a9f4dc7d0982bf32"
119
119
  }
@@ -149,10 +149,10 @@ adw-about-dialog {
149
149
  height: 96px;
150
150
  margin-top: var(--spacing-s);
151
151
  margin-bottom: var(--spacing-m);
152
- // Mask-driven .adw-icon skin scaled up to the about-dialog app-icon size.
152
+ // A symbolic glyph scales with the box; the fill stays `.adw-icon`'s `currentColor`,
153
+ // which `.adw-icon-color` turns off for a registered colour application icon.
153
154
  -webkit-mask-size: contain;
154
155
  mask-size: contain;
155
- background-color: currentColor;
156
156
  }
157
157
 
158
158
  // --- App name (title-1) ---
package/scss/_icon.scss CHANGED
@@ -96,3 +96,21 @@
96
96
  .adw-icon:not([class*='adw-icon--']) {
97
97
  background-color: transparent;
98
98
  }
99
+
100
+ // A FULL-COLOUR icon — what `registerColorIcon()` registers and `<gtk-image>` resolves a
101
+ // name to before (or, for `-symbolic` names, after) the symbolic glyph, in the order GTK's
102
+ // theme tries them. It is drawn as-is, the way GTK draws `hicolor/…/apps/<app-id>.svg`: no
103
+ // mask and no `currentColor` fill, only the image the element puts in `background-image`.
104
+ //
105
+ // The modifier is NOT `adw-icon--…`, so the rule above never sees it as a mask class.
106
+ // Two classes beat both `:where(.adw-icon)` and the one-class sizing rules a context sets
107
+ // (`.adw-about-dialog-icon`'s mask-size), so an icon box drawn in colour never picks up a
108
+ // leftover mask from them.
109
+ .adw-icon.adw-icon-color {
110
+ mask-image: none;
111
+ -webkit-mask-image: none;
112
+ background-color: transparent;
113
+ background-position: center;
114
+ background-repeat: no-repeat;
115
+ background-size: contain;
116
+ }
@@ -63,7 +63,55 @@ export class Canvas2DBridgeElement extends HostElement {
63
63
  private _drawFrame: number | null = null;
64
64
 
65
65
  static get observedAttributes() {
66
- return ['content-width', 'content-height'];
66
+ return ['content-width', 'content-height', 'focusable', 'can-focus'];
67
+ }
68
+
69
+ /**
70
+ * `Gtk.Widget:focusable`, whether the widget can hold the keyboard focus. `Gtk.DrawingArea`
71
+ * leaves it FALSE, so it is read by PRESENCE; a blueprint's `focusable: true` writes it.
72
+ */
73
+ get focusable(): boolean {
74
+ return this.hasAttribute('focusable');
75
+ }
76
+
77
+ set focusable(v: boolean) {
78
+ this.toggleAttribute('focusable', !!v);
79
+ }
80
+
81
+ /** `Gtk.Widget:can-focus`, TRUE by default, so it is read by VALUE. */
82
+ get canFocus(): boolean {
83
+ return this.getAttribute('can-focus') !== 'false';
84
+ }
85
+
86
+ set canFocus(v: boolean) {
87
+ this.setAttribute('can-focus', v ? 'true' : 'false');
88
+ }
89
+
90
+ get can_focus(): boolean {
91
+ return this.canFocus;
92
+ }
93
+
94
+ set can_focus(v: boolean) {
95
+ this.canFocus = v;
96
+ }
97
+
98
+ /**
99
+ * `gtk_widget_grab_focus`: moves the keyboard focus here and says whether it landed.
100
+ * FALSE when the widget is not focusable or cannot focus, as in GTK.
101
+ */
102
+ grab_focus(): boolean {
103
+ if (!this.focusable || !this.canFocus) return false;
104
+ this.focus();
105
+ return this.ownerDocument.activeElement === this;
106
+ }
107
+
108
+ grabFocus(): boolean {
109
+ return this.grab_focus();
110
+ }
111
+
112
+ private _syncFocus(): void {
113
+ if (this.focusable && this.canFocus) this.tabIndex = 0;
114
+ else this.removeAttribute('tabindex');
67
115
  }
68
116
 
69
117
  /** `Gtk.DrawingArea:content-width`, the minimum and natural width. Defaults to 0. */
@@ -161,6 +209,7 @@ export class Canvas2DBridgeElement extends HostElement {
161
209
  this.style.overflow = 'hidden';
162
210
  this.style.position = 'relative';
163
211
  this._publishSize();
212
+ this._syncFocus();
164
213
  this._observe();
165
214
  }
166
215
 
@@ -171,6 +220,7 @@ export class Canvas2DBridgeElement extends HostElement {
171
220
 
172
221
  attributeChangedCallback(name: string) {
173
222
  if (name === 'content-width' || name === 'content-height') this._publishSize();
223
+ else this._syncFocus();
174
224
  }
175
225
 
176
226
  private _publishSize(): void {
@@ -0,0 +1,47 @@
1
+ // DOM-level tests for <gjsify-canvas2d-bridge>: the keyboard focus a game console needs for its keys.
2
+ import { describe, expect, it } from '@gjsify/unit';
3
+
4
+ import type { Canvas2DBridgeElement } from './canvas2d/canvas2d-bridge.js';
5
+
6
+ function mount(attrs: Record<string, string> = {}): { el: Canvas2DBridgeElement; host: HTMLElement } {
7
+ const host = document.createElement('div');
8
+ document.body.appendChild(host);
9
+ const el = document.createElement('gjsify-canvas2d-bridge') as Canvas2DBridgeElement;
10
+ for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value);
11
+ host.appendChild(el);
12
+ return { el, host };
13
+ }
14
+
15
+ export const Canvas2DBridgeTest = async () => {
16
+ await describe('<gjsify-canvas2d-bridge> focus', async () => {
17
+ await it('is not focusable by default, so grab_focus returns false', () => {
18
+ const { el, host } = mount();
19
+ expect(el.focusable).toBe(false);
20
+ expect(el.grab_focus()).toBe(false);
21
+ expect(document.activeElement === el).toBe(false);
22
+ host.remove();
23
+ });
24
+
25
+ await it('grab_focus takes the keyboard focus when focusable', () => {
26
+ const { el, host } = mount({ focusable: '' });
27
+ expect(el.grab_focus()).toBe(true);
28
+ expect(document.activeElement === el).toBe(true);
29
+ host.remove();
30
+ });
31
+
32
+ await it('a focusable set after connect makes the element focusable', () => {
33
+ const { el, host } = mount();
34
+ el.focusable = true;
35
+ expect(el.grab_focus()).toBe(true);
36
+ host.remove();
37
+ });
38
+
39
+ await it('can-focus false refuses the focus', () => {
40
+ const { el, host } = mount({ focusable: '' });
41
+ el.canFocus = false;
42
+ expect(el.can_focus).toBe(false);
43
+ expect(el.grab_focus()).toBe(false);
44
+ host.remove();
45
+ });
46
+ });
47
+ };
@@ -19,8 +19,10 @@
19
19
  // Properties / attributes (all reflected, mirroring the AdwAboutDialog GObject
20
20
  // properties of the same name, hyphenated):
21
21
  // application-name the application's display name (shown as a title-1)
22
- // application-icon a symbolic icon name (with or without -symbolic); absent
23
- // one, no icon is drawn
22
+ // application-icon an icon name, by convention the application ID; drawn in
23
+ // colour once `registerColorIcon(id, …)` registered it, else
24
+ // as a symbolic glyph or `image-missing` (`<gtk-image>`'s
25
+ // lookup); absent one, no icon is drawn
24
26
  // developer-name the developer / vendor line below the name
25
27
  // version the version, shown as a clickable pill
26
28
  // comments a longer description (Details page)
@@ -1,14 +1,17 @@
1
- // <gtk-image> — the symbolic icon node as an element. Every icon in this package is a
2
- // CSS-masked box whose glyph comes from a generated `.adw-icon--<name>` class and
3
- // whose colour is `currentColor`, so it themes with whatever contains it. The name
4
- // derivation and its guard live once in `@gjsify/adwaita-core`'s
5
- // {@link normalizeIconName}, the markup once here.
1
+ // <gtk-image> — the icon node as an element. A symbolic icon is a CSS-masked box whose
2
+ // glyph comes from a generated `.adw-icon--<name>` class and whose colour is
3
+ // `currentColor`, so it themes with whatever contains it. A full-colour icon registered
4
+ // through `registerColorIcon` is the same box with the image as its background and no
5
+ // mask. Which of the two a name reaches is decided once, in `../icon-theme.ts`, in the
6
+ // order GTK's own theme tries them; the symbolic name derivation and its guard live once
7
+ // in `@gjsify/adwaita-core`'s {@link normalizeIconName}, the markup once here.
6
8
  //
7
9
  // Attributes:
8
- // icon-name — symbolic name, with or without the `-symbolic` suffix. A name that is
9
- // not a single CSS token (a space, a quote, a reverse-DNS application
10
- // id) never reaches a class — it draws {@link MISSING_ICON_NAME}, which
11
- // is what GTK draws for it, rather than injecting markup.
10
+ // icon-name — icon name, `-symbolic` suffix optional. A name that is neither a
11
+ // registered colour icon nor a single CSS token (a space, a quote, an
12
+ // unregistered reverse-DNS application id) never reaches a class — it
13
+ // draws {@link MISSING_ICON_NAME}, which is what GTK draws for it, rather
14
+ // than injecting markup.
12
15
  // size — rendered edge length in px. Absent leaves the stylesheet's 16px in
13
16
  // charge; a context that sizes its own icons through CSS
14
17
  // (`.adw-about-dialog-icon`, the split button's 14px arrows) keeps doing
@@ -30,7 +33,9 @@
30
33
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
31
34
  // name derivation composed from @gjsify/adwaita-core.
32
35
 
33
- import { normalizeIconName, stringIsNotEmpty } from '@gjsify/adwaita-core';
36
+ import { normalizeIconName } from '@gjsify/adwaita-core';
37
+
38
+ import { lookupIcon, onIconThemeChanged } from '../icon-theme.js';
34
39
 
35
40
  /**
36
41
  * The generated mask-class prefix — the one place the `--` spelling appears. `icon-registry`
@@ -46,6 +51,13 @@ export const MASK_CLASS_PREFIX = 'adw-icon--';
46
51
  */
47
52
  export const MISSING_ICON_NAME = 'image-missing';
48
53
 
54
+ /**
55
+ * The modifier a colour icon carries. Deliberately NOT under {@link MASK_CLASS_PREFIX}: the
56
+ * stale-class sweep below and `.adw-icon:not([class*='adw-icon--'])` both read that prefix
57
+ * as "a mask is applied", and a colour icon has none.
58
+ */
59
+ export const COLOR_ICON_CLASS = 'adw-icon-color';
60
+
49
61
  export class GtkImage extends HTMLElement {
50
62
  static get observedAttributes() {
51
63
  return ['icon-name', 'size'];
@@ -62,9 +74,10 @@ export class GtkImage extends HTMLElement {
62
74
  }
63
75
 
64
76
  /**
65
- * The name that actually reached the mask class — `''` when the icon-name is
66
- * absent, empty, or not usable as one CSS token. Consumers that need to know
67
- * whether the DECLARED name is drawable ask this, not the raw attribute.
77
+ * The name the SYMBOLIC lookup works with — `''` when the icon-name is absent, empty,
78
+ * or not usable as one CSS token. A registered colour icon can still draw for a name
79
+ * that answers `''` here (`org.gnome.Builder`); whether a name draws anything but
80
+ * `image-missing` is `isIconAvailable`'s question.
68
81
  *
69
82
  * `''` does NOT mean the box draws nothing: an unusable name that was given still
70
83
  * draws {@link MISSING_ICON_NAME}. Whether an icon was ASKED FOR is the raw
@@ -85,10 +98,18 @@ export class GtkImage extends HTMLElement {
85
98
  else this.setAttribute('size', String(value));
86
99
  }
87
100
 
101
+ private _unsubscribe: (() => void) | null = null;
102
+
88
103
  connectedCallback() {
104
+ this._unsubscribe ??= onIconThemeChanged(() => this._render());
89
105
  this._render();
90
106
  }
91
107
 
108
+ disconnectedCallback() {
109
+ this._unsubscribe?.();
110
+ this._unsubscribe = null;
111
+ }
112
+
92
113
  attributeChangedCallback() {
93
114
  this._render();
94
115
  }
@@ -102,20 +123,30 @@ export class GtkImage extends HTMLElement {
102
123
  // would skip the entry after each hit.
103
124
  const stale = [...this.classList].filter((existing) => existing.startsWith(MASK_CLASS_PREFIX));
104
125
  this.classList.remove(...stale);
105
- const name = this.resolvedIconName;
106
- if (name !== '') {
107
- this.classList.add(`${MASK_CLASS_PREFIX}${name}`);
108
- } else if (stringIsNotEmpty(this.getAttribute('icon-name'))) {
109
- // A name was GIVEN and cannot be one CSS token (a space, a quote, a reverse-DNS
110
- // application id). GTK has no such state: `gtk_icon_theme_lookup_icon` never
111
- // returns NULL, it returns the always-available `image-missing`
112
- // (refs/gtk/gtk/gtkicontheme.c — `gtk_icon_paintable_get_icon_name`'s own docs
113
- // say so). So the same glyph is drawn here rather than an invisible 16px hole,
114
- // which is what `<adw-button-row start-icon-name="a b">` reserved and showed.
126
+ const found = lookupIcon(this.getAttribute('icon-name'));
127
+ this.classList.toggle(COLOR_ICON_CLASS, found?.kind === 'color');
128
+ if (found?.kind === 'color') {
129
+ // Drawn as-is: `_icon.scss` drops the mask and the `currentColor` fill for this
130
+ // modifier, so only the image paints. `url` is a data-URI or a resolved href;
131
+ // the quote and backslash escapes keep it one CSS string either way.
132
+ this.style.backgroundImage = `url("${found.url.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}")`;
133
+ } else {
134
+ this.style.removeProperty('background-image');
135
+ }
136
+ if (found?.kind === 'symbolic') {
137
+ this.classList.add(`${MASK_CLASS_PREFIX}${found.name}`);
138
+ } else if (found?.kind === 'missing') {
139
+ // A name was GIVEN and nothing answers it — not a colour icon, and not one CSS
140
+ // token (a space, a quote, an unregistered reverse-DNS application id). GTK has
141
+ // no such state: `gtk_icon_theme_lookup_icon` never returns NULL, it returns the
142
+ // always-available `image-missing` (refs/gtk/gtk/gtkicontheme.c —
143
+ // `gtk_icon_paintable_get_icon_name`'s own docs say so). So the same glyph is
144
+ // drawn here rather than an invisible 16px hole, which is what
145
+ // `<adw-button-row start-icon-name="a b">` reserved and showed.
115
146
  this.classList.add(`${MASK_CLASS_PREFIX}${MISSING_ICON_NAME}`);
116
147
  }
117
148
 
118
- // A masked box has no text, so assistive tech has nothing to announce — the
149
+ // An icon box has no text, so assistive tech has nothing to announce — the
119
150
  // accessible name belongs to the control that HOSTS the icon.
120
151
  this.setAttribute('aria-hidden', 'true');
121
152
 
@@ -394,6 +394,36 @@ export class GtkScrolledWindow extends HTMLElement {
394
394
  return this._bars?.vertical ?? null;
395
395
  }
396
396
 
397
+ /** `Gtk.ScrolledWindow:child` — the widget inside the viewport, or the one waiting to be adopted. */
398
+ get child(): Element | null {
399
+ return this._built ? this._viewport.firstElementChild : this.firstElementChild;
400
+ }
401
+
402
+ set child(value: Element | null) {
403
+ this.set_child(value);
404
+ }
405
+
406
+ get_child(): Element | null {
407
+ return this.child;
408
+ }
409
+
410
+ /**
411
+ * `gtk_scrolled_window_set_child`: replace the child, which sits in the viewport. Before the
412
+ * first connect there is no viewport yet, so the child waits in the light DOM where
413
+ * `connectedCallback` adopts it the way it adopts a declared one.
414
+ */
415
+ set_child(child: Element | null): void {
416
+ const current = this.child;
417
+ if (child === current) return;
418
+ if (this._built) {
419
+ this._viewport.replaceChildren(...(child === null ? [] : [child]));
420
+ } else {
421
+ current?.remove();
422
+ if (child !== null) this.appendChild(child);
423
+ }
424
+ this.dispatchEvent(new CustomEvent('notify::child', { bubbles: true, detail: { child } }));
425
+ }
426
+
397
427
  /** The `<gtk-viewport>` the child sits in — the one GTK's `set_child` builds for itself. */
398
428
  get viewport(): GtkViewport {
399
429
  return this._viewport;
@@ -408,7 +438,13 @@ export class GtkScrolledWindow extends HTMLElement {
408
438
  horizontal.setAttribute('orientation', 'horizontal');
409
439
  vertical.setAttribute('orientation', 'vertical');
410
440
  this._bars = { horizontal, vertical };
411
- bindSlottedChildren(this, [{ into: this._viewport }]).install(this._viewport, vertical, horizontal);
441
+ // `Gtk.ScrolledWindow:child` is a widget PROPERTY, so an authored `child: …` names the
442
+ // slot by that name; the bare child is the default. Both land in the viewport.
443
+ bindSlottedChildren(this, [{ name: 'child', into: this._viewport }, { into: this._viewport }]).install(
444
+ this._viewport,
445
+ vertical,
446
+ horizontal,
447
+ );
412
448
  this.setAttribute('role', 'generic');
413
449
  // A bar is a VIEW of the viewport's adjustment, so the viewport's
414
450
  // `value-changed` is what moves the content and a bar's is what moves the
@@ -62,6 +62,34 @@ GObject.registerClass(
62
62
  GoWebHexdump as never,
63
63
  );
64
64
 
65
+ const SensitiveTemplate = tree(`template $GoWebSensitive : Adw.Bin {
66
+ child: Gtk.Box {
67
+ Gtk.Button btn { sensitive: bind template.enabled; }
68
+ Gtk.Switch sw { sensitive: bind template.enabled inverted; }
69
+ Gtk.Label lbl { sensitive: bind template.enabled; }
70
+ };
71
+ }`);
72
+
73
+ class GoWebSensitive extends Adw.Bin {
74
+ declare _btn: HTMLButtonElement;
75
+ declare _sw: HTMLElement & { disabled: boolean };
76
+ declare _lbl: HTMLElement;
77
+ declare enabled: boolean;
78
+ constructor(params?: Record<string, unknown>) {
79
+ // @ts-expect-error the element's own constructor takes none; the registered class's takes the params
80
+ super(params);
81
+ }
82
+ }
83
+ GObject.registerClass(
84
+ {
85
+ GTypeName: 'GoWebSensitive',
86
+ Template: SensitiveTemplate,
87
+ InternalChildren: ['btn', 'sw', 'lbl'],
88
+ Properties: { enabled: GObject.ParamSpec.boolean('enabled', '', '', GObject.ParamFlags.READWRITE, true) },
89
+ },
90
+ GoWebSensitive as never,
91
+ );
92
+
65
93
  export const GObjectDoorTest = async () => {
66
94
  await driveGObjectVectors(subject, { describe, it, expect }, GOBJECT_VECTORS);
67
95
  await describe('adwaita-web: GObject.registerClass end to end (ADR 0096)', async () => {
@@ -116,4 +144,20 @@ export const GObjectDoorTest = async () => {
116
144
  });
117
145
  }
118
146
  });
147
+ await describe('adwaita-web: Gtk.Widget:sensitive in a template bind', async () => {
148
+ await it('binds sensitive to a template property, following it both ways of the flag', () => {
149
+ const el = new GoWebSensitive();
150
+ expect(el._btn.hasAttribute('disabled')).toBe(false);
151
+ expect(el._lbl.getAttribute('aria-disabled')).toBe(null);
152
+ el.enabled = false;
153
+ expect(el._btn.hasAttribute('disabled')).toBe(true);
154
+ expect((el._btn as unknown as { sensitive: boolean }).sensitive).toBe(false);
155
+ expect(el._lbl.getAttribute('aria-disabled')).toBe('true');
156
+ expect(el._sw.hasAttribute('disabled')).toBe(false);
157
+ el.enabled = true;
158
+ expect(el._btn.hasAttribute('disabled')).toBe(false);
159
+ expect(el._lbl.getAttribute('aria-disabled')).toBe(null);
160
+ expect(el._sw.hasAttribute('disabled')).toBe(true);
161
+ });
162
+ });
119
163
  };
@@ -132,6 +132,28 @@ export function dispatchElement(el: HTMLElement, signal: string, args: readonly
132
132
  el.dispatchEvent(domEvent);
133
133
  }
134
134
 
135
+ /**
136
+ * `Gtk.Widget:sensitive` for an element that has no member of that name. The DOM spells it
137
+ * `disabled` on a form control and `aria-disabled` on anything else (`adw-action-row.ts` reads
138
+ * both), so the accessor writes the spelling the element understands. An element that already
139
+ * declares `sensitive` (`<adw-toggle-group>`) keeps its own.
140
+ */
141
+ export function installSensitive(el: HTMLElement): void {
142
+ if ('sensitive' in el) return;
143
+ const observed = (el.constructor as { observedAttributes?: string[] }).observedAttributes ?? [];
144
+ const form = 'disabled' in el || observed.includes('disabled');
145
+ Object.defineProperty(el, 'sensitive', {
146
+ configurable: true,
147
+ enumerable: false,
148
+ get: () => (form ? !el.hasAttribute('disabled') : el.getAttribute('aria-disabled') !== 'true'),
149
+ set: (value: boolean) => {
150
+ if (form) el.toggleAttribute('disabled', !value);
151
+ else if (value) el.removeAttribute('aria-disabled');
152
+ else el.setAttribute('aria-disabled', 'true');
153
+ },
154
+ });
155
+ }
156
+
135
157
  /** Gives `el` the instance API of a registered class, over the events it declares. */
136
158
  export function installElementApi(el: HTMLElement): void {
137
159
  // A registered class already has the core's four methods on its prototype.
@@ -91,6 +91,80 @@ export const GtkScrolledWindowTest = async () => {
91
91
  });
92
92
  });
93
93
 
94
+ await describe('<gtk-scrolled-window> its child, before and after connect', async () => {
95
+ const detached = (): { el: GtkScrolledWindow; host: HTMLElement } => {
96
+ const host = document.createElement('div');
97
+ host.style.height = '100px';
98
+ const el = document.createElement('gtk-scrolled-window') as GtkScrolledWindow;
99
+ host.appendChild(el);
100
+ return { el, host };
101
+ };
102
+ const label = (slot?: string): HTMLElement => {
103
+ const child = document.createElement('gtk-label');
104
+ if (slot !== undefined) child.setAttribute('slot', slot);
105
+ return child;
106
+ };
107
+
108
+ await it('adopts a slot="child" child appended BEFORE the element connects', async () => {
109
+ const host = document.createElement('div');
110
+ const el = document.createElement('gtk-scrolled-window') as GtkScrolledWindow;
111
+ const child = label('child');
112
+ el.appendChild(child);
113
+ host.appendChild(el);
114
+ document.body.appendChild(host);
115
+ await settle();
116
+ expect(el.viewport.contains(child)).toBe(true);
117
+ host.remove();
118
+ });
119
+
120
+ await it('adopts a slot="child" child appended AFTER the element connects', async () => {
121
+ const { el, host } = mount();
122
+ const child = label('child');
123
+ el.appendChild(child);
124
+ await settle();
125
+ expect(el.viewport.contains(child)).toBe(true);
126
+ host.remove();
127
+ });
128
+
129
+ await it('adopts a default-slot child appended before connect', async () => {
130
+ const host = document.createElement('div');
131
+ const el = document.createElement('gtk-scrolled-window') as GtkScrolledWindow;
132
+ const child = label();
133
+ el.appendChild(child);
134
+ host.appendChild(el);
135
+ document.body.appendChild(host);
136
+ await settle();
137
+ expect(el.viewport.contains(child)).toBe(true);
138
+ host.remove();
139
+ });
140
+
141
+ await it('set_child before connect is adopted on connect', async () => {
142
+ const { el, host } = detached();
143
+ const child = label();
144
+ el.set_child(child);
145
+ expect(el.get_child()).toBe(child);
146
+ document.body.appendChild(host);
147
+ await settle();
148
+ expect(el.viewport.contains(child)).toBe(true);
149
+ expect(el.child).toBe(child);
150
+ host.remove();
151
+ });
152
+
153
+ await it('set_child after connect replaces the child inside the viewport', async () => {
154
+ const { el, host } = mount();
155
+ const first = label();
156
+ const second = label();
157
+ el.set_child(first);
158
+ expect(el.viewport.contains(first)).toBe(true);
159
+ el.set_child(second);
160
+ expect(el.viewport.contains(first)).toBe(false);
161
+ expect(el.viewport.contains(second)).toBe(true);
162
+ el.set_child(null);
163
+ expect(el.child).toBe(null);
164
+ host.remove();
165
+ });
166
+ });
167
+
94
168
  await describe('<gtk-scrolled-window> the policies', async () => {
95
169
  await it('shows an automatic bar only while the content is longer than the window', async () => {
96
170
  const { el, host } = mount();
@@ -31,6 +31,7 @@ import { normalizeIconName } from '@gjsify/adwaita-core';
31
31
  import { toDataUri } from '@gjsify/adwaita-icons/utils';
32
32
 
33
33
  import { MASK_CLASS_PREFIX } from './elements/gtk-image.js';
34
+ import { iconResolves, notifyIconThemeChanged } from './icon-theme.js';
34
35
 
35
36
  /** Marks the one `<style>` element this module owns, so a reload reuses it. */
36
37
  const REGISTRY_STYLE_ID = 'adwaita-web-icon-registry';
@@ -130,13 +131,20 @@ export function registerIcon(name: string, svg: string): void {
130
131
  // BEFORE the `ruled` early return: taking the sheet is what detects a lost <style>,
131
132
  // and a name already in `ruled` is exactly the one the early return would skip.
132
133
  const sheet = registrySheet();
133
- if (ruled.has(resolved)) return;
134
- insertMaskRule(sheet, resolved);
135
- ruled.add(resolved);
134
+ if (!ruled.has(resolved)) {
135
+ insertMaskRule(sheet, resolved);
136
+ ruled.add(resolved);
137
+ }
138
+ // The CSS above is live for every mask class, but a `name-symbolic` request that fell
139
+ // back to a registered COLOUR icon has no mask class to update — the symbolic glyph
140
+ // now wins that lookup, so the element has to run it again.
141
+ notifyIconThemeChanged();
136
142
  }
137
143
 
138
144
  /**
139
- * Whether `name` has a glyph — compiled into the stylesheet, or registered at runtime.
145
+ * Whether `name` draws something other than `image-missing` — a symbolic glyph compiled into
146
+ * the stylesheet or registered at runtime, or a colour icon from `registerColorIcon`. The
147
+ * name is looked up exactly as `<gtk-image icon-name>` looks it up (`icon-theme.ts`).
140
148
  *
141
149
  * Reads the live cascade rather than a generated list, so it answers for a glyph that
142
150
  * arrived by any route, including a consumer's own `--icon-<name>` declaration. It is
@@ -151,7 +159,5 @@ export function registerIcon(name: string, svg: string): void {
151
159
  * {@link registerIcon} are the two things that create the pair, and both create both.
152
160
  */
153
161
  export function isIconAvailable(name: string): boolean {
154
- const resolved = normalizeIconName(name);
155
- if (resolved === '') return false;
156
- return getComputedStyle(document.documentElement).getPropertyValue(`--icon-${resolved}`).trim() !== '';
162
+ return iconResolves(name);
157
163
  }