@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
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// A `Gtk.Label`'s markup DRAWN — the flat styled runs of `@gjsify/adwaita-core`'s
|
|
2
|
+
// `labelMarkupRender` as DOM nodes, and the `<a href>` elements beside them.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS IS NOT `pango-markup.ts`. That file walks the parse TREE and builds one element
|
|
5
|
+
// per element, which is the obvious mapping and the right one for a surface that only
|
|
6
|
+
// shows markup (`<adw-banner>`, `<adw-alert-dialog>`). A label additionally has LINKS,
|
|
7
|
+
// whose ranges are recorded against the display TEXT and may cross the tree's shape, and
|
|
8
|
+
// it shares its derivation with the NativeScript port, whose `FormattedString` has no tree
|
|
9
|
+
// to walk. So the label draws the RUNS; core's `label-markup.ts` header carries why they
|
|
10
|
+
// are flat. The two files share the parts that must not drift: {@link TAG_STYLES} (which
|
|
11
|
+
// element draws which pango tag) and {@link applySpanAttributes} (the `<span>` attribute
|
|
12
|
+
// whitelist), both imported from `pango-markup.ts` rather than restated.
|
|
13
|
+
//
|
|
14
|
+
// NO `innerHTML`, SAME AS EVER (`check-adwaita-no-markup-sinks.mjs`): every node here is
|
|
15
|
+
// `createElement` and every character is a text node, so a string the label carries can
|
|
16
|
+
// never become an element the browser parses.
|
|
17
|
+
//
|
|
18
|
+
// THE `<a>` IS A REAL LINK AND STILL GOES THROUGH THE SIGNAL. `href` is set, because an
|
|
19
|
+
// anchor without one is not a link to the keyboard, to the accessibility tree or to the
|
|
20
|
+
// browser's own "copy link address" — all of which GTK's link node has. The click is then
|
|
21
|
+
// `preventDefault`ed and `activate-link` is dispatched instead, because GTK does NOT
|
|
22
|
+
// navigate the window the label sits in: `gtk_label_activate_link` hands the URI to the
|
|
23
|
+
// desktop, which opens it elsewhere and leaves the window standing. `<gtk-link-button>`
|
|
24
|
+
// answers the same question the same way (`window.open(uri, '_blank', 'noopener')`), and
|
|
25
|
+
// the two must not disagree about what following a link does.
|
|
26
|
+
//
|
|
27
|
+
// WHERE A `<span>` ATTRIBUTE LOSES TO A TAG — a DECLARED divergence. Pango resolves a
|
|
28
|
+
// collision between `<span weight="light">` and `<b>` by NESTING: the inner one wins. The
|
|
29
|
+
// runs cannot say which was inner, because {@link LabelMarkupStyle} records the tags as
|
|
30
|
+
// booleans and the `<span>`s as a separate stack, so this file picks one order and states
|
|
31
|
+
// it: the `<span>`s are the OUTER elements and the tags the inner ones, i.e. a tag always
|
|
32
|
+
// wins. It is also the better of the two for `<big>`/`<small>`, whose `font-size: larger`
|
|
33
|
+
// is RELATIVE: nested inside a `<span size="12pt">` it multiplies that size, which is what
|
|
34
|
+
// pango does with a scale attribute beside an absolute one. The corpus this was measured
|
|
35
|
+
// against (155 labels, `adwaita-core`'s `label.ts` header) has no `<span>` at all.
|
|
36
|
+
//
|
|
37
|
+
// Reference: packages/web/adwaita-core/src/label-markup.ts (the runs, the links, the
|
|
38
|
+
// `::activate-link` rule)
|
|
39
|
+
// Reference: refs/gtk/gtk/gtklabel.c (the `link` CSS class, the per-link tooltip,
|
|
40
|
+
// gtk_label_activate_link)
|
|
41
|
+
// Copyright (c) The GTK Team. LGPLv2.1+.
|
|
42
|
+
|
|
43
|
+
import {
|
|
44
|
+
stripMnemonic,
|
|
45
|
+
type LabelMarkupRender,
|
|
46
|
+
type LabelMarkupRun,
|
|
47
|
+
type LabelMarkupStyle,
|
|
48
|
+
} from '@gjsify/adwaita-core';
|
|
49
|
+
|
|
50
|
+
import { applySpanAttributes, TAG_STYLES } from './pango-markup.js';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Which link an `<a>` draws, as its index into {@link LabelMarkupRender.links}.
|
|
54
|
+
*
|
|
55
|
+
* On the attribute and not in a JS-side map, because the element re-reads it to paint
|
|
56
|
+
* `visited` and to answer a click: one link may be drawn as SEVERAL anchors (a link whose
|
|
57
|
+
* text changes style mid-way is several runs), and the index is what ties them back
|
|
58
|
+
* together.
|
|
59
|
+
*/
|
|
60
|
+
export const LABEL_LINK_ATTRIBUTE = 'data-gtk-link';
|
|
61
|
+
|
|
62
|
+
/** The pango tags {@link buildRun} nests inside a run's `<span>`s, innermost last. */
|
|
63
|
+
const STYLE_TAGS: readonly (readonly [keyof LabelMarkupStyle, string])[] = [
|
|
64
|
+
['monospace', 'tt'],
|
|
65
|
+
['underline', 'u'],
|
|
66
|
+
['strikethrough', 's'],
|
|
67
|
+
['italic', 'i'],
|
|
68
|
+
['bold', 'b'],
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/** One element of {@link TAG_STYLES}, with the CSS that table gives it. */
|
|
72
|
+
function tagElement(tag: string, doc: Document): HTMLElement {
|
|
73
|
+
const spec = TAG_STYLES[tag];
|
|
74
|
+
// Every tag this file names is in the table — it is the same closed set pango's parser
|
|
75
|
+
// accepts — so a miss is a programming error here, not an input the label can carry.
|
|
76
|
+
if (spec === undefined) throw new Error(`label markup: no element draws <${tag}>`);
|
|
77
|
+
const el = doc.createElement(spec.element);
|
|
78
|
+
if (spec.style !== undefined)
|
|
79
|
+
for (const [property, value] of Object.entries(spec.style)) el.style.setProperty(property, value);
|
|
80
|
+
return el;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One run as a chain of elements around its text, outermost returned.
|
|
85
|
+
*
|
|
86
|
+
* The chain is `<span>`s (outermost first, so the innermost attribute set wins per CSS
|
|
87
|
+
* property) and then the pango tags — see the header for why that order and not the
|
|
88
|
+
* reverse. A run with no style at all is a bare text node: a plain label keeps the "one
|
|
89
|
+
* text node, no elements" DOM shape `gtk-label.spec.ts` asserts for the XSS answer.
|
|
90
|
+
*
|
|
91
|
+
* `mnemonics` is `use-underline`, and it is applied to the run's TEXT rather than to the
|
|
92
|
+
* markup string: `gtk_label_set_markup_with_mnemonic` parses first and strips the marker
|
|
93
|
+
* out of the result, so an `_` inside a tag's attributes is not a mnemonic — the same
|
|
94
|
+
* order `labelDisplayText` follows for the text reading.
|
|
95
|
+
*/
|
|
96
|
+
function buildRun(run: LabelMarkupRun, doc: Document, mnemonics: boolean): Node {
|
|
97
|
+
const chain: HTMLElement[] = [];
|
|
98
|
+
|
|
99
|
+
for (const attributes of run.style.spans) {
|
|
100
|
+
const el = doc.createElement('span');
|
|
101
|
+
applySpanAttributes(el, attributes);
|
|
102
|
+
chain.push(el);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// `<big>`/`<small>` are a COUNT, so they are that many nested elements: `font-size:
|
|
106
|
+
// larger` is relative, and two of them is pango's two scale factors multiplied.
|
|
107
|
+
const steps = run.style.sizeSteps;
|
|
108
|
+
for (let index = 0; index < Math.abs(steps); index += 1) chain.push(tagElement(steps > 0 ? 'big' : 'small', doc));
|
|
109
|
+
|
|
110
|
+
for (const [flag, tag] of STYLE_TAGS) if (run.style[flag]) chain.push(tagElement(tag, doc));
|
|
111
|
+
if (run.style.script !== null) chain.push(tagElement(run.style.script, doc));
|
|
112
|
+
|
|
113
|
+
const text = doc.createTextNode(mnemonics ? stripMnemonic(run.text) : run.text);
|
|
114
|
+
if (chain.length === 0) return text;
|
|
115
|
+
|
|
116
|
+
for (let index = 0; index < chain.length - 1; index += 1) chain[index]!.append(chain[index + 1]!);
|
|
117
|
+
chain[chain.length - 1]!.append(text);
|
|
118
|
+
return chain[0]!;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The `<a>` for the link at `index`, empty.
|
|
123
|
+
*
|
|
124
|
+
* `link` is GTK's own CSS class on the link node (gtklabel.c, `_link.scss` here);
|
|
125
|
+
* `<a class>`'s value rides beside it, which is what that attribute is FOR — GTK puts it
|
|
126
|
+
* on the same node. `title` is the per-link tooltip, and only when the markup gave one:
|
|
127
|
+
* an empty `title` attribute is a tooltip in some browsers.
|
|
128
|
+
*/
|
|
129
|
+
function buildAnchor(render: LabelMarkupRender, index: number, doc: Document): HTMLAnchorElement {
|
|
130
|
+
const link = render.links[index]!;
|
|
131
|
+
const anchor = doc.createElement('a');
|
|
132
|
+
anchor.setAttribute(LABEL_LINK_ATTRIBUTE, String(index));
|
|
133
|
+
anchor.href = link.href;
|
|
134
|
+
anchor.classList.add('link');
|
|
135
|
+
if (link.cssClass !== null && link.cssClass !== '') anchor.classList.add(link.cssClass);
|
|
136
|
+
anchor.classList.toggle('visited', link.visited);
|
|
137
|
+
if (link.title !== null) anchor.title = link.title;
|
|
138
|
+
return anchor;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* `render` as nodes, ready to be put into a label.
|
|
143
|
+
*
|
|
144
|
+
* CONSECUTIVE RUNS OF ONE LINK SHARE ONE `<a>`, so a link is one navigable element to the
|
|
145
|
+
* keyboard and one entry in the accessibility tree even when its text changes style
|
|
146
|
+
* part-way through — which is what GTK's single link node is. Runs of DIFFERENT links, or
|
|
147
|
+
* a run with none between them, close the anchor.
|
|
148
|
+
*/
|
|
149
|
+
export function labelMarkupNodes(render: LabelMarkupRender, doc: Document, mnemonics = false): Node[] {
|
|
150
|
+
const nodes: Node[] = [];
|
|
151
|
+
let anchor: HTMLAnchorElement | null = null;
|
|
152
|
+
let anchored: number | null = null;
|
|
153
|
+
|
|
154
|
+
for (const run of render.runs) {
|
|
155
|
+
if (run.link !== anchored) {
|
|
156
|
+
anchor = run.link === null ? null : buildAnchor(render, run.link, doc);
|
|
157
|
+
anchored = run.link;
|
|
158
|
+
if (anchor !== null) nodes.push(anchor);
|
|
159
|
+
}
|
|
160
|
+
(anchor ?? { append: (node: Node) => nodes.push(node) }).append(buildRun(run, doc, mnemonics));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A LINK WITH NO TEXT still has a node: `<a href="…"></a>` is a link GTK records and
|
|
164
|
+
// gives a range of zero length, and dropping it here would lose the only trace of it.
|
|
165
|
+
for (let index = 0; index < render.links.length; index += 1)
|
|
166
|
+
if (!render.runs.some((run) => run.link === index)) nodes.push(buildAnchor(render, index, doc));
|
|
167
|
+
|
|
168
|
+
return nodes;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Write `render` into `target`, replacing what was there. */
|
|
172
|
+
export function renderLabelMarkup(target: HTMLElement, render: LabelMarkupRender, mnemonics = false): void {
|
|
173
|
+
const doc = target.ownerDocument ?? document;
|
|
174
|
+
target.replaceChildren(...labelMarkupNodes(render, doc, mnemonics));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The `<a>` elements {@link labelMarkupNodes} put under `target`, in reading order. */
|
|
178
|
+
export function labelMarkupAnchors(target: HTMLElement): HTMLAnchorElement[] {
|
|
179
|
+
return [...target.querySelectorAll<HTMLAnchorElement>(`a[${LABEL_LINK_ATTRIBUTE}]`)];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The link index `anchor` draws, or `null` when it is not one of ours.
|
|
184
|
+
*
|
|
185
|
+
* Read back from the attribute rather than closed over, because a click arrives at an
|
|
186
|
+
* element and the handler has only the DOM to go on.
|
|
187
|
+
*/
|
|
188
|
+
export function labelMarkupAnchorIndex(anchor: Element): number | null {
|
|
189
|
+
const raw = anchor.getAttribute(LABEL_LINK_ATTRIBUTE);
|
|
190
|
+
if (raw === null) return null;
|
|
191
|
+
const index = Number(raw);
|
|
192
|
+
return Number.isInteger(index) && index >= 0 ? index : null;
|
|
193
|
+
}
|
package/src/pango-markup.spec.ts
CHANGED
|
@@ -73,6 +73,17 @@ export const PangoMarkupTest = async () => {
|
|
|
73
73
|
expect(target.querySelector('i > u')?.textContent).toBe('b');
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
await it("sub and sup carry pango's 1 / 1.2 font_scale fallback", () => {
|
|
77
|
+
const { target } = render('<sub>d</sub><sup>e</sup>');
|
|
78
|
+
// Compared as a computed ratio: browsers serialise the `calc()` differently
|
|
79
|
+
// (Firefox gives `calc(0.833333em)`).
|
|
80
|
+
const base = Number.parseFloat(getComputedStyle(target).fontSize);
|
|
81
|
+
for (const el of target.children) {
|
|
82
|
+
const ratio = Number.parseFloat(getComputedStyle(el).fontSize) / base;
|
|
83
|
+
expect(Math.abs(ratio - 1 / 1.2)).toBeLessThan(0.01);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
76
87
|
await it("<markup> draws its children — it is pango's implicit root, not a box", async () => {
|
|
77
88
|
const { target } = render('<markup><b>x</b></markup>');
|
|
78
89
|
expect(target.querySelector('b')?.textContent).toBe('x');
|
package/src/pango-markup.ts
CHANGED
|
@@ -51,7 +51,7 @@ import { parsePangoMarkup, type PangoMarkupNode } from '@gjsify/adwaita-core';
|
|
|
51
51
|
* presentational UA rule covers it. Every entry is a CLOSED set: a tag missing here is a
|
|
52
52
|
* parse error, so there is no default branch to fall through.
|
|
53
53
|
*/
|
|
54
|
-
const TAG_STYLES: Readonly<Record<string, { element: string; style?: Readonly<Record<string, string>> }>> = {
|
|
54
|
+
export const TAG_STYLES: Readonly<Record<string, { element: string; style?: Readonly<Record<string, string>> }>> = {
|
|
55
55
|
b: { element: 'b' },
|
|
56
56
|
i: { element: 'i' },
|
|
57
57
|
u: { element: 'u' },
|
|
@@ -59,8 +59,11 @@ const TAG_STYLES: Readonly<Record<string, { element: string; style?: Readonly<Re
|
|
|
59
59
|
tt: { element: 'span', style: { 'font-family': 'monospace' } },
|
|
60
60
|
big: { element: 'span', style: { 'font-size': 'larger' } },
|
|
61
61
|
small: { element: 'span', style: { 'font-size': 'smaller' } },
|
|
62
|
-
sub
|
|
63
|
-
|
|
62
|
+
// pango's `font_scale` for `<sub>`/`<sup>` falls back to `1 / 1.2` when the font's OpenType
|
|
63
|
+
// metrics carry no script size (pango/itemize.c, `PANGO_FONT_SCALE_SUBSCRIPT`/`SUPERSCRIPT`);
|
|
64
|
+
// spelled out so it does not depend on each UA's `font-size: smaller` ratio.
|
|
65
|
+
sub: { element: 'sub', style: { 'font-size': 'calc(1em / 1.2)' } },
|
|
66
|
+
sup: { element: 'sup', style: { 'font-size': 'calc(1em / 1.2)' } },
|
|
64
67
|
markup: { element: 'span', style: { display: 'contents' } },
|
|
65
68
|
span: { element: 'span' },
|
|
66
69
|
};
|
|
@@ -140,8 +143,14 @@ function buildNode(node: PangoMarkupNode, doc: Document): Node | null {
|
|
|
140
143
|
return el;
|
|
141
144
|
}
|
|
142
145
|
|
|
143
|
-
/**
|
|
144
|
-
|
|
146
|
+
/**
|
|
147
|
+
* Write the recognised `<span>` attributes onto `el` as style declarations.
|
|
148
|
+
*
|
|
149
|
+
* Exported for `label-markup.ts`, which draws a `Gtk.Label`'s markup from flat RUNS rather
|
|
150
|
+
* than from this file's tree: the whitelist and its value tables are the part the two must
|
|
151
|
+
* share, so an attribute is added once and both renderers gain it.
|
|
152
|
+
*/
|
|
153
|
+
export function applySpanAttributes(el: HTMLElement, attributes: Readonly<Record<string, string>>): void {
|
|
145
154
|
// Additive, so `<span underline="single" strikethrough="true">` draws BOTH —
|
|
146
155
|
// `text-decoration` is a shorthand that would drop the first one.
|
|
147
156
|
const decorations: string[] = [];
|
package/src/split-button.spec.ts
CHANGED
|
@@ -98,13 +98,16 @@ export const AdwSplitButtonTest = async () => {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
// The element re-emits libadwaita's notify sequence verbatim, so the
|
|
101
|
-
// ORDER —
|
|
101
|
+
// ORDER — the property SET first, then the slots that were filled
|
|
102
|
+
// before it — is DOM-observable.
|
|
102
103
|
expect(seen).toStrictEqual([...step.notified]);
|
|
103
104
|
expect(el.label).toBe(step.label);
|
|
104
105
|
expect(el.iconName).toBe(step.iconName);
|
|
105
|
-
//
|
|
106
|
-
// distinction,
|
|
107
|
-
|
|
106
|
+
// What is PAINTED follows the two readbacks, not the content mode: an
|
|
107
|
+
// empty label is SET but paints nothing (the `label[0]` distinction),
|
|
108
|
+
// and an icon name set to `''` leaves the internal GtkImage EMPTY, so
|
|
109
|
+
// an icon-mode button with a NULL readback paints nothing either.
|
|
110
|
+
const painted = step.iconName !== null ? 'icon' : (step.label ?? '').length > 0 ? 'label' : 'empty';
|
|
108
111
|
expect(renderedMode(el)).toBe(painted);
|
|
109
112
|
expect(rootStyleClasses(el)).toStrictEqual([...step.styleClasses]);
|
|
110
113
|
}
|