@gjsify/adwaita-web 0.47.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
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
// <adw-combo-row> (which embeds the same idea inside a boxed-list row) — reach
|
|
6
6
|
// for this one when you need a bare dropdown, not a row.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
// both value and label), supplied via the `
|
|
10
|
-
//
|
|
8
|
+
// The model is `{ value, label }` pairs (or plain strings, where the string is
|
|
9
|
+
// both value and label), supplied via the `model` property or a JSON `model`
|
|
10
|
+
// attribute — `Gtk.DropDown:model`'s own name (ADR 0034 clause 1, ADR 0046), which
|
|
11
|
+
// replaced the two spellings `options`/`items`. Optional `enable-search` puts a search entry atop
|
|
11
12
|
// the popover for long lists. The popover is dismissed on an outside click or
|
|
12
13
|
// Escape and supports arrow-key navigation, Home/End, Enter to pick, and
|
|
13
14
|
// type-ahead (typing jumps to the next matching option).
|
|
@@ -23,13 +24,21 @@
|
|
|
23
24
|
// copy is how ArrowUp from an unfocused list lands on the SECOND-TO-LAST option, which
|
|
24
25
|
// `enable-search` hits on every open because it focuses the entry.
|
|
25
26
|
//
|
|
27
|
+
// THE MODEL IS SPLICED, NOT REBUILT (ADR 0046). `ComboState` reports WHERE the model
|
|
28
|
+
// changed — `Gio.ListModel::items-changed`, one splice per assignment — so replacing a
|
|
29
|
+
// model that gained one item inserts ONE row button and leaves the rest, with their focus
|
|
30
|
+
// and their scroll position, alone. It also closed a latent defect the rebuild hid: each
|
|
31
|
+
// row's click handler used to close over the index it was built at, so any splice that
|
|
32
|
+
// shifted a row would have selected the wrong item. The handler now asks the list where
|
|
33
|
+
// the row IS.
|
|
34
|
+
//
|
|
26
35
|
// Attributes:
|
|
27
|
-
//
|
|
36
|
+
// model — JSON array: `["a","b"]` or `[{"value":"a","label":"A"}]`.
|
|
28
37
|
// selected — the selected index (number).
|
|
29
38
|
// enable-search — boolean; show a filtering search entry in the popover.
|
|
30
39
|
// disabled — boolean; a disabled dropdown does not open.
|
|
31
40
|
// Properties (mirroring Gtk.DropDown):
|
|
32
|
-
//
|
|
41
|
+
// model — the list model ({ value, label }[]) (get/set).
|
|
33
42
|
// selected — the selected index (get/set; bounds-guarded).
|
|
34
43
|
// selectedValue — the selected option's value ('' when empty) (get/set-by-value).
|
|
35
44
|
// selectedItem — the selected option descriptor (or null).
|
|
@@ -56,8 +65,8 @@
|
|
|
56
65
|
import './gtk-popover.js';
|
|
57
66
|
import type { GtkPopover } from './gtk-popover.js';
|
|
58
67
|
|
|
59
|
-
import { ComboState, normalizeComboOptions } from '@gjsify/adwaita-core';
|
|
60
|
-
import type { AdwComboOption,
|
|
68
|
+
import { ComboState, normalizeComboOptions, parseListModel } from '@gjsify/adwaita-core';
|
|
69
|
+
import type { AdwComboOption, AdwListItemsChanged, AdwListModelInput } from '@gjsify/adwaita-core';
|
|
61
70
|
|
|
62
71
|
import { createGtkImage } from './gtk-image.js';
|
|
63
72
|
|
|
@@ -82,27 +91,18 @@ export class GtkDropDown extends HTMLElement {
|
|
|
82
91
|
private _typeAheadTimer: ReturnType<typeof setTimeout> | null = null;
|
|
83
92
|
|
|
84
93
|
static get observedAttributes() {
|
|
85
|
-
return ['
|
|
94
|
+
return ['model', 'selected', 'enable-search', 'disabled'];
|
|
86
95
|
}
|
|
87
96
|
|
|
88
|
-
/** The
|
|
89
|
-
get
|
|
90
|
-
return this._state.
|
|
97
|
+
/** The list model (`Gtk.DropDown:model`). Setting it splices the popover and clamps the selection. */
|
|
98
|
+
get model(): GtkDropDownOption[] {
|
|
99
|
+
return this._state.model;
|
|
91
100
|
}
|
|
92
101
|
|
|
93
|
-
set
|
|
94
|
-
// `
|
|
102
|
+
set model(value: AdwListModelInput) {
|
|
103
|
+
// `setModel` resets the index to 0 when it no longer fits — the same
|
|
95
104
|
// clamp this element used to spell out.
|
|
96
|
-
this._state.
|
|
97
|
-
if (this._initialized) this._renderAll();
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
get items(): GtkDropDownOption[] {
|
|
101
|
-
return this._state.options;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
set items(value: ReadonlyArray<AdwComboOptionInput>) {
|
|
105
|
-
this.options = value;
|
|
105
|
+
this._state.setModel(normalizeComboOptions(value));
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
/** The selected index. Out-of-range values are ignored (Gtk.DropDown:selected). */
|
|
@@ -120,12 +120,12 @@ export class GtkDropDown extends HTMLElement {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
set selectedValue(value: string) {
|
|
123
|
-
const index = this._state.
|
|
123
|
+
const index = this._state.model.findIndex((o) => o.value === value);
|
|
124
124
|
if (index >= 0) this._selectIndex(index);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
get selectedItem(): GtkDropDownOption | null {
|
|
128
|
-
return this._state.
|
|
128
|
+
return this._state.model[this._state.selectedIndex] ?? null;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
get enableSearch(): boolean {
|
|
@@ -178,10 +178,14 @@ export class GtkDropDown extends HTMLElement {
|
|
|
178
178
|
|
|
179
179
|
// Seed options BEFORE anything subscribes, so building the initial DOM below is
|
|
180
180
|
// not driven by a change notification.
|
|
181
|
-
if (this._state.count === 0) this._state.
|
|
181
|
+
if (this._state.count === 0) this._state.setModel(parseListModel(this.getAttribute('model')));
|
|
182
182
|
const attrSelected = Number.parseInt(this.getAttribute('selected') ?? '', 10);
|
|
183
183
|
if (this._state.hasIndex(attrSelected)) this._state.setSelectedIndex(attrSelected);
|
|
184
184
|
|
|
185
|
+
// WHERE the model changed. Subscribed before the first render so every later
|
|
186
|
+
// assignment reaches the splice rather than a rebuild.
|
|
187
|
+
this._state.subscribeItems((change) => this._applyItemsChanged(change));
|
|
188
|
+
|
|
185
189
|
this.replaceChildren(this._buttonEl, this._popoverEl);
|
|
186
190
|
this._popoverEl.anchor = this._buttonEl;
|
|
187
191
|
this._popoverEl.subscribe((open) => this._onPopoverToggled(open));
|
|
@@ -194,9 +198,10 @@ export class GtkDropDown extends HTMLElement {
|
|
|
194
198
|
|
|
195
199
|
attributeChangedCallback(name: string, _old: string | null, value: string | null) {
|
|
196
200
|
if (!this._initialized || !this._buttonEl) return;
|
|
197
|
-
if (name === '
|
|
198
|
-
|
|
199
|
-
|
|
201
|
+
if (name === 'model') {
|
|
202
|
+
// No render call: the splice subscription does the DOM half, so the markup door
|
|
203
|
+
// and the property door cannot drift into two update paths.
|
|
204
|
+
this._state.setModel(parseListModel(value));
|
|
200
205
|
} else if (name === 'selected') {
|
|
201
206
|
const index = Number.parseInt(value ?? '', 10);
|
|
202
207
|
if (!Number.isNaN(index)) this._selectIndex(index, { fromAttr: true });
|
|
@@ -208,25 +213,13 @@ export class GtkDropDown extends HTMLElement {
|
|
|
208
213
|
}
|
|
209
214
|
}
|
|
210
215
|
|
|
211
|
-
private _parseOptionsAttr(): GtkDropDownOption[] {
|
|
212
|
-
const raw = this.getAttribute('options') ?? this.getAttribute('items');
|
|
213
|
-
if (!raw) return [];
|
|
214
|
-
try {
|
|
215
|
-
// `normalizeComboOptions` already guards a non-array, so a JSON object or
|
|
216
|
-
// scalar yields the empty model rather than a throw.
|
|
217
|
-
return normalizeComboOptions(JSON.parse(raw) as AdwComboOptionInput[]);
|
|
218
|
-
} catch {
|
|
219
|
-
return [];
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
216
|
private _selectIndex(index: number, opts: { fromAttr?: boolean; fromUser?: boolean } = {}): void {
|
|
224
217
|
// The bounds arithmetic is `ComboState`'s; the POLICY of rejecting an
|
|
225
218
|
// out-of-range set is this element's published `selected` contract, and
|
|
226
219
|
// `<adw-combo-row>` deliberately answers it the other way (`ComboState.hasIndex`).
|
|
227
220
|
if (!this._state.hasIndex(index)) return;
|
|
228
221
|
// Both core setters return "did it change", which is what gates the notify — no
|
|
229
|
-
// second copy of the current index is kept here, so `
|
|
222
|
+
// second copy of the current index is kept here, so `setModel`' own label
|
|
230
223
|
// re-sync cannot masquerade as one.
|
|
231
224
|
const changed = opts.fromUser ? this._state.select(index) : this._state.setSelectedIndex(index);
|
|
232
225
|
this._updateLabel();
|
|
@@ -236,7 +229,7 @@ export class GtkDropDown extends HTMLElement {
|
|
|
236
229
|
this.setAttribute('selected', String(index));
|
|
237
230
|
}
|
|
238
231
|
if (changed) {
|
|
239
|
-
const option = this._state.
|
|
232
|
+
const option = this._state.model[index];
|
|
240
233
|
// `notify::selected` mirrors GObject property-notify: EVERY change,
|
|
241
234
|
// programmatic included.
|
|
242
235
|
this.dispatchEvent(new CustomEvent('notify::selected', { bubbles: true, detail: { selected: index } }));
|
|
@@ -290,7 +283,7 @@ export class GtkDropDown extends HTMLElement {
|
|
|
290
283
|
this._typeAheadBuffer += char.toLowerCase();
|
|
291
284
|
if (this._typeAheadTimer) clearTimeout(this._typeAheadTimer);
|
|
292
285
|
this._typeAheadTimer = setTimeout(() => (this._typeAheadBuffer = ''), 800);
|
|
293
|
-
const match = this._state.
|
|
286
|
+
const match = this._state.model.findIndex((o) => o.label.toLowerCase().startsWith(this._typeAheadBuffer));
|
|
294
287
|
if (match < 0) return;
|
|
295
288
|
if (this._popoverEl.open) this._itemButtons[match]?.focus();
|
|
296
289
|
else this._selectIndex(match, { fromUser: true });
|
|
@@ -298,8 +291,11 @@ export class GtkDropDown extends HTMLElement {
|
|
|
298
291
|
|
|
299
292
|
private _filter(query: string): void {
|
|
300
293
|
const q = query.trim().toLowerCase();
|
|
294
|
+
// Hoisted for the reason `_applyItemsChanged` gives: this runs on every keystroke of
|
|
295
|
+
// the search field, over every row.
|
|
296
|
+
const model = this._state.model;
|
|
301
297
|
this._itemButtons.forEach((button, index) => {
|
|
302
|
-
const match = q.length === 0 || (
|
|
298
|
+
const match = q.length === 0 || (model[index]?.label.toLowerCase().includes(q) ?? false);
|
|
303
299
|
button.hidden = !match;
|
|
304
300
|
});
|
|
305
301
|
}
|
|
@@ -348,27 +344,8 @@ export class GtkDropDown extends HTMLElement {
|
|
|
348
344
|
if (existing) existing.remove();
|
|
349
345
|
}
|
|
350
346
|
|
|
351
|
-
for (const
|
|
352
|
-
const item =
|
|
353
|
-
item.type = 'button';
|
|
354
|
-
item.className = 'adw-popover-item adw-drop-down-item';
|
|
355
|
-
item.setAttribute('role', 'option');
|
|
356
|
-
item.tabIndex = -1;
|
|
357
|
-
|
|
358
|
-
const label = document.createElement('span');
|
|
359
|
-
label.className = 'adw-drop-down-item-label';
|
|
360
|
-
label.textContent = option.label;
|
|
361
|
-
|
|
362
|
-
const check = document.createElement('span');
|
|
363
|
-
check.className = 'adw-drop-down-item-check';
|
|
364
|
-
check.setAttribute('aria-hidden', 'true');
|
|
365
|
-
|
|
366
|
-
item.append(label, check);
|
|
367
|
-
item.addEventListener('click', () => {
|
|
368
|
-
this._selectIndex(index, { fromUser: true });
|
|
369
|
-
this._popoverEl.popdown();
|
|
370
|
-
this._buttonEl.focus();
|
|
371
|
-
});
|
|
347
|
+
for (const option of this._state.model) {
|
|
348
|
+
const item = this._createItem(option);
|
|
372
349
|
this._listEl.appendChild(item);
|
|
373
350
|
this._itemButtons.push(item);
|
|
374
351
|
}
|
|
@@ -376,6 +353,72 @@ export class GtkDropDown extends HTMLElement {
|
|
|
376
353
|
this._updateSelectedStates();
|
|
377
354
|
if (this._state.count === 0) this._popoverEl.popdown();
|
|
378
355
|
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* One row button.
|
|
359
|
+
*
|
|
360
|
+
* The click handler reads the row's CURRENT position out of `_itemButtons` instead of
|
|
361
|
+
* closing over the index it was built at. Under a full rebuild the two were always
|
|
362
|
+
* equal, which is why the closure survived; under a splice they are not, and a row
|
|
363
|
+
* that shifted by one would have selected its neighbour.
|
|
364
|
+
*/
|
|
365
|
+
private _createItem(option: GtkDropDownOption): HTMLButtonElement {
|
|
366
|
+
const item = document.createElement('button');
|
|
367
|
+
item.type = 'button';
|
|
368
|
+
item.className = 'adw-popover-item adw-drop-down-item';
|
|
369
|
+
item.setAttribute('role', 'option');
|
|
370
|
+
item.tabIndex = -1;
|
|
371
|
+
|
|
372
|
+
const label = document.createElement('span');
|
|
373
|
+
label.className = 'adw-drop-down-item-label';
|
|
374
|
+
label.textContent = option.label;
|
|
375
|
+
|
|
376
|
+
const check = document.createElement('span');
|
|
377
|
+
check.className = 'adw-drop-down-item-check';
|
|
378
|
+
check.setAttribute('aria-hidden', 'true');
|
|
379
|
+
|
|
380
|
+
item.append(label, check);
|
|
381
|
+
item.addEventListener('click', () => {
|
|
382
|
+
const index = this._itemButtons.indexOf(item);
|
|
383
|
+
if (index < 0) return;
|
|
384
|
+
this._selectIndex(index, { fromUser: true });
|
|
385
|
+
this._popoverEl.popdown();
|
|
386
|
+
this._buttonEl.focus();
|
|
387
|
+
});
|
|
388
|
+
return item;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Apply one `items-changed` to the row list — remove `removed` buttons at `position`,
|
|
393
|
+
* insert `added` fresh ones there, leave every other row (and its focus) standing.
|
|
394
|
+
*
|
|
395
|
+
* It owns everything a model replacement has to refresh, so the property door and the
|
|
396
|
+
* attribute door share one update path: the button label, the per-row selected state,
|
|
397
|
+
* and popping the popover down when the model empties under it.
|
|
398
|
+
*/
|
|
399
|
+
private _applyItemsChanged(change: AdwListItemsChanged): void {
|
|
400
|
+
if (!this._initialized) return;
|
|
401
|
+
for (const removedButton of this._itemButtons.splice(change.position, change.removed)) {
|
|
402
|
+
removedButton.remove();
|
|
403
|
+
}
|
|
404
|
+
const before = this._itemButtons[change.position] ?? null;
|
|
405
|
+
const inserted: HTMLButtonElement[] = [];
|
|
406
|
+
// Read ONCE: `model` hands back a copy (ADR 0046), so reading it per item would make
|
|
407
|
+
// a splice of n items O(n²) instead of the O(n) the splice is.
|
|
408
|
+
const model = this._state.model;
|
|
409
|
+
for (let i = 0; i < change.added; i++) {
|
|
410
|
+
const option = model[change.position + i];
|
|
411
|
+
if (!option) continue;
|
|
412
|
+
const item = this._createItem(option);
|
|
413
|
+
this._listEl.insertBefore(item, before);
|
|
414
|
+
inserted.push(item);
|
|
415
|
+
}
|
|
416
|
+
this._itemButtons.splice(change.position, 0, ...inserted);
|
|
417
|
+
|
|
418
|
+
this._updateSelectedStates();
|
|
419
|
+
this._updateLabel();
|
|
420
|
+
if (this._state.count === 0) this._popoverEl.popdown();
|
|
421
|
+
}
|
|
379
422
|
}
|
|
380
423
|
|
|
381
424
|
customElements.define('gtk-drop-down', GtkDropDown);
|
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
// `normalizeIconName` guard is what keeps a multi-token `icon-name="a b"` from
|
|
10
10
|
// shipping a stray CSS class — for the button icon and for every menu entry's.
|
|
11
11
|
//
|
|
12
|
-
// The `menu` attribute is a JSON array
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// twin.
|
|
12
|
+
// The `menu-model` attribute is a JSON array in the portable menu model (ADR 0042) —
|
|
13
|
+
// items, sections and submenus, with the same attributes GTK reads. Choosing an item
|
|
14
|
+
// closes the popover and fires `menu-item-activated` (CustomEvent, bubbles, detail
|
|
15
|
+
// `{ id, label, path }`) with `id` falling back to `label`, matching the NS twin. The
|
|
16
|
+
// rows themselves are `PopoverMenuView`'s, shared with `<adw-split-button>`: the split
|
|
17
|
+
// button's dropdown half IS a GtkMenuButton, so one popup implementation is the whole
|
|
18
|
+
// point.
|
|
16
19
|
//
|
|
17
20
|
// Reference: refs/gtk/gtk/gtkmenubutton.c (GtkMenuButton)
|
|
18
21
|
// Reference: refs/libadwaita/src/stylesheet/widgets/_buttons.scss (menubutton)
|
|
@@ -22,12 +25,18 @@
|
|
|
22
25
|
// Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
|
|
23
26
|
|
|
24
27
|
import {
|
|
28
|
+
ADW_MENU_SURFACE_WEB,
|
|
29
|
+
assertMenuRenderable,
|
|
25
30
|
isSplitButtonDirection,
|
|
31
|
+
menuRefusals,
|
|
26
32
|
menuButtonPopupDirection,
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
menuItemAt,
|
|
34
|
+
normalizeMenuModel,
|
|
35
|
+
parseMenuModel,
|
|
29
36
|
} from '@gjsify/adwaita-core';
|
|
30
|
-
import type {
|
|
37
|
+
import type { AdwMenuActions, AdwMenuInput, AdwMenuModel, SplitButtonDirection } from '@gjsify/adwaita-core';
|
|
38
|
+
|
|
39
|
+
import { PopoverMenuView } from './popover-menu.js';
|
|
31
40
|
|
|
32
41
|
// SIDE-EFFECT import, deliberately separate from the type import below: it guarantees
|
|
33
42
|
// `gtk-popover` is defined before this module's `customElements.define` can upgrade a
|
|
@@ -40,25 +49,6 @@ import type { GtkPopover } from './gtk-popover.js';
|
|
|
40
49
|
|
|
41
50
|
import { createGtkImage } from './gtk-image.js';
|
|
42
51
|
|
|
43
|
-
/**
|
|
44
|
-
* A menu entry — `@gjsify/adwaita-core`'s {@link AdwMenuEntry} under the name both
|
|
45
|
-
* renderers export it as.
|
|
46
|
-
*
|
|
47
|
-
* IT KEEPS THE `Adw` PREFIX WHILE THE ELEMENT ABOVE IT DOES NOT, and the reason is not
|
|
48
|
-
* "the name it has always used" — ADR 0034 clause 1 just moved every name in this file
|
|
49
|
-
* that had one. It is that this is not a WIDGET name: clause 1 is about the tag, and
|
|
50
|
-
* this is the descriptor a consumer writes menu entries with. `@gjsify/adwaita-
|
|
51
|
-
* nativescript` exports `AdwMenuItem` for the same type, and that port deliberately
|
|
52
|
-
* keeps `Adw` throughout, so renaming this half would give one object two spellings —
|
|
53
|
-
* which is the thing the ADR is removing, not applying. (`GtkDropDownOption` next door
|
|
54
|
-
* DID move: its NativeScript twin exports no such name, so there was nothing to split.)
|
|
55
|
-
*
|
|
56
|
-
* It used to be a SECOND declaration of the same shape, and the NativeScript menu
|
|
57
|
-
* button declared a THIRD that was missing `icon` entirely. One type, so a consumer
|
|
58
|
-
* writing menu entries writes the same object for either renderer.
|
|
59
|
-
*/
|
|
60
|
-
export type AdwMenuItem = AdwMenuEntry;
|
|
61
|
-
|
|
62
52
|
/**
|
|
63
53
|
* Where the popover sits, per `GtkArrowType`. `menuButtonPopupDirection` folds `none`
|
|
64
54
|
* onto `down` — GtkMenuButton's rule, not ours; this table is only the
|
|
@@ -77,13 +67,14 @@ const POPOVER_POSITIONS = {
|
|
|
77
67
|
export class GtkMenuButton extends HTMLElement {
|
|
78
68
|
private _buttonEl!: HTMLButtonElement;
|
|
79
69
|
private _popoverEl!: GtkPopover;
|
|
80
|
-
private
|
|
70
|
+
private _model: AdwMenuModel = [];
|
|
71
|
+
private _actions: AdwMenuActions | null = null;
|
|
81
72
|
private _menuTitle = '';
|
|
82
73
|
private _initialized = false;
|
|
83
|
-
private
|
|
74
|
+
private _menuView!: PopoverMenuView;
|
|
84
75
|
|
|
85
76
|
static get observedAttributes() {
|
|
86
|
-
return ['icon-name', 'menu-title', 'menu', 'disabled', 'flat', 'circular', 'direction'];
|
|
77
|
+
return ['icon-name', 'menu-title', 'menu-model', 'disabled', 'flat', 'circular', 'direction'];
|
|
87
78
|
}
|
|
88
79
|
|
|
89
80
|
/** Whether the popover menu is currently open. */
|
|
@@ -91,13 +82,69 @@ export class GtkMenuButton extends HTMLElement {
|
|
|
91
82
|
return this._popoverEl?.open ?? false;
|
|
92
83
|
}
|
|
93
84
|
|
|
94
|
-
/** The menu
|
|
95
|
-
get
|
|
96
|
-
return this.
|
|
85
|
+
/** The menu, normalised (ADR 0042). Setting rebuilds the popover. */
|
|
86
|
+
get menuModel(): AdwMenuModel {
|
|
87
|
+
return this._model;
|
|
97
88
|
}
|
|
98
89
|
|
|
99
|
-
|
|
100
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Set the menu from anything the portable model accepts. Refused HERE, at the
|
|
92
|
+
* assignment, for a `custom` item this surface cannot host — see the split button's
|
|
93
|
+
* note on the same setter.
|
|
94
|
+
*/
|
|
95
|
+
set menuModel(value: AdwMenuInput) {
|
|
96
|
+
this._model = this._acceptMenu(value);
|
|
97
|
+
if (this._initialized) this._renderMenu();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Normalise, REFUSE, store — the one door every menu comes through.
|
|
102
|
+
*
|
|
103
|
+
* The property setter is not the only way a menu arrives: an attribute is, and the
|
|
104
|
+
* first cut refused only the setter, so a `custom` item written in markup drew
|
|
105
|
+
* exactly the row `assertMenuRenderable` exists to prevent — one that reads like a
|
|
106
|
+
* command and does nothing.
|
|
107
|
+
*
|
|
108
|
+
* THE TWO DOORS REFUSE DIFFERENTLY, and the difference is which side can hear it.
|
|
109
|
+
* A property assignment is a CALL, so it throws and the caller can catch. An
|
|
110
|
+
* attribute is MARKUP, parsed by the browser: a throw from `connectedCallback` is
|
|
111
|
+
* not delivered to whoever appended the element, it is reported as an uncaught
|
|
112
|
+
* page error — measured, it broke `adwaita-upgrade-order.spec.ts`, which counts
|
|
113
|
+
* exactly those. Nobody can handle it and everybody else pays for it. So the
|
|
114
|
+
* attribute path REFUSES THE MENU (no menu, and a menu-less dropdown is
|
|
115
|
+
* insensitive, which is visible) and says why on `console.error`.
|
|
116
|
+
*
|
|
117
|
+
* A REFUSAL AND A TYPO ARE STILL DIFFERENT THINGS. `parseMenuModel` is total
|
|
118
|
+
* because malformed JSON is an author slip and must not stop the element
|
|
119
|
+
* upgrading; `custom` is well-formed, deliberate, and unhonourable here.
|
|
120
|
+
*/
|
|
121
|
+
private _acceptMenu(input: AdwMenuInput, strict = true): AdwMenuModel {
|
|
122
|
+
const model = normalizeMenuModel(input);
|
|
123
|
+
if (strict) {
|
|
124
|
+
assertMenuRenderable(model, ADW_MENU_SURFACE_WEB);
|
|
125
|
+
return model;
|
|
126
|
+
}
|
|
127
|
+
const refusals = menuRefusals(model, ADW_MENU_SURFACE_WEB);
|
|
128
|
+
if (refusals.length === 0) return model;
|
|
129
|
+
// `console.error` is the browser's own channel for "this is wrong, the page
|
|
130
|
+
// continues" — and the alternative here is a row that lies.
|
|
131
|
+
console.error(`${ADW_MENU_SURFACE_WEB.name} cannot render this menu, so it has none:`);
|
|
132
|
+
for (const refusal of refusals) console.error(` [${refusal.path.join('.')}] ${refusal.message}`);
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* What the action group publishes about the actions this menu names — the portable
|
|
138
|
+
* stand-in for a `GActionGroup`, and the only source of a menu's enabled/checked
|
|
139
|
+
* state (ADR 0042).
|
|
140
|
+
*/
|
|
141
|
+
get actions(): AdwMenuActions | null {
|
|
142
|
+
return this._actions;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
set actions(value: AdwMenuActions | null) {
|
|
146
|
+
this._actions = value ?? null;
|
|
147
|
+
this._menuView?.setActions(this._actions);
|
|
101
148
|
if (this._initialized) this._renderMenu();
|
|
102
149
|
}
|
|
103
150
|
|
|
@@ -145,15 +192,29 @@ export class GtkMenuButton extends HTMLElement {
|
|
|
145
192
|
this._popoverEl.anchor = this._buttonEl;
|
|
146
193
|
this._popoverEl.subscribe((open) => this._onPopoverToggled(open));
|
|
147
194
|
|
|
195
|
+
this._menuView = new PopoverMenuView(this._popoverEl, 'adw-menu-button', (path) => {
|
|
196
|
+
const item = menuItemAt(this._model, path);
|
|
197
|
+
if (item === null) return;
|
|
198
|
+
this._popoverEl.popdown();
|
|
199
|
+
this._buttonEl.focus();
|
|
200
|
+
this.dispatchEvent(
|
|
201
|
+
new CustomEvent('menu-item-activated', {
|
|
202
|
+
bubbles: true,
|
|
203
|
+
detail: { id: item.id ?? item.label, label: item.label, path: [...path] },
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
this._menuView.setActions(this._actions);
|
|
208
|
+
|
|
148
209
|
this._buttonEl.addEventListener('click', () => {
|
|
149
210
|
if (this.hasAttribute('disabled')) return;
|
|
150
211
|
// Nothing to show is not a menu — never open an empty popover.
|
|
151
|
-
if (!this._popoverEl.open && this.
|
|
212
|
+
if (!this._popoverEl.open && this._model.length === 0) return;
|
|
152
213
|
this._popoverEl.open = !this._popoverEl.open;
|
|
153
214
|
});
|
|
154
215
|
|
|
155
|
-
// Seed
|
|
156
|
-
if (this.
|
|
216
|
+
// Seed the menu from the attribute if the property was not set.
|
|
217
|
+
if (this._model.length === 0) this._model = this._parseMenuAttr();
|
|
157
218
|
// The flat default is set only now the DOM exists, so the observed-attribute
|
|
158
219
|
// callback (which renders) never runs before `_buttonEl` is created.
|
|
159
220
|
if (!this.hasAttribute('flat') && !this.hasAttribute('circular')) this.setAttribute('flat', '');
|
|
@@ -162,8 +223,8 @@ export class GtkMenuButton extends HTMLElement {
|
|
|
162
223
|
|
|
163
224
|
attributeChangedCallback(name: string) {
|
|
164
225
|
if (!this._initialized || !this._buttonEl) return;
|
|
165
|
-
if (name === 'menu') {
|
|
166
|
-
this.
|
|
226
|
+
if (name === 'menu-model') {
|
|
227
|
+
this._model = this._parseMenuAttr();
|
|
167
228
|
this._renderMenu();
|
|
168
229
|
return;
|
|
169
230
|
}
|
|
@@ -171,21 +232,24 @@ export class GtkMenuButton extends HTMLElement {
|
|
|
171
232
|
}
|
|
172
233
|
|
|
173
234
|
/**
|
|
174
|
-
* The `menu` attribute, through the core parser
|
|
235
|
+
* The `menu-model` attribute, through the core parser both elements share.
|
|
175
236
|
*
|
|
176
237
|
* The copy this replaces was weaker in a way markup can reach: it kept `id` and
|
|
177
238
|
* `icon` WHATEVER their runtime type, so `{"label":"Open","id":7}` produced an
|
|
178
239
|
* entry whose `id` is a number in a field typed `string` — and `id` is what the
|
|
179
|
-
* activation event reports. The core parser keeps only string-typed
|
|
240
|
+
* activation event reports. The core parser keeps only string-typed attributes.
|
|
180
241
|
*/
|
|
181
|
-
private _parseMenuAttr():
|
|
182
|
-
return
|
|
242
|
+
private _parseMenuAttr(): AdwMenuModel {
|
|
243
|
+
return this._acceptMenu(parseMenuModel(this.getAttribute('menu-model')), false);
|
|
183
244
|
}
|
|
184
245
|
|
|
185
246
|
private _onPopoverToggled(open: boolean): void {
|
|
186
247
|
this.classList.toggle('active', open);
|
|
187
248
|
this._buttonEl.setAttribute('aria-expanded', String(open));
|
|
188
|
-
if (open) this.
|
|
249
|
+
if (open) this._menuView.focusableRows[0]?.focus();
|
|
250
|
+
// A dismissal returns the popup to its top page: a menu that reopens inside a
|
|
251
|
+
// submenu is one the reader cannot get out of.
|
|
252
|
+
else this._menuView.reset();
|
|
189
253
|
}
|
|
190
254
|
|
|
191
255
|
private _render(): void {
|
|
@@ -207,50 +271,10 @@ export class GtkMenuButton extends HTMLElement {
|
|
|
207
271
|
}
|
|
208
272
|
|
|
209
273
|
private _renderMenu(): void {
|
|
210
|
-
this.
|
|
211
|
-
this.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (title) {
|
|
215
|
-
const titleEl = document.createElement('div');
|
|
216
|
-
titleEl.className = 'adw-popover-title adw-menu-button-title';
|
|
217
|
-
titleEl.textContent = title;
|
|
218
|
-
this._popoverEl.appendChild(titleEl);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
for (const [index, entry] of this._items.entries()) {
|
|
222
|
-
const item = document.createElement('button');
|
|
223
|
-
item.type = 'button';
|
|
224
|
-
item.className = 'adw-popover-item adw-menu-button-item';
|
|
225
|
-
item.setAttribute('role', 'menuitem');
|
|
226
|
-
item.tabIndex = -1;
|
|
227
|
-
|
|
228
|
-
// An entry that asked for NO icon gets no icon node — the emptiness of the
|
|
229
|
-
// declared name, not whether it resolved. Testing `resolvedIconName` dropped
|
|
230
|
-
// the node for a name that was given and merely undrawable, which is the one
|
|
231
|
-
// case a reader needs to SEE; `<gtk-image>` draws `image-missing` there.
|
|
232
|
-
const entryIcon = createGtkImage(entry.icon ?? null, 'adw-menu-button-item-icon');
|
|
233
|
-
if (stringIsNotEmpty(entry.icon)) item.appendChild(entryIcon);
|
|
234
|
-
const labelEl = document.createElement('span');
|
|
235
|
-
labelEl.className = 'adw-menu-button-item-label';
|
|
236
|
-
labelEl.textContent = entry.label;
|
|
237
|
-
item.appendChild(labelEl);
|
|
238
|
-
|
|
239
|
-
item.addEventListener('click', () => {
|
|
240
|
-
this._popoverEl.popdown();
|
|
241
|
-
this._buttonEl.focus();
|
|
242
|
-
this.dispatchEvent(
|
|
243
|
-
new CustomEvent('menu-item-activated', {
|
|
244
|
-
bubbles: true,
|
|
245
|
-
detail: { id: entry.id ?? entry.label, label: entry.label, index },
|
|
246
|
-
}),
|
|
247
|
-
);
|
|
248
|
-
});
|
|
249
|
-
this._popoverEl.appendChild(item);
|
|
250
|
-
this._itemButtons.push(item);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if (this._items.length === 0) this._popoverEl.popdown();
|
|
274
|
+
this._menuView.setMenuModel(this._model);
|
|
275
|
+
this._menuView.setTitle(this.getAttribute('menu-title') ?? this._menuTitle);
|
|
276
|
+
this._menuView.render();
|
|
277
|
+
if (this._model.length === 0) this._popoverEl.popdown();
|
|
254
278
|
}
|
|
255
279
|
}
|
|
256
280
|
|
|
@@ -139,9 +139,24 @@ export class GtkPopover extends HTMLElement {
|
|
|
139
139
|
return ROLES.includes(value as GtkPopoverRole) ? (value as GtkPopoverRole) : 'menu';
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* The navigable rows, in DOM order.
|
|
144
|
+
*
|
|
145
|
+
* HIDDEN rows are skipped — a filtered list navigates what it shows. DISABLED rows
|
|
146
|
+
* are skipped for a harder reason: a disabled `<button>` cannot take focus at all, so
|
|
147
|
+
* `items[index].focus()` is a NO-OP and the arrow key reads as dead. Keeping one in
|
|
148
|
+
* the walk is the same defect a roving tabindex with no keys behind it has, one row
|
|
149
|
+
* wide — measured on a menu whose action group reported an action disabled
|
|
150
|
+
* (ADR 0042): ArrowDown onto it left `document.activeElement` where it was.
|
|
151
|
+
*
|
|
152
|
+
* GTK skips them for the same reason and says so in the general rule rather than in
|
|
153
|
+
* the menu: `gtk_widget_focus_move` walks only widgets `gtk_widget_get_can_focus`
|
|
154
|
+
* accepts, and an insensitive widget is not one.
|
|
155
|
+
*/
|
|
143
156
|
get items(): HTMLElement[] {
|
|
144
|
-
return [...this.querySelectorAll<HTMLElement>(ITEM_SELECTOR)].filter(
|
|
157
|
+
return [...this.querySelectorAll<HTMLElement>(ITEM_SELECTOR)].filter(
|
|
158
|
+
(item) => !item.hidden && !(item as HTMLButtonElement).disabled,
|
|
159
|
+
);
|
|
145
160
|
}
|
|
146
161
|
|
|
147
162
|
/** Show the popover — `gtk_popover_popup()`. */
|