@gjsify/adwaita-web 0.64.1 → 0.64.2-next.252

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 (83) hide show
  1. package/README.md +12 -8
  2. package/dist/adwaita-web.css +39 -9
  3. package/dist/adwaita-web.css.map +1 -1
  4. package/lib/types/application.d.ts +1 -1
  5. package/lib/types/child-slot.spec.d.ts +1 -0
  6. package/lib/types/container-fill.spec.d.ts +1 -0
  7. package/lib/types/elements/adw-alert-dialog.d.ts +77 -17
  8. package/lib/types/elements/adw-toast-overlay.d.ts +8 -29
  9. package/lib/types/elements/adw-window-features.d.ts +8 -0
  10. package/lib/types/elements/gtk-application-window.d.ts +1 -4
  11. package/lib/types/elements/gtk-emoji-chooser.d.ts +2 -0
  12. package/lib/types/elements/gtk-popover-menu.d.ts +2 -0
  13. package/lib/types/elements/gtk-popover.d.ts +5 -0
  14. package/lib/types/elements/gtk-stack-page.d.ts +28 -0
  15. package/lib/types/elements/gtk-stack.d.ts +11 -0
  16. package/lib/types/elements/gtk-window.d.ts +16 -1
  17. package/lib/types/gobject-elements.d.ts +9 -0
  18. package/lib/types/index.d.ts +2 -2
  19. package/lib/types/learn6502-window.spec.d.ts +1 -0
  20. package/lib/types/namespace/adw.d.ts +1 -1
  21. package/lib/types/namespace/gtk.d.ts +2 -0
  22. package/lib/types/shared-tree-builder.d.ts +31 -0
  23. package/lib/types/styles.generated.d.ts +1 -1
  24. package/lib/types/widget-visible.spec.d.ts +1 -0
  25. package/lib/types/window-maximize.spec.d.ts +1 -0
  26. package/package.json +13 -12
  27. package/scss/_bin.scss +6 -3
  28. package/scss/_overlay.scss +11 -1
  29. package/scss/_reset.scss +1 -1
  30. package/scss/_stack.scss +8 -2
  31. package/scss/_toast.scss +13 -3
  32. package/scss/_window.scss +16 -0
  33. package/src/adw-alert-dialog.spec.ts +71 -5
  34. package/src/adw-bin.spec.ts +4 -3
  35. package/src/adw-header-bar.spec.ts +18 -0
  36. package/src/adw-toast-overlay.spec.ts +68 -24
  37. package/src/application.spec.ts +44 -0
  38. package/src/application.ts +13 -18
  39. package/src/capabilities.mjs +1 -3
  40. package/src/child-slot.spec.ts +71 -0
  41. package/src/construct-vectors.spec.ts +51 -6
  42. package/src/container-fill.spec.ts +70 -0
  43. package/src/elements/adw-alert-dialog.ts +174 -44
  44. package/src/elements/adw-application-window.ts +0 -3
  45. package/src/elements/adw-breakpoint-bin.ts +3 -0
  46. package/src/elements/adw-dialog.ts +3 -1
  47. package/src/elements/adw-header-bar.ts +4 -0
  48. package/src/elements/adw-toast-overlay.ts +35 -69
  49. package/src/elements/adw-window-features.ts +14 -2
  50. package/src/elements/gtk-application-window.ts +3 -8
  51. package/src/elements/gtk-drag-icon.ts +4 -0
  52. package/src/elements/gtk-emoji-chooser.ts +5 -0
  53. package/src/elements/gtk-flow-box-child.ts +3 -1
  54. package/src/elements/gtk-graphics-offload.ts +3 -1
  55. package/src/elements/gtk-list-box-row.ts +3 -1
  56. package/src/elements/gtk-overlay.ts +5 -4
  57. package/src/elements/gtk-paned.ts +7 -1
  58. package/src/elements/gtk-popover-bin.ts +3 -0
  59. package/src/elements/gtk-popover-menu.ts +5 -0
  60. package/src/elements/gtk-popover.ts +12 -0
  61. package/src/elements/gtk-search-bar.ts +5 -5
  62. package/src/elements/gtk-stack-page.ts +111 -0
  63. package/src/elements/gtk-stack.ts +50 -5
  64. package/src/elements/gtk-tree-expander.ts +3 -0
  65. package/src/elements/gtk-viewport.ts +4 -0
  66. package/src/elements/gtk-window-handle.ts +4 -0
  67. package/src/elements/gtk-window.ts +55 -6
  68. package/src/gobject-elements.ts +28 -0
  69. package/src/gtk-stack.spec.ts +170 -0
  70. package/src/index.ts +2 -2
  71. package/src/keyboard-operable.spec.ts +3 -3
  72. package/src/learn6502-window.spec.ts +113 -0
  73. package/src/namespace/adw.ts +1 -1
  74. package/src/namespace/gtk.ts +2 -0
  75. package/src/shared-tree-builder.ts +105 -10
  76. package/src/shared-trees.spec.ts +15 -1
  77. package/src/slotted-children.spec.ts +24 -0
  78. package/src/slotted-children.ts +18 -2
  79. package/src/styles.generated.ts +1 -1
  80. package/src/test.browser.mts +10 -0
  81. package/src/value-lists.spec.ts +3 -3
  82. package/src/widget-visible.spec.ts +104 -0
  83. package/src/window-maximize.spec.ts +93 -0
@@ -0,0 +1 @@
1
+ export declare const AdwWidgetVisibleTest: () => Promise<void>;
@@ -0,0 +1 @@
1
+ export declare const WindowMaximizeTest: () => Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/adwaita-web",
3
- "version": "0.64.1",
3
+ "version": "0.64.2-next.252",
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",
62
- "@gjsify/adwaita-fonts": "^0.64.1",
63
- "@gjsify/adwaita-icons": "^0.64.1",
64
- "@gjsify/gtksource-core": "^0.64.1",
61
+ "@gjsify/adwaita-core": "0.64.2-next.252",
62
+ "@gjsify/adwaita-fonts": "0.64.2-next.252",
63
+ "@gjsify/adwaita-icons": "0.64.2-next.252",
64
+ "@gjsify/gtksource-core": "0.64.2-next.252",
65
65
  "@lezer/highlight": "^1.2.3"
66
66
  },
67
67
  "peerDependencies": {
68
- "@gjsify/app-settings": "^0.64.1"
68
+ "@gjsify/app-settings": "0.64.2-next.252"
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",
77
- "@gjsify/blueprint": "^0.64.1",
78
- "@gjsify/cli": "^0.64.1",
79
- "@gjsify/unit": "^0.64.1",
80
- "@gjsify/vite-plugin-blueprint": "^0.64.1",
76
+ "@gjsify/app-settings": "0.64.2-next.252",
77
+ "@gjsify/blueprint": "0.64.2-next.252",
78
+ "@gjsify/cli": "0.64.2-next.252",
79
+ "@gjsify/unit": "0.64.2-next.252",
80
+ "@gjsify/vite-plugin-blueprint": "0.64.2-next.252",
81
81
  "sass": "^1.101.0",
82
82
  "typescript": "^6.0.3"
83
83
  },
@@ -114,5 +114,6 @@
114
114
  "bugs": {
115
115
  "url": "https://github.com/gjsify/gjsify/issues"
116
116
  },
117
- "homepage": "https://github.com/gjsify/gjsify/tree/main/packages/web/adwaita-web#readme"
117
+ "homepage": "https://github.com/gjsify/gjsify/tree/main/packages/web/adwaita-web#readme",
118
+ "gitHead": "3853c65f772d30460e3894ff08a6079a70d7b206"
118
119
  }
package/scss/_bin.scss CHANGED
@@ -14,13 +14,16 @@
14
14
  // Modifications: Adapted for @gjsify/adwaita-web Web Components.
15
15
 
16
16
  adw-bin {
17
- display: block;
17
+ display: flex;
18
+ flex-direction: column;
18
19
 
19
20
  // One child, and it fills — `gtk_bin_layout_allocate` allocates the whole widget to
20
21
  // its single child. `:only-child` rather than `> *` because a bin keeps ONE
21
22
  // (`adw_bin_set_child` unparents the one it replaces), so the case where the selector
22
- // could match two is one the element has already refused.
23
+ // could match two is one the element has already refused. Flex stretch rather than
24
+ // `block-size: 100%`: a percentage needs a definite parent height, which a bin that is
25
+ // itself a flex item (a stack page) only has by the stretch algorithm.
23
26
  > :only-child {
24
- block-size: 100%;
27
+ flex: 1 1 auto;
25
28
  }
26
29
  }
@@ -38,13 +38,23 @@
38
38
  // Modifications: Adapted for @gjsify/adwaita-web Web Components.
39
39
 
40
40
  gtk-overlay {
41
- display: block;
41
+ // A column flex box rather than a block: `gtk_overlay_size_allocate` hands the main child
42
+ // the overlay's WHOLE allocation (gtkoverlay.c), and a block would give it only its content
43
+ // height. With an auto height the overlay still measures as its child (the child's
44
+ // `flex-basis: auto` is the content), with a definite one the child grows to fill it.
45
+ display: flex;
46
+ flex-direction: column;
42
47
  position: relative;
43
48
  }
44
49
 
45
50
  .adw-overlay-child {
46
51
  // The main child, in the host's own flow — see the header.
47
52
  display: contents;
53
+
54
+ // Fill the allocation on the main axis; the cross axis stretches by default.
55
+ > * {
56
+ flex: 1 1 auto;
57
+ }
48
58
  }
49
59
 
50
60
  .adw-overlay-layer {
package/scss/_reset.scss CHANGED
@@ -48,7 +48,7 @@ $adw-components:
48
48
  gtk-link-button, gtk-list-box, gtk-list-box-row, gtk-list-view, gtk-media-controls, gtk-menu-button, gtk-overlay, gtk-page-setup-unix-dialog, gtk-paned,
49
49
  gtk-password-entry, gtk-picture, gtk-popover, gtk-popover-bin, gtk-popover-menu, gtk-popover-menu-bar, gtk-print-unix-dialog,
50
50
  gtk-progress-bar, gtk-revealer, gtk-scale, gtk-scale-button, gtk-scrollbar, gtk-scrolled-window, gtk-search-bar, gtk-search-entry, gtk-separator, gtk-source-view,
51
- gtk-spin-button, gtk-spinner, gtk-stack, gtk-stack-sidebar, gtk-stack-switcher, gtk-switch, gtk-text, gtk-text-view, gtk-toggle-button, gtk-tree-expander, gtk-video, gtk-viewport,
51
+ gtk-spin-button, gtk-spinner, gtk-stack, gtk-stack-page, gtk-stack-sidebar, gtk-stack-switcher, gtk-switch, gtk-text, gtk-text-view, gtk-toggle-button, gtk-tree-expander, gtk-video, gtk-viewport,
52
52
  gtk-window, gtk-window-controls, gtk-window-handle;
53
53
 
54
54
  // Roots: re-root the inherited typographic properties (and the box model) so a
package/scss/_stack.scss CHANGED
@@ -60,13 +60,19 @@ gtk-stack {
60
60
  // looks at the VISIBLE child alone on that axis (gtkstack.c:2899-2905) — the stack is
61
61
  // as big as the current page. Turned off, the stack is as big as the LARGEST page,
62
62
  // which is `max-content` on the cross axis and `flex: 1 1 auto` along the main one.
63
- &:not([hhomogeneous]) > .adw-stack-page {
63
+ //
64
+ // The element reflects the flag as an ABSENT attribute or `=""` for TRUE and
65
+ // `="false"` for FALSE (`_flag`: `raw !== 'false'`, absent → the default TRUE), so
66
+ // only the literal `'false'` is the non-homogeneous state. `:not([hhomogeneous])`
67
+ // read a missing attribute as false and gave every default stack's page
68
+ // `width: max-content`, a zero-width page for a child with no intrinsic width.
69
+ &[hhomogeneous='false'] > .adw-stack-page {
64
70
  flex: 0 1 auto;
65
71
  width: max-content;
66
72
  max-width: 100%;
67
73
  }
68
74
 
69
- &:not([vhomogeneous]) > .adw-stack-page {
75
+ &[vhomogeneous='false'] > .adw-stack-page {
70
76
  height: max-content;
71
77
  max-height: 100%;
72
78
  }
package/scss/_toast.scss CHANGED
@@ -12,12 +12,22 @@
12
12
 
13
13
  adw-toast-overlay {
14
14
  position: relative;
15
- display: block;
15
+ display: flex;
16
+ flex-direction: column;
16
17
 
17
- // The wrapped content (Adw.ToastOverlay :child) occupies the overlay's box.
18
+ // The wrapped content (Adw.ToastOverlay :child) occupies the overlay's box: AdwToastOverlay
19
+ // allocates its child the full size (adw-toast-overlay.c size_allocate). A percentage height
20
+ // resolves against an indefinite one inside a block parent, so the fill is flex stretch.
18
21
  .adw-toast-overlay-content {
22
+ display: flex;
23
+ flex: 1 1 auto;
24
+ flex-direction: column;
25
+ min-height: 0;
19
26
  width: 100%;
20
- height: 100%;
27
+
28
+ > * {
29
+ flex: 1 1 auto;
30
+ }
21
31
  }
22
32
 
23
33
  // The toast slot: layered over the bottom edge of the overlay and centered,
package/scss/_window.scss CHANGED
@@ -43,6 +43,15 @@ adw-application-window {
43
43
  box-shadow: none;
44
44
  }
45
45
 
46
+ // A maximized or fullscreen window ignores its default size and fills the work area
47
+ // (gtkwindow.c); on a page that is the containing block. The element drops its inline
48
+ // `default-width`/`default-height` in these states so this can apply, and keeps the minimum.
49
+ &[maximized]:not([maximized="false"]),
50
+ &[fullscreened]:not([fullscreened="false"]) {
51
+ width: 100%;
52
+ height: 100%;
53
+ }
54
+
46
55
  // `present()` and `hide-on-close` toggle `hidden`, which `display: flex` would override.
47
56
  &[hidden] {
48
57
  display: none;
@@ -110,6 +119,13 @@ gtk-application-window {
110
119
  box-shadow: none;
111
120
  }
112
121
 
122
+ // The same fill as `adw-window` above: the default size is only the restored size.
123
+ &[maximized]:not([maximized="false"]),
124
+ &[fullscreened]:not([fullscreened="false"]) {
125
+ width: 100%;
126
+ height: 100%;
127
+ }
128
+
113
129
  // The strip: an application's `titlebar` slot, or the two window-control groups and the
114
130
  // window's own title. `min-width: 0` on the title is what lets a long one ellipsize
115
131
  // instead of pushing the end controls out of the frame.
@@ -124,8 +124,8 @@ export const AdwAlertDialogTest = async () => {
124
124
  // directly would assert a contract this element does not have.
125
125
  const { el, host } = mount({ 'close-response': 'cancel', open: '' });
126
126
  let seen: string | null = null;
127
- el.addEventListener('response', (event) => {
128
- seen = (event as CustomEvent<{ response: string }>).detail.response;
127
+ el.connect('response', (_dialog, response: string) => {
128
+ seen = response;
129
129
  });
130
130
  (el.querySelector('.adw-alert-dialog-scrim') as HTMLElement).click();
131
131
  expect(seen).toBe('cancel');
@@ -147,9 +147,9 @@ export const AdwAlertDialogTest = async () => {
147
147
  await describe('adw-alert-dialog responses before the first connect', async () => {
148
148
  await it('buttons added while detached appear once connected', () => {
149
149
  const el = document.createElement('adw-alert-dialog') as AdwAlertDialog;
150
- el.addResponse('cancel', 'Cancel');
151
- el.addResponse('ok', 'OK');
152
- expect(el.getResponseLabel('ok')).toBe('OK');
150
+ el.add_response('cancel', 'Cancel');
151
+ el.add_response('ok', 'OK');
152
+ expect(el.get_response_label('ok')).toBe('OK');
153
153
  const host = document.createElement('div');
154
154
  document.body.appendChild(host);
155
155
  host.appendChild(el);
@@ -158,4 +158,70 @@ export const AdwAlertDialogTest = async () => {
158
158
  host.remove();
159
159
  });
160
160
  });
161
+
162
+ // The calls a GJS app makes (Learn6502's `notification.service.ts`), run as written.
163
+ await describe('adw-alert-dialog in the GJS construct-props form', async () => {
164
+ const Dialog = customElements.get('adw-alert-dialog') as unknown as new (
165
+ props?: Record<string, unknown>,
166
+ ) => AdwAlertDialog;
167
+
168
+ const confirmDialog = () => {
169
+ const dialog = new Dialog({
170
+ heading: 'Reset?',
171
+ body: 'This clears the memory.',
172
+ close_response: 'cancel',
173
+ default_response: 'confirm',
174
+ });
175
+ dialog.add_response('cancel', 'Cancel');
176
+ dialog.add_response('confirm', 'Confirm');
177
+ return dialog;
178
+ };
179
+
180
+ await it('takes the snake_case bag', () => {
181
+ const dialog = confirmDialog();
182
+ expect(dialog.heading).toBe('Reset?');
183
+ expect(dialog.body).toBe('This clears the memory.');
184
+ expect(dialog.get_close_response()).toBe('cancel');
185
+ expect(dialog.get_default_response()).toBe('confirm');
186
+ });
187
+
188
+ await it('refuses a key that is no Adw.AlertDialog property', () => {
189
+ expect(() => new Dialog({ no_such_prop: 1 })).toThrow();
190
+ });
191
+
192
+ await it('present() attaches it, a click emits response with the id, and closing detaches it', () => {
193
+ const dialog = confirmDialog();
194
+ const seen: unknown[][] = [];
195
+ dialog.connect('response', (emitter, response: string) => seen.push([emitter, response]));
196
+ dialog.present(null);
197
+ expect(dialog.isConnected).toBe(true);
198
+ expect(dialog.open).toBe(true);
199
+ const buttons = [...dialog.querySelectorAll('.adw-alert-dialog-response')] as HTMLButtonElement[];
200
+ buttons.find((b) => b.textContent === 'Confirm')!.click();
201
+ expect(seen.length).toBe(1);
202
+ expect(seen[0]![0]).toBe(dialog);
203
+ expect(seen[0]![1]).toBe('confirm');
204
+ expect(dialog.open).toBe(false);
205
+ expect(dialog.isConnected).toBe(false);
206
+ });
207
+
208
+ await it('a dismissal emits the close_response once, not the clicked one too', () => {
209
+ const dialog = confirmDialog();
210
+ const seen: string[] = [];
211
+ dialog.connect('response', (_dialog, response: string) => seen.push(response));
212
+ dialog.present(null);
213
+ (dialog.querySelector('.adw-alert-dialog-scrim') as HTMLElement).click();
214
+ expect(seen.join(',')).toBe('cancel');
215
+ expect(dialog.isConnected).toBe(false);
216
+ });
217
+
218
+ await it('remove_response drops the response and its button', () => {
219
+ const dialog = confirmDialog();
220
+ dialog.present(null);
221
+ dialog.remove_response('confirm');
222
+ expect(dialog.has_response('confirm')).toBe(false);
223
+ expect(dialog.querySelectorAll('.adw-alert-dialog-response').length).toBe(1);
224
+ dialog.remove();
225
+ });
226
+ });
161
227
  };
@@ -61,12 +61,13 @@ export const AdwBinTest = async () => {
61
61
  other.remove();
62
62
  });
63
63
 
64
- await it('an empty bin is a block box, and its only child fills it', () => {
64
+ await it('a bin is a column flex box, and its only child fills it', () => {
65
65
  const { el, host } = mount([document.createElement('div')]);
66
66
  // A bin takes the size it is GIVEN and hands all of it to the child, which
67
- // `GtkBinLayout` does on both axes (gtkbinlayout.c:81).
67
+ // `GtkBinLayout` does on both axes (gtkbinlayout.c:81). Flex stretch, not a
68
+ // percentage, so it also holds when the bin is itself a flex item.
68
69
  el.style.height = '120px';
69
- expect(getComputedStyle(el).display).toBe('block');
70
+ expect(getComputedStyle(el).display).toBe('flex');
70
71
  const child = el.firstElementChild as HTMLElement;
71
72
  expect(child.getBoundingClientRect().height).toBe(120);
72
73
  host.remove();
@@ -233,6 +233,24 @@ export const AdwHeaderBarTest = async () => {
233
233
 
234
234
  // `windowtitle .title, .subtitle { padding: 0 12px }` (_header-bar.scss:214-227).
235
235
  // Measured beside a GTK render of the gallery's window-title `.blp`: 27px narrower here.
236
+ await describe('<adw-header-bar> packing', async () => {
237
+ await it('packs an untyped child from the start, declared or appended', async () => {
238
+ // adw-header-bar.c:1040-1041 — `type == NULL` is pack_start. A Blueprint `[start]`
239
+ // tags only the next child, so the buttons after it arrive untyped.
240
+ const tagged = document.createElement('button');
241
+ tagged.setAttribute('slot', 'start');
242
+ const declared = document.createElement('button');
243
+ const bar = mountHeaderBar({ title: 'T' }, [tagged, declared]);
244
+ const late = document.createElement('button');
245
+ bar.appendChild(late);
246
+ await Promise.resolve();
247
+ const start = bar.querySelector('.adw-header-bar-start');
248
+ const report = [tagged, declared, late].map((el) => el.parentElement === start);
249
+ unmountAll();
250
+ expect(report).toStrictEqual([true, true, true]);
251
+ });
252
+ });
253
+
236
254
  await describe('adw-window-title label sides', async () => {
237
255
  await it('pads its title and subtitle 12px on each side', () => {
238
256
  const title = document.createElement('adw-window-title');
@@ -1,12 +1,14 @@
1
1
  // DOM-level behaviour tests for <adw-toast-overlay>, in a real browser via the
2
2
  // @gjsify/adwaita-web browser test axis.
3
3
  //
4
- // `Adw.ToastOverlay` shows exactly ONE toast at a time and queues the rest FIFO,
4
+ // `Adw.ToastOverlay` shows exactly ONE toast at a time and queues the rest,
5
5
  // advancing only when the visible one is dismissed (manually or by its timeout) — the
6
- // policy `AdwToastQueue` owns in `@gjsify/adwaita-core` and this element composes.
6
+ // policy `ToastOverlayQueue` owns in `@gjsify/adwaita-core` and this element composes.
7
+ // A toast is an `Adw.Toast`, added with `add_toast(toast)` as on GTK.
7
8
  //
8
9
  // Timing runs through core's injected `ToastScheduler` seam (a virtual clock), so the
9
10
  // auto-dismiss lifecycle is deterministic with no real timers.
11
+ import { Toast } from '@gjsify/adwaita-core';
10
12
  import { describe, expect, it } from '@gjsify/unit';
11
13
 
12
14
  import type { AdwToastOverlay, ToastScheduler, ToastTimerHandle } from './elements/adw-toast-overlay.js';
@@ -65,12 +67,10 @@ export const AdwToastOverlayTest = async () => {
65
67
  await it('shows exactly one toast at a time, in FIFO order', async () => {
66
68
  const { overlay, clock, host } = mountOverlay();
67
69
 
68
- overlay.addToast('First', { timeout: 3 });
69
- overlay.addToast('Second', { timeout: 3 });
70
- overlay.addToast('Third', { timeout: 3 });
70
+ overlay.add_toast(new Toast({ title: 'First', timeout: 3 }));
71
+ overlay.add_toast(new Toast({ title: 'Second', timeout: 3 }));
72
+ overlay.add_toast(new Toast({ title: 'Third', timeout: 3 }));
71
73
 
72
- // BEFORE the fix this was ['First', 'Second', 'Third'] — all three
73
- // strips mounted at once.
74
74
  expect(toastTitles(overlay)).toStrictEqual(['First']);
75
75
  expect(overlay.pendingToasts).toBe(2);
76
76
  expect(overlay.currentToast?.title).toBe('First');
@@ -96,8 +96,9 @@ export const AdwToastOverlayTest = async () => {
96
96
  await it('holds the queue while a sticky toast (timeout 0) is shown', async () => {
97
97
  const { overlay, clock, host } = mountOverlay();
98
98
 
99
- overlay.addToast('Sticky', { timeout: 0 });
100
- overlay.addToast('Next', { timeout: 3 });
99
+ const sticky = new Toast({ title: 'Sticky', timeout: 0 });
100
+ overlay.add_toast(sticky);
101
+ overlay.add_toast(new Toast({ title: 'Next', timeout: 3 }));
101
102
 
102
103
  // No timer was scheduled for the sticky toast, so time does not advance it.
103
104
  clock.advance(60_000);
@@ -105,7 +106,7 @@ export const AdwToastOverlayTest = async () => {
105
106
  expect(overlay.pendingToasts).toBe(1);
106
107
 
107
108
  // Dismissing it hands the slot to the queued toast.
108
- overlay.dismiss();
109
+ sticky.dismiss();
109
110
  expect(toastTitles(overlay)).toStrictEqual(['Next']);
110
111
  expect(overlay.pendingToasts).toBe(0);
111
112
 
@@ -115,30 +116,34 @@ export const AdwToastOverlayTest = async () => {
115
116
  await it('advances the queue when the close button is pressed', async () => {
116
117
  const { overlay, host } = mountOverlay();
117
118
 
118
- overlay.addToast('First', { timeout: 3 });
119
- overlay.addToast('Second', { timeout: 3 });
119
+ const first = new Toast({ title: 'First', timeout: 3 });
120
+ let dismissed = 0;
121
+ first.connect('dismissed', () => {
122
+ dismissed += 1;
123
+ });
124
+ overlay.add_toast(first);
125
+ overlay.add_toast(new Toast({ title: 'Second', timeout: 3 }));
120
126
 
121
127
  const close = overlay.querySelector('.adw-toast-close-button') as HTMLButtonElement;
122
128
  close.click();
123
129
 
130
+ expect(dismissed).toBe(1);
124
131
  expect(toastTitles(overlay)).toStrictEqual(['Second']);
125
132
  expect(overlay.pendingToasts).toBe(0);
126
133
 
127
134
  host.remove();
128
135
  });
129
136
 
130
- await it('runs the action callback and advances the queue', async () => {
137
+ await it('emits button-clicked and advances the queue', async () => {
131
138
  const { overlay, host } = mountOverlay();
132
139
 
140
+ const toast = new Toast({ title: 'File moved to Trash', timeout: 3, button_label: 'Undo' });
133
141
  let undone = 0;
134
- overlay.addToast('File moved to Trash', {
135
- timeout: 3,
136
- buttonLabel: 'Undo',
137
- onAction: () => {
138
- undone += 1;
139
- },
142
+ toast.connect('button-clicked', () => {
143
+ undone += 1;
140
144
  });
141
- overlay.addToast('Second', { timeout: 3 });
145
+ overlay.add_toast(toast);
146
+ overlay.add_toast(new Toast({ title: 'Second', timeout: 3 }));
142
147
 
143
148
  const action = overlay.querySelector('.adw-toast-action-button') as HTMLButtonElement;
144
149
  expect(action.textContent).toBe('Undo');
@@ -150,12 +155,51 @@ export const AdwToastOverlayTest = async () => {
150
155
  host.remove();
151
156
  });
152
157
 
153
- await it('drops the queued toasts on clearToasts()', async () => {
158
+ await it('draws no action button without a button_label', async () => {
159
+ const { overlay, host } = mountOverlay();
160
+
161
+ overlay.add_toast(new Toast({ title: 'Saved' }));
162
+ expect(overlay.querySelector('.adw-toast-action-button')).toBeNull();
163
+
164
+ host.remove();
165
+ });
166
+
167
+ await it('renders the title as markup unless use_markup is off', async () => {
168
+ const { overlay, host } = mountOverlay();
169
+
170
+ overlay.add_toast(new Toast({ title: '<b>Bold</b>', timeout: 0 }));
171
+ expect(overlay.querySelector('.adw-toast-title b')?.textContent).toBe('Bold');
172
+ overlay.dismiss_all();
173
+
174
+ const { overlay: plain, host: plainHost } = mountOverlay();
175
+ plain.add_toast(new Toast({ title: '<b>Bold</b>', timeout: 0, use_markup: false }));
176
+ expect(plain.querySelector('.adw-toast-title b')).toBeNull();
177
+ expect(toastTitles(plain)).toStrictEqual(['<b>Bold</b>']);
178
+
179
+ host.remove();
180
+ plainHost.remove();
181
+ });
182
+
183
+ await it('shows a HIGH priority toast ahead of the current one', async () => {
184
+ const { overlay, host } = mountOverlay();
185
+
186
+ overlay.add_toast(new Toast({ title: 'Normal', timeout: 0 }));
187
+ const urgent = new Toast({ title: 'Urgent', timeout: 0, priority: 1 });
188
+ overlay.add_toast(urgent);
189
+
190
+ expect(toastTitles(overlay)).toStrictEqual(['Urgent']);
191
+ urgent.dismiss();
192
+ expect(toastTitles(overlay)).toStrictEqual(['Normal']);
193
+
194
+ host.remove();
195
+ });
196
+
197
+ await it('dismisses the visible toast and the queue on dismiss_all()', async () => {
154
198
  const { overlay, clock, host } = mountOverlay();
155
199
 
156
- overlay.addToast('First', { timeout: 3 });
157
- overlay.addToast('Second', { timeout: 3 });
158
- overlay.clearToasts();
200
+ overlay.add_toast(new Toast({ title: 'First', timeout: 3 }));
201
+ overlay.add_toast(new Toast({ title: 'Second', timeout: 3 }));
202
+ overlay.dismiss_all();
159
203
 
160
204
  expect(overlay.currentToast).toBeNull();
161
205
  expect(overlay.pendingToasts).toBe(0);
@@ -95,6 +95,50 @@ export const ApplicationTest = async () => {
95
95
  (window as unknown as { application: object | null }).application = null;
96
96
  expect(press({ key: 's', ctrlKey: true })).toBe(false);
97
97
  });
98
+ await it('inhibit(LOGOUT) cancels beforeunload until uninhibit', () => {
99
+ const app = new Adw.Application({ application_id: 'org.gjsify.vec.WebInhibit' });
100
+ const unload = (): boolean => {
101
+ const event = new Event('beforeunload', { cancelable: true });
102
+ window.dispatchEvent(event);
103
+ return event.defaultPrevented;
104
+ };
105
+ expect(unload()).toBe(false);
106
+ const cookie = app.inhibit(null, Gtk.ApplicationInhibitFlags.LOGOUT, 'unsaved changes');
107
+ expect(cookie > 0).toBe(true);
108
+ expect(unload()).toBe(true);
109
+ app.uninhibit(cookie);
110
+ expect(unload()).toBe(false);
111
+ });
112
+ await it('every window class has application, get_application and set_application', () => {
113
+ const app = new Adw.Application({ application_id: 'org.gjsify.vec.WebWindowApp' });
114
+ const other = new Adw.Application({ application_id: 'org.gjsify.vec.WebWindowOther' });
115
+ for (const tag of ['gtk-window', 'gtk-application-window', 'adw-window', 'adw-application-window']) {
116
+ const window = document.createElement(tag) as unknown as Gtk.WindowCommon<Element>;
117
+ const notified: unknown[] = [];
118
+ window.connect('notify::application', () => notified.push(window.application));
119
+ expect(window.get_application()).toBe(null);
120
+ window.set_application(app as never);
121
+ expect(window.get_application()).toBe(app);
122
+ expect(window.application).toBe(app);
123
+ expect(app.get_windows().includes(window)).toBe(true);
124
+ window.set_application(other as never);
125
+ expect(app.get_windows().includes(window)).toBe(false);
126
+ expect(other.get_windows().includes(window)).toBe(true);
127
+ window.application = null;
128
+ expect(window.get_application()).toBe(null);
129
+ expect(other.get_windows().includes(window)).toBe(false);
130
+ expect(notified).toStrictEqual([app, other, null]);
131
+ }
132
+ });
133
+ await it('a window reaches inhibit through get_application()', () => {
134
+ const app = new Adw.Application({ application_id: 'org.gjsify.vec.WebWindowInhibit' });
135
+ const window = document.createElement('gtk-window') as unknown as Gtk.WindowCommon<Element>;
136
+ window.set_application(app as never);
137
+ const cookie = window.get_application()?.inhibit(window, Gtk.ApplicationInhibitFlags.LOGOUT, 'unsaved');
138
+ expect((cookie ?? 0) > 0).toBe(true);
139
+ window.get_application()?.uninhibit(cookie ?? 0);
140
+ window.set_application(null);
141
+ });
98
142
  await it('quit() takes the windows it added off the page', async () => {
99
143
  const app = new Adw.Application({ application_id: 'org.gjsify.vec.WebQuit' });
100
144
  const window = scene.window(app as never) as Element;
@@ -10,10 +10,9 @@ import {
10
10
  withGtkApplication,
11
11
  activateWidgetAction as activateIn,
12
12
  activateMenuItem,
13
- applicationOfWindow,
13
+ defineWindowApplicationMembers,
14
14
  dispatchAccelerator,
15
15
  menuActionsFor,
16
- installActionMap,
17
16
  type AdwMenuItem,
18
17
  type AdwMenuActions,
19
18
  type AdwMenuModel,
@@ -40,8 +39,18 @@ export const webHost: ApplicationHost = {
40
39
  },
41
40
  exit() {},
42
41
  iconTheme: () => IconTheme.get_for_display(null),
42
+ guardUnload(active) {
43
+ if (active) window.addEventListener('beforeunload', confirmUnload);
44
+ else window.removeEventListener('beforeunload', confirmUnload);
45
+ },
43
46
  };
44
47
 
48
+ /** The only hook a page has on Ctrl+Q, closing the tab and reload; the browser words the prompt. */
49
+ function confirmUnload(event: BeforeUnloadEvent): void {
50
+ event.preventDefault();
51
+ event.returnValue = '';
52
+ }
53
+
45
54
  /** An element's parent for action resolution; a popover is rooted at the widget that owns it. */
46
55
  export function parentOfElement(node: object): object | null {
47
56
  if (!(node instanceof Element)) return null;
@@ -88,23 +97,9 @@ function followAccelerators(window: object, application: WindowApplication | nul
88
97
  }
89
98
 
90
99
  /**
91
- * `Gtk.Window:application` and the window's `Gio.ActionMap` (`win.` actions) on an element class.
100
+ * `Gtk.Window:application`, `get_application()` and `set_application()` on a window element class.
92
101
  * Setting the property is `add_window`, as on GTK; setting it to `null` is `remove_window`.
93
102
  */
94
103
  export function defineWindowApplication(proto: object): void {
95
- installActionMap(proto);
96
- Object.defineProperty(proto, 'application', {
97
- configurable: true,
98
- enumerable: true,
99
- get(this: object) {
100
- return applicationOfWindow(this);
101
- },
102
- set(this: object, value: WindowApplication | null) {
103
- const current = applicationOfWindow(this) as unknown as WindowApplication | null;
104
- if (current === value) return;
105
- current?.remove_window(this);
106
- value?.add_window(this);
107
- followAccelerators(this, value);
108
- },
109
- });
104
+ defineWindowApplicationMembers(proto, followAccelerators);
110
105
  }
@@ -12,7 +12,5 @@ export const capabilities = {
12
12
  signal: 'implemented',
13
13
  bind: 'implemented',
14
14
  breakpoint: 'implemented',
15
- 'sibling-object': {
16
- refused: 'the web builder mounts one root element and has no consumer for a second root yet (ADR 0093)',
17
- },
15
+ 'sibling-object': 'implemented',
18
16
  };