@gjsify/adwaita-web 0.47.0 → 0.49.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/README.md +3 -3
- package/dist/adwaita-web.css +62 -0
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/elements/adw-combo-row.d.ts +56 -0
- package/lib/types/elements/adw-spin-row.d.ts +11 -1
- package/lib/types/elements/adw-split-button.d.ts +43 -3
- package/lib/types/elements/adw-tab-view.d.ts +23 -5
- package/lib/types/elements/adw-view-stack.d.ts +17 -2
- package/lib/types/elements/gtk-drop-down.d.ts +22 -7
- package/lib/types/elements/gtk-menu-button.d.ts +43 -26
- package/lib/types/elements/gtk-popover.d.ts +14 -1
- package/lib/types/elements/popover-menu.d.ts +99 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/shared-trees.spec.d.ts +2 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +6 -6
- package/scss/_popover.scss +99 -0
- package/src/adw-avatar.spec.ts +17 -0
- package/src/adw-row-state.spec.ts +335 -17
- package/src/adw-tab-view.spec.ts +8 -5
- package/src/elements/adw-avatar.ts +11 -3
- package/src/elements/adw-combo-row.ts +161 -22
- package/src/elements/adw-spin-row.ts +49 -28
- package/src/elements/adw-split-button.ts +117 -39
- package/src/elements/adw-tab-view.ts +42 -22
- package/src/elements/adw-view-stack.ts +20 -4
- package/src/elements/adw-view-switcher-bar.ts +6 -3
- package/src/elements/gtk-drop-down.ts +109 -66
- package/src/elements/gtk-menu-button.ts +112 -88
- package/src/elements/gtk-popover.ts +17 -2
- package/src/elements/popover-menu.ts +366 -0
- package/src/gtk-drop-down.spec.ts +117 -25
- package/src/gtk-image.spec.ts +3 -3
- package/src/gtk-menu-button.spec.ts +165 -11
- package/src/index.ts +14 -1
- package/src/keyboard-operable.spec.ts +170 -1
- package/src/shared-trees.spec.ts +151 -0
- package/src/split-button.spec.ts +36 -22
- package/src/styles.generated.ts +1 -1
- package/src/tab-view.spec.ts +83 -15
- package/src/test.browser.mts +2 -0
- package/src/view-stack.spec.ts +5 -3
|
@@ -1,20 +1,29 @@
|
|
|
1
|
-
// DOM-level tests for <gtk-menu-button>'s `menu` attribute.
|
|
1
|
+
// DOM-level tests for <gtk-menu-button>'s `menu-model` attribute.
|
|
2
2
|
//
|
|
3
3
|
// The element parsed the JSON itself, and the copy was weaker than the core parser
|
|
4
4
|
// the split button next to it already used: it kept `id` and `icon` whatever their
|
|
5
5
|
// runtime type, so author markup could put a number in a field typed `string` — and
|
|
6
|
-
// `id` is exactly what the activation event reports. These drive the core's parse
|
|
7
|
-
// vectors against the real element.
|
|
6
|
+
// `id` is exactly what the activation event reports. These drive the core's parse and
|
|
7
|
+
// flatten vectors against the real element.
|
|
8
8
|
|
|
9
9
|
import { describe, expect, it } from '@gjsify/unit';
|
|
10
|
-
import {
|
|
10
|
+
import { MENU_ITEM_STATE_VECTORS, MENU_PARSE_VECTORS, MENU_REFUSAL_VECTORS } from '@gjsify/adwaita-core/conformance';
|
|
11
|
+
import { flattenMenu } from '@gjsify/adwaita-core';
|
|
12
|
+
import type { AdwMenuInput } from '@gjsify/adwaita-core';
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
interface MenuButtonElement extends HTMLElement {
|
|
15
|
+
menuModel: AdwMenuInput;
|
|
16
|
+
actions: Record<string, { enabled?: boolean; state?: string }> | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Mount a menu button carrying `menu-model` as JSON, and return its rendered row labels. */
|
|
13
20
|
function mountWithMenu(json: string): { el: HTMLElement; labels: string[] } {
|
|
14
21
|
const el = document.createElement('gtk-menu-button');
|
|
15
|
-
el.setAttribute('menu', json);
|
|
22
|
+
el.setAttribute('menu-model', json);
|
|
16
23
|
document.body.appendChild(el);
|
|
17
|
-
const labels = Array.from(el.querySelectorAll('.adw-popover
|
|
24
|
+
const labels = Array.from(el.querySelectorAll('.adw-popover-item')).map(
|
|
25
|
+
(b) => b.querySelector('.adw-menu-button-item-label')?.textContent ?? '',
|
|
26
|
+
);
|
|
18
27
|
return { el, labels };
|
|
19
28
|
}
|
|
20
29
|
|
|
@@ -24,24 +33,26 @@ function unmountAll(): void {
|
|
|
24
33
|
|
|
25
34
|
export const GtkMenuButtonTest = async () => {
|
|
26
35
|
await describe('<gtk-menu-button> menu attribute', async () => {
|
|
27
|
-
for (const { json,
|
|
36
|
+
for (const { json, model, rule } of MENU_PARSE_VECTORS) {
|
|
28
37
|
await it(`${rule}`, () => {
|
|
29
38
|
const { labels } = mountWithMenu(json ?? '');
|
|
30
|
-
|
|
39
|
+
// The rendered rows are what `flattenMenu` says: a section is inlined,
|
|
40
|
+
// a submenu is ONE row, and both keep their own label.
|
|
41
|
+
expect(labels).toStrictEqual(flattenMenu(model).map((row) => row.node.label));
|
|
31
42
|
unmountAll();
|
|
32
43
|
});
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
await it('drops a non-string id instead of leaking a number into the event', () => {
|
|
36
47
|
const el = document.createElement('gtk-menu-button');
|
|
37
|
-
el.setAttribute('menu', '[{"label":"Open","id":7}]');
|
|
48
|
+
el.setAttribute('menu-model', '[{"label":"Open","id":7}]');
|
|
38
49
|
document.body.appendChild(el);
|
|
39
50
|
|
|
40
51
|
let seen: unknown = 'not fired';
|
|
41
52
|
el.addEventListener('menu-item-activated', (e) => {
|
|
42
53
|
seen = (e as CustomEvent<{ id: string }>).detail.id;
|
|
43
54
|
});
|
|
44
|
-
(el.querySelector('.adw-popover
|
|
55
|
+
(el.querySelector('.adw-popover-item') as HTMLElement | null)?.click();
|
|
45
56
|
|
|
46
57
|
// `id` fell back to the label, which is the documented default — rather
|
|
47
58
|
// than arriving as the number 7 in a field every consumer reads as text.
|
|
@@ -49,4 +60,147 @@ export const GtkMenuButtonTest = async () => {
|
|
|
49
60
|
unmountAll();
|
|
50
61
|
});
|
|
51
62
|
});
|
|
63
|
+
|
|
64
|
+
await describe('<gtk-menu-button> keeps its menu replaceable while live', async () => {
|
|
65
|
+
// The property `check-adwaita-collection-reactivity.mjs` exists to protect, and
|
|
66
|
+
// the one it cannot see here: this widget keeps its model in its own field rather
|
|
67
|
+
// than in a core state class, and parses its attribute through a MODULE function,
|
|
68
|
+
// so every rule in that reader is silent on it. A spec stands where the reader
|
|
69
|
+
// cannot.
|
|
70
|
+
await it('a menuModel assigned AFTER connect reaches the rows', () => {
|
|
71
|
+
const el = document.createElement('gtk-menu-button') as MenuButtonElement;
|
|
72
|
+
document.body.appendChild(el);
|
|
73
|
+
expect(el.querySelectorAll('.adw-popover-item').length).toBe(0);
|
|
74
|
+
el.menuModel = ['New', 'Open'];
|
|
75
|
+
expect([...el.querySelectorAll('.adw-menu-button-item-label')].map((n) => n.textContent)).toStrictEqual([
|
|
76
|
+
'New',
|
|
77
|
+
'Open',
|
|
78
|
+
]);
|
|
79
|
+
// REPLACED, not merely seeded — the failure mode is a second assignment
|
|
80
|
+
// writing an expando while the first happened to work at connect.
|
|
81
|
+
el.menuModel = [{ label: 'Only', action: 'app.only' }];
|
|
82
|
+
expect([...el.querySelectorAll('.adw-menu-button-item-label')].map((n) => n.textContent)).toStrictEqual([
|
|
83
|
+
'Only',
|
|
84
|
+
]);
|
|
85
|
+
unmountAll();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await it('a menu-model ATTRIBUTE set after connect reaches them too', () => {
|
|
89
|
+
const el = document.createElement('gtk-menu-button') as MenuButtonElement;
|
|
90
|
+
document.body.appendChild(el);
|
|
91
|
+
el.setAttribute('menu-model', '[{"label":"Later"}]');
|
|
92
|
+
expect([...el.querySelectorAll('.adw-menu-button-item-label')].map((n) => n.textContent)).toStrictEqual([
|
|
93
|
+
'Later',
|
|
94
|
+
]);
|
|
95
|
+
unmountAll();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await describe('<gtk-menu-button> renders the whole model (ADR 0042)', async () => {
|
|
100
|
+
await it('draws a section heading, a separator and a submenu page', () => {
|
|
101
|
+
const el = document.createElement('gtk-menu-button') as MenuButtonElement;
|
|
102
|
+
document.body.appendChild(el);
|
|
103
|
+
el.menuModel = [
|
|
104
|
+
{ label: 'New' },
|
|
105
|
+
{ section: [{ label: 'Cut' }, { label: 'Copy' }], label: 'Edit' },
|
|
106
|
+
{ label: 'More', submenu: [{ label: 'Rename' }, { label: 'Duplicate' }] },
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
// TWO: a section is separated from what comes before it AND from what comes
|
|
110
|
+
// after, which is what `GtkPopoverMenu` draws for the same model.
|
|
111
|
+
expect(el.querySelectorAll('.adw-popover-separator').length).toBe(2);
|
|
112
|
+
expect(el.querySelector('.adw-popover-section-title')?.textContent).toBe('Edit');
|
|
113
|
+
const rows = [...el.querySelectorAll('.adw-popover-item')] as HTMLButtonElement[];
|
|
114
|
+
expect(rows.length).toBe(4);
|
|
115
|
+
// The submenu is a row that OPENS, not a row that acts.
|
|
116
|
+
expect(rows[3].getAttribute('aria-haspopup')).toBe('menu');
|
|
117
|
+
|
|
118
|
+
rows[3].click();
|
|
119
|
+
const page = [...el.querySelectorAll('.adw-popover-item')] as HTMLButtonElement[];
|
|
120
|
+
// A back row plus the submenu's two items — the submenu's contents were
|
|
121
|
+
// never inlined into the parent page.
|
|
122
|
+
expect(page.length).toBe(3);
|
|
123
|
+
expect(page[0].classList.contains('adw-popover-back')).toBe(true);
|
|
124
|
+
expect(page.map((r) => r.querySelector('.adw-menu-button-item-label')?.textContent)).toStrictEqual([
|
|
125
|
+
'More',
|
|
126
|
+
'Rename',
|
|
127
|
+
'Duplicate',
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
page[0].click();
|
|
131
|
+
expect(el.querySelectorAll('.adw-popover-item').length).toBe(4);
|
|
132
|
+
unmountAll();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
for (const { item, actions, state, rule } of MENU_ITEM_STATE_VECTORS) {
|
|
136
|
+
await it(`action state reaches the DOM — ${rule}`, () => {
|
|
137
|
+
const el = document.createElement('gtk-menu-button') as MenuButtonElement;
|
|
138
|
+
document.body.appendChild(el);
|
|
139
|
+
el.actions = { ...actions };
|
|
140
|
+
el.menuModel = [item];
|
|
141
|
+
const row = el.querySelector('.adw-popover-item') as HTMLButtonElement;
|
|
142
|
+
expect(row.hidden).toBe(!state.visible);
|
|
143
|
+
expect(row.disabled).toBe(!state.sensitive);
|
|
144
|
+
const expectedRole =
|
|
145
|
+
state.role === 'normal'
|
|
146
|
+
? 'menuitem'
|
|
147
|
+
: state.role === 'check'
|
|
148
|
+
? 'menuitemcheckbox'
|
|
149
|
+
: 'menuitemradio';
|
|
150
|
+
expect(row.getAttribute('role')).toBe(expectedRole);
|
|
151
|
+
expect(row.getAttribute('aria-checked')).toBe(state.role === 'normal' ? null : String(state.toggled));
|
|
152
|
+
unmountAll();
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await it('the ATTRIBUTE path refuses too — markup is where a blank row actually ships', () => {
|
|
157
|
+
// The first cut asserted only the property setter, so a `custom` item written
|
|
158
|
+
// in markup drew exactly the row the refusal exists to prevent.
|
|
159
|
+
//
|
|
160
|
+
// It does NOT throw, and that is measured rather than chosen: a throw from
|
|
161
|
+
// `connectedCallback` is not delivered to whoever appended the element, it is
|
|
162
|
+
// reported as an uncaught PAGE error — it broke `adwaita-upgrade-order`,
|
|
163
|
+
// which counts those. So the menu is refused whole (a menu-less dropdown is
|
|
164
|
+
// insensitive, which is visible) and the reason goes to `console.error`.
|
|
165
|
+
const errors: unknown[] = [];
|
|
166
|
+
const real = console.error;
|
|
167
|
+
console.error = (...args: unknown[]) => errors.push(args.join(' '));
|
|
168
|
+
try {
|
|
169
|
+
const el = document.createElement('gtk-menu-button');
|
|
170
|
+
el.setAttribute('menu-model', '[{"label":"Zoom","custom":"zoom-control"},{"label":"Quit"}]');
|
|
171
|
+
document.body.appendChild(el);
|
|
172
|
+
expect(el.querySelectorAll('.adw-popover-item').length).toBe(0);
|
|
173
|
+
expect(errors.join('\n')).toContain('zoom-control');
|
|
174
|
+
} finally {
|
|
175
|
+
console.error = real;
|
|
176
|
+
}
|
|
177
|
+
unmountAll();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await it('a malformed attribute is NOT a refusal — a typo may not stop the upgrade', () => {
|
|
181
|
+
// The other half of the same rule: `parseMenuModel` stays total, so this
|
|
182
|
+
// element upgrades with no menu instead of throwing.
|
|
183
|
+
const { labels } = mountWithMenu('not json at all');
|
|
184
|
+
expect(labels).toStrictEqual([]);
|
|
185
|
+
unmountAll();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
for (const { model, paths, rule } of MENU_REFUSAL_VECTORS) {
|
|
189
|
+
await it(`refusal — ${rule}`, () => {
|
|
190
|
+
const el = document.createElement('gtk-menu-button') as MenuButtonElement;
|
|
191
|
+
document.body.appendChild(el);
|
|
192
|
+
if (paths.length === 0) {
|
|
193
|
+
el.menuModel = model;
|
|
194
|
+
expect(el.querySelectorAll('.adw-popover-item').length).toBe(flattenMenu(model).length);
|
|
195
|
+
} else {
|
|
196
|
+
// LOUD, at the assignment: a `custom` item names an application
|
|
197
|
+
// widget, and a surface that ignored it would draw a blank row.
|
|
198
|
+
expect(() => {
|
|
199
|
+
el.menuModel = model;
|
|
200
|
+
}).toThrow('adwaita-web');
|
|
201
|
+
}
|
|
202
|
+
unmountAll();
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
});
|
|
52
206
|
};
|
package/src/index.ts
CHANGED
|
@@ -134,6 +134,19 @@ export { AdwAlertResponse } from './elements/adw-alert-dialog.js';
|
|
|
134
134
|
// return. Not widgets, so clause 2 has nothing to say about them.
|
|
135
135
|
export type { GtkPopoverAlign, GtkPopoverPosition, GtkPopoverRole } from './elements/gtk-popover.js';
|
|
136
136
|
export type { AdwViewStackPageInfo } from './elements/adw-view-stack.js';
|
|
137
|
-
|
|
137
|
+
// The portable menu model (ADR 0042) is `@gjsify/adwaita-core`'s, re-exported so a
|
|
138
|
+
// consumer of THIS package reaches the menu types without a second dependency — one
|
|
139
|
+
// definition, two doors, never two declarations.
|
|
140
|
+
export type {
|
|
141
|
+
AdwMenuActions,
|
|
142
|
+
AdwMenuEntryInput,
|
|
143
|
+
AdwMenuInput,
|
|
144
|
+
AdwMenuItem,
|
|
145
|
+
AdwMenuModel,
|
|
146
|
+
AdwMenuNode,
|
|
147
|
+
AdwMenuPath,
|
|
148
|
+
AdwMenuSection,
|
|
149
|
+
AdwMenuSubmenu,
|
|
150
|
+
} from '@gjsify/adwaita-core';
|
|
138
151
|
export type { GtkDropDownOption } from './elements/gtk-drop-down.js';
|
|
139
152
|
export type { AdwDialogPresentationMode } from './elements/adw-dialog.js';
|
|
@@ -685,6 +685,175 @@ export const AdwKeyboardOperableTest = async () => {
|
|
|
685
685
|
});
|
|
686
686
|
});
|
|
687
687
|
|
|
688
|
+
await describe('a portable menu is traversable, page by page (ADR 0042)', async () => {
|
|
689
|
+
// The roving tabindex on a menu row moved into `PopoverMenuView` when the two
|
|
690
|
+
// menu buttons started sharing one popup, and it took its keyboard obligation
|
|
691
|
+
// with it. `check-adwaita-keyboard-contract.mjs` holds that a listener EXISTS;
|
|
692
|
+
// this holds that the keys move focus — the half a static reader cannot have.
|
|
693
|
+
const open = (): { el: HTMLElement; rows: () => HTMLButtonElement[] } => {
|
|
694
|
+
const el = document.createElement('gtk-menu-button') as HTMLElement & {
|
|
695
|
+
menuModel: unknown;
|
|
696
|
+
actions: unknown;
|
|
697
|
+
};
|
|
698
|
+
document.body.appendChild(el);
|
|
699
|
+
el.actions = { 'app.export': { enabled: false }, 'app.new': {} };
|
|
700
|
+
el.menuModel = [
|
|
701
|
+
{ label: 'New', action: 'app.new' },
|
|
702
|
+
{ section: [{ label: 'Export', action: 'app.export' }, { label: 'Print' }] },
|
|
703
|
+
{ label: 'More', submenu: [{ label: 'Rename' }, { label: 'Duplicate' }] },
|
|
704
|
+
];
|
|
705
|
+
(el.querySelector('.adw-menu-button-button') as HTMLElement).click();
|
|
706
|
+
return {
|
|
707
|
+
el,
|
|
708
|
+
rows: () => [...el.querySelectorAll<HTMLButtonElement>('.adw-popover-item')],
|
|
709
|
+
};
|
|
710
|
+
};
|
|
711
|
+
const labelOf = (el: Element | null) =>
|
|
712
|
+
el?.querySelector('.adw-menu-button-item-label')?.textContent ?? el?.localName ?? 'none';
|
|
713
|
+
|
|
714
|
+
await it('the arrow keys walk the rows and wrap, and Home/End jump', () => {
|
|
715
|
+
const { el, rows } = open();
|
|
716
|
+
const items = rows();
|
|
717
|
+
// Four ROWS, but only three are navigable: `app.export` is disabled by the
|
|
718
|
+
// action group, and a disabled button cannot take focus.
|
|
719
|
+
expect(items.length).toBe(4);
|
|
720
|
+
expect(items[1].disabled).toBe(true);
|
|
721
|
+
|
|
722
|
+
items[0].focus();
|
|
723
|
+
press(items[0], 'ArrowDown');
|
|
724
|
+
// Straight past the disabled row — an arrow that lands on it is a dead press.
|
|
725
|
+
expect(labelOf(document.activeElement)).toBe('Print');
|
|
726
|
+
press(document.activeElement as HTMLElement, 'ArrowDown');
|
|
727
|
+
expect(labelOf(document.activeElement)).toBe('More');
|
|
728
|
+
// A menu popover WRAPS, unlike the tab lists: `resolvePopoverKey` is modular.
|
|
729
|
+
press(document.activeElement as HTMLElement, 'ArrowDown');
|
|
730
|
+
expect(labelOf(document.activeElement)).toBe('New');
|
|
731
|
+
|
|
732
|
+
press(document.activeElement as HTMLElement, 'End');
|
|
733
|
+
expect(labelOf(document.activeElement)).toBe('More');
|
|
734
|
+
press(document.activeElement as HTMLElement, 'Home');
|
|
735
|
+
expect(labelOf(document.activeElement)).toBe('New');
|
|
736
|
+
el.remove();
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
await it('neither a section heading nor a separator is a row', () => {
|
|
740
|
+
const { el } = open();
|
|
741
|
+
// Both are `<div>`s without `.adw-popover-item`, so the walk cannot land on
|
|
742
|
+
// them — asserted rather than assumed, because the class is what decides it.
|
|
743
|
+
expect(el.querySelectorAll('.adw-popover-separator').length).toBe(2);
|
|
744
|
+
for (const node of el.querySelectorAll('.adw-popover-separator, .adw-popover-title')) {
|
|
745
|
+
expect(node.classList.contains('adw-popover-item')).toBe(false);
|
|
746
|
+
}
|
|
747
|
+
el.remove();
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
await it('ArrowRight opens a submenu and ArrowLeft leaves it — the row answers, not the surface', () => {
|
|
751
|
+
const { el, rows } = open();
|
|
752
|
+
const opener = rows()[3];
|
|
753
|
+
expect(labelOf(opener)).toBe('More');
|
|
754
|
+
|
|
755
|
+
opener.focus();
|
|
756
|
+
// `gtk_model_button_focus`: GTK_DIR_RIGHT on a row with a `menu-name`
|
|
757
|
+
// (gtkmodelbutton.c:1189-1195).
|
|
758
|
+
const right = press(opener, 'ArrowRight');
|
|
759
|
+
expect(right.defaultPrevented).toBe(true);
|
|
760
|
+
expect(rows().map(labelOf)).toStrictEqual(['More', 'Rename', 'Duplicate']);
|
|
761
|
+
// Focus lands on the first ITEM of the page, past the back row.
|
|
762
|
+
expect(labelOf(document.activeElement)).toBe('Rename');
|
|
763
|
+
|
|
764
|
+
// GTK_DIR_LEFT answers only on the TITLE row (:1182-1188), so from the
|
|
765
|
+
// middle of the page it does nothing — deliberately the same here.
|
|
766
|
+
const inert = press(document.activeElement as HTMLElement, 'ArrowLeft');
|
|
767
|
+
expect(inert.defaultPrevented).toBe(false);
|
|
768
|
+
expect(labelOf(document.activeElement)).toBe('Rename');
|
|
769
|
+
|
|
770
|
+
const back = rows()[0];
|
|
771
|
+
back.focus();
|
|
772
|
+
const left = press(back, 'ArrowLeft');
|
|
773
|
+
expect(left.defaultPrevented).toBe(true);
|
|
774
|
+
expect(rows().map(labelOf)).toStrictEqual(['New', 'Export', 'Print', 'More']);
|
|
775
|
+
el.remove();
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
await it('a page change never strands focus outside the popup, whatever is disabled', () => {
|
|
779
|
+
// K1. Both page changes focused a HARD-CODED index of `_rows`, which includes
|
|
780
|
+
// rows no key can reach — so entering a submenu whose first item is disabled
|
|
781
|
+
// put `document.activeElement` on <body>, OUTSIDE an open popover: Tab then
|
|
782
|
+
// walked to the control behind it and every arrow was dead, because both
|
|
783
|
+
// keydown listeners are element-scoped. The two ELEMENTS already filtered on
|
|
784
|
+
// their open path; the two page changes did not, which is two of four.
|
|
785
|
+
const el = document.createElement('gtk-menu-button') as HTMLElement & {
|
|
786
|
+
menuModel: unknown;
|
|
787
|
+
actions: unknown;
|
|
788
|
+
};
|
|
789
|
+
document.body.appendChild(el);
|
|
790
|
+
el.actions = { 'app.off': { enabled: false } };
|
|
791
|
+
el.menuModel = [
|
|
792
|
+
{ label: 'Dim', action: 'app.off' },
|
|
793
|
+
{ label: 'Live' },
|
|
794
|
+
{
|
|
795
|
+
label: 'More',
|
|
796
|
+
submenu: [{ label: 'SubDim', action: 'app.off' }, { label: 'SubLive' }],
|
|
797
|
+
},
|
|
798
|
+
];
|
|
799
|
+
(el.querySelector('.adw-menu-button-button') as HTMLElement).click();
|
|
800
|
+
const rows = () => [...el.querySelectorAll<HTMLButtonElement>('.adw-popover-item')];
|
|
801
|
+
const label = () =>
|
|
802
|
+
document.activeElement?.querySelector('.adw-menu-button-item-label')?.textContent ??
|
|
803
|
+
document.activeElement?.localName ??
|
|
804
|
+
'none';
|
|
805
|
+
const inside = () => el.contains(document.activeElement);
|
|
806
|
+
|
|
807
|
+
// ENTERING: past the back row, counted in REACHABLE rows — not index 1.
|
|
808
|
+
const opener = rows()[2] as HTMLButtonElement;
|
|
809
|
+
opener.focus();
|
|
810
|
+
press(opener, 'ArrowRight');
|
|
811
|
+
expect(inside()).toBe(true);
|
|
812
|
+
expect(label()).toBe('SubLive');
|
|
813
|
+
|
|
814
|
+
// LEAVING: the parent's first row is disabled too, so index 0 would strand.
|
|
815
|
+
const back = rows()[0] as HTMLButtonElement;
|
|
816
|
+
back.focus();
|
|
817
|
+
press(back, 'ArrowLeft');
|
|
818
|
+
expect(inside()).toBe(true);
|
|
819
|
+
expect(label()).toBe('Live');
|
|
820
|
+
el.remove();
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
await it('a submenu with NOTHING reachable focuses its back row, not the page', () => {
|
|
824
|
+
const el = document.createElement('gtk-menu-button') as HTMLElement & {
|
|
825
|
+
menuModel: unknown;
|
|
826
|
+
actions: unknown;
|
|
827
|
+
};
|
|
828
|
+
document.body.appendChild(el);
|
|
829
|
+
el.actions = { 'app.off': { enabled: false } };
|
|
830
|
+
el.menuModel = [{ label: 'More', submenu: [{ label: 'Only', action: 'app.off' }] }];
|
|
831
|
+
(el.querySelector('.adw-menu-button-button') as HTMLElement).click();
|
|
832
|
+
const opener = el.querySelector('.adw-popover-item') as HTMLButtonElement;
|
|
833
|
+
opener.focus();
|
|
834
|
+
press(opener, 'ArrowRight');
|
|
835
|
+
// The back row is always reachable, so the reader can still leave.
|
|
836
|
+
expect(el.contains(document.activeElement)).toBe(true);
|
|
837
|
+
expect(document.activeElement?.classList.contains('adw-popover-back')).toBe(true);
|
|
838
|
+
el.remove();
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
await it('Enter activates the focused row, and reports its path', () => {
|
|
842
|
+
const { el, rows } = open();
|
|
843
|
+
const seen: number[][] = [];
|
|
844
|
+
el.addEventListener('menu-item-activated', (event) => {
|
|
845
|
+
seen.push((event as CustomEvent<{ path: number[] }>).detail.path);
|
|
846
|
+
});
|
|
847
|
+
const print = rows()[2];
|
|
848
|
+
print.focus();
|
|
849
|
+
press(print, 'Enter');
|
|
850
|
+
// Inside a section, so only a PATH names it — the flat index would be 2 here
|
|
851
|
+
// and 1 in the model.
|
|
852
|
+
expect(seen).toStrictEqual([[1, 1]]);
|
|
853
|
+
el.remove();
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
|
|
688
857
|
await describe('the row family is a tab stop where libadwaita makes one', async () => {
|
|
689
858
|
const mount = (markup: string) => {
|
|
690
859
|
const host = document.createElement('div');
|
|
@@ -728,7 +897,7 @@ export const AdwKeyboardOperableTest = async () => {
|
|
|
728
897
|
await it('activates on Enter and Space, and lets a child keep its own keys', () => {
|
|
729
898
|
const host = mount(
|
|
730
899
|
'<adw-action-row id="k-keys" title="Keys" activatable></adw-action-row>' +
|
|
731
|
-
'<adw-combo-row id="k-combo" title="Combo"
|
|
900
|
+
'<adw-combo-row id="k-combo" title="Combo" model=\'["a","b"]\'></adw-combo-row>',
|
|
732
901
|
);
|
|
733
902
|
const row = document.getElementById('k-keys') as HTMLElement;
|
|
734
903
|
let activated = 0;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// ONE AUTHORED TREE, BUILT BY THIS RENDERER — ADR 0051, and the renderer ADR 0027 § 9
|
|
2
|
+
// names by name: "the same authored tree, rendered through this host and through
|
|
3
|
+
// `adwaita-web`, satisfies the same `@gjsify/adwaita-core/conformance` vectors with no
|
|
4
|
+
// per-surface markup branch".
|
|
5
|
+
//
|
|
6
|
+
// The sibling driver is `packages/framework/gtk-host/src/shared-trees.spec.ts`. One corpus
|
|
7
|
+
// — `scripts/adwaita-gallery-shared-trees.mjs`, read here and not transcribed — one set of
|
|
8
|
+
// expectations, a driver per renderer: ADR 0030's shape one level up, so a green-here /
|
|
9
|
+
// red-there diff is attributable to the RENDERER and not to two suites disagreeing about
|
|
10
|
+
// what they test.
|
|
11
|
+
//
|
|
12
|
+
// TWO DECLARED TRANSFORMS ON THE WAY IN, and no third. `hostTagOf` turns the authored GIR
|
|
13
|
+
// class name into the element name, which is the same case rule `gtk-host` stamps its tags
|
|
14
|
+
// with; and an authored property name becomes its kebab-case ATTRIBUTE, because this
|
|
15
|
+
// renderer's door is markup. Both are total functions over the corpus with no tag list and
|
|
16
|
+
// no per-block case — a `switch` on a widget name here would be the per-surface branch
|
|
17
|
+
// § 9 forbids, and the reason the corpus admits a block only when it needs no alias.
|
|
18
|
+
//
|
|
19
|
+
// The readers below go through the REAL DOM the element rendered, never a state object of
|
|
20
|
+
// its own: an element asserting against its own bookkeeping agrees with itself while the
|
|
21
|
+
// page is wrong.
|
|
22
|
+
//
|
|
23
|
+
// What is NOT here is everything that is not about this renderer — which tables the corpus
|
|
24
|
+
// reaches, which block is declared to reach none, whether an expectation's address exists.
|
|
25
|
+
// Those are facts about the CORPUS and are asserted once, in `adwaita-core`'s own suite,
|
|
26
|
+
// which runs on Node as well as in a browser.
|
|
27
|
+
|
|
28
|
+
import { describe, expect, it } from '@gjsify/unit';
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
authoredTags,
|
|
32
|
+
sharedTreeExpectations,
|
|
33
|
+
subjectIndexOf,
|
|
34
|
+
type SharedTreeExpectation,
|
|
35
|
+
type SharedTreeNode,
|
|
36
|
+
} from '@gjsify/adwaita-core/conformance';
|
|
37
|
+
|
|
38
|
+
// `attributeOf` — `buttonLabel` -> `button-label` — comes from the corpus rather than
|
|
39
|
+
// being spelled again here, because arm 13 of `check-generated-website-data.mjs` reads
|
|
40
|
+
// gallery fences back through the SAME rule: two copies would let this driver and that
|
|
41
|
+
// check agree with each other while the fence a reader copies means something else.
|
|
42
|
+
import {
|
|
43
|
+
ADWAITA_GALLERY_SHARED_TREES,
|
|
44
|
+
attributeOf,
|
|
45
|
+
hostTagOf,
|
|
46
|
+
} from '../../../../scripts/adwaita-gallery-shared-trees.mjs';
|
|
47
|
+
|
|
48
|
+
import '@gjsify/adwaita-web';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The whole renderer-specific half of this driver: an element, its authored properties as
|
|
52
|
+
* attributes, its children, in that order.
|
|
53
|
+
*
|
|
54
|
+
* A boolean is the ATTRIBUTE'S PRESENCE, which is what every element here reads
|
|
55
|
+
* (`hasAttribute('revealed')`, `hasAttribute('expanded')`) — spelling `"true"` would set a
|
|
56
|
+
* present attribute for `false` as well.
|
|
57
|
+
*/
|
|
58
|
+
function build(node: SharedTreeNode): HTMLElement {
|
|
59
|
+
const el = document.createElement(hostTagOf(node.tag));
|
|
60
|
+
for (const [prop, value] of Object.entries(node.props ?? {})) {
|
|
61
|
+
if (typeof value === 'boolean') el.toggleAttribute(attributeOf(prop), value);
|
|
62
|
+
else el.setAttribute(attributeOf(prop), String(value));
|
|
63
|
+
}
|
|
64
|
+
for (const child of node.children ?? []) el.append(build(child));
|
|
65
|
+
return el;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Connect the authored root — these elements build on connect — run, and take it down again.
|
|
70
|
+
*
|
|
71
|
+
* The teardown is in a `finally` because a RED test must not leave a mounted tree behind:
|
|
72
|
+
* the next test would then be reading a document two blocks deep, and the failure it
|
|
73
|
+
* reported would name the wrong renderer.
|
|
74
|
+
*/
|
|
75
|
+
function mounted<T>(node: SharedTreeNode, use: (root: Element) => T): T {
|
|
76
|
+
const host = document.createElement('div');
|
|
77
|
+
host.append(build(node));
|
|
78
|
+
document.body.append(host);
|
|
79
|
+
try {
|
|
80
|
+
return use(host.firstElementChild!);
|
|
81
|
+
} finally {
|
|
82
|
+
host.remove();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const bannerButton = (banner: Element) => banner.querySelector<HTMLButtonElement>('.adw-banner-button');
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read one observable off the element this renderer built.
|
|
90
|
+
*
|
|
91
|
+
* The ONE seam between the shared expectations and this renderer, and the mirror of
|
|
92
|
+
* `read()` in the gtk-host driver. Everything above it is renderer-free.
|
|
93
|
+
*/
|
|
94
|
+
function read(expectation: SharedTreeExpectation, el: Element): string | number | boolean {
|
|
95
|
+
switch (expectation.observable) {
|
|
96
|
+
case 'entry-text-length':
|
|
97
|
+
return (el as unknown as { textLength: number }).textLength;
|
|
98
|
+
case 'switch-row-active':
|
|
99
|
+
return (el as unknown as { active: boolean }).active;
|
|
100
|
+
case 'banner-button-visible': {
|
|
101
|
+
const button = bannerButton(el);
|
|
102
|
+
return button ? !button.hidden : false;
|
|
103
|
+
}
|
|
104
|
+
case 'banner-button-text':
|
|
105
|
+
return bannerButton(el)?.textContent ?? '';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The realised tree, depth-first in document order and filtered to the authored element
|
|
111
|
+
* names: the parts an element renders for itself (a listbox, a revealer, a title span) are
|
|
112
|
+
* not the renderer's promise; the ORDER and the NESTING of what was authored is.
|
|
113
|
+
*/
|
|
114
|
+
function realised(root: Element, wanted: readonly string[]): Element[] {
|
|
115
|
+
const set = new Set(wanted);
|
|
116
|
+
return [root, ...root.querySelectorAll('*')].filter((el) => set.has(el.tagName.toLowerCase()));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const AdwSharedTreesTest = async () => {
|
|
120
|
+
await describe('the shared corpus builds through adwaita-web', async () => {
|
|
121
|
+
for (const block of ADWAITA_GALLERY_SHARED_TREES) {
|
|
122
|
+
await it(`${block.widget} builds, and the REAL DOM carries the authored nodes in order`, () => {
|
|
123
|
+
const wanted = authoredTags(block.root, hostTagOf);
|
|
124
|
+
|
|
125
|
+
mounted(block.root, (root) => {
|
|
126
|
+
const built = realised(root, wanted).map((el) => el.tagName.toLowerCase());
|
|
127
|
+
expect(built).toStrictEqual(wanted);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await describe('the shared corpus against the adwaita-core vectors it reaches', async () => {
|
|
134
|
+
for (const block of ADWAITA_GALLERY_SHARED_TREES) {
|
|
135
|
+
for (const expectation of sharedTreeExpectations(block.root)) {
|
|
136
|
+
await it(`${block.widget} — ${expectation.path}: ${expectation.table} — ${expectation.rule}`, () => {
|
|
137
|
+
mounted(block.root, (root) => {
|
|
138
|
+
// The SAME filtered walk the shape test asserts, so the element an
|
|
139
|
+
// expectation is read off is the one at the authored ADDRESS rather
|
|
140
|
+
// than the first of its name the DOM happens to contain.
|
|
141
|
+
const built = realised(root, authoredTags(block.root, hostTagOf));
|
|
142
|
+
const subject = built[subjectIndexOf(block.root, expectation.path)]!;
|
|
143
|
+
expect(subject.tagName.toLowerCase()).toBe(hostTagOf(expectation.gtype));
|
|
144
|
+
|
|
145
|
+
expect(read(expectation, subject)).toBe(expectation.expected);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
};
|