@gjsify/adwaita-web 0.31.0 → 0.33.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.
- package/dist/adwaita-web.css +835 -299
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/adw-about-dialog.spec.d.ts +1 -0
- package/lib/types/adw-action-rows.spec.d.ts +1 -0
- package/lib/types/adw-avatar.spec.d.ts +1 -0
- package/lib/types/adw-banner.spec.d.ts +1 -0
- package/lib/types/adw-button-content.spec.d.ts +1 -0
- package/lib/types/adw-carousel.spec.d.ts +1 -0
- package/lib/types/adw-checks.spec.d.ts +1 -0
- package/lib/types/adw-header-bar.spec.d.ts +1 -0
- package/lib/types/adw-icon.spec.d.ts +1 -0
- package/lib/types/adw-progress-bar.spec.d.ts +1 -0
- package/lib/types/adw-sidebar.spec.d.ts +1 -0
- package/lib/types/adw-switch.spec.d.ts +1 -0
- package/lib/types/adw-wrap-box.spec.d.ts +1 -0
- package/lib/types/bottom-sheet.spec.d.ts +1 -0
- package/lib/types/breakpoints.d.ts +22 -0
- package/lib/types/breakpoints.spec.d.ts +1 -0
- package/lib/types/chrome.spec.d.ts +1 -0
- package/lib/types/elements/adw-about-dialog.d.ts +47 -8
- package/lib/types/elements/adw-action-row.d.ts +35 -0
- package/lib/types/elements/adw-avatar.d.ts +11 -0
- package/lib/types/elements/adw-bottom-sheet.d.ts +33 -4
- package/lib/types/elements/adw-button-content.d.ts +12 -0
- package/lib/types/elements/adw-button-row.d.ts +10 -1
- package/lib/types/elements/adw-carousel.d.ts +111 -8
- package/lib/types/elements/adw-checks.d.ts +57 -0
- package/lib/types/elements/adw-clamp.d.ts +4 -1
- package/lib/types/elements/adw-combo-row.d.ts +10 -0
- package/lib/types/elements/adw-data-grid.d.ts +11 -31
- package/lib/types/elements/adw-drop-down.d.ts +17 -20
- package/lib/types/elements/adw-entry-row.d.ts +93 -3
- package/lib/types/elements/adw-expander-row.d.ts +7 -1
- package/lib/types/elements/adw-header-bar.d.ts +15 -0
- package/lib/types/elements/adw-icon.d.ts +25 -0
- package/lib/types/elements/adw-inline-view-switcher.d.ts +73 -11
- package/lib/types/elements/adw-menu-button.d.ts +6 -6
- package/lib/types/elements/adw-navigation-split-view.d.ts +48 -0
- package/lib/types/elements/adw-navigation-view.d.ts +63 -20
- package/lib/types/elements/adw-overlay-split-view.d.ts +94 -0
- package/lib/types/elements/adw-password-entry-row.d.ts +19 -10
- package/lib/types/elements/adw-popover.d.ts +58 -0
- package/lib/types/elements/adw-preferences-dialog.d.ts +57 -2
- package/lib/types/elements/adw-preferences-group.d.ts +31 -0
- package/lib/types/elements/adw-progress-bar.d.ts +38 -0
- package/lib/types/elements/adw-sidebar.d.ts +37 -5
- package/lib/types/elements/adw-spinner.d.ts +23 -0
- package/lib/types/elements/adw-split-button.d.ts +53 -7
- package/lib/types/elements/adw-switch-row.d.ts +11 -2
- package/lib/types/elements/adw-switch.d.ts +15 -0
- package/lib/types/elements/adw-tab-view.d.ts +129 -12
- package/lib/types/elements/adw-toolbar-view.d.ts +5 -0
- package/lib/types/elements/adw-view-stack.d.ts +39 -19
- package/lib/types/elements/adw-view-switcher-bar.d.ts +25 -7
- package/lib/types/elements/adw-view-switcher.d.ts +76 -13
- package/lib/types/elements/adw-window-title.d.ts +3 -1
- package/lib/types/elements/adw-wrap-box.d.ts +58 -3
- package/lib/types/elements/view-switcher-dom.d.ts +44 -0
- package/lib/types/entry-rows.spec.d.ts +1 -0
- package/lib/types/index.d.ts +7 -0
- package/lib/types/navigation-view.spec.d.ts +1 -0
- package/lib/types/preferences-search.d.ts +20 -0
- package/lib/types/preferences.spec.d.ts +1 -0
- package/lib/types/split-button.spec.d.ts +1 -0
- package/lib/types/split-views.spec.d.ts +1 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/lib/types/tab-view.spec.d.ts +1 -0
- package/lib/types/view-stack.spec.d.ts +1 -0
- package/lib/types/view-switcher.spec.d.ts +1 -0
- package/package.json +6 -6
- package/scss/_banner.scss +13 -0
- package/scss/_bottom_sheet.scss +5 -6
- package/scss/_button.scss +3 -26
- package/scss/_button_content.scss +28 -0
- package/scss/_checks.scss +228 -0
- package/scss/_clamp.scss +9 -3
- package/scss/_combo_row.scss +19 -0
- package/scss/_data_grid.scss +15 -7
- package/scss/_drop_down.scss +12 -55
- package/scss/_expander_row.scss +5 -50
- package/scss/_icon.scss +51 -0
- package/scss/_icons.generated.scss +6 -0
- package/scss/_inline_view_switcher.scss +34 -0
- package/scss/_labels.scss +135 -0
- package/scss/_menu_button.scss +10 -70
- package/scss/_navigation_split_view.scss +47 -8
- package/scss/_overlay_split_view.scss +117 -32
- package/scss/_popover.scss +186 -0
- package/scss/_preferences.scss +29 -0
- package/scss/_preferences_dialog.scss +17 -0
- package/scss/_progress_bar.scss +161 -0
- package/scss/_reset.scss +18 -7
- package/scss/_spinner.scss +37 -19
- package/scss/_split_button.scss +6 -50
- package/scss/_switch.scss +90 -0
- package/scss/_switch_row.scss +6 -52
- package/scss/_tab_view.scss +63 -0
- package/scss/_theme.scss +10 -0
- package/scss/_toolbar_view.scss +74 -0
- package/scss/_variables.scss +34 -0
- package/scss/_view_switcher.scss +40 -0
- package/scss/_view_switcher_bar.scss +37 -0
- package/scss/_wrap_box.scss +65 -6
- package/scss/adwaita-skin.scss +23 -0
- package/src/adw-about-dialog.spec.ts +350 -0
- package/src/adw-action-rows.spec.ts +342 -0
- package/src/adw-avatar.spec.ts +176 -0
- package/src/adw-banner.spec.ts +152 -0
- package/src/adw-button-content.spec.ts +207 -0
- package/src/adw-button-row.spec.ts +12 -6
- package/src/adw-carousel.spec.ts +389 -0
- package/src/adw-checks.spec.ts +368 -0
- package/src/adw-drop-down.spec.ts +123 -0
- package/src/adw-header-bar.spec.ts +83 -0
- package/src/adw-icon.spec.ts +167 -0
- package/src/adw-progress-bar.spec.ts +173 -0
- package/src/adw-row-state.spec.ts +49 -0
- package/src/adw-sidebar.spec.ts +332 -0
- package/src/adw-switch.spec.ts +242 -0
- package/src/adw-tab-view.spec.ts +12 -3
- package/src/adw-view-switcher-bar.spec.ts +61 -13
- package/src/adw-wrap-box.spec.ts +341 -0
- package/src/bottom-sheet.spec.ts +325 -0
- package/src/breakpoints.spec.ts +147 -0
- package/src/breakpoints.ts +67 -0
- package/src/chrome.spec.ts +400 -0
- package/src/elements/adw-about-dialog.ts +223 -163
- package/src/elements/adw-action-row.ts +131 -11
- package/src/elements/adw-avatar.ts +48 -54
- package/src/elements/adw-banner.ts +46 -10
- package/src/elements/adw-bottom-sheet.ts +145 -60
- package/src/elements/adw-button-content.ts +107 -33
- package/src/elements/adw-button-row.ts +61 -37
- package/src/elements/adw-button.ts +5 -7
- package/src/elements/adw-carousel.ts +467 -105
- package/src/elements/adw-checks.ts +299 -0
- package/src/elements/adw-clamp.ts +80 -10
- package/src/elements/adw-combo-row.ts +32 -7
- package/src/elements/adw-data-grid.ts +43 -91
- package/src/elements/adw-drop-down.ts +117 -128
- package/src/elements/adw-entry-row.ts +404 -36
- package/src/elements/adw-expander-row.ts +61 -29
- package/src/elements/adw-header-bar.ts +31 -6
- package/src/elements/adw-icon.ts +148 -0
- package/src/elements/adw-inline-view-switcher.ts +325 -131
- package/src/elements/adw-menu-button.ts +93 -70
- package/src/elements/adw-navigation-split-view.ts +220 -14
- package/src/elements/adw-navigation-view.ts +286 -148
- package/src/elements/adw-overlay-split-view.ts +536 -28
- package/src/elements/adw-password-entry-row.ts +111 -89
- package/src/elements/adw-popover.ts +283 -0
- package/src/elements/adw-preferences-dialog.ts +120 -21
- package/src/elements/adw-preferences-group.ts +128 -22
- package/src/elements/adw-progress-bar.ts +189 -0
- package/src/elements/adw-sidebar.ts +301 -142
- package/src/elements/adw-spin-row.ts +11 -5
- package/src/elements/adw-spinner.ts +168 -9
- package/src/elements/adw-split-button.ts +371 -92
- package/src/elements/adw-status-page.ts +9 -9
- package/src/elements/adw-switch-row.ts +100 -30
- package/src/elements/adw-switch.ts +129 -0
- package/src/elements/adw-tab-view.ts +700 -129
- package/src/elements/adw-toggle-group.ts +5 -7
- package/src/elements/adw-toolbar-view.ts +84 -21
- package/src/elements/adw-view-stack.ts +183 -85
- package/src/elements/adw-view-switcher-bar.ts +171 -81
- package/src/elements/adw-view-switcher.ts +328 -117
- package/src/elements/adw-window-title.ts +44 -11
- package/src/elements/adw-wrap-box.ts +329 -78
- package/src/elements/view-switcher-dom.ts +108 -0
- package/src/entry-rows.spec.ts +391 -0
- package/src/index.ts +23 -0
- package/src/navigation-view.spec.ts +435 -0
- package/src/preferences-search.ts +135 -0
- package/src/preferences.spec.ts +250 -0
- package/src/source-view/adw-source-view.ts +2 -5
- package/src/split-button.spec.ts +470 -0
- package/src/split-views.spec.ts +893 -0
- package/src/styles.generated.ts +1 -1
- package/src/tab-view.spec.ts +451 -0
- package/src/test.browser.mts +49 -0
- package/src/view-stack.spec.ts +272 -0
- package/src/view-switcher.spec.ts +665 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
// DOM-level conformance tests for <adw-split-button>, driven by the SAME vectors
|
|
2
|
+
// the NativeScript renderer asserts against (`@gjsify/adwaita-core/conformance`).
|
|
3
|
+
//
|
|
4
|
+
// The two renderers used to carry independent split-button logic, and the copies
|
|
5
|
+
// had drifted apart AND away from libadwaita: this element rendered the label AND
|
|
6
|
+
// the icon at once where GTK clears whichever slot it is not using, could not
|
|
7
|
+
// clear the label at all (so the storybook's icon-only mode showed the icon next
|
|
8
|
+
// to a stale "Save"), trimmed `label=" "` away, hardcoded "Menu" as the
|
|
9
|
+
// dropdown's accessible name and left it EMPTY for `dropdown-tooltip=""`, and
|
|
10
|
+
// opened a fully styled empty popover when there was no menu at all. Nothing
|
|
11
|
+
// failed, because nothing compared them. This suite is that comparison.
|
|
12
|
+
import { describe, expect, it } from '@gjsify/unit';
|
|
13
|
+
|
|
14
|
+
import { DEFAULT_DROPDOWN_TOOLTIP } from '@gjsify/adwaita-core';
|
|
15
|
+
import {
|
|
16
|
+
POPOVER_SURFACE_VECTORS,
|
|
17
|
+
SPLIT_BUTTON_CONTENT_VECTORS,
|
|
18
|
+
SPLIT_BUTTON_DIRECTION_VECTORS,
|
|
19
|
+
SPLIT_BUTTON_MENU_PARSE_VECTORS,
|
|
20
|
+
SPLIT_BUTTON_STYLE_CLASS_VECTORS,
|
|
21
|
+
SPLIT_BUTTON_TOOLTIP_VECTORS,
|
|
22
|
+
} from '@gjsify/adwaita-core/conformance';
|
|
23
|
+
import type { SplitButtonProperty } from '@gjsify/adwaita-core';
|
|
24
|
+
import type { AdwSplitButton } from './elements/adw-split-button.js';
|
|
25
|
+
|
|
26
|
+
/** The GObject properties the element re-emits as `notify::*` CustomEvents. */
|
|
27
|
+
const NOTIFY_PROPERTIES: readonly SplitButtonProperty[] = [
|
|
28
|
+
'label',
|
|
29
|
+
'icon-name',
|
|
30
|
+
'child',
|
|
31
|
+
'use-underline',
|
|
32
|
+
'menu-model',
|
|
33
|
+
'popover',
|
|
34
|
+
'direction',
|
|
35
|
+
'dropdown-tooltip',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Mount a split button, setting attributes through `setAttribute` rather than
|
|
40
|
+
* parsed HTML — several vectors hinge on exact whitespace (`label=" "`), which
|
|
41
|
+
* an attribute value in markup is not a reliable carrier for.
|
|
42
|
+
*/
|
|
43
|
+
function mount(attributes: Record<string, string> = {}): { el: AdwSplitButton; host: HTMLElement } {
|
|
44
|
+
const host = document.createElement('div');
|
|
45
|
+
document.body.appendChild(host);
|
|
46
|
+
const el = document.createElement('adw-split-button') as AdwSplitButton;
|
|
47
|
+
for (const [name, value] of Object.entries(attributes)) el.setAttribute(name, value);
|
|
48
|
+
host.appendChild(el);
|
|
49
|
+
return { el, host };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const actionHalf = (el: AdwSplitButton): HTMLButtonElement =>
|
|
53
|
+
el.querySelector('.adw-split-button-action') as HTMLButtonElement;
|
|
54
|
+
|
|
55
|
+
const dropdownHalf = (el: AdwSplitButton): HTMLButtonElement =>
|
|
56
|
+
el.querySelector('.adw-split-button-dropdown') as HTMLButtonElement;
|
|
57
|
+
|
|
58
|
+
/** What the action half actually shows: an icon, a label, or nothing. */
|
|
59
|
+
function renderedMode(el: AdwSplitButton): 'icon' | 'label' | 'empty' {
|
|
60
|
+
const action = actionHalf(el);
|
|
61
|
+
if (action.querySelector('.adw-icon') !== null) return 'icon';
|
|
62
|
+
return (action.textContent ?? '').length > 0 ? 'label' : 'empty';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The root style classes libadwaita derives from the content. */
|
|
66
|
+
function rootStyleClasses(el: AdwSplitButton): string[] {
|
|
67
|
+
return ['image-button', 'text-button'].filter((cls) => el.classList.contains(cls));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The arrow node's mask class for a direction, mounted and read back off the DOM. */
|
|
71
|
+
function arrowClass(direction: string): string {
|
|
72
|
+
const { el, host } = mount({ direction });
|
|
73
|
+
const className = (dropdownHalf(el).querySelector('.adw-icon') as HTMLElement).className;
|
|
74
|
+
host.remove();
|
|
75
|
+
return className;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const AdwSplitButtonTest = async () => {
|
|
79
|
+
await describe('adw-split-button content (libadwaita conformance vectors)', async () => {
|
|
80
|
+
for (const vector of SPLIT_BUTTON_CONTENT_VECTORS) {
|
|
81
|
+
await it(`follows ${vector.name}`, () => {
|
|
82
|
+
const { el, host } = mount();
|
|
83
|
+
const seen: string[] = [];
|
|
84
|
+
for (const property of NOTIFY_PROPERTIES) {
|
|
85
|
+
el.addEventListener(`notify::${property}`, () => seen.push(property));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const step of vector.steps) {
|
|
89
|
+
// A DOM element has no `child` slot; clearing the content is
|
|
90
|
+
// the one child step it can express (both attributes gone).
|
|
91
|
+
if (step.op === 'child' && step.value !== null) break;
|
|
92
|
+
|
|
93
|
+
seen.length = 0;
|
|
94
|
+
if (step.op === 'label') el.setAttribute('label', step.value ?? '');
|
|
95
|
+
else if (step.op === 'icon-name') el.setAttribute('icon-name', step.value ?? '');
|
|
96
|
+
else {
|
|
97
|
+
el.removeAttribute('label');
|
|
98
|
+
el.removeAttribute('icon-name');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The element re-emits libadwaita's notify sequence verbatim,
|
|
102
|
+
// so the ORDER — cleared slots first, then the set one — is
|
|
103
|
+
// observable from the DOM.
|
|
104
|
+
expect(seen).toStrictEqual([...step.notified]);
|
|
105
|
+
expect(el.label).toBe(step.label);
|
|
106
|
+
expect(el.iconName).toBe(step.iconName);
|
|
107
|
+
// An empty label is SET but paints nothing, so it reads as an
|
|
108
|
+
// empty action half — the `label[0]` distinction, on screen.
|
|
109
|
+
const painted = step.mode === 'icon' ? 'icon' : (step.label ?? '').length > 0 ? 'label' : 'empty';
|
|
110
|
+
expect(renderedMode(el)).toBe(painted);
|
|
111
|
+
expect(rootStyleClasses(el)).toStrictEqual([...step.styleClasses]);
|
|
112
|
+
}
|
|
113
|
+
host.remove();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const { label, iconName, classes, rule } of SPLIT_BUTTON_STYLE_CLASS_VECTORS) {
|
|
118
|
+
// A split button cannot hold both slots at once, so the both-set row
|
|
119
|
+
// is a pure-function case the core suite owns.
|
|
120
|
+
if (label !== null && iconName !== null) continue;
|
|
121
|
+
|
|
122
|
+
await it(`root classes for ${JSON.stringify(label)}/${JSON.stringify(iconName)} — ${rule}`, () => {
|
|
123
|
+
const attributes: Record<string, string> = {};
|
|
124
|
+
if (label !== null) attributes.label = label;
|
|
125
|
+
if (iconName !== null) attributes['icon-name'] = iconName;
|
|
126
|
+
const { el, host } = mount(attributes);
|
|
127
|
+
expect(rootStyleClasses(el)).toStrictEqual([...classes]);
|
|
128
|
+
host.remove();
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await it('renders the icon INSTEAD of the label, never both', () => {
|
|
133
|
+
const { el, host } = mount({ label: 'Save', 'icon-name': 'document-save' });
|
|
134
|
+
expect(el.label).toBe(null);
|
|
135
|
+
expect(el.iconName).toBe('document-save');
|
|
136
|
+
expect(actionHalf(el).textContent).toBe('');
|
|
137
|
+
expect(actionHalf(el).classList.contains('icon-only')).toBe(true);
|
|
138
|
+
host.remove();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await it('drops the label when its attribute is removed (the storybook icon-only path)', () => {
|
|
142
|
+
const { el, host } = mount({ label: 'Save' });
|
|
143
|
+
el.setAttribute('icon-name', 'document-save');
|
|
144
|
+
el.removeAttribute('label');
|
|
145
|
+
expect(el.label).toBe(null);
|
|
146
|
+
expect(actionHalf(el).textContent).toBe('');
|
|
147
|
+
host.remove();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await it('falls back to the sibling attribute when one is removed', () => {
|
|
151
|
+
const { el, host } = mount({ label: 'Save', 'icon-name': 'document-save' });
|
|
152
|
+
el.removeAttribute('icon-name');
|
|
153
|
+
expect(el.iconName).toBe(null);
|
|
154
|
+
expect(el.label).toBe('Save');
|
|
155
|
+
expect(actionHalf(el).textContent).toBe('Save');
|
|
156
|
+
host.remove();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await it('never trims the label — two spaces render as two spaces', () => {
|
|
160
|
+
const { el, host } = mount({ label: ' ' });
|
|
161
|
+
expect(el.label).toBe(' ');
|
|
162
|
+
expect(actionHalf(el).textContent).toBe(' ');
|
|
163
|
+
expect(rootStyleClasses(el)).toStrictEqual(['text-button']);
|
|
164
|
+
host.remove();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await it('keeps a stray icon name out of the class list', () => {
|
|
168
|
+
const { el, host } = mount({ 'icon-name': 'a b' });
|
|
169
|
+
const icon = actionHalf(el).querySelector('.adw-icon') as HTMLElement;
|
|
170
|
+
// `adw-icon--a b` would have added a second, unrelated `b` class.
|
|
171
|
+
expect(icon.classList.contains('b')).toBe(false);
|
|
172
|
+
expect(icon.className).toBe('adw-icon');
|
|
173
|
+
host.remove();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await it('strips the -symbolic suffix GTK names carry', () => {
|
|
177
|
+
const { el, host } = mount({ 'icon-name': 'document-save-symbolic' });
|
|
178
|
+
const icon = actionHalf(el).querySelector('.adw-icon') as HTMLElement;
|
|
179
|
+
expect(icon.classList.contains('adw-icon--document-save')).toBe(true);
|
|
180
|
+
host.remove();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await describe('adw-split-button dropdown tooltip (libadwaita conformance vectors)', async () => {
|
|
185
|
+
for (const { tooltip, text, rule } of SPLIT_BUTTON_TOOLTIP_VECTORS) {
|
|
186
|
+
await it(`${JSON.stringify(tooltip)} → ${JSON.stringify(text)} — ${rule}`, () => {
|
|
187
|
+
const { el, host } = mount({ 'dropdown-tooltip': tooltip });
|
|
188
|
+
expect(dropdownHalf(el).getAttribute('aria-label')).toBe(text);
|
|
189
|
+
expect(dropdownHalf(el).title).toBe(text);
|
|
190
|
+
host.remove();
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await it('an unset tooltip still names the dropdown (WCAG 4.1.2)', () => {
|
|
195
|
+
const { el, host } = mount();
|
|
196
|
+
expect(dropdownHalf(el).getAttribute('aria-label')).toBe(DEFAULT_DROPDOWN_TOOLTIP);
|
|
197
|
+
host.remove();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await it('clearing a set tooltip restores the default instead of blanking it', () => {
|
|
201
|
+
const { el, host } = mount({ 'dropdown-tooltip': 'Save options' });
|
|
202
|
+
expect(dropdownHalf(el).getAttribute('aria-label')).toBe('Save options');
|
|
203
|
+
el.setAttribute('dropdown-tooltip', '');
|
|
204
|
+
expect(dropdownHalf(el).getAttribute('aria-label')).toBe(DEFAULT_DROPDOWN_TOOLTIP);
|
|
205
|
+
host.remove();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await describe('adw-split-button direction (libadwaita conformance vectors)', async () => {
|
|
210
|
+
for (const { direction, rule } of SPLIT_BUTTON_DIRECTION_VECTORS) {
|
|
211
|
+
await it(`${direction} — ${rule}`, () => {
|
|
212
|
+
const { el, host } = mount({ direction });
|
|
213
|
+
expect(el.direction).toBe(direction);
|
|
214
|
+
const arrow = dropdownHalf(el).querySelector('.adw-icon') as HTMLElement;
|
|
215
|
+
expect(arrow.className.startsWith('adw-icon adw-icon--')).toBe(true);
|
|
216
|
+
if (direction === 'up') expect(arrow.style.transform).toBe('rotate(180deg)');
|
|
217
|
+
host.remove();
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
await it('`none` draws the DOWN caret, not the open-menu hamburger (_buttons.scss:621-623)', () => {
|
|
222
|
+
// The element used to key its mask table by DIRECTION and put
|
|
223
|
+
// `open-menu` on `none`, which is the PLAIN menubutton glyph
|
|
224
|
+
// (:454-456). Inside a `splitbutton` that rule is overridden, so the
|
|
225
|
+
// symbolic name now comes from `splitButtonArrowIcon` and only the
|
|
226
|
+
// glyph→mask mapping is still decided here.
|
|
227
|
+
expect(arrowClass('none')).toBe(arrowClass('down'));
|
|
228
|
+
expect(arrowClass('none').includes('adw-icon--open-menu')).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await it('ignores a direction that is not a GtkArrowType', () => {
|
|
232
|
+
const { el, host } = mount({ direction: 'sideways' });
|
|
233
|
+
expect(el.direction).toBe('down');
|
|
234
|
+
host.remove();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
await describe('adw-split-button menu', async () => {
|
|
239
|
+
for (const { json, entries, rule } of SPLIT_BUTTON_MENU_PARSE_VECTORS) {
|
|
240
|
+
await it(`parses ${JSON.stringify(json)} into ${entries.length} entries — ${rule}`, () => {
|
|
241
|
+
const { el, host } = mount(json === null ? {} : { menu: json });
|
|
242
|
+
expect([...el.menuItems]).toStrictEqual([...entries]);
|
|
243
|
+
expect(el.querySelectorAll('.adw-split-button-menu-item').length).toBe(entries.length);
|
|
244
|
+
host.remove();
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await it('a menu-less split button has an INSENSITIVE dropdown', () => {
|
|
249
|
+
const { el, host } = mount({ label: 'Save' });
|
|
250
|
+
expect(el.dropdownEnabled).toBe(false);
|
|
251
|
+
expect(dropdownHalf(el).disabled).toBe(true);
|
|
252
|
+
dropdownHalf(el).click();
|
|
253
|
+
expect(el.active).toBe(false);
|
|
254
|
+
// The popover must stay hidden — it used to open, empty and styled.
|
|
255
|
+
expect((el.querySelector('.adw-split-button-menu') as HTMLElement).hidden).toBe(true);
|
|
256
|
+
host.remove();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await it('opens and closes on the dropdown half once there is a menu', () => {
|
|
260
|
+
const { el, host } = mount({ menu: '[{"label":"Save as…","action":"app.save-as"}]' });
|
|
261
|
+
expect(el.dropdownEnabled).toBe(true);
|
|
262
|
+
expect(dropdownHalf(el).disabled).toBe(false);
|
|
263
|
+
|
|
264
|
+
const active: boolean[] = [];
|
|
265
|
+
el.addEventListener('notify::active', (event) => active.push((event as CustomEvent).detail.active));
|
|
266
|
+
|
|
267
|
+
dropdownHalf(el).click();
|
|
268
|
+
expect(el.active).toBe(true);
|
|
269
|
+
expect(dropdownHalf(el).getAttribute('aria-expanded')).toBe('true');
|
|
270
|
+
expect((el.querySelector('.adw-split-button-menu') as HTMLElement).hidden).toBe(false);
|
|
271
|
+
|
|
272
|
+
dropdownHalf(el).click();
|
|
273
|
+
expect(el.active).toBe(false);
|
|
274
|
+
expect(active).toStrictEqual([true, false]);
|
|
275
|
+
host.remove();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
await it('dispatches a duplicate label BY POSITION, with its own action', () => {
|
|
279
|
+
const { el, host } = mount({
|
|
280
|
+
menu: '[{"label":"Copy","action":"app.copy"},{"label":"Copy","action":"app.copy-special"}]',
|
|
281
|
+
});
|
|
282
|
+
const activated: Array<{ label: string; action?: string; index: number }> = [];
|
|
283
|
+
el.addEventListener('menu-activated', (event) => activated.push((event as CustomEvent).detail));
|
|
284
|
+
|
|
285
|
+
dropdownHalf(el).click();
|
|
286
|
+
const items = el.querySelectorAll('.adw-split-button-menu-item');
|
|
287
|
+
(items[1] as HTMLButtonElement).click();
|
|
288
|
+
|
|
289
|
+
expect(activated).toStrictEqual([{ label: 'Copy', action: 'app.copy-special', index: 1 }]);
|
|
290
|
+
// Activating an entry dismisses the menu.
|
|
291
|
+
expect(el.active).toBe(false);
|
|
292
|
+
host.remove();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
await it('losing its menu closes an open one', () => {
|
|
296
|
+
const { el, host } = mount({ menu: '[{"label":"Print"}]' });
|
|
297
|
+
dropdownHalf(el).click();
|
|
298
|
+
expect(el.active).toBe(true);
|
|
299
|
+
el.setAttribute('menu', '[]');
|
|
300
|
+
expect(el.active).toBe(false);
|
|
301
|
+
expect(el.dropdownEnabled).toBe(false);
|
|
302
|
+
host.remove();
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await describe('adw-split-button signals + state classes', async () => {
|
|
307
|
+
await it('emits clicked from the action half, and not while disabled', () => {
|
|
308
|
+
const { el, host } = mount({ label: 'Save' });
|
|
309
|
+
let clicks = 0;
|
|
310
|
+
el.addEventListener('clicked', () => clicks++);
|
|
311
|
+
actionHalf(el).click();
|
|
312
|
+
expect(clicks).toBe(1);
|
|
313
|
+
|
|
314
|
+
el.setAttribute('disabled', '');
|
|
315
|
+
expect(actionHalf(el).disabled).toBe(true);
|
|
316
|
+
actionHalf(el).click();
|
|
317
|
+
expect(clicks).toBe(1);
|
|
318
|
+
host.remove();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await it('marks the root checked while the menu is open (the update_state fold)', () => {
|
|
322
|
+
const { el, host } = mount({ menu: '[{"label":"Print"}]' });
|
|
323
|
+
expect(el.classList.contains('checked')).toBe(false);
|
|
324
|
+
dropdownHalf(el).click();
|
|
325
|
+
expect(el.classList.contains('checked')).toBe(true);
|
|
326
|
+
el.popdown();
|
|
327
|
+
expect(el.classList.contains('checked')).toBe(false);
|
|
328
|
+
host.remove();
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// The two behaviours this element NEVER HAD. Its hand-rolled popover carried
|
|
333
|
+
// an outside-click handler and nothing else — a menu you could open from the
|
|
334
|
+
// keyboard and then only leave with the mouse, whose items the arrow keys
|
|
335
|
+
// could not reach at all. Both arrive with `<adw-popover>`; these are the
|
|
336
|
+
// cases that prove the lift was a lift and not a move.
|
|
337
|
+
await describe('adw-split-button menu keyboard (gained with <adw-popover>)', async () => {
|
|
338
|
+
await it('Escape dismisses the menu and returns focus to the dropdown half', () => {
|
|
339
|
+
const { el, host } = mount({ menu: '[{"label":"Print"},{"label":"Export"}]' });
|
|
340
|
+
dropdownHalf(el).click();
|
|
341
|
+
expect(el.active).toBe(true);
|
|
342
|
+
|
|
343
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
344
|
+
|
|
345
|
+
expect(el.active).toBe(false);
|
|
346
|
+
// SplitButtonState stays the source of truth — a dismissal the
|
|
347
|
+
// popover owns is fed back into it, not held only in the popover.
|
|
348
|
+
expect(el.classList.contains('checked')).toBe(false);
|
|
349
|
+
expect(document.activeElement).toBe(dropdownHalf(el));
|
|
350
|
+
host.remove();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
await it('Escape reaches notify::active, so the dismissal is a real state change', () => {
|
|
354
|
+
const { el, host } = mount({ menu: '[{"label":"Print"}]' });
|
|
355
|
+
const seen: boolean[] = [];
|
|
356
|
+
el.addEventListener('notify::active', (event) => seen.push((event as CustomEvent).detail.active));
|
|
357
|
+
|
|
358
|
+
dropdownHalf(el).click();
|
|
359
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
360
|
+
|
|
361
|
+
expect(seen).toStrictEqual([true, false]);
|
|
362
|
+
host.remove();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
await it('the arrow keys walk the menu items and wrap, from the row focused on open', () => {
|
|
366
|
+
const { el, host } = mount({ menu: '[{"label":"Print"},{"label":"Export"},{"label":"Share"}]' });
|
|
367
|
+
dropdownHalf(el).click();
|
|
368
|
+
|
|
369
|
+
// Queried AFTER opening on purpose: `_renderMenu` runs on every state
|
|
370
|
+
// change and `replaceChildren()`s the rows, so nodes captured before
|
|
371
|
+
// the click are detached and `document.activeElement` can never be one
|
|
372
|
+
// of them. The popover ELEMENT survives — only its children are rebuilt.
|
|
373
|
+
const items = [...el.querySelectorAll<HTMLButtonElement>('.adw-split-button-menu-item')];
|
|
374
|
+
expect(items.length).toBe(3);
|
|
375
|
+
// Opening focuses the first row — without that there is nothing to move from.
|
|
376
|
+
expect(document.activeElement).toBe(items[0]);
|
|
377
|
+
|
|
378
|
+
const menu = el.querySelector('adw-popover') as HTMLElement;
|
|
379
|
+
const press = (key: string) => menu.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
|
380
|
+
|
|
381
|
+
press('ArrowDown');
|
|
382
|
+
expect(document.activeElement).toBe(items[1]);
|
|
383
|
+
press('ArrowDown');
|
|
384
|
+
expect(document.activeElement).toBe(items[2]);
|
|
385
|
+
press('ArrowDown');
|
|
386
|
+
expect(document.activeElement).toBe(items[0]); // wraps past the end
|
|
387
|
+
press('ArrowUp');
|
|
388
|
+
expect(document.activeElement).toBe(items[2]); // wraps past the start
|
|
389
|
+
press('End');
|
|
390
|
+
expect(document.activeElement).toBe(items[2]);
|
|
391
|
+
press('Home');
|
|
392
|
+
expect(document.activeElement).toBe(items[0]);
|
|
393
|
+
|
|
394
|
+
el.popdown();
|
|
395
|
+
host.remove();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
await it('Enter on a focused row activates it BY POSITION, exactly once', () => {
|
|
399
|
+
const { el, host } = mount({ menu: '[{"label":"Copy"},{"label":"Copy"}]' });
|
|
400
|
+
const seen: number[] = [];
|
|
401
|
+
el.addEventListener('menu-activated', (event) => seen.push((event as CustomEvent).detail.index));
|
|
402
|
+
|
|
403
|
+
dropdownHalf(el).click();
|
|
404
|
+
const menu = el.querySelector('adw-popover') as HTMLElement;
|
|
405
|
+
menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
406
|
+
menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
|
407
|
+
|
|
408
|
+
// Two entries share a label, so only the position tells them apart —
|
|
409
|
+
// and `preventDefault()` before the synthetic click keeps a focused
|
|
410
|
+
// <button> from ALSO activating natively, which would fire twice.
|
|
411
|
+
expect(seen).toStrictEqual([1]);
|
|
412
|
+
host.remove();
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// The assertion that would have CAUGHT the original bug. The core vectors pin
|
|
417
|
+
// the numbers against the vendored stylesheet; nothing pinned the numbers the
|
|
418
|
+
// browser actually paints, which is how a 9px surface under a two-layer shadow
|
|
419
|
+
// shipped here (and a 12px one under the right shadow shipped twice more).
|
|
420
|
+
// Computed style, not the SCSS text: a rule that loses the cascade reads fine
|
|
421
|
+
// in the source and renders wrong.
|
|
422
|
+
await describe('adw-split-button menu surface (libadwaita popover vectors)', async () => {
|
|
423
|
+
/** The `popover.menu` row of the shared table — what this element renders. */
|
|
424
|
+
const menuVector = POPOVER_SURFACE_VECTORS.find((vector) => vector.variant === 'menu');
|
|
425
|
+
const itemVector = POPOVER_SURFACE_VECTORS.find((vector) => vector.variant === 'menu-item');
|
|
426
|
+
|
|
427
|
+
await it('the vectors this suite drives are present', () => {
|
|
428
|
+
// A `find` that missed would make every assertion below vacuous.
|
|
429
|
+
expect(menuVector !== undefined).toBe(true);
|
|
430
|
+
expect(itemVector !== undefined).toBe(true);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
await it(`draws a ${menuVector?.borderRadius}px surface with ${menuVector?.padding}px padding and a THREE-layer shadow`, () => {
|
|
434
|
+
const { el, host } = mount({ menu: '[{"label":"Print"}]' });
|
|
435
|
+
dropdownHalf(el).click();
|
|
436
|
+
const surface = el.querySelector('adw-popover') as HTMLElement;
|
|
437
|
+
const style = getComputedStyle(surface);
|
|
438
|
+
|
|
439
|
+
expect(style.paddingTop).toBe(`${menuVector?.padding}px`);
|
|
440
|
+
expect(style.paddingLeft).toBe(`${menuVector?.padding}px`);
|
|
441
|
+
// $popover_radius = $menu_radius + 6 = 15px. This element drew 9px.
|
|
442
|
+
expect(style.borderTopLeftRadius).toBe(`${menuVector?.borderRadius}px`);
|
|
443
|
+
|
|
444
|
+
for (const geometry of menuVector?.shadow ?? []) {
|
|
445
|
+
expect(style.boxShadow.includes(geometry)).toBe(true);
|
|
446
|
+
}
|
|
447
|
+
// Every layer carries exactly one colour function, so counting them
|
|
448
|
+
// counts the layers — this element shipped two where GTK has three.
|
|
449
|
+
expect((style.boxShadow.match(/rgba?\(/g) ?? []).length).toBe(menuVector?.shadow.length);
|
|
450
|
+
|
|
451
|
+
el.popdown();
|
|
452
|
+
host.remove();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
await it(`draws menu rows at the ${itemVector?.borderRadius}px modelbutton radius`, () => {
|
|
456
|
+
const { el, host } = mount({ menu: '[{"label":"Print"}]' });
|
|
457
|
+
dropdownHalf(el).click();
|
|
458
|
+
const row = el.querySelector('.adw-split-button-menu-item') as HTMLElement;
|
|
459
|
+
const style = getComputedStyle(row);
|
|
460
|
+
|
|
461
|
+
// $menu_radius, not the `calc(var(--button-radius) - 2px)` (7px) this
|
|
462
|
+
// element and the menu button each invented separately.
|
|
463
|
+
expect(style.borderTopLeftRadius).toBe(`${itemVector?.borderRadius}px`);
|
|
464
|
+
expect(style.paddingLeft).toBe(`${itemVector?.padding}px`);
|
|
465
|
+
|
|
466
|
+
el.popdown();
|
|
467
|
+
host.remove();
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
};
|