@gjsify/adwaita-web 0.62.0 → 0.63.1-next.235

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +31 -0
  2. package/dist/adwaita-web.css +4 -0
  3. package/dist/adwaita-web.css.map +1 -1
  4. package/lib/types/canvas2d/canvas2d-bridge.d.ts +26 -5
  5. package/lib/types/elements/adw-application-window.d.ts +26 -7
  6. package/lib/types/elements/adw-bottom-sheet.d.ts +3 -0
  7. package/lib/types/elements/adw-data-grid.d.ts +4 -0
  8. package/lib/types/elements/adw-window-features.d.ts +39 -0
  9. package/lib/types/elements/adw-window.d.ts +11 -24
  10. package/lib/types/elements/gtk-application-window.d.ts +12 -4
  11. package/lib/types/elements/gtk-window.d.ts +44 -0
  12. package/lib/types/gtk-window.spec.d.ts +6 -0
  13. package/lib/types/pango-markup.d.ts +10 -0
  14. package/lib/types/pango-markup.spec.d.ts +1 -0
  15. package/lib/types/source-view/copy-glyph.d.ts +6 -0
  16. package/lib/types/source-view/copy-glyph.spec.d.ts +1 -0
  17. package/lib/types/styles.generated.d.ts +1 -1
  18. package/package.json +13 -12
  19. package/scss/_window.scss +7 -2
  20. package/src/adw-application-window.spec.ts +8 -0
  21. package/src/adw-data-grid.spec.ts +11 -0
  22. package/src/adw-window.spec.ts +8 -0
  23. package/src/application.spec.ts +34 -1
  24. package/src/application.ts +16 -0
  25. package/src/bottom-sheet.spec.ts +9 -0
  26. package/src/canvas2d/canvas2d-bridge.ts +24 -8
  27. package/src/elements/adw-alert-dialog.ts +8 -5
  28. package/src/elements/adw-application-window.ts +90 -20
  29. package/src/elements/adw-banner.ts +14 -9
  30. package/src/elements/adw-bottom-sheet.ts +9 -0
  31. package/src/elements/adw-data-grid.ts +18 -0
  32. package/src/elements/adw-window-features.ts +128 -0
  33. package/src/elements/adw-window.ts +45 -108
  34. package/src/elements/gtk-application-window.ts +17 -6
  35. package/src/elements/gtk-window.ts +170 -18
  36. package/src/gtk-window.spec.ts +133 -0
  37. package/src/pango-markup.spec.ts +257 -0
  38. package/src/pango-markup.ts +266 -0
  39. package/src/source-view/adw-source-view.ts +2 -8
  40. package/src/source-view/copy-glyph.spec.ts +46 -0
  41. package/src/source-view/copy-glyph.ts +52 -0
  42. package/src/styles.generated.ts +1 -1
  43. package/src/test.browser.mts +4 -0
@@ -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';
@@ -198,7 +233,7 @@ export class GtkWindow extends HTMLElement {
198
233
  this._content.className = 'adw-gtk-window-content';
199
234
 
200
235
  bindSlottedChildren(this, [
201
- { name: 'titlebar', into: this._titlebar },
236
+ ...(this.hasTitlebar() ? [{ name: 'titlebar', into: this._titlebar }] : []),
202
237
  ...this.extraSlots(),
203
238
  { into: this._content },
204
239
  ]).install(...this.strips());
@@ -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
  /**
@@ -242,14 +308,28 @@ export class GtkWindow extends HTMLElement {
242
308
  return [this._titlebar, this._content];
243
309
  }
244
310
 
311
+ /**
312
+ * Whether this window has a titlebar strip at all. `Adw.Window` has none (it styles nothing
313
+ * itself, you pair it with a header bar), so a subclass answers `false` and neither the
314
+ * `titlebar` slot nor the server-side decoration is built.
315
+ */
316
+ protected hasTitlebar(): boolean {
317
+ return true;
318
+ }
319
+
245
320
  /** The slots a subclass adds beside `titlebar`; none by default. */
246
321
  protected extraSlots(): AdwSlot[] {
247
322
  return [];
248
323
  }
249
324
 
250
325
  attributeChangedCallback(name: string) {
251
- if (!this._initialized) return;
252
- this._render();
326
+ if (name === 'hidden') {
327
+ this._syncActive();
328
+ return;
329
+ }
330
+ // Before the first connect there is nothing to draw, but the property still moved: GTK
331
+ // notifies a window that was never mapped, and a listener on a detached frame hears it.
332
+ if (this._initialized) this._render();
253
333
  this.dispatchEvent(
254
334
  new CustomEvent(`notify::${name}`, { bubbles: true, detail: { [name]: this._property(name) } }),
255
335
  );
@@ -274,6 +354,19 @@ export class GtkWindow extends HTMLElement {
274
354
  this.classList.toggle('maximized', this.decorated && this.maximized);
275
355
  this.classList.toggle('fullscreen', this.decorated && this._offByDefault('fullscreened'));
276
356
 
357
+ this.renderFrameSize();
358
+
359
+ const title = this.getAttribute('title');
360
+ if (title === null) this.removeAttribute('aria-label');
361
+ else this.setAttribute('aria-label', title);
362
+ this._renderTitlebar();
363
+ }
364
+
365
+ /**
366
+ * The frame's box from `default-width` / `default-height`; an absent one clears it. A subclass
367
+ * with a size request of its own (`Adw.Window`'s 360×200) replaces this.
368
+ */
369
+ protected renderFrameSize(): void {
277
370
  const width = Number.parseFloat(this.getAttribute('default-width') ?? '');
278
371
  const height = Number.parseFloat(this.getAttribute('default-height') ?? '');
279
372
  for (const [axis, value] of [
@@ -283,11 +376,6 @@ export class GtkWindow extends HTMLElement {
283
376
  if (Number.isFinite(value) && value > 0) this.style.setProperty(axis, `${value}px`);
284
377
  else this.style.removeProperty(axis);
285
378
  }
286
-
287
- const title = this.getAttribute('title');
288
- if (title === null) this.removeAttribute('aria-label');
289
- else this.setAttribute('aria-label', title);
290
- this._renderTitlebar();
291
379
  }
292
380
 
293
381
  /**
@@ -313,7 +401,7 @@ export class GtkWindow extends HTMLElement {
313
401
  this._titleLabel = null;
314
402
  return;
315
403
  }
316
- if (!this.decorated) return;
404
+ if (!this.decorated || !this.hasTitlebar()) return;
317
405
 
318
406
  if (this._startControls === null) {
319
407
  this._startControls = document.createElement('gtk-window-controls');
@@ -344,14 +432,78 @@ export class GtkWindow extends HTMLElement {
344
432
  * it, hide the window when `hide-on-close` is set and DESTROY it otherwise. "Destroy" is
345
433
  * the one word this port cannot mean — a page has no unref — so the default detaches the
346
434
  * frame, which is the same observable outcome for everything above it.
435
+ *
436
+ * A frame that is not in the page is not realized, so there is nothing to close; a listener
437
+ * that calls `close()` again is ignored, as GTK ignores it. Answers whether the window went
438
+ * away (hidden or detached), `false` when a listener took the request over or it was ignored.
347
439
  */
348
440
  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;
441
+ const outcome = this._closing.close({
442
+ realized: this.isConnected,
443
+ hideOnClose: this._offByDefault('hide-on-close'),
444
+ emit: () => {
445
+ const request = new CustomEvent('close-request', { bubbles: true, cancelable: true });
446
+ this.dispatchEvent(request);
447
+ return request.defaultPrevented;
448
+ },
449
+ hide: () => void (this.hidden = true),
450
+ destroy: () => this.remove(),
451
+ });
452
+ return outcome === 'hidden' || outcome === 'destroyed';
453
+ }
454
+
455
+ /**
456
+ * `gtk_window_present`: show the frame. GTK also raises and focuses the toplevel, which is
457
+ * the window manager's to grant; a page owns no such surface, so this is only the first half.
458
+ */
459
+ present(): void {
460
+ this.hidden = false;
461
+ }
462
+
463
+ /** `Gtk.Window:is-active` — see the header: the document's focus, while this frame is shown. */
464
+ get isActive(): boolean {
465
+ return this._members.active;
466
+ }
467
+
468
+ /** `gtk_window_is_active`. */
469
+ is_active(): boolean {
470
+ return this._members.active;
471
+ }
472
+
473
+ /** `Gtk.Window:focus-widget`, read live from the document. */
474
+ get focusWidget(): Element | null {
475
+ return this.get_focus();
476
+ }
477
+
478
+ /** `gtk_window_get_focus`: the element inside the frame that has focus, or `null`. */
479
+ get_focus(): Element | null {
480
+ const active = document.activeElement;
481
+ return active !== null && active !== this && this.contains(active) ? active : null;
482
+ }
483
+
484
+ /**
485
+ * `gtk_window_set_focus`. `null` clears the focus. An element outside the frame is refused:
486
+ * GTK's window only focuses its own descendants.
487
+ */
488
+ set_focus(widget: Element | null): void {
489
+ if (widget === null) {
490
+ const current = this.get_focus();
491
+ if (current instanceof HTMLElement || current instanceof SVGElement) current.blur();
492
+ this._members.reportFocus(null);
493
+ return;
494
+ }
495
+ if (widget === this || !this.contains(widget)) {
496
+ throw new Error('<gtk-window>.set_focus: the widget is not inside this window');
497
+ }
498
+ if (widget instanceof HTMLElement || widget instanceof SVGElement) widget.focus();
499
+ // `focus()` fires no focusin while the document itself is unfocused, so report what the
500
+ // document now says; an equal write is dropped by the state.
501
+ this._members.reportFocus(this.get_focus());
502
+ }
503
+
504
+ /** `gtk_window_is_maximized`: the FRAME's state, not the browser window's. */
505
+ is_maximized(): boolean {
506
+ return this.maximized;
355
507
  }
356
508
 
357
509
  /**
@@ -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 } {
@@ -24,6 +26,58 @@ function titlebar(el: GtkWindow): HTMLElement {
24
26
  return el.querySelector('.adw-gtk-window-titlebar') as HTMLElement;
25
27
  }
26
28
 
29
+ /** The `Gtk.Window` conformance subject for any window element (`gtk-window`, `adw-window`, …). */
30
+ export function webWindowSubject(tag: string): { subject: WindowSubject; dispose: () => void } {
31
+ const windows = new Map<object, HTMLElement>();
32
+ const subject: WindowSubject = {
33
+ name: `<${tag}>`,
34
+ isOracle: false,
35
+ capabilities: { maximizable: true, observesFocus: true, hideOnClose: true },
36
+ create({ hideOnClose }) {
37
+ // Never in a page: not realized, which is GTK's state for a window before `present()`.
38
+ const el = document.createElement(tag) as GtkWindow;
39
+ if (hideOnClose) el.setAttribute('hide-on-close', 'true');
40
+ el.append(document.createElement('button'), document.createElement('button'));
41
+ return el;
42
+ },
43
+ show(w) {
44
+ const host = document.createElement('div');
45
+ document.body.appendChild(host);
46
+ host.appendChild(w as GtkWindow);
47
+ windows.set(w, host);
48
+ (w as GtkWindow).present();
49
+ },
50
+ connect(w, signal, handler) {
51
+ (w as GtkWindow).addEventListener(signal, (event) => {
52
+ if (signal === 'close-request') {
53
+ if (event.defaultPrevented) return;
54
+ if (handler()) {
55
+ event.preventDefault();
56
+ event.stopImmediatePropagation();
57
+ }
58
+ return;
59
+ }
60
+ handler();
61
+ });
62
+ return 1;
63
+ },
64
+ close: (w) => void (w as GtkWindow).close(),
65
+ maximize: (w) => (w as GtkWindow).maximize(),
66
+ unmaximize: (w) => (w as GtkWindow).unmaximize(),
67
+ isMaximized: (w) => (w as GtkWindow).is_maximized(),
68
+ isVisible: (w) => (w as GtkWindow).isConnected && !(w as GtkWindow).hidden,
69
+ focusables: (w) => [...(w as GtkWindow).querySelectorAll('button')] as unknown as [Element, Element],
70
+ setFocus: (w, widget) => (w as GtkWindow).set_focus(widget as Element | null),
71
+ getFocus: (w) => (w as GtkWindow).get_focus(),
72
+ };
73
+ return {
74
+ subject,
75
+ dispose: () => {
76
+ for (const host of windows.values()) host.remove();
77
+ },
78
+ };
79
+ }
80
+
27
81
  export const GtkWindowTest = async () => {
28
82
  await describe('<gtk-window> structure', async () => {
29
83
  await it('is a titlebar strip over a content box', async () => {
@@ -199,4 +253,83 @@ export const GtkWindowTest = async () => {
199
253
  host.remove();
200
254
  });
201
255
  });
256
+
257
+ // The element's native close contract is `preventDefault()`; a GTK handler "returns true". The
258
+ // adapter below is that mapping, and it also stops later listeners, which is what the handled
259
+ // accumulator does. So the "third never runs" row exercises the adapter plus the element's
260
+ // sequence, not DOM dispatch alone.
261
+ const { subject: webSubject, dispose } = webWindowSubject('gtk-window');
262
+ await driveWindowVectors(webSubject, { describe, it, expect }, WINDOW_VECTORS);
263
+ dispose();
264
+
265
+ await describe('<gtk-window> state members', async () => {
266
+ await it('a frame that was never connected is not realized: close() does nothing', async () => {
267
+ const el = document.createElement('gtk-window') as GtkWindow;
268
+ let seen = 0;
269
+ el.addEventListener('close-request', () => void seen++);
270
+ expect(el.close()).toBe(false);
271
+ expect(seen).toBe(0);
272
+ });
273
+
274
+ await it('is-active follows the document focus and notifies only on change', async () => {
275
+ const { el, host } = mount();
276
+ const seen: boolean[] = [];
277
+ el.addEventListener('notify::is-active', (e) => seen.push((e as CustomEvent).detail['is-active']));
278
+ const before = el.isActive;
279
+ expect(before).toBe(document.hasFocus());
280
+ el.hidden = true;
281
+ expect(el.is_active()).toBe(false);
282
+ el.present();
283
+ expect(el.isActive).toBe(document.hasFocus());
284
+ // Every transition above is one notification at most, and none repeats a value.
285
+ for (let i = 1; i < seen.length; i++) expect(seen[i]).not.toBe(seen[i - 1]);
286
+ host.remove();
287
+ expect(el.isActive).toBe(false);
288
+ });
289
+
290
+ await it('is-active has no setter: GTK declares it read-only', async () => {
291
+ const { el, host } = mount();
292
+ const descriptor =
293
+ Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el.constructor.prototype), 'isActive') ??
294
+ Object.getOwnPropertyDescriptor(el.constructor.prototype, 'isActive');
295
+ expect(descriptor?.set).toBe(undefined);
296
+ host.remove();
297
+ });
298
+
299
+ await it('set_focus refuses an element outside the frame, by name', async () => {
300
+ const { el, host } = mount();
301
+ const outside = document.createElement('button');
302
+ document.body.append(outside);
303
+ let message = '';
304
+ try {
305
+ el.set_focus(outside);
306
+ } catch (error) {
307
+ message = (error as Error).message;
308
+ }
309
+ expect(message.includes('set_focus')).toBe(true);
310
+ outside.remove();
311
+ host.remove();
312
+ });
313
+
314
+ await it('focus-widget notifies with the widget in its detail', async () => {
315
+ const { el, host } = mount();
316
+ const button = document.createElement('button');
317
+ el.append(button);
318
+ await Promise.resolve();
319
+ let detail: unknown;
320
+ el.addEventListener('notify::focus-widget', (e) => (detail = (e as CustomEvent).detail['focus-widget']));
321
+ el.set_focus(button);
322
+ expect(detail).toBe(button);
323
+ expect(el.focusWidget).toBe(button);
324
+ host.remove();
325
+ });
326
+
327
+ await it('is_maximized() reads the frame state', async () => {
328
+ const { el, host } = mount();
329
+ expect(el.is_maximized()).toBe(false);
330
+ el.maximize();
331
+ expect(el.is_maximized()).toBe(true);
332
+ host.remove();
333
+ });
334
+ });
202
335
  };