@gjsify/adwaita-web 0.46.0 → 0.48.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/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/styles.generated.d.ts +1 -1
- package/package.json +6 -6
- package/scss/_popover.scss +99 -0
- package/src/adw-row-state.spec.ts +335 -17
- 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/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/split-button.spec.ts +36 -22
- package/src/styles.generated.ts +1 -1
- package/src/tab-view.spec.ts +14 -0
|
@@ -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;
|
package/src/split-button.spec.ts
CHANGED
|
@@ -13,10 +13,11 @@ import {
|
|
|
13
13
|
POPOVER_SURFACE_VECTORS,
|
|
14
14
|
SPLIT_BUTTON_CONTENT_VECTORS,
|
|
15
15
|
SPLIT_BUTTON_DIRECTION_VECTORS,
|
|
16
|
-
|
|
16
|
+
MENU_NORMALIZE_VECTORS,
|
|
17
17
|
SPLIT_BUTTON_STYLE_CLASS_VECTORS,
|
|
18
18
|
SPLIT_BUTTON_TOOLTIP_VECTORS,
|
|
19
19
|
} from '@gjsify/adwaita-core/conformance';
|
|
20
|
+
import { ADW_MENU_SURFACE_WEB, flattenMenu, menuRefusals } from '@gjsify/adwaita-core';
|
|
20
21
|
import type { SplitButtonProperty } from '@gjsify/adwaita-core';
|
|
21
22
|
import type { AdwSplitButton } from './elements/adw-split-button.js';
|
|
22
23
|
import { fallbackMask, maskOf } from './icon-registry.spec.js';
|
|
@@ -234,11 +235,24 @@ export const AdwSplitButtonTest = async () => {
|
|
|
234
235
|
});
|
|
235
236
|
|
|
236
237
|
await describe('adw-split-button menu', async () => {
|
|
237
|
-
for (const {
|
|
238
|
-
await it(`
|
|
239
|
-
const { el, host } = mount(
|
|
240
|
-
|
|
241
|
-
|
|
238
|
+
for (const { input, model, rule } of MENU_NORMALIZE_VECTORS) {
|
|
239
|
+
await it(`normalises an authored menu — ${rule}`, () => {
|
|
240
|
+
const { el, host } = mount({ label: 'Save' });
|
|
241
|
+
// What THIS surface refuses is core's answer, not a list restated here:
|
|
242
|
+
// a `custom` item names an application widget the browser cannot host,
|
|
243
|
+
// and the setter is where that is said out loud.
|
|
244
|
+
if (menuRefusals(model, ADW_MENU_SURFACE_WEB).length > 0) {
|
|
245
|
+
expect(() => {
|
|
246
|
+
el.menuModel = input;
|
|
247
|
+
}).toThrow('adwaita-web');
|
|
248
|
+
host.remove();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
el.menuModel = input;
|
|
252
|
+
expect([...el.menuModel]).toStrictEqual([...model]);
|
|
253
|
+
// Every node the model draws is a row: a section is inlined, a submenu
|
|
254
|
+
// is one row of its own.
|
|
255
|
+
expect(el.querySelectorAll('.adw-split-button-menu-item').length).toBe(flattenMenu(model).length);
|
|
242
256
|
host.remove();
|
|
243
257
|
});
|
|
244
258
|
}
|
|
@@ -255,7 +269,7 @@ export const AdwSplitButtonTest = async () => {
|
|
|
255
269
|
});
|
|
256
270
|
|
|
257
271
|
await it('opens and closes on the dropdown half once there is a menu', () => {
|
|
258
|
-
const { el, host } = mount({ menu: '[{"label":"Save as…","action":"app.save-as"}]' });
|
|
272
|
+
const { el, host } = mount({ 'menu-model': '[{"label":"Save as…","action":"app.save-as"}]' });
|
|
259
273
|
expect(el.dropdownEnabled).toBe(true);
|
|
260
274
|
expect(dropdownHalf(el).disabled).toBe(false);
|
|
261
275
|
|
|
@@ -275,25 +289,25 @@ export const AdwSplitButtonTest = async () => {
|
|
|
275
289
|
|
|
276
290
|
await it('dispatches a duplicate label BY POSITION, with its own action', () => {
|
|
277
291
|
const { el, host } = mount({
|
|
278
|
-
menu: '[{"label":"Copy","action":"app.copy"},{"label":"Copy","action":"app.copy-special"}]',
|
|
292
|
+
'menu-model': '[{"label":"Copy","action":"app.copy"},{"label":"Copy","action":"app.copy-special"}]',
|
|
279
293
|
});
|
|
280
|
-
const activated: Array<{ label: string; action?: string;
|
|
294
|
+
const activated: Array<{ label: string; action?: string; id: string; path: number[] }> = [];
|
|
281
295
|
el.addEventListener('menu-activated', (event) => activated.push((event as CustomEvent).detail));
|
|
282
296
|
|
|
283
297
|
dropdownHalf(el).click();
|
|
284
298
|
const items = el.querySelectorAll('.adw-split-button-menu-item');
|
|
285
299
|
(items[1] as HTMLButtonElement).click();
|
|
286
300
|
|
|
287
|
-
expect(activated).toStrictEqual([{ label: 'Copy', action: 'app.copy-special',
|
|
301
|
+
expect(activated).toStrictEqual([{ label: 'Copy', action: 'app.copy-special', id: 'Copy', path: [1] }]);
|
|
288
302
|
expect(el.active).toBe(false);
|
|
289
303
|
host.remove();
|
|
290
304
|
});
|
|
291
305
|
|
|
292
306
|
await it('losing its menu closes an open one', () => {
|
|
293
|
-
const { el, host } = mount({ menu: '[{"label":"Print"}]' });
|
|
307
|
+
const { el, host } = mount({ 'menu-model': '[{"label":"Print"}]' });
|
|
294
308
|
dropdownHalf(el).click();
|
|
295
309
|
expect(el.active).toBe(true);
|
|
296
|
-
el.setAttribute('menu', '[]');
|
|
310
|
+
el.setAttribute('menu-model', '[]');
|
|
297
311
|
expect(el.active).toBe(false);
|
|
298
312
|
expect(el.dropdownEnabled).toBe(false);
|
|
299
313
|
host.remove();
|
|
@@ -316,7 +330,7 @@ export const AdwSplitButtonTest = async () => {
|
|
|
316
330
|
});
|
|
317
331
|
|
|
318
332
|
await it('marks the root checked while the menu is open (the update_state fold)', () => {
|
|
319
|
-
const { el, host } = mount({ menu: '[{"label":"Print"}]' });
|
|
333
|
+
const { el, host } = mount({ 'menu-model': '[{"label":"Print"}]' });
|
|
320
334
|
expect(el.classList.contains('checked')).toBe(false);
|
|
321
335
|
dropdownHalf(el).click();
|
|
322
336
|
expect(el.classList.contains('checked')).toBe(true);
|
|
@@ -331,7 +345,7 @@ export const AdwSplitButtonTest = async () => {
|
|
|
331
345
|
// from `<gtk-popover>`, and these cases prove the lift was a lift, not a move.
|
|
332
346
|
await describe('adw-split-button menu keyboard (gained with <gtk-popover>)', async () => {
|
|
333
347
|
await it('Escape dismisses the menu and returns focus to the dropdown half', () => {
|
|
334
|
-
const { el, host } = mount({ menu: '[{"label":"Print"},{"label":"Export"}]' });
|
|
348
|
+
const { el, host } = mount({ 'menu-model': '[{"label":"Print"},{"label":"Export"}]' });
|
|
335
349
|
dropdownHalf(el).click();
|
|
336
350
|
expect(el.active).toBe(true);
|
|
337
351
|
|
|
@@ -346,7 +360,7 @@ export const AdwSplitButtonTest = async () => {
|
|
|
346
360
|
});
|
|
347
361
|
|
|
348
362
|
await it('Escape reaches notify::active, so the dismissal is a real state change', () => {
|
|
349
|
-
const { el, host } = mount({ menu: '[{"label":"Print"}]' });
|
|
363
|
+
const { el, host } = mount({ 'menu-model': '[{"label":"Print"}]' });
|
|
350
364
|
const seen: boolean[] = [];
|
|
351
365
|
el.addEventListener('notify::active', (event) => seen.push((event as CustomEvent).detail.active));
|
|
352
366
|
|
|
@@ -358,7 +372,7 @@ export const AdwSplitButtonTest = async () => {
|
|
|
358
372
|
});
|
|
359
373
|
|
|
360
374
|
await it('the arrow keys walk the menu items and wrap, from the row focused on open', () => {
|
|
361
|
-
const { el, host } = mount({ menu: '[{"label":"Print"},{"label":"Export"},{"label":"Share"}]' });
|
|
375
|
+
const { el, host } = mount({ 'menu-model': '[{"label":"Print"},{"label":"Export"},{"label":"Share"}]' });
|
|
362
376
|
dropdownHalf(el).click();
|
|
363
377
|
|
|
364
378
|
// Queried AFTER opening on purpose: `_renderMenu` runs on every state change
|
|
@@ -391,9 +405,9 @@ export const AdwSplitButtonTest = async () => {
|
|
|
391
405
|
});
|
|
392
406
|
|
|
393
407
|
await it('Enter on a focused row activates it BY POSITION, exactly once', () => {
|
|
394
|
-
const { el, host } = mount({ menu: '[{"label":"Copy"},{"label":"Copy"}]' });
|
|
395
|
-
const seen: number[] = [];
|
|
396
|
-
el.addEventListener('menu-activated', (event) => seen.push((event as CustomEvent).detail.
|
|
408
|
+
const { el, host } = mount({ 'menu-model': '[{"label":"Copy"},{"label":"Copy"}]' });
|
|
409
|
+
const seen: number[][] = [];
|
|
410
|
+
el.addEventListener('menu-activated', (event) => seen.push((event as CustomEvent).detail.path));
|
|
397
411
|
|
|
398
412
|
dropdownHalf(el).click();
|
|
399
413
|
const menu = el.querySelector('gtk-popover') as HTMLElement;
|
|
@@ -403,7 +417,7 @@ export const AdwSplitButtonTest = async () => {
|
|
|
403
417
|
// Two entries share a label, so only position tells them apart; and
|
|
404
418
|
// `preventDefault()` before the synthetic click keeps a focused <button> from
|
|
405
419
|
// ALSO activating natively and firing twice.
|
|
406
|
-
expect(seen).toStrictEqual([1]);
|
|
420
|
+
expect(seen).toStrictEqual([[1]]);
|
|
407
421
|
host.remove();
|
|
408
422
|
});
|
|
409
423
|
});
|
|
@@ -423,7 +437,7 @@ export const AdwSplitButtonTest = async () => {
|
|
|
423
437
|
});
|
|
424
438
|
|
|
425
439
|
await it(`draws a ${menuVector?.borderRadius}px surface with ${menuVector?.padding}px padding and a THREE-layer shadow`, () => {
|
|
426
|
-
const { el, host } = mount({ menu: '[{"label":"Print"}]' });
|
|
440
|
+
const { el, host } = mount({ 'menu-model': '[{"label":"Print"}]' });
|
|
427
441
|
dropdownHalf(el).click();
|
|
428
442
|
const surface = el.querySelector('gtk-popover') as HTMLElement;
|
|
429
443
|
const style = getComputedStyle(surface);
|
|
@@ -445,7 +459,7 @@ export const AdwSplitButtonTest = async () => {
|
|
|
445
459
|
});
|
|
446
460
|
|
|
447
461
|
await it(`draws menu rows at the ${itemVector?.borderRadius}px modelbutton radius`, () => {
|
|
448
|
-
const { el, host } = mount({ menu: '[{"label":"Print"}]' });
|
|
462
|
+
const { el, host } = mount({ 'menu-model': '[{"label":"Print"}]' });
|
|
449
463
|
dropdownHalf(el).click();
|
|
450
464
|
const row = el.querySelector('.adw-split-button-menu-item') as HTMLElement;
|
|
451
465
|
const style = getComputedStyle(row);
|