@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/adwaita-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.0",
|
|
4
4
|
"description": "Adwaita/Libadwaita web components for browser targets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -45,14 +45,14 @@
|
|
|
45
45
|
"@codemirror/language": "^6.12.4",
|
|
46
46
|
"@codemirror/state": "^6.7.0",
|
|
47
47
|
"@codemirror/view": "^6.43.4",
|
|
48
|
-
"@gjsify/adwaita-core": "^0.
|
|
49
|
-
"@gjsify/adwaita-fonts": "^0.
|
|
50
|
-
"@gjsify/adwaita-icons": "^0.
|
|
48
|
+
"@gjsify/adwaita-core": "^0.48.0",
|
|
49
|
+
"@gjsify/adwaita-fonts": "^0.48.0",
|
|
50
|
+
"@gjsify/adwaita-icons": "^0.48.0",
|
|
51
51
|
"@lezer/highlight": "^1.2.3"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@gjsify/cli": "^0.
|
|
55
|
-
"@gjsify/unit": "^0.
|
|
54
|
+
"@gjsify/cli": "^0.48.0",
|
|
55
|
+
"@gjsify/unit": "^0.48.0",
|
|
56
56
|
"sass": "^1.101.0",
|
|
57
57
|
"typescript": "^6.0.3"
|
|
58
58
|
},
|
package/scss/_popover.scss
CHANGED
|
@@ -188,3 +188,102 @@ gtk-popover {
|
|
|
188
188
|
font-weight: 700;
|
|
189
189
|
opacity: var(--dim-opacity);
|
|
190
190
|
}
|
|
191
|
+
|
|
192
|
+
// --- The menu INSIDE the popover (ADR 0042) -----------------------------------
|
|
193
|
+
//
|
|
194
|
+
// One rule set for both menu-bearing elements, because the split button's dropdown
|
|
195
|
+
// half IS a GtkMenuButton and its popup is the same `popover.menu`. SIX nodes, none of
|
|
196
|
+
// which has a counterpart in the port's previous menus — they drew a flat list of labels
|
|
197
|
+
// and nothing else: the separator, the section title, the accelerator, the check/radio
|
|
198
|
+
// mark (plus the two nested `[aria-checked='true']` rules that draw it), the submenu
|
|
199
|
+
// chevron and the back row.
|
|
200
|
+
|
|
201
|
+
// `popover.menu separator { margin: 6px 0 }` (_menus.scss:68-70). GTK's separator is
|
|
202
|
+
// a real widget with its own background; in the DOM the hairline is the border, so
|
|
203
|
+
// the 6px is split above and below it rather than being a margin on a 1px box.
|
|
204
|
+
.adw-popover-separator {
|
|
205
|
+
height: 1px;
|
|
206
|
+
margin: 6px 0;
|
|
207
|
+
background-color: var(--separator-color, rgba(0, 0, 0, 0.15));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// A section's heading. `.adw-popover-title` already carries the weight and the dim;
|
|
211
|
+
// this is the tighter top inset a heading needs when it follows a separator rather
|
|
212
|
+
// than opening the menu.
|
|
213
|
+
.adw-popover-section-title {
|
|
214
|
+
padding-top: 2px;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// `popover.menu accelerator { color: dim; margin-left: $menu_padding }`
|
|
218
|
+
// (_menus.scss:78-82). `margin-inline-start` rather than `margin-left`, so it follows
|
|
219
|
+
// the writing direction the way GTK's `:dir(ltr)`/`:dir(rtl)` pair does — the two
|
|
220
|
+
// rules are one logical property here.
|
|
221
|
+
.adw-popover-item-accel {
|
|
222
|
+
margin-inline-start: auto;
|
|
223
|
+
padding-inline-start: 12px;
|
|
224
|
+
opacity: var(--dim-opacity);
|
|
225
|
+
font-size: 0.9em;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// `popover.menu check, radio { min-width: 14px; min-height: 14px; padding: 2px }`
|
|
229
|
+
// (_menus.scss:85-90). The slot is reserved whether or not the row is checked, so the
|
|
230
|
+
// labels of a group line up — the reason it is a node and not a `::before`.
|
|
231
|
+
//
|
|
232
|
+
// A DELIBERATE SUBSTITUTION, the same kind `<adw-split-button>`'s pan-up arrow already
|
|
233
|
+
// documents: GTK draws the tick with `object-select-symbolic`, and this port's icon set
|
|
234
|
+
// ships no checkmark (`_icons.generated.scss` has 42 glyphs and none of them is one). A
|
|
235
|
+
// two-sided rotated border is the tick, so nothing here depends on an asset that would
|
|
236
|
+
// silently render as `image-missing`.
|
|
237
|
+
.adw-popover-item-check {
|
|
238
|
+
position: relative;
|
|
239
|
+
box-sizing: border-box;
|
|
240
|
+
flex: none;
|
|
241
|
+
width: 14px;
|
|
242
|
+
height: 14px;
|
|
243
|
+
padding: 2px;
|
|
244
|
+
|
|
245
|
+
// Drawn off `aria-checked`, never off a class of its own: the accessible state and
|
|
246
|
+
// the visible state are then the same fact rather than two that can disagree.
|
|
247
|
+
[aria-checked='true'] > &::after {
|
|
248
|
+
content: '';
|
|
249
|
+
position: absolute;
|
|
250
|
+
inset-inline-start: 4px;
|
|
251
|
+
top: 1px;
|
|
252
|
+
width: 4px;
|
|
253
|
+
height: 8px;
|
|
254
|
+
border: solid currentcolor;
|
|
255
|
+
border-width: 0 2px 2px 0;
|
|
256
|
+
transform: rotate(45deg);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// A radio's mark is a filled dot, so the tick becomes a disc.
|
|
260
|
+
&.radio {
|
|
261
|
+
border-radius: 9999px;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
[aria-checked='true'] > &.radio::after {
|
|
265
|
+
inset-inline-start: 4px;
|
|
266
|
+
top: 4px;
|
|
267
|
+
width: 6px;
|
|
268
|
+
height: 6px;
|
|
269
|
+
border: none;
|
|
270
|
+
border-radius: 9999px;
|
|
271
|
+
background-color: currentcolor;
|
|
272
|
+
transform: none;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// The chevron on a row that opens a submenu.
|
|
277
|
+
.adw-popover-item-arrow {
|
|
278
|
+
margin-inline-start: auto;
|
|
279
|
+
width: 16px;
|
|
280
|
+
height: 16px;
|
|
281
|
+
flex: none;
|
|
282
|
+
opacity: var(--dim-opacity);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// The row that leaves a submenu. It carries the parent's own label, so it reads as
|
|
286
|
+
// the heading of the page it returns to rather than as a bare "Back".
|
|
287
|
+
.adw-popover-back {
|
|
288
|
+
font-weight: 700;
|
|
289
|
+
}
|
|
@@ -9,7 +9,19 @@
|
|
|
9
9
|
// display silently.
|
|
10
10
|
import { describe, expect, it } from '@gjsify/unit';
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
ADJUSTMENT_AUTHORED_VECTORS,
|
|
14
|
+
ADJUSTMENT_PARSE_VECTORS,
|
|
15
|
+
COMBO_CHOOSER_VECTORS,
|
|
16
|
+
COMBO_SELECTION_VECTORS,
|
|
17
|
+
LIST_ITEMS_CHANGED_VECTORS,
|
|
18
|
+
LIST_MODEL_OWNERSHIP_VECTORS,
|
|
19
|
+
LIST_NORMALIZE_VECTORS,
|
|
20
|
+
LIST_PARSE_VECTORS,
|
|
21
|
+
LIST_SELECTION_CLAMP_VECTORS,
|
|
22
|
+
applyListReadback,
|
|
23
|
+
} from '@gjsify/adwaita-core/conformance';
|
|
24
|
+
import type { ComboSelectionStep } from '@gjsify/adwaita-core/conformance';
|
|
13
25
|
|
|
14
26
|
import type { AdwComboRow } from './elements/adw-combo-row.js';
|
|
15
27
|
import type { AdwExpanderRow } from './elements/adw-expander-row.js';
|
|
@@ -32,11 +44,45 @@ function record(el: HTMLElement, event: string): { details: unknown[]; stop: ()
|
|
|
32
44
|
return { details, stop: () => el.removeEventListener(event, listener) };
|
|
33
45
|
}
|
|
34
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Replay one `COMBO_SELECTION_VECTORS` step against a live `<adw-combo-row>` — the
|
|
49
|
+
* property sets a consumer has for the programmatic ops, a real `<select>` pick for the
|
|
50
|
+
* interactive one.
|
|
51
|
+
*
|
|
52
|
+
* The row can drive the table at all only because the model and the select-by-value are
|
|
53
|
+
* now published; the conformance header names that as the condition for it inheriting
|
|
54
|
+
* these rows. It drives EVERY row, including `an index past the end`, which
|
|
55
|
+
* `<gtk-drop-down>` carves out: the row keeps `ComboState`'s permissive answer where the
|
|
56
|
+
* drop-down's published `selected` contract rejects the set, and `ComboState.hasIndex`'s
|
|
57
|
+
* doc is where that split is stated. So the carve-out is now visible from both sides.
|
|
58
|
+
*/
|
|
59
|
+
function applyComboStep(row: AdwComboRow, step: ComboSelectionStep): void {
|
|
60
|
+
const select = row.querySelector('select') as HTMLSelectElement;
|
|
61
|
+
switch (step.op) {
|
|
62
|
+
case 'setModel':
|
|
63
|
+
row.model = step.model;
|
|
64
|
+
return;
|
|
65
|
+
case 'setSelectedIndex':
|
|
66
|
+
row.selected = step.index;
|
|
67
|
+
return;
|
|
68
|
+
case 'setSelectedValue':
|
|
69
|
+
row.selectedValue = step.value;
|
|
70
|
+
return;
|
|
71
|
+
case 'select':
|
|
72
|
+
// A dismissed chooser: the native dropdown closes with nothing picked, which
|
|
73
|
+
// dispatches no `change` at all — there is no gesture to replay.
|
|
74
|
+
if (step.index < 0) return;
|
|
75
|
+
select.selectedIndex = step.index;
|
|
76
|
+
select.dispatchEvent(new Event('change'));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
35
81
|
export const AdwRowStateTest = async () => {
|
|
36
82
|
await describe('adw-combo-row (ComboState)', async () => {
|
|
37
83
|
await it('renders the selected label and notifies only on a user pick', async () => {
|
|
38
84
|
const { el: row, host } = parse<AdwComboRow>(
|
|
39
|
-
`<adw-combo-row title="Colour"
|
|
85
|
+
`<adw-combo-row title="Colour" model='["Red","Green","Blue"]' selected="1"></adw-combo-row>`,
|
|
40
86
|
'adw-combo-row',
|
|
41
87
|
);
|
|
42
88
|
const select = row.querySelector('select') as HTMLSelectElement;
|
|
@@ -68,7 +114,7 @@ export const AdwRowStateTest = async () => {
|
|
|
68
114
|
|
|
69
115
|
await it('renders an empty value for an out-of-range selection', async () => {
|
|
70
116
|
const { el: row, host } = parse<AdwComboRow>(
|
|
71
|
-
`<adw-combo-row title="Empty"
|
|
117
|
+
`<adw-combo-row title="Empty" model='[]' selected="3"></adw-combo-row>`,
|
|
72
118
|
'adw-combo-row',
|
|
73
119
|
);
|
|
74
120
|
expect((row.querySelector('.adw-row-value') as HTMLSpanElement).textContent).toBe('');
|
|
@@ -79,7 +125,7 @@ export const AdwRowStateTest = async () => {
|
|
|
79
125
|
await it(`${count} option(s) → ${presentsChooser ? 'a chooser' : 'not a chooser'}: ${rule}`, async () => {
|
|
80
126
|
const items = JSON.stringify(Array.from({ length: count }, (_, i) => `Item ${i}`));
|
|
81
127
|
const { el: row, host } = parse<AdwComboRow>(
|
|
82
|
-
`<adw-combo-row title="Pick"
|
|
128
|
+
`<adw-combo-row title="Pick" model='${items}'></adw-combo-row>`,
|
|
83
129
|
'adw-combo-row',
|
|
84
130
|
);
|
|
85
131
|
const value = row.querySelector('.adw-row-value') as HTMLSpanElement;
|
|
@@ -101,31 +147,302 @@ export const AdwRowStateTest = async () => {
|
|
|
101
147
|
|
|
102
148
|
await it('gains its arrow back when the model grows past one item', async () => {
|
|
103
149
|
const { el: row, host } = parse<AdwComboRow>(
|
|
104
|
-
`<adw-combo-row title="Pick"
|
|
150
|
+
`<adw-combo-row title="Pick" model='["Only"]'></adw-combo-row>`,
|
|
105
151
|
'adw-combo-row',
|
|
106
152
|
);
|
|
107
153
|
const select = row.querySelector('select') as HTMLSelectElement;
|
|
108
154
|
expect(select.disabled).toBe(true);
|
|
109
155
|
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
row as unknown as { _state: { setOptions(o: { label: string; value: string }[]): void } }
|
|
114
|
-
)._state.setOptions([
|
|
115
|
-
{ label: 'One', value: 'a' },
|
|
116
|
-
{ label: 'Two', value: 'b' },
|
|
117
|
-
]);
|
|
156
|
+
// Through the PUBLISHED path a consumer has — the same `model` property
|
|
157
|
+
// `<gtk-drop-down>` publishes, not a reach into the composed state.
|
|
158
|
+
row.model = ['One', 'Two'];
|
|
118
159
|
expect(select.disabled).toBe(false);
|
|
119
160
|
expect(row.dataset.presentsChooser).toBe('true');
|
|
120
161
|
|
|
121
162
|
host.remove();
|
|
122
163
|
});
|
|
164
|
+
|
|
165
|
+
// The model is INPUT, so replacing it after connect has to reach the DOM — the
|
|
166
|
+
// half `<gtk-drop-down>` has had all along and this row did not: it parsed its
|
|
167
|
+
// items once in `connectedCallback`, observed `['title','subtitle','selected']`,
|
|
168
|
+
// and published no accessor, so `row.items = […]` wrote an expando onto the
|
|
169
|
+
// element and `setAttribute('items', …)` reached no callback (#1525). Both doors
|
|
170
|
+
// are asserted because a consumer reaches for whichever its framework binds.
|
|
171
|
+
await it('re-renders the options when the model is replaced after connect', async () => {
|
|
172
|
+
const { el: row, host } = parse<AdwComboRow>(
|
|
173
|
+
`<adw-combo-row title="Colour" model='["Red","Green"]'></adw-combo-row>`,
|
|
174
|
+
'adw-combo-row',
|
|
175
|
+
);
|
|
176
|
+
const select = row.querySelector('select') as HTMLSelectElement;
|
|
177
|
+
const value = row.querySelector('.adw-row-value') as HTMLSpanElement;
|
|
178
|
+
expect(select.options.length).toBe(2);
|
|
179
|
+
|
|
180
|
+
// The PROPERTY, with the plain-string vocabulary both selectors accept.
|
|
181
|
+
row.model = ['Cyan', 'Magenta', 'Yellow'];
|
|
182
|
+
expect([...select.options].map((o) => o.textContent).join('|')).toBe('Cyan|Magenta|Yellow');
|
|
183
|
+
expect(row.model.map((o) => o.label).join('|')).toBe('Cyan|Magenta|Yellow');
|
|
184
|
+
// The model was replaced under an index the new one still has, so autoselect
|
|
185
|
+
// keeps 0 and the inline label has to follow the NEW option at that index.
|
|
186
|
+
expect(value.textContent).toBe('Cyan');
|
|
187
|
+
|
|
188
|
+
// The ATTRIBUTE, the spelling markup and every attribute-binding framework use.
|
|
189
|
+
row.setAttribute('model', '["Only"]');
|
|
190
|
+
expect([...select.options].map((o) => o.textContent).join('|')).toBe('Only');
|
|
191
|
+
expect(value.textContent).toBe('Only');
|
|
192
|
+
// …and the chooser rule re-runs off the replaced model.
|
|
193
|
+
expect(select.disabled).toBe(true);
|
|
194
|
+
expect(row.dataset.presentsChooser).toBe('false');
|
|
195
|
+
|
|
196
|
+
host.remove();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// One spelling on both selectors — `model`, `Adw.ComboRow`'s own property name
|
|
200
|
+
// (ADR 0046) — so a consumer moving a model between them renames nothing.
|
|
201
|
+
await it('accepts descriptor objects, and selection by value', async () => {
|
|
202
|
+
const { el: row, host } = parse<AdwComboRow>(
|
|
203
|
+
`<adw-combo-row title="Colour"></adw-combo-row>`,
|
|
204
|
+
'adw-combo-row',
|
|
205
|
+
);
|
|
206
|
+
const select = row.querySelector('select') as HTMLSelectElement;
|
|
207
|
+
|
|
208
|
+
row.model = [
|
|
209
|
+
{ value: 'r', label: 'Red' },
|
|
210
|
+
{ value: 'g', label: 'Green' },
|
|
211
|
+
];
|
|
212
|
+
expect([...select.options].map((o) => o.textContent).join('|')).toBe('Red|Green');
|
|
213
|
+
expect(row.model.map((o) => o.value).join('|')).toBe('r|g');
|
|
214
|
+
|
|
215
|
+
// And selection by VALUE, the drop-down's other published setter.
|
|
216
|
+
row.selectedValue = 'g';
|
|
217
|
+
expect(row.selected).toBe(1);
|
|
218
|
+
expect((row.querySelector('.adw-row-value') as HTMLSpanElement).textContent).toBe('Green');
|
|
219
|
+
|
|
220
|
+
host.remove();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// The row publishes the drop-down's property set apart from `enableSearch` and
|
|
224
|
+
// `active`, which are that element's popover chrome. `selectedItem` is NOT chrome —
|
|
225
|
+
// it is `Adw.ComboRow:selected-item` — so it is here, read-only as the GObject
|
|
226
|
+
// property is, and `null` rather than a blank descriptor when nothing is addressed.
|
|
227
|
+
await it('reads the selected descriptor back, and null when nothing is addressed', async () => {
|
|
228
|
+
const { el: row, host } = parse<AdwComboRow>(
|
|
229
|
+
`<adw-combo-row title="Pick"></adw-combo-row>`,
|
|
230
|
+
'adw-combo-row',
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
expect(row.selectedItem).toBeNull();
|
|
234
|
+
|
|
235
|
+
row.model = [
|
|
236
|
+
{ value: 'r', label: 'Red' },
|
|
237
|
+
{ value: 'g', label: 'Green' },
|
|
238
|
+
];
|
|
239
|
+
expect(row.selectedItem?.value).toBe('r');
|
|
240
|
+
row.selected = 1;
|
|
241
|
+
expect(row.selectedItem?.label).toBe('Green');
|
|
242
|
+
|
|
243
|
+
// The one row of the shared contract the two selectors answer differently: an
|
|
244
|
+
// index past the end is ACCEPTED here (`ComboState.setSelectedIndex` mirrors a
|
|
245
|
+
// guint property) where `<gtk-drop-down>` rejects the set. So the index moves,
|
|
246
|
+
// and everything read out of the model reads empty.
|
|
247
|
+
row.selected = 9;
|
|
248
|
+
expect(row.selected).toBe(9);
|
|
249
|
+
expect(row.selectedItem).toBeNull();
|
|
250
|
+
expect(row.selectedValue).toBe('');
|
|
251
|
+
expect((row.querySelector('.adw-row-value') as HTMLSpanElement).textContent).toBe('');
|
|
252
|
+
|
|
253
|
+
host.remove();
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// The portable list model (ADR 0046), driven through this row's own `model` door.
|
|
258
|
+
//
|
|
259
|
+
// The ITEMS-CHANGED rows are the ones that could not be asserted anywhere else: they
|
|
260
|
+
// compare `<option>` node IDENTITY across a replacement, which is the difference
|
|
261
|
+
// between splicing the model and rebuilding it — a difference no assertion about the
|
|
262
|
+
// model, the labels or the selection can see. Before the splice landed, every row with
|
|
263
|
+
// a non-zero `survivors` scored 0.
|
|
264
|
+
await describe('adw-combo-row list model (portable list-model vectors)', async () => {
|
|
265
|
+
for (const vector of LIST_NORMALIZE_VECTORS) {
|
|
266
|
+
await it(`normalize — ${vector.rule}`, async () => {
|
|
267
|
+
const { el: row, host } = parse<AdwComboRow>(
|
|
268
|
+
`<adw-combo-row title="Pick"></adw-combo-row>`,
|
|
269
|
+
'adw-combo-row',
|
|
270
|
+
);
|
|
271
|
+
row.model = vector.input;
|
|
272
|
+
expect(row.model).toStrictEqual([...vector.model]);
|
|
273
|
+
const select = row.querySelector('select') as HTMLSelectElement;
|
|
274
|
+
expect([...select.options].map((option) => option.textContent)).toStrictEqual(
|
|
275
|
+
vector.model.map((item) => item.label),
|
|
276
|
+
);
|
|
277
|
+
host.remove();
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
for (const vector of LIST_PARSE_VECTORS) {
|
|
282
|
+
await it(`attribute — ${vector.rule}`, async () => {
|
|
283
|
+
const { el: row, host } = parse<AdwComboRow>(
|
|
284
|
+
`<adw-combo-row title="Pick"></adw-combo-row>`,
|
|
285
|
+
'adw-combo-row',
|
|
286
|
+
);
|
|
287
|
+
// Set AFTER connect, so the attribute reaches the callback rather than the
|
|
288
|
+
// seeding path: a total parser has to be total on the live door too.
|
|
289
|
+
row.setAttribute('model', vector.attribute);
|
|
290
|
+
expect(row.model).toStrictEqual([...vector.model]);
|
|
291
|
+
host.remove();
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for (const vector of LIST_ITEMS_CHANGED_VECTORS) {
|
|
296
|
+
await it(`items-changed — ${vector.rule}`, async () => {
|
|
297
|
+
const { el: row, host } = parse<AdwComboRow>(
|
|
298
|
+
`<adw-combo-row title="Pick"></adw-combo-row>`,
|
|
299
|
+
'adw-combo-row',
|
|
300
|
+
);
|
|
301
|
+
const select = row.querySelector('select') as HTMLSelectElement;
|
|
302
|
+
row.model = vector.previous;
|
|
303
|
+
const before = [...select.options];
|
|
304
|
+
|
|
305
|
+
row.model = vector.next;
|
|
306
|
+
|
|
307
|
+
const after = [...select.options];
|
|
308
|
+
expect(after.map((option) => option.textContent)).toStrictEqual(vector.next.map((item) => item.label));
|
|
309
|
+
expect(before.filter((option) => after.includes(option)).length).toBe(vector.survivors);
|
|
310
|
+
host.remove();
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// WHO OWNS the array `model` hands back. The DOM is what makes these rows worth
|
|
315
|
+
// driving at the element: without the copy the MODEL still reads correctly on the
|
|
316
|
+
// mutate-then-assign rows — it is `_options` itself that was mutated — and only the
|
|
317
|
+
// `<option>` list shows that no splice was ever emitted.
|
|
318
|
+
for (const vector of LIST_MODEL_OWNERSHIP_VECTORS) {
|
|
319
|
+
await it(`ownership — ${vector.rule}`, async () => {
|
|
320
|
+
const { el: row, host } = parse<AdwComboRow>(
|
|
321
|
+
`<adw-combo-row title="Pick"></adw-combo-row>`,
|
|
322
|
+
'adw-combo-row',
|
|
323
|
+
);
|
|
324
|
+
const select = row.querySelector('select') as HTMLSelectElement;
|
|
325
|
+
const input = vector.initial.map((item) => ({ ...item }));
|
|
326
|
+
row.model = input;
|
|
327
|
+
const before = [...select.options];
|
|
328
|
+
|
|
329
|
+
const mutated = applyListReadback(vector.source === 'input' ? input : row.model, vector.op);
|
|
330
|
+
if (vector.assign) row.model = mutated;
|
|
331
|
+
|
|
332
|
+
expect(row.model).toStrictEqual([...vector.model]);
|
|
333
|
+
const after = [...select.options];
|
|
334
|
+
expect(after.map((option) => option.textContent)).toStrictEqual(vector.model.map((item) => item.label));
|
|
335
|
+
expect(before.filter((option) => after.includes(option)).length).toBe(vector.survivors);
|
|
336
|
+
host.remove();
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
for (const vector of LIST_SELECTION_CLAMP_VECTORS) {
|
|
341
|
+
await it(`autoselect — ${vector.rule}`, async () => {
|
|
342
|
+
const { el: row, host } = parse<AdwComboRow>(
|
|
343
|
+
`<adw-combo-row title="Pick"></adw-combo-row>`,
|
|
344
|
+
'adw-combo-row',
|
|
345
|
+
);
|
|
346
|
+
// Seed a model long enough to hold the starting index, then replace it with
|
|
347
|
+
// one of the vector's length. NARROWER THAN THE VECTOR ON ONE ROW, and it
|
|
348
|
+
// says so: this element cannot HOLD a non-finite index — `setSelectedIndex`
|
|
349
|
+
// folds it to 0 before the clamp is reached — so that row asserts the same
|
|
350
|
+
// outcome by a different route here, and the non-finite input itself is
|
|
351
|
+
// driven in the core suite.
|
|
352
|
+
row.model = Array.from({ length: 8 }, (_, i) => `item ${i}`);
|
|
353
|
+
row.setAttribute('selected', String(vector.selected));
|
|
354
|
+
row.model = Array.from({ length: vector.length }, (_, i) => `next ${i}`);
|
|
355
|
+
expect(row.selected).toBe(vector.result);
|
|
356
|
+
host.remove();
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// The SAME table `<gtk-drop-down>` and the NativeScript renderer are held to
|
|
362
|
+
// (`COMBO_SELECTION_VECTORS`), replayed through this row's own DOM and read back as
|
|
363
|
+
// what a user would see: the inline value label, `selected`/`selectedValue`, and the
|
|
364
|
+
// `notify::selected` stream. Two of the four step ops had no spelling here until the
|
|
365
|
+
// model and the select-by-value were published, which is what the conformance header
|
|
366
|
+
// names as the condition for this row inheriting the rows.
|
|
367
|
+
await describe('adw-combo-row selection (libadwaita conformance vectors)', async () => {
|
|
368
|
+
for (const vector of COMBO_SELECTION_VECTORS) {
|
|
369
|
+
await it(`${vector.name} — ${vector.rule}`, async () => {
|
|
370
|
+
const { el: row, host } = parse<AdwComboRow>(
|
|
371
|
+
`<adw-combo-row title="Pick"></adw-combo-row>`,
|
|
372
|
+
'adw-combo-row',
|
|
373
|
+
);
|
|
374
|
+
const value = row.querySelector('.adw-row-value') as HTMLSpanElement;
|
|
375
|
+
const events = record(row, 'notify::selected');
|
|
376
|
+
|
|
377
|
+
for (const step of vector.steps) applyComboStep(row, step);
|
|
378
|
+
|
|
379
|
+
expect(row.selected).toBe(vector.selected);
|
|
380
|
+
expect(row.selectedValue).toBe(vector.value);
|
|
381
|
+
expect(value.textContent).toBe(vector.label);
|
|
382
|
+
// `notify::selected` is this row's DOM spelling of the `interactive`
|
|
383
|
+
// flag: a user pick emits, a programmatic set repaints silently. So the
|
|
384
|
+
// interactive frames of the state's stream ARE the events a listener sees.
|
|
385
|
+
expect(events.details).toStrictEqual(
|
|
386
|
+
vector.emitted
|
|
387
|
+
.filter((change) => change.interactive)
|
|
388
|
+
.map((change) => ({ selected: change.selected })),
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
events.stop();
|
|
392
|
+
host.remove();
|
|
393
|
+
});
|
|
394
|
+
}
|
|
123
395
|
});
|
|
124
396
|
|
|
125
397
|
await describe('adw-spin-row (SpinState)', async () => {
|
|
398
|
+
// THE SHARED TABLES, driven through the ELEMENT and its markup door — the same rows
|
|
399
|
+
// the core suite and the NativeScript suite assert, so a range authored in one
|
|
400
|
+
// dialect means the same thing in all three (ADR 0047).
|
|
401
|
+
for (const vector of ADJUSTMENT_AUTHORED_VECTORS) {
|
|
402
|
+
await it(`authored: ${vector.rule}`, () => {
|
|
403
|
+
const { el: row, host } = parse<AdwSpinRow>(
|
|
404
|
+
`<adw-spin-row title="Amount" adjustment='${JSON.stringify(vector.input)}'></adw-spin-row>`,
|
|
405
|
+
'adw-spin-row',
|
|
406
|
+
);
|
|
407
|
+
expect(row.adjustment).toStrictEqual({ ...vector.adjustment });
|
|
408
|
+
expect(row.value).toBe(vector.adjustment.value);
|
|
409
|
+
host.remove();
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// The state the parse rows are written over: the defaults with a value of 7, so a
|
|
414
|
+
// door that answered with a WHOLE adjustment would take the value back to 0 — which
|
|
415
|
+
// is what this table's `{"upper":20}` row exists to catch.
|
|
416
|
+
const SEEDED = { value: 7, lower: 0, upper: 100, stepIncrement: 1, pageIncrement: 1, pageSize: 0 };
|
|
417
|
+
|
|
418
|
+
for (const vector of ADJUSTMENT_PARSE_VECTORS) {
|
|
419
|
+
await it(`attribute: ${vector.rule}`, () => {
|
|
420
|
+
// Seeded through the ATTRIBUTE and not left at the defaults, so the write
|
|
421
|
+
// below has something to overwrite: a row that starts where it ends cannot
|
|
422
|
+
// tell a door that read the vector from one that was never called.
|
|
423
|
+
const { el: row, host } = parse<AdwSpinRow>(
|
|
424
|
+
`<adw-spin-row title="Amount" value="7" adjustment='{"upper":40}'></adw-spin-row>`,
|
|
425
|
+
'adw-spin-row',
|
|
426
|
+
);
|
|
427
|
+
expect(row.adjustment).toStrictEqual({ ...SEEDED, upper: 40 });
|
|
428
|
+
|
|
429
|
+
// `null` is the ABSENT attribute, and removing one is how an element meets
|
|
430
|
+
// it: `attributeChangedCallback` fires with a null value, which the door has
|
|
431
|
+
// to answer as "nothing authored" rather than as a whole adjustment. The
|
|
432
|
+
// element never sees that call if the test simply skips the write.
|
|
433
|
+
if (vector.raw === null) row.removeAttribute('adjustment');
|
|
434
|
+
else row.setAttribute('adjustment', vector.raw);
|
|
435
|
+
|
|
436
|
+
// The authored fields over what the row already held, and nothing else —
|
|
437
|
+
// `upper: 40` survives every row that does not name an upper.
|
|
438
|
+
expect(row.adjustment).toStrictEqual({ ...SEEDED, upper: 40, ...vector.input });
|
|
439
|
+
host.remove();
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
126
443
|
await it('steps at step precision and notifies only on a stepper press', async () => {
|
|
127
444
|
const { el: row, host } = parse<AdwSpinRow>(
|
|
128
|
-
|
|
445
|
+
`<adw-spin-row title="Amount" value="0" adjustment='{"lower":0,"upper":10,"stepIncrement":0.1}'></adw-spin-row>`,
|
|
129
446
|
'adw-spin-row',
|
|
130
447
|
);
|
|
131
448
|
const input = row.querySelector('input') as HTMLInputElement;
|
|
@@ -154,7 +471,7 @@ export const AdwRowStateTest = async () => {
|
|
|
154
471
|
|
|
155
472
|
await it('clamps to the lower bound and re-clamps when a bound moves', async () => {
|
|
156
473
|
const { el: row, host } = parse<AdwSpinRow>(
|
|
157
|
-
|
|
474
|
+
`<adw-spin-row title="Amount" value="5" adjustment='{"lower":2,"upper":8}'></adw-spin-row>`,
|
|
158
475
|
'adw-spin-row',
|
|
159
476
|
);
|
|
160
477
|
const input = row.querySelector('input') as HTMLInputElement;
|
|
@@ -162,9 +479,10 @@ export const AdwRowStateTest = async () => {
|
|
|
162
479
|
row.value = -4;
|
|
163
480
|
expect(row.value).toBe(2);
|
|
164
481
|
|
|
165
|
-
// Moving the upper bound below the value pulls the value into range
|
|
482
|
+
// Moving the upper bound below the value pulls the value into range — and the
|
|
483
|
+
// attribute names ONLY the bound, so the value it does not name is not reset.
|
|
166
484
|
row.value = 8;
|
|
167
|
-
row.setAttribute('
|
|
485
|
+
row.setAttribute('adjustment', '{"upper":6}');
|
|
168
486
|
expect(row.value).toBe(6);
|
|
169
487
|
expect(input.value).toBe('6');
|
|
170
488
|
|