@gjsify/adwaita-web 0.14.0 → 0.16.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 +58 -0
- package/dist/adwaita-web.css +617 -0
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/adw-data-grid.spec.d.ts +1 -0
- package/lib/types/adw-dialog.spec.d.ts +1 -0
- package/lib/types/adw-drop-down.spec.d.ts +1 -0
- package/lib/types/elements/adw-data-grid.d.ts +60 -0
- package/lib/types/elements/adw-dialog.d.ts +51 -0
- package/lib/types/elements/adw-drop-down.d.ts +63 -0
- package/lib/types/elements/adw-menu-button.d.ts +34 -0
- package/lib/types/elements/adw-view-stack.d.ts +37 -0
- package/lib/types/elements/adw-view-switcher-bar.d.ts +27 -0
- package/lib/types/index.d.ts +11 -0
- package/lib/types/source-view/adw-source-view.d.ts +75 -0
- package/lib/types/source-view/asm6502.d.ts +28 -0
- package/lib/types/source-view/hex.d.ts +27 -0
- package/lib/types/source-view/index.d.ts +4 -0
- package/lib/types/source-view/theme.d.ts +15 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +24 -12
- package/scss/_data_grid.scss +144 -0
- package/scss/_dialog.scss +220 -0
- package/scss/_drop_down.scss +185 -0
- package/scss/_menu_button.scss +151 -0
- package/scss/_root.scss +22 -0
- package/scss/_theme.scss +5 -0
- package/scss/_variables.scss +9 -0
- package/scss/_view_stack.scss +29 -0
- package/scss/_view_switcher_bar.scss +94 -0
- package/scss/adwaita-skin.scss +7 -0
- package/src/adw-data-grid.spec.ts +243 -0
- package/src/adw-dialog.spec.ts +169 -0
- package/src/adw-drop-down.spec.ts +186 -0
- package/src/elements/adw-data-grid.ts +335 -0
- package/src/elements/adw-dialog.ts +329 -0
- package/src/elements/adw-drop-down.ts +405 -0
- package/src/elements/adw-menu-button.ts +247 -0
- package/src/elements/adw-view-stack.ts +174 -0
- package/src/elements/adw-view-switcher-bar.ts +168 -0
- package/src/index.ts +16 -0
- package/src/source-view/adw-source-view.ts +436 -0
- package/src/source-view/asm6502.ts +232 -0
- package/src/source-view/hex.ts +43 -0
- package/src/source-view/index.ts +28 -0
- package/src/source-view/theme.ts +224 -0
- package/src/test.browser.mts +14 -0
- package/src/index.js +0 -1
- package/src/styles.generated.js +0 -2976
- package/src/styles2.generated.js +0 -2976
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
// <adw-drop-down> — A standalone dropdown selector: the web counterpart of
|
|
2
|
+
// Gtk.DropDown. It is a flat Adwaita button showing the current selection (its
|
|
3
|
+
// label + a `pan-down` chevron) that opens an Adwaita popover list of options;
|
|
4
|
+
// picking one updates the selection. This is the toolbar/inline control twin of
|
|
5
|
+
// <adw-combo-row> (which embeds the same idea inside a boxed-list row) — reach
|
|
6
|
+
// for this one when you need a bare dropdown, not a row.
|
|
7
|
+
//
|
|
8
|
+
// Options are `{ value, label }` pairs (or plain strings, where the string is
|
|
9
|
+
// both value and label), supplied via the `options` / `items` property or a
|
|
10
|
+
// JSON `options` attribute. Optional `enable-search` puts a search entry atop
|
|
11
|
+
// the popover for long lists. The popover is dismissed on an outside click or
|
|
12
|
+
// Escape and supports arrow-key navigation, Home/End, Enter to pick, and
|
|
13
|
+
// type-ahead (typing jumps to the next matching option).
|
|
14
|
+
//
|
|
15
|
+
// Attributes:
|
|
16
|
+
// options / items — JSON array: `["a","b"]` or `[{"value":"a","label":"A"}]`.
|
|
17
|
+
// selected — the selected index (number).
|
|
18
|
+
// enable-search — boolean; show a filtering search entry in the popover.
|
|
19
|
+
// disabled — boolean; a disabled dropdown does not open.
|
|
20
|
+
// Properties (mirroring Gtk.DropDown):
|
|
21
|
+
// options / items — the option list ({ value, label }[]) (get/set).
|
|
22
|
+
// selected — the selected index (get/set; bounds-guarded).
|
|
23
|
+
// selectedValue — the selected option's value ('' when empty) (get/set-by-value).
|
|
24
|
+
// selectedItem — the selected option descriptor (or null).
|
|
25
|
+
// enableSearch — whether the search entry is shown (get/set).
|
|
26
|
+
// active — whether the popover is open (get).
|
|
27
|
+
// Events:
|
|
28
|
+
// `notify::selected` (CustomEvent, bubbles, detail = { selected }) — the index
|
|
29
|
+
// changed (mirrors the Gtk.DropDown `notify::selected` GObject signal).
|
|
30
|
+
// `change` (CustomEvent, bubbles, detail = { index, value, label }) —
|
|
31
|
+
// a convenience event carrying the full selection.
|
|
32
|
+
// Reference: refs/libadwaita/src/stylesheet/widgets/_menus.scss (popover surface)
|
|
33
|
+
// Reference: refs/adwaita-web (adw-combo-row dropdown-arrow mask technique)
|
|
34
|
+
// Copyright (c) GNOME contributors (libadwaita) / the GTK team. LGPLv2.1+.
|
|
35
|
+
// Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
|
|
36
|
+
|
|
37
|
+
/** A dropdown option — `value` is the stable id, `label` the shown text. */
|
|
38
|
+
export interface AdwDropDownOption {
|
|
39
|
+
value: string;
|
|
40
|
+
label: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type RawOption = string | { value?: unknown; label?: unknown };
|
|
44
|
+
|
|
45
|
+
export class AdwDropDown extends HTMLElement {
|
|
46
|
+
private _buttonEl!: HTMLButtonElement;
|
|
47
|
+
private _labelEl!: HTMLSpanElement;
|
|
48
|
+
private _popoverEl!: HTMLDivElement;
|
|
49
|
+
private _listEl!: HTMLDivElement;
|
|
50
|
+
private _searchEl: HTMLInputElement | null = null;
|
|
51
|
+
private _options: AdwDropDownOption[] = [];
|
|
52
|
+
private _itemButtons: HTMLButtonElement[] = [];
|
|
53
|
+
private _selected = 0;
|
|
54
|
+
private _active = false;
|
|
55
|
+
private _initialized = false;
|
|
56
|
+
private _typeAheadBuffer = '';
|
|
57
|
+
private _typeAheadTimer: ReturnType<typeof setTimeout> | null = null;
|
|
58
|
+
|
|
59
|
+
private _onDocumentPointerDown = (event: Event): void => {
|
|
60
|
+
if (!this.contains(event.target as Node)) this._setActive(false);
|
|
61
|
+
};
|
|
62
|
+
private _onDocumentKeyDown = (event: KeyboardEvent): void => {
|
|
63
|
+
if (event.key === 'Escape') {
|
|
64
|
+
event.stopPropagation();
|
|
65
|
+
this._setActive(false);
|
|
66
|
+
this._buttonEl.focus();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
static get observedAttributes() {
|
|
71
|
+
return ['options', 'items', 'selected', 'enable-search', 'disabled'];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The options list. Setting it rebuilds the popover and clamps the selection. */
|
|
75
|
+
get options(): AdwDropDownOption[] {
|
|
76
|
+
return this._options;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
set options(value: ReadonlyArray<RawOption>) {
|
|
80
|
+
this._options = normalizeOptions(value);
|
|
81
|
+
if (this._selected >= this._options.length) this._selected = 0;
|
|
82
|
+
if (this._initialized) this._renderAll();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Alias of `options` (Gtk.DropDown's model is often called "items"). */
|
|
86
|
+
get items(): AdwDropDownOption[] {
|
|
87
|
+
return this._options;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
set items(value: ReadonlyArray<RawOption>) {
|
|
91
|
+
this.options = value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The selected index. Out-of-range values are ignored (Gtk.DropDown:selected). */
|
|
95
|
+
get selected(): number {
|
|
96
|
+
return this._selected;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
set selected(value: number) {
|
|
100
|
+
this._selectIndex(value);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The selected option's value, or '' when empty (Gtk.DropDown:selected-item). */
|
|
104
|
+
get selectedValue(): string {
|
|
105
|
+
return this._options[this._selected]?.value ?? '';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
set selectedValue(value: string) {
|
|
109
|
+
const index = this._options.findIndex((o) => o.value === value);
|
|
110
|
+
if (index >= 0) this._selectIndex(index);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The selected option descriptor, or null when empty. */
|
|
114
|
+
get selectedItem(): AdwDropDownOption | null {
|
|
115
|
+
return this._options[this._selected] ?? null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Whether the search entry is shown in the popover. */
|
|
119
|
+
get enableSearch(): boolean {
|
|
120
|
+
return this.hasAttribute('enable-search');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
set enableSearch(value: boolean) {
|
|
124
|
+
if (value) this.setAttribute('enable-search', '');
|
|
125
|
+
else this.removeAttribute('enable-search');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Whether the popover is currently open. */
|
|
129
|
+
get active(): boolean {
|
|
130
|
+
return this._active;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
connectedCallback() {
|
|
134
|
+
if (this._initialized) return;
|
|
135
|
+
this._initialized = true;
|
|
136
|
+
|
|
137
|
+
this.classList.add('adw-drop-down');
|
|
138
|
+
|
|
139
|
+
this._buttonEl = document.createElement('button');
|
|
140
|
+
this._buttonEl.type = 'button';
|
|
141
|
+
this._buttonEl.className = 'adw-drop-down-button';
|
|
142
|
+
this._buttonEl.setAttribute('aria-haspopup', 'listbox');
|
|
143
|
+
this._buttonEl.setAttribute('aria-expanded', 'false');
|
|
144
|
+
|
|
145
|
+
this._labelEl = document.createElement('span');
|
|
146
|
+
this._labelEl.className = 'adw-drop-down-label';
|
|
147
|
+
|
|
148
|
+
const arrow = document.createElement('span');
|
|
149
|
+
arrow.className = 'adw-icon adw-icon--go-down adw-drop-down-arrow';
|
|
150
|
+
arrow.setAttribute('aria-hidden', 'true');
|
|
151
|
+
|
|
152
|
+
this._buttonEl.append(this._labelEl, arrow);
|
|
153
|
+
this._buttonEl.addEventListener('click', () => {
|
|
154
|
+
if (this.hasAttribute('disabled')) return;
|
|
155
|
+
this._setActive(!this._active);
|
|
156
|
+
});
|
|
157
|
+
this._buttonEl.addEventListener('keydown', (event) => this._onButtonKeyDown(event));
|
|
158
|
+
|
|
159
|
+
this._popoverEl = document.createElement('div');
|
|
160
|
+
this._popoverEl.className = 'adw-drop-down-popover';
|
|
161
|
+
this._popoverEl.setAttribute('role', 'listbox');
|
|
162
|
+
this._popoverEl.hidden = true;
|
|
163
|
+
this._popoverEl.addEventListener('keydown', (event) => this._onPopoverKeyDown(event));
|
|
164
|
+
|
|
165
|
+
this._listEl = document.createElement('div');
|
|
166
|
+
this._listEl.className = 'adw-drop-down-list';
|
|
167
|
+
this._popoverEl.appendChild(this._listEl);
|
|
168
|
+
|
|
169
|
+
// Seed options from the property (already set) or the JSON attribute.
|
|
170
|
+
if (this._options.length === 0) this._options = this._parseOptionsAttr();
|
|
171
|
+
const attrSelected = Number.parseInt(this.getAttribute('selected') ?? '', 10);
|
|
172
|
+
if (!Number.isNaN(attrSelected) && attrSelected >= 0 && attrSelected < this._options.length) {
|
|
173
|
+
this._selected = attrSelected;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this.replaceChildren(this._buttonEl, this._popoverEl);
|
|
177
|
+
this._renderAll();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
disconnectedCallback() {
|
|
181
|
+
document.removeEventListener('pointerdown', this._onDocumentPointerDown);
|
|
182
|
+
document.removeEventListener('keydown', this._onDocumentKeyDown, true);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
attributeChangedCallback(name: string, _old: string | null, value: string | null) {
|
|
186
|
+
if (!this._initialized || !this._buttonEl) return;
|
|
187
|
+
if (name === 'options' || name === 'items') {
|
|
188
|
+
this._options = this._parseOptionsAttr();
|
|
189
|
+
if (this._selected >= this._options.length) this._selected = 0;
|
|
190
|
+
this._renderAll();
|
|
191
|
+
} else if (name === 'selected') {
|
|
192
|
+
const index = Number.parseInt(value ?? '', 10);
|
|
193
|
+
if (!Number.isNaN(index)) this._selectIndex(index);
|
|
194
|
+
} else if (name === 'enable-search') {
|
|
195
|
+
this._renderPopover();
|
|
196
|
+
} else if (name === 'disabled') {
|
|
197
|
+
this._buttonEl.disabled = this.hasAttribute('disabled');
|
|
198
|
+
if (this.hasAttribute('disabled')) this._setActive(false);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── internals ──────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
private _parseOptionsAttr(): AdwDropDownOption[] {
|
|
205
|
+
const raw = this.getAttribute('options') ?? this.getAttribute('items');
|
|
206
|
+
if (!raw) return [];
|
|
207
|
+
try {
|
|
208
|
+
const parsed: unknown = JSON.parse(raw);
|
|
209
|
+
return Array.isArray(parsed) ? normalizeOptions(parsed as RawOption[]) : [];
|
|
210
|
+
} catch {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private _selectIndex(index: number, fromAttr = false): void {
|
|
216
|
+
if (!Number.isFinite(index) || index < 0 || index >= this._options.length) return;
|
|
217
|
+
const changed = index !== this._selected;
|
|
218
|
+
this._selected = index;
|
|
219
|
+
this._updateLabel();
|
|
220
|
+
this._updateSelectedStates();
|
|
221
|
+
// Keep the attribute in sync without re-entering the guarded callback.
|
|
222
|
+
if (!fromAttr && this.getAttribute('selected') !== String(index)) {
|
|
223
|
+
this.setAttribute('selected', String(index));
|
|
224
|
+
}
|
|
225
|
+
// Notify only on an actual change (GObject notify semantics).
|
|
226
|
+
if (changed) {
|
|
227
|
+
const option = this._options[index];
|
|
228
|
+
this.dispatchEvent(new CustomEvent('notify::selected', { bubbles: true, detail: { selected: index } }));
|
|
229
|
+
this.dispatchEvent(
|
|
230
|
+
new CustomEvent('change', {
|
|
231
|
+
bubbles: true,
|
|
232
|
+
detail: { index, value: option?.value ?? '', label: option?.label ?? '' },
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private _setActive(active: boolean): void {
|
|
239
|
+
if (this._active === active) return;
|
|
240
|
+
if (active && (this._options.length === 0 || this.hasAttribute('disabled'))) return;
|
|
241
|
+
this._active = active;
|
|
242
|
+
this._popoverEl.hidden = !active;
|
|
243
|
+
this.classList.toggle('active', active);
|
|
244
|
+
this._buttonEl.setAttribute('aria-expanded', String(active));
|
|
245
|
+
if (active) {
|
|
246
|
+
this._filter('');
|
|
247
|
+
document.addEventListener('pointerdown', this._onDocumentPointerDown);
|
|
248
|
+
// Capture Escape before it reaches the button/popover handlers.
|
|
249
|
+
document.addEventListener('keydown', this._onDocumentKeyDown, true);
|
|
250
|
+
if (this._searchEl) {
|
|
251
|
+
this._searchEl.value = '';
|
|
252
|
+
this._searchEl.focus();
|
|
253
|
+
} else {
|
|
254
|
+
(this._visibleItems()[this._selected] ?? this._visibleItems()[0])?.focus();
|
|
255
|
+
}
|
|
256
|
+
} else {
|
|
257
|
+
document.removeEventListener('pointerdown', this._onDocumentPointerDown);
|
|
258
|
+
document.removeEventListener('keydown', this._onDocumentKeyDown, true);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private _onButtonKeyDown(event: KeyboardEvent): void {
|
|
263
|
+
if (this.hasAttribute('disabled')) return;
|
|
264
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter' || event.key === ' ') {
|
|
265
|
+
event.preventDefault();
|
|
266
|
+
this._setActive(true);
|
|
267
|
+
} else if (event.key.length === 1 && !this._active) {
|
|
268
|
+
// Type-ahead directly on the closed button (Gtk.DropDown behaviour).
|
|
269
|
+
this._typeAhead(event.key);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private _onPopoverKeyDown(event: KeyboardEvent): void {
|
|
274
|
+
const items = this._visibleItems();
|
|
275
|
+
if (items.length === 0) return;
|
|
276
|
+
const current = items.indexOf(document.activeElement as HTMLButtonElement);
|
|
277
|
+
if (event.key === 'ArrowDown') {
|
|
278
|
+
event.preventDefault();
|
|
279
|
+
items[(current + 1 + items.length) % items.length]?.focus();
|
|
280
|
+
} else if (event.key === 'ArrowUp') {
|
|
281
|
+
event.preventDefault();
|
|
282
|
+
items[(current - 1 + items.length) % items.length]?.focus();
|
|
283
|
+
} else if (event.key === 'Home') {
|
|
284
|
+
event.preventDefault();
|
|
285
|
+
items[0]?.focus();
|
|
286
|
+
} else if (event.key === 'End') {
|
|
287
|
+
event.preventDefault();
|
|
288
|
+
items[items.length - 1]?.focus();
|
|
289
|
+
} else if ((event.key === 'Enter' || event.key === ' ') && current >= 0) {
|
|
290
|
+
event.preventDefault();
|
|
291
|
+
items[current]?.click();
|
|
292
|
+
} else if (!this._searchEl && event.key.length === 1) {
|
|
293
|
+
this._typeAhead(event.key);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Type-ahead: accumulate keys, focus/select the next matching option. */
|
|
298
|
+
private _typeAhead(char: string): void {
|
|
299
|
+
this._typeAheadBuffer += char.toLowerCase();
|
|
300
|
+
if (this._typeAheadTimer) clearTimeout(this._typeAheadTimer);
|
|
301
|
+
this._typeAheadTimer = setTimeout(() => (this._typeAheadBuffer = ''), 800);
|
|
302
|
+
const match = this._options.findIndex((o) => o.label.toLowerCase().startsWith(this._typeAheadBuffer));
|
|
303
|
+
if (match < 0) return;
|
|
304
|
+
if (this._active) this._itemButtons[match]?.focus();
|
|
305
|
+
else this._selectIndex(match);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Filter the visible items by a search query (case-insensitive substring). */
|
|
309
|
+
private _filter(query: string): void {
|
|
310
|
+
const q = query.trim().toLowerCase();
|
|
311
|
+
this._itemButtons.forEach((button, index) => {
|
|
312
|
+
const match = q.length === 0 || (this._options[index]?.label.toLowerCase().includes(q) ?? false);
|
|
313
|
+
button.hidden = !match;
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private _visibleItems(): HTMLButtonElement[] {
|
|
318
|
+
return this._itemButtons.filter((b) => !b.hidden);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private _renderAll(): void {
|
|
322
|
+
this._buttonEl.disabled = this.hasAttribute('disabled');
|
|
323
|
+
this.classList.toggle('disabled', this.hasAttribute('disabled'));
|
|
324
|
+
if (this._selected >= this._options.length) this._selected = 0;
|
|
325
|
+
this._renderPopover();
|
|
326
|
+
this._updateLabel();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private _updateLabel(): void {
|
|
330
|
+
// The setters (`options`/`selected`) may run before connectedCallback
|
|
331
|
+
// builds the DOM — stay a no-op until the label element exists.
|
|
332
|
+
if (!this._labelEl) return;
|
|
333
|
+
this._labelEl.textContent = this._options[this._selected]?.label ?? '';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private _updateSelectedStates(): void {
|
|
337
|
+
this._itemButtons.forEach((button, index) => {
|
|
338
|
+
const isSelected = index === this._selected;
|
|
339
|
+
button.classList.toggle('selected', isSelected);
|
|
340
|
+
button.setAttribute('aria-selected', String(isSelected));
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private _renderPopover(): void {
|
|
345
|
+
this._listEl.replaceChildren();
|
|
346
|
+
this._itemButtons = [];
|
|
347
|
+
this._searchEl = null;
|
|
348
|
+
|
|
349
|
+
if (this.enableSearch) {
|
|
350
|
+
this._searchEl = document.createElement('input');
|
|
351
|
+
this._searchEl.type = 'text';
|
|
352
|
+
this._searchEl.className = 'adw-drop-down-search adw-entry';
|
|
353
|
+
this._searchEl.placeholder = 'Search…';
|
|
354
|
+
this._searchEl.setAttribute('aria-label', 'Search');
|
|
355
|
+
this._searchEl.addEventListener('input', () => this._filter(this._searchEl?.value ?? ''));
|
|
356
|
+
// Prepend before the list so it sits atop the options.
|
|
357
|
+
this._popoverEl.insertBefore(this._searchEl, this._listEl);
|
|
358
|
+
} else {
|
|
359
|
+
const existing = this._popoverEl.querySelector('.adw-drop-down-search');
|
|
360
|
+
if (existing) existing.remove();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
for (const [index, option] of this._options.entries()) {
|
|
364
|
+
const item = document.createElement('button');
|
|
365
|
+
item.type = 'button';
|
|
366
|
+
item.className = 'adw-drop-down-item';
|
|
367
|
+
item.setAttribute('role', 'option');
|
|
368
|
+
item.tabIndex = -1;
|
|
369
|
+
|
|
370
|
+
const label = document.createElement('span');
|
|
371
|
+
label.className = 'adw-drop-down-item-label';
|
|
372
|
+
label.textContent = option.label;
|
|
373
|
+
|
|
374
|
+
// A check icon marks the selected option (Gtk.DropDown's selected row).
|
|
375
|
+
const check = document.createElement('span');
|
|
376
|
+
check.className = 'adw-drop-down-item-check';
|
|
377
|
+
check.setAttribute('aria-hidden', 'true');
|
|
378
|
+
|
|
379
|
+
item.append(label, check);
|
|
380
|
+
item.addEventListener('click', () => {
|
|
381
|
+
this._selectIndex(index);
|
|
382
|
+
this._setActive(false);
|
|
383
|
+
this._buttonEl.focus();
|
|
384
|
+
});
|
|
385
|
+
this._listEl.appendChild(item);
|
|
386
|
+
this._itemButtons.push(item);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
this._updateSelectedStates();
|
|
390
|
+
if (this._options.length === 0) this._setActive(false);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Normalise raw options (strings or `{value,label}`) to a stable descriptor list. */
|
|
395
|
+
function normalizeOptions(raw: ReadonlyArray<RawOption>): AdwDropDownOption[] {
|
|
396
|
+
if (!Array.isArray(raw)) return [];
|
|
397
|
+
return raw.map((entry) => {
|
|
398
|
+
if (typeof entry === 'string') return { value: entry, label: entry };
|
|
399
|
+
const value = entry.value !== undefined ? String(entry.value) : String(entry.label ?? '');
|
|
400
|
+
const label = entry.label !== undefined ? String(entry.label) : value;
|
|
401
|
+
return { value, label };
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
customElements.define('adw-drop-down', AdwDropDown);
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// <adw-menu-button> — An icon button that opens a popover menu, the web
|
|
2
|
+
// counterpart of Adw.MenuButton / a Gtk.MenuButton. By default it is the flat
|
|
3
|
+
// `open-menu-symbolic` primary/app-menu button used at the end of a header bar
|
|
4
|
+
// (About / Preferences / Keyboard Shortcuts / Quit …). Clicking it toggles a
|
|
5
|
+
// real Adwaita-styled popover positioned under the button; the popover is
|
|
6
|
+
// dismissed on an outside click or Escape and supports arrow-key navigation.
|
|
7
|
+
//
|
|
8
|
+
// Attributes:
|
|
9
|
+
// icon-name — symbolic icon (no `-symbolic` suffix), default `open-menu`.
|
|
10
|
+
// menu-title — optional heading shown atop the popover.
|
|
11
|
+
// menu — JSON array of items, each `{ "id"?, "label", "icon"? }`.
|
|
12
|
+
// disabled — boolean; a disabled button does not open the menu.
|
|
13
|
+
// flat — boolean style class (default: flat, like the header app menu).
|
|
14
|
+
// circular — boolean style class (round 34×34 button).
|
|
15
|
+
// Properties (mirroring Adw.MenuButton / the NS twin):
|
|
16
|
+
// menuItems — the menu entries ({ id?, label, icon? }[]) (get/set).
|
|
17
|
+
// menuTitle — the popover heading (get/set).
|
|
18
|
+
// active — whether the popover is open (get).
|
|
19
|
+
// Events:
|
|
20
|
+
// `menu-item-activated` (CustomEvent, bubbles, detail = { id, label, index })
|
|
21
|
+
// when an item is chosen — `id` falls back to `label` when omitted (matches
|
|
22
|
+
// the NS twin). The popover closes on activation.
|
|
23
|
+
// Reference: refs/libadwaita/src/adw-menu-button.c (AdwMenuButton)
|
|
24
|
+
// Reference: refs/libadwaita/src/stylesheet/widgets/_menus.scss (popover.menu / modelbutton)
|
|
25
|
+
// Reference: packages/nativescript-bridge/adwaita/src/widgets/adw-menu-button.ts (NS twin)
|
|
26
|
+
// Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
|
|
27
|
+
// Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
|
|
28
|
+
|
|
29
|
+
/** A menu entry. `id` defaults to `label` on activation; `icon` is optional. */
|
|
30
|
+
export interface AdwMenuItem {
|
|
31
|
+
id?: string;
|
|
32
|
+
label: string;
|
|
33
|
+
icon?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class AdwMenuButton extends HTMLElement {
|
|
37
|
+
private _buttonEl!: HTMLButtonElement;
|
|
38
|
+
private _popoverEl!: HTMLDivElement;
|
|
39
|
+
private _items: AdwMenuItem[] = [];
|
|
40
|
+
private _menuTitle = '';
|
|
41
|
+
private _active = false;
|
|
42
|
+
private _initialized = false;
|
|
43
|
+
private _itemButtons: HTMLButtonElement[] = [];
|
|
44
|
+
private _onDocumentPointerDown = (event: Event): void => {
|
|
45
|
+
if (!this.contains(event.target as Node)) this._setActive(false);
|
|
46
|
+
};
|
|
47
|
+
private _onDocumentKeyDown = (event: KeyboardEvent): void => {
|
|
48
|
+
if (event.key === 'Escape') {
|
|
49
|
+
this._setActive(false);
|
|
50
|
+
this._buttonEl.focus();
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
static get observedAttributes() {
|
|
55
|
+
return ['icon-name', 'menu-title', 'menu', 'disabled', 'flat', 'circular'];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether the popover menu is currently open. */
|
|
59
|
+
get active(): boolean {
|
|
60
|
+
return this._active;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The menu entries. Setting rebuilds the popover. */
|
|
64
|
+
get menuItems(): AdwMenuItem[] {
|
|
65
|
+
return this._items;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
set menuItems(value: AdwMenuItem[]) {
|
|
69
|
+
this._items = Array.isArray(value) ? value.map((it) => ({ ...it })) : [];
|
|
70
|
+
if (this._initialized) this._renderMenu();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The optional popover heading. */
|
|
74
|
+
get menuTitle(): string {
|
|
75
|
+
return this.getAttribute('menu-title') ?? this._menuTitle;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
set menuTitle(value: string) {
|
|
79
|
+
this._menuTitle = value ?? '';
|
|
80
|
+
this.setAttribute('menu-title', this._menuTitle);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
connectedCallback() {
|
|
84
|
+
if (this._initialized) return;
|
|
85
|
+
this._initialized = true;
|
|
86
|
+
|
|
87
|
+
this.classList.add('adw-menu-button');
|
|
88
|
+
|
|
89
|
+
this._buttonEl = document.createElement('button');
|
|
90
|
+
this._buttonEl.type = 'button';
|
|
91
|
+
this._buttonEl.className = 'adw-menu-button-button';
|
|
92
|
+
this._buttonEl.setAttribute('aria-haspopup', 'menu');
|
|
93
|
+
this._buttonEl.setAttribute('aria-expanded', 'false');
|
|
94
|
+
|
|
95
|
+
this._popoverEl = document.createElement('div');
|
|
96
|
+
this._popoverEl.className = 'adw-menu-button-popover';
|
|
97
|
+
this._popoverEl.setAttribute('role', 'menu');
|
|
98
|
+
this._popoverEl.hidden = true;
|
|
99
|
+
this._popoverEl.addEventListener('keydown', (e) => this._onMenuKeyDown(e));
|
|
100
|
+
|
|
101
|
+
this.replaceChildren(this._buttonEl, this._popoverEl);
|
|
102
|
+
|
|
103
|
+
this._buttonEl.addEventListener('click', () => {
|
|
104
|
+
if (this.hasAttribute('disabled')) return;
|
|
105
|
+
this._setActive(!this._active);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Seed items from the `menu` attribute if the property was not set.
|
|
109
|
+
if (this._items.length === 0) this._items = this._parseMenuAttr();
|
|
110
|
+
// Default to the flat header idiom unless another variant is requested.
|
|
111
|
+
// Set only now the DOM exists, so the observed-attribute callback (which
|
|
112
|
+
// renders) never runs before `_buttonEl` is created.
|
|
113
|
+
if (!this.hasAttribute('flat') && !this.hasAttribute('circular')) this.setAttribute('flat', '');
|
|
114
|
+
this._render();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
disconnectedCallback() {
|
|
118
|
+
document.removeEventListener('pointerdown', this._onDocumentPointerDown);
|
|
119
|
+
document.removeEventListener('keydown', this._onDocumentKeyDown);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
attributeChangedCallback(name: string) {
|
|
123
|
+
if (!this._initialized || !this._buttonEl) return;
|
|
124
|
+
if (name === 'menu') {
|
|
125
|
+
this._items = this._parseMenuAttr();
|
|
126
|
+
this._renderMenu();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
this._render();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private _parseMenuAttr(): AdwMenuItem[] {
|
|
133
|
+
const raw = this.getAttribute('menu');
|
|
134
|
+
if (!raw) return [];
|
|
135
|
+
try {
|
|
136
|
+
const parsed: unknown = JSON.parse(raw);
|
|
137
|
+
if (!Array.isArray(parsed)) return [];
|
|
138
|
+
return parsed
|
|
139
|
+
.filter((e): e is AdwMenuItem => typeof e === 'object' && e !== null && 'label' in e)
|
|
140
|
+
.map((e) => ({ id: e.id, label: String(e.label), icon: e.icon }));
|
|
141
|
+
} catch {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private _setActive(active: boolean): void {
|
|
147
|
+
if (this._active === active) return;
|
|
148
|
+
// Only open when there is something to show.
|
|
149
|
+
if (active && this._items.length === 0) return;
|
|
150
|
+
this._active = active;
|
|
151
|
+
this._popoverEl.hidden = !active;
|
|
152
|
+
this.classList.toggle('active', active);
|
|
153
|
+
this._buttonEl.setAttribute('aria-expanded', String(active));
|
|
154
|
+
if (active) {
|
|
155
|
+
document.addEventListener('pointerdown', this._onDocumentPointerDown);
|
|
156
|
+
document.addEventListener('keydown', this._onDocumentKeyDown);
|
|
157
|
+
// Move focus into the menu for keyboard navigation.
|
|
158
|
+
this._itemButtons[0]?.focus();
|
|
159
|
+
} else {
|
|
160
|
+
document.removeEventListener('pointerdown', this._onDocumentPointerDown);
|
|
161
|
+
document.removeEventListener('keydown', this._onDocumentKeyDown);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private _onMenuKeyDown(event: KeyboardEvent): void {
|
|
166
|
+
const buttons = this._itemButtons;
|
|
167
|
+
if (buttons.length === 0) return;
|
|
168
|
+
const current = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
|
169
|
+
let next = -1;
|
|
170
|
+
if (event.key === 'ArrowDown') next = (current + 1) % buttons.length;
|
|
171
|
+
else if (event.key === 'ArrowUp') next = (current - 1 + buttons.length) % buttons.length;
|
|
172
|
+
else if (event.key === 'Home') next = 0;
|
|
173
|
+
else if (event.key === 'End') next = buttons.length - 1;
|
|
174
|
+
else return;
|
|
175
|
+
event.preventDefault();
|
|
176
|
+
buttons[next]?.focus();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private _render(): void {
|
|
180
|
+
const disabled = this.hasAttribute('disabled');
|
|
181
|
+
this.classList.toggle('disabled', disabled);
|
|
182
|
+
this._buttonEl.disabled = disabled;
|
|
183
|
+
this.classList.toggle('flat', this.hasAttribute('flat'));
|
|
184
|
+
this.classList.toggle('circular', this.hasAttribute('circular'));
|
|
185
|
+
|
|
186
|
+
const icon = (this.getAttribute('icon-name') ?? 'open-menu').replace(/-symbolic$/, '');
|
|
187
|
+
this._buttonEl.replaceChildren();
|
|
188
|
+
const iconEl = document.createElement('span');
|
|
189
|
+
iconEl.className = `adw-icon adw-menu-button-icon${icon ? ` adw-icon--${icon}` : ''}`;
|
|
190
|
+
iconEl.setAttribute('aria-hidden', 'true'); // decorative (mask-image only)
|
|
191
|
+
this._buttonEl.appendChild(iconEl);
|
|
192
|
+
this._buttonEl.setAttribute('aria-label', this.getAttribute('menu-title') || 'Menu');
|
|
193
|
+
|
|
194
|
+
this._renderMenu();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private _renderMenu(): void {
|
|
198
|
+
this._popoverEl.replaceChildren();
|
|
199
|
+
this._itemButtons = [];
|
|
200
|
+
|
|
201
|
+
const title = this.getAttribute('menu-title') ?? this._menuTitle;
|
|
202
|
+
if (title) {
|
|
203
|
+
const titleEl = document.createElement('div');
|
|
204
|
+
titleEl.className = 'adw-menu-button-title';
|
|
205
|
+
titleEl.textContent = title;
|
|
206
|
+
this._popoverEl.appendChild(titleEl);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const [index, entry] of this._items.entries()) {
|
|
210
|
+
const item = document.createElement('button');
|
|
211
|
+
item.type = 'button';
|
|
212
|
+
item.className = 'adw-menu-button-item';
|
|
213
|
+
item.setAttribute('role', 'menuitem');
|
|
214
|
+
item.tabIndex = -1;
|
|
215
|
+
|
|
216
|
+
const iconName = (entry.icon ?? '').replace(/-symbolic$/, '');
|
|
217
|
+
if (iconName) {
|
|
218
|
+
const iconEl = document.createElement('span');
|
|
219
|
+
iconEl.className = `adw-icon adw-menu-button-item-icon adw-icon--${iconName}`;
|
|
220
|
+
iconEl.setAttribute('aria-hidden', 'true');
|
|
221
|
+
item.appendChild(iconEl);
|
|
222
|
+
}
|
|
223
|
+
const labelEl = document.createElement('span');
|
|
224
|
+
labelEl.className = 'adw-menu-button-item-label';
|
|
225
|
+
labelEl.textContent = entry.label;
|
|
226
|
+
item.appendChild(labelEl);
|
|
227
|
+
|
|
228
|
+
item.addEventListener('click', () => {
|
|
229
|
+
this._setActive(false);
|
|
230
|
+
this._buttonEl.focus();
|
|
231
|
+
this.dispatchEvent(
|
|
232
|
+
new CustomEvent('menu-item-activated', {
|
|
233
|
+
bubbles: true,
|
|
234
|
+
detail: { id: entry.id ?? entry.label, label: entry.label, index },
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
this._popoverEl.appendChild(item);
|
|
239
|
+
this._itemButtons.push(item);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// A menu-button with no entries can never open.
|
|
243
|
+
if (this._items.length === 0) this._setActive(false);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
customElements.define('adw-menu-button', AdwMenuButton);
|