@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.
Files changed (42) hide show
  1. package/README.md +3 -3
  2. package/dist/adwaita-web.css +62 -0
  3. package/dist/adwaita-web.css.map +1 -1
  4. package/lib/types/elements/adw-combo-row.d.ts +56 -0
  5. package/lib/types/elements/adw-spin-row.d.ts +11 -1
  6. package/lib/types/elements/adw-split-button.d.ts +43 -3
  7. package/lib/types/elements/adw-tab-view.d.ts +23 -5
  8. package/lib/types/elements/adw-view-stack.d.ts +17 -2
  9. package/lib/types/elements/gtk-drop-down.d.ts +22 -7
  10. package/lib/types/elements/gtk-menu-button.d.ts +43 -26
  11. package/lib/types/elements/gtk-popover.d.ts +14 -1
  12. package/lib/types/elements/popover-menu.d.ts +99 -0
  13. package/lib/types/index.d.ts +1 -1
  14. package/lib/types/shared-trees.spec.d.ts +2 -0
  15. package/lib/types/styles.generated.d.ts +1 -1
  16. package/package.json +6 -6
  17. package/scss/_popover.scss +99 -0
  18. package/src/adw-avatar.spec.ts +17 -0
  19. package/src/adw-row-state.spec.ts +335 -17
  20. package/src/adw-tab-view.spec.ts +8 -5
  21. package/src/elements/adw-avatar.ts +11 -3
  22. package/src/elements/adw-combo-row.ts +161 -22
  23. package/src/elements/adw-spin-row.ts +49 -28
  24. package/src/elements/adw-split-button.ts +117 -39
  25. package/src/elements/adw-tab-view.ts +42 -22
  26. package/src/elements/adw-view-stack.ts +20 -4
  27. package/src/elements/adw-view-switcher-bar.ts +6 -3
  28. package/src/elements/gtk-drop-down.ts +109 -66
  29. package/src/elements/gtk-menu-button.ts +112 -88
  30. package/src/elements/gtk-popover.ts +17 -2
  31. package/src/elements/popover-menu.ts +366 -0
  32. package/src/gtk-drop-down.spec.ts +117 -25
  33. package/src/gtk-image.spec.ts +3 -3
  34. package/src/gtk-menu-button.spec.ts +165 -11
  35. package/src/index.ts +14 -1
  36. package/src/keyboard-operable.spec.ts +170 -1
  37. package/src/shared-trees.spec.ts +151 -0
  38. package/src/split-button.spec.ts +36 -22
  39. package/src/styles.generated.ts +1 -1
  40. package/src/tab-view.spec.ts +83 -15
  41. package/src/test.browser.mts +2 -0
  42. 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 of `{ "id"?, "label", "icon"? }`. Choosing an
13
- // item closes the popover and fires `menu-item-activated` (CustomEvent, bubbles,
14
- // detail `{ id, label, index }`) with `id` falling back to `label`, matching the NS
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
- parseMenuEntries,
28
- stringIsNotEmpty,
33
+ menuItemAt,
34
+ normalizeMenuModel,
35
+ parseMenuModel,
29
36
  } from '@gjsify/adwaita-core';
30
- import type { AdwMenuEntry, SplitButtonDirection } from '@gjsify/adwaita-core';
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 _items: AdwMenuItem[] = [];
70
+ private _model: AdwMenuModel = [];
71
+ private _actions: AdwMenuActions | null = null;
81
72
  private _menuTitle = '';
82
73
  private _initialized = false;
83
- private _itemButtons: HTMLButtonElement[] = [];
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 entries. Setting rebuilds the popover. */
95
- get menuItems(): AdwMenuItem[] {
96
- return this._items;
85
+ /** The menu, normalised (ADR 0042). Setting rebuilds the popover. */
86
+ get menuModel(): AdwMenuModel {
87
+ return this._model;
97
88
  }
98
89
 
99
- set menuItems(value: AdwMenuItem[]) {
100
- this._items = Array.isArray(value) ? value.map((it) => ({ ...it })) : [];
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._items.length === 0) return;
212
+ if (!this._popoverEl.open && this._model.length === 0) return;
152
213
  this._popoverEl.open = !this._popoverEl.open;
153
214
  });
154
215
 
155
- // Seed items from the `menu` attribute if the property was not set.
156
- if (this._items.length === 0) this._items = this._parseMenuAttr();
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._items = this._parseMenuAttr();
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 the split button already used.
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 extras.
240
+ * activation event reports. The core parser keeps only string-typed attributes.
180
241
  */
181
- private _parseMenuAttr(): AdwMenuItem[] {
182
- return parseMenuEntries(this.getAttribute('menu'));
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._itemButtons[0]?.focus();
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._popoverEl.replaceChildren();
211
- this._itemButtons = [];
212
-
213
- const title = this.getAttribute('menu-title') ?? this._menuTitle;
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
- /** The navigable rows, in DOM order. Hidden rows are skipped — a filtered list navigates what it shows. */
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((item) => !item.hidden);
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()`. */