@gjsify/adwaita-web 0.64.12-next.280 → 0.64.12
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/lib/types/elements/adw-combo-row.d.ts +34 -15
- package/lib/types/elements/gtk-drop-down.d.ts +31 -5
- package/lib/types/namespace/gdk.d.ts +1 -1
- package/lib/types/namespace/gio.d.ts +1 -0
- package/lib/types/namespace/gtk.d.ts +1 -1
- package/lib/types/shared-tree-builder.d.ts +7 -0
- package/package.json +12 -13
- package/src/adw-row-state.spec.ts +120 -63
- package/src/construct-vectors.spec.ts +4 -2
- package/src/elements/adw-combo-row.ts +93 -47
- package/src/elements/gtk-drop-down.ts +111 -35
- package/src/elements/gtk-page-setup-unix-dialog.ts +8 -6
- package/src/file-chooser-host.ts +2 -1
- package/src/gobject-door.spec.ts +57 -0
- package/src/gtk-drop-down.spec.ts +91 -75
- package/src/gtk-value-doors.spec.ts +70 -32
- package/src/namespace/gdk.ts +1 -0
- package/src/namespace/gio.ts +3 -0
- package/src/namespace/gtk.ts +13 -9
- package/src/shared-tree-builder.ts +42 -23
- package/src/value-lists.spec.ts +2 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// <adw-combo-row> — row with a title/subtitle and a dropdown select. The model is a
|
|
2
|
-
// array in the `model`
|
|
3
|
-
// an index, and the native <select> is stretched
|
|
4
|
-
// anywhere opens it.
|
|
1
|
+
// <adw-combo-row> — row with a title/subtitle and a dropdown select. The model is a real
|
|
2
|
+
// `Gio.ListModel` on the PROPERTY and a JSON array in the `model` ATTRIBUTE (`["a","b"]` or
|
|
3
|
+
// `[{"value":"a","label":"A"}]`), `selected` an index, and the native <select> is stretched
|
|
4
|
+
// invisibly over the row so clicking anywhere opens it.
|
|
5
5
|
//
|
|
6
6
|
// The SELECTION state machine (the model, the two-way index↔value mapping, the
|
|
7
7
|
// empty/out-of-range guards and the programmatic-vs-interactive notify split) is HEADLESS
|
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
// `scripts/check-adwaita-collection-reactivity.mjs` holds the rule for every collection
|
|
17
17
|
// either renderer takes in.
|
|
18
18
|
//
|
|
19
|
+
// THE PROPERTY TAKES A LIST MODEL, THE ATTRIBUTE TAKES JSON (ADR 0122). `model` is an object
|
|
20
|
+
// property over `Gio.ListModel`, held by identity, and an array written to it is a
|
|
21
|
+
// `TypeError`. The ATTRIBUTE is untouched — a `Gio.ListModel` has no attribute spelling — and
|
|
22
|
+
// it fills the element's own `GjsifyOptionList`, whose identity survives an edit so appending
|
|
23
|
+
// one option to `model='[…]'` splices rather than replacing the list and resetting the
|
|
24
|
+
// selection. `readModelWrite` in core is the one door both go through.
|
|
25
|
+
//
|
|
19
26
|
// THE MODEL IS SPLICED, NOT REBUILT (ADR 0046). `ComboState` reports WHERE the model
|
|
20
27
|
// changed — `Gio.ListModel::items-changed`, one splice per assignment — and this element
|
|
21
28
|
// applies exactly that range. Appending one item to a hundred-item model adds ONE
|
|
@@ -35,17 +42,16 @@
|
|
|
35
42
|
// Properties — the `<gtk-drop-down>` set minus `enableSearch` and `active`, which are that
|
|
36
43
|
// element's own popover chrome and have no counterpart on a row that opens a native
|
|
37
44
|
// <select>:
|
|
38
|
-
// model — the list model
|
|
39
|
-
// own name, per ADR 0034 clause 1; it replaced
|
|
40
|
-
// further spellings of one GTK property.
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
// `ComboState.hasIndex` is where the bounds live
|
|
46
|
-
// renderer's. Stated here because a consumer of THIS element reads this file.
|
|
45
|
+
// model — the list model, a `Gio.ListModel` (get/set), held BY IDENTITY.
|
|
46
|
+
// `Adw.ComboRow:model`'s own name, per ADR 0034 clause 1; it replaced
|
|
47
|
+
// `options`/`items`, which were two further spellings of one GTK property. An ARRAY is a
|
|
48
|
+
// `TypeError` naming the type (ADR 0122 § 4) — `options` is the descriptor side.
|
|
49
|
+
// options — the held model's items as `{ value, label }` descriptors (get).
|
|
50
|
+
// selected — the selected index (get/set). An index past the end is IGNORED, as
|
|
51
|
+
// measured in libadwaita and as on `<gtk-drop-down>`; the two selectors no longer
|
|
52
|
+
// answer this differently. `ComboState.hasIndex` is where the bounds live.
|
|
47
53
|
// selectedValue — the selected option's value ('' when empty) (get/set-by-value).
|
|
48
|
-
// selectedItem — the selected
|
|
54
|
+
// selectedItem — the model's own selected item, or null (get).
|
|
49
55
|
// Events:
|
|
50
56
|
// `notify::selected` (CustomEvent, bubbles, detail = { selected }) — a USER pick.
|
|
51
57
|
// Deliberately NOT every change, unlike `<gtk-drop-down>`: this row's event has
|
|
@@ -58,8 +64,8 @@
|
|
|
58
64
|
// title/subtitle text column added to match Adw.ComboRow; the selection state
|
|
59
65
|
// machine composed from @gjsify/adwaita-core.
|
|
60
66
|
|
|
61
|
-
import { ComboState, deriveRowLabels,
|
|
62
|
-
import type { AdwComboOption, AdwListItemsChanged,
|
|
67
|
+
import { ComboState, deriveRowLabels, GjsifyOptionList, parseListModel, readModelWrite } from '@gjsify/adwaita-core';
|
|
68
|
+
import type { AdwComboOption, AdwListItemsChanged, ComboWatchableModel } from '@gjsify/adwaita-core';
|
|
63
69
|
|
|
64
70
|
import { applyRowTooltip } from '../row-tooltip.js';
|
|
65
71
|
import { bindSlottedChildren } from '../slotted-children.js';
|
|
@@ -76,34 +82,58 @@ export class AdwComboRow extends TooltipElement {
|
|
|
76
82
|
private _subtitleEl!: HTMLSpanElement;
|
|
77
83
|
/** The headless options list + selectedIndex↔selectedValue state machine (ADR 0004). */
|
|
78
84
|
private readonly _state = new ComboState();
|
|
85
|
+
/**
|
|
86
|
+
* The list the MARKUP doors fill — the `model` ATTRIBUTE is JSON text and a
|
|
87
|
+
* `<gtk-string-list slot="model">` child is strings, and a real `Gio.ListModel` has no
|
|
88
|
+
* spelling in either (ADR 0122 § 5). Held for the element's whole life so re-parsing the
|
|
89
|
+
* attribute splices the list the element already points at rather than replacing it, which
|
|
90
|
+
* would reset the selection.
|
|
91
|
+
*/
|
|
92
|
+
private readonly _authored = GjsifyOptionList.new(null);
|
|
79
93
|
private _initialized = false;
|
|
80
94
|
|
|
81
95
|
static get observedAttributes() {
|
|
82
96
|
return ['title', 'subtitle', 'model', 'selected', 'tooltip-text', 'title-lines', 'subtitle-lines'];
|
|
83
97
|
}
|
|
84
98
|
|
|
85
|
-
/**
|
|
86
|
-
|
|
99
|
+
/**
|
|
100
|
+
* The list model (`Adw.ComboRow:model`) — the OBJECT that was assigned, by identity, as
|
|
101
|
+
* `row.model === model` is measured to hold in libadwaita (ADR 0122 § 4). The normalised
|
|
102
|
+
* descriptors are {@link options}. Assigning splices the `<select>` and re-runs autoselect.
|
|
103
|
+
*/
|
|
104
|
+
get model(): ComboWatchableModel | null {
|
|
87
105
|
return this._state.model;
|
|
88
106
|
}
|
|
89
107
|
|
|
90
|
-
set model(value:
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
108
|
+
set model(value: ComboWatchableModel | string | null) {
|
|
109
|
+
// ONE door for every surface: a real model by identity, `null` for none, a STRING for
|
|
110
|
+
// the `model` attribute (JSON), and a `TypeError` in the property's own words for
|
|
111
|
+
// anything else, an array included.
|
|
112
|
+
this._state.setModel(readModelWrite(value, this._authored));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The held model's items as `{ value, label }` descriptors. */
|
|
116
|
+
get options(): AdwComboOption[] {
|
|
117
|
+
return this._state.options;
|
|
94
118
|
}
|
|
95
119
|
|
|
96
120
|
/** `adw_combo_row_set_model`. */
|
|
97
|
-
set_model(model:
|
|
98
|
-
this.model = model
|
|
121
|
+
set_model(model: ComboWatchableModel | null): void {
|
|
122
|
+
this.model = model;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `adw_combo_row_get_model`. */
|
|
126
|
+
get_model(): ComboWatchableModel | null {
|
|
127
|
+
return this.model;
|
|
99
128
|
}
|
|
100
129
|
|
|
101
130
|
/**
|
|
102
|
-
* The selected index (Adw.ComboRow:selected).
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
131
|
+
* The selected index (Adw.ComboRow:selected). An index past the end is IGNORED — the
|
|
132
|
+
* selection stays where it was, which is what libadwaita is measured to do (ADR 0122 § 4,
|
|
133
|
+
* `combo-probe2.js`) and what `<gtk-drop-down>` already did. This element used to ACCEPT
|
|
134
|
+
* such a set and read back an empty {@link selectedValue}, the one row of
|
|
135
|
+
* `COMBO_SELECTION_VECTORS` the two selectors answered differently; the permissive half
|
|
136
|
+
* was ours, not GTK's, and it is gone. `ComboState.hasIndex` owns the bounds.
|
|
107
137
|
*/
|
|
108
138
|
get selected(): number {
|
|
109
139
|
return this._initialized ? this._state.selectedIndex : parseInt(this.getAttribute('selected') || '0', 10);
|
|
@@ -134,19 +164,21 @@ export class AdwComboRow extends TooltipElement {
|
|
|
134
164
|
}
|
|
135
165
|
|
|
136
166
|
set selectedValue(value: string) {
|
|
137
|
-
const index = this._state.
|
|
167
|
+
const index = this._state.options.findIndex((option) => option.value === value);
|
|
138
168
|
if (index >= 0) this.selected = index;
|
|
139
169
|
}
|
|
140
170
|
|
|
141
171
|
/**
|
|
142
|
-
* The selected
|
|
172
|
+
* The selected ITEM, or `null` (Adw.ComboRow:selected-item).
|
|
143
173
|
*
|
|
144
|
-
* Read-only, as the GObject property is
|
|
145
|
-
*
|
|
146
|
-
*
|
|
174
|
+
* Read-only, as the GObject property is, and the MODEL'S OWN object rather than a
|
|
175
|
+
* descriptor, so the identity comparison a `selected-item` consumer writes holds (ADR
|
|
176
|
+
* 0122 § 4). `null` and not a blank item: the sentinel and an index past the end both
|
|
177
|
+
* address no option, and inventing an empty one would make "nothing is selected"
|
|
178
|
+
* indistinguishable from an option with no text.
|
|
147
179
|
*/
|
|
148
|
-
get selectedItem():
|
|
149
|
-
return this._state.
|
|
180
|
+
get selectedItem(): unknown {
|
|
181
|
+
return this._state.selectedItem;
|
|
150
182
|
}
|
|
151
183
|
|
|
152
184
|
connectedCallback() {
|
|
@@ -177,12 +209,26 @@ export class AdwComboRow extends TooltipElement {
|
|
|
177
209
|
// A `<gtk-string-list slot="model">` child is the list a `.blp` authored (ADR 0072),
|
|
178
210
|
// consumed here BEFORE the attribute seed below, so the seed sees a model and leaves
|
|
179
211
|
// it alone, and before the selection, so an authored index lands on the real list.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
212
|
+
// The slot hands over DESCRIPTORS, which the authored list adopts — the same list the
|
|
213
|
+
// `model` attribute fills, because the two are one markup door and an author who wrote
|
|
214
|
+
// both should not get two lists.
|
|
215
|
+
bindSlottedChildren(this, [
|
|
216
|
+
stringListSlot((options) => {
|
|
217
|
+
this._authored.reconcile(options);
|
|
218
|
+
this._state.setModel(this._authored);
|
|
219
|
+
}),
|
|
220
|
+
]).install(text, this._valueEl, this._select);
|
|
221
|
+
// Two tests, and both are needed. `model === null` and no longer "holds no items" is
|
|
222
|
+
// the test for "the slot above did not already set it": a model is an object now, so
|
|
223
|
+
// an EMPTY one is a model the author meant and the count test would have thrown it
|
|
224
|
+
// away. The ATTRIBUTE'S PRESENCE is the test for whether to seed at all — a fresh
|
|
225
|
+
// widget with no authored model has `model === null`, as GTK's does, and seeding the
|
|
226
|
+
// empty authored list unconditionally would hand every unconfigured row a model.
|
|
227
|
+
const authoredModel = this.getAttribute('model');
|
|
228
|
+
if (this._state.model === null && authoredModel !== null) {
|
|
229
|
+
this._authored.reconcile(parseListModel(authoredModel));
|
|
230
|
+
this._state.setModel(this._authored);
|
|
231
|
+
}
|
|
186
232
|
this._state.setSelectedIndex(parseInt(this.getAttribute('selected') || '0', 10));
|
|
187
233
|
|
|
188
234
|
// WHERE the model changed — applied before the selection change below, which is
|
|
@@ -218,7 +264,7 @@ export class AdwComboRow extends TooltipElement {
|
|
|
218
264
|
attributeChangedCallback(name: string, _old: string | null, value: string | null) {
|
|
219
265
|
if (!this._initialized) return;
|
|
220
266
|
if (name === 'model') {
|
|
221
|
-
this.
|
|
267
|
+
this.model = value;
|
|
222
268
|
} else if (name === 'selected') {
|
|
223
269
|
// A programmatic set — the core state notifies `interactive: false`,
|
|
224
270
|
// so the subscriber re-syncs the display without emitting an event.
|
|
@@ -257,11 +303,11 @@ export class AdwComboRow extends TooltipElement {
|
|
|
257
303
|
if (!this._initialized) return;
|
|
258
304
|
for (let i = 0; i < change.removed; i++) this._select.remove(change.position);
|
|
259
305
|
const before = this._select.options[change.position] ?? null;
|
|
260
|
-
// Read ONCE: `
|
|
306
|
+
// Read ONCE: `options` hands back a copy (ADR 0046), so reading it per item would make
|
|
261
307
|
// a splice of n items O(n²) instead of the O(n) the splice is.
|
|
262
|
-
const
|
|
308
|
+
const options = this._state.options;
|
|
263
309
|
for (let i = 0; i < change.added; i++) {
|
|
264
|
-
const option =
|
|
310
|
+
const option = options[change.position + i];
|
|
265
311
|
if (option) this._select.insertBefore(this._createOption(option), before);
|
|
266
312
|
}
|
|
267
313
|
}
|
|
@@ -274,7 +320,7 @@ export class AdwComboRow extends TooltipElement {
|
|
|
274
320
|
*/
|
|
275
321
|
private _renderOptions(): void {
|
|
276
322
|
if (!this._initialized) return;
|
|
277
|
-
this._select.replaceChildren(...this._state.
|
|
323
|
+
this._select.replaceChildren(...this._state.options.map((option) => this._createOption(option)));
|
|
278
324
|
this._select.selectedIndex = this._state.selectedIndex;
|
|
279
325
|
this._valueEl.textContent = this._state.selectedLabel;
|
|
280
326
|
this._syncChooser();
|
|
@@ -32,16 +32,24 @@
|
|
|
32
32
|
// shifted a row would have selected the wrong item. The handler now asks the list where
|
|
33
33
|
// the row IS.
|
|
34
34
|
//
|
|
35
|
+
// THE `model` PROPERTY IS A REAL `Gio.ListModel` (ADR 0122 § 4), held by identity, and an
|
|
36
|
+
// array written to it is a `TypeError` in GJS's own words. The ARRAY spellings are the
|
|
37
|
+
// MARKUP doors — the JSON attribute and the `<gtk-string-list slot="model">` child — and they
|
|
38
|
+
// fill one list this element owns for its whole life, so re-parsing the attribute splices
|
|
39
|
+
// that list rather than replacing it. `options` is where the normalised `{ value, label }`
|
|
40
|
+
// descriptors moved; everything that draws a row reads them from there.
|
|
41
|
+
//
|
|
35
42
|
// Attributes:
|
|
36
43
|
// model — JSON array: `["a","b"]` or `[{"value":"a","label":"A"}]`.
|
|
37
44
|
// selected — the selected index (number).
|
|
38
45
|
// enable-search — boolean; show a filtering search entry in the popover.
|
|
39
46
|
// disabled — boolean; a disabled dropdown does not open.
|
|
40
47
|
// Properties (mirroring Gtk.DropDown):
|
|
41
|
-
// model — the list model
|
|
48
|
+
// model — the list model, a real `Gio.ListModel` or null (get/set).
|
|
49
|
+
// options — the model's items as { value, label } descriptors (get).
|
|
42
50
|
// selected — the selected index (get/set; bounds-guarded).
|
|
43
51
|
// selectedValue — the selected option's value ('' when empty) (get/set-by-value).
|
|
44
|
-
// selectedItem — the selected
|
|
52
|
+
// selectedItem — the model's own selected item, or null (get).
|
|
45
53
|
// enableSearch — whether the search entry is shown (get/set).
|
|
46
54
|
// active — whether the popover is open (get).
|
|
47
55
|
// Events:
|
|
@@ -67,8 +75,8 @@
|
|
|
67
75
|
import './gtk-popover.js';
|
|
68
76
|
import type { GtkPopover } from './gtk-popover.js';
|
|
69
77
|
|
|
70
|
-
import { ComboState,
|
|
71
|
-
import type { AdwComboOption, AdwListItemsChanged,
|
|
78
|
+
import { ComboState, GjsifyOptionList, parseListModel, readModelWrite } from '@gjsify/adwaita-core';
|
|
79
|
+
import type { AdwComboOption, AdwListItemsChanged, ComboWatchableModel } from '@gjsify/adwaita-core';
|
|
72
80
|
|
|
73
81
|
import { bindSlottedChildren } from '../slotted-children.js';
|
|
74
82
|
import type { DispatchedSignals } from '../signals.js';
|
|
@@ -99,6 +107,14 @@ export class GtkDropDown extends WidgetElement {
|
|
|
99
107
|
private _itemButtons: HTMLButtonElement[] = [];
|
|
100
108
|
/** The headless options list + selectedIndex↔selectedValue machine (ADR 0004). */
|
|
101
109
|
private readonly _state = new ComboState();
|
|
110
|
+
/**
|
|
111
|
+
* The list the MARKUP doors fill — the `model` ATTRIBUTE is JSON text and a
|
|
112
|
+
* `<gtk-string-list slot="model">` child is strings, and a real `Gio.ListModel` has no
|
|
113
|
+
* spelling in either (ADR 0122 § 5). Held for the element's whole life so re-parsing the
|
|
114
|
+
* attribute splices the list the element already points at rather than replacing it, which
|
|
115
|
+
* would reset the selection.
|
|
116
|
+
*/
|
|
117
|
+
private readonly _authored = GjsifyOptionList.new(null);
|
|
102
118
|
private _initialized = false;
|
|
103
119
|
private _typeAheadBuffer = '';
|
|
104
120
|
private _typeAheadTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -107,15 +123,35 @@ export class GtkDropDown extends WidgetElement {
|
|
|
107
123
|
return ['model', 'selected', 'enable-search', 'disabled'];
|
|
108
124
|
}
|
|
109
125
|
|
|
110
|
-
/**
|
|
111
|
-
|
|
126
|
+
/**
|
|
127
|
+
* The list model (`Gtk.DropDown:model`) — the OBJECT that was assigned, by identity, as
|
|
128
|
+
* `dropdown.model === model` is measured to hold in GTK (ADR 0122 § 4). The normalised
|
|
129
|
+
* descriptors are {@link options}. Assigning splices the popover and re-runs autoselect.
|
|
130
|
+
*/
|
|
131
|
+
get model(): ComboWatchableModel | null {
|
|
112
132
|
return this._state.model;
|
|
113
133
|
}
|
|
114
134
|
|
|
115
|
-
set model(value:
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
135
|
+
set model(value: ComboWatchableModel | string | null) {
|
|
136
|
+
// ONE door for every surface: a real model by identity, `null` for none, a STRING for
|
|
137
|
+
// the `model` attribute (JSON), and a `TypeError` in the property's own words for
|
|
138
|
+
// anything else, an array included.
|
|
139
|
+
this._state.setModel(readModelWrite(value, this._authored));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The held model's items as `{ value, label }` descriptors. */
|
|
143
|
+
get options(): AdwComboOption[] {
|
|
144
|
+
return this._state.options;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `gtk_drop_down_set_model`. */
|
|
148
|
+
set_model(model: ComboWatchableModel | null): void {
|
|
149
|
+
this.model = model;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** `gtk_drop_down_get_model`. */
|
|
153
|
+
get_model(): ComboWatchableModel | null {
|
|
154
|
+
return this.model;
|
|
119
155
|
}
|
|
120
156
|
|
|
121
157
|
/** The selected index. Out-of-range values are ignored (Gtk.DropDown:selected). */
|
|
@@ -143,12 +179,23 @@ export class GtkDropDown extends WidgetElement {
|
|
|
143
179
|
}
|
|
144
180
|
|
|
145
181
|
set selectedValue(value: string) {
|
|
146
|
-
const index = this._state.
|
|
182
|
+
const index = this._state.options.findIndex((o) => o.value === value);
|
|
147
183
|
if (index >= 0) this._selectIndex(index);
|
|
148
184
|
}
|
|
149
185
|
|
|
150
|
-
|
|
151
|
-
|
|
186
|
+
/**
|
|
187
|
+
* The selected ITEM, or `null` (Gtk.DropDown:selected-item).
|
|
188
|
+
*
|
|
189
|
+
* The MODEL'S OWN object rather than a descriptor, so the identity comparison a
|
|
190
|
+
* `selected-item` consumer writes holds (ADR 0122 § 4).
|
|
191
|
+
*/
|
|
192
|
+
get selectedItem(): unknown {
|
|
193
|
+
return this._state.selectedItem;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** `gtk_drop_down_get_selected_item`. */
|
|
197
|
+
get_selected_item(): unknown {
|
|
198
|
+
return this.selectedItem;
|
|
152
199
|
}
|
|
153
200
|
|
|
154
201
|
get enableSearch(): boolean {
|
|
@@ -200,15 +247,30 @@ export class GtkDropDown extends WidgetElement {
|
|
|
200
247
|
this._popoverEl.appendChild(this._listEl);
|
|
201
248
|
|
|
202
249
|
// A `<gtk-string-list slot="model">` child is the list a `.blp` authored (ADR 0072),
|
|
203
|
-
// consumed first so the attribute seed below sees a model and leaves it alone.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
250
|
+
// consumed first so the attribute seed below sees a model and leaves it alone. The
|
|
251
|
+
// slot hands over DESCRIPTORS, which the authored list adopts — the same list the
|
|
252
|
+
// `model` attribute fills, because the two are one markup door and an author who
|
|
253
|
+
// wrote both should not get two lists.
|
|
254
|
+
bindSlottedChildren(this, [
|
|
255
|
+
stringListSlot((options) => {
|
|
256
|
+
this._authored.reconcile(options);
|
|
257
|
+
this._state.setModel(this._authored);
|
|
258
|
+
}),
|
|
259
|
+
]).install(this._buttonEl, this._popoverEl);
|
|
208
260
|
|
|
209
261
|
// Seed options BEFORE anything subscribes, so building the initial DOM below is
|
|
210
|
-
// not driven by a change notification.
|
|
211
|
-
|
|
262
|
+
// not driven by a change notification. Two tests, and both are needed. `model ===
|
|
263
|
+
// null` and no longer "holds no items" is the test for "the slot above did not
|
|
264
|
+
// already set it": a model is an object now, so an EMPTY one is a model the author
|
|
265
|
+
// meant and the count test would have thrown it away. The ATTRIBUTE'S PRESENCE is
|
|
266
|
+
// the test for whether to seed at all — a fresh widget with no authored model has
|
|
267
|
+
// `model === null`, as GTK's does, and seeding the empty authored list
|
|
268
|
+
// unconditionally would hand every unconfigured drop-down a model.
|
|
269
|
+
const authoredModel = this.getAttribute('model');
|
|
270
|
+
if (this._state.model === null && authoredModel !== null) {
|
|
271
|
+
this._authored.reconcile(parseListModel(authoredModel));
|
|
272
|
+
this._state.setModel(this._authored);
|
|
273
|
+
}
|
|
212
274
|
const attrSelected = Number.parseInt(this.getAttribute('selected') ?? '', 10);
|
|
213
275
|
if (this._state.hasIndex(attrSelected)) this._state.setSelectedIndex(attrSelected);
|
|
214
276
|
|
|
@@ -216,6 +278,17 @@ export class GtkDropDown extends WidgetElement {
|
|
|
216
278
|
// assignment reaches the splice rather than a rebuild.
|
|
217
279
|
this._state.subscribeItems((change) => this._applyItemsChanged(change));
|
|
218
280
|
|
|
281
|
+
// THAT the selection moved, for the moves this element did not ask for: a `model`
|
|
282
|
+
// write re-runs autoselect and lands the selection on 0 (ADR 0122 § 4), and an
|
|
283
|
+
// `items-changed` clamp moves it too. Neither goes through `_selectIndex`, and a
|
|
284
|
+
// model of EQUAL shape splices nothing, so `subscribeItems` alone would leave the
|
|
285
|
+
// button reading the old item's label. The DOM half only — `_selectIndex` owns the
|
|
286
|
+
// `notify::` dispatch and the `change` event, so there is still one emitter.
|
|
287
|
+
this._state.subscribe(() => {
|
|
288
|
+
this._updateLabel();
|
|
289
|
+
this._updateSelectedStates();
|
|
290
|
+
});
|
|
291
|
+
|
|
219
292
|
this._popoverEl.anchor = this._buttonEl;
|
|
220
293
|
this._popoverEl.subscribe((open) => this._onPopoverToggled(open));
|
|
221
294
|
// Type-ahead is the ONE key class the shared popover deliberately leaves to the
|
|
@@ -230,7 +303,7 @@ export class GtkDropDown extends WidgetElement {
|
|
|
230
303
|
if (name === 'model') {
|
|
231
304
|
// No render call: the splice subscription does the DOM half, so the markup door
|
|
232
305
|
// and the property door cannot drift into two update paths.
|
|
233
|
-
this.
|
|
306
|
+
this.model = value;
|
|
234
307
|
} else if (name === 'selected') {
|
|
235
308
|
const index = Number.parseInt(value ?? '', 10);
|
|
236
309
|
if (!Number.isNaN(index)) this._selectIndex(index, { fromAttr: true });
|
|
@@ -243,9 +316,9 @@ export class GtkDropDown extends WidgetElement {
|
|
|
243
316
|
}
|
|
244
317
|
|
|
245
318
|
private _selectIndex(index: number, opts: { fromAttr?: boolean; fromUser?: boolean } = {}): void {
|
|
246
|
-
// The bounds arithmetic
|
|
247
|
-
//
|
|
248
|
-
// `<adw-combo-row>`
|
|
319
|
+
// The bounds arithmetic AND the policy are `ComboState`'s: an index the model does
|
|
320
|
+
// not address is ignored silently, measured on both widgets (ADR 0122 § 4). This
|
|
321
|
+
// element and `<adw-combo-row>` once answered it differently; neither does now.
|
|
249
322
|
if (!this._state.hasIndex(index)) return;
|
|
250
323
|
// Both core setters return "did it change", which is what gates the notify — no
|
|
251
324
|
// second copy of the current index is kept here, so `setModel`' own label
|
|
@@ -258,7 +331,10 @@ export class GtkDropDown extends WidgetElement {
|
|
|
258
331
|
this.setAttribute('selected', String(index));
|
|
259
332
|
}
|
|
260
333
|
if (changed) {
|
|
261
|
-
|
|
334
|
+
// The MODEL'S OWN item, not a descriptor: `selected-item` is an identity in GTK
|
|
335
|
+
// (ADR 0122 § 4), so a listener comparing it against the item it put in the model
|
|
336
|
+
// has to match.
|
|
337
|
+
const item = this._state.selectedItem;
|
|
262
338
|
// `notify::selected` mirrors GObject property-notify: EVERY change,
|
|
263
339
|
// programmatic included.
|
|
264
340
|
this.dispatchEvent(new CustomEvent('notify::selected', { bubbles: true, detail: { selected: index } }));
|
|
@@ -266,7 +342,7 @@ export class GtkDropDown extends WidgetElement {
|
|
|
266
342
|
this.dispatchEvent(
|
|
267
343
|
new CustomEvent('notify::selected-item', {
|
|
268
344
|
bubbles: true,
|
|
269
|
-
detail: { 'selected-item':
|
|
345
|
+
detail: { 'selected-item': item ?? null },
|
|
270
346
|
}),
|
|
271
347
|
);
|
|
272
348
|
// `change` mirrors the DOM <select> contract: ONLY a user-initiated change. A
|
|
@@ -276,7 +352,7 @@ export class GtkDropDown extends WidgetElement {
|
|
|
276
352
|
this.dispatchEvent(
|
|
277
353
|
new CustomEvent('change', {
|
|
278
354
|
bubbles: true,
|
|
279
|
-
detail: { index, value:
|
|
355
|
+
detail: { index, value: this._state.selectedValue, label: this._state.selectedLabel },
|
|
280
356
|
}),
|
|
281
357
|
);
|
|
282
358
|
}
|
|
@@ -319,7 +395,7 @@ export class GtkDropDown extends WidgetElement {
|
|
|
319
395
|
this._typeAheadBuffer += char.toLowerCase();
|
|
320
396
|
if (this._typeAheadTimer) clearTimeout(this._typeAheadTimer);
|
|
321
397
|
this._typeAheadTimer = setTimeout(() => (this._typeAheadBuffer = ''), 800);
|
|
322
|
-
const match = this._state.
|
|
398
|
+
const match = this._state.options.findIndex((o) => o.label.toLowerCase().startsWith(this._typeAheadBuffer));
|
|
323
399
|
if (match < 0) return;
|
|
324
400
|
if (this._popoverEl.open) this._itemButtons[match]?.focus();
|
|
325
401
|
else this._selectIndex(match, { fromUser: true });
|
|
@@ -329,9 +405,9 @@ export class GtkDropDown extends WidgetElement {
|
|
|
329
405
|
const q = query.trim().toLowerCase();
|
|
330
406
|
// Hoisted for the reason `_applyItemsChanged` gives: this runs on every keystroke of
|
|
331
407
|
// the search field, over every row.
|
|
332
|
-
const
|
|
408
|
+
const options = this._state.options;
|
|
333
409
|
this._itemButtons.forEach((button, index) => {
|
|
334
|
-
const match = q.length === 0 || (
|
|
410
|
+
const match = q.length === 0 || (options[index]?.label.toLowerCase().includes(q) ?? false);
|
|
335
411
|
button.hidden = !match;
|
|
336
412
|
});
|
|
337
413
|
}
|
|
@@ -380,7 +456,7 @@ export class GtkDropDown extends WidgetElement {
|
|
|
380
456
|
if (existing) existing.remove();
|
|
381
457
|
}
|
|
382
458
|
|
|
383
|
-
for (const option of this._state.
|
|
459
|
+
for (const option of this._state.options) {
|
|
384
460
|
const item = this._createItem(option);
|
|
385
461
|
this._listEl.appendChild(item);
|
|
386
462
|
this._itemButtons.push(item);
|
|
@@ -439,11 +515,11 @@ export class GtkDropDown extends WidgetElement {
|
|
|
439
515
|
}
|
|
440
516
|
const before = this._itemButtons[change.position] ?? null;
|
|
441
517
|
const inserted: HTMLButtonElement[] = [];
|
|
442
|
-
// Read ONCE: `
|
|
443
|
-
// a splice of n items O(n²) instead of the O(n) the splice is.
|
|
444
|
-
const
|
|
518
|
+
// Read ONCE: `options` normalises every item on each call (ADR 0046), so reading it
|
|
519
|
+
// per item would make a splice of n items O(n²) instead of the O(n) the splice is.
|
|
520
|
+
const options = this._state.options;
|
|
445
521
|
for (let i = 0; i < change.added; i++) {
|
|
446
|
-
const option =
|
|
522
|
+
const option = options[change.position + i];
|
|
447
523
|
if (!option) continue;
|
|
448
524
|
const item = this._createItem(option);
|
|
449
525
|
this._listEl.insertBefore(item, before);
|
|
@@ -178,12 +178,14 @@ export class GtkPageSetupUnixDialog extends AdwDialog {
|
|
|
178
178
|
*/
|
|
179
179
|
set pageSetup(value: PageSetup) {
|
|
180
180
|
const index = this._setups.findIndex((setup) => pageSetupEquals(setup, value));
|
|
181
|
-
if (index < 0)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
181
|
+
if (index < 0) this._setups.push(value);
|
|
182
|
+
// The row list GROWS BEFORE the selection moves. The drop-down refuses an index its
|
|
183
|
+
// model does not address, and a splice that lands at or before the selection shifts
|
|
184
|
+
// it rather than leaving it standing (ADR 0122 § 4). Selecting first worked only
|
|
185
|
+
// while the appended row happened to fall where `Manage Custom Sizes…` already was.
|
|
186
|
+
if (this._built) this._renderSetup();
|
|
187
|
+
const wanted = index < 0 ? this._setups.length - 1 : index;
|
|
188
|
+
if (this._paperEl.selected !== wanted) this._paperEl.selected = wanted;
|
|
187
189
|
this._setOrientation(value.orientation);
|
|
188
190
|
if (this._built) this._renderSetup();
|
|
189
191
|
}
|
package/src/file-chooser-host.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
dialogFailedError,
|
|
23
23
|
dismissedError,
|
|
24
24
|
fileFilterAccepts,
|
|
25
|
+
GjsifyOptionList,
|
|
25
26
|
GLibError,
|
|
26
27
|
registerFileChooserDriver,
|
|
27
28
|
type FileChooserDriver,
|
|
@@ -291,7 +292,7 @@ export function presentOpfsDialog(
|
|
|
291
292
|
let dropDown: GtkDropDown | null = null;
|
|
292
293
|
if (filters.length > 0) {
|
|
293
294
|
dropDown = el('gtk-drop-down') as GtkDropDown;
|
|
294
|
-
dropDown.model = filters.map((f, i) => f.name ?? `Filter ${i + 1}`);
|
|
295
|
+
dropDown.model = GjsifyOptionList.new(filters.map((f, i) => f.name ?? `Filter ${i + 1}`));
|
|
295
296
|
dropDown.selected = Math.max(initial, 0);
|
|
296
297
|
bottom.append(dropDown);
|
|
297
298
|
}
|
package/src/gobject-door.spec.ts
CHANGED
|
@@ -91,6 +91,45 @@ GObject.registerClass(
|
|
|
91
91
|
GoWebSensitive as never,
|
|
92
92
|
);
|
|
93
93
|
|
|
94
|
+
const SiblingsTemplate = tree(`template $GoWebSiblings : Adw.Bin {
|
|
95
|
+
child: Gtk.Box {
|
|
96
|
+
Gtk.Label caption { label: bind peer.title; }
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
Adw.AlertDialog unsavedChangesDialog { heading: "Save changes?"; }
|
|
101
|
+
|
|
102
|
+
$GoWebPeer peer { }`);
|
|
103
|
+
|
|
104
|
+
class GoWebPeer extends Adw.Bin {
|
|
105
|
+
declare title: string;
|
|
106
|
+
}
|
|
107
|
+
GObject.registerClass(
|
|
108
|
+
{
|
|
109
|
+
GTypeName: 'GoWebPeer',
|
|
110
|
+
Properties: { title: GObject.ParamSpec.string('title', '', '', GObject.ParamFlags.READWRITE, 'first') },
|
|
111
|
+
},
|
|
112
|
+
GoWebPeer as never,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
class GoWebSiblings extends Adw.Bin {
|
|
116
|
+
declare _unsavedChangesDialog: HTMLElement & { heading: string };
|
|
117
|
+
declare _peer: GoWebPeer;
|
|
118
|
+
declare _caption: HTMLElement & { label: string };
|
|
119
|
+
constructor(params?: Record<string, unknown>) {
|
|
120
|
+
// @ts-expect-error the element's own constructor takes none; the registered class's takes the params
|
|
121
|
+
super(params);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
GObject.registerClass(
|
|
125
|
+
{
|
|
126
|
+
GTypeName: 'GoWebSiblings',
|
|
127
|
+
Template: SiblingsTemplate,
|
|
128
|
+
InternalChildren: ['unsavedChangesDialog', 'peer', 'caption'],
|
|
129
|
+
},
|
|
130
|
+
GoWebSiblings as never,
|
|
131
|
+
);
|
|
132
|
+
|
|
94
133
|
export const GObjectDoorTest = async () => {
|
|
95
134
|
await driveGObjectVectors(subject, { describe, it, expect }, GOBJECT_VECTORS);
|
|
96
135
|
await describe('adwaita-web: GObject.registerClass end to end (ADR 0096)', async () => {
|
|
@@ -98,6 +137,24 @@ export const GObjectDoorTest = async () => {
|
|
|
98
137
|
expect(tagForTypeName('Hexdump')).toBe('gjsify-hexdump');
|
|
99
138
|
expect(customElements.get('gjsify-go-web-hexdump')).toBe(GoWebHexdump);
|
|
100
139
|
});
|
|
140
|
+
await it('reaches the objects written beside the template through InternalChildren, outside its children', () => {
|
|
141
|
+
const el = new GoWebSiblings();
|
|
142
|
+
expect(el._unsavedChangesDialog.localName).toBe('adw-alert-dialog');
|
|
143
|
+
expect(el._peer instanceof GoWebPeer).toBe(true);
|
|
144
|
+
document.body.append(el);
|
|
145
|
+
try {
|
|
146
|
+
// Independent objects, as in GTK: neither is a child of the template widget.
|
|
147
|
+
expect(el.contains(el._unsavedChangesDialog)).toBe(false);
|
|
148
|
+
expect(el.contains(el._peer)).toBe(false);
|
|
149
|
+
expect(el.contains(el._caption)).toBe(true);
|
|
150
|
+
// A template child names a sibling by id, and follows it.
|
|
151
|
+
expect(el._caption.label).toBe('first');
|
|
152
|
+
el._peer.title = 'second';
|
|
153
|
+
expect(el._caption.label).toBe('second');
|
|
154
|
+
} finally {
|
|
155
|
+
el.remove();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
101
158
|
await it('refuses a second class under the same tag', () => {
|
|
102
159
|
expect(() =>
|
|
103
160
|
GObject.registerClass({ GTypeName: 'GoWebHexdump' }, class extends Adw.Bin {} as never),
|