@gjsify/adwaita-web 0.47.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/adwaita-web.css +62 -0
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/elements/adw-combo-row.d.ts +56 -0
- package/lib/types/elements/adw-spin-row.d.ts +11 -1
- package/lib/types/elements/adw-split-button.d.ts +43 -3
- package/lib/types/elements/adw-tab-view.d.ts +23 -5
- package/lib/types/elements/adw-view-stack.d.ts +17 -2
- package/lib/types/elements/gtk-drop-down.d.ts +22 -7
- package/lib/types/elements/gtk-menu-button.d.ts +43 -26
- package/lib/types/elements/gtk-popover.d.ts +14 -1
- package/lib/types/elements/popover-menu.d.ts +99 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/shared-trees.spec.d.ts +2 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +6 -6
- package/scss/_popover.scss +99 -0
- package/src/adw-avatar.spec.ts +17 -0
- package/src/adw-row-state.spec.ts +335 -17
- package/src/adw-tab-view.spec.ts +8 -5
- package/src/elements/adw-avatar.ts +11 -3
- package/src/elements/adw-combo-row.ts +161 -22
- package/src/elements/adw-spin-row.ts +49 -28
- package/src/elements/adw-split-button.ts +117 -39
- package/src/elements/adw-tab-view.ts +42 -22
- package/src/elements/adw-view-stack.ts +20 -4
- package/src/elements/adw-view-switcher-bar.ts +6 -3
- package/src/elements/gtk-drop-down.ts +109 -66
- package/src/elements/gtk-menu-button.ts +112 -88
- package/src/elements/gtk-popover.ts +17 -2
- package/src/elements/popover-menu.ts +366 -0
- package/src/gtk-drop-down.spec.ts +117 -25
- package/src/gtk-image.spec.ts +3 -3
- package/src/gtk-menu-button.spec.ts +165 -11
- package/src/index.ts +14 -1
- package/src/keyboard-operable.spec.ts +170 -1
- package/src/shared-trees.spec.ts +151 -0
- package/src/split-button.spec.ts +36 -22
- package/src/styles.generated.ts +1 -1
- package/src/tab-view.spec.ts +83 -15
- package/src/test.browser.mts +2 -0
- package/src/view-stack.spec.ts +5 -3
|
@@ -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()`. */
|