@gjsify/adwaita-web 0.64.12-next.281 → 0.64.13-next.283
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/dist/adwaita-web.css +31 -0
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/elements/adw-combo-row.d.ts +34 -15
- package/lib/types/elements/adw-expander-row.d.ts +3 -0
- package/lib/types/elements/gtk-drop-down.d.ts +31 -5
- package/lib/types/elements/gtk-label.d.ts +64 -0
- package/lib/types/label-markup.d.ts +30 -0
- package/lib/types/namespace/gdk.d.ts +1 -1
- package/lib/types/pango-markup.d.ts +17 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +12 -12
- package/scss/_labels.scss +19 -0
- package/scss/_link.scss +73 -7
- package/src/adw-action-rows.spec.ts +19 -0
- package/src/adw-row-state.spec.ts +200 -63
- package/src/construct-vectors.spec.ts +4 -2
- package/src/elements/adw-combo-row.ts +93 -47
- package/src/elements/adw-expander-row.ts +46 -16
- package/src/elements/gtk-drop-down.ts +111 -35
- package/src/elements/gtk-label.ts +206 -16
- package/src/elements/gtk-page-setup-unix-dialog.ts +8 -6
- package/src/file-chooser-host.ts +2 -1
- package/src/gtk-drop-down.spec.ts +91 -75
- package/src/gtk-label.spec.ts +431 -11
- package/src/gtk-switch.spec.ts +8 -0
- package/src/gtk-value-doors.spec.ts +70 -32
- package/src/label-markup.ts +193 -0
- package/src/namespace/gdk.ts +1 -0
- package/src/pango-markup.ts +9 -3
- package/src/split-button.spec.ts +7 -4
- package/src/styles.generated.ts +1 -1
- package/src/value-lists.spec.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/adwaita-web",
|
|
3
|
-
"version": "0.64.
|
|
3
|
+
"version": "0.64.13-next.283",
|
|
4
4
|
"description": "Adwaita/Libadwaita web components for browser targets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -58,14 +58,14 @@
|
|
|
58
58
|
"@codemirror/language": "^6.12.4",
|
|
59
59
|
"@codemirror/state": "^6.7.0",
|
|
60
60
|
"@codemirror/view": "^6.43.4",
|
|
61
|
-
"@gjsify/adwaita-core": "0.64.
|
|
62
|
-
"@gjsify/adwaita-fonts": "0.64.
|
|
63
|
-
"@gjsify/adwaita-icons": "0.64.
|
|
64
|
-
"@gjsify/gtksource-core": "0.64.
|
|
61
|
+
"@gjsify/adwaita-core": "0.64.13-next.283",
|
|
62
|
+
"@gjsify/adwaita-fonts": "0.64.13-next.283",
|
|
63
|
+
"@gjsify/adwaita-icons": "0.64.13-next.283",
|
|
64
|
+
"@gjsify/gtksource-core": "0.64.13-next.283",
|
|
65
65
|
"@lezer/highlight": "^1.2.3"
|
|
66
66
|
},
|
|
67
67
|
"peerDependencies": {
|
|
68
|
-
"@gjsify/app-settings": "0.64.
|
|
68
|
+
"@gjsify/app-settings": "0.64.13-next.283"
|
|
69
69
|
},
|
|
70
70
|
"peerDependenciesMeta": {
|
|
71
71
|
"@gjsify/app-settings": {
|
|
@@ -73,11 +73,11 @@
|
|
|
73
73
|
}
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@gjsify/app-settings": "0.64.
|
|
77
|
-
"@gjsify/blueprint": "0.64.
|
|
78
|
-
"@gjsify/cli": "0.64.
|
|
79
|
-
"@gjsify/unit": "0.64.
|
|
80
|
-
"@gjsify/vite-plugin-blueprint": "0.64.
|
|
76
|
+
"@gjsify/app-settings": "0.64.13-next.283",
|
|
77
|
+
"@gjsify/blueprint": "0.64.13-next.283",
|
|
78
|
+
"@gjsify/cli": "0.64.13-next.283",
|
|
79
|
+
"@gjsify/unit": "0.64.13-next.283",
|
|
80
|
+
"@gjsify/vite-plugin-blueprint": "0.64.13-next.283",
|
|
81
81
|
"sass": "^1.101.0",
|
|
82
82
|
"typescript": "^6.0.3"
|
|
83
83
|
},
|
|
@@ -115,5 +115,5 @@
|
|
|
115
115
|
"url": "https://github.com/gjsify/gjsify/issues"
|
|
116
116
|
},
|
|
117
117
|
"homepage": "https://github.com/gjsify/gjsify/tree/main/packages/web/adwaita-web#readme",
|
|
118
|
-
"gitHead": "
|
|
118
|
+
"gitHead": "05fae97125a374e84bf57c2d2d086fb53523f205"
|
|
119
119
|
}
|
package/scss/_labels.scss
CHANGED
|
@@ -109,6 +109,25 @@ gtk-label {
|
|
|
109
109
|
max-width: 100%;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// `gtk-label.ts`'s run-render path, SINGLE-LINE: the markup's runs move into this span
|
|
113
|
+
// because this element is a flex container (the two spacers above), and a node directly
|
|
114
|
+
// under one is a FLEX ITEM, not text — `vertical-align` does not apply to a flex item,
|
|
115
|
+
// so `<sup>`/`<sub>` stop being raised, and `align-items` centres each run on its own
|
|
116
|
+
// instead of the line sharing one baseline. Plain text never needed a box: a bare text
|
|
117
|
+
// node is ONE anonymous item, where a run list is one item per run.
|
|
118
|
+
//
|
|
119
|
+
// NO `max-width` HERE, unlike `.adw-label-text`: this box only ever holds ONE line (the
|
|
120
|
+
// host's `white-space: pre`, and this path is the not-wrapping, not-ellipsizing case),
|
|
121
|
+
// so there is no shrink-to-fit collapse to measure around — a single-line label as wide
|
|
122
|
+
// as its text, overflowing a narrower container, is what GtkLabel does and what
|
|
123
|
+
// `gtk-label.spec.ts` already pins for the plain-text path. The box's own size is its
|
|
124
|
+
// text's: `flex-basis: auto` over `width: auto` is the item's max-content width, which
|
|
125
|
+
// leaves the two spacers the free space `xalign` splits.
|
|
126
|
+
.adw-label-runs {
|
|
127
|
+
// Blockified by spec as a flex item; spelled out as `.adw-label-text` spells it out.
|
|
128
|
+
display: block;
|
|
129
|
+
}
|
|
130
|
+
|
|
112
131
|
// `wrap-mode` — Pango's break-opportunity choice — is a real `word-break`/
|
|
113
132
|
// `overflow-wrap` mapping, not a declared gap: CSS's `break-all` is Pango's CHAR (break
|
|
114
133
|
// anywhere), and `overflow-wrap: anywhere` is Pango's WORD_CHAR (word boundaries, falling
|
package/scss/_link.scss
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
// _link.scss — `link` and `button.link`, libadwaita's link treatment.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// `button.link` (`_links.scss:1-2`)
|
|
5
|
-
// `<
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
3
|
+
// TWO NODES, NEVER A BARE `.link`. Upstream's rule names the `link` NODE and
|
|
4
|
+
// `button.link` (`_links.scss:1-2`). `button.link` is `<gtk-link-button>`'s inner
|
|
5
|
+
// `<button class="adw-button link">`, which the first selector below reads. The `link`
|
|
6
|
+
// node is the inner node of a `Gtk.Label`'s `<a>` markup, and `<gtk-label>` now DRAWS
|
|
7
|
+
// that — an `<a href>` per link in the markup (`label-markup.ts`) — so the second
|
|
8
|
+
// selector is upstream's `link` rule, ported onto the element that finally exists.
|
|
9
|
+
//
|
|
10
|
+
// The `.link` style CLASS on anything but those two is unstyled upstream: `AdwLinkRow`
|
|
11
|
+
// adds it to its row (adw-link-row.c:377), and no rule matches `row.link`, so its title
|
|
12
|
+
// stays in the row's own colour. A bare `.link` here painted the About dialog's link rows
|
|
13
|
+
// blue and underlined. It would now also hit `<gtk-label>` ITSELF, which gains the class
|
|
14
|
+
// whenever its markup has a link (gtklabel.c:3896) — the label is not a link, its anchors
|
|
15
|
+
// are. So both selectors name a node, and neither matches a class alone.
|
|
16
|
+
//
|
|
17
|
+
// THE LABEL'S ANCHORS ARE NOT STYLED THROUGH `:visited`, the CSS pseudo-class, even
|
|
18
|
+
// though they are real `<a href>` elements that would match it. A browser's `:visited` is
|
|
19
|
+
// the user's BROWSING HISTORY: a link followed in another tab, or last year, would come
|
|
20
|
+
// up visited in a label that has never been clicked, which `Gtk.Label` never does. Its
|
|
21
|
+
// `visited` is per-label state carried by URI across a re-parse
|
|
22
|
+
// (`labelLinksInheritVisited`), painted as `.visited` by `_paintVisited` — the same class
|
|
23
|
+
// `<gtk-link-button>` writes for the same reason one paragraph down.
|
|
10
24
|
//
|
|
11
25
|
// THE TWO COLOURS ARE THE PALETTE'S, not new tokens: `$link_color: var(--accent-color)`
|
|
12
26
|
// and `$link_visited_color: color-mix(in srgb, var(--accent-color) 80%, var(--view-fg-color))`
|
|
@@ -36,6 +50,7 @@
|
|
|
36
50
|
$link-color: var(--accent-color);
|
|
37
51
|
$link-visited-color: color-mix(in srgb, var(--accent-color) 80%, var(--view-fg-color));
|
|
38
52
|
$osd-link-color: color-mix(in srgb, var(--accent-bg-color) 50%, white);
|
|
53
|
+
$osd-link-visited-color: color-mix(in srgb, var(--accent-bg-color) 75%, white);
|
|
39
54
|
|
|
40
55
|
.adw-button.link {
|
|
41
56
|
color: $link-color;
|
|
@@ -82,4 +97,55 @@ $osd-link-color: color-mix(in srgb, var(--accent-bg-color) 50%, white);
|
|
|
82
97
|
&.checked {
|
|
83
98
|
background-color: transparent;
|
|
84
99
|
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Upstream's bare `link` node — a `Gtk.Label`'s `<a href>` markup, which `<gtk-label>`
|
|
103
|
+
// draws as a real anchor per link (`label-markup.ts`, `buildAnchor`).
|
|
104
|
+
//
|
|
105
|
+
// THE SELECTOR IS THE ATTRIBUTE, not the class. `data-gtk-link` is written by the renderer
|
|
106
|
+
// and by nothing else, so it names the node the way upstream's `link` names a GTK node;
|
|
107
|
+
// the `.link` class on the same anchor is GTK's own style class, which a consumer may also
|
|
108
|
+
// put on anything (and `<gtk-label>` ITSELF carries it whenever its markup has a link —
|
|
109
|
+
// see the header). Scoped under `gtk-label` so an `<a>` a consumer put in a label's slot
|
|
110
|
+
// for its own reasons is untouched.
|
|
111
|
+
gtk-label a[data-gtk-link] {
|
|
112
|
+
color: $link-color;
|
|
113
|
+
text-decoration: underline;
|
|
114
|
+
font-weight: inherit;
|
|
115
|
+
|
|
116
|
+
&:hover {
|
|
117
|
+
color: hsl(from $link-color h calc(s * 1.1) calc(l * 1.1));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
&:active {
|
|
121
|
+
color: $link-color;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// `.visited` and NOT `:visited` — the pseudo-class is the user's browsing history, where
|
|
125
|
+
// `Gtk.Label`'s `visited` is per-label state carried by URI (`_paintVisited`, and the
|
|
126
|
+
// paragraph in the header).
|
|
127
|
+
&.visited {
|
|
128
|
+
color: $link-visited-color;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.osd & {
|
|
132
|
+
color: $osd-link-color;
|
|
133
|
+
|
|
134
|
+
&.visited {
|
|
135
|
+
color: $osd-link-visited-color;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
&:active {
|
|
139
|
+
color: $osd-link-color;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// `link { @include focus-ring() }` (_links.scss:33-35) — the mixin is a 2px outline at
|
|
144
|
+
// offset -2px (`_drawing.scss`), spelled out as `_expander.scss` and `_checks.scss`
|
|
145
|
+
// spell it out. `:focus-visible` rather than `:focus`: GTK draws the ring on keyboard
|
|
146
|
+
// focus, and a click on a link must not leave one behind.
|
|
147
|
+
&:focus-visible {
|
|
148
|
+
outline: 2px solid var(--accent-color);
|
|
149
|
+
outline-offset: -2px;
|
|
150
|
+
}
|
|
85
151
|
}
|
|
@@ -19,6 +19,7 @@ import { describe, expect, it } from '@gjsify/unit';
|
|
|
19
19
|
|
|
20
20
|
import {
|
|
21
21
|
ACTION_ROW_ACTIVATION_VECTORS,
|
|
22
|
+
ACTION_ROW_SUBTITLE_VECTORS,
|
|
22
23
|
BUTTON_ROW_ACTIVATABLE_VECTORS,
|
|
23
24
|
BUTTON_ROW_ICON_VECTORS,
|
|
24
25
|
LABEL_VISIBILITY_VECTORS,
|
|
@@ -98,6 +99,24 @@ export const AdwActionRowsTest = async () => {
|
|
|
98
99
|
});
|
|
99
100
|
});
|
|
100
101
|
|
|
102
|
+
await describe('<adw-action-row> subtitle (libadwaita conformance vectors)', async () => {
|
|
103
|
+
// The element is attribute-driven and publishes no `notify::subtitle`, so only the
|
|
104
|
+
// value the vectors hold afterwards is observable; `null` is an absent attribute.
|
|
105
|
+
for (const vector of ACTION_ROW_SUBTITLE_VECTORS) {
|
|
106
|
+
await it(`${vector.name} — ${vector.rule}`, () => {
|
|
107
|
+
const { el: row, host } = mount<AdwActionRow>('adw-action-row');
|
|
108
|
+
for (const step of vector.steps) {
|
|
109
|
+
if (step.write === null) row.removeAttribute('subtitle');
|
|
110
|
+
else row.setAttribute('subtitle', step.write);
|
|
111
|
+
const label = row.querySelector('.adw-row-subtitle') as HTMLElement;
|
|
112
|
+
expect(label.textContent).toBe(step.subtitle);
|
|
113
|
+
expect(label.hidden).toBe(step.subtitle === '');
|
|
114
|
+
}
|
|
115
|
+
host.remove();
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
101
120
|
await describe('<adw-action-row> activatable-widget (libadwaita conformance vectors)', async () => {
|
|
102
121
|
for (const vector of ACTION_ROW_ACTIVATION_VECTORS) {
|
|
103
122
|
await it(`${vector.name} — ${vector.rule}`, async () => {
|
|
@@ -13,15 +13,21 @@ import {
|
|
|
13
13
|
ADJUSTMENT_AUTHORED_VECTORS,
|
|
14
14
|
ADJUSTMENT_PARSE_VECTORS,
|
|
15
15
|
COMBO_CHOOSER_VECTORS,
|
|
16
|
+
COMBO_ITEMS_CHANGED_VECTORS,
|
|
16
17
|
COMBO_SELECTION_VECTORS,
|
|
18
|
+
EXPANDER_ROW_CONSTRUCT_VECTORS,
|
|
19
|
+
EXPANDER_ROW_INITIAL,
|
|
20
|
+
EXPANDER_ROW_VECTORS,
|
|
17
21
|
LIST_ITEMS_CHANGED_VECTORS,
|
|
18
22
|
LIST_MODEL_OWNERSHIP_VECTORS,
|
|
19
23
|
LIST_NORMALIZE_VECTORS,
|
|
20
24
|
LIST_PARSE_VECTORS,
|
|
21
|
-
|
|
25
|
+
applyListMutation,
|
|
22
26
|
applyListReadback,
|
|
27
|
+
listModelForInput,
|
|
23
28
|
} from '@gjsify/adwaita-core/conformance';
|
|
24
29
|
import type { ComboSelectionStep } from '@gjsify/adwaita-core/conformance';
|
|
30
|
+
import { GjsifyOptionList } from '@gjsify/adwaita-core';
|
|
25
31
|
|
|
26
32
|
import type { AdwComboRow } from './elements/adw-combo-row.js';
|
|
27
33
|
import type { AdwExpanderRow } from './elements/adw-expander-row.js';
|
|
@@ -51,17 +57,24 @@ function record(el: HTMLElement, event: string): { details: unknown[]; stop: ()
|
|
|
51
57
|
*
|
|
52
58
|
* The row can drive the table at all only because the model and the select-by-value are
|
|
53
59
|
* now published; the conformance header names that as the condition for it inheriting
|
|
54
|
-
* these rows. It drives EVERY row
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
60
|
+
* these rows. It drives EVERY row with no carve-out — `an index past the end` used to be
|
|
61
|
+
* the one the two browser selectors answered differently and ADR 0122 measured it instead,
|
|
62
|
+
* so the permissive half this element carried is gone.
|
|
63
|
+
*
|
|
64
|
+
* Every `setModel` builds a FRESH list, which is what makes `setModelAgain` — assigning the
|
|
65
|
+
* identity the row already holds — a distinct step rather than a repeat of the one above it.
|
|
58
66
|
*/
|
|
59
67
|
function applyComboStep(row: AdwComboRow, step: ComboSelectionStep): void {
|
|
60
68
|
const select = row.querySelector('select') as HTMLSelectElement;
|
|
61
69
|
switch (step.op) {
|
|
62
70
|
case 'setModel':
|
|
63
|
-
row.model = step.model;
|
|
71
|
+
row.model = GjsifyOptionList.new([...step.model]);
|
|
72
|
+
return;
|
|
73
|
+
case 'setModelAgain': {
|
|
74
|
+
const held = row.model;
|
|
75
|
+
row.model = held;
|
|
64
76
|
return;
|
|
77
|
+
}
|
|
65
78
|
case 'setSelectedIndex':
|
|
66
79
|
row.selected = step.index;
|
|
67
80
|
return;
|
|
@@ -155,7 +168,7 @@ export const AdwRowStateTest = async () => {
|
|
|
155
168
|
|
|
156
169
|
// Through the PUBLISHED path a consumer has — the same `model` property
|
|
157
170
|
// `<gtk-drop-down>` publishes, not a reach into the composed state.
|
|
158
|
-
row.model = ['One', 'Two'];
|
|
171
|
+
row.model = GjsifyOptionList.new(['One', 'Two']);
|
|
159
172
|
expect(select.disabled).toBe(false);
|
|
160
173
|
expect(row.dataset.presentsChooser).toBe('true');
|
|
161
174
|
|
|
@@ -177,10 +190,10 @@ export const AdwRowStateTest = async () => {
|
|
|
177
190
|
const value = row.querySelector('.adw-row-value') as HTMLSpanElement;
|
|
178
191
|
expect(select.options.length).toBe(2);
|
|
179
192
|
|
|
180
|
-
// The PROPERTY, with the plain-string vocabulary
|
|
181
|
-
row.model = ['Cyan', 'Magenta', 'Yellow'];
|
|
193
|
+
// The PROPERTY, with the plain-string vocabulary `GjsifyOptionList` accepts.
|
|
194
|
+
row.model = GjsifyOptionList.new(['Cyan', 'Magenta', 'Yellow']);
|
|
182
195
|
expect([...select.options].map((o) => o.textContent).join('|')).toBe('Cyan|Magenta|Yellow');
|
|
183
|
-
expect(row.
|
|
196
|
+
expect(row.options.map((o) => o.label).join('|')).toBe('Cyan|Magenta|Yellow');
|
|
184
197
|
// The model was replaced under an index the new one still has, so autoselect
|
|
185
198
|
// keeps 0 and the inline label has to follow the NEW option at that index.
|
|
186
199
|
expect(value.textContent).toBe('Cyan');
|
|
@@ -205,12 +218,12 @@ export const AdwRowStateTest = async () => {
|
|
|
205
218
|
);
|
|
206
219
|
const select = row.querySelector('select') as HTMLSelectElement;
|
|
207
220
|
|
|
208
|
-
row.model = [
|
|
221
|
+
row.model = GjsifyOptionList.new([
|
|
209
222
|
{ value: 'r', label: 'Red' },
|
|
210
223
|
{ value: 'g', label: 'Green' },
|
|
211
|
-
];
|
|
224
|
+
]);
|
|
212
225
|
expect([...select.options].map((o) => o.textContent).join('|')).toBe('Red|Green');
|
|
213
|
-
expect(row.
|
|
226
|
+
expect(row.options.map((o) => o.value).join('|')).toBe('r|g');
|
|
214
227
|
|
|
215
228
|
// And selection by VALUE, the drop-down's other published setter.
|
|
216
229
|
row.selectedValue = 'g';
|
|
@@ -223,42 +236,50 @@ export const AdwRowStateTest = async () => {
|
|
|
223
236
|
// The row publishes the drop-down's property set apart from `enableSearch` and
|
|
224
237
|
// `active`, which are that element's popover chrome. `selectedItem` is NOT chrome —
|
|
225
238
|
// it is `Adw.ComboRow:selected-item` — so it is here, read-only as the GObject
|
|
226
|
-
// property is, and `null` rather than a blank
|
|
227
|
-
|
|
239
|
+
// property is, and `null` rather than a blank item when nothing is addressed.
|
|
240
|
+
//
|
|
241
|
+
// THE MODEL'S OWN ITEM, by identity (ADR 0122 § 4): `row.selectedItem ===
|
|
242
|
+
// model.get_item(row.selected)`, which is the comparison a `selected-item` consumer
|
|
243
|
+
// writes and the one a normalised descriptor would have failed.
|
|
244
|
+
await it('reads the selected item back by identity, and null when nothing is addressed', async () => {
|
|
228
245
|
const { el: row, host } = parse<AdwComboRow>(
|
|
229
246
|
`<adw-combo-row title="Pick"></adw-combo-row>`,
|
|
230
247
|
'adw-combo-row',
|
|
231
248
|
);
|
|
232
249
|
|
|
250
|
+
// No model at all — `null` and not an item of an empty list.
|
|
251
|
+
expect(row.model).toBeNull();
|
|
233
252
|
expect(row.selectedItem).toBeNull();
|
|
234
253
|
|
|
235
|
-
|
|
254
|
+
const model = GjsifyOptionList.new([
|
|
236
255
|
{ value: 'r', label: 'Red' },
|
|
237
256
|
{ value: 'g', label: 'Green' },
|
|
238
|
-
];
|
|
239
|
-
|
|
257
|
+
]);
|
|
258
|
+
row.model = model;
|
|
259
|
+
expect(row.model === model).toBe(true);
|
|
260
|
+
expect(row.selectedItem === model.get_item(0)).toBe(true);
|
|
240
261
|
row.selected = 1;
|
|
241
|
-
expect(row.selectedItem
|
|
262
|
+
expect(row.selectedItem === model.get_item(1)).toBe(true);
|
|
242
263
|
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
264
|
+
// An index past the end is IGNORED, as measured in libadwaita — the selection
|
|
265
|
+
// stays where it was, and nothing reads empty. This element used to ACCEPT the
|
|
266
|
+
// set and read back blank, the one row of the shared table the two browser
|
|
267
|
+
// selectors answered differently; the permissive half was ours, not GTK's.
|
|
247
268
|
row.selected = 9;
|
|
248
|
-
expect(row.selected).toBe(
|
|
249
|
-
expect(row.
|
|
250
|
-
expect(row.
|
|
251
|
-
expect((row.querySelector('.adw-row-value') as HTMLSpanElement).textContent).toBe('');
|
|
269
|
+
expect(row.selected).toBe(1);
|
|
270
|
+
expect(row.selectedValue).toBe('g');
|
|
271
|
+
expect((row.querySelector('.adw-row-value') as HTMLSpanElement).textContent).toBe('Green');
|
|
252
272
|
|
|
253
273
|
host.remove();
|
|
254
274
|
});
|
|
255
275
|
});
|
|
256
276
|
|
|
257
|
-
// The portable list model (ADR 0046), driven through this row's own `model`
|
|
277
|
+
// The portable list model (ADR 0046), driven through this row's own `model` doors — the
|
|
278
|
+
// property for a real model, the attribute for markup.
|
|
258
279
|
//
|
|
259
280
|
// The ITEMS-CHANGED rows are the ones that could not be asserted anywhere else: they
|
|
260
|
-
// compare `<option>` node IDENTITY across a
|
|
261
|
-
// between splicing the
|
|
281
|
+
// compare `<option>` node IDENTITY across a markup update, which is the difference
|
|
282
|
+
// between splicing the list and rebuilding it — a difference no assertion about the
|
|
262
283
|
// model, the labels or the selection can see. Before the splice landed, every row with
|
|
263
284
|
// a non-zero `survivors` scored 0.
|
|
264
285
|
await describe('adw-combo-row list model (portable list-model vectors)', async () => {
|
|
@@ -268,8 +289,10 @@ export const AdwRowStateTest = async () => {
|
|
|
268
289
|
`<adw-combo-row title="Pick"></adw-combo-row>`,
|
|
269
290
|
'adw-combo-row',
|
|
270
291
|
);
|
|
271
|
-
row
|
|
272
|
-
|
|
292
|
+
// `listModelForInput` is the table's own answer to which door a row reaches
|
|
293
|
+
// through now that `model` takes no array (ADR 0122 § 4).
|
|
294
|
+
row.model = listModelForInput(vector.input);
|
|
295
|
+
expect(row.options).toStrictEqual([...vector.model]);
|
|
273
296
|
const select = row.querySelector('select') as HTMLSelectElement;
|
|
274
297
|
expect([...select.options].map((option) => option.textContent)).toStrictEqual(
|
|
275
298
|
vector.model.map((item) => item.label),
|
|
@@ -287,7 +310,7 @@ export const AdwRowStateTest = async () => {
|
|
|
287
310
|
// Set AFTER connect, so the attribute reaches the callback rather than the
|
|
288
311
|
// seeding path: a total parser has to be total on the live door too.
|
|
289
312
|
row.setAttribute('model', vector.attribute);
|
|
290
|
-
expect(row.
|
|
313
|
+
expect(row.options).toStrictEqual([...vector.model]);
|
|
291
314
|
host.remove();
|
|
292
315
|
});
|
|
293
316
|
}
|
|
@@ -299,10 +322,16 @@ export const AdwRowStateTest = async () => {
|
|
|
299
322
|
'adw-combo-row',
|
|
300
323
|
);
|
|
301
324
|
const select = row.querySelector('select') as HTMLSelectElement;
|
|
302
|
-
|
|
325
|
+
// Driven through the MARKUP door, which is the one that still computes a
|
|
326
|
+
// splice: the attribute reconciles the element's own authored list (ADR 0122
|
|
327
|
+
// amendment A4), keeping its identity, so the survivors these rows count are
|
|
328
|
+
// the `<option>` nodes a minimal splice left alone. A `model` PROPERTY write
|
|
329
|
+
// cannot answer them any more — a different list is a replacement, and a
|
|
330
|
+
// replacement carries nothing over by definition.
|
|
331
|
+
row.setAttribute('model', JSON.stringify(vector.previous));
|
|
303
332
|
const before = [...select.options];
|
|
304
333
|
|
|
305
|
-
row.model
|
|
334
|
+
row.setAttribute('model', JSON.stringify(vector.next));
|
|
306
335
|
|
|
307
336
|
const after = [...select.options];
|
|
308
337
|
expect(after.map((option) => option.textContent)).toStrictEqual(vector.next.map((item) => item.label));
|
|
@@ -311,10 +340,10 @@ export const AdwRowStateTest = async () => {
|
|
|
311
340
|
});
|
|
312
341
|
}
|
|
313
342
|
|
|
314
|
-
// WHO OWNS the
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
// `<option>` list shows
|
|
343
|
+
// WHO OWNS the options on either side of the model, and what the model's own API
|
|
344
|
+
// moves. The DOM is what makes these rows worth driving at the element: on the
|
|
345
|
+
// caller-side rows the descriptors still read correctly from the model itself, and
|
|
346
|
+
// only the `<option>` list shows whether a splice was emitted for them.
|
|
318
347
|
for (const vector of LIST_MODEL_OWNERSHIP_VECTORS) {
|
|
319
348
|
await it(`ownership — ${vector.rule}`, async () => {
|
|
320
349
|
const { el: row, host } = parse<AdwComboRow>(
|
|
@@ -323,39 +352,23 @@ export const AdwRowStateTest = async () => {
|
|
|
323
352
|
);
|
|
324
353
|
const select = row.querySelector('select') as HTMLSelectElement;
|
|
325
354
|
const input = vector.initial.map((item) => ({ ...item }));
|
|
326
|
-
|
|
355
|
+
const list = GjsifyOptionList.new(input);
|
|
356
|
+
row.model = list;
|
|
327
357
|
const before = [...select.options];
|
|
328
358
|
|
|
329
|
-
|
|
330
|
-
|
|
359
|
+
if (vector.source === 'model') applyListMutation(list, vector.op);
|
|
360
|
+
else applyListReadback(vector.source === 'input' ? input : row.options, vector.op);
|
|
331
361
|
|
|
332
|
-
expect(row.
|
|
362
|
+
expect(row.options).toStrictEqual([...vector.model]);
|
|
363
|
+
// None of these ops is a replacement, so the element must still be pointing
|
|
364
|
+
// at the list it was handed.
|
|
365
|
+
expect(row.model).toBe(list);
|
|
333
366
|
const after = [...select.options];
|
|
334
367
|
expect(after.map((option) => option.textContent)).toStrictEqual(vector.model.map((item) => item.label));
|
|
335
368
|
expect(before.filter((option) => after.includes(option)).length).toBe(vector.survivors);
|
|
336
369
|
host.remove();
|
|
337
370
|
});
|
|
338
371
|
}
|
|
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
372
|
});
|
|
360
373
|
|
|
361
374
|
// The SAME table `<gtk-drop-down>` and the NativeScript renderer are held to
|
|
@@ -394,6 +407,53 @@ export const AdwRowStateTest = async () => {
|
|
|
394
407
|
}
|
|
395
408
|
});
|
|
396
409
|
|
|
410
|
+
// The model MOVING under a live row — the axis that only exists since `model` holds a
|
|
411
|
+
// real `Gio.ListModel` (ADR 0122 § 6). Nothing re-assigns `model` here: the list is
|
|
412
|
+
// spliced through its own API and the row has to follow, which is what separates this
|
|
413
|
+
// from every replacement row above it.
|
|
414
|
+
//
|
|
415
|
+
// What a DOM surface can show is the OUTCOME — `selected`, `selectedValue`, the inline
|
|
416
|
+
// label and the rebuilt `<option>` list — plus the silence: a model moving underneath is
|
|
417
|
+
// not a user pick, so `notify::selected` must not fire. The vectors' `notified` column is
|
|
418
|
+
// the GObject half this element has no spelling for and the core suite asserts alone.
|
|
419
|
+
await describe('adw-combo-row items-changed on the held model (GtkSingleSelection)', async () => {
|
|
420
|
+
for (const vector of COMBO_ITEMS_CHANGED_VECTORS) {
|
|
421
|
+
await it(`${vector.name} — ${vector.rule}`, async () => {
|
|
422
|
+
const { el: row, host } = parse<AdwComboRow>(
|
|
423
|
+
`<adw-combo-row title="Pick"></adw-combo-row>`,
|
|
424
|
+
'adw-combo-row',
|
|
425
|
+
);
|
|
426
|
+
const select = row.querySelector('select') as HTMLSelectElement;
|
|
427
|
+
const value = row.querySelector('.adw-row-value') as HTMLSpanElement;
|
|
428
|
+
|
|
429
|
+
const list = GjsifyOptionList.new([...vector.initial]);
|
|
430
|
+
row.model = list;
|
|
431
|
+
if (vector.selected >= 0) row.selected = vector.selected;
|
|
432
|
+
expect(row.selected, 'the row set up the selection it says it did').toBe(vector.selected);
|
|
433
|
+
|
|
434
|
+
const events = record(row, 'notify::selected');
|
|
435
|
+
list.splice(vector.splice.position, vector.splice.removed, [...vector.splice.added]);
|
|
436
|
+
|
|
437
|
+
const expected = [...vector.initial];
|
|
438
|
+
expected.splice(vector.splice.position, vector.splice.removed, ...vector.splice.added);
|
|
439
|
+
|
|
440
|
+
expect(row.selected).toBe(vector.result);
|
|
441
|
+
expect(row.selectedValue).toBe(vector.value);
|
|
442
|
+
expect(value.textContent).toBe(expected[vector.result]?.label ?? '');
|
|
443
|
+
// The drawn list follows the MODEL: the row was never re-assigned, so a
|
|
444
|
+
// `<select>` still showing the old options is exactly the bug the
|
|
445
|
+
// `items-changed` channel exists to prevent.
|
|
446
|
+
expect([...select.options].map((option) => option.textContent)).toStrictEqual(
|
|
447
|
+
expected.map((option) => option.label),
|
|
448
|
+
);
|
|
449
|
+
expect(events.details).toStrictEqual([]);
|
|
450
|
+
|
|
451
|
+
events.stop();
|
|
452
|
+
host.remove();
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
|
|
397
457
|
await describe('adw-spin-row (SpinState)', async () => {
|
|
398
458
|
// THE SHARED TABLES, driven through the ELEMENT and its markup door — the same rows
|
|
399
459
|
// the core suite and the NativeScript suite assert, so a range authored in one
|
|
@@ -722,6 +782,83 @@ export const AdwRowStateTest = async () => {
|
|
|
722
782
|
host.remove();
|
|
723
783
|
});
|
|
724
784
|
|
|
785
|
+
for (const vector of EXPANDER_ROW_VECTORS) {
|
|
786
|
+
await it(`holds ${vector.name} (shared conformance vectors)`, async () => {
|
|
787
|
+
const { el: row, host } = parse<AdwExpanderRow>(
|
|
788
|
+
'<adw-expander-row title="More"><adw-action-row title="Nested"></adw-action-row></adw-expander-row>',
|
|
789
|
+
'adw-expander-row',
|
|
790
|
+
);
|
|
791
|
+
expect(row.expanded).toBe(EXPANDER_ROW_INITIAL.expanded);
|
|
792
|
+
expect(row.enableExpansion).toBe(EXPANDER_ROW_INITIAL.enableExpansion);
|
|
793
|
+
for (const step of vector.steps) {
|
|
794
|
+
if (step.op === 'expanded') row.expanded = step.value;
|
|
795
|
+
else row.enableExpansion = step.value;
|
|
796
|
+
// A DOM element publishes only the user's notifications, so `notified` is
|
|
797
|
+
// not observable here — the held values and what they reflect to are.
|
|
798
|
+
expect(row.expanded).toBe(step.expanded);
|
|
799
|
+
expect(row.enableExpansion).toBe(step.enableExpansion);
|
|
800
|
+
expect(row.hasAttribute('expanded')).toBe(step.expanded);
|
|
801
|
+
expect(row.classList.contains('expanded')).toBe(step.expanded);
|
|
802
|
+
}
|
|
803
|
+
host.remove();
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
for (const vector of EXPANDER_ROW_CONSTRUCT_VECTORS) {
|
|
808
|
+
await it(`parses expanded=${vector.expanded}, enable-expansion=${vector.enableExpansion} — ${vector.rule}`, async () => {
|
|
809
|
+
const enable = `enable-expansion="${vector.enableExpansion}"`;
|
|
810
|
+
const expanded = vector.expanded ? 'expanded' : '';
|
|
811
|
+
// Both attribute orders: the parser delivers them in source order.
|
|
812
|
+
for (const attributes of [`${enable} ${expanded}`, `${expanded} ${enable}`]) {
|
|
813
|
+
const { el: row, host } = parse<AdwExpanderRow>(
|
|
814
|
+
`<adw-expander-row title="x" ${attributes}></adw-expander-row>`,
|
|
815
|
+
'adw-expander-row',
|
|
816
|
+
);
|
|
817
|
+
expect(row.enableExpansion).toBe(vector.enableExpansion);
|
|
818
|
+
expect(row.expanded).toBe(vector.resultExpanded);
|
|
819
|
+
expect(row.hasAttribute('expanded')).toBe(vector.resultExpanded);
|
|
820
|
+
host.remove();
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
await it('does not open on a header click while expansion is disabled', async () => {
|
|
826
|
+
const { el: row, host } = parse<AdwExpanderRow>(
|
|
827
|
+
'<adw-expander-row title="More" enable-expansion="false"></adw-expander-row>',
|
|
828
|
+
'adw-expander-row',
|
|
829
|
+
);
|
|
830
|
+
const header = row.querySelector('.adw-expander-row-header') as HTMLDivElement;
|
|
831
|
+
const events = record(row, 'notify::expanded');
|
|
832
|
+
header.click();
|
|
833
|
+
expect(row.expanded).toBe(false);
|
|
834
|
+
expect(events.details.length).toBe(0);
|
|
835
|
+
events.stop();
|
|
836
|
+
host.remove();
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
await it('the enable switch publishes expanded BEFORE enable-expansion, as libadwaita emits them', async () => {
|
|
840
|
+
const { el: row, host } = parse<AdwExpanderRow>(
|
|
841
|
+
'<adw-expander-row title="More" show-enable-switch enable-expansion="false"></adw-expander-row>',
|
|
842
|
+
'adw-expander-row',
|
|
843
|
+
);
|
|
844
|
+
const seen: string[] = [];
|
|
845
|
+
row.addEventListener('notify::expanded', () => seen.push('expanded'));
|
|
846
|
+
row.addEventListener('notify::enable-expansion', () => seen.push('enable-expansion'));
|
|
847
|
+
const toggle = row.querySelector('.adw-expander-row-enable-switch') as HTMLElement & { active: boolean };
|
|
848
|
+
|
|
849
|
+
toggle.active = true;
|
|
850
|
+
expect(seen.join()).toBe('expanded,enable-expansion');
|
|
851
|
+
expect(row.expanded).toBe(true);
|
|
852
|
+
expect(row.hasAttribute('expanded')).toBe(true);
|
|
853
|
+
|
|
854
|
+
seen.length = 0;
|
|
855
|
+
toggle.active = false;
|
|
856
|
+
expect(seen.join()).toBe('expanded,enable-expansion');
|
|
857
|
+
expect(row.expanded).toBe(false);
|
|
858
|
+
expect(row.hasAttribute('expanded')).toBe(false);
|
|
859
|
+
host.remove();
|
|
860
|
+
});
|
|
861
|
+
|
|
725
862
|
await it('adopts a declarative expanded attribute', async () => {
|
|
726
863
|
const { el: row, host } = parse<AdwExpanderRow>(
|
|
727
864
|
'<adw-expander-row title="Open" expanded></adw-expander-row>',
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type ConstructVector,
|
|
10
10
|
type SharedTreeNode,
|
|
11
11
|
} from '@gjsify/adwaita-core/conformance';
|
|
12
|
+
import { GjsifyOptionList } from '@gjsify/adwaita-core';
|
|
12
13
|
import { describe, expect, it } from '@gjsify/unit';
|
|
13
14
|
|
|
14
15
|
import type { AdwAlertDialog } from './elements/adw-alert-dialog.js';
|
|
@@ -280,7 +281,7 @@ export const AdwConstructVectorsTest = async () => {
|
|
|
280
281
|
});
|
|
281
282
|
await it('dispatches DropDown `notify::selected-item` after `notify::selected`, on a real change only', () => {
|
|
282
283
|
const dropDown = document.createElement('gtk-drop-down') as HTMLElement & {
|
|
283
|
-
model:
|
|
284
|
+
model: unknown;
|
|
284
285
|
selected: number;
|
|
285
286
|
};
|
|
286
287
|
const seen: string[] = [];
|
|
@@ -288,7 +289,8 @@ export const AdwConstructVectorsTest = async () => {
|
|
|
288
289
|
dropDown.addEventListener(name, () => seen.push(name));
|
|
289
290
|
document.body.append(dropDown);
|
|
290
291
|
try {
|
|
291
|
-
|
|
292
|
+
// `model` takes a real list model, never an array (ADR 0122 § 4).
|
|
293
|
+
dropDown.model = GjsifyOptionList.new(['a', 'b']);
|
|
292
294
|
seen.length = 0;
|
|
293
295
|
dropDown.selected = 1;
|
|
294
296
|
dropDown.selected = 1;
|