@gjsify/adwaita-web 0.15.1 → 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.
@@ -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);
package/src/index.ts CHANGED
@@ -24,6 +24,13 @@ if (typeof document !== 'undefined' && !document.getElementById('adwaita-web-sty
24
24
  export { AdwAvatar } from './elements/adw-avatar.js';
25
25
  export { AdwBanner } from './elements/adw-banner.js';
26
26
  export { AdwCard } from './elements/adw-card.js';
27
+ export { AdwDataGrid } from './elements/adw-data-grid.js';
28
+ export type {
29
+ AdwDataGridAlign,
30
+ AdwDataGridColumn,
31
+ AdwDataGridRow,
32
+ AdwDataGridRowVariant,
33
+ } from './elements/adw-data-grid.js';
27
34
  export { AdwClamp } from './elements/adw-clamp.js';
28
35
  export { AdwSpinner } from './elements/adw-spinner.js';
29
36
  export { AdwStatusPage } from './elements/adw-status-page.js';
@@ -58,9 +65,13 @@ export type { AdwViewStackPageInfo } from './elements/adw-view-stack.js';
58
65
  export { AdwViewSwitcherBar } from './elements/adw-view-switcher-bar.js';
59
66
  export { AdwMenuButton } from './elements/adw-menu-button.js';
60
67
  export type { AdwMenuItem } from './elements/adw-menu-button.js';
68
+ export { AdwDropDown } from './elements/adw-drop-down.js';
69
+ export type { AdwDropDownOption } from './elements/adw-drop-down.js';
61
70
  export { AdwNavigationPage, AdwNavigationView } from './elements/adw-navigation-view.js';
62
71
  export { AdwBottomSheet, AdwBottomSheetContent, AdwBottomSheetSheet } from './elements/adw-bottom-sheet.js';
63
72
  export { AdwSidebar, AdwSidebarItem, AdwSidebarSection } from './elements/adw-sidebar.js';
64
73
  export { AdwAboutDialog } from './elements/adw-about-dialog.js';
65
74
  export { AdwAlertDialog, AdwAlertResponse } from './elements/adw-alert-dialog.js';
66
75
  export { AdwPreferencesDialog, AdwPreferencesPage } from './elements/adw-preferences-dialog.js';
76
+ export { AdwDialog } from './elements/adw-dialog.js';
77
+ export type { AdwDialogPresentationMode } from './elements/adw-dialog.js';
@@ -0,0 +1,14 @@
1
+ // Browser test entry for @gjsify/adwaita-web. Importing the package root
2
+ // registers every custom element AND self-applies the stylesheet, so the specs
3
+ // only have to create + drive the elements. Discovered + run by the
4
+ // tests/browser Playwright harness (see AGENTS.md → Browser tests).
5
+ import { run } from '@gjsify/unit';
6
+
7
+ // Registers the custom elements (side-effect) + injects the compiled CSS.
8
+ import '@gjsify/adwaita-web';
9
+
10
+ import { AdwDataGridTest } from './adw-data-grid.spec.js';
11
+ import { AdwDialogTest } from './adw-dialog.spec.js';
12
+ import { AdwDropDownTest } from './adw-drop-down.spec.js';
13
+
14
+ run({ AdwDataGridTest, AdwDialogTest, AdwDropDownTest });