@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
|
@@ -106,11 +106,11 @@ export class AdwTabView extends HTMLElement {
|
|
|
106
106
|
private readonly _hovered = new Set<string>();
|
|
107
107
|
private _initialized = false;
|
|
108
108
|
private _generatedIds = 0;
|
|
109
|
-
/** Guards the `selected` attribute reflection against re-entering the model. */
|
|
109
|
+
/** Guards the `selected-page` attribute reflection against re-entering the model. */
|
|
110
110
|
private _reflecting = false;
|
|
111
111
|
|
|
112
112
|
static get observedAttributes() {
|
|
113
|
-
return ['selected', 'autohide', 'expand-tabs', 'no-close'];
|
|
113
|
+
return ['selected-page', 'autohide', 'expand-tabs', 'no-close'];
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
constructor() {
|
|
@@ -131,13 +131,26 @@ export class AdwTabView extends HTMLElement {
|
|
|
131
131
|
this._state.subscribe((change) => this._onSelectionChange(change));
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
/**
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
/**
|
|
135
|
+
* The selected page (`Adw.TabView:selected-page`), `null` when the view is empty.
|
|
136
|
+
*
|
|
137
|
+
* THE PAGE, NOT ITS POSITION (ADR 0048). Its position is {@link selectedIndex}, which
|
|
138
|
+
* stays a read-only report; `Adw.TabView` selects by object, and the model has held
|
|
139
|
+
* that object all along.
|
|
140
|
+
*/
|
|
141
|
+
get selectedPage(): AdwTabViewPage | null {
|
|
142
|
+
return this._state.selectedPage;
|
|
137
143
|
}
|
|
138
144
|
|
|
139
|
-
set
|
|
140
|
-
|
|
145
|
+
set selectedPage(page: AdwTabViewPage | null) {
|
|
146
|
+
// Straight to the core, which owns the page list and therefore owns
|
|
147
|
+
// `page_belongs_to_this_view` — the check an ID cannot answer: an id is unique
|
|
148
|
+
// WITHIN a view, never across two (a declared `page-id` is the author's to repeat
|
|
149
|
+
// and `_nextId` counts per element, so two views both hold a `tab-1`). Measured
|
|
150
|
+
// before the core took the page: `viewA.selectedPage = viewB.pages[2]` moved viewA
|
|
151
|
+
// to index 2 instead of refusing. The refusal carries C's own diagnostic now,
|
|
152
|
+
// where a renderer-side guard could only return in silence.
|
|
153
|
+
this._state.setSelectedPage(page);
|
|
141
154
|
}
|
|
142
155
|
|
|
143
156
|
/**
|
|
@@ -177,8 +190,8 @@ export class AdwTabView extends HTMLElement {
|
|
|
177
190
|
this._initialized = true;
|
|
178
191
|
|
|
179
192
|
// Read the declared selection BEFORE adopting anything: the auto-select
|
|
180
|
-
// of the first page reflects its own
|
|
181
|
-
const declaredSelection = this.getAttribute('selected');
|
|
193
|
+
// of the first page reflects its own id into this very attribute.
|
|
194
|
+
const declaredSelection = this.getAttribute('selected-page');
|
|
182
195
|
|
|
183
196
|
// Snapshot the declared pages, then take over the subtree. The elements
|
|
184
197
|
// themselves become the panels, so they must survive replaceChildren.
|
|
@@ -195,12 +208,12 @@ export class AdwTabView extends HTMLElement {
|
|
|
195
208
|
|
|
196
209
|
attributeChangedCallback(name: string, _old: string | null, _value: string | null) {
|
|
197
210
|
if (!this._initialized) return;
|
|
198
|
-
if (name === 'selected') {
|
|
211
|
+
if (name === 'selected-page') {
|
|
199
212
|
// The LIVE attribute, not the value captured when the reaction was
|
|
200
213
|
// queued: reflection writes this attribute, and a custom-element
|
|
201
214
|
// reaction can run after a later write has already superseded it.
|
|
202
215
|
if (this._reflecting) return;
|
|
203
|
-
const current = this.getAttribute('selected');
|
|
216
|
+
const current = this.getAttribute('selected-page');
|
|
204
217
|
if (current !== null) this._applySelectedAttribute(current);
|
|
205
218
|
return;
|
|
206
219
|
}
|
|
@@ -272,8 +285,9 @@ export class AdwTabView extends HTMLElement {
|
|
|
272
285
|
return this._state.isClosing(id);
|
|
273
286
|
}
|
|
274
287
|
|
|
275
|
-
|
|
276
|
-
|
|
288
|
+
/** `adw_tab_view_set_selected_page`, which takes the PAGE; an id is accepted too. */
|
|
289
|
+
setSelectedPage(page: AdwTabViewPage | string | null): boolean {
|
|
290
|
+
return this._state.setSelectedPage(page);
|
|
277
291
|
}
|
|
278
292
|
|
|
279
293
|
selectNthPage(n: number): boolean {
|
|
@@ -724,19 +738,25 @@ export class AdwTabView extends HTMLElement {
|
|
|
724
738
|
|
|
725
739
|
private _reflectSelected(): void {
|
|
726
740
|
this._reflecting = true;
|
|
727
|
-
const
|
|
728
|
-
if (
|
|
729
|
-
else if (this.getAttribute('selected') !==
|
|
741
|
+
const id = this._state.selectedId;
|
|
742
|
+
if (id === null) this.removeAttribute('selected-page');
|
|
743
|
+
else if (this.getAttribute('selected-page') !== id) this.setAttribute('selected-page', id);
|
|
730
744
|
this._reflecting = false;
|
|
731
745
|
}
|
|
732
746
|
|
|
747
|
+
/**
|
|
748
|
+
* `selected-page="<page id>"` — the markup half of {@link selectedPage} (ADR 0048).
|
|
749
|
+
*
|
|
750
|
+
* The id is the one `<adw-tab-page page-id>` declares; a page that declares none gets
|
|
751
|
+
* a generated one, which an author cannot predict and therefore cannot select by. That
|
|
752
|
+
* is the same bargain `<adw-view-stack visible-child-name>` already makes for a page
|
|
753
|
+
* with no name, and it is why `page-id` exists.
|
|
754
|
+
*
|
|
755
|
+
* An id no page holds is IGNORED rather than treated as the first page — libadwaita
|
|
756
|
+
* refuses a foreign `AdwTabPage` outright, and `setSelectedPage` is the refusal here.
|
|
757
|
+
*/
|
|
733
758
|
private _applySelectedAttribute(value: string): void {
|
|
734
|
-
|
|
735
|
-
// An unparseable value is IGNORED rather than treated as 0, and an out-of-range
|
|
736
|
-
// one is refused rather than clamped: libadwaita refuses both, so
|
|
737
|
-
// `selected="oops"` must not select the first page nor `selected="99"` the last.
|
|
738
|
-
if (Number.isNaN(index)) return;
|
|
739
|
-
this._state.selectNthPage(index);
|
|
759
|
+
this._state.setSelectedPage(value);
|
|
740
760
|
}
|
|
741
761
|
|
|
742
762
|
/**
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
// Properties (mirroring Adw.ViewStack):
|
|
27
27
|
// pages — readonly page descriptors ({ name, title, icon, content, visible }).
|
|
28
28
|
// visibleChildName — name of the visible page ('' when nothing is selected).
|
|
29
|
-
// visibleChildIndex —
|
|
29
|
+
// visibleChildIndex — READ-ONLY report: position of the visible page, -1 for none.
|
|
30
|
+
// Selecting is `visibleChildName` (ADR 0048).
|
|
30
31
|
// visibleChild — the visible page's content element (or null).
|
|
31
32
|
// Methods:
|
|
32
33
|
// add(content, name, title?, icon?) — append a page; returns its descriptor.
|
|
@@ -70,13 +71,28 @@ export class AdwViewStack extends HTMLElement {
|
|
|
70
71
|
return this._state.pages;
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Zero-based position of the visible page, `-1` when nothing is selected.
|
|
76
|
+
*
|
|
77
|
+
* A REPORT, NOT A DOOR (ADR 0048). `Adw.ViewStack` selects by NAME, and an ordinal is
|
|
78
|
+
* derived — an insertion before the visible page increments it, a reorder rewrites it,
|
|
79
|
+
* a removal decrements it. Selecting is {@link visibleChildName}.
|
|
80
|
+
*/
|
|
74
81
|
get visibleChildIndex(): number {
|
|
75
82
|
return this._state.visibleIndex;
|
|
76
83
|
}
|
|
77
84
|
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Show the page at position `n`. Returns whether the selection moved.
|
|
87
|
+
*
|
|
88
|
+
* THE ORDINAL IS A CALL, NOT A PROPERTY (ADR 0048): it resolves against the page list
|
|
89
|
+
* as it is now and keeps nothing, where a held index comes to mean another page after
|
|
90
|
+
* an insert, a reorder or a removal. Port-owned — `refs/libadwaita` gives
|
|
91
|
+
* `AdwViewStack` no ordinal API — and named for the tab view's existing
|
|
92
|
+
* `selectNthPage`. The guard on `n` is the core's and is pinned by the shared vectors.
|
|
93
|
+
*/
|
|
94
|
+
selectNthPage(n: number): boolean {
|
|
95
|
+
return this._state.setVisibleIndex(n);
|
|
80
96
|
}
|
|
81
97
|
|
|
82
98
|
get visibleChildName(): string {
|
|
@@ -99,8 +99,10 @@ export class AdwViewSwitcherBar extends HTMLElement {
|
|
|
99
99
|
const stack = this._stack;
|
|
100
100
|
if (!stack) return;
|
|
101
101
|
const index = Number(item.dataset.pageIndex);
|
|
102
|
-
//
|
|
103
|
-
|
|
102
|
+
// The roving-focus list is ordinal and the stack resolves it (ADR 0048).
|
|
103
|
+
// Re-selecting the current page would re-run the stack's transition, which
|
|
104
|
+
// is why the position is compared before the call.
|
|
105
|
+
if (stack.visibleChildIndex !== index) stack.selectNthPage(index);
|
|
104
106
|
},
|
|
105
107
|
});
|
|
106
108
|
}
|
|
@@ -238,7 +240,8 @@ export class AdwViewSwitcherBar extends HTMLElement {
|
|
|
238
240
|
|
|
239
241
|
button.append(icon, label, indicator);
|
|
240
242
|
button.addEventListener('click', () => {
|
|
241
|
-
|
|
243
|
+
// Ordinal here, resolved by the stack (ADR 0048) — as in `select` above.
|
|
244
|
+
this._stack?.selectNthPage(pageIndex);
|
|
242
245
|
});
|
|
243
246
|
|
|
244
247
|
return { button, icon, label, indicator };
|
|
@@ -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);
|