@gjsify/adwaita-web 0.61.0 → 0.63.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.
@@ -47,7 +47,7 @@
47
47
  // difference a reader of the DOM can see.
48
48
  //
49
49
  // NOT PORTED, and why — these are the KNOWN_GAPS of this element:
50
- // `application`, `child`, `default-widget`, `display`, `focus-widget` — WIDGET- or
50
+ // `application`, `child`, `default-widget`, `display` — WIDGET- or
51
51
  // DISPLAY-typed, so a slot or an id here rather than an attribute; `child` and
52
52
  // `titlebar` ARE slots and `transient-for` only has to be PRESENT.
53
53
  // `destroy-with-parent` — a DOM node has no destroy, and a page has no parent to destroy.
@@ -59,15 +59,32 @@
59
59
  // business and needs the `Gio.MenuModel` it is built from (:1150).
60
60
  // `startup-id` — write-only, and written by the launcher that started the application (:883).
61
61
  //
62
+ // THE STATE MEMBERS, and what each is in a page (the shared rules — notify only on change, the
63
+ // close sequence — live in `@gjsify/adwaita-core`'s `window-members.ts`):
64
+ // `is-active` — the DOCUMENT has focus and this frame is connected and shown. A page has one
65
+ // focus for all its windows, so every shown frame is active together; that is
66
+ // the honest limit of a frame (`document.hasFocus()`, window focus/blur and
67
+ // `visibilitychange` feed it). Read-only, as in GTK.
68
+ // `focus-widget` — the element inside the frame that holds `document.activeElement`, live.
69
+ // `set_focus(el)` calls `el.focus()` and refuses an element outside the frame;
70
+ // `set_focus(null)` blurs. `focusin`/`focusout` notify; focus that merely
71
+ // leaves with the window (blur) keeps its widget, as GTK keeps it.
72
+ // `maximized` — the FRAME's state (square corners), not the browser window's: a page cannot
73
+ // maximize its browser window. `is_maximized()` reads it.
74
+ // `present()` — un-hides the frame; a page cannot raise or focus a window it does not own.
75
+ //
62
76
  // Events: `notify::<prop>` (CustomEvent, bubbles, detail `{ <prop>: value }`) on every real
63
- // change, and `close-request` (CustomEvent, bubbles, CANCELLED, no detail), which is
64
- // `Gtk.Window::close-request`.
77
+ // change, and `close-request` (CustomEvent, bubbles, CANCELLABLE, no detail), which is
78
+ // `Gtk.Window::close-request`: a listener that calls `preventDefault()` is a GTK handler that
79
+ // returns true. `window.close()` runs it through `CloseRequestState`.
65
80
  //
66
81
  // Reference: refs/gtk/gtk/gtkwindow.c
67
82
  // Reference: refs/libadwaita/src/stylesheet/widgets/_window.scss (GtkWindow)
68
83
  // Copyright (c) The GTK Team. LGPLv2.1+.
69
84
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
70
85
 
86
+ import { CloseRequestState, WindowMemberState, type WindowMemberProperty } from '@gjsify/adwaita-core';
87
+
71
88
  import { bindSlottedChildren, type AdwSlot } from '../slotted-children.js';
72
89
 
73
90
  // Registers <gtk-window-controls>: a frame with no titlebar of its own draws the two of them
@@ -88,6 +105,20 @@ export class GtkWindow extends HTMLElement {
88
105
  private _titleLabel: HTMLElement | null = null;
89
106
  /** Watches the strip for an author's titlebar arriving after the build. */
90
107
  private _titlebarObserver: MutationObserver | null = null;
108
+ /** `is-active` and `focus-widget`; `maximized` stays the attribute above. */
109
+ private readonly _members = new WindowMemberState<Element>((property) => this._notifyMember(property));
110
+ private readonly _closing = new CloseRequestState();
111
+ private readonly _syncActive = (): void => void this._members.reportActive(this._documentIsActive());
112
+ private readonly _onFocusIn = (event: FocusEvent): void => {
113
+ if (event.target instanceof Element) this._members.reportFocus(event.target);
114
+ };
115
+ private readonly _onFocusOut = (event: FocusEvent): void => {
116
+ const next = event.relatedTarget;
117
+ if (next instanceof Node && this.contains(next)) return;
118
+ // Focus leaving WITH the window (tab switch, another app) keeps the widget; GTK does too.
119
+ if (next === null && !document.hasFocus()) return;
120
+ this._members.reportFocus(null);
121
+ };
91
122
 
92
123
  static get observedAttributes() {
93
124
  return [
@@ -103,6 +134,8 @@ export class GtkWindow extends HTMLElement {
103
134
  'resizable',
104
135
  'title',
105
136
  'transient-for',
137
+ // Not a window property: `is-active` depends on it, so it is watched but never notified.
138
+ 'hidden',
106
139
  ];
107
140
  }
108
141
 
@@ -186,11 +219,13 @@ export class GtkWindow extends HTMLElement {
186
219
 
187
220
  connectedCallback() {
188
221
  if (this._initialized) {
222
+ this._watchActivity();
189
223
  this._watchTitlebar();
190
224
  this._render();
191
225
  return;
192
226
  }
193
227
  this._initialized = true;
228
+ this._watchActivity();
194
229
 
195
230
  this._titlebar = document.createElement('div');
196
231
  this._titlebar.className = 'adw-gtk-window-titlebar';
@@ -216,6 +251,37 @@ export class GtkWindow extends HTMLElement {
216
251
  disconnectedCallback() {
217
252
  this._titlebarObserver?.disconnect();
218
253
  this._titlebarObserver = null;
254
+ window.removeEventListener('focus', this._syncActive);
255
+ window.removeEventListener('blur', this._syncActive);
256
+ document.removeEventListener('visibilitychange', this._syncActive);
257
+ this.removeEventListener('focusin', this._onFocusIn);
258
+ this.removeEventListener('focusout', this._onFocusOut);
259
+ // An unmapped window is neither active nor holding focus.
260
+ this._members.reportActive(false);
261
+ this._members.reportFocus(null);
262
+ }
263
+
264
+ private _watchActivity(): void {
265
+ window.addEventListener('focus', this._syncActive);
266
+ window.addEventListener('blur', this._syncActive);
267
+ document.addEventListener('visibilitychange', this._syncActive);
268
+ this.addEventListener('focusin', this._onFocusIn);
269
+ this.addEventListener('focusout', this._onFocusOut);
270
+ this._syncActive();
271
+ }
272
+
273
+ private _documentIsActive(): boolean {
274
+ return this.isConnected && !this.hidden && document.visibilityState !== 'hidden' && document.hasFocus();
275
+ }
276
+
277
+ private _notifyMember(property: WindowMemberProperty): void {
278
+ const detail =
279
+ property === 'is-active'
280
+ ? this._members.active
281
+ : property === 'focus-widget'
282
+ ? this._members.focus
283
+ : this.maximized;
284
+ this.dispatchEvent(new CustomEvent(`notify::${property}`, { bubbles: true, detail: { [property]: detail } }));
219
285
  }
220
286
 
221
287
  /**
@@ -248,8 +314,13 @@ export class GtkWindow extends HTMLElement {
248
314
  }
249
315
 
250
316
  attributeChangedCallback(name: string) {
251
- if (!this._initialized) return;
252
- this._render();
317
+ if (name === 'hidden') {
318
+ this._syncActive();
319
+ return;
320
+ }
321
+ // Before the first connect there is nothing to draw, but the property still moved: GTK
322
+ // notifies a window that was never mapped, and a listener on a detached frame hears it.
323
+ if (this._initialized) this._render();
253
324
  this.dispatchEvent(
254
325
  new CustomEvent(`notify::${name}`, { bubbles: true, detail: { [name]: this._property(name) } }),
255
326
  );
@@ -344,14 +415,78 @@ export class GtkWindow extends HTMLElement {
344
415
  * it, hide the window when `hide-on-close` is set and DESTROY it otherwise. "Destroy" is
345
416
  * the one word this port cannot mean — a page has no unref — so the default detaches the
346
417
  * frame, which is the same observable outcome for everything above it.
418
+ *
419
+ * A frame that is not in the page is not realized, so there is nothing to close; a listener
420
+ * that calls `close()` again is ignored, as GTK ignores it. Answers whether the window went
421
+ * away (hidden or detached), `false` when a listener took the request over or it was ignored.
347
422
  */
348
423
  close(): boolean {
349
- const request = new CustomEvent('close-request', { bubbles: true, cancelable: true });
350
- this.dispatchEvent(request);
351
- if (request.defaultPrevented) return false;
352
- if (this._offByDefault('hide-on-close')) this.hidden = true;
353
- else this.remove();
354
- return true;
424
+ const outcome = this._closing.close({
425
+ realized: this.isConnected,
426
+ hideOnClose: this._offByDefault('hide-on-close'),
427
+ emit: () => {
428
+ const request = new CustomEvent('close-request', { bubbles: true, cancelable: true });
429
+ this.dispatchEvent(request);
430
+ return request.defaultPrevented;
431
+ },
432
+ hide: () => void (this.hidden = true),
433
+ destroy: () => this.remove(),
434
+ });
435
+ return outcome === 'hidden' || outcome === 'destroyed';
436
+ }
437
+
438
+ /**
439
+ * `gtk_window_present`: show the frame. GTK also raises and focuses the toplevel, which is
440
+ * the window manager's to grant; a page owns no such surface, so this is only the first half.
441
+ */
442
+ present(): void {
443
+ this.hidden = false;
444
+ }
445
+
446
+ /** `Gtk.Window:is-active` — see the header: the document's focus, while this frame is shown. */
447
+ get isActive(): boolean {
448
+ return this._members.active;
449
+ }
450
+
451
+ /** `gtk_window_is_active`. */
452
+ is_active(): boolean {
453
+ return this._members.active;
454
+ }
455
+
456
+ /** `Gtk.Window:focus-widget`, read live from the document. */
457
+ get focusWidget(): Element | null {
458
+ return this.get_focus();
459
+ }
460
+
461
+ /** `gtk_window_get_focus`: the element inside the frame that has focus, or `null`. */
462
+ get_focus(): Element | null {
463
+ const active = document.activeElement;
464
+ return active !== null && active !== this && this.contains(active) ? active : null;
465
+ }
466
+
467
+ /**
468
+ * `gtk_window_set_focus`. `null` clears the focus. An element outside the frame is refused:
469
+ * GTK's window only focuses its own descendants.
470
+ */
471
+ set_focus(widget: Element | null): void {
472
+ if (widget === null) {
473
+ const current = this.get_focus();
474
+ if (current instanceof HTMLElement || current instanceof SVGElement) current.blur();
475
+ this._members.reportFocus(null);
476
+ return;
477
+ }
478
+ if (widget === this || !this.contains(widget)) {
479
+ throw new Error('<gtk-window>.set_focus: the widget is not inside this window');
480
+ }
481
+ if (widget instanceof HTMLElement || widget instanceof SVGElement) widget.focus();
482
+ // `focus()` fires no focusin while the document itself is unfocused, so report what the
483
+ // document now says; an equal write is dropped by the state.
484
+ this._members.reportFocus(this.get_focus());
485
+ }
486
+
487
+ /** `gtk_window_is_maximized`: the FRAME's state, not the browser window's. */
488
+ is_maximized(): boolean {
489
+ return this.maximized;
355
490
  }
356
491
 
357
492
  /**
@@ -5,6 +5,8 @@
5
5
  // (gtkwindow.c:1274-1287).
6
6
  import { describe, expect, it } from '@gjsify/unit';
7
7
 
8
+ import { driveWindowVectors, WINDOW_VECTORS, type WindowSubject } from '@gjsify/adwaita-core/conformance';
9
+
8
10
  import type { GtkWindow } from './elements/gtk-window.js';
9
11
 
10
12
  function mount(attrs: Record<string, string> = {}): { el: GtkWindow; host: HTMLElement } {
@@ -199,4 +201,124 @@ export const GtkWindowTest = async () => {
199
201
  host.remove();
200
202
  });
201
203
  });
204
+
205
+ // The element's native close contract is `preventDefault()`; a GTK handler "returns true". The
206
+ // adapter below is that mapping, and it also stops later listeners, which is what the handled
207
+ // accumulator does. So the "third never runs" row exercises the adapter plus the element's
208
+ // sequence, not DOM dispatch alone.
209
+ const windows = new Map<object, HTMLElement>();
210
+ const webSubject: WindowSubject = {
211
+ name: '<gtk-window>',
212
+ isOracle: false,
213
+ capabilities: { maximizable: true, observesFocus: true, hideOnClose: true },
214
+ create({ hideOnClose }) {
215
+ // Never in a page: not realized, which is GTK's state for a window before `present()`.
216
+ const el = document.createElement('gtk-window') as GtkWindow;
217
+ if (hideOnClose) el.setAttribute('hide-on-close', 'true');
218
+ el.append(document.createElement('button'), document.createElement('button'));
219
+ return el;
220
+ },
221
+ show(w) {
222
+ const host = document.createElement('div');
223
+ document.body.appendChild(host);
224
+ host.appendChild(w as GtkWindow);
225
+ windows.set(w, host);
226
+ (w as GtkWindow).present();
227
+ },
228
+ connect(w, signal, handler) {
229
+ (w as GtkWindow).addEventListener(signal, (event) => {
230
+ if (signal === 'close-request') {
231
+ if (event.defaultPrevented) return;
232
+ if (handler()) {
233
+ event.preventDefault();
234
+ event.stopImmediatePropagation();
235
+ }
236
+ return;
237
+ }
238
+ handler();
239
+ });
240
+ return 1;
241
+ },
242
+ close: (w) => void (w as GtkWindow).close(),
243
+ maximize: (w) => (w as GtkWindow).maximize(),
244
+ unmaximize: (w) => (w as GtkWindow).unmaximize(),
245
+ isMaximized: (w) => (w as GtkWindow).is_maximized(),
246
+ isVisible: (w) => (w as GtkWindow).isConnected && !(w as GtkWindow).hidden,
247
+ focusables: (w) => [...(w as GtkWindow).querySelectorAll('button')] as unknown as [Element, Element],
248
+ setFocus: (w, widget) => (w as GtkWindow).set_focus(widget as Element | null),
249
+ getFocus: (w) => (w as GtkWindow).get_focus(),
250
+ };
251
+ await driveWindowVectors(webSubject, { describe, it, expect }, WINDOW_VECTORS);
252
+ for (const host of windows.values()) host.remove();
253
+
254
+ await describe('<gtk-window> state members', async () => {
255
+ await it('a frame that was never connected is not realized: close() does nothing', async () => {
256
+ const el = document.createElement('gtk-window') as GtkWindow;
257
+ let seen = 0;
258
+ el.addEventListener('close-request', () => void seen++);
259
+ expect(el.close()).toBe(false);
260
+ expect(seen).toBe(0);
261
+ });
262
+
263
+ await it('is-active follows the document focus and notifies only on change', async () => {
264
+ const { el, host } = mount();
265
+ const seen: boolean[] = [];
266
+ el.addEventListener('notify::is-active', (e) => seen.push((e as CustomEvent).detail['is-active']));
267
+ const before = el.isActive;
268
+ expect(before).toBe(document.hasFocus());
269
+ el.hidden = true;
270
+ expect(el.is_active()).toBe(false);
271
+ el.present();
272
+ expect(el.isActive).toBe(document.hasFocus());
273
+ // Every transition above is one notification at most, and none repeats a value.
274
+ for (let i = 1; i < seen.length; i++) expect(seen[i]).not.toBe(seen[i - 1]);
275
+ host.remove();
276
+ expect(el.isActive).toBe(false);
277
+ });
278
+
279
+ await it('is-active has no setter: GTK declares it read-only', async () => {
280
+ const { el, host } = mount();
281
+ const descriptor =
282
+ Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el.constructor.prototype), 'isActive') ??
283
+ Object.getOwnPropertyDescriptor(el.constructor.prototype, 'isActive');
284
+ expect(descriptor?.set).toBe(undefined);
285
+ host.remove();
286
+ });
287
+
288
+ await it('set_focus refuses an element outside the frame, by name', async () => {
289
+ const { el, host } = mount();
290
+ const outside = document.createElement('button');
291
+ document.body.append(outside);
292
+ let message = '';
293
+ try {
294
+ el.set_focus(outside);
295
+ } catch (error) {
296
+ message = (error as Error).message;
297
+ }
298
+ expect(message.includes('set_focus')).toBe(true);
299
+ outside.remove();
300
+ host.remove();
301
+ });
302
+
303
+ await it('focus-widget notifies with the widget in its detail', async () => {
304
+ const { el, host } = mount();
305
+ const button = document.createElement('button');
306
+ el.append(button);
307
+ await Promise.resolve();
308
+ let detail: unknown;
309
+ el.addEventListener('notify::focus-widget', (e) => (detail = (e as CustomEvent).detail['focus-widget']));
310
+ el.set_focus(button);
311
+ expect(detail).toBe(button);
312
+ expect(el.focusWidget).toBe(button);
313
+ host.remove();
314
+ });
315
+
316
+ await it('is_maximized() reads the frame state', async () => {
317
+ const { el, host } = mount();
318
+ expect(el.is_maximized()).toBe(false);
319
+ el.maximize();
320
+ expect(el.is_maximized()).toBe(true);
321
+ host.remove();
322
+ });
323
+ });
202
324
  };
@@ -0,0 +1,257 @@
1
+ // `renderPangoMarkup` — markup becomes NODES, and a string the browser never parses.
2
+ //
3
+ // The rows that matter here are the ones that used to be `innerHTML`: a `<b>` has to come
4
+ // out as an ELEMENT (that is the whole feature — the reviewer who flagged the innerHTML
5
+ // was right about the sink and wrong that the only alternative is plain text), and an
6
+ // `<img src=x onerror=…>` or a `<script>` has to come out as NOTHING an element could
7
+ // carry. Both run through the real elements where they can, because a helper tested alone
8
+ // only proves the helper.
9
+
10
+ import { describe, expect, it } from '@gjsify/unit';
11
+
12
+ import { renderPangoMarkup } from './pango-markup.js';
13
+ import type { AdwAlertDialog } from './elements/adw-alert-dialog.js';
14
+ import type { AdwBanner } from './elements/adw-banner.js';
15
+
16
+ /** Render markup into a detached target and hand both back. */
17
+ function render(markup: string): { target: HTMLElement; text: string } {
18
+ const target = document.createElement('span');
19
+ document.body.appendChild(target);
20
+ renderPangoMarkup(target, markup);
21
+ return { target, text: target.textContent ?? '' };
22
+ }
23
+
24
+ /** Computed `font-weight`, which is `bold` or `700` depending on the engine. */
25
+ function isBold(el: Element): boolean {
26
+ const weight = getComputedStyle(el).fontWeight;
27
+ return weight === 'bold' || Number.parseInt(weight, 10) >= 600;
28
+ }
29
+
30
+ function mount<T extends HTMLElement>(tag: string, attributes: Record<string, string>): { el: T; host: HTMLElement } {
31
+ const host = document.createElement('div');
32
+ document.body.appendChild(host);
33
+ const el = document.createElement(tag) as T;
34
+ for (const [name, value] of Object.entries(attributes)) el.setAttribute(name, value);
35
+ host.appendChild(el);
36
+ return { el, host };
37
+ }
38
+
39
+ const bannerTitle = (el: AdwBanner): HTMLElement => el.querySelector('.adw-banner-title') as HTMLElement;
40
+ const alertHeading = (el: AdwAlertDialog): HTMLElement => el.querySelector('.adw-alert-dialog-heading') as HTMLElement;
41
+ const alertBody = (el: AdwAlertDialog): HTMLElement => el.querySelector('.adw-alert-dialog-body') as HTMLElement;
42
+
43
+ export const PangoMarkupTest = async () => {
44
+ await describe('renderPangoMarkup — <b> is drawn bold, because the tree says so', async () => {
45
+ await it('<b>x</b> becomes a <b> ELEMENT, and it computes bold', () => {
46
+ const { target } = render('<b>x</b>');
47
+ const bold = target.querySelector('b');
48
+ expect(bold).not.toBe(null);
49
+ expect(bold?.textContent).toBe('x');
50
+ expect(isBold(bold!)).toBe(true);
51
+ target.remove();
52
+ });
53
+
54
+ // The four tags with no presentational UA rule, each spelled out as a style — the
55
+ // rows a "map the tag name to the same HTML element" port silently gets wrong,
56
+ // because <tt>, <big> and <small> exist in the HTML namespace and mean nothing.
57
+ await it('tt, big and small draw through styles, not through obsolete elements', () => {
58
+ const { target } = render('<tt>code</tt><big>big</big><small>small</small>');
59
+ const tt = target.querySelector('span')!;
60
+ expect(getComputedStyle(tt).fontFamily.toLowerCase()).toContain('monospace');
61
+ const big = target.children[1] as HTMLElement;
62
+ const small = target.children[2] as HTMLElement;
63
+ expect(Number.parseFloat(getComputedStyle(big).fontSize)).toBeGreaterThan(
64
+ Number.parseFloat(getComputedStyle(small).fontSize),
65
+ );
66
+ expect(big.tagName).toBe('SPAN');
67
+ expect(small.tagName).toBe('SPAN');
68
+ });
69
+
70
+ await it('the remaining tags become their own elements, nested as written', () => {
71
+ const { target } = render('<i>a<u>b</u></i><s>c</s><sub>d</sub><sup>e</sup>');
72
+ expect([...target.children].map((el) => el.tagName)).toStrictEqual(['I', 'S', 'SUB', 'SUP']);
73
+ expect(target.querySelector('i > u')?.textContent).toBe('b');
74
+ });
75
+
76
+ await it("<markup> draws its children — it is pango's implicit root, not a box", async () => {
77
+ const { target } = render('<markup><b>x</b></markup>');
78
+ expect(target.querySelector('b')?.textContent).toBe('x');
79
+ });
80
+ });
81
+
82
+ await describe('renderPangoMarkup — the rows that were the injection sink', async () => {
83
+ // Each of these was reachable as `banner.innerHTML = title` / `heading.innerHTML
84
+ // = heading`. There is no branch in the renderer that creates an element for a
85
+ // tag pango does not know, and no branch that sets an HTML attribute from a
86
+ // `<span>` attribute — so none of them can produce an element at all.
87
+ for (const markup of [
88
+ '<img src=x onerror="alert(1)">',
89
+ '<script>alert(1)</script>',
90
+ '<div onclick="alert(1)">x</div>',
91
+ '<iframe src="javascript:alert(1)"></iframe>',
92
+ '<a href="javascript:alert(1)">x</a>',
93
+ '<b onclick="alert(1)">x</b>',
94
+ ]) {
95
+ await it(`${JSON.stringify(markup)} → no element, the raw string as text`, () => {
96
+ const { target, text } = render(markup);
97
+ expect(text).toBe(markup);
98
+ expect(target.children.length).toBe(0);
99
+ expect(target.querySelector('img, script, div, iframe, a, b, span')).toBe(null);
100
+ expect(target.getAttribute('onerror')).toBe(null);
101
+ expect(target.getAttribute('onclick')).toBe(null);
102
+ });
103
+ }
104
+
105
+ // The one shape that DOES parse and could still carry an `on*`: `<span>` takes
106
+ // attributes, so an unknown one reaches the tree. It is dropped there and here —
107
+ // the run renders as plain text with no attribute on it, which is what makes the
108
+ // whitelist load-bearing rather than decoration.
109
+ await it('an unknown <span> attribute is dropped, never set on the element', () => {
110
+ const { target } = render('<span onmouseover="alert(1)">x</span>');
111
+ const span = target.querySelector('span')!;
112
+ expect(span.textContent).toBe('x');
113
+ expect(span.attributes.length).toBe(0);
114
+ expect((span as HTMLElement).style.cssText).toBe('');
115
+ });
116
+
117
+ await it('unparseable markup keeps the raw string, which is the C fallback', () => {
118
+ for (const markup of ['a < b', 'Tom & Jerry', '<b>unclosed', '<b>wrong</i>']) {
119
+ const { target, text } = render(markup);
120
+ expect(text).toBe(markup);
121
+ expect(target.children.length).toBe(0);
122
+ }
123
+ });
124
+
125
+ await it('no element carries an attribute derived from a <span> attribute', () => {
126
+ const { target } = render('<span weight="bold" font_family="Serif">x</span>');
127
+ const span = target.querySelector('span')!;
128
+ // `style` and nothing else: CSSOM reflects the declarations it holds, but a
129
+ // MARKUP attribute must never reach the DOM as one.
130
+ expect([...span.attributes].map((attribute) => attribute.name)).toStrictEqual(['style']);
131
+ expect(span.style.fontWeight).toBe('bold');
132
+ });
133
+ });
134
+
135
+ await describe('renderPangoMarkup — a <span> attribute reaches CSS, or nothing', async () => {
136
+ await it("weight and style take pango's NAMED values", () => {
137
+ const { target } = render('<span weight="bold" style="italic">x</span>');
138
+ const span = target.querySelector('span')!;
139
+ expect(span.style.fontWeight).toBe('bold');
140
+ expect(span.style.fontStyle).toBe('italic');
141
+ });
142
+
143
+ await it('an unmapped weight is DROPPED, never passed through to CSS', () => {
144
+ const { target } = render('<span weight="expression(alert(1))">x</span>');
145
+ expect(target.querySelector('span')!.style.fontWeight).toBe('');
146
+ });
147
+
148
+ await it('underline and strikethrough ADD, so a span carrying both keeps both', () => {
149
+ const { target } = render('<span underline="single" strikethrough="true">x</span>');
150
+ const decoration = target.querySelector('span')!.style.textDecorationLine;
151
+ expect(decoration).toContain('underline');
152
+ expect(decoration).toContain('line-through');
153
+ });
154
+
155
+ await it('underline="none" and strikethrough="false" draw neither', () => {
156
+ const { target } = render('<span underline="none" strikethrough="false">x</span>');
157
+ expect(target.querySelector('span')!.style.textDecorationLine).toBe('');
158
+ });
159
+
160
+ await it('a font family list keeps its order; an unparseable one is refused whole', () => {
161
+ const ok = render('<span font_family="Cantarell, sans-serif">x</span>');
162
+ expect(ok.target.querySelector('span')!.style.fontFamily).toContain('Cantarell');
163
+ const refused = render('<span font_family="a&quot;; color: red">x</span>');
164
+ expect(refused.target.querySelector('span')!.style.fontFamily).toBe('');
165
+ expect(refused.target.querySelector('span')!.style.color).toBe('');
166
+ });
167
+
168
+ await it('a size travels with its unit — a bare pango number is 1024ths of a point', () => {
169
+ const units = render('<span size="12288">x</span>');
170
+ expect(units.target.querySelector('span')!.style.fontSize).toBe('12pt');
171
+ const points = render('<span size="12.5pt">x</span>');
172
+ expect(points.target.querySelector('span')!.style.fontSize).toBe('12.5pt');
173
+ const relative = render('<span font_size="larger">x</span>');
174
+ expect(relative.target.querySelector('span')!.style.fontSize).toBe('larger');
175
+ const percent = render('<span size="150%">x</span>');
176
+ expect(percent.target.querySelector('span')!.style.fontSize).toBe('150%');
177
+ const named = render('<span size="x-small">x</span>');
178
+ expect(named.target.querySelector('span')!.style.fontSize).toBe('x-small');
179
+ });
180
+
181
+ await it("reads pango's aliases and a numeric weight", () => {
182
+ const { target } = render('<span font_weight="600" font_style="italic" face="monospace">x</span>');
183
+ const span = target.querySelector('span')!;
184
+ expect(span.style.fontWeight).toBe('600');
185
+ expect(span.style.fontStyle).toBe('italic');
186
+ expect(span.style.fontFamily).toBe('monospace');
187
+ });
188
+
189
+ await it('an attribute pango accepts but CSS cannot express is dropped', () => {
190
+ // `rise` and `letter_spacing` are pango attributes with no equivalent here.
191
+ const { target } = render('<span rise="4" letter_spacing="2">x</span>');
192
+ expect(target.querySelector('span')!.getAttribute('style')).toBe(null);
193
+ });
194
+ });
195
+
196
+ await describe('the elements render the same way', async () => {
197
+ await it('<adw-banner> draws its <b> bold when use-markup is set', () => {
198
+ const { el, host } = mount<AdwBanner>('adw-banner', {
199
+ title: '<b>Metered</b> connection',
200
+ revealed: '',
201
+ 'use-markup': '',
202
+ });
203
+ const bold = bannerTitle(el).querySelector('b');
204
+ expect(bold?.textContent).toBe('Metered');
205
+ expect(isBold(bold!)).toBe(true);
206
+ host.remove();
207
+ });
208
+
209
+ await it('<adw-banner> shows an unknown tag as text, never as an element', () => {
210
+ const { el, host } = mount<AdwBanner>('adw-banner', {
211
+ title: '<img src=x onerror="alert(1)">',
212
+ revealed: '',
213
+ 'use-markup': '',
214
+ });
215
+ expect(bannerTitle(el).querySelector('img')).toBe(null);
216
+ expect(bannerTitle(el).textContent).toBe('<img src=x onerror="alert(1)">');
217
+ host.remove();
218
+ });
219
+
220
+ await it('<adw-alert-dialog> draws heading AND body markup bold/italic', () => {
221
+ const { el, host } = mount<AdwAlertDialog>('adw-alert-dialog', {
222
+ heading: '<b>Delete</b> file?',
223
+ 'heading-use-markup': '',
224
+ body: 'Really delete <i>notes.txt</i>?',
225
+ 'body-use-markup': '',
226
+ });
227
+ expect(isBold(alertHeading(el).querySelector('b')!)).toBe(true);
228
+ expect(getComputedStyle(alertBody(el).querySelector('i')!).fontStyle).toBe('italic');
229
+ host.remove();
230
+ });
231
+
232
+ await it('<adw-alert-dialog> shows an unknown tag in either slot as text', () => {
233
+ const { el, host } = mount<AdwAlertDialog>('adw-alert-dialog', {
234
+ heading: '<script>alert(1)</script>',
235
+ 'heading-use-markup': '',
236
+ body: '<img src=x onerror="alert(1)">',
237
+ 'body-use-markup': '',
238
+ });
239
+ expect(alertHeading(el).querySelector('script')).toBe(null);
240
+ expect(alertHeading(el).textContent).toBe('<script>alert(1)</script>');
241
+ expect(alertBody(el).querySelector('img')).toBe(null);
242
+ expect(alertBody(el).textContent).toBe('<img src=x onerror="alert(1)">');
243
+ host.remove();
244
+ });
245
+
246
+ await it('without use-markup both slots stay literal text', () => {
247
+ const { el, host } = mount<AdwAlertDialog>('adw-alert-dialog', {
248
+ heading: '<b>Delete</b> file?',
249
+ body: '<i>notes.txt</i>',
250
+ });
251
+ expect(alertHeading(el).textContent).toBe('<b>Delete</b> file?');
252
+ expect(alertHeading(el).querySelector('b')).toBe(null);
253
+ expect(alertBody(el).querySelector('i')).toBe(null);
254
+ host.remove();
255
+ });
256
+ });
257
+ };