@gjsify/adwaita-web 0.47.0 → 0.48.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/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/styles.generated.d.ts +1 -1
- package/package.json +6 -6
- package/scss/_popover.scss +99 -0
- package/src/adw-row-state.spec.ts +335 -17
- 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/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/split-button.spec.ts +36 -22
- package/src/styles.generated.ts +1 -1
- package/src/tab-view.spec.ts +14 -0
|
@@ -1,19 +1,62 @@
|
|
|
1
|
-
// <adw-combo-row> — row with a title/subtitle and a dropdown select.
|
|
2
|
-
//
|
|
3
|
-
// row so clicking
|
|
1
|
+
// <adw-combo-row> — row with a title/subtitle and a dropdown select. The model is a JSON
|
|
2
|
+
// array in the `model` attribute (`["a","b"]` or `[{"value":"a","label":"A"}]`), `selected`
|
|
3
|
+
// an index, and the native <select> is stretched invisibly over the row so clicking
|
|
4
|
+
// anywhere opens it.
|
|
4
5
|
//
|
|
5
|
-
// The SELECTION state machine (the
|
|
6
|
+
// The SELECTION state machine (the model, the two-way index↔value mapping, the
|
|
6
7
|
// empty/out-of-range guards and the programmatic-vs-interactive notify split) is HEADLESS
|
|
7
8
|
// and lives in `@gjsify/adwaita-core` (ADR 0004) as {@link ComboState}; this element keeps
|
|
8
9
|
// only the DOM half — the <select>, the inline value label and `notify::selected`.
|
|
9
10
|
//
|
|
11
|
+
// THE MODEL IS INPUT, AND INPUT STAYS LIVE. This row and `<gtk-drop-down>` are the same
|
|
12
|
+
// list widget on GTK and share that one `ComboState` here — but the row used to parse its
|
|
13
|
+
// items once in `connectedCallback`, observe only `['title','subtitle','selected']` and
|
|
14
|
+
// publish no accessor at all, so a model replaced after connect changed nothing, silently
|
|
15
|
+
// (#1525). Both spellings now reach the same rebuild, and
|
|
16
|
+
// `scripts/check-adwaita-collection-reactivity.mjs` holds the rule for every collection
|
|
17
|
+
// either renderer takes in.
|
|
18
|
+
//
|
|
19
|
+
// THE MODEL IS SPLICED, NOT REBUILT (ADR 0046). `ComboState` reports WHERE the model
|
|
20
|
+
// changed — `Gio.ListModel::items-changed`, one splice per assignment — and this element
|
|
21
|
+
// applies exactly that range. Appending one item to a hundred-item model adds ONE
|
|
22
|
+
// `<option>` and leaves the other hundred nodes standing; the previous code dropped every
|
|
23
|
+
// node on every assignment, which is also what threw away the browser's own state for the
|
|
24
|
+
// rows that did not change. It is what removes the ordering hazard this file used to
|
|
25
|
+
// carry as a comment: the splice arrives BEFORE the selection change, so the subscriber's
|
|
26
|
+
// index write already lands on the new list.
|
|
27
|
+
//
|
|
28
|
+
// Attributes:
|
|
29
|
+
// title / subtitle — the text column.
|
|
30
|
+
// model — JSON array: `["a","b"]` or `[{"value":"a","label":"A"}]`.
|
|
31
|
+
// selected — the selected index (number).
|
|
32
|
+
// Properties — the `<gtk-drop-down>` set minus `enableSearch` and `active`, which are that
|
|
33
|
+
// element's own popover chrome and have no counterpart on a row that opens a native
|
|
34
|
+
// <select>:
|
|
35
|
+
// model — the list model ({ value, label }[]) (get/set). `Adw.ComboRow:model`'s
|
|
36
|
+
// own name, per ADR 0034 clause 1; it replaced `options`/`items`, which were two
|
|
37
|
+
// further spellings of one GTK property.
|
|
38
|
+
// selected — the selected index (get/set). PERMISSIVE, and this is the one row of
|
|
39
|
+
// `ComboState`'s contract the two selectors answer differently: an index past the end
|
|
40
|
+
// is ACCEPTED here and reads back with an empty `selectedValue`/label, where
|
|
41
|
+
// `<gtk-drop-down>` rejects the set outright (gtk-drop-down.ts). Neither is a bug —
|
|
42
|
+
// `ComboState.hasIndex` is where the bounds live and why the policy is each
|
|
43
|
+
// renderer's. Stated here because a consumer of THIS element reads this file.
|
|
44
|
+
// selectedValue — the selected option's value ('' when empty) (get/set-by-value).
|
|
45
|
+
// selectedItem — the selected option descriptor, or null (get).
|
|
46
|
+
// Events:
|
|
47
|
+
// `notify::selected` (CustomEvent, bubbles, detail = { selected }) — a USER pick.
|
|
48
|
+
// Deliberately NOT every change, unlike `<gtk-drop-down>`: this row's event has
|
|
49
|
+
// always been the interactive half of `ComboState`'s split, and `adw-row-state.spec.ts`
|
|
50
|
+
// pins it.
|
|
51
|
+
//
|
|
10
52
|
// Adapted from Adwaita Web UI Framework (https://github.com/mclellac/adwaita-web).
|
|
11
53
|
// Copyright (c) 2025 csm. MIT License.
|
|
12
54
|
// Modifications: Reimplemented as Web Component for @gjsify/adwaita-web;
|
|
13
55
|
// title/subtitle text column added to match Adw.ComboRow; the selection state
|
|
14
56
|
// machine composed from @gjsify/adwaita-core.
|
|
15
57
|
|
|
16
|
-
import { ComboState, deriveRowLabels, normalizeComboOptions } from '@gjsify/adwaita-core';
|
|
58
|
+
import { ComboState, deriveRowLabels, normalizeComboOptions, parseListModel } from '@gjsify/adwaita-core';
|
|
59
|
+
import type { AdwComboOption, AdwListItemsChanged, AdwListModelInput } from '@gjsify/adwaita-core';
|
|
17
60
|
|
|
18
61
|
export class AdwComboRow extends HTMLElement {
|
|
19
62
|
private _select!: HTMLSelectElement;
|
|
@@ -25,9 +68,27 @@ export class AdwComboRow extends HTMLElement {
|
|
|
25
68
|
private _initialized = false;
|
|
26
69
|
|
|
27
70
|
static get observedAttributes() {
|
|
28
|
-
return ['title', 'subtitle', 'selected'];
|
|
71
|
+
return ['title', 'subtitle', 'model', 'selected'];
|
|
29
72
|
}
|
|
30
73
|
|
|
74
|
+
/** The list model (`Adw.ComboRow:model`). Setting it splices the <select> and clamps the selection. */
|
|
75
|
+
get model(): AdwComboOption[] {
|
|
76
|
+
return this._state.model;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
set model(value: AdwListModelInput) {
|
|
80
|
+
// `setModel` re-runs autoselect, so an index the new model does not have falls
|
|
81
|
+
// back to 0 — the clamp is core's, exactly as `<gtk-drop-down>` gets it.
|
|
82
|
+
this._state.setModel(normalizeComboOptions(value));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The selected index (Adw.ComboRow:selected). PERMISSIVE: an index past the end is
|
|
87
|
+
* accepted and reads back with an empty {@link selectedValue}, mirroring the `guint`
|
|
88
|
+
* property that takes any position — where `<gtk-drop-down>` rejects the same set, as
|
|
89
|
+
* its own docs promise. `ComboState.hasIndex` owns the bounds and records why the
|
|
90
|
+
* POLICY is each renderer's; `COMBO_SELECTION_VECTORS` drives both answers.
|
|
91
|
+
*/
|
|
31
92
|
get selected(): number {
|
|
32
93
|
return this._initialized ? this._state.selectedIndex : parseInt(this.getAttribute('selected') || '0', 10);
|
|
33
94
|
}
|
|
@@ -36,17 +97,47 @@ export class AdwComboRow extends HTMLElement {
|
|
|
36
97
|
this.setAttribute('selected', String(value));
|
|
37
98
|
}
|
|
38
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The selected option's `value`, or '' when nothing is selected (Adw.ComboRow:selected-item).
|
|
102
|
+
*
|
|
103
|
+
* Assigning routes through `selected`, i.e. through the attribute, so a set BY VALUE
|
|
104
|
+
* and a set BY INDEX leave the element in the same observable state — including the
|
|
105
|
+
* reflected `selected=` a consumer or a stylesheet may be reading. An unknown value is
|
|
106
|
+
* a no-op, as on `<gtk-drop-down>`.
|
|
107
|
+
*/
|
|
108
|
+
get selectedValue(): string {
|
|
109
|
+
return this._state.selectedValue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
set selectedValue(value: string) {
|
|
113
|
+
const index = this._state.model.findIndex((option) => option.value === value);
|
|
114
|
+
if (index >= 0) this.selected = index;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The selected option descriptor, or `null` (Adw.ComboRow:selected-item).
|
|
119
|
+
*
|
|
120
|
+
* Read-only, as the GObject property is. `null` and not a blank descriptor: the
|
|
121
|
+
* sentinel and an index past the end both address no option, and inventing an empty
|
|
122
|
+
* one would make "nothing is selected" indistinguishable from an option with no text.
|
|
123
|
+
*/
|
|
124
|
+
get selectedItem(): AdwComboOption | null {
|
|
125
|
+
return this._state.model[this._state.selectedIndex] ?? null;
|
|
126
|
+
}
|
|
127
|
+
|
|
39
128
|
connectedCallback() {
|
|
40
129
|
if (this._initialized) return;
|
|
41
130
|
this._initialized = true;
|
|
42
131
|
|
|
43
|
-
const items: string[] = JSON.parse(this.getAttribute('items') || '[]');
|
|
44
132
|
// Seed the headless state BEFORE subscribing, so building the initial DOM below is
|
|
45
133
|
// not driven by a change notification. The string→descriptor mapping is core's, so
|
|
46
134
|
// this row and `<gtk-drop-down>` accept one option vocabulary.
|
|
47
|
-
|
|
135
|
+
//
|
|
136
|
+
// Only when the PROPERTY was not already set, the rule `<adw-data-grid>` and
|
|
137
|
+
// `<gtk-drop-down>` both follow: a model assigned to a detached element must
|
|
138
|
+
// survive being attached, and an absent attribute must not blank it.
|
|
139
|
+
if (this._state.count === 0) this._state.setModel(parseListModel(this.getAttribute('model')));
|
|
48
140
|
this._state.setSelectedIndex(parseInt(this.getAttribute('selected') || '0', 10));
|
|
49
|
-
const selectedIdx = this._state.selectedIndex;
|
|
50
141
|
|
|
51
142
|
const text = document.createElement('div');
|
|
52
143
|
text.className = 'adw-row-text';
|
|
@@ -58,20 +149,14 @@ export class AdwComboRow extends HTMLElement {
|
|
|
58
149
|
|
|
59
150
|
this._valueEl = document.createElement('span');
|
|
60
151
|
this._valueEl.className = 'adw-row-value';
|
|
61
|
-
this._valueEl.textContent = this._state.selectedLabel;
|
|
62
152
|
|
|
63
|
-
|
|
64
|
-
items.forEach((item, i) => {
|
|
65
|
-
const option = document.createElement('option');
|
|
66
|
-
option.value = String(i);
|
|
67
|
-
option.textContent = item;
|
|
68
|
-
if (i === selectedIdx) option.selected = true;
|
|
69
|
-
select.appendChild(option);
|
|
70
|
-
});
|
|
153
|
+
this._select = document.createElement('select');
|
|
71
154
|
|
|
72
|
-
this.replaceChildren(text, this._valueEl,
|
|
155
|
+
this.replaceChildren(text, this._valueEl, this._select);
|
|
73
156
|
|
|
74
|
-
|
|
157
|
+
// WHERE the model changed — applied before the selection change below, which is
|
|
158
|
+
// the ordering that lets that subscriber's index write land on the new list.
|
|
159
|
+
this._state.subscribeItems((change) => this._applyItemsChanged(change));
|
|
75
160
|
|
|
76
161
|
// The core state drives the inline label + the <select> on every change,
|
|
77
162
|
// and the notify::selected event only on an interactive pick.
|
|
@@ -95,13 +180,15 @@ export class AdwComboRow extends HTMLElement {
|
|
|
95
180
|
this._state.select(this._select.selectedIndex);
|
|
96
181
|
});
|
|
97
182
|
|
|
98
|
-
this.
|
|
183
|
+
this._renderOptions();
|
|
99
184
|
this._renderText();
|
|
100
185
|
}
|
|
101
186
|
|
|
102
187
|
attributeChangedCallback(name: string, _old: string | null, value: string | null) {
|
|
103
188
|
if (!this._initialized) return;
|
|
104
|
-
if (name === '
|
|
189
|
+
if (name === 'model') {
|
|
190
|
+
this._state.setModel(parseListModel(value));
|
|
191
|
+
} else if (name === 'selected') {
|
|
105
192
|
// A programmatic set — the core state notifies `interactive: false`,
|
|
106
193
|
// so the subscriber re-syncs the display without emitting an event.
|
|
107
194
|
this._state.setSelectedIndex(parseInt(value || '0', 10));
|
|
@@ -110,6 +197,58 @@ export class AdwComboRow extends HTMLElement {
|
|
|
110
197
|
}
|
|
111
198
|
}
|
|
112
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Build one `<option>` for a model item.
|
|
202
|
+
*
|
|
203
|
+
* ITS `value` IS THE MODEL'S value, not the position. The position used to be written
|
|
204
|
+
* there with a comment defending it, and nothing in this package or its consumers ever
|
|
205
|
+
* read it back — while it made every insertion renumber the whole tail, which is the
|
|
206
|
+
* one thing a splice exists to avoid. The element still addresses its model by
|
|
207
|
+
* POSITION (`selectedIndex`); what the attribute now carries is the descriptor's own
|
|
208
|
+
* value, which is also what a form submission would have wanted.
|
|
209
|
+
*/
|
|
210
|
+
private _createOption(option: AdwComboOption): HTMLOptionElement {
|
|
211
|
+
const el = document.createElement('option');
|
|
212
|
+
el.value = option.value;
|
|
213
|
+
el.textContent = option.label;
|
|
214
|
+
return el;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Apply one `items-changed` to the `<option>` list — remove `removed` nodes at
|
|
219
|
+
* `position`, insert `added` fresh ones there, touch nothing else.
|
|
220
|
+
*
|
|
221
|
+
* A no-op before `connectedCallback` has built the DOM, which is what lets `model` be
|
|
222
|
+
* assigned to a detached element: the state keeps the model and the connect path
|
|
223
|
+
* renders it whole.
|
|
224
|
+
*/
|
|
225
|
+
private _applyItemsChanged(change: AdwListItemsChanged): void {
|
|
226
|
+
if (!this._initialized) return;
|
|
227
|
+
for (let i = 0; i < change.removed; i++) this._select.remove(change.position);
|
|
228
|
+
const before = this._select.options[change.position] ?? null;
|
|
229
|
+
// Read ONCE: `model` hands back a copy (ADR 0046), so reading it per item would make
|
|
230
|
+
// a splice of n items O(n²) instead of the O(n) the splice is.
|
|
231
|
+
const model = this._state.model;
|
|
232
|
+
for (let i = 0; i < change.added; i++) {
|
|
233
|
+
const option = model[change.position + i];
|
|
234
|
+
if (option) this._select.insertBefore(this._createOption(option), before);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Build the whole `<option>` list — the CONNECT path only, where there is no previous
|
|
240
|
+
* list to splice against.
|
|
241
|
+
*
|
|
242
|
+
* Every later assignment goes through {@link _applyItemsChanged}.
|
|
243
|
+
*/
|
|
244
|
+
private _renderOptions(): void {
|
|
245
|
+
if (!this._initialized) return;
|
|
246
|
+
this._select.replaceChildren(...this._state.model.map((option) => this._createOption(option)));
|
|
247
|
+
this._select.selectedIndex = this._state.selectedIndex;
|
|
248
|
+
this._valueEl.textContent = this._state.selectedLabel;
|
|
249
|
+
this._syncChooser();
|
|
250
|
+
}
|
|
251
|
+
|
|
113
252
|
/**
|
|
114
253
|
* `model_changed`: one option or none is not a choice, so the arrow goes and the row
|
|
115
254
|
* stops being activatable.
|
|
@@ -1,29 +1,40 @@
|
|
|
1
1
|
// <adw-spin-row> — row with a title/subtitle and a numeric spin control (+/− buttons).
|
|
2
2
|
//
|
|
3
|
-
// The ADJUSTMENT state machine (
|
|
4
|
-
//
|
|
3
|
+
// The ADJUSTMENT state machine (the range, the clamp on every mutation and the change
|
|
4
|
+
// detection) is HEADLESS and lives in `@gjsify/adwaita-core` (ADR 0004) as
|
|
5
5
|
// {@link SpinState}; this element keeps only the DOM half — the −/input/+ control, the
|
|
6
6
|
// step-precision display (the `Adw.SpinRow:digits` concern, which belongs to the widget
|
|
7
7
|
// rather than to the adjustment) and the `notify::value` event.
|
|
8
8
|
//
|
|
9
|
+
// THE RANGE IS ONE ATTRIBUTE, `adjustment`, and it is JSON:
|
|
10
|
+
//
|
|
11
|
+
// <adw-spin-row title="Zoom" adjustment='{"lower":1,"upper":20,"stepIncrement":1}'>
|
|
12
|
+
//
|
|
13
|
+
// `min` / `max` / `step` are gone. `Adw.SpinRow` has no such properties — it has an
|
|
14
|
+
// `adjustment`, and three renderers spelling that range three ways is what ADR 0047
|
|
15
|
+
// removes. The value is the portable `AdwAdjustment`, so a range authored here is the one
|
|
16
|
+
// the NativeScript row and the React Native widget take. The same markup door
|
|
17
|
+
// `<adw-combo-row model=…>` opens for its list, on the same reasoning.
|
|
18
|
+
//
|
|
9
19
|
// Adapted from Adwaita Web UI Framework (https://github.com/mclellac/adwaita-web).
|
|
10
20
|
// Copyright (c) 2025 csm. MIT License.
|
|
11
21
|
// Modifications: Reimplemented as Web Component for @gjsify/adwaita-web;
|
|
12
22
|
// title/subtitle text column added to match Adw.SpinRow; the adjustment state
|
|
13
23
|
// machine composed from @gjsify/adwaita-core.
|
|
14
24
|
|
|
15
|
-
import { SpinState, deriveRowLabels } from '@gjsify/adwaita-core';
|
|
25
|
+
import { SpinState, deriveRowLabels, normalizeAdjustment, parseAdjustment } from '@gjsify/adwaita-core';
|
|
26
|
+
import type { AdwAdjustment, AdwAdjustmentInput } from '@gjsify/adwaita-core';
|
|
16
27
|
|
|
17
28
|
export class AdwSpinRow extends HTMLElement {
|
|
18
29
|
private _input!: HTMLInputElement;
|
|
19
30
|
private _titleEl!: HTMLSpanElement;
|
|
20
31
|
private _subtitleEl!: HTMLSpanElement;
|
|
21
|
-
/** The headless
|
|
32
|
+
/** The headless adjustment: the range, the clamp and the two signals (ADR 0004, ADR 0047). */
|
|
22
33
|
private readonly _state = new SpinState();
|
|
23
34
|
private _initialized = false;
|
|
24
35
|
|
|
25
36
|
static get observedAttributes() {
|
|
26
|
-
return ['title', 'subtitle', 'value', '
|
|
37
|
+
return ['title', 'subtitle', 'value', 'adjustment'];
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
get value(): number {
|
|
@@ -37,16 +48,35 @@ export class AdwSpinRow extends HTMLElement {
|
|
|
37
48
|
this._syncValue();
|
|
38
49
|
}
|
|
39
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The numeric range — `Adw.SpinRow:adjustment`, as the portable value.
|
|
53
|
+
*
|
|
54
|
+
* Reads back whole; writes MERGE, so `el.adjustment = { upper: 20 }` moves one bound
|
|
55
|
+
* and leaves the value where it is. A string is read as the JSON the attribute carries,
|
|
56
|
+
* so the property and the attribute mean the same thing.
|
|
57
|
+
*/
|
|
58
|
+
get adjustment(): AdwAdjustment {
|
|
59
|
+
return this._state.adjustment;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
set adjustment(v: AdwAdjustmentInput | string) {
|
|
63
|
+
this._state.configure(typeof v === 'string' ? parseAdjustment(v) : v);
|
|
64
|
+
}
|
|
65
|
+
|
|
40
66
|
connectedCallback() {
|
|
41
67
|
if (this._initialized) return;
|
|
42
68
|
this._initialized = true;
|
|
43
69
|
|
|
44
70
|
// Seed the headless adjustment BEFORE subscribing, so building the initial
|
|
45
|
-
// DOM below is not driven by a change notification.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
this._state.
|
|
71
|
+
// DOM below is not driven by a change notification. The range comes whole, so the
|
|
72
|
+
// authored `value` attribute is applied against the range it was authored beside
|
|
73
|
+
// rather than against whatever a partly-seeded state held.
|
|
74
|
+
const range = normalizeAdjustment(parseAdjustment(this.getAttribute('adjustment')));
|
|
75
|
+
this._state.configure(range);
|
|
76
|
+
const authoredValue = this.getAttribute('value');
|
|
77
|
+
this._state.setValue(
|
|
78
|
+
this._roundToStep(authoredValue === null ? range.value : Number.parseFloat(authoredValue)),
|
|
79
|
+
);
|
|
50
80
|
|
|
51
81
|
const text = document.createElement('div');
|
|
52
82
|
text.className = 'adw-row-text';
|
|
@@ -62,7 +92,7 @@ export class AdwSpinRow extends HTMLElement {
|
|
|
62
92
|
const decBtn = document.createElement('button');
|
|
63
93
|
decBtn.className = 'adw-spin-dec';
|
|
64
94
|
decBtn.textContent = '−';
|
|
65
|
-
decBtn.addEventListener('click', () => this._adjust(-this._state.
|
|
95
|
+
decBtn.addEventListener('click', () => this._adjust(-this._state.adjustment.stepIncrement));
|
|
66
96
|
|
|
67
97
|
const input = document.createElement('input');
|
|
68
98
|
input.type = 'text';
|
|
@@ -80,7 +110,7 @@ export class AdwSpinRow extends HTMLElement {
|
|
|
80
110
|
const incBtn = document.createElement('button');
|
|
81
111
|
incBtn.className = 'adw-spin-inc';
|
|
82
112
|
incBtn.textContent = '+';
|
|
83
|
-
incBtn.addEventListener('click', () => this._adjust(this._state.
|
|
113
|
+
incBtn.addEventListener('click', () => this._adjust(this._state.adjustment.stepIncrement));
|
|
84
114
|
|
|
85
115
|
control.append(decBtn, input, incBtn);
|
|
86
116
|
this.replaceChildren(text, control);
|
|
@@ -99,21 +129,12 @@ export class AdwSpinRow extends HTMLElement {
|
|
|
99
129
|
this._renderText();
|
|
100
130
|
return;
|
|
101
131
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
this._state.setValue(this._roundToStep(num));
|
|
106
|
-
break;
|
|
107
|
-
case 'min':
|
|
108
|
-
this._state.setMin(num);
|
|
109
|
-
break;
|
|
110
|
-
case 'max':
|
|
111
|
-
this._state.setMax(num);
|
|
112
|
-
break;
|
|
113
|
-
case 'step':
|
|
114
|
-
this._state.setStep(num);
|
|
115
|
-
break;
|
|
132
|
+
if (name === 'adjustment') {
|
|
133
|
+
this._state.configure(parseAdjustment(val));
|
|
134
|
+
return;
|
|
116
135
|
}
|
|
136
|
+
// `value`, the only other observed attribute reaching here.
|
|
137
|
+
this._state.setValue(this._roundToStep(Number.parseFloat(val || '0')));
|
|
117
138
|
}
|
|
118
139
|
|
|
119
140
|
private _renderText() {
|
|
@@ -151,7 +172,7 @@ export class AdwSpinRow extends HTMLElement {
|
|
|
151
172
|
|
|
152
173
|
private _roundToStep(n: number): number {
|
|
153
174
|
if (!Number.isFinite(n)) return n;
|
|
154
|
-
const decimals = this._countDecimals(this._state.
|
|
175
|
+
const decimals = this._countDecimals(this._state.adjustment.stepIncrement);
|
|
155
176
|
return decimals > 0 ? parseFloat(n.toFixed(decimals)) : n;
|
|
156
177
|
}
|
|
157
178
|
|
|
@@ -162,7 +183,7 @@ export class AdwSpinRow extends HTMLElement {
|
|
|
162
183
|
}
|
|
163
184
|
|
|
164
185
|
private _formatValue(v: number): string {
|
|
165
|
-
const decimals = this._countDecimals(this._state.
|
|
186
|
+
const decimals = this._countDecimals(this._state.adjustment.stepIncrement);
|
|
166
187
|
return decimals > 0 ? v.toFixed(decimals) : String(v);
|
|
167
188
|
}
|
|
168
189
|
}
|
|
@@ -11,14 +11,15 @@
|
|
|
11
11
|
// tooltip — tooltip/accessible name of the action half.
|
|
12
12
|
// dropdown-tooltip — tooltip of the dropdown half; empty restores "More Options".
|
|
13
13
|
// direction — none | up | down | left | right (default down).
|
|
14
|
-
// menu
|
|
14
|
+
// menu-model — JSON array in the portable menu model (ADR 0042): items,
|
|
15
|
+
// sections (`{"section":[…]}`) and submenus (`{"submenu":[…]}`).
|
|
15
16
|
// disabled, flat, suggested, destructive — boolean flags.
|
|
16
17
|
// Events (all CustomEvent, bubbles — mirror the Adw.SplitButton GObject signals):
|
|
17
18
|
// `clicked` — the primary action half was activated.
|
|
18
19
|
// `notify::active` — the dropdown menu was opened/closed (detail.active).
|
|
19
20
|
// `notify::<prop>` — one per GObject property the state machine notified,
|
|
20
21
|
// in libadwaita's emission order.
|
|
21
|
-
// `menu-activated` — a menu
|
|
22
|
+
// `menu-activated` — a menu item was chosen (detail.label / .action / .id / .path).
|
|
22
23
|
//
|
|
23
24
|
// All of the behaviour above lives in `@gjsify/adwaita-core` (ADR 0004) as
|
|
24
25
|
// {@link SplitButtonState}; this element only paints it and translates DOM events
|
|
@@ -36,15 +37,28 @@
|
|
|
36
37
|
// Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
|
|
37
38
|
|
|
38
39
|
import {
|
|
40
|
+
ADW_MENU_SURFACE_WEB,
|
|
39
41
|
SplitButtonState,
|
|
42
|
+
assertMenuRenderable,
|
|
40
43
|
isSplitButtonDirection,
|
|
41
|
-
|
|
44
|
+
menuRefusals,
|
|
45
|
+
normalizeMenuModel,
|
|
46
|
+
parseMenuModel,
|
|
42
47
|
resolveDropdownTooltip,
|
|
43
48
|
splitButtonArrowIcon,
|
|
44
49
|
splitButtonPopupDirection,
|
|
45
50
|
splitButtonRootState,
|
|
46
51
|
} from '@gjsify/adwaita-core';
|
|
47
|
-
import type {
|
|
52
|
+
import type {
|
|
53
|
+
AdwArrowIcon,
|
|
54
|
+
AdwMenuActions,
|
|
55
|
+
AdwMenuInput,
|
|
56
|
+
AdwMenuModel,
|
|
57
|
+
SplitButtonChange,
|
|
58
|
+
SplitButtonDirection,
|
|
59
|
+
} from '@gjsify/adwaita-core';
|
|
60
|
+
|
|
61
|
+
import { PopoverMenuView } from './popover-menu.js';
|
|
48
62
|
|
|
49
63
|
// SIDE-EFFECT import, deliberately separate from the type import below: it is
|
|
50
64
|
// what guarantees `gtk-popover` is defined before this module's own
|
|
@@ -109,6 +123,8 @@ export class AdwSplitButton extends HTMLElement {
|
|
|
109
123
|
private _dropdownEl!: HTMLButtonElement;
|
|
110
124
|
private _arrowEl!: GtkImage;
|
|
111
125
|
private _menuEl!: GtkPopover;
|
|
126
|
+
private _menuView!: PopoverMenuView;
|
|
127
|
+
private _actions: AdwMenuActions | null = null;
|
|
112
128
|
/** Inline text content, captured before we take over the subtree. */
|
|
113
129
|
private _inlineLabel = '';
|
|
114
130
|
/** Last `open` value reflected as `notify::active`, so the event fires once per flip. */
|
|
@@ -123,7 +139,7 @@ export class AdwSplitButton extends HTMLElement {
|
|
|
123
139
|
'tooltip',
|
|
124
140
|
'dropdown-tooltip',
|
|
125
141
|
'direction',
|
|
126
|
-
'menu',
|
|
142
|
+
'menu-model',
|
|
127
143
|
'disabled',
|
|
128
144
|
'flat',
|
|
129
145
|
'suggested',
|
|
@@ -162,12 +178,74 @@ export class AdwSplitButton extends HTMLElement {
|
|
|
162
178
|
this.removeAttribute('label');
|
|
163
179
|
}
|
|
164
180
|
|
|
165
|
-
|
|
181
|
+
/** The menu, normalised (ADR 0042). Empty when the dropdown has none. */
|
|
182
|
+
get menuModel(): AdwMenuModel {
|
|
166
183
|
return this._state.menuModel ?? [];
|
|
167
184
|
}
|
|
168
185
|
|
|
169
|
-
|
|
170
|
-
|
|
186
|
+
/**
|
|
187
|
+
* Set the menu from anything the portable model accepts — a bare `string[]`, item
|
|
188
|
+
* descriptors, sections, submenus.
|
|
189
|
+
*
|
|
190
|
+
* The REFUSAL is here rather than at render time: a `custom` item names an
|
|
191
|
+
* application widget this surface cannot host, and discovering that as a blank row
|
|
192
|
+
* three interactions later is what {@link assertMenuRenderable} exists to prevent.
|
|
193
|
+
*/
|
|
194
|
+
set menuModel(value: AdwMenuInput) {
|
|
195
|
+
// Refused BEFORE it is stored, so a throw leaves the widget showing the menu it
|
|
196
|
+
// had rather than one it cannot draw.
|
|
197
|
+
this._state.setMenuModel(this._acceptMenu(value));
|
|
198
|
+
if (this._initialized) this._render();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Normalise, REFUSE, store — the one door every menu comes through.
|
|
203
|
+
*
|
|
204
|
+
* The property setter is not the only way a menu arrives: an attribute is, and the
|
|
205
|
+
* first cut refused only the setter, so a `custom` item written in markup drew
|
|
206
|
+
* exactly the row `assertMenuRenderable` exists to prevent — one that reads like a
|
|
207
|
+
* command and does nothing.
|
|
208
|
+
*
|
|
209
|
+
* THE TWO DOORS REFUSE DIFFERENTLY, and the difference is which side can hear it.
|
|
210
|
+
* A property assignment is a CALL, so it throws and the caller can catch. An
|
|
211
|
+
* attribute is MARKUP, parsed by the browser: a throw from `connectedCallback` is
|
|
212
|
+
* not delivered to whoever appended the element, it is reported as an uncaught
|
|
213
|
+
* page error — measured, it broke `adwaita-upgrade-order.spec.ts`, which counts
|
|
214
|
+
* exactly those. Nobody can handle it and everybody else pays for it. So the
|
|
215
|
+
* attribute path REFUSES THE MENU (no menu, and a menu-less dropdown is
|
|
216
|
+
* insensitive, which is visible) and says why on `console.error`.
|
|
217
|
+
*
|
|
218
|
+
* A REFUSAL AND A TYPO ARE STILL DIFFERENT THINGS. `parseMenuModel` is total
|
|
219
|
+
* because malformed JSON is an author slip and must not stop the element
|
|
220
|
+
* upgrading; `custom` is well-formed, deliberate, and unhonourable here.
|
|
221
|
+
*/
|
|
222
|
+
private _acceptMenu(input: AdwMenuInput, strict = true): AdwMenuModel {
|
|
223
|
+
const model = normalizeMenuModel(input);
|
|
224
|
+
if (strict) {
|
|
225
|
+
assertMenuRenderable(model, ADW_MENU_SURFACE_WEB);
|
|
226
|
+
return model;
|
|
227
|
+
}
|
|
228
|
+
const refusals = menuRefusals(model, ADW_MENU_SURFACE_WEB);
|
|
229
|
+
if (refusals.length === 0) return model;
|
|
230
|
+
// `console.error` is the browser's own channel for "this is wrong, the page
|
|
231
|
+
// continues" — and the alternative here is a row that lies.
|
|
232
|
+
console.error(`${ADW_MENU_SURFACE_WEB.name} cannot render this menu, so it has none:`);
|
|
233
|
+
for (const refusal of refusals) console.error(` [${refusal.path.join('.')}] ${refusal.message}`);
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* What the action group publishes about the actions this menu names — the portable
|
|
239
|
+
* stand-in for a `GActionGroup`, and the ONLY place a menu's enabled/checked state
|
|
240
|
+
* can come from (ADR 0042). Setting it re-renders the rows.
|
|
241
|
+
*/
|
|
242
|
+
get actions(): AdwMenuActions | null {
|
|
243
|
+
return this._actions;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
set actions(value: AdwMenuActions | null) {
|
|
247
|
+
this._actions = value ?? null;
|
|
248
|
+
this._menuView?.setActions(this._actions);
|
|
171
249
|
if (this._initialized) this._render();
|
|
172
250
|
}
|
|
173
251
|
|
|
@@ -248,8 +326,29 @@ export class AdwSplitButton extends HTMLElement {
|
|
|
248
326
|
// `notify::active` all read it). A dismissal the popover owns — outside
|
|
249
327
|
// click, Escape — is fed back in here rather than diverging.
|
|
250
328
|
this._menuEl.subscribe((open) => {
|
|
251
|
-
if (!open)
|
|
329
|
+
if (!open) {
|
|
330
|
+
this._state.closeMenu();
|
|
331
|
+
// A menu that reopens three pages deep is a menu the reader cannot get
|
|
332
|
+
// out of, so a dismissal returns it to the top.
|
|
333
|
+
this._menuView.reset();
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
this._menuView = new PopoverMenuView(this._menuEl, 'adw-split-button-menu', (path) => {
|
|
337
|
+
const activated = this._state.activateMenuItem(path);
|
|
338
|
+
if (activated === null) return;
|
|
339
|
+
this.dispatchEvent(
|
|
340
|
+
new CustomEvent('menu-activated', {
|
|
341
|
+
bubbles: true,
|
|
342
|
+
detail: {
|
|
343
|
+
label: activated.label,
|
|
344
|
+
action: activated.action,
|
|
345
|
+
id: activated.id ?? activated.label,
|
|
346
|
+
path: [...path],
|
|
347
|
+
},
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
252
350
|
});
|
|
351
|
+
this._menuView.setActions(this._actions);
|
|
253
352
|
|
|
254
353
|
this._actionEl.addEventListener('click', () => {
|
|
255
354
|
if (this.hasAttribute('disabled')) return;
|
|
@@ -284,8 +383,8 @@ export class AdwSplitButton extends HTMLElement {
|
|
|
284
383
|
case 'dropdown-tooltip':
|
|
285
384
|
this._state.setDropdownTooltip(value ?? '');
|
|
286
385
|
break;
|
|
287
|
-
case 'menu':
|
|
288
|
-
this._state.setMenuModel(
|
|
386
|
+
case 'menu-model':
|
|
387
|
+
this._state.setMenuModel(this._acceptMenu(parseMenuModel(value), false));
|
|
289
388
|
break;
|
|
290
389
|
default:
|
|
291
390
|
break;
|
|
@@ -307,9 +406,11 @@ export class AdwSplitButton extends HTMLElement {
|
|
|
307
406
|
const direction = this.getAttribute('direction');
|
|
308
407
|
if (isSplitButtonDirection(direction)) this._state.setDirection(direction);
|
|
309
408
|
this._state.setDropdownTooltip(this.getAttribute('dropdown-tooltip') ?? '');
|
|
310
|
-
// The `
|
|
409
|
+
// The `menuModel` property may have been set before the element upgraded;
|
|
311
410
|
// the attribute only seeds an otherwise empty menu.
|
|
312
|
-
if (this._state.menuModel === null)
|
|
411
|
+
if (this._state.menuModel === null) {
|
|
412
|
+
this._state.setMenuModel(this._acceptMenu(parseMenuModel(this.getAttribute('menu-model')), false));
|
|
413
|
+
}
|
|
313
414
|
}
|
|
314
415
|
|
|
315
416
|
/**
|
|
@@ -344,7 +445,7 @@ export class AdwSplitButton extends HTMLElement {
|
|
|
344
445
|
this._render();
|
|
345
446
|
// Focus lands in the menu on open, AFTER _render has built the rows —
|
|
346
447
|
// otherwise the popover's arrow keys have nothing to move from.
|
|
347
|
-
if (opened) this.
|
|
448
|
+
if (opened) this._menuView.focusableRows[0]?.focus();
|
|
348
449
|
}
|
|
349
450
|
|
|
350
451
|
private _render(): void {
|
|
@@ -423,32 +524,9 @@ export class AdwSplitButton extends HTMLElement {
|
|
|
423
524
|
}
|
|
424
525
|
|
|
425
526
|
private _renderMenu(): void {
|
|
426
|
-
this._menuEl.replaceChildren();
|
|
427
527
|
this._menuEl.open = this._state.open;
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
const item = document.createElement('button');
|
|
431
|
-
item.type = 'button';
|
|
432
|
-
// `.adw-popover-item` is what makes the row navigable: it is the selector
|
|
433
|
-
// `<gtk-popover>` walks for arrow/Home/End/Enter.
|
|
434
|
-
item.className = 'adw-popover-item adw-split-button-menu-item';
|
|
435
|
-
item.setAttribute('role', 'menuitem');
|
|
436
|
-
item.tabIndex = -1;
|
|
437
|
-
item.textContent = entry.label;
|
|
438
|
-
item.addEventListener('click', () => {
|
|
439
|
-
// By position, never by label: two entries called "Copy" are legal
|
|
440
|
-
// and must each dispatch their own action.
|
|
441
|
-
const activated = this._state.activateMenuEntry(index);
|
|
442
|
-
if (activated === null) return;
|
|
443
|
-
this.dispatchEvent(
|
|
444
|
-
new CustomEvent('menu-activated', {
|
|
445
|
-
bubbles: true,
|
|
446
|
-
detail: { label: activated.label, action: activated.action, index },
|
|
447
|
-
}),
|
|
448
|
-
);
|
|
449
|
-
});
|
|
450
|
-
this._menuEl.appendChild(item);
|
|
451
|
-
}
|
|
528
|
+
this._menuView.setMenuModel(this._state.menuModel ?? []);
|
|
529
|
+
this._menuView.render();
|
|
452
530
|
}
|
|
453
531
|
}
|
|
454
532
|
|