@gjsify/adwaita-web 0.30.0 → 0.32.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.
Files changed (176) hide show
  1. package/dist/adwaita-web.css +786 -272
  2. package/dist/adwaita-web.css.map +1 -1
  3. package/lib/types/adw-about-dialog.spec.d.ts +1 -0
  4. package/lib/types/adw-action-rows.spec.d.ts +1 -0
  5. package/lib/types/adw-avatar.spec.d.ts +1 -0
  6. package/lib/types/adw-banner.spec.d.ts +1 -0
  7. package/lib/types/adw-button-content.spec.d.ts +1 -0
  8. package/lib/types/adw-carousel.spec.d.ts +1 -0
  9. package/lib/types/adw-checks.spec.d.ts +1 -0
  10. package/lib/types/adw-header-bar.spec.d.ts +1 -0
  11. package/lib/types/adw-icon.spec.d.ts +1 -0
  12. package/lib/types/adw-progress-bar.spec.d.ts +1 -0
  13. package/lib/types/adw-sidebar.spec.d.ts +1 -0
  14. package/lib/types/adw-switch.spec.d.ts +1 -0
  15. package/lib/types/adw-wrap-box.spec.d.ts +1 -0
  16. package/lib/types/bottom-sheet.spec.d.ts +1 -0
  17. package/lib/types/breakpoints.d.ts +22 -0
  18. package/lib/types/breakpoints.spec.d.ts +1 -0
  19. package/lib/types/chrome.spec.d.ts +1 -0
  20. package/lib/types/elements/adw-about-dialog.d.ts +47 -8
  21. package/lib/types/elements/adw-action-row.d.ts +35 -0
  22. package/lib/types/elements/adw-avatar.d.ts +11 -0
  23. package/lib/types/elements/adw-bottom-sheet.d.ts +33 -4
  24. package/lib/types/elements/adw-button-content.d.ts +12 -0
  25. package/lib/types/elements/adw-button-row.d.ts +10 -1
  26. package/lib/types/elements/adw-carousel.d.ts +111 -8
  27. package/lib/types/elements/adw-checks.d.ts +57 -0
  28. package/lib/types/elements/adw-clamp.d.ts +4 -1
  29. package/lib/types/elements/adw-data-grid.d.ts +11 -31
  30. package/lib/types/elements/adw-drop-down.d.ts +17 -20
  31. package/lib/types/elements/adw-entry-row.d.ts +93 -3
  32. package/lib/types/elements/adw-expander-row.d.ts +7 -1
  33. package/lib/types/elements/adw-header-bar.d.ts +15 -0
  34. package/lib/types/elements/adw-icon.d.ts +25 -0
  35. package/lib/types/elements/adw-inline-view-switcher.d.ts +37 -11
  36. package/lib/types/elements/adw-menu-button.d.ts +6 -6
  37. package/lib/types/elements/adw-navigation-split-view.d.ts +6 -0
  38. package/lib/types/elements/adw-navigation-view.d.ts +63 -20
  39. package/lib/types/elements/adw-overlay-split-view.d.ts +38 -0
  40. package/lib/types/elements/adw-password-entry-row.d.ts +19 -10
  41. package/lib/types/elements/adw-popover.d.ts +58 -0
  42. package/lib/types/elements/adw-preferences-dialog.d.ts +57 -2
  43. package/lib/types/elements/adw-preferences-group.d.ts +31 -0
  44. package/lib/types/elements/adw-progress-bar.d.ts +38 -0
  45. package/lib/types/elements/adw-sidebar.d.ts +37 -5
  46. package/lib/types/elements/adw-split-button.d.ts +53 -7
  47. package/lib/types/elements/adw-switch-row.d.ts +11 -2
  48. package/lib/types/elements/adw-switch.d.ts +15 -0
  49. package/lib/types/elements/adw-tab-view.d.ts +129 -12
  50. package/lib/types/elements/adw-toolbar-view.d.ts +5 -0
  51. package/lib/types/elements/adw-view-stack.d.ts +27 -19
  52. package/lib/types/elements/adw-view-switcher-bar.d.ts +23 -7
  53. package/lib/types/elements/adw-view-switcher.d.ts +22 -11
  54. package/lib/types/elements/adw-window-title.d.ts +3 -1
  55. package/lib/types/elements/view-switcher-dom.d.ts +44 -0
  56. package/lib/types/entry-rows.spec.d.ts +1 -0
  57. package/lib/types/index.d.ts +7 -0
  58. package/lib/types/navigation-view.spec.d.ts +1 -0
  59. package/lib/types/preferences-search.d.ts +20 -0
  60. package/lib/types/preferences.spec.d.ts +1 -0
  61. package/lib/types/split-button.spec.d.ts +1 -0
  62. package/lib/types/split-views.spec.d.ts +1 -0
  63. package/lib/types/styles.generated.d.ts +1 -1
  64. package/lib/types/tab-view.spec.d.ts +1 -0
  65. package/lib/types/view-stack.spec.d.ts +1 -0
  66. package/lib/types/view-switcher.spec.d.ts +1 -0
  67. package/package.json +6 -6
  68. package/scss/_banner.scss +13 -0
  69. package/scss/_bottom_sheet.scss +5 -6
  70. package/scss/_button.scss +3 -26
  71. package/scss/_button_content.scss +28 -0
  72. package/scss/_checks.scss +228 -0
  73. package/scss/_clamp.scss +9 -3
  74. package/scss/_data_grid.scss +15 -7
  75. package/scss/_drop_down.scss +12 -55
  76. package/scss/_expander_row.scss +5 -50
  77. package/scss/_icon.scss +51 -0
  78. package/scss/_icons.generated.scss +6 -0
  79. package/scss/_inline_view_switcher.scss +34 -0
  80. package/scss/_labels.scss +135 -0
  81. package/scss/_menu_button.scss +10 -70
  82. package/scss/_popover.scss +186 -0
  83. package/scss/_preferences.scss +29 -0
  84. package/scss/_preferences_dialog.scss +17 -0
  85. package/scss/_progress_bar.scss +161 -0
  86. package/scss/_reset.scss +18 -7
  87. package/scss/_spinner.scss +7 -3
  88. package/scss/_split_button.scss +6 -50
  89. package/scss/_switch.scss +90 -0
  90. package/scss/_switch_row.scss +6 -52
  91. package/scss/_tab_view.scss +64 -0
  92. package/scss/_theme.scss +10 -0
  93. package/scss/_toolbar_view.scss +74 -0
  94. package/scss/_variables.scss +34 -0
  95. package/scss/_view_switcher.scss +40 -0
  96. package/scss/_view_switcher_bar.scss +37 -0
  97. package/scss/_wrap_box.scss +56 -6
  98. package/scss/adwaita-skin.scss +23 -0
  99. package/src/adw-about-dialog.spec.ts +350 -0
  100. package/src/adw-action-rows.spec.ts +342 -0
  101. package/src/adw-avatar.spec.ts +139 -0
  102. package/src/adw-banner.spec.ts +152 -0
  103. package/src/adw-button-content.spec.ts +183 -0
  104. package/src/adw-button-row.spec.ts +12 -6
  105. package/src/adw-carousel.spec.ts +389 -0
  106. package/src/adw-checks.spec.ts +368 -0
  107. package/src/adw-drop-down.spec.ts +123 -0
  108. package/src/adw-header-bar.spec.ts +83 -0
  109. package/src/adw-icon.spec.ts +167 -0
  110. package/src/adw-progress-bar.spec.ts +173 -0
  111. package/src/adw-sidebar.spec.ts +332 -0
  112. package/src/adw-switch.spec.ts +242 -0
  113. package/src/adw-tab-view.spec.ts +12 -3
  114. package/src/adw-view-switcher-bar.spec.ts +61 -13
  115. package/src/adw-wrap-box.spec.ts +194 -0
  116. package/src/bottom-sheet.spec.ts +325 -0
  117. package/src/breakpoints.spec.ts +147 -0
  118. package/src/breakpoints.ts +67 -0
  119. package/src/chrome.spec.ts +301 -0
  120. package/src/elements/adw-about-dialog.ts +223 -163
  121. package/src/elements/adw-action-row.ts +120 -10
  122. package/src/elements/adw-avatar.ts +48 -54
  123. package/src/elements/adw-banner.ts +46 -10
  124. package/src/elements/adw-bottom-sheet.ts +145 -60
  125. package/src/elements/adw-button-content.ts +107 -33
  126. package/src/elements/adw-button-row.ts +61 -37
  127. package/src/elements/adw-button.ts +5 -7
  128. package/src/elements/adw-carousel.ts +467 -105
  129. package/src/elements/adw-checks.ts +299 -0
  130. package/src/elements/adw-clamp.ts +80 -10
  131. package/src/elements/adw-combo-row.ts +15 -7
  132. package/src/elements/adw-data-grid.ts +43 -91
  133. package/src/elements/adw-drop-down.ts +117 -128
  134. package/src/elements/adw-entry-row.ts +404 -36
  135. package/src/elements/adw-expander-row.ts +61 -29
  136. package/src/elements/adw-header-bar.ts +31 -6
  137. package/src/elements/adw-icon.ts +148 -0
  138. package/src/elements/adw-inline-view-switcher.ts +240 -129
  139. package/src/elements/adw-menu-button.ts +93 -70
  140. package/src/elements/adw-navigation-split-view.ts +48 -7
  141. package/src/elements/adw-navigation-view.ts +286 -148
  142. package/src/elements/adw-overlay-split-view.ts +164 -14
  143. package/src/elements/adw-password-entry-row.ts +111 -89
  144. package/src/elements/adw-popover.ts +283 -0
  145. package/src/elements/adw-preferences-dialog.ts +120 -21
  146. package/src/elements/adw-preferences-group.ts +128 -22
  147. package/src/elements/adw-progress-bar.ts +189 -0
  148. package/src/elements/adw-sidebar.ts +301 -142
  149. package/src/elements/adw-spin-row.ts +11 -5
  150. package/src/elements/adw-spinner.ts +23 -8
  151. package/src/elements/adw-split-button.ts +371 -92
  152. package/src/elements/adw-status-page.ts +9 -9
  153. package/src/elements/adw-switch-row.ts +100 -30
  154. package/src/elements/adw-switch.ts +129 -0
  155. package/src/elements/adw-tab-view.ts +682 -129
  156. package/src/elements/adw-toggle-group.ts +5 -7
  157. package/src/elements/adw-toolbar-view.ts +84 -21
  158. package/src/elements/adw-view-stack.ts +162 -85
  159. package/src/elements/adw-view-switcher-bar.ts +163 -81
  160. package/src/elements/adw-view-switcher.ts +231 -112
  161. package/src/elements/adw-window-title.ts +44 -11
  162. package/src/elements/adw-wrap-box.ts +131 -35
  163. package/src/elements/view-switcher-dom.ts +108 -0
  164. package/src/entry-rows.spec.ts +391 -0
  165. package/src/index.ts +23 -0
  166. package/src/navigation-view.spec.ts +435 -0
  167. package/src/preferences-search.ts +135 -0
  168. package/src/preferences.spec.ts +250 -0
  169. package/src/source-view/adw-source-view.ts +2 -5
  170. package/src/split-button.spec.ts +470 -0
  171. package/src/split-views.spec.ts +309 -0
  172. package/src/styles.generated.ts +1 -1
  173. package/src/tab-view.spec.ts +451 -0
  174. package/src/test.browser.mts +49 -0
  175. package/src/view-stack.spec.ts +272 -0
  176. package/src/view-switcher.spec.ts +446 -0
@@ -47,6 +47,15 @@ adw-view-switcher {
47
47
  user-select: none;
48
48
  -webkit-user-select: none;
49
49
 
50
+ // A page with neither title nor icon has NO button at all
51
+ // (adw-view-switcher.c:178), spelled here as the `hidden` attribute.
52
+ // Re-assert it as an AUTHOR rule: the UA `[hidden] { display: none }`
53
+ // loses to this block's own author `display: inline-flex` (cascade ORIGIN
54
+ // beats specificity), so without this a hidden button stays on screen.
55
+ &[hidden] {
56
+ display: none;
57
+ }
58
+
50
59
  .adw-icon {
51
60
  width: 16px;
52
61
  height: 16px;
@@ -59,6 +68,37 @@ adw-view-switcher {
59
68
  text-overflow: ellipsis;
60
69
  }
61
70
 
71
+ // AdwIndicatorBin's badge — the unread count, or a bare dot when only
72
+ // `needs-attention` is set.
73
+ // Reference: refs/libadwaita/src/adw-indicator-bin.c
74
+ .adw-view-switcher-indicator {
75
+ display: inline-flex;
76
+ box-sizing: border-box;
77
+ align-items: center;
78
+ justify-content: center;
79
+ min-width: 16px;
80
+ height: 16px;
81
+ padding: 0 4px;
82
+ border-radius: 8px;
83
+ background-color: var(--accent-bg-color);
84
+ color: var(--accent-fg-color);
85
+ font-size: 0.625rem;
86
+ font-weight: 700;
87
+ line-height: 1;
88
+
89
+ &[hidden] {
90
+ display: none;
91
+ }
92
+
93
+ &.needs-attention {
94
+ min-width: 6px;
95
+ width: 6px;
96
+ height: 6px;
97
+ padding: 0;
98
+ border-radius: 3px;
99
+ }
100
+ }
101
+
62
102
  &:hover {
63
103
  background-color: var(--button-hover-color);
64
104
  }
@@ -64,6 +64,14 @@ adw-view-switcher-bar {
64
64
  user-select: none;
65
65
  -webkit-user-select: none;
66
66
 
67
+ // Same cascade trap as the bar itself: a page with neither title nor
68
+ // icon has no button (adw-view-switcher.c:178), and the UA
69
+ // `[hidden] { display: none }` loses to this block's author
70
+ // `display: inline-flex` unless it is re-asserted here.
71
+ &[hidden] {
72
+ display: none;
73
+ }
74
+
67
75
  .adw-view-switcher-bar-icon {
68
76
  width: 16px;
69
77
  height: 16px;
@@ -78,6 +86,35 @@ adw-view-switcher-bar {
78
86
  text-overflow: ellipsis;
79
87
  }
80
88
 
89
+ // AdwIndicatorBin's badge — see refs/libadwaita/src/adw-indicator-bin.c.
90
+ .adw-view-switcher-bar-indicator {
91
+ display: inline-flex;
92
+ box-sizing: border-box;
93
+ align-items: center;
94
+ justify-content: center;
95
+ min-width: 16px;
96
+ height: 16px;
97
+ padding: 0 4px;
98
+ border-radius: 8px;
99
+ background-color: var(--accent-bg-color);
100
+ color: var(--accent-fg-color);
101
+ font-size: 0.625rem;
102
+ font-weight: 700;
103
+ line-height: 1;
104
+
105
+ &[hidden] {
106
+ display: none;
107
+ }
108
+
109
+ &.needs-attention {
110
+ min-width: 6px;
111
+ width: 6px;
112
+ height: 6px;
113
+ padding: 0;
114
+ border-radius: 3px;
115
+ }
116
+ }
117
+
81
118
  &:hover {
82
119
  background-color: var(--button-hover-color);
83
120
  }
@@ -5,16 +5,66 @@
5
5
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
6
6
  // Modifications: Adapted for @gjsify/adwaita-web Web Components.
7
7
 
8
- // Layout (flex-direction, gaps, justify-content, align-items, align-content) is
9
- // driven imperatively by the component from its attributes — these are the
10
- // libadwaita defaults (horizontal, packed to the start of each line, lines
11
- // wrapping downwards from the top). A wrap box is a pure layout container, so it
12
- // carries no background of its own; children handle their own theming.
8
+ // Direction, gaps, `justify-content` and `align-content` are driven imperatively
9
+ // by the component from its attributes — these are the libadwaita defaults
10
+ // (horizontal, packed to the start of each line, lines wrapping downwards from
11
+ // the top). A wrap box is a pure layout container, so it carries no background
12
+ // of its own; children handle their own theming.
13
13
  adw-wrap-box {
14
14
  display: flex;
15
15
  flex-direction: row;
16
16
  flex-wrap: wrap;
17
17
  justify-content: flex-start;
18
- align-items: flex-start;
18
+ // Each child is allocated the FULL cross extent of its line — `h = line_size`
19
+ // in adw-wrap-layout.c:746-751 — which is `stretch`, the flexbox default. The
20
+ // port used to write `flex-start` here and drive it from `align`, which is a
21
+ // MAIN-axis property in libadwaita and belongs on `justify-content`.
22
+ align-items: stretch;
19
23
  align-content: flex-start;
20
24
  }
25
+
26
+ // FILL grows the children and keeps the spacing constant
27
+ // (adw-wrap-layout.c:317-319, :326-341); SPREAD deliberately does NOT, so the
28
+ // gaps take the leftover instead. `data-line-justify` is the mode the component
29
+ // resolved for a COMPLETE line.
30
+ //
31
+ // A lone child is excluded because it is not on a complete line: a box with one
32
+ // element child has exactly one line, and that line is the final one — see
33
+ // below.
34
+ adw-wrap-box[data-line-justify='fill'] > *:not(:only-child) {
35
+ flex-grow: 1;
36
+ }
37
+
38
+ // `:only-child` is the one line-membership fact CSS knows without a layout pass,
39
+ // and libadwaita treats that line as the LAST one. Both justified modes stretch
40
+ // it: FILL by definition, SPREAD because its "keep the children at their minimum
41
+ // size" branch is guarded by `n_children > 1` (adw-wrap-layout.c:338), so a lone
42
+ // child is stretched regardless (adw-wrap-box.c:349-352).
43
+ adw-wrap-box[data-last-line-justify='fill'] > :only-child,
44
+ adw-wrap-box[data-last-line-justify='spread'] > :only-child {
45
+ flex-grow: 1;
46
+ }
47
+
48
+ // The final line, when it is NOT justified (`justify-last-line` unset — the C
49
+ // default, adw-wrap-box.c:379-381): a generated flex item absorbs that line's
50
+ // leftover space, so it packs at the start of the main axis the way an
51
+ // un-justified line does, while `justify-content: space-between` keeps spreading
52
+ // the complete lines above it. `::after` is the only per-line hook flexbox
53
+ // offers, and it lands on the final line by construction.
54
+ //
55
+ // The negative main-axis margin cancels exactly the one gap the filler would
56
+ // otherwise add, so an exactly-full line can never be pushed into a new one.
57
+ adw-wrap-box[data-line-justify='spread'][data-last-line-justify='none']::after {
58
+ content: '';
59
+ flex: 1 0 0;
60
+ min-width: 0;
61
+ min-height: 0;
62
+ margin-left: calc(-1 * var(--adw-wrap-box-child-spacing, 0px));
63
+ }
64
+
65
+ // A vertical box packs along the block axis, so the gap to cancel is the one
66
+ // before the filler in that direction.
67
+ adw-wrap-box[orientation='vertical'][data-line-justify='spread'][data-last-line-justify='none']::after {
68
+ margin-left: 0;
69
+ margin-top: calc(-1 * var(--adw-wrap-box-child-spacing, 0px));
70
+ }
@@ -13,16 +13,39 @@
13
13
  // Style-isolation boundary — emitted before the components so their own rules
14
14
  // override this typographic/box-model floor (see _reset.scss).
15
15
  @use 'reset';
16
+ // The ONE symbolic-icon box, behind <adw-icon>. Emitted before every widget that
17
+ // draws one, so a context that sizes its own icons (the about dialog's 96px, the
18
+ // split button's 14px arrows, the view switcher's 16px) still wins on ordinary
19
+ // specificity.
20
+ @use 'icon';
21
+ // libadwaita's typography + semantic-colour UTILITY CLASSES (`.dimmed`,
22
+ // `.numeric`, `.monospace`, `.title-1`…). Classes only — there is deliberately
23
+ // no <adw-label>, see _labels.scss. Emitted BEFORE every widget so a widget's
24
+ // own rule wins on ordinary specificity, and because two partials @extend from
25
+ // it (`_progress_bar.scss`'s text node, `_data_grid.scss`'s cells) exactly as
26
+ // libadwaita's own `_progress-bar.scss` does.
27
+ @use 'labels';
16
28
  @use 'window';
17
29
  @use 'headerbar';
18
30
  @use 'window_title';
19
31
  @use 'button';
20
32
  @use 'button_content';
33
+ // The ONE popover surface. Emitted BEFORE the widgets that host one, so a
34
+ // consumer's (or a widget's) own higher-specificity rule still wins over it.
35
+ @use 'popover';
21
36
  @use 'split_button';
22
37
  @use 'menu_button';
23
38
  @use 'drop_down';
24
39
  @use 'toggle_group';
40
+ // The ONE toggle, behind <adw-switch>. Emitted before `switch_row` and
41
+ // `expander_row`, the two widgets that host one — and which each carried their
42
+ // own copy of it until it was lifted out of `adw-switch-row {}`.
43
+ @use 'switch';
25
44
  @use 'entry';
45
+ // The two form-control primitives, in ONE partial because upstream merges them
46
+ // (there is no `_radio.scss` in libadwaita) — see _checks.scss.
47
+ @use 'checks';
48
+ @use 'progress_bar';
26
49
  @use 'preferences';
27
50
  @use 'card';
28
51
  @use 'data_grid';
@@ -0,0 +1,350 @@
1
+ // DOM-level conformance tests for <adw-about-dialog>, driven by the vectors in
2
+ // `@gjsify/adwaita-core/conformance` — the same table the core suite asserts.
3
+ //
4
+ // This element carried its own copy of `parse_person`, its own page-visibility
5
+ // rules and its own set of invented fallbacks, and diverged from libadwaita on
6
+ // thirteen counts. Two of them were not subtle: Support Questions and Report an
7
+ // Issue were rendered on the DETAILS page, one navigation step deeper than GTK
8
+ // puts them (adw-about-dialog.ui:146-186), and `website` was treated as Details
9
+ // content, which is exactly the one input libadwaita leaves OUT of that
10
+ // predicate (:1108). Nothing compared the two, so nothing failed.
11
+ import { describe, expect, it } from '@gjsify/unit';
12
+
13
+ import { ADW_ABOUT_DIALOG_LABELS, stripMnemonic } from '@gjsify/adwaita-core';
14
+ import {
15
+ ABOUT_DIALOG_CREDITS_LEGAL_VECTORS,
16
+ ABOUT_DIALOG_DETAILS_VECTORS,
17
+ ABOUT_DIALOG_HEADER_VECTORS,
18
+ ABOUT_DIALOG_SUPPORT_VECTORS,
19
+ CREDITS_SECTIONS_VECTORS,
20
+ CREDIT_PERSON_VECTORS,
21
+ } from '@gjsify/adwaita-core/conformance';
22
+
23
+ /** The label a renderer without an accelerator layer paints for a row. */
24
+ const label = (key: keyof typeof ADW_ABOUT_DIALOG_LABELS) => stripMnemonic(ADW_ABOUT_DIALOG_LABELS[key]);
25
+
26
+ interface Mounted {
27
+ dialog: HTMLElement & { developers: string[]; designers: string[]; artists: string[]; documenters: string[] };
28
+ host: HTMLElement;
29
+ /** Every `window.open` the dialog performed, most recent last. */
30
+ opened: string[];
31
+ /** Restore the stubbed `window.open`. */
32
+ dispose: () => void;
33
+ }
34
+
35
+ /**
36
+ * Mount a dialog and set every attribute imperatively — several vectors hinge on
37
+ * exact whitespace, which a value parsed out of markup is not a reliable
38
+ * carrier for. `window.open` is stubbed because the link rows really do call it.
39
+ */
40
+ function mount(attributes: Record<string, string> = {}): Mounted {
41
+ const host = document.createElement('div');
42
+ document.body.appendChild(host);
43
+ const dialog = document.createElement('adw-about-dialog') as Mounted['dialog'];
44
+ host.appendChild(dialog);
45
+ for (const [name, value] of Object.entries(attributes)) dialog.setAttribute(name, value);
46
+
47
+ const opened: string[] = [];
48
+ const original = window.open;
49
+ (window as unknown as { open: unknown }).open = (url?: string | URL) => {
50
+ opened.push(String(url ?? ''));
51
+ return null;
52
+ };
53
+
54
+ return {
55
+ dialog,
56
+ host,
57
+ opened,
58
+ dispose: () => {
59
+ (window as unknown as { open: unknown }).open = original;
60
+ host.remove();
61
+ },
62
+ };
63
+ }
64
+
65
+ /** The navigation page with `tag`, or `null` when the dialog did not build it. */
66
+ function page(dialog: HTMLElement, tag: string): HTMLElement | null {
67
+ return dialog.querySelector(`adw-navigation-page[tag="${tag}"]`);
68
+ }
69
+
70
+ /** The preferences group the template calls `id`, on whichever page it lives. */
71
+ function group(dialog: HTMLElement, id: string): HTMLElement | null {
72
+ return dialog.querySelector(`adw-preferences-group[data-group="${id}"]`);
73
+ }
74
+
75
+ /** Whether a row with `title` exists inside `scope`. */
76
+ function hasRow(scope: Element | null, title: string): boolean {
77
+ if (!scope) return false;
78
+ return scope.querySelector(`adw-action-row[title="${title}"]`) !== null;
79
+ }
80
+
81
+ /** The row titles inside `scope`, in document order. */
82
+ function rowTitles(scope: Element | null): string[] {
83
+ if (!scope) return [];
84
+ return [...scope.querySelectorAll('adw-action-row')].map((row) => row.getAttribute('title') ?? '');
85
+ }
86
+
87
+ /**
88
+ * Activate a row and report the URI it asked for.
89
+ *
90
+ * The URI never reaches the DOM — it is captured in the row's activation
91
+ * handler — so the only way to observe it is to drive the row and listen.
92
+ */
93
+ function activateRow(dialog: HTMLElement, row: Element): string | null {
94
+ let uri: string | null = null;
95
+ const listener = (event: Event) => {
96
+ uri = (event as CustomEvent<{ uri: string }>).detail.uri;
97
+ };
98
+ dialog.addEventListener('activate-link', listener);
99
+ row.dispatchEvent(new CustomEvent('activated', { bubbles: true }));
100
+ dialog.removeEventListener('activate-link', listener);
101
+ return uri;
102
+ }
103
+
104
+ export const AdwAboutDialogTest = async () => {
105
+ await describe('adw-about-dialog credit parsing (parse_person vectors)', async () => {
106
+ for (const { input, name, uri, rule } of CREDIT_PERSON_VECTORS) {
107
+ await it(`${JSON.stringify(input)} — ${rule}`, () => {
108
+ const mounted = mount({ 'application-name': 'Builder' });
109
+ mounted.dialog.developers = [input];
110
+
111
+ const credits = page(mounted.dialog, 'credits');
112
+ const rows = credits ? [...credits.querySelectorAll('adw-action-row')] : [];
113
+ expect(rows).toHaveLength(1);
114
+
115
+ const row = rows[0]!;
116
+ expect(row.getAttribute('title')).toBe(name);
117
+ expect(activateRow(mounted.dialog, row)).toBe(uri);
118
+
119
+ mounted.dispose();
120
+ });
121
+ }
122
+
123
+ await it('makes a row with an empty link a LINK row, not a plain one', () => {
124
+ // `if (link)` is a pointer test in the C, so `"Ada <>"` still links,
125
+ // to the URI `mailto:` — collapsing "" to null loses the row type.
126
+ const mounted = mount();
127
+ mounted.dialog.developers = ['Ada <>'];
128
+ const row = page(mounted.dialog, 'credits')!.querySelector('adw-action-row')!;
129
+ expect(row.hasAttribute('activatable')).toBe(true);
130
+ expect(activateRow(mounted.dialog, row)).toBe('mailto:');
131
+ mounted.dispose();
132
+ });
133
+ });
134
+
135
+ await describe('adw-about-dialog credits page (update_credits vectors)', async () => {
136
+ for (const { input, sections, rule } of CREDITS_SECTIONS_VECTORS) {
137
+ // `add_credit_section` has no counterpart on this element yet, so the
138
+ // rows exercising it are asserted by the core suite alone.
139
+ if (input.creditSections) continue;
140
+ // Same for `translator-credits`: no property, so no "Translated by".
141
+ if (input.translatorCredits !== undefined) continue;
142
+
143
+ await it(rule, () => {
144
+ const mounted = mount();
145
+ mounted.dialog.developers = [...(input.developers ?? [])];
146
+ mounted.dialog.designers = [...(input.designers ?? [])];
147
+ mounted.dialog.artists = [...(input.artists ?? [])];
148
+ mounted.dialog.documenters = [...(input.documenters ?? [])];
149
+
150
+ const credits = page(mounted.dialog, 'credits');
151
+ const groups = credits ? [...credits.querySelectorAll('adw-preferences-group')] : [];
152
+ expect(groups.map((node) => node.getAttribute('title'))).toStrictEqual(
153
+ sections.map((section) => section.title),
154
+ );
155
+ for (const [index, section] of sections.entries()) {
156
+ expect(rowTitles(groups[index]!)).toStrictEqual(section.people.map((person) => person.name));
157
+ }
158
+
159
+ mounted.dispose();
160
+ });
161
+ }
162
+ });
163
+
164
+ await describe('adw-about-dialog main page (update_details vectors)', async () => {
165
+ // `add_link` has no counterpart on this element, so `has_custom_links`
166
+ // is always FALSE here; those rows are asserted by the core suite.
167
+ for (const vector of ABOUT_DIALOG_DETAILS_VECTORS.filter((row) => !row.customLinks && !row.releaseNotes)) {
168
+ const { website, comments, visible, rule } = vector;
169
+ await it(`website=${website} comments=${comments} — ${rule}`, () => {
170
+ const mounted = mount({
171
+ 'application-name': 'Builder',
172
+ ...(website ? { website: 'https://example.org' } : {}),
173
+ ...(comments ? { comments: 'A description.' } : {}),
174
+ });
175
+
176
+ const main = page(mounted.dialog, 'main');
177
+ const details = page(mounted.dialog, 'details');
178
+
179
+ expect({
180
+ detailsGroup: group(mounted.dialog, 'details') !== null,
181
+ detailsRow: hasRow(main, label('detailsRow')),
182
+ websiteRow: hasRow(main, label('websiteRow')),
183
+ commentsLabel: details?.querySelector('.adw-about-dialog-comments') != null,
184
+ linksGroup: group(mounted.dialog, 'links') !== null,
185
+ detailsWebsiteRow: hasRow(details, label('websiteRow')),
186
+ }).toStrictEqual({
187
+ detailsGroup: visible.detailsGroup,
188
+ detailsRow: visible.detailsRow,
189
+ websiteRow: visible.websiteRow,
190
+ commentsLabel: visible.commentsLabel,
191
+ linksGroup: visible.linksGroup,
192
+ detailsWebsiteRow: visible.detailsWebsiteRow,
193
+ });
194
+
195
+ mounted.dispose();
196
+ });
197
+ }
198
+
199
+ await it('keeps a website-only dialog on the main page with no Details row', () => {
200
+ // `show_details` is `has_comments || has_custom_links` (:1108) —
201
+ // website is deliberately not in it, so a dialog whose only extra is
202
+ // a website links straight from the main page.
203
+ const mounted = mount({ 'application-name': 'Builder', website: 'https://example.org' });
204
+ expect(page(mounted.dialog, 'details')).toBe(null);
205
+ expect(hasRow(page(mounted.dialog, 'main'), label('websiteRow'))).toBe(true);
206
+ mounted.dispose();
207
+ });
208
+ });
209
+
210
+ await describe('adw-about-dialog main page (update_support vectors)', async () => {
211
+ // debug-info has no counterpart on this element (there is no
212
+ // Troubleshooting page), so those rows are asserted by the core suite.
213
+ for (const vector of ABOUT_DIALOG_SUPPORT_VECTORS.filter((row) => !row.debugInfo)) {
214
+ const { supportUrl, issueUrl, visible, rule } = vector;
215
+ await it(`support=${supportUrl} issue=${issueUrl} — ${rule}`, () => {
216
+ const mounted = mount({
217
+ 'application-name': 'Builder',
218
+ ...(supportUrl ? { 'support-url': 'https://example.org/help' } : {}),
219
+ ...(issueUrl ? { 'issue-url': 'https://example.org/issues' } : {}),
220
+ });
221
+
222
+ const main = page(mounted.dialog, 'main');
223
+ expect({
224
+ supportGroup: group(mounted.dialog, 'support') !== null,
225
+ supportRow: hasRow(main, label('supportRow')),
226
+ issueRow: hasRow(main, label('issueRow')),
227
+ }).toStrictEqual({
228
+ supportGroup: visible.supportGroup,
229
+ supportRow: visible.supportRow,
230
+ issueRow: visible.issueRow,
231
+ });
232
+
233
+ mounted.dispose();
234
+ });
235
+ }
236
+
237
+ await it('puts the support and issue rows on the MAIN page, not on Details', () => {
238
+ // adw-about-dialog.ui:146-186 — `support_group` is a child of the
239
+ // main page, and the class docs say so outright (:87-90).
240
+ const mounted = mount({
241
+ 'application-name': 'Builder',
242
+ 'support-url': 'https://example.org/help',
243
+ 'issue-url': 'https://example.org/issues',
244
+ });
245
+ expect(rowTitles(page(mounted.dialog, 'main'))).toContain(label('supportRow'));
246
+ expect(rowTitles(page(mounted.dialog, 'main'))).toContain(label('issueRow'));
247
+ // With neither comments nor custom links there is no Details page.
248
+ expect(page(mounted.dialog, 'details')).toBe(null);
249
+ mounted.dispose();
250
+ });
251
+ });
252
+
253
+ await describe('adw-about-dialog main page (credits/legal vectors)', async () => {
254
+ // No acknowledgements API on this element; the core suite covers it.
255
+ for (const vector of ABOUT_DIALOG_CREDITS_LEGAL_VECTORS.filter((row) => !row.hasAcknowledgements)) {
256
+ const { hasCredits, hasLegal, visible, rule } = vector;
257
+ await it(`credits=${hasCredits} legal=${hasLegal} — ${rule}`, () => {
258
+ const mounted = mount({
259
+ 'application-name': 'Builder',
260
+ ...(hasLegal ? { copyright: '© 2026 Ada' } : {}),
261
+ });
262
+ if (hasCredits) mounted.dialog.developers = ['Ada'];
263
+
264
+ const main = page(mounted.dialog, 'main');
265
+ expect({
266
+ creditsLegalGroup: group(mounted.dialog, 'credits-legal') !== null,
267
+ creditsRow: hasRow(main, label('creditsRow')),
268
+ legalRow: hasRow(main, label('legalRow')),
269
+ }).toStrictEqual({
270
+ creditsLegalGroup: visible.creditsLegalGroup,
271
+ creditsRow: visible.creditsRow,
272
+ legalRow: visible.legalRow,
273
+ });
274
+
275
+ mounted.dispose();
276
+ });
277
+ }
278
+ });
279
+
280
+ await describe('adw-about-dialog header fields (no invented fallbacks)', async () => {
281
+ for (const { property, value, visible, rule } of ABOUT_DIALOG_HEADER_VECTORS) {
282
+ if (property === 'application-icon' || property === 'application-name') {
283
+ await it(`${property}=${JSON.stringify(value)} — ${rule}`, () => {
284
+ const mounted = mount(value.length > 0 ? { [property]: value } : {});
285
+ const main = page(mounted.dialog, 'main')!;
286
+ const node =
287
+ property === 'application-icon'
288
+ ? main.querySelector('.adw-about-dialog-icon')
289
+ : main.querySelector('.adw-about-dialog-name');
290
+ expect(node !== null).toBe(visible);
291
+ mounted.dispose();
292
+ });
293
+ continue;
294
+ }
295
+
296
+ await it(`${property}=${JSON.stringify(value)} — ${rule}`, () => {
297
+ const mounted = mount(value.length > 0 ? { [property]: value } : {});
298
+ const main = page(mounted.dialog, 'main')!;
299
+ const selector =
300
+ property === 'developer-name' ? '.adw-about-dialog-developer' : '.adw-about-dialog-version';
301
+ expect(main.querySelector(selector) !== null).toBe(visible);
302
+ mounted.dispose();
303
+ });
304
+ }
305
+
306
+ await it('titles the dialog "About", not "About <app name>"', () => {
307
+ // The main page's title is bound to AdwDialog:title, whose template
308
+ // default is the bare word (.ui:6, :19); the application name lives
309
+ // in the header revealer instead (.ui:29-31).
310
+ const mounted = mount({ 'application-name': 'Builder' });
311
+ expect(page(mounted.dialog, 'main')!.getAttribute('title')).toBe('About');
312
+ mounted.dispose();
313
+ });
314
+ });
315
+
316
+ await describe('adw-about-dialog activate-link is cancellable', async () => {
317
+ await it('opens the URI when nothing handled the signal', () => {
318
+ const mounted = mount({ 'application-name': 'Builder', website: 'https://example.org' });
319
+ const row = page(mounted.dialog, 'main')!.querySelector(`adw-action-row[title="${label('websiteRow')}"]`)!;
320
+ row.dispatchEvent(new CustomEvent('activated', { bubbles: true }));
321
+ expect(mounted.opened).toStrictEqual(['https://example.org']);
322
+ mounted.dispose();
323
+ });
324
+
325
+ await it('does NOT open when a handler cancels it', () => {
326
+ // `activate_link_cb` returns the accumulated handler result
327
+ // (:429-437, accumulator `g_signal_accumulator_true_handled`
328
+ // :2098): a handler saying "handled" suppresses the default
329
+ // navigation. `preventDefault()` is the web spelling of that, and it
330
+ // was unavailable — the event was not cancelable and `window.open`
331
+ // ran regardless.
332
+ const mounted = mount({ 'application-name': 'Builder', website: 'https://example.org' });
333
+ mounted.dialog.addEventListener('activate-link', (event) => event.preventDefault());
334
+ const row = page(mounted.dialog, 'main')!.querySelector(`adw-action-row[title="${label('websiteRow')}"]`)!;
335
+ row.dispatchEvent(new CustomEvent('activated', { bubbles: true }));
336
+ expect(mounted.opened).toStrictEqual([]);
337
+ mounted.dispose();
338
+ });
339
+
340
+ await it('is cancelable on a credit row too', () => {
341
+ const mounted = mount();
342
+ mounted.dialog.developers = ['Ada <ada@lovelace.org>'];
343
+ mounted.dialog.addEventListener('activate-link', (event) => event.preventDefault());
344
+ const row = page(mounted.dialog, 'credits')!.querySelector('adw-action-row')!;
345
+ row.dispatchEvent(new CustomEvent('activated', { bubbles: true }));
346
+ expect(mounted.opened).toStrictEqual([]);
347
+ mounted.dispose();
348
+ });
349
+ });
350
+ };