@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
@@ -0,0 +1,183 @@
1
+ // DOM-level conformance tests for <adw-button-content>, driven by the SAME
2
+ // vectors the NativeScript renderer asserts against
3
+ // (`@gjsify/adwaita-core/conformance`).
4
+ //
5
+ // THE STYLE CLASS IS WHY THIS SUITE EXISTS. `AdwButtonContent` puts
6
+ // `image-text-button` on the button hosting it (adw-button-content.c:115) and
7
+ // removes it on unroot (:126); the stylesheet gives that class
8
+ // `padding-left/right: 9px` where a plain text button has 17px
9
+ // (_buttons.scss:77-80 against :72-75). `grep -rn "image-text-button"` over both
10
+ // renderer trees returned NOTHING, so every icon+label button in this package
11
+ // was drawn with the wrong horizontal padding for its whole life. `use-underline`
12
+ // was absent, and an empty `icon-name` hid the image where the C sets
13
+ // `image-missing` (:355-356).
14
+ import { describe, expect, it } from '@gjsify/unit';
15
+
16
+ import { BUTTON_CONTENT_STYLE_CLASS, normalizeIconName } from '@gjsify/adwaita-core';
17
+ import {
18
+ BUTTON_CONTENT_ELLIPSIZE_VECTORS,
19
+ BUTTON_CONTENT_ICON_VECTORS,
20
+ BUTTON_CONTENT_LABEL_VECTORS,
21
+ BUTTON_CONTENT_TEXT_VECTORS,
22
+ } from '@gjsify/adwaita-core/conformance';
23
+ import type { AdwButtonContent } from './elements/adw-button-content.js';
24
+
25
+ /** Mount a button content inside a plain Adwaita button, the way the stories do. */
26
+ function mount(attributes: Record<string, string> = {}): {
27
+ el: AdwButtonContent;
28
+ button: HTMLButtonElement;
29
+ host: HTMLElement;
30
+ } {
31
+ const host = document.createElement('div');
32
+ document.body.appendChild(host);
33
+ const button = document.createElement('button');
34
+ button.className = 'adw-button';
35
+ const el = document.createElement('adw-button-content') as AdwButtonContent;
36
+ for (const [name, value] of Object.entries(attributes)) el.setAttribute(name, value);
37
+ button.appendChild(el);
38
+ host.appendChild(button);
39
+ return { el, button, host };
40
+ }
41
+
42
+ const iconEl = (el: AdwButtonContent): HTMLElement => el.querySelector('[aria-hidden="true"]') as HTMLElement;
43
+ const labelEl = (el: AdwButtonContent): HTMLElement => el.querySelector('.adw-button-content-label') as HTMLElement;
44
+
45
+ export const AdwButtonContentTest = async () => {
46
+ await describe('adw-button-content parent style class (adw_button_content_root, :115)', async () => {
47
+ await it('stamps image-text-button on the button hosting it', () => {
48
+ const { button, host } = mount({ 'icon-name': 'folder-download', label: 'Download' });
49
+ expect(button.classList.contains(BUTTON_CONTENT_STYLE_CLASS)).toBe(true);
50
+ host.remove();
51
+ });
52
+
53
+ await it('gives that button the 9px padding the class carries (_buttons.scss:77-80)', () => {
54
+ const { button, host } = mount({ 'icon-name': 'folder-download', label: 'Download' });
55
+ const style = getComputedStyle(button);
56
+ expect(style.paddingLeft).toBe('9px');
57
+ expect(style.paddingRight).toBe('9px');
58
+ host.remove();
59
+ });
60
+
61
+ await it('a plain Adwaita button without a content keeps its own padding', () => {
62
+ const host = document.createElement('div');
63
+ document.body.appendChild(host);
64
+ const button = document.createElement('button');
65
+ button.className = 'adw-button';
66
+ button.textContent = 'Download';
67
+ host.appendChild(button);
68
+ expect(button.classList.contains(BUTTON_CONTENT_STYLE_CLASS)).toBe(false);
69
+ expect(getComputedStyle(button).paddingLeft === '9px').toBe(false);
70
+ host.remove();
71
+ });
72
+
73
+ await it('takes the class off again when the content leaves (:126)', () => {
74
+ const { el, button, host } = mount({ 'icon-name': 'folder-download', label: 'Download' });
75
+ el.remove();
76
+ expect(button.classList.contains(BUTTON_CONTENT_STYLE_CLASS)).toBe(false);
77
+ host.remove();
78
+ });
79
+
80
+ await it('retargets to the split button when the button sits inside one (:112-113)', () => {
81
+ const host = document.createElement('div');
82
+ document.body.appendChild(host);
83
+ const split = document.createElement('div');
84
+ split.className = 'adw-split-button';
85
+ const action = document.createElement('button');
86
+ action.className = 'adw-split-button-action';
87
+ const el = document.createElement('adw-button-content') as AdwButtonContent;
88
+ el.setAttribute('label', 'Save');
89
+ action.appendChild(el);
90
+ split.appendChild(action);
91
+ host.appendChild(split);
92
+
93
+ // The class goes on the `splitbutton` node, which is what
94
+ // `splitbutton.image-text-button > button` (:499-507) selects —
95
+ // a different declaration block from the plain-button one.
96
+ expect(split.classList.contains(BUTTON_CONTENT_STYLE_CLASS)).toBe(true);
97
+ expect(action.classList.contains(BUTTON_CONTENT_STYLE_CLASS)).toBe(false);
98
+ // …and the padding lands on the INNER button, which is what that
99
+ // block declares — not on the node carrying the class.
100
+ expect(getComputedStyle(action).paddingLeft).toBe('9px');
101
+ host.remove();
102
+ });
103
+
104
+ await it('styles nothing when there is no button ancestor', () => {
105
+ const host = document.createElement('div');
106
+ document.body.appendChild(host);
107
+ const el = document.createElement('adw-button-content') as AdwButtonContent;
108
+ el.setAttribute('label', 'Save');
109
+ host.appendChild(el);
110
+ expect(host.classList.contains(BUTTON_CONTENT_STYLE_CLASS)).toBe(false);
111
+ host.remove();
112
+ });
113
+ });
114
+
115
+ await describe('adw-button-content icon slot (libadwaita conformance vectors)', async () => {
116
+ for (const { iconName, resolved, rule } of BUTTON_CONTENT_ICON_VECTORS) {
117
+ await it(`${JSON.stringify(iconName)} → ${JSON.stringify(resolved)} — ${rule}`, () => {
118
+ const { el, host } = mount({ 'icon-name': iconName, label: 'Download' });
119
+ const icon = iconEl(el);
120
+ // The core resolution (`resolved`) is the NAME; the mask class is
121
+ // this package's convention on top of it, and the two differ —
122
+ // the generated classes never carry `-symbolic`. This element used
123
+ // to interpolate the raw name, so `icon-name="folder-download-symbolic"`
124
+ // asked for `.adw-icon--folder-download-symbolic`, a class that has
125
+ // never existed, and drew an empty 16px box. `<adw-icon>` applies
126
+ // `normalizeIconName`, which also makes the `' '` row assertable
127
+ // instead of skipped: a space is not one CSS token, so it draws
128
+ // nothing rather than injecting a second class.
129
+ const maskName = normalizeIconName(resolved);
130
+ expect(icon.classList.contains(`adw-icon--${maskName}`)).toBe(maskName !== '');
131
+ expect([...icon.classList].filter((c) => c.startsWith('adw-icon--')).length).toBe(
132
+ maskName === '' ? 0 : 1,
133
+ );
134
+ // The image node is never hidden — `gtk_widget_set_visible` is
135
+ // called on the LABEL only (:300, :398).
136
+ expect(icon.hidden).toBe(false);
137
+ host.remove();
138
+ });
139
+ }
140
+
141
+ await it('an absent icon-name attribute is the empty case, so the image still shows', () => {
142
+ const { el, host } = mount({ label: 'Download' });
143
+ expect(iconEl(el).hidden).toBe(false);
144
+ expect(iconEl(el).classList.contains('adw-icon--image-missing')).toBe(true);
145
+ host.remove();
146
+ });
147
+ });
148
+
149
+ await describe('adw-button-content label slot (libadwaita conformance vectors)', async () => {
150
+ for (const { label, visible, rule } of BUTTON_CONTENT_LABEL_VECTORS) {
151
+ await it(`${JSON.stringify(label)} → visible ${visible} — ${rule}`, () => {
152
+ const { el, host } = mount({ 'icon-name': 'folder-download', label });
153
+ expect(labelEl(el).hidden).toBe(!visible);
154
+ host.remove();
155
+ });
156
+ }
157
+ });
158
+
159
+ await describe('adw-button-content use-underline (libadwaita conformance vectors)', async () => {
160
+ for (const { label, useUnderline, text, rule } of BUTTON_CONTENT_TEXT_VECTORS) {
161
+ await it(`${JSON.stringify(label)} + ${useUnderline} → ${JSON.stringify(text)} — ${rule}`, () => {
162
+ const attributes: Record<string, string> = { 'icon-name': 'folder-download', label };
163
+ if (useUnderline) attributes['use-underline'] = '';
164
+ const { el, host } = mount(attributes);
165
+ expect(labelEl(el).textContent).toBe(text);
166
+ host.remove();
167
+ });
168
+ }
169
+ });
170
+
171
+ await describe('adw-button-content can-shrink (libadwaita conformance vectors)', async () => {
172
+ for (const { canShrink, ellipsize, rule } of BUTTON_CONTENT_ELLIPSIZE_VECTORS) {
173
+ await it(`${canShrink} → ${ellipsize} — ${rule}`, () => {
174
+ const attributes: Record<string, string> = { 'icon-name': 'folder-download', label: 'Download' };
175
+ if (canShrink) attributes['can-shrink'] = '';
176
+ const { el, host } = mount(attributes);
177
+ expect(el.classList.contains('can-shrink')).toBe(canShrink);
178
+ expect(getComputedStyle(labelEl(el)).textOverflow).toBe(canShrink ? 'ellipsis' : 'clip');
179
+ host.remove();
180
+ });
181
+ }
182
+ });
183
+ };
@@ -43,13 +43,19 @@ export const AdwButtonRowTest = async () => {
43
43
  host.remove();
44
44
  });
45
45
 
46
- await it('honours activatable="false"', async () => {
47
- const { row, host } = parseRow('<adw-button-row title="Locked" activatable="false"></adw-button-row>');
48
- expect(row.classList.contains('activatable')).toBe(false);
49
- host.remove();
50
- });
46
+ // DELETED: `honours activatable="false"`. It was green, and what it
47
+ // pinned was an INVENTION — `Adw.ButtonRow` is always activatable
48
+ // (`<property name="activatable">True</property>`, adw-button-row.ui:5;
49
+ // "AdwButtonRow is always activatable.", adw-button-row.c:31), and the
50
+ // class exposes no property, no setter and no getter for it. The opt-out
51
+ // also gave one markup two opposite meanings inside one package, because
52
+ // <adw-action-row> reads `activatable` by PRESENCE — so
53
+ // `activatable="false"` deactivated a button row and ACTIVATED an action
54
+ // row. Its replacement is BUTTON_ROW_ACTIVATABLE_VECTORS, driven from
55
+ // adw-action-rows.spec.ts, which asserts that every spelling of the
56
+ // attribute leaves the row activatable.
51
57
 
52
- await it('emits `activated` on click when activatable', async () => {
58
+ await it('emits `activated` on click', async () => {
53
59
  const { row, host } = parseRow('<adw-button-row title="Add account"></adw-button-row>');
54
60
  let activated = false;
55
61
  row.addEventListener('activated', () => {
@@ -0,0 +1,389 @@
1
+ // DOM-level conformance tests for <adw-carousel>, driven by the SAME vectors the
2
+ // NativeScript renderer asserts against (`@gjsify/adwaita-core/conformance`).
3
+ //
4
+ // The two renderers used to carry independent, differently-wrong copies of the
5
+ // carousel's arithmetic, and nothing compared them: this element defaulted
6
+ // `allow-scroll-wheel` to FALSE where libadwaita defaults it TRUE (so a plain
7
+ // <adw-carousel> ignored the wheel while its own header claimed the opposite),
8
+ // resolved a half-way position to the LATER page where every libadwaita lookup
9
+ // takes the earlier one, dropped `<adw-carousel position="2">` on the floor,
10
+ // dropped the horizontal-delta fallback its comment described, and emitted
11
+ // `page-changed` only when the index changed — which made the documented -1
12
+ // unreachable. This suite is that comparison.
13
+ //
14
+ // Scrolls are made instant by overriding the track's `scroll-behavior`; the
15
+ // element sets it as an inline style precisely so it stays overridable.
16
+ import { describe, expect, it } from '@gjsify/unit';
17
+
18
+ import { CAROUSEL_PAGE_AT_POSITION_VECTORS, CAROUSEL_PAGE_LIST_VECTORS } from '@gjsify/adwaita-core/conformance';
19
+ import type { CarouselPageOp } from '@gjsify/adwaita-core/conformance';
20
+
21
+ /** The carousel surface this suite drives — the element's own public API. */
22
+ interface CarouselElement extends HTMLElement {
23
+ readonly nPages: number;
24
+ readonly position: number;
25
+ readonly currentPage: number;
26
+ allowScrollWheel: boolean;
27
+ allowLongSwipes: boolean;
28
+ interactive: boolean;
29
+ scrollToPage(index: number): void;
30
+ insertPage(page: HTMLElement, position?: number): boolean;
31
+ appendPage(page: HTMLElement): boolean;
32
+ removePage(page: HTMLElement): boolean;
33
+ reorderPage(page: HTMLElement, position: number): boolean;
34
+ navigate(direction: 'back' | 'forward'): boolean;
35
+ }
36
+
37
+ /** One page-sized card, tagged so the DOM order can be read back. */
38
+ function card(label: string): HTMLElement {
39
+ const element = document.createElement('div');
40
+ element.dataset.label = label;
41
+ element.textContent = label;
42
+ element.style.cssText = 'width:100%;height:100%;';
43
+ return element;
44
+ }
45
+
46
+ /**
47
+ * Mount a carousel with the given page labels.
48
+ *
49
+ * The host gets an explicit size because the page pitch is MEASURED — a carousel
50
+ * with no width has no distance, and every scroll would be a no-op.
51
+ */
52
+ function mount(labels: readonly string[], attributes: Record<string, string> = {}) {
53
+ const host = document.createElement('div');
54
+ host.style.cssText = 'width:200px;height:80px;';
55
+ document.body.appendChild(host);
56
+
57
+ const carousel = document.createElement('adw-carousel') as CarouselElement;
58
+ carousel.style.cssText = 'display:block;width:200px;height:80px;';
59
+ for (const [name, value] of Object.entries(attributes)) carousel.setAttribute(name, value);
60
+ for (const label of labels) carousel.appendChild(card(label));
61
+ host.appendChild(carousel);
62
+
63
+ const track = carousel.querySelector('.adw-carousel-track') as HTMLElement;
64
+ // Instant scrolls: the assertions are about WHERE it lands, never about the
65
+ // animation getting there. Snapping is off for the same reason — `mandatory`
66
+ // re-snaps a programmatic `scrollLeft` to the nearest page, which is exactly
67
+ // the fractional position several of these tests are about.
68
+ track.style.setProperty('scroll-behavior', 'auto');
69
+ track.style.setProperty('scroll-snap-type', 'none');
70
+ return { carousel, host, track };
71
+ }
72
+
73
+ /** The page labels in DOM order — what the user actually sees, not what the model says. */
74
+ function domOrder(carousel: HTMLElement): string[] {
75
+ return Array.from(carousel.querySelectorAll('.adw-carousel-page')).map(
76
+ (slot) => (slot.firstElementChild as HTMLElement | null)?.dataset.label ?? '',
77
+ );
78
+ }
79
+
80
+ /** Let the scroll event reach the rAF-throttled handler. */
81
+ function flush(): Promise<void> {
82
+ return new Promise((resolve) => {
83
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
84
+ });
85
+ }
86
+
87
+ /**
88
+ * The px pitch between two page origins, MEASURED off the DOM — the same
89
+ * quantity `self->distance` is, read back rather than recomputed.
90
+ */
91
+ function pitch(carousel: HTMLElement): number {
92
+ const track = carousel.querySelector('.adw-carousel-track') as HTMLElement;
93
+ const slot = track.firstElementChild as HTMLElement | null;
94
+ if (!slot) return 0;
95
+ const gap = Number.parseFloat(getComputedStyle(track).columnGap);
96
+ return slot.getBoundingClientRect().width + (Number.isFinite(gap) ? gap : 0);
97
+ }
98
+
99
+ /** Scroll the track by hand, as a user drag does, and let the element observe it. */
100
+ async function dragTo(carousel: HTMLElement, position: number): Promise<void> {
101
+ const track = carousel.querySelector('.adw-carousel-track') as HTMLElement;
102
+ track.scrollLeft = position * pitch(carousel);
103
+ await flush();
104
+ }
105
+
106
+ /** Dispatch one wheel notch and report whether the carousel consumed it. */
107
+ function wheel(carousel: HTMLElement, init: WheelEventInit): boolean {
108
+ const track = carousel.querySelector('.adw-carousel-track') as HTMLElement;
109
+ const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, ...init });
110
+ track.dispatchEvent(event);
111
+ return event.defaultPrevented;
112
+ }
113
+
114
+ /** Apply one page-list vector op through the element's public methods. */
115
+ function applyPageOp(carousel: CarouselElement, op: CarouselPageOp, pages: Map<string, HTMLElement>): boolean {
116
+ switch (op.kind) {
117
+ case 'insert': {
118
+ const page = card(op.id);
119
+ pages.set(op.id, page);
120
+ return carousel.insertPage(page, op.position);
121
+ }
122
+ case 'remove': {
123
+ const page = pages.get(op.id);
124
+ // A page the carousel never had must be refused, not thrown at.
125
+ return page ? carousel.removePage(page) : carousel.removePage(card(op.id));
126
+ }
127
+ case 'reorder': {
128
+ const page = pages.get(op.id);
129
+ return page ? carousel.reorderPage(page, op.position) : false;
130
+ }
131
+ case 'scrollTo':
132
+ case 'setPosition':
133
+ case 'settle':
134
+ // Position ops need a settled scroll, so they are driven separately
135
+ // by the scroll tests below rather than through this replay.
136
+ return true;
137
+ }
138
+ }
139
+
140
+ export const AdwCarouselTest = async () => {
141
+ await describe('adw-carousel page list (libadwaita conformance vectors)', async () => {
142
+ // Only the structural rows: the element's page methods are what the DOM
143
+ // exposes, while position ops need a real scroll and are covered below.
144
+ const structural = CAROUSEL_PAGE_LIST_VECTORS.filter((vector) =>
145
+ vector.ops.every((op) => op.kind === 'insert' || op.kind === 'remove' || op.kind === 'reorder'),
146
+ );
147
+
148
+ for (const vector of structural) {
149
+ await it(vector.rule, () => {
150
+ const { carousel, host } = mount(vector.pages);
151
+ const pages = new Map<string, HTMLElement>();
152
+ Array.from(carousel.querySelectorAll('.adw-carousel-page')).forEach((slot, index) => {
153
+ pages.set(vector.pages[index]!, slot.firstElementChild as HTMLElement);
154
+ });
155
+
156
+ const results = vector.ops.map((op) => applyPageOp(carousel, op, pages));
157
+
158
+ expect(results).toStrictEqual([...vector.opResults]);
159
+ expect(carousel.nPages).toBe(vector.expected.nPages);
160
+ // The DOM order IS the model order — a reorder that only moved
161
+ // the model would leave the pages painted where they were.
162
+ expect(domOrder(carousel)).toStrictEqual([...vector.expected.ids]);
163
+ // And the position compensation reached the scroll container: a
164
+ // page revealed before the current one must not push it aside.
165
+ expect(carousel.position).toBe(vector.expected.position);
166
+ host.remove();
167
+ });
168
+ }
169
+ });
170
+
171
+ await describe('adw-carousel page lookup (libadwaita conformance vectors)', async () => {
172
+ for (const vector of CAROUSEL_PAGE_AT_POSITION_VECTORS) {
173
+ // Only the rows whose snap points are a settled page list are
174
+ // reachable through the DOM; the mid-reveal geometry rows need a
175
+ // reveal ramp this port does not have.
176
+ const settled = vector.snapPoints.length > 0 && vector.snapPoints.every((point, i) => point === i);
177
+ if (!settled) continue;
178
+
179
+ await it(`${vector.position} → page ${vector.page} — ${vector.rule}`, async () => {
180
+ // Driven through the `position` property rather than a real
181
+ // scroll: a scroll offset is quantised to device pixels, so the
182
+ // ±1e-7 rows around the tie are not expressible as a drag. The
183
+ // tie ITSELF is, and gets its own drag test below.
184
+ const { carousel, host } = mount(vector.snapPoints.map((_point, index) => `p${index}`));
185
+ (carousel as unknown as { position: number }).position = vector.position;
186
+ await flush();
187
+ expect(carousel.currentPage).toBe(vector.page);
188
+ host.remove();
189
+ });
190
+ }
191
+ });
192
+
193
+ await describe('adw-carousel scroll wheel (scroll_cb)', async () => {
194
+ await it('pages on a mouse wheel WITHOUT being asked to — the default is TRUE', async () => {
195
+ // The regression pin: this element used to require an explicit
196
+ // `allow-scroll-wheel` attribute, inverting adw-carousel.c:1103-1106.
197
+ const { carousel, host } = mount(['a', 'b', 'c']);
198
+ expect(carousel.allowScrollWheel).toBe(true);
199
+ expect(wheel(carousel, { deltaY: 120 })).toBe(true);
200
+ await flush();
201
+ expect(carousel.currentPage).toBe(1);
202
+ host.remove();
203
+ });
204
+
205
+ await it('honours allow-scroll-wheel="false"', async () => {
206
+ const { carousel, host } = mount(['a', 'b', 'c'], { 'allow-scroll-wheel': 'false' });
207
+ expect(carousel.allowScrollWheel).toBe(false);
208
+ expect(wheel(carousel, { deltaY: 120 })).toBe(false);
209
+ await flush();
210
+ expect(carousel.currentPage).toBe(0);
211
+ host.remove();
212
+ });
213
+
214
+ await it('pages on a horizontal delta — the fallback the port dropped', async () => {
215
+ // adw-carousel.c:554-559. The old code returned early whenever
216
+ // |deltaY| <= |deltaX|, so a dx-only event did nothing at all.
217
+ const { carousel, host } = mount(['a', 'b', 'c']);
218
+ expect(wheel(carousel, { deltaX: 120 })).toBe(true);
219
+ await flush();
220
+ expect(carousel.currentPage).toBe(1);
221
+ host.remove();
222
+ });
223
+
224
+ await it('locks out a second notch for 150 ms', async () => {
225
+ // adw-carousel.c:22, :526. The old accumulator had no time component
226
+ // at all, so a fast wheel paged as many times as it had deltas.
227
+ const { carousel, host } = mount(['a', 'b', 'c']);
228
+ expect(wheel(carousel, { deltaY: 120 })).toBe(true);
229
+ expect(wheel(carousel, { deltaY: 120 })).toBe(false);
230
+ await flush();
231
+ expect(carousel.currentPage).toBe(1);
232
+ host.remove();
233
+ });
234
+
235
+ await it('leaves a touchpad gesture to the native scroll-snap', async () => {
236
+ // A touchpad is ignored outright (:537-538) because its own kinetic
237
+ // scroll already drives the strip — here, the track's scroll-snap.
238
+ const { carousel, host } = mount(['a', 'b', 'c']);
239
+ expect(wheel(carousel, { deltaX: 12, deltaY: 7 })).toBe(false);
240
+ await flush();
241
+ expect(carousel.currentPage).toBe(0);
242
+ host.remove();
243
+ });
244
+
245
+ await it('does not consume a wheel event it did not act on', async () => {
246
+ // GDK_EVENT_STOP only when it paged (:561-562 vs :576). The old code
247
+ // called preventDefault BEFORE its threshold test.
248
+ const { carousel, host } = mount(['a', 'b', 'c'], { interactive: 'false' });
249
+ expect(wheel(carousel, { deltaY: 120 })).toBe(false);
250
+ host.remove();
251
+ });
252
+ });
253
+
254
+ await describe('adw-carousel page-changed (scroll_animation_done_cb)', async () => {
255
+ await it('reports every completed scroll, including back onto the same page', async () => {
256
+ // The old emission was gated on the ROUNDED index changing, so a
257
+ // settle back onto the page it started from said nothing.
258
+ const { carousel, host } = mount(['a', 'b', 'c']);
259
+ const seen: number[] = [];
260
+ carousel.addEventListener('page-changed', (event) => {
261
+ seen.push((event as CustomEvent<{ index: number }>).detail.index);
262
+ });
263
+
264
+ await dragTo(carousel, 1);
265
+ await dragTo(carousel, 1.4);
266
+ await dragTo(carousel, 1);
267
+ expect(seen).toStrictEqual([1, 1]);
268
+ host.remove();
269
+ });
270
+
271
+ await it('reports the LOWER page for a half-way settle', async () => {
272
+ const { carousel, host } = mount(['a', 'b']);
273
+ await dragTo(carousel, 0.5);
274
+ // Math.round would have said page 1 here; every libadwaita lookup
275
+ // resolves the tie downwards (adw-carousel.c:198-201).
276
+ expect(carousel.currentPage).toBe(0);
277
+ host.remove();
278
+ });
279
+ });
280
+
281
+ await describe('adw-carousel declarative position', async () => {
282
+ await it('honours <adw-carousel position="2"> at upgrade time', async () => {
283
+ // Attribute changes are delivered BEFORE connectedCallback, and the
284
+ // old guard dropped them, so a declared position never applied.
285
+ const { carousel, host } = mount(['a', 'b', 'c'], { position: '2' });
286
+ await flush();
287
+ expect(carousel.currentPage).toBe(2);
288
+ host.remove();
289
+ });
290
+
291
+ await it('parses the attribute and the property the same way', async () => {
292
+ // "1.7" used to mean page 1 through the attribute (parseInt) and page
293
+ // 2 through the property (Math.round). Both now resolve it through
294
+ // get_page_at_position, which answers 2.
295
+ const { carousel, host } = mount(['a', 'b', 'c']);
296
+ carousel.setAttribute('position', '1.7');
297
+ await flush();
298
+ expect(carousel.currentPage).toBe(2);
299
+
300
+ (carousel as unknown as { position: number }).position = 0.4;
301
+ await flush();
302
+ expect(carousel.currentPage).toBe(0);
303
+ host.remove();
304
+ });
305
+
306
+ await it('refuses an out-of-range scrollToPage instead of clamping', async () => {
307
+ const { carousel, host } = mount(['a', 'b', 'c']);
308
+ carousel.scrollToPage(1);
309
+ await flush();
310
+ carousel.scrollToPage(99);
311
+ carousel.scrollToPage(Number.NaN);
312
+ await flush();
313
+ expect(carousel.currentPage).toBe(1);
314
+ host.remove();
315
+ });
316
+ });
317
+
318
+ await describe('adw-carousel indicators', async () => {
319
+ await it('marks exactly one dot at a half-way position', async () => {
320
+ // With two pages at position 0.5 both dots scored exactly 0.5, and
321
+ // the old `progress > 0.5` test marked NEITHER — every dot reported
322
+ // aria-current="false".
323
+ const { carousel, host } = mount(['a', 'b']);
324
+ carousel.id = 'carousel-indicator-halfway';
325
+ const dots = document.createElement('adw-carousel-indicator-dots');
326
+ dots.setAttribute('for', carousel.id);
327
+ host.appendChild(dots);
328
+
329
+ await dragTo(carousel, 0.5);
330
+ const current = Array.from(dots.children).map((dot) => dot.getAttribute('aria-current'));
331
+ expect(current).toStrictEqual(['true', 'false']);
332
+ host.remove();
333
+ });
334
+
335
+ await it('follows the carousel when a page is added later', async () => {
336
+ const { carousel, host } = mount(['a', 'b']);
337
+ carousel.id = 'carousel-indicator-grow';
338
+ const dots = document.createElement('adw-carousel-indicator-dots');
339
+ dots.setAttribute('for', carousel.id);
340
+ host.appendChild(dots);
341
+ expect(dots.childElementCount).toBe(2);
342
+
343
+ carousel.appendPage(card('c'));
344
+ expect(dots.childElementCount).toBe(3);
345
+ host.remove();
346
+ });
347
+ });
348
+
349
+ await describe('adw-carousel properties', async () => {
350
+ await it('defaults interactive TRUE and gates navigation on it', async () => {
351
+ const { carousel, host } = mount(['a', 'b', 'c']);
352
+ expect(carousel.interactive).toBe(true);
353
+ expect(carousel.navigate('back')).toBe(false); // already at the first page
354
+ expect(carousel.navigate('forward')).toBe(true);
355
+
356
+ carousel.interactive = false;
357
+ expect(carousel.navigate('forward')).toBe(false);
358
+ host.remove();
359
+ });
360
+
361
+ await it('turns allow-long-swipes into scroll-snap-stop', async () => {
362
+ // The property used to be inert: both branches of its ternary were
363
+ // `current + step`. FALSE means "each swipe can only move to the
364
+ // adjacent pages" (adw-carousel.c:1111-1113), which is what
365
+ // scroll-snap-stop: always enforces.
366
+ const { carousel, host } = mount(['a', 'b', 'c']);
367
+ const slot = carousel.querySelector('.adw-carousel-page') as HTMLElement;
368
+ expect(carousel.allowLongSwipes).toBe(false);
369
+ expect(slot.style.getPropertyValue('scroll-snap-stop')).toBe('always');
370
+
371
+ carousel.allowLongSwipes = true;
372
+ expect(slot.style.getPropertyValue('scroll-snap-stop')).toBe('normal');
373
+ host.remove();
374
+ });
375
+
376
+ await it('spaces the pages by the spacing property', async () => {
377
+ // `distance = size + spacing` (adw-carousel.c:767); with no gap in the
378
+ // track the property would have nowhere to show up.
379
+ const { carousel, host, track } = mount(['a', 'b', 'c'], { spacing: '16' });
380
+ expect(track.style.columnGap).toBe('16px');
381
+ // The pitch a drag is measured in must include the gap; if it did not,
382
+ // page 1 would sit 16px short of where the carousel thinks it is.
383
+ expect(pitch(carousel)).toBe(track.getBoundingClientRect().width + 16);
384
+ await dragTo(carousel, 1);
385
+ expect(carousel.currentPage).toBe(1);
386
+ host.remove();
387
+ });
388
+ });
389
+ };