@gjsify/adwaita-web 0.31.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,167 @@
1
+ // DOM-level tests for <adw-icon>, driven by the SAME `normalizeIconName` vectors
2
+ // the core suite asserts against (`@gjsify/adwaita-core/conformance`), plus the
3
+ // REGRESSION PROOF for the drift the element was extracted to close.
4
+ //
5
+ // The drift: `<span class="adw-icon adw-icon--<name>" aria-hidden="true">` was
6
+ // built by hand at twenty-three sites across seventeen files, nine of them
7
+ // re-deriving `name.replace(/-symbolic$/, '')` on the spot. Exactly ONE site —
8
+ // `<adw-split-button>` — also checked the result was a single CSS token. So
9
+ // `icon-name="a b"` interpolated into `class="adw-icon adw-icon--a b"` and shipped
10
+ // a stray `b` class through `<adw-menu-button>`, for its own icon AND for every
11
+ // JSON menu entry's, while the one element that guarded kept passing its spec.
12
+ // The vectors below now run against the ELEMENT, so a renderer that starts
13
+ // building the class by hand again fails naming the input.
14
+ import { describe, expect, it } from '@gjsify/unit';
15
+
16
+ import { VIEW_STACK_ICON_NAME_VECTORS } from '@gjsify/adwaita-core/conformance';
17
+
18
+ import type { AdwIcon } from './elements/adw-icon.js';
19
+ import type { AdwMenuButton } from './elements/adw-menu-button.js';
20
+
21
+ function mount<T extends HTMLElement>(tag: string): { el: T; host: HTMLElement } {
22
+ const host = document.createElement('div');
23
+ document.body.appendChild(host);
24
+ const el = document.createElement(tag) as T;
25
+ host.appendChild(el);
26
+ return { el, host };
27
+ }
28
+
29
+ /** Every class on the node that is not the base `.adw-icon`. */
30
+ function extraClasses(el: HTMLElement): string[] {
31
+ return [...el.classList].filter((c) => c !== 'adw-icon').sort();
32
+ }
33
+
34
+ export const AdwIconTest = async () => {
35
+ await describe('<adw-icon> mask class (normalizeIconName vectors)', async () => {
36
+ for (const { icon, normalized, rule } of VIEW_STACK_ICON_NAME_VECTORS) {
37
+ await it(`${JSON.stringify(icon)} → ${JSON.stringify(normalized)} — ${rule}`, () => {
38
+ const { el, host } = mount<AdwIcon>('adw-icon');
39
+ el.iconName = icon ?? null;
40
+
41
+ expect(el.resolvedIconName).toBe(normalized);
42
+ // The base class is unconditional — a nameless icon is still an
43
+ // icon box, which is what `<adw-status-page>` hides rather than
44
+ // omits.
45
+ expect(el.classList.contains('adw-icon')).toBe(true);
46
+ // …and NOTHING else is on the node. This is the assertion the
47
+ // hand-rolled copies could not make: `icon-name="a b"` used to
48
+ // leave a stray `b` here.
49
+ expect(extraClasses(el)).toStrictEqual(normalized === '' ? [] : [`adw-icon--${normalized}`]);
50
+ host.remove();
51
+ });
52
+ }
53
+ });
54
+
55
+ await describe('<adw-icon> node contract', async () => {
56
+ await it('is decorative — the accessible name belongs to the host control', () => {
57
+ const { el, host } = mount<AdwIcon>('adw-icon');
58
+ expect(el.getAttribute('aria-hidden')).toBe('true');
59
+ host.remove();
60
+ });
61
+
62
+ await it('swaps ONLY the mask class, keeping the caller position classes', () => {
63
+ const { el, host } = mount<AdwIcon>('adw-icon');
64
+ el.classList.add('adw-drop-down-arrow', 'start');
65
+ el.iconName = 'go-down';
66
+ expect(extraClasses(el)).toStrictEqual(['adw-drop-down-arrow', 'adw-icon--go-down', 'start']);
67
+
68
+ el.iconName = 'go-next';
69
+ // Exactly one mask class at a time, and the caller's are untouched —
70
+ // a `className =` assignment would have taken them with it.
71
+ expect(extraClasses(el)).toStrictEqual(['adw-drop-down-arrow', 'adw-icon--go-next', 'start']);
72
+
73
+ el.iconName = null;
74
+ expect(extraClasses(el)).toStrictEqual(['adw-drop-down-arrow', 'start']);
75
+ host.remove();
76
+ });
77
+
78
+ await it('sizes the box AND the mask together', () => {
79
+ const { el, host } = mount<AdwIcon>('adw-icon');
80
+ el.iconName = 'go-next';
81
+ // Unset: the stylesheet's 16px.
82
+ expect(el.size).toBe(null);
83
+ expect(getComputedStyle(el).width).toBe('16px');
84
+
85
+ el.size = 32;
86
+ const styled = getComputedStyle(el);
87
+ expect(styled.width).toBe('32px');
88
+ expect(styled.height).toBe('32px');
89
+ // Sizing only the box would leave a 16px glyph floating in it. Only
90
+ // the axis we set is asserted: `mask-size: 32px` means "32px wide,
91
+ // height from the aspect ratio", and the CSSOM re-serializes the
92
+ // omitted half (Firefox reports `32px auto`, in the inline style too).
93
+ expect(styled.maskSize.split(' ')[0]).toBe('32px');
94
+ expect(styled.webkitMaskSize.split(' ')[0]).toBe('32px');
95
+
96
+ el.size = null;
97
+ expect(getComputedStyle(el).width).toBe('16px');
98
+ host.remove();
99
+ });
100
+
101
+ await it('paints in the INHERITED colour, not the boundary reset colour', () => {
102
+ // `.adw-icon` masks with `currentColor`, and <adw-icon> is a custom
103
+ // element — so the ADR-0010 `$adw-components` reset re-roots `color`
104
+ // on it. Without `color: inherit` in `_icon.scss` every icon would
105
+ // repaint in `--window-fg-color` regardless of the button it sits in.
106
+ const { el: box, host } = mount<HTMLElement>('div');
107
+ box.style.color = 'rgb(255, 0, 0)';
108
+ const icon = document.createElement('adw-icon') as AdwIcon;
109
+ icon.iconName = 'go-next';
110
+ box.appendChild(icon);
111
+ expect(getComputedStyle(icon).backgroundColor).toBe('rgb(255, 0, 0)');
112
+ host.remove();
113
+ });
114
+ });
115
+
116
+ await describe('the hand-rolled copies are gone', async () => {
117
+ await it('<adw-menu-button> no longer injects a stray class from its icon-name', () => {
118
+ const { el, host } = mount<AdwMenuButton>('adw-menu-button');
119
+ el.setAttribute('icon-name', 'a b');
120
+ const icon = el.querySelector('.adw-icon') as HTMLElement;
121
+ // `adw-icon--a b` would have added a second, unrelated `b` class.
122
+ expect(extraClasses(icon)).toStrictEqual(['adw-menu-button-icon']);
123
+ host.remove();
124
+ });
125
+
126
+ await it('<adw-menu-button> menu entries guard their icons too', () => {
127
+ const { el, host } = mount<AdwMenuButton>('adw-menu-button');
128
+ el.menuItems = [
129
+ { label: 'Bad', icon: 'a b' },
130
+ { label: 'Good', icon: 'view-refresh-symbolic' },
131
+ { label: 'None' },
132
+ ];
133
+ const items = [...el.querySelectorAll('.adw-menu-button-item')];
134
+ expect(items.length).toBe(3);
135
+ // An unusable name draws nothing rather than injecting markup, and
136
+ // an entry with no icon still gets no icon node at all.
137
+ expect(items[0].querySelector('.adw-icon')).toBe(null);
138
+ expect(items[2].querySelector('.adw-icon')).toBe(null);
139
+ const good = items[1].querySelector('.adw-icon') as HTMLElement;
140
+ expect(extraClasses(good)).toStrictEqual(['adw-icon--view-refresh', 'adw-menu-button-item-icon']);
141
+ host.remove();
142
+ });
143
+
144
+ await it('every icon in the package is an <adw-icon>', () => {
145
+ // The point of the extraction, asserted structurally: build a page of
146
+ // the widgets that draw icons and check no bare span carries the
147
+ // base class. A new hand-rolled `<span class="adw-icon …">` fails
148
+ // here even if it happens to guard its own name correctly.
149
+ const { el: page, host } = mount<HTMLElement>('div');
150
+ page.innerHTML = `
151
+ <adw-button icon="go-next" label="Next"></adw-button>
152
+ <adw-button-row title="Row" start-icon-name="list-add" end-icon-name="go-next"></adw-button-row>
153
+ <adw-status-page icon="folder" title="Empty"></adw-status-page>
154
+ <adw-split-button label="Save" menu='[{"label":"Save As"}]'></adw-split-button>
155
+ <adw-drop-down options='["a","b"]'></adw-drop-down>
156
+ <adw-expander-row title="More" show-enable-switch></adw-expander-row>
157
+ <adw-avatar text="Ada" icon="avatar-default"></adw-avatar>
158
+ <adw-toggle-group><adw-toggle icon-name="view-list"></adw-toggle></adw-toggle-group>
159
+ `;
160
+ const bareSpans = [...host.querySelectorAll('span.adw-icon')];
161
+ expect(bareSpans.length).toBe(0);
162
+ // …and the icons really are there, so the check is not vacuous.
163
+ expect(host.querySelectorAll('adw-icon.adw-icon').length > 5).toBe(true);
164
+ host.remove();
165
+ });
166
+ });
167
+ };
@@ -0,0 +1,173 @@
1
+ // DOM-level tests for <adw-progress-bar>.
2
+ //
3
+ // Three things here are worth more than the attribute round-trips: the CLAMP is
4
+ // GLib's (`glibClamp`, shared from `@gjsify/adwaita-core`, not a local
5
+ // `Math.min/max`), the ZERO fraction has to make the indicator VANISH rather
6
+ // than draw a rounded stub (`> trough.empty > progress { all: unset }`,
7
+ // _progress-bar.scss:85), and the text node has to actually PICK UP `.dimmed` +
8
+ // `.numeric` from `_labels.scss` — that composition is the reason those classes
9
+ // exist, and a `@extend` that silently reached nothing would look identical in
10
+ // the source.
11
+
12
+ import { describe, expect, it } from '@gjsify/unit';
13
+
14
+ import type { AdwProgressBar } from './elements/adw-progress-bar.js';
15
+
16
+ function mount(): { el: AdwProgressBar; host: HTMLElement } {
17
+ const host = document.createElement('div');
18
+ document.body.appendChild(host);
19
+ const el = document.createElement('adw-progress-bar') as AdwProgressBar;
20
+ host.appendChild(el);
21
+ return { el, host };
22
+ }
23
+
24
+ /** The parts, by the markup contract the stylesheet selects on. */
25
+ function parts(bar: AdwProgressBar): { trough: HTMLElement; progress: HTMLElement; text: HTMLElement } {
26
+ return {
27
+ trough: bar.querySelector('.adw-progress-bar-trough') as HTMLElement,
28
+ progress: bar.querySelector('.adw-progress-bar-progress') as HTMLElement,
29
+ text: bar.querySelector('.adw-progress-bar-text') as HTMLElement,
30
+ };
31
+ }
32
+
33
+ export const AdwProgressBarTest = async () => {
34
+ await describe('<adw-progress-bar> fraction', async () => {
35
+ await it('starts empty, with the trough flagged so the indicator disappears', () => {
36
+ const { el, host } = mount();
37
+ const { trough, progress } = parts(el);
38
+ expect(el.fraction).toBe(0);
39
+ expect(trough).toBeTruthy();
40
+ expect(progress).toBeTruthy();
41
+ expect(trough.classList.contains('empty')).toBe(true);
42
+ expect(el.getAttribute('role')).toBe('progressbar');
43
+ expect(el.getAttribute('aria-valuenow')).toBe('0');
44
+ host.remove();
45
+ });
46
+
47
+ await it('fills to the fraction and drops the empty flag', () => {
48
+ const { el, host } = mount();
49
+ el.fraction = 0.25;
50
+ const { trough, progress } = parts(el);
51
+ expect(trough.classList.contains('empty')).toBe(false);
52
+ expect(progress.style.width).toBe('25%');
53
+ expect(el.getAttribute('aria-valuenow')).toBe('0.25');
54
+ host.remove();
55
+ });
56
+
57
+ await it('clamps out-of-range and unusable fractions', () => {
58
+ const { el, host } = mount();
59
+ el.fraction = 1.5;
60
+ expect(el.fraction).toBe(1);
61
+ expect(parts(el).progress.style.width).toBe('100%');
62
+ el.fraction = -3;
63
+ expect(el.fraction).toBe(0);
64
+ // A garbage attribute is GtkProgressBar's default 0, never NaN —
65
+ // NaN would reach both the width and `aria-valuenow`.
66
+ el.setAttribute('fraction', 'later');
67
+ expect(el.fraction).toBe(0);
68
+ expect(el.getAttribute('aria-valuenow')).toBe('0');
69
+ host.remove();
70
+ });
71
+ });
72
+
73
+ await describe('<adw-progress-bar> indeterminate', async () => {
74
+ await it('pulse() enters the pulsing state and drops aria-valuenow', () => {
75
+ const { el, host } = mount();
76
+ el.fraction = 0.4;
77
+ expect(el.getAttribute('aria-valuenow')).toBe('0.4');
78
+
79
+ el.pulse();
80
+
81
+ expect(el.pulsing).toBe(true);
82
+ expect(el.hasAttribute('pulsing')).toBe(true);
83
+ // ARIA spells "indeterminate" as an ABSENT valuenow; a stale 0.4
84
+ // would be announced as real progress.
85
+ expect(el.hasAttribute('aria-valuenow')).toBe(false);
86
+ // A pulsing bar is never `.empty` — the bouncing block is what says
87
+ // "working", so it must not be unset away.
88
+ expect(parts(el).trough.classList.contains('empty')).toBe(false);
89
+ host.remove();
90
+ });
91
+
92
+ await it('leaving the pulsing state restores the determinate value', () => {
93
+ const { el, host } = mount();
94
+ el.fraction = 0;
95
+ el.pulse();
96
+ el.pulsing = false;
97
+ expect(el.getAttribute('aria-valuenow')).toBe('0');
98
+ expect(parts(el).trough.classList.contains('empty')).toBe(true);
99
+ host.remove();
100
+ });
101
+ });
102
+
103
+ await describe('<adw-progress-bar> text', async () => {
104
+ await it('hides the text node until show-text, then shows the percentage', () => {
105
+ const { el, host } = mount();
106
+ expect(parts(el).text.hidden).toBe(true);
107
+
108
+ el.fraction = 0.42;
109
+ el.showText = true;
110
+
111
+ expect(parts(el).text.hidden).toBe(false);
112
+ expect(parts(el).text.textContent).toBe('42%');
113
+ host.remove();
114
+ });
115
+
116
+ await it('an explicit text wins over the percentage', () => {
117
+ const { el, host } = mount();
118
+ el.showText = true;
119
+ el.fraction = 0.5;
120
+ el.text = 'Copying 3 of 6';
121
+ expect(el.text).toBe('Copying 3 of 6');
122
+ expect(parts(el).text.textContent).toBe('Copying 3 of 6');
123
+ host.remove();
124
+ });
125
+
126
+ await it('the text node composes .dimmed and .numeric from _labels.scss', () => {
127
+ // `> text { @extend .dimmed; @extend .numeric; }`
128
+ // (_progress-bar.scss:17-21) — this asserts the extends actually
129
+ // REACHED the utility classes, which a source read cannot tell.
130
+ const { el, host } = mount();
131
+ el.showText = true;
132
+ const style = getComputedStyle(parts(el).text);
133
+ expect(style.getPropertyValue('font-variant-numeric')).toBe('tabular-nums');
134
+ expect(Number.parseFloat(style.opacity)).toBeLessThan(1);
135
+ host.remove();
136
+ });
137
+ });
138
+
139
+ await describe('<adw-progress-bar> variants', async () => {
140
+ await it('is an 8px trough by default and a 2px hairline with osd', () => {
141
+ // The 8px is libadwaita's (_progress-bar.scss:6) and the 2px is its
142
+ // `.osd` variant (:59-83) — NOT the vendored web port's 6px, which
143
+ // has no OSD variant at all.
144
+ //
145
+ // TWO bars rather than one flipped in place, on purpose: the trough
146
+ // TRANSITIONS `background`, so a computed read taken right after an
147
+ // attribute flip returns the INTERPOLATED colour — i.e. still the
148
+ // old one — and the assertion would be timing-dependent. Setting the
149
+ // state before the element's first style resolution leaves no
150
+ // transition to be part-way through.
151
+ const host = document.createElement('div');
152
+ document.body.appendChild(host);
153
+ const plain = document.createElement('adw-progress-bar') as AdwProgressBar;
154
+ const osd = document.createElement('adw-progress-bar') as AdwProgressBar;
155
+ osd.setAttribute('osd', '');
156
+ host.append(plain, osd);
157
+
158
+ expect(getComputedStyle(parts(plain).trough).height).toBe('8px');
159
+ const osdTrough = getComputedStyle(parts(osd).trough);
160
+ expect(osdTrough.height).toBe('2px');
161
+ expect(osdTrough.backgroundColor).toBe('rgba(0, 0, 0, 0)');
162
+ host.remove();
163
+ });
164
+
165
+ await it('inverted fills from the far end', () => {
166
+ const { el, host } = mount();
167
+ expect(getComputedStyle(parts(el).trough).justifyContent).toBe('normal');
168
+ el.inverted = true;
169
+ expect(getComputedStyle(parts(el).trough).justifyContent).toBe('flex-end');
170
+ host.remove();
171
+ });
172
+ });
173
+ };
@@ -0,0 +1,332 @@
1
+ // DOM-level conformance tests for <adw-sidebar>, driven by the SAME vectors the
2
+ // NativeScript renderer asserts against (`@gjsify/adwaita-core/conformance`).
3
+ //
4
+ // The two renderers used to carry independent selection logic, and it had
5
+ // drifted apart AND away from libadwaita: `selected = 5` on a 3-item sidebar
6
+ // gave "no selection" in GTK, `2` here (it clamped to the last row) and `0` on
7
+ // NativeScript (it dropped the write). This element also keyed section headers
8
+ // off the DECLARATION index, so an empty leading section drew a stray separator
9
+ // and cost the next section its `.first` flush-to-top padding. Nothing failed,
10
+ // because nothing compared them. This suite is that comparison.
11
+ import { describe, expect, it } from '@gjsify/unit';
12
+
13
+ import type { AdwSidebarItemSpec, AdwSidebarSectionSpec } from '@gjsify/adwaita-core';
14
+ import {
15
+ SIDEBAR_ACTIVATION_VECTORS,
16
+ SIDEBAR_CLAMP_VECTORS,
17
+ SIDEBAR_FILTER_VECTORS,
18
+ SIDEBAR_ITEM_FLAG_VECTORS,
19
+ SIDEBAR_MODEL_VECTORS,
20
+ SIDEBAR_MODE_VECTORS,
21
+ } from '@gjsify/adwaita-core/conformance';
22
+ import type { AdwSidebar } from './elements/adw-sidebar.js';
23
+
24
+ /**
25
+ * Mount a sidebar built from DECLARED children, the way an author writes it —
26
+ * the element consumes them at connect time, so they all have to exist before
27
+ * the host is appended.
28
+ */
29
+ function mountSidebar(
30
+ sections: ReadonlyArray<AdwSidebarSectionSpec>,
31
+ attributes: { mode?: string; selected?: string } = {},
32
+ ): { sidebar: AdwSidebar; host: HTMLElement } {
33
+ const host = document.createElement('div');
34
+ document.body.appendChild(host);
35
+
36
+ const sidebar = document.createElement('adw-sidebar') as AdwSidebar;
37
+ if (attributes.mode !== undefined) sidebar.setAttribute('mode', attributes.mode);
38
+ if (attributes.selected !== undefined) sidebar.setAttribute('selected', attributes.selected);
39
+
40
+ for (const section of sections) {
41
+ const sectionEl = document.createElement('adw-sidebar-section');
42
+ if (section.title !== undefined) sectionEl.setAttribute('title', section.title);
43
+
44
+ for (const item of section.items) {
45
+ sectionEl.appendChild(createItem(item));
46
+ }
47
+ sidebar.appendChild(sectionEl);
48
+ }
49
+
50
+ host.appendChild(sidebar);
51
+ return { sidebar, host };
52
+ }
53
+
54
+ function createItem(item: AdwSidebarItemSpec): HTMLElement {
55
+ const el = document.createElement('adw-sidebar-item');
56
+ el.setAttribute('title', item.title);
57
+ if (item.subtitle !== undefined) el.setAttribute('subtitle', item.subtitle);
58
+ if (item.iconName !== undefined) el.setAttribute('icon-name', item.iconName);
59
+ if (item.enabled === false) el.setAttribute('disabled', '');
60
+ if (item.visible === false) el.setAttribute('hidden', '');
61
+ return el;
62
+ }
63
+
64
+ /** The rendered rows, in render order. */
65
+ function rowsOf(sidebar: AdwSidebar): HTMLButtonElement[] {
66
+ return Array.from(sidebar.querySelectorAll('.adw-sidebar-item')) as HTMLButtonElement[];
67
+ }
68
+
69
+ /** The rendered headers, reduced to the three things the C source derives. */
70
+ function headersOf(sidebar: AdwSidebar): { kind: string; title: string; first: boolean }[] {
71
+ return Array.from(sidebar.querySelectorAll('.adw-sidebar-section-header')).map((el) => ({
72
+ kind: el.classList.contains('has-title') ? 'title' : 'separator',
73
+ title: el.querySelector('.adw-sidebar-section-heading')?.textContent ?? '',
74
+ first: el.classList.contains('first'),
75
+ }));
76
+ }
77
+
78
+ /** A sidebar of `count` plain rows — the shape the clamp vectors are stated over. */
79
+ function countedSections(count: number): AdwSidebarSectionSpec[] {
80
+ if (count === 0) return [];
81
+ return [{ items: Array.from({ length: count }, (_, i) => ({ title: `Item ${i}` })) }];
82
+ }
83
+
84
+ export const AdwSidebarTest = async () => {
85
+ await describe('adw-sidebar selection bounds (libadwaita conformance vectors)', async () => {
86
+ for (const { index, count, selected, rule } of SIDEBAR_CLAMP_VECTORS) {
87
+ await it(`selected="${index}" on ${count} rows → ${selected} — ${rule}`, () => {
88
+ const { sidebar, host } = mountSidebar(countedSections(count), { selected: String(index) });
89
+
90
+ expect(sidebar.selected).toBe(selected);
91
+ // The attribute is normalised back, so it never claims an index
92
+ // the property does not report.
93
+ expect(sidebar.getAttribute('selected')).toBe(String(selected));
94
+ expect(rowsOf(sidebar).filter((row) => row.classList.contains('selected'))).toHaveLength(
95
+ selected === -1 ? 0 : 1,
96
+ );
97
+
98
+ host.remove();
99
+ });
100
+ }
101
+
102
+ await it('auto-selects the first row when no index is declared', () => {
103
+ const { sidebar, host } = mountSidebar(countedSections(3));
104
+ expect(sidebar.selected).toBe(0);
105
+ expect(rowsOf(sidebar)[0]!.classList.contains('selected')).toBe(true);
106
+ host.remove();
107
+ });
108
+
109
+ await it('notifies on a programmatic change, not only on a click', () => {
110
+ const { sidebar, host } = mountSidebar(countedSections(3));
111
+ const seen: { selected: number; interactive: boolean }[] = [];
112
+ sidebar.addEventListener('notify::selected', (event) => {
113
+ seen.push((event as CustomEvent).detail as { selected: number; interactive: boolean });
114
+ });
115
+
116
+ sidebar.selected = 2;
117
+ expect(seen).toHaveLength(1);
118
+ expect(seen[0]!.selected).toBe(2);
119
+ expect(seen[0]!.interactive).toBe(false);
120
+
121
+ host.remove();
122
+ });
123
+ });
124
+
125
+ await describe('adw-sidebar rows + headers (libadwaita conformance vectors)', async () => {
126
+ for (const vector of SIDEBAR_MODEL_VECTORS) {
127
+ await it(`${vector.name} — ${vector.rule}`, () => {
128
+ const { sidebar, host } = mountSidebar(vector.sections);
129
+
130
+ expect(rowsOf(sidebar)).toHaveLength(vector.count);
131
+ expect(
132
+ rowsOf(sidebar).map((row) => row.querySelector('.adw-sidebar-item-title')?.textContent ?? ''),
133
+ ).toStrictEqual(vector.flat.map((entry) => entry.title));
134
+ expect(headersOf(sidebar)).toStrictEqual(
135
+ vector.headers.map((header) => ({
136
+ kind: header.kind as string,
137
+ title: header.title,
138
+ first: header.first,
139
+ })),
140
+ );
141
+
142
+ host.remove();
143
+ });
144
+ }
145
+
146
+ await it('draws no section container for a section that renders no rows', () => {
147
+ // The old element appended a header AND an empty `.adw-sidebar-section`
148
+ // per DECLARED section, which in page mode is a visible empty card.
149
+ const { sidebar, host } = mountSidebar([{ title: 'Empty', items: [] }, { items: [{ title: 'A' }] }]);
150
+ expect(sidebar.querySelectorAll('.adw-sidebar-section')).toHaveLength(1);
151
+ expect(headersOf(sidebar)).toHaveLength(0);
152
+ host.remove();
153
+ });
154
+ });
155
+
156
+ await describe('adw-sidebar item labels (string_is_not_empty bindings)', async () => {
157
+ for (const vector of SIDEBAR_ITEM_FLAG_VECTORS) {
158
+ await it(`${JSON.stringify(vector.item)} — ${vector.rule}`, () => {
159
+ const { sidebar, host } = mountSidebar([{ items: [vector.item] }]);
160
+ const row = rowsOf(sidebar)[0]!;
161
+
162
+ expect((row.querySelector('.adw-sidebar-item-title') as HTMLElement).hidden).toBe(!vector.titleVisible);
163
+ expect((row.querySelector('.adw-sidebar-item-subtitle') as HTMLElement).hidden).toBe(
164
+ !vector.subtitleVisible,
165
+ );
166
+ expect((row.querySelector('.adw-sidebar-item-icon') as HTMLElement).hidden).toBe(!vector.iconVisible);
167
+
168
+ host.remove();
169
+ });
170
+ }
171
+ });
172
+
173
+ await describe('adw-sidebar activation (row-selected then row-activated)', async () => {
174
+ for (const vector of SIDEBAR_ACTIVATION_VECTORS) {
175
+ await it(`${vector.name} — ${vector.rule}`, () => {
176
+ const { sidebar, host } = mountSidebar(vector.sections);
177
+ sidebar.selected = vector.initialSelected;
178
+
179
+ const activations: number[] = [];
180
+ const notifications: number[] = [];
181
+ sidebar.addEventListener('activated', (event) => {
182
+ activations.push(((event as CustomEvent).detail as { index: number }).index);
183
+ });
184
+ sidebar.addEventListener('notify::selected', (event) => {
185
+ notifications.push(((event as CustomEvent).detail as { selected: number }).selected);
186
+ });
187
+
188
+ const row = rowsOf(sidebar)[vector.activate];
189
+ if (row) row.click();
190
+ else expect(vector.activated).toBe(false);
191
+
192
+ expect(activations).toHaveLength(vector.activated ? 1 : 0);
193
+ expect(notifications).toHaveLength(vector.selectionChanged ? 1 : 0);
194
+ expect(sidebar.selected).toBe(vector.selected);
195
+
196
+ host.remove();
197
+ });
198
+ }
199
+
200
+ await it('re-clicking the selected row still fires activated — the split-view reveal', () => {
201
+ const { sidebar, host } = mountSidebar([{ items: [{ title: 'A' }, { title: 'B' }] }]);
202
+ const activations: number[] = [];
203
+ sidebar.addEventListener('activated', () => activations.push(1));
204
+
205
+ rowsOf(sidebar)[0]!.click();
206
+ rowsOf(sidebar)[0]!.click();
207
+ expect(activations).toHaveLength(2);
208
+ expect(sidebar.selected).toBe(0);
209
+
210
+ host.remove();
211
+ });
212
+
213
+ await it('tags a click as interactive and a property write as not', () => {
214
+ const { sidebar, host } = mountSidebar([{ items: [{ title: 'A' }, { title: 'B' }] }]);
215
+ const flags: boolean[] = [];
216
+ sidebar.addEventListener('notify::selected', (event) => {
217
+ flags.push(((event as CustomEvent).detail as { interactive: boolean }).interactive);
218
+ });
219
+
220
+ rowsOf(sidebar)[1]!.click();
221
+ sidebar.selected = 0;
222
+ expect(flags).toStrictEqual([true, false]);
223
+
224
+ host.remove();
225
+ });
226
+ });
227
+
228
+ await describe('adw-sidebar filter + empty state (Adw.Sidebar:filter / update_placeholder)', async () => {
229
+ for (const vector of SIDEBAR_FILTER_VECTORS) {
230
+ await it(`${vector.name} — ${vector.rule}`, () => {
231
+ const { sidebar, host } = mountSidebar(vector.sections);
232
+ sidebar.filter = (item) => vector.keepTitles.includes(item.title);
233
+
234
+ expect(rowsOf(sidebar)).toHaveLength(vector.visibleIndices.length);
235
+ expect(headersOf(sidebar)).toStrictEqual(
236
+ vector.headers.map((header) => ({
237
+ kind: header.kind as string,
238
+ title: header.title,
239
+ first: header.first,
240
+ })),
241
+ );
242
+ expect(sidebar.classList.contains('empty')).toBe(vector.isEmpty);
243
+
244
+ host.remove();
245
+ });
246
+ }
247
+
248
+ await it('keeps the selection index in the UNFILTERED space', () => {
249
+ const { sidebar, host } = mountSidebar([{ items: [{ title: 'A' }, { title: 'B' }, { title: 'C' }] }]);
250
+ sidebar.selected = 2;
251
+ sidebar.filter = (item) => item.title !== 'A';
252
+
253
+ expect(sidebar.selected).toBe(2);
254
+ expect(sidebar.selectedItem?.title).toBe('C');
255
+ expect(rowsOf(sidebar)).toHaveLength(2);
256
+ expect(rowsOf(sidebar)[1]!.classList.contains('selected')).toBe(true);
257
+
258
+ host.remove();
259
+ });
260
+ });
261
+
262
+ await describe('adw-sidebar mode (adw_sidebar_set_mode)', async () => {
263
+ for (const { mode, selectionVisible, rule } of SIDEBAR_MODE_VECTORS) {
264
+ await it(`${mode} paints the selection: ${selectionVisible} — ${rule}`, () => {
265
+ const { sidebar, host } = mountSidebar(countedSections(3), { mode, selected: '1' });
266
+
267
+ expect(sidebar.mode).toBe(mode);
268
+ expect(sidebar.selected).toBe(1);
269
+ expect(rowsOf(sidebar)[1]!.classList.contains('selected')).toBe(selectionVisible);
270
+ // Tracked either way — aria still reports which item is current.
271
+ expect(rowsOf(sidebar)[1]!.getAttribute('aria-selected')).toBe('true');
272
+
273
+ host.remove();
274
+ });
275
+ }
276
+
277
+ await it('keeps the selection across a mode switch and back', () => {
278
+ const { sidebar, host } = mountSidebar(countedSections(3), { selected: '2' });
279
+
280
+ sidebar.mode = 'page';
281
+ expect(sidebar.selected).toBe(2);
282
+ expect(rowsOf(sidebar)[2]!.classList.contains('selected')).toBe(false);
283
+
284
+ sidebar.mode = 'sidebar';
285
+ expect(sidebar.selected).toBe(2);
286
+ expect(rowsOf(sidebar)[2]!.classList.contains('selected')).toBe(true);
287
+
288
+ host.remove();
289
+ });
290
+ });
291
+
292
+ await describe('adw-sidebar live model changes', async () => {
293
+ await it('rebuilds when the sections are replaced, re-running the 0 → n auto-select', () => {
294
+ const { sidebar, host } = mountSidebar([{ items: [{ title: 'A' }, { title: 'B' }] }]);
295
+ sidebar.selected = 1;
296
+
297
+ sidebar.sections = [{ title: 'New', items: [{ title: 'X' }, { title: 'Y' }, { title: 'Z' }] }];
298
+ expect(rowsOf(sidebar)).toHaveLength(3);
299
+ expect(sidebar.selected).toBe(0);
300
+ expect(headersOf(sidebar)).toStrictEqual([{ kind: 'title', title: 'New', first: true }]);
301
+
302
+ host.remove();
303
+ });
304
+
305
+ await it('follows a declared item attribute change without touching the selection', () => {
306
+ // <adw-sidebar-item> declared `observedAttributes` with no
307
+ // attributeChangedCallback behind it, so every post-construction
308
+ // change was silently lost. The declared element is detached once
309
+ // consumed, so the link back to its spec cannot be a DOM lookup.
310
+ const declared = createItem({ title: 'A' });
311
+ const sectionEl = document.createElement('adw-sidebar-section');
312
+ sectionEl.append(declared, createItem({ title: 'B' }));
313
+
314
+ const host = document.createElement('div');
315
+ document.body.appendChild(host);
316
+ const sidebar = document.createElement('adw-sidebar') as AdwSidebar;
317
+ sidebar.appendChild(sectionEl);
318
+ host.appendChild(sidebar);
319
+
320
+ sidebar.selected = 1;
321
+ declared.setAttribute('title', 'Renamed');
322
+ declared.setAttribute('icon-name', 'folder-symbolic');
323
+
324
+ const row = rowsOf(sidebar)[0]!;
325
+ expect(row.querySelector('.adw-sidebar-item-title')?.textContent).toBe('Renamed');
326
+ expect((row.querySelector('.adw-sidebar-item-icon') as HTMLElement).hidden).toBe(false);
327
+ expect(sidebar.selected).toBe(1);
328
+
329
+ host.remove();
330
+ });
331
+ });
332
+ };