@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
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// The menu INSIDE a `<gtk-popover>` — one implementation for both menu-bearing
|
|
2
|
+
// elements (ADR 0042).
|
|
3
|
+
//
|
|
4
|
+
// `<adw-split-button>` and `<gtk-menu-button>` are different buttons with the same
|
|
5
|
+
// popup: `GtkMenuButton` builds it in both cases, and the split button's dropdown half
|
|
6
|
+
// IS one. Before the portable menu model each element built its own rows, and the two
|
|
7
|
+
// disagreed about everything a model can carry — one drew icons and an `id`, the other
|
|
8
|
+
// drew neither; neither drew a section, a submenu, an accelerator, or a check.
|
|
9
|
+
//
|
|
10
|
+
// WHAT THIS DRAWS, which is what `GtkPopoverMenu` draws:
|
|
11
|
+
//
|
|
12
|
+
// · a SECTION is inlined with a separator on each boundary (never a leading one) and
|
|
13
|
+
// an optional heading — the split `flattenMenu` decides, not this file;
|
|
14
|
+
// · a SUBMENU is a row that SWAPS THE PAGE, with a back row above the new list. That
|
|
15
|
+
// is `GtkPopoverMenu`'s own shape (it is a stack of pages with a back button), and
|
|
16
|
+
// it is what keeps "the user opened it" true — inlining a submenu would offer items
|
|
17
|
+
// nobody asked for;
|
|
18
|
+
// · CHECK and RADIO decorations, and the dim of an insensitive row, from
|
|
19
|
+
// `resolveMenuItemState` — never from a field on the item, because `GMenuModel` has
|
|
20
|
+
// no such field;
|
|
21
|
+
// · an ACCELERATOR, right-aligned and dimmed (`popover.menu accelerator`). It is
|
|
22
|
+
// DISPLAY ONLY: nothing here binds a key, exactly as in GTK, where the binding
|
|
23
|
+
// comes from `gtk_application_set_accels_for_action`.
|
|
24
|
+
//
|
|
25
|
+
// THE KEYS ARE SPLIT, and the split is the reason this file registers a listener at all.
|
|
26
|
+
// `<gtk-popover>` owns the LIST keys for every surface that has rows — Arrow up/down with
|
|
27
|
+
// wrap, Home/End, Enter/Space to activate, Escape to dismiss — walking `.adw-popover-item`
|
|
28
|
+
// and skipping what cannot take focus. Re-implementing those here would be a second copy
|
|
29
|
+
// of one arithmetic on the same rows, and both copies would move the focus.
|
|
30
|
+
//
|
|
31
|
+
// What a popover cannot own is what makes a MENU a menu: LEFT and RIGHT change the PAGE.
|
|
32
|
+
// GTK puts that rule on the row rather than on the surface too —
|
|
33
|
+
// `gtk_model_button_focus` (`refs/gtk/gtk/gtkmodelbutton.c:1174-1210`): a focused NORMAL
|
|
34
|
+
// row with a `menu-name` takes GTK_DIR_RIGHT into that submenu (`:1189-1195`), and a
|
|
35
|
+
// focused TITLE row — the back row at the top of an open page — takes GTK_DIR_LEFT out of
|
|
36
|
+
// it (`:1182-1188`). Both are the row's own answer to a direction, which is why Left from
|
|
37
|
+
// the middle of a submenu does nothing in GTK and does nothing here.
|
|
38
|
+
//
|
|
39
|
+
// ARIA IS DERIVED, NOT DECLARED. `role` follows the item's menu role
|
|
40
|
+
// (`menuitem`/`menuitemcheckbox`/`menuitemradio`) and `aria-checked` follows `toggled`,
|
|
41
|
+
// so a screen reader hears the same three-way distinction a sighted reader sees. A row
|
|
42
|
+
// the action group disables is `aria-disabled` AND `disabled`, so it is announced and
|
|
43
|
+
// unclickable rather than merely dim.
|
|
44
|
+
//
|
|
45
|
+
// Reference: refs/gtk/gtk/gtkpopovermenu.c (the page stack, the attribute list)
|
|
46
|
+
// Reference: refs/libadwaita/src/stylesheet/widgets/_menus.scss:68-90 (separator, accelerator, check/radio)
|
|
47
|
+
// Copyright (c) GNOME contributors (GTK, libadwaita). LGPLv2.1+.
|
|
48
|
+
|
|
49
|
+
import { flattenMenu, menuNodeAt, resolveMenuItemState } from '@gjsify/adwaita-core';
|
|
50
|
+
import type { AdwMenuActions, AdwMenuItem, AdwMenuModel, AdwMenuPath } from '@gjsify/adwaita-core';
|
|
51
|
+
|
|
52
|
+
import { createGtkImage } from './gtk-image.js';
|
|
53
|
+
import type { GtkPopover } from './gtk-popover.js';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Called when the reader chooses an item, with the path that addresses it in the WHOLE
|
|
57
|
+
* model — never the item itself. The element resolves it through its own state, which is
|
|
58
|
+
* what dismisses the menu, so handing over a second copy of the item would give the
|
|
59
|
+
* activation two sources.
|
|
60
|
+
*/
|
|
61
|
+
export type PopoverMenuActivate = (path: AdwMenuPath) => void;
|
|
62
|
+
|
|
63
|
+
/** The label of the row that leaves a submenu. `GtkPopoverMenu` draws the parent's own label. */
|
|
64
|
+
const BACK_LABEL = 'Back';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The rows of one popover, and which page is showing.
|
|
68
|
+
*
|
|
69
|
+
* A view rather than a function because a submenu is a NAVIGATION: the open page has to
|
|
70
|
+
* survive between renders, and it has to reset when the popover closes — a menu that
|
|
71
|
+
* reopens three levels deep is a menu the reader cannot get out of.
|
|
72
|
+
*/
|
|
73
|
+
export class PopoverMenuView {
|
|
74
|
+
private _model: AdwMenuModel = [];
|
|
75
|
+
private _actions: AdwMenuActions | null = null;
|
|
76
|
+
private _title = '';
|
|
77
|
+
/** The submenu whose items are showing; `[]` is the root page. */
|
|
78
|
+
private _page: AdwMenuPath = [];
|
|
79
|
+
/** The rows that OPEN a page — what ArrowRight acts on. Rebuilt by every render. */
|
|
80
|
+
private _openers = new Set<HTMLElement>();
|
|
81
|
+
/** The row that LEAVES a page — what ArrowLeft acts on. */
|
|
82
|
+
private _backRow: HTMLElement | null = null;
|
|
83
|
+
/** The canonical text of the model showing, so an unchanged re-render keeps the page. */
|
|
84
|
+
private _key = '[]';
|
|
85
|
+
private _rows: HTMLButtonElement[] = [];
|
|
86
|
+
|
|
87
|
+
constructor(
|
|
88
|
+
private readonly surface: GtkPopover,
|
|
89
|
+
/** Class prefix for the element's own hooks — `adw-menu-button`, `adw-split-button-menu`. */
|
|
90
|
+
private readonly prefix: string,
|
|
91
|
+
private readonly onActivate: PopoverMenuActivate,
|
|
92
|
+
) {
|
|
93
|
+
// On the SURFACE, not on each row: rows are replaced by every render and a
|
|
94
|
+
// per-row listener would have to be rebound with them, which is how one gets
|
|
95
|
+
// forgotten. The surface outlives every page.
|
|
96
|
+
this.surface.addEventListener('keydown', (event) => this._onKeyDown(event));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* LEFT and RIGHT, which change the page. Every other key belongs to `<gtk-popover>`.
|
|
101
|
+
*
|
|
102
|
+
* The act is the row's own CLICK, not a second copy of what opening a page means:
|
|
103
|
+
* `switch_menu` is one behaviour in GTK too, reached by a pointer and by a direction
|
|
104
|
+
* alike. `preventDefault` before it, for the reason the popover's own Enter handler
|
|
105
|
+
* gives — a focused `<button>` would otherwise also activate natively.
|
|
106
|
+
*/
|
|
107
|
+
private _onKeyDown(event: KeyboardEvent): void {
|
|
108
|
+
const active = document.activeElement as HTMLElement | null;
|
|
109
|
+
if (active === null || !this.surface.contains(active)) return;
|
|
110
|
+
if (event.key === 'ArrowRight' && this._openers.has(active)) {
|
|
111
|
+
event.preventDefault();
|
|
112
|
+
active.click();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (event.key === 'ArrowLeft' && active === this._backRow) {
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
active.click();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Every row of the open page, in order — including the ones no key can reach. */
|
|
122
|
+
get rows(): readonly HTMLButtonElement[] {
|
|
123
|
+
return this._rows;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The rows a key can actually REACH, which is the only list worth focusing into.
|
|
128
|
+
*
|
|
129
|
+
* A hidden row is not rendered and a DISABLED one cannot take focus, so
|
|
130
|
+
* `element.focus()` on either is a silent no-op — and a no-op after `render()` has
|
|
131
|
+
* replaced the row that had focus leaves `document.activeElement` on `<body>`,
|
|
132
|
+
* OUTSIDE an open popover. Measured under real keys: Tab then walked to the control
|
|
133
|
+
* BEHIND the popup and every arrow was dead, because both keydown listeners are
|
|
134
|
+
* element-scoped and the event no longer fired inside either. Escape was the only
|
|
135
|
+
* way out.
|
|
136
|
+
*
|
|
137
|
+
* The predicate lived in the two ELEMENTS (their open path) and not in the two page
|
|
138
|
+
* changes here, which is how it came to be applied in two places out of four. One
|
|
139
|
+
* list, so there is no fourth place to forget.
|
|
140
|
+
*/
|
|
141
|
+
get focusableRows(): readonly HTMLButtonElement[] {
|
|
142
|
+
return this._rows.filter((row) => !row.hidden && !row.disabled);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Put focus on a page, counting only rows a key can reach.
|
|
147
|
+
*
|
|
148
|
+
* `skip` is how many reachable rows to pass — 1 when ENTERING a submenu, because the
|
|
149
|
+
* back row is reachable and the reader was going to the first item past it. The
|
|
150
|
+
* fallback to the first reachable row is what covers a submenu whose every item is
|
|
151
|
+
* disabled: focus lands on the back row, which is always reachable, and the reader
|
|
152
|
+
* can still leave with Left, Enter or Escape.
|
|
153
|
+
*/
|
|
154
|
+
private focusPage(skip: number): void {
|
|
155
|
+
const rows = this.focusableRows;
|
|
156
|
+
(rows[skip] ?? rows[0])?.focus();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Named for the value it takes, and NOT `setModel` — the portable LIST model gave
|
|
161
|
+
* `ComboState` a `setModel` (ADR 0046), and two methods called `setModel` over two
|
|
162
|
+
* unrelated values on one surface is the collision the vocabulary work exists to
|
|
163
|
+
* remove. It also un-blinds `check-adwaita-collection-reactivity`, which resolves a
|
|
164
|
+
* collection call by the setter's name.
|
|
165
|
+
*/
|
|
166
|
+
setMenuModel(model: AdwMenuModel): void {
|
|
167
|
+
// A CHANGED model invalidates the open page: the submenu it named may not exist
|
|
168
|
+
// any more, and rendering a page that is gone leaves an empty popover with a
|
|
169
|
+
// back row and no way to see what happened. An UNCHANGED one must not — every
|
|
170
|
+
// element re-render calls this, and resetting unconditionally shut the submenu
|
|
171
|
+
// the reader had just opened.
|
|
172
|
+
const key = JSON.stringify(model);
|
|
173
|
+
if (key !== this._key) {
|
|
174
|
+
this._key = key;
|
|
175
|
+
this._page = [];
|
|
176
|
+
}
|
|
177
|
+
this._model = model;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
setActions(actions: AdwMenuActions | null): void {
|
|
181
|
+
this._actions = actions;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
setTitle(title: string): void {
|
|
185
|
+
this._title = title;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Return to the root page — called when the popover closes. */
|
|
189
|
+
reset(): void {
|
|
190
|
+
this._page = [];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The items of the page currently showing. */
|
|
194
|
+
private currentItems(): AdwMenuModel {
|
|
195
|
+
if (this._page.length === 0) return this._model;
|
|
196
|
+
const node = menuNodeAt(this._model, this._page);
|
|
197
|
+
return node !== null && node.kind !== 'item' ? node.items : this._model;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Rebuild the surface's children from the model. */
|
|
201
|
+
render(): void {
|
|
202
|
+
this.surface.replaceChildren();
|
|
203
|
+
this._rows = [];
|
|
204
|
+
this._openers = new Set();
|
|
205
|
+
this._backRow = null;
|
|
206
|
+
|
|
207
|
+
const open = menuNodeAt(this._model, this._page);
|
|
208
|
+
if (this._page.length > 0 && open !== null && open.kind === 'submenu') {
|
|
209
|
+
this.surface.appendChild(this.backRow(open.label));
|
|
210
|
+
} else if (this._title.length > 0) {
|
|
211
|
+
this.surface.appendChild(this.heading(this._title, `${this.prefix}-title`));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Keyed on the section's PATH, never on its label: two sections may be headed
|
|
215
|
+
// the same, and comparing labels swallowed the second heading.
|
|
216
|
+
let headed = '';
|
|
217
|
+
for (const row of flattenMenu(this.currentItems(), this._page)) {
|
|
218
|
+
if (row.separated) this.surface.appendChild(this.separator());
|
|
219
|
+
// A section heading belongs to the SECTION, and `flattenMenu` deliberately
|
|
220
|
+
// does not carry it — a heading is a DOM node, not a menu row, so the label
|
|
221
|
+
// is read off the node the row's path leads through.
|
|
222
|
+
const parentPath = row.path.slice(0, -1);
|
|
223
|
+
const key = parentPath.join('.');
|
|
224
|
+
if (key !== headed) {
|
|
225
|
+
headed = key;
|
|
226
|
+
const heading = this.sectionHeading(parentPath);
|
|
227
|
+
if (heading !== null) {
|
|
228
|
+
this.surface.appendChild(this.heading(heading, 'adw-popover-section-title'));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (row.node.kind === 'submenu') {
|
|
232
|
+
this.surface.appendChild(this.submenuRow(row.node.label, row.node.icon, row.path));
|
|
233
|
+
} else {
|
|
234
|
+
this.surface.appendChild(this.itemRow(row.node, row.path));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The label of the section at `path`, or `null` when it is not a labelled section. */
|
|
240
|
+
private sectionHeading(path: AdwMenuPath): string | null {
|
|
241
|
+
if (path.length <= this._page.length) return null;
|
|
242
|
+
const parent = menuNodeAt(this._model, path);
|
|
243
|
+
return parent !== null && parent.kind === 'section' && parent.label !== undefined ? parent.label : null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private heading(text: string, extraClass: string): HTMLElement {
|
|
247
|
+
const el = document.createElement('div');
|
|
248
|
+
el.className = `adw-popover-title ${extraClass}`;
|
|
249
|
+
el.textContent = text;
|
|
250
|
+
return el;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private separator(): HTMLElement {
|
|
254
|
+
const el = document.createElement('div');
|
|
255
|
+
el.className = 'adw-popover-separator';
|
|
256
|
+
// A separator is decoration: it must not be walked as a row by a screen reader
|
|
257
|
+
// any more than it is by `<gtk-popover>`'s `.adw-popover-item` selector.
|
|
258
|
+
el.setAttribute('role', 'separator');
|
|
259
|
+
return el;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** A row, with the class that makes `<gtk-popover>` treat it as navigable. */
|
|
263
|
+
private baseRow(extra: string): HTMLButtonElement {
|
|
264
|
+
const button = document.createElement('button');
|
|
265
|
+
button.type = 'button';
|
|
266
|
+
button.className = `adw-popover-item ${extra}`;
|
|
267
|
+
button.tabIndex = -1;
|
|
268
|
+
this._rows.push(button);
|
|
269
|
+
return button;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private backRow(label: string): HTMLButtonElement {
|
|
273
|
+
const button = this.baseRow(`${this.prefix}-item adw-popover-back`);
|
|
274
|
+
button.setAttribute('role', 'menuitem');
|
|
275
|
+
button.appendChild(createGtkImage('go-previous', `${this.prefix}-item-icon`));
|
|
276
|
+
const text = document.createElement('span');
|
|
277
|
+
text.className = `${this.prefix}-item-label`;
|
|
278
|
+
// The parent's own label names where Back goes; `Back` alone is the fallback for
|
|
279
|
+
// a submenu that has none.
|
|
280
|
+
text.textContent = label.length > 0 ? label : BACK_LABEL;
|
|
281
|
+
button.appendChild(text);
|
|
282
|
+
button.addEventListener('click', () => {
|
|
283
|
+
this._page = this._page.slice(0, -1);
|
|
284
|
+
this.render();
|
|
285
|
+
this.focusPage(0);
|
|
286
|
+
});
|
|
287
|
+
this._backRow = button;
|
|
288
|
+
return button;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private submenuRow(label: string, icon: string | undefined, path: AdwMenuPath): HTMLButtonElement {
|
|
292
|
+
const button = this.baseRow(`${this.prefix}-item`);
|
|
293
|
+
button.setAttribute('role', 'menuitem');
|
|
294
|
+
// A submenu OPENS something, so it is a menu with a popup, not a command.
|
|
295
|
+
button.setAttribute('aria-haspopup', 'menu');
|
|
296
|
+
if (icon !== undefined && icon.length > 0) {
|
|
297
|
+
button.appendChild(createGtkImage(icon, `${this.prefix}-item-icon`));
|
|
298
|
+
}
|
|
299
|
+
const text = document.createElement('span');
|
|
300
|
+
text.className = `${this.prefix}-item-label`;
|
|
301
|
+
text.textContent = label;
|
|
302
|
+
button.appendChild(text);
|
|
303
|
+
button.appendChild(createGtkImage('go-next', 'adw-popover-item-arrow'));
|
|
304
|
+
button.addEventListener('click', () => {
|
|
305
|
+
this._page = [...path];
|
|
306
|
+
this.render();
|
|
307
|
+
// Past the back row, which is where the reader was going — counted in
|
|
308
|
+
// REACHABLE rows, so a disabled first item does not strand focus on <body>.
|
|
309
|
+
this.focusPage(1);
|
|
310
|
+
});
|
|
311
|
+
this._openers.add(button);
|
|
312
|
+
return button;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private itemRow(item: AdwMenuItem, path: AdwMenuPath): HTMLButtonElement {
|
|
316
|
+
const state = resolveMenuItemState(item, this._actions ?? undefined);
|
|
317
|
+
const button = this.baseRow(`${this.prefix}-item`);
|
|
318
|
+
// `hidden-when` removes the row from the reader's world, not just from view:
|
|
319
|
+
// `hidden` on a button also takes it out of the accessibility tree.
|
|
320
|
+
button.hidden = !state.visible;
|
|
321
|
+
button.disabled = !state.sensitive;
|
|
322
|
+
if (!state.sensitive) button.setAttribute('aria-disabled', 'true');
|
|
323
|
+
|
|
324
|
+
if (state.role === 'normal') {
|
|
325
|
+
button.setAttribute('role', 'menuitem');
|
|
326
|
+
} else {
|
|
327
|
+
button.setAttribute('role', state.role === 'check' ? 'menuitemcheckbox' : 'menuitemradio');
|
|
328
|
+
button.setAttribute('aria-checked', String(state.toggled));
|
|
329
|
+
const mark = document.createElement('span');
|
|
330
|
+
mark.className = `adw-popover-item-check ${state.role === 'radio' ? 'radio' : ''}`.trim();
|
|
331
|
+
// The tick is drawn by CSS off `aria-checked`, so an unchecked row still
|
|
332
|
+
// reserves the space and the labels of a group line up.
|
|
333
|
+
button.appendChild(mark);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// An item that asked for NO icon gets no icon node — the emptiness of the
|
|
337
|
+
// DECLARED name, not whether it resolved: `<gtk-image>` draws `image-missing`
|
|
338
|
+
// for a name that was given and is merely undrawable, which is the one case a
|
|
339
|
+
// reader needs to see.
|
|
340
|
+
if (item.icon !== undefined && item.icon.length > 0) {
|
|
341
|
+
button.appendChild(createGtkImage(item.icon, `${this.prefix}-item-icon`));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const label = document.createElement('span');
|
|
345
|
+
label.className = `${this.prefix}-item-label`;
|
|
346
|
+
// `use-markup` says the label IS Pango markup. The browser has no Pango, and
|
|
347
|
+
// handing author markup to `innerHTML` would be an injection door in a renderer
|
|
348
|
+
// that has no other one, so the text is shown verbatim and the flag is carried
|
|
349
|
+
// for the GTK path. Stated rather than silently honoured.
|
|
350
|
+
label.textContent = item.label;
|
|
351
|
+
button.appendChild(label);
|
|
352
|
+
|
|
353
|
+
if (item.accel !== undefined && item.accel.length > 0) {
|
|
354
|
+
const accel = document.createElement('span');
|
|
355
|
+
accel.className = 'adw-popover-item-accel';
|
|
356
|
+
accel.textContent = item.accel;
|
|
357
|
+
button.appendChild(accel);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
button.addEventListener('click', () => {
|
|
361
|
+
if (!state.sensitive) return;
|
|
362
|
+
this.onActivate(path);
|
|
363
|
+
});
|
|
364
|
+
return button;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
@@ -5,13 +5,20 @@
|
|
|
5
5
|
// against (`COMBO_SELECTION_VECTORS`, `@gjsify/adwaita-core/conformance`), replayed
|
|
6
6
|
// through the API a consumer would use and read back as what a user would see: the
|
|
7
7
|
// button label, `selected`/`selectedValue` and the `change` stream. `<adw-combo-row>`
|
|
8
|
-
// composes the same `ComboState`
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// composes the same `ComboState` and drives the same table from its own DOM
|
|
9
|
+
// (`adw-row-state.spec.ts`) — it could not while two of its four step ops had no DOM
|
|
10
|
+
// spelling there, its model arriving through an attribute at connect time only (#1525).
|
|
11
|
+
// The two are worth driving separately because they part on ONE row, below.
|
|
12
12
|
import { describe, it, expect } from '@gjsify/unit';
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
COMBO_SELECTION_VECTORS,
|
|
16
|
+
LIST_ITEMS_CHANGED_VECTORS,
|
|
17
|
+
LIST_MODEL_OWNERSHIP_VECTORS,
|
|
18
|
+
LIST_NORMALIZE_VECTORS,
|
|
19
|
+
LIST_PARSE_VECTORS,
|
|
20
|
+
applyListReadback,
|
|
21
|
+
} from '@gjsify/adwaita-core/conformance';
|
|
15
22
|
import type { ComboSelectionStep, ComboSelectionVector } from '@gjsify/adwaita-core/conformance';
|
|
16
23
|
|
|
17
24
|
import type { GtkDropDown } from './elements/gtk-drop-down.js';
|
|
@@ -38,8 +45,8 @@ function openChooser(dd: GtkDropDown): void {
|
|
|
38
45
|
*/
|
|
39
46
|
function applyStep(dd: GtkDropDown, step: ComboSelectionStep): void {
|
|
40
47
|
switch (step.op) {
|
|
41
|
-
case '
|
|
42
|
-
dd.
|
|
48
|
+
case 'setModel':
|
|
49
|
+
dd.model = step.model;
|
|
43
50
|
return;
|
|
44
51
|
case 'setSelectedIndex':
|
|
45
52
|
dd.selected = step.index;
|
|
@@ -129,19 +136,19 @@ export const GtkDropDownTest = async () => {
|
|
|
129
136
|
});
|
|
130
137
|
});
|
|
131
138
|
|
|
132
|
-
await describe('gtk-drop-down
|
|
139
|
+
await describe('gtk-drop-down model', async () => {
|
|
133
140
|
await it('accepts a string[] (value === label)', async () => {
|
|
134
141
|
const dd = makeDropDown();
|
|
135
|
-
dd.
|
|
136
|
-
expect(dd.
|
|
137
|
-
expect(dd.
|
|
142
|
+
dd.model = ['One', 'Two', 'Three'];
|
|
143
|
+
expect(dd.model.length).toBe(3);
|
|
144
|
+
expect(dd.model[0]).toStrictEqual({ value: 'One', label: 'One' });
|
|
138
145
|
expect(dd.querySelector('.adw-drop-down-label')?.textContent).toBe('One');
|
|
139
146
|
dd.remove();
|
|
140
147
|
});
|
|
141
148
|
|
|
142
149
|
await it('accepts {value,label}[] and shows the selected label', async () => {
|
|
143
150
|
const dd = makeDropDown();
|
|
144
|
-
dd.
|
|
151
|
+
dd.model = [
|
|
145
152
|
{ value: 'a', label: 'Apple' },
|
|
146
153
|
{ value: 'b', label: 'Banana' },
|
|
147
154
|
];
|
|
@@ -151,12 +158,12 @@ export const GtkDropDownTest = async () => {
|
|
|
151
158
|
dd.remove();
|
|
152
159
|
});
|
|
153
160
|
|
|
154
|
-
await it('parses
|
|
161
|
+
await it('parses the model from the JSON attribute', async () => {
|
|
155
162
|
const dd = document.createElement('gtk-drop-down') as GtkDropDown;
|
|
156
|
-
dd.setAttribute('
|
|
163
|
+
dd.setAttribute('model', '[{"value":"x","label":"Ex"},{"value":"y","label":"Why"}]');
|
|
157
164
|
dd.setAttribute('selected', '1');
|
|
158
165
|
document.body.appendChild(dd);
|
|
159
|
-
expect(dd.
|
|
166
|
+
expect(dd.model.length).toBe(2);
|
|
160
167
|
expect(dd.selected).toBe(1);
|
|
161
168
|
expect(dd.selectedValue).toBe('y');
|
|
162
169
|
dd.remove();
|
|
@@ -164,16 +171,101 @@ export const GtkDropDownTest = async () => {
|
|
|
164
171
|
|
|
165
172
|
await it('renders one popover item per option', async () => {
|
|
166
173
|
const dd = makeDropDown();
|
|
167
|
-
dd.
|
|
174
|
+
dd.model = ['a', 'b', 'c'];
|
|
168
175
|
expect(dd.querySelectorAll('.adw-drop-down-item').length).toBe(3);
|
|
169
176
|
dd.remove();
|
|
170
177
|
});
|
|
178
|
+
|
|
179
|
+
for (const vector of LIST_NORMALIZE_VECTORS) {
|
|
180
|
+
await it(`normalize — ${vector.rule}`, async () => {
|
|
181
|
+
const dd = makeDropDown();
|
|
182
|
+
dd.model = vector.input;
|
|
183
|
+
expect(dd.model).toStrictEqual([...vector.model]);
|
|
184
|
+
expect(
|
|
185
|
+
[...dd.querySelectorAll('.adw-drop-down-item-label')].map((label) => label.textContent),
|
|
186
|
+
).toStrictEqual(vector.model.map((item) => item.label));
|
|
187
|
+
dd.remove();
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// The `model` ATTRIBUTE door, driven here as well as on `<adw-combo-row>`: both
|
|
192
|
+
// selectors read it through the SAME `parseListModel`, so a table asserted on one
|
|
193
|
+
// of them says nothing about the other's `attributeChangedCallback` branch — which
|
|
194
|
+
// is the half that was write-once on the row for its whole life (#1525).
|
|
195
|
+
for (const vector of LIST_PARSE_VECTORS) {
|
|
196
|
+
await it(`attribute — ${vector.rule}`, async () => {
|
|
197
|
+
const dd = makeDropDown();
|
|
198
|
+
// Set AFTER connect, so the attribute reaches the callback rather than the
|
|
199
|
+
// seeding path: a total parser has to be total on the live door too.
|
|
200
|
+
dd.setAttribute('model', vector.attribute);
|
|
201
|
+
expect(dd.model).toStrictEqual([...vector.model]);
|
|
202
|
+
dd.remove();
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The half no pure test reaches: a replaced model must SPLICE the row buttons, not
|
|
207
|
+
// rebuild them. Identity is what says which happened — and it is what a user feels,
|
|
208
|
+
// because a rebuilt row loses its focus and the popover its scroll position.
|
|
209
|
+
for (const vector of LIST_ITEMS_CHANGED_VECTORS) {
|
|
210
|
+
await it(`items-changed — ${vector.rule}`, async () => {
|
|
211
|
+
const dd = makeDropDown();
|
|
212
|
+
dd.model = vector.previous;
|
|
213
|
+
const before = [...dd.querySelectorAll('.adw-drop-down-item')];
|
|
214
|
+
|
|
215
|
+
dd.model = vector.next;
|
|
216
|
+
|
|
217
|
+
const after = [...dd.querySelectorAll('.adw-drop-down-item')];
|
|
218
|
+
expect(after.map((item) => item.querySelector('.adw-drop-down-item-label')?.textContent)).toStrictEqual(
|
|
219
|
+
vector.next.map((item) => item.label),
|
|
220
|
+
);
|
|
221
|
+
expect(before.filter((item) => after.includes(item)).length).toBe(vector.survivors);
|
|
222
|
+
dd.remove();
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// WHO OWNS the array `model` hands back — the row buttons are what shows it, for
|
|
227
|
+
// the reason `adw-row-state.spec.ts` gives on the same table.
|
|
228
|
+
for (const vector of LIST_MODEL_OWNERSHIP_VECTORS) {
|
|
229
|
+
await it(`ownership — ${vector.rule}`, async () => {
|
|
230
|
+
const dd = makeDropDown();
|
|
231
|
+
const input = vector.initial.map((item) => ({ ...item }));
|
|
232
|
+
dd.model = input;
|
|
233
|
+
const before = [...dd.querySelectorAll('.adw-drop-down-item')];
|
|
234
|
+
|
|
235
|
+
const mutated = applyListReadback(vector.source === 'input' ? input : dd.model, vector.op);
|
|
236
|
+
if (vector.assign) dd.model = mutated;
|
|
237
|
+
|
|
238
|
+
expect(dd.model).toStrictEqual([...vector.model]);
|
|
239
|
+
const after = [...dd.querySelectorAll('.adw-drop-down-item')];
|
|
240
|
+
expect(after.map((item) => item.querySelector('.adw-drop-down-item-label')?.textContent)).toStrictEqual(
|
|
241
|
+
vector.model.map((item) => item.label),
|
|
242
|
+
);
|
|
243
|
+
expect(before.filter((item) => after.includes(item)).length).toBe(vector.survivors);
|
|
244
|
+
dd.remove();
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// The defect the splice exposed: each row's click handler used to close over the
|
|
249
|
+
// index it was BUILT at, which a rebuild always refreshed. Under a splice it does
|
|
250
|
+
// not, so the handler asks the list where the row is now.
|
|
251
|
+
await it('a row that a splice shifted still selects itself', async () => {
|
|
252
|
+
const dd = makeDropDown();
|
|
253
|
+
dd.model = ['b', 'c'];
|
|
254
|
+
const rowForB = dd.querySelectorAll('.adw-drop-down-item')[0] as HTMLButtonElement;
|
|
255
|
+
dd.model = ['a', 'b', 'c'];
|
|
256
|
+
expect(dd.querySelectorAll('.adw-drop-down-item')[1]).toBe(rowForB);
|
|
257
|
+
|
|
258
|
+
(dd.querySelector('.adw-drop-down-button') as HTMLButtonElement).click();
|
|
259
|
+
rowForB.click();
|
|
260
|
+
expect(dd.selectedValue).toBe('b');
|
|
261
|
+
dd.remove();
|
|
262
|
+
});
|
|
171
263
|
});
|
|
172
264
|
|
|
173
265
|
await describe('gtk-drop-down selected ↔ value sync', async () => {
|
|
174
266
|
await it('selectedValue setter finds the index by value', async () => {
|
|
175
267
|
const dd = makeDropDown();
|
|
176
|
-
dd.
|
|
268
|
+
dd.model = [
|
|
177
269
|
{ value: 'r', label: 'Red' },
|
|
178
270
|
{ value: 'g', label: 'Green' },
|
|
179
271
|
{ value: 'b', label: 'Blue' },
|
|
@@ -186,7 +278,7 @@ export const GtkDropDownTest = async () => {
|
|
|
186
278
|
|
|
187
279
|
await it('selected reflects to the attribute + marks the row', async () => {
|
|
188
280
|
const dd = makeDropDown();
|
|
189
|
-
dd.
|
|
281
|
+
dd.model = ['a', 'b'];
|
|
190
282
|
dd.selected = 1;
|
|
191
283
|
expect(dd.getAttribute('selected')).toBe('1');
|
|
192
284
|
const items = dd.querySelectorAll('.adw-drop-down-item');
|
|
@@ -197,7 +289,7 @@ export const GtkDropDownTest = async () => {
|
|
|
197
289
|
|
|
198
290
|
await it('selectedItem returns the descriptor', async () => {
|
|
199
291
|
const dd = makeDropDown();
|
|
200
|
-
dd.
|
|
292
|
+
dd.model = [{ value: 'k', label: 'Key' }];
|
|
201
293
|
expect(dd.selectedItem).toStrictEqual({ value: 'k', label: 'Key' });
|
|
202
294
|
dd.remove();
|
|
203
295
|
});
|
|
@@ -206,7 +298,7 @@ export const GtkDropDownTest = async () => {
|
|
|
206
298
|
// The property setters can run BEFORE connectedCallback builds the DOM, where
|
|
207
299
|
// _updateLabel would touch an undefined label element.
|
|
208
300
|
const dd = document.createElement('gtk-drop-down') as GtkDropDown;
|
|
209
|
-
dd.
|
|
301
|
+
dd.model = ['a', 'b', 'c'];
|
|
210
302
|
dd.selected = 2;
|
|
211
303
|
expect(dd.isConnected).toBe(false);
|
|
212
304
|
document.body.appendChild(dd);
|
|
@@ -220,7 +312,7 @@ export const GtkDropDownTest = async () => {
|
|
|
220
312
|
await describe('gtk-drop-down notify-on-change', async () => {
|
|
221
313
|
await it('a programmatic set fires notify::selected but NOT change (DOM <select> semantics)', async () => {
|
|
222
314
|
const dd = makeDropDown();
|
|
223
|
-
dd.
|
|
315
|
+
dd.model = ['a', 'b', 'c'];
|
|
224
316
|
let notifyCount = 0;
|
|
225
317
|
let changeCount = 0;
|
|
226
318
|
dd.addEventListener('notify::selected', () => notifyCount++);
|
|
@@ -243,7 +335,7 @@ export const GtkDropDownTest = async () => {
|
|
|
243
335
|
|
|
244
336
|
await it('a popover item click selects, fires change + closes', async () => {
|
|
245
337
|
const dd = makeDropDown();
|
|
246
|
-
dd.
|
|
338
|
+
dd.model = ['a', 'b', 'c'];
|
|
247
339
|
let change: unknown = null;
|
|
248
340
|
dd.addEventListener('change', (e) => {
|
|
249
341
|
change = (e as CustomEvent).detail;
|
|
@@ -262,7 +354,7 @@ export const GtkDropDownTest = async () => {
|
|
|
262
354
|
await describe('gtk-drop-down popover behaviour', async () => {
|
|
263
355
|
await it('opens on button click and closes on outside pointerdown', async () => {
|
|
264
356
|
const dd = makeDropDown();
|
|
265
|
-
dd.
|
|
357
|
+
dd.model = ['a', 'b'];
|
|
266
358
|
const button = dd.querySelector('.adw-drop-down-button') as HTMLButtonElement;
|
|
267
359
|
const popover = dd.querySelector('.adw-drop-down-popover') as HTMLElement;
|
|
268
360
|
expect(popover.hidden).toBe(true);
|
|
@@ -278,7 +370,7 @@ export const GtkDropDownTest = async () => {
|
|
|
278
370
|
|
|
279
371
|
await it('a disabled dropdown does not open', async () => {
|
|
280
372
|
const dd = makeDropDown();
|
|
281
|
-
dd.
|
|
373
|
+
dd.model = ['a', 'b'];
|
|
282
374
|
dd.setAttribute('disabled', '');
|
|
283
375
|
(dd.querySelector('.adw-drop-down-button') as HTMLButtonElement).click();
|
|
284
376
|
expect(dd.active).toBe(false);
|
|
@@ -289,7 +381,7 @@ export const GtkDropDownTest = async () => {
|
|
|
289
381
|
await describe('gtk-drop-down search', async () => {
|
|
290
382
|
await it('renders a search entry and filters the list', async () => {
|
|
291
383
|
const dd = makeDropDown();
|
|
292
|
-
dd.
|
|
384
|
+
dd.model = ['Apple', 'Banana', 'Cherry'];
|
|
293
385
|
dd.enableSearch = true;
|
|
294
386
|
const search = dd.querySelector('.adw-drop-down-search') as HTMLInputElement;
|
|
295
387
|
expect(search).toBeTruthy();
|
package/src/gtk-image.spec.ts
CHANGED
|
@@ -124,7 +124,7 @@ export const GtkImageTest = async () => {
|
|
|
124
124
|
|
|
125
125
|
await it('<gtk-menu-button> menu entries guard their icons too', () => {
|
|
126
126
|
const { el, host } = mount<GtkMenuButton>('gtk-menu-button');
|
|
127
|
-
el.
|
|
127
|
+
el.menuModel = [
|
|
128
128
|
{ label: 'Bad', icon: 'a b' },
|
|
129
129
|
{ label: 'Good', icon: 'view-refresh-symbolic' },
|
|
130
130
|
{ label: 'None' },
|
|
@@ -151,8 +151,8 @@ export const GtkImageTest = async () => {
|
|
|
151
151
|
<gtk-button icon="go-next" label="Next"></gtk-button>
|
|
152
152
|
<adw-button-row title="Row" start-icon-name="list-add" end-icon-name="go-next"></adw-button-row>
|
|
153
153
|
<adw-status-page icon="folder" title="Empty"></adw-status-page>
|
|
154
|
-
<adw-split-button label="Save" menu='[{"label":"Save As"}]'></adw-split-button>
|
|
155
|
-
<gtk-drop-down
|
|
154
|
+
<adw-split-button label="Save" menu-model='[{"label":"Save As"}]'></adw-split-button>
|
|
155
|
+
<gtk-drop-down model='["a","b"]'></gtk-drop-down>
|
|
156
156
|
<adw-expander-row title="More" show-enable-switch></adw-expander-row>
|
|
157
157
|
<adw-avatar text="Ada" icon="avatar-default"></adw-avatar>
|
|
158
158
|
<adw-toggle-group><adw-toggle icon-name="view-list"></adw-toggle></adw-toggle-group>
|