@gjsify/adwaita-web 0.64.12 → 0.64.13
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-bin.d.ts +13 -0
- package/lib/types/elements/adw-expander-row.d.ts +3 -0
- package/lib/types/elements/gtk-label.d.ts +64 -0
- package/lib/types/label-markup.d.ts +30 -0
- package/lib/types/pango-markup.d.ts +17 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/lib/types/widget-element.d.ts +8 -2
- package/package.json +11 -11
- package/scss/_labels.scss +19 -0
- package/scss/_link.scss +73 -7
- package/src/adw-action-rows.spec.ts +19 -0
- package/src/adw-bin.spec.ts +20 -0
- package/src/adw-row-state.spec.ts +80 -0
- package/src/elements/adw-bin.ts +20 -1
- package/src/elements/adw-expander-row.ts +46 -16
- package/src/elements/gtk-label.ts +206 -16
- package/src/gtk-label.spec.ts +431 -11
- package/src/gtk-switch.spec.ts +8 -0
- package/src/label-markup.ts +193 -0
- package/src/pango-markup.spec.ts +11 -0
- package/src/pango-markup.ts +14 -5
- package/src/split-button.spec.ts +7 -4
- package/src/styles.generated.ts +1 -1
- package/src/widget-element.ts +28 -7
- package/src/widget-members.spec.ts +42 -3
package/src/gtk-label.spec.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// DOM-level tests for <gtk-label>.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// Four things matter more than the attribute round-trips. A label WITHOUT `use-markup`
|
|
4
|
+
// reaches the DOM as one text node, and one WITH it is drawn as the styled runs core
|
|
5
|
+
// derived — built node by node, so no string the label carries is ever parsed as markup
|
|
6
|
+
// (the `<img>` row: markup pango rejects stays text, it does not become an element).
|
|
7
|
+
// `<a href>` is a real anchor whose click is cancelled in favour of `activate-link`.
|
|
8
|
+
// `xalign` is read off where the text actually SITS in the label's box, not off a style the
|
|
9
|
+
// element wrote. And the typography classes from `_labels.scss` apply to the element,
|
|
10
|
+
// including inside another widget whose isolation floor would otherwise repaint it
|
|
11
|
+
// (`_reset.scss`).
|
|
9
12
|
|
|
10
13
|
import { describe, expect, it } from '@gjsify/unit';
|
|
11
14
|
|
|
@@ -16,16 +19,21 @@ import {
|
|
|
16
19
|
LABEL_ELLIPSIZE_OVERFLOW_VECTORS,
|
|
17
20
|
LABEL_ELLIPSIZE_VECTORS,
|
|
18
21
|
LABEL_JUSTIFY_VECTORS,
|
|
22
|
+
LABEL_MARKUP_LINK_VECTORS,
|
|
23
|
+
LABEL_MARKUP_RUN_VECTORS,
|
|
19
24
|
LABEL_WIDTH_CHARS_EXTENT_VECTORS,
|
|
20
25
|
LABEL_WRAP_MODE_VECTORS,
|
|
21
26
|
LABEL_XALIGN_VECTORS,
|
|
22
27
|
LABEL_YALIGN_ALIGN_ITEMS_VECTORS,
|
|
23
28
|
LABEL_YALIGN_VECTORS,
|
|
29
|
+
type LabelMarkupLinkExpectation,
|
|
30
|
+
type LabelMarkupRunExpectation,
|
|
24
31
|
} from '@gjsify/adwaita-core/conformance';
|
|
25
32
|
|
|
26
33
|
import type { GtkLabel } from './elements/gtk-label.js';
|
|
34
|
+
import { labelMarkupAnchorIndex, labelMarkupAnchors } from './label-markup.js';
|
|
27
35
|
|
|
28
|
-
function mount(label: string, parent?: HTMLElement): { el: GtkLabel; host: HTMLElement } {
|
|
36
|
+
function mount(label: string, parent?: HTMLElement): { el: GtkLabel; host: HTMLElement; opened: string[] } {
|
|
29
37
|
const host = document.createElement('div');
|
|
30
38
|
host.style.width = '400px';
|
|
31
39
|
document.body.appendChild(host);
|
|
@@ -33,7 +41,136 @@ function mount(label: string, parent?: HTMLElement): { el: GtkLabel; host: HTMLE
|
|
|
33
41
|
el.label = label;
|
|
34
42
|
(parent ?? host).appendChild(el);
|
|
35
43
|
if (parent) host.appendChild(parent);
|
|
36
|
-
|
|
44
|
+
// `window.open` is the launcher call; a spec must not open a tab to prove a click.
|
|
45
|
+
const opened: string[] = [];
|
|
46
|
+
(el as unknown as { launch: (uri: string) => void }).launch = (uri: string) => opened.push(uri);
|
|
47
|
+
return { el, host, opened };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One click a user could make, cancelable so the spec can read back whether it was. */
|
|
51
|
+
function click(target: Element): MouseEvent {
|
|
52
|
+
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
|
|
53
|
+
target.dispatchEvent(event);
|
|
54
|
+
return event;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Collect the `detail` of every `event` dispatched on (or bubbling to) `el`. */
|
|
58
|
+
function record(el: HTMLElement, event: string): unknown[] {
|
|
59
|
+
const details: unknown[] = [];
|
|
60
|
+
el.addEventListener(event, (e) => details.push((e as CustomEvent).detail));
|
|
61
|
+
return details;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Every text node under `root`, in reading order — ONE PER DRAWN RUN, because a run is
|
|
66
|
+
* built as a chain of elements around a single text node (`label-markup.ts`, `buildRun`).
|
|
67
|
+
* So this list and the vector's `runs` line up position for position, and a renderer that
|
|
68
|
+
* failed to merge two adjacent equal-styled runs produces one node too many.
|
|
69
|
+
*/
|
|
70
|
+
function textNodes(root: Node): Text[] {
|
|
71
|
+
const found: Text[] = [];
|
|
72
|
+
for (const child of root.childNodes) {
|
|
73
|
+
if (child.nodeType === Node.TEXT_NODE) found.push(child as Text);
|
|
74
|
+
else found.push(...textNodes(child));
|
|
75
|
+
}
|
|
76
|
+
return found;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A run as the DOM holds it, in the shape {@link LabelMarkupRunExpectation} states it. */
|
|
80
|
+
interface DrawnRun {
|
|
81
|
+
text: string;
|
|
82
|
+
bold: boolean;
|
|
83
|
+
italic: boolean;
|
|
84
|
+
underline: boolean;
|
|
85
|
+
strikethrough: boolean;
|
|
86
|
+
monospace: boolean;
|
|
87
|
+
sizeSteps: number;
|
|
88
|
+
script: 'sub' | 'sup' | null;
|
|
89
|
+
link: number | null;
|
|
90
|
+
/** How many `<span>` attribute sets the run sits in — the COUNT, see below. */
|
|
91
|
+
spans: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `node`'s run, read back off its ancestors up to `root`.
|
|
96
|
+
*
|
|
97
|
+
* THE `<span>`S ARE COUNTED, NOT COMPARED. `tt`, `big` and `small` are each drawn as a
|
|
98
|
+
* `<span>` carrying the one CSS property `TAG_STYLES` gives them, so those three are
|
|
99
|
+
* recognised by that property and everything else that is a `<span>` came from a pango
|
|
100
|
+
* `<span>` element. Its ATTRIBUTES are not asserted here: `applySpanAttributes` drops
|
|
101
|
+
* every attribute outside its whitelist (`foreground` among them), so the mapping is
|
|
102
|
+
* `pango-markup.spec.ts`'s subject and what matters at this level is that each `<span>` in
|
|
103
|
+
* the run's stack became one element in the chain, in order.
|
|
104
|
+
*/
|
|
105
|
+
function drawnRun(node: Text, root: Element): DrawnRun {
|
|
106
|
+
const run: DrawnRun = {
|
|
107
|
+
text: node.data,
|
|
108
|
+
bold: false,
|
|
109
|
+
italic: false,
|
|
110
|
+
underline: false,
|
|
111
|
+
strikethrough: false,
|
|
112
|
+
monospace: false,
|
|
113
|
+
sizeSteps: 0,
|
|
114
|
+
script: null,
|
|
115
|
+
link: null,
|
|
116
|
+
spans: 0,
|
|
117
|
+
};
|
|
118
|
+
for (let el = node.parentElement; el !== null && el !== root; el = el.parentElement) {
|
|
119
|
+
const tag = el.tagName.toLowerCase();
|
|
120
|
+
if (tag === 'b') run.bold = true;
|
|
121
|
+
else if (tag === 'i') run.italic = true;
|
|
122
|
+
else if (tag === 'u') run.underline = true;
|
|
123
|
+
else if (tag === 's') run.strikethrough = true;
|
|
124
|
+
else if (tag === 'sub' || tag === 'sup') run.script = tag;
|
|
125
|
+
else if (tag === 'a') run.link = labelMarkupAnchorIndex(el);
|
|
126
|
+
// THE TWO BOXES ARE THE ELEMENT'S OWN, not a run's: `.adw-label-text` is the
|
|
127
|
+
// wrap/ellipsize box (`_renderSpan`), `.adw-label-runs` the single-line one the
|
|
128
|
+
// runs need because the label is a flex container (`_runSpan`). Neither carries
|
|
129
|
+
// style a run asked for, so neither counts as a `<span>` from the markup.
|
|
130
|
+
else if (el.classList.contains('adw-label-text') || el.classList.contains('adw-label-runs')) continue;
|
|
131
|
+
else if (el.style.fontFamily === 'monospace') run.monospace = true;
|
|
132
|
+
else if (el.style.fontSize === 'larger') run.sizeSteps += 1;
|
|
133
|
+
else if (el.style.fontSize === 'smaller') run.sizeSteps -= 1;
|
|
134
|
+
else run.spans += 1;
|
|
135
|
+
}
|
|
136
|
+
return run;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The same shape from the vector, where every omitted field means "not set". */
|
|
140
|
+
function expectedRun(run: LabelMarkupRunExpectation): DrawnRun {
|
|
141
|
+
return {
|
|
142
|
+
text: run.text,
|
|
143
|
+
bold: run.bold ?? false,
|
|
144
|
+
italic: run.italic ?? false,
|
|
145
|
+
underline: run.underline ?? false,
|
|
146
|
+
strikethrough: run.strikethrough ?? false,
|
|
147
|
+
monospace: run.monospace ?? false,
|
|
148
|
+
sizeSteps: run.sizeSteps ?? 0,
|
|
149
|
+
script: run.script ?? null,
|
|
150
|
+
link: run.link ?? null,
|
|
151
|
+
spans: run.spans?.length ?? 0,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The text the DOM can give the anchor for `links[index]`: the link's range, minus the
|
|
157
|
+
* range of any link nested INSIDE it.
|
|
158
|
+
*
|
|
159
|
+
* THE ONE PLACE THE ANCHORS ARE NOT THE RANGES. GTK's ranges may overlap —
|
|
160
|
+
* `<a href="outer">out <a href="inner">in</a></a>` is two links and the outer's range
|
|
161
|
+
* covers the inner's text — and one DOM element cannot hold text another also holds. The
|
|
162
|
+
* inner anchor owns that text, as the inner link owns it in GTK too (`finish_text`
|
|
163
|
+
* attributes to the open element), and the outer's node keeps the rest of its range.
|
|
164
|
+
*/
|
|
165
|
+
function anchorText(text: string, links: ReadonlyArray<LabelMarkupLinkExpectation>, index: number): string {
|
|
166
|
+
const link = links[index]!;
|
|
167
|
+
const inner = links.filter(
|
|
168
|
+
(other, at) => at !== index && other.end > other.start && other.start >= link.start && other.end <= link.end,
|
|
169
|
+
);
|
|
170
|
+
let kept = '';
|
|
171
|
+
for (let at = link.start; at < link.end; at += 1)
|
|
172
|
+
if (!inner.some((nested) => nested.start <= at && at < nested.end)) kept += text[at];
|
|
173
|
+
return kept;
|
|
37
174
|
}
|
|
38
175
|
|
|
39
176
|
/** Where the rendered TEXT sits, measured on the text node rather than the element. */
|
|
@@ -43,6 +180,13 @@ function textRect(el: HTMLElement): DOMRect {
|
|
|
43
180
|
return range.getBoundingClientRect();
|
|
44
181
|
}
|
|
45
182
|
|
|
183
|
+
/** Where one drawn run's GLYPHS sit — the text node's own box, not its element's. */
|
|
184
|
+
function runRect(node: Text): DOMRect {
|
|
185
|
+
const range = document.createRange();
|
|
186
|
+
range.selectNodeContents(node);
|
|
187
|
+
return range.getBoundingClientRect();
|
|
188
|
+
}
|
|
189
|
+
|
|
46
190
|
export const GtkLabelTest = async () => {
|
|
47
191
|
await describe('<gtk-label> against LABEL_DISPLAY_TEXT_VECTORS', async () => {
|
|
48
192
|
for (const vector of LABEL_DISPLAY_TEXT_VECTORS) {
|
|
@@ -50,16 +194,292 @@ export const GtkLabelTest = async () => {
|
|
|
50
194
|
const { el, host } = mount(vector.label);
|
|
51
195
|
el.useMarkup = vector.useMarkup;
|
|
52
196
|
el.useUnderline = vector.useUnderline;
|
|
53
|
-
// What reached the SCREEN
|
|
54
|
-
//
|
|
197
|
+
// What reached the SCREEN. `textContent` is the display text whichever
|
|
198
|
+
// path drew it — a plain text node, or the run elements concatenated —
|
|
199
|
+
// so this is the assertion that says the two agree.
|
|
200
|
+
expect(el.textContent).toBe(vector.text);
|
|
201
|
+
expect(el.getText()).toBe(vector.text);
|
|
202
|
+
// NO ELEMENT WAS PARSED FROM THE STRING, which is the XSS answer and a
|
|
203
|
+
// different claim from "no elements at all": a row whose markup PARSES is
|
|
204
|
+
// now drawn as runs (`<b>` is a `<b>`), so the check is that nothing the
|
|
205
|
+
// markup named as a foreign tag became a node. The `<img>` row is the one
|
|
206
|
+
// that matters — it does not parse, so it stays text.
|
|
207
|
+
expect(el.querySelectorAll('img, script, iframe').length).toBe(0);
|
|
208
|
+
host.remove();
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await describe('<gtk-label> against LABEL_MARKUP_RUN_VECTORS — the runs as DRAWN nodes', async () => {
|
|
214
|
+
for (const vector of LABEL_MARKUP_RUN_VECTORS) {
|
|
215
|
+
await it(vector.rule, () => {
|
|
216
|
+
const { el, host } = mount(vector.markup);
|
|
217
|
+
el.useMarkup = true;
|
|
218
|
+
|
|
219
|
+
// The row with NO runs is markup pango rejects, and GTK then shows the
|
|
220
|
+
// raw string: one text node, the markup included, no element built.
|
|
221
|
+
if (vector.runs.length === 0) {
|
|
222
|
+
expect(el.textContent).toBe(vector.text);
|
|
223
|
+
expect(el.childElementCount).toBe(0);
|
|
224
|
+
host.remove();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const drawn = textNodes(el).map((node) => drawnRun(node, el));
|
|
229
|
+
expect(drawn).toStrictEqual(vector.runs.map(expectedRun));
|
|
230
|
+
// The runs' text concatenated IS the display text — `gtk_label_get_text`
|
|
231
|
+
// and what the user sees cannot be two different strings.
|
|
55
232
|
expect(el.textContent).toBe(vector.text);
|
|
56
233
|
expect(el.getText()).toBe(vector.text);
|
|
57
|
-
expect(el.childElementCount).toBe(0);
|
|
58
234
|
host.remove();
|
|
59
235
|
});
|
|
60
236
|
}
|
|
61
237
|
});
|
|
62
238
|
|
|
239
|
+
await describe('<gtk-label> draws its runs as TEXT, not as flex items', async () => {
|
|
240
|
+
await it('puts the runs in one box of their own, and takes it away again', () => {
|
|
241
|
+
const { el, host } = mount('<b>Bold</b> and <i>italic</i>');
|
|
242
|
+
el.useMarkup = true;
|
|
243
|
+
// ONE element child, holding every run. The label itself is a flex container
|
|
244
|
+
// (the `xalign` spacers, `_labels.scss`), so a run list directly under it is
|
|
245
|
+
// one FLEX ITEM PER RUN — three boxes shrinking independently, not a line of
|
|
246
|
+
// text. The box is the item; the runs inside it are inline.
|
|
247
|
+
expect(el.childElementCount).toBe(1);
|
|
248
|
+
expect(el.firstElementChild?.classList.contains('adw-label-runs')).toBe(true);
|
|
249
|
+
expect(textNodes(el).length).toBe(3);
|
|
250
|
+
|
|
251
|
+
// And it is GONE when there is no markup to draw: a plain label is back to the
|
|
252
|
+
// single text node the XSS note pins, no leftover box.
|
|
253
|
+
el.useMarkup = false;
|
|
254
|
+
expect(el.childElementCount).toBe(0);
|
|
255
|
+
expect(el.textContent).toBe('<b>Bold</b> and <i>italic</i>');
|
|
256
|
+
host.remove();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await it('hands the box over to the wrap box, never nests the two', () => {
|
|
260
|
+
const { el, host } = mount('<b>Bold</b> and <i>italic</i>');
|
|
261
|
+
el.useMarkup = true;
|
|
262
|
+
el.wrap = true;
|
|
263
|
+
// `.adw-label-text` is `display: block`, so the runs in it are already inline
|
|
264
|
+
// boxes — a second box inside it would be one box too many, and `_remeasure`
|
|
265
|
+
// would then be pinning the wrong element's width.
|
|
266
|
+
expect(el.childElementCount).toBe(1);
|
|
267
|
+
expect(el.firstElementChild?.classList.contains('adw-label-text')).toBe(true);
|
|
268
|
+
expect(el.querySelectorAll('.adw-label-runs').length).toBe(0);
|
|
269
|
+
expect(el.textContent).toBe('Bold and italic');
|
|
270
|
+
|
|
271
|
+
el.wrap = false;
|
|
272
|
+
expect(el.childElementCount).toBe(1);
|
|
273
|
+
expect(el.firstElementChild?.classList.contains('adw-label-runs')).toBe(true);
|
|
274
|
+
expect(el.querySelectorAll('.adw-label-text').length).toBe(0);
|
|
275
|
+
host.remove();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
await it('raises sup above sub — the proof the runs are text and not items', () => {
|
|
279
|
+
const { el, host } = mount('x<sub>2</sub> and y<sup>3</sup>');
|
|
280
|
+
el.useMarkup = true;
|
|
281
|
+
const [, sub, , sup] = textNodes(el);
|
|
282
|
+
// THE ASSERTION THAT CATCHES THE FLEX ITEM. `vertical-align` does not apply to
|
|
283
|
+
// a flex item: as items these two would each be laid out by the container's
|
|
284
|
+
// `align-items: center`, landing at the SAME height, and a formula would read
|
|
285
|
+
// `x2 and y3` flat. As inline text `<sup>` is raised and `<sub>` lowered, so
|
|
286
|
+
// the one sits strictly above the other.
|
|
287
|
+
expect(runRect(sup!).top).toBeLessThan(runRect(sub!).top);
|
|
288
|
+
// Both still on the label's own one line: neither escaped the line box.
|
|
289
|
+
const whole = textRect(el);
|
|
290
|
+
expect(runRect(sub!).bottom).toBeLessThanOrEqual(Math.ceil(whole.bottom));
|
|
291
|
+
expect(runRect(sup!).top).toBeGreaterThanOrEqual(Math.floor(whole.top));
|
|
292
|
+
host.remove();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
await it('still offsets the whole line by xalign', () => {
|
|
296
|
+
const { el, host } = mount('<b>Hi</b> there');
|
|
297
|
+
el.useMarkup = true;
|
|
298
|
+
host.style.width = '400px';
|
|
299
|
+
// The box sizes to its text (`flex-basis: auto` over `display: block`), which
|
|
300
|
+
// is what leaves the two spacer pseudo-elements free space to split — the
|
|
301
|
+
// whole of `xalign`. A box stretched to the container would have none, and the
|
|
302
|
+
// text would sit at the left whatever `xalign` said.
|
|
303
|
+
el.xalign = 0;
|
|
304
|
+
const left = textRect(el);
|
|
305
|
+
el.xalign = 1;
|
|
306
|
+
const right = textRect(el);
|
|
307
|
+
expect(Math.round(left.left)).toBeLessThan(Math.round(right.left));
|
|
308
|
+
expect(Math.round(right.width)).toBe(Math.round(left.width));
|
|
309
|
+
host.remove();
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
await describe('<gtk-label> against LABEL_MARKUP_LINK_VECTORS — the anchors', async () => {
|
|
314
|
+
for (const vector of LABEL_MARKUP_LINK_VECTORS) {
|
|
315
|
+
await it(vector.rule, () => {
|
|
316
|
+
const { el, host } = mount(vector.markup);
|
|
317
|
+
el.useMarkup = true;
|
|
318
|
+
|
|
319
|
+
// `null` is markup that does not parse: no links, and so neither of the
|
|
320
|
+
// two things `gtk_label_set_markup` puts on the widget for having them.
|
|
321
|
+
const links = vector.links ?? [];
|
|
322
|
+
const anchors = labelMarkupAnchors(el);
|
|
323
|
+
expect(anchors.length).toBe(links.length);
|
|
324
|
+
expect(el.classList.contains('link')).toBe(vector.cssClass);
|
|
325
|
+
expect(el.hasAttribute('has-tooltip')).toBe(vector.hasTooltip);
|
|
326
|
+
|
|
327
|
+
for (const [index, link] of links.entries()) {
|
|
328
|
+
const anchor = anchors[index]!;
|
|
329
|
+
// The anchor is a REAL link: `href` is set, which is what makes it one
|
|
330
|
+
// to the keyboard, to the accessibility tree and to "copy link
|
|
331
|
+
// address". `getAttribute`, not `.href`, because the property
|
|
332
|
+
// resolves a relative URI against the document.
|
|
333
|
+
expect(anchor.getAttribute('href')).toBe(link.href);
|
|
334
|
+
expect(labelMarkupAnchorIndex(anchor)).toBe(index);
|
|
335
|
+
expect(anchor.classList.contains('link')).toBe(true);
|
|
336
|
+
// GTK's `link` class and `<a class>`'s value sit on the same node.
|
|
337
|
+
if (link.cssClass !== null) expect(anchor.classList.contains(link.cssClass)).toBe(true);
|
|
338
|
+
// An EMPTY `title` attribute is a tooltip in some browsers, so a link
|
|
339
|
+
// without one must not carry the attribute at all.
|
|
340
|
+
expect(anchor.hasAttribute('title')).toBe(link.title !== null);
|
|
341
|
+
if (link.title !== null) expect(anchor.title).toBe(link.title);
|
|
342
|
+
// THE OFFSETS REACHED THE DOM: the characters the row's range cuts out
|
|
343
|
+
// of the display text are the characters under this anchor.
|
|
344
|
+
expect(anchor.textContent).toBe(anchorText(el.textContent ?? '', links, index));
|
|
345
|
+
}
|
|
346
|
+
host.remove();
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
await describe('<gtk-label> activate-link', async () => {
|
|
352
|
+
const markup = 'Read the <a href="https://gtk.org">docs</a> first';
|
|
353
|
+
|
|
354
|
+
await it('cancels the browser navigation and launches the uri instead', () => {
|
|
355
|
+
const { el, host, opened } = mount(markup);
|
|
356
|
+
el.useMarkup = true;
|
|
357
|
+
const details = record(el, 'activate-link');
|
|
358
|
+
const anchor = labelMarkupAnchors(el)[0]!;
|
|
359
|
+
|
|
360
|
+
// `preventDefault`ed EVERY time: the anchor has a real `href`, so a browser
|
|
361
|
+
// would navigate this window, and GTK never does — `gtk_label_activate_link`
|
|
362
|
+
// hands the URI to the desktop and leaves the window standing.
|
|
363
|
+
expect(click(anchor).defaultPrevented).toBe(true);
|
|
364
|
+
expect(opened).toStrictEqual(['https://gtk.org']);
|
|
365
|
+
expect(details).toStrictEqual([{ uri: 'https://gtk.org' }]);
|
|
366
|
+
expect(anchor.classList.contains('visited')).toBe(true);
|
|
367
|
+
host.remove();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
await it('a handler that handles it stops the launch — and still visits the link', () => {
|
|
371
|
+
const { el, host, opened } = mount(markup);
|
|
372
|
+
el.useMarkup = true;
|
|
373
|
+
// `preventDefault` is a handler returning TRUE under the boolean accumulator,
|
|
374
|
+
// so the class handler never runs and nothing is launched.
|
|
375
|
+
el.addEventListener('activate-link', (event) => event.preventDefault());
|
|
376
|
+
const anchor = labelMarkupAnchors(el)[0]!;
|
|
377
|
+
click(anchor);
|
|
378
|
+
expect(opened).toStrictEqual([]);
|
|
379
|
+
// VISITED ALL THE SAME, and this is where the label and `<gtk-link-button>`
|
|
380
|
+
// part ways. `emit_activate_link` (gtklabel.c:2053) reads the ACCUMULATED
|
|
381
|
+
// result — `if (handled && !link->visited)` — so a handler that followed the
|
|
382
|
+
// link its own way has followed it. `GtkLinkButton` instead sets `visited`
|
|
383
|
+
// inside its own default handler (gtklinkbutton.c:521), which a cancelling
|
|
384
|
+
// handler skips. Two widgets, two places, and only the C says which is which.
|
|
385
|
+
expect(anchor.classList.contains('visited')).toBe(true);
|
|
386
|
+
host.remove();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
await it('visits every link to the same uri, and leaves the others alone', () => {
|
|
390
|
+
const { el, host, opened } = mount('<a href="a">one</a> <a href="b">two</a> <a href="a">three</a>');
|
|
391
|
+
el.useMarkup = true;
|
|
392
|
+
const anchors = labelMarkupAnchors(el);
|
|
393
|
+
click(anchors[0]!);
|
|
394
|
+
// BY URI, not by index (gtklabel.c:3635-3647): two links to the same place are
|
|
395
|
+
// one visited state, so following either marks both.
|
|
396
|
+
expect(anchors[0]!.classList.contains('visited')).toBe(true);
|
|
397
|
+
expect(anchors[2]!.classList.contains('visited')).toBe(true);
|
|
398
|
+
expect(anchors[1]!.classList.contains('visited')).toBe(false);
|
|
399
|
+
expect(opened).toStrictEqual(['a']);
|
|
400
|
+
host.remove();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
await it('carries visited across a re-render, by uri', () => {
|
|
404
|
+
const { el, host } = mount(markup);
|
|
405
|
+
el.useMarkup = true;
|
|
406
|
+
click(labelMarkupAnchors(el)[0]!);
|
|
407
|
+
// Rewriting the text AROUND a link has not un-followed it.
|
|
408
|
+
el.label = 'Now read the <a href="https://gtk.org">manual</a>';
|
|
409
|
+
expect(labelMarkupAnchors(el)[0]!.classList.contains('visited')).toBe(true);
|
|
410
|
+
host.remove();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
await it('ignores a click that is not on a link', () => {
|
|
414
|
+
const { el, host, opened } = mount(markup);
|
|
415
|
+
el.useMarkup = true;
|
|
416
|
+
const details = record(el, 'activate-link');
|
|
417
|
+
expect(click(el).defaultPrevented).toBe(false);
|
|
418
|
+
expect(opened).toStrictEqual([]);
|
|
419
|
+
expect(details).toStrictEqual([]);
|
|
420
|
+
host.remove();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
await it('keeps the run elements across a render that did not touch the text', () => {
|
|
424
|
+
const { el, host } = mount(markup);
|
|
425
|
+
el.useMarkup = true;
|
|
426
|
+
const anchor = labelMarkupAnchors(el)[0]!;
|
|
427
|
+
// `_render` runs on EVERY observed attribute, and rebuilding the runs for one
|
|
428
|
+
// that never touched the text would drop a selection and the focus inside a
|
|
429
|
+
// link — so the nodes have to be the SAME nodes.
|
|
430
|
+
el.xalign = 1;
|
|
431
|
+
expect(labelMarkupAnchors(el)[0]).toBe(anchor);
|
|
432
|
+
host.remove();
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
await it('declares activate-link, so connect() takes it', () => {
|
|
436
|
+
const { el, host } = mount(markup);
|
|
437
|
+
el.useMarkup = true;
|
|
438
|
+
const connectable = el as unknown as { connect(signal: string, handler: () => void): number };
|
|
439
|
+
expect(() => connectable.connect('activate-link', () => {})).not.toThrow();
|
|
440
|
+
host.remove();
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
await describe('<gtk-label> gives its anchors the link treatment', async () => {
|
|
445
|
+
const markup = 'Read the <a href="https://gtk.org">docs</a> first';
|
|
446
|
+
|
|
447
|
+
await it('underlines the anchor and recolours it, where the text around it is plain', () => {
|
|
448
|
+
const { el, host } = mount(markup);
|
|
449
|
+
el.useMarkup = true;
|
|
450
|
+
const anchor = labelMarkupAnchors(el)[0]!;
|
|
451
|
+
// `_link.scss`'s `link`-node rule, upstream's `_links.scss:1-5`. The label is
|
|
452
|
+
// NOT a link even though it carries the `.link` style class (gtklabel.c:3896),
|
|
453
|
+
// so the treatment has to stop at the anchor.
|
|
454
|
+
expect(getComputedStyle(anchor).textDecorationLine).toBe('underline');
|
|
455
|
+
expect(getComputedStyle(el).textDecorationLine).toBe('none');
|
|
456
|
+
expect(getComputedStyle(anchor).color === getComputedStyle(el).color).toBe(false);
|
|
457
|
+
host.remove();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
await it('keeps the label text weight inside the link', () => {
|
|
461
|
+
const { el, host } = mount('<b>bold <a href="https://gtk.org">link</a></b>');
|
|
462
|
+
el.useMarkup = true;
|
|
463
|
+
const anchor = labelMarkupAnchors(el)[0]!;
|
|
464
|
+
// `font-weight: inherit` (`_links.scss:4`): a link inside bold markup stays
|
|
465
|
+
// bold, where a UA's own `<a>` styling would not care.
|
|
466
|
+
expect(getComputedStyle(anchor).fontWeight).toBe(getComputedStyle(anchor.parentElement!).fontWeight);
|
|
467
|
+
host.remove();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
await it('draws a followed link in a different colour', () => {
|
|
471
|
+
const { el, host } = mount(markup);
|
|
472
|
+
el.useMarkup = true;
|
|
473
|
+
const fresh = getComputedStyle(labelMarkupAnchors(el)[0]!).color;
|
|
474
|
+
click(labelMarkupAnchors(el)[0]!);
|
|
475
|
+
// `.visited`, the class `_paintVisited` writes — NOT the `:visited`
|
|
476
|
+
// pseudo-class, which is the user's browsing history and would colour a link
|
|
477
|
+
// in a label that was never clicked.
|
|
478
|
+
expect(getComputedStyle(labelMarkupAnchors(el)[0]!).color === fresh).toBe(false);
|
|
479
|
+
host.remove();
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
63
483
|
await describe('<gtk-label> against LABEL_XALIGN_VECTORS and LABEL_JUSTIFY_VECTORS', async () => {
|
|
64
484
|
for (const vector of LABEL_XALIGN_VECTORS) {
|
|
65
485
|
await it(`xalign: ${vector.rule}`, () => {
|
package/src/gtk-switch.spec.ts
CHANGED
|
@@ -206,6 +206,14 @@ export const GtkSwitchTest = async () => {
|
|
|
206
206
|
parts(sw).slider.click();
|
|
207
207
|
expect(row.expanded).toBe(false);
|
|
208
208
|
|
|
209
|
+
// The click turned the row's expansion OFF, and a disabled row refuses to
|
|
210
|
+
// disclose (libadwaita's `enable-expansion` gate); an enabled one discloses.
|
|
211
|
+
expect(row.enableExpansion).toBe(false);
|
|
212
|
+
(row.querySelector('.adw-expander-row-header') as HTMLElement).click();
|
|
213
|
+
expect(row.expanded).toBe(false);
|
|
214
|
+
|
|
215
|
+
row.enableExpansion = true;
|
|
216
|
+
row.expanded = false;
|
|
209
217
|
(row.querySelector('.adw-expander-row-header') as HTMLElement).click();
|
|
210
218
|
expect(row.expanded).toBe(true);
|
|
211
219
|
host.remove();
|