@jxsuite/import 0.40.0 → 0.40.2
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 +8 -30
- package/package.json +6 -4
- package/src/ai-componentize.ts +12 -2
- package/src/apply-accordions.ts +304 -0
- package/src/apply-disclosures.ts +275 -0
- package/src/apply-popovers.ts +298 -0
- package/src/apply-styles.ts +60 -11
- package/src/asset-collect.ts +2 -2
- package/src/asset-download.ts +10 -11
- package/src/asset-rewrite.ts +30 -6
- package/src/browser-local.ts +67 -0
- package/src/capture.ts +50 -61
- package/src/componentize.ts +35 -1
- package/src/crawl.ts +23 -17
- package/src/derived-geometry.ts +115 -0
- package/src/emit.ts +59 -34
- package/src/image-family.ts +227 -0
- package/src/index.ts +11 -5
- package/src/io.ts +52 -0
- package/src/media-extract.ts +75 -6
- package/src/pipeline.ts +673 -0
- package/src/run.ts +93 -503
- package/src/style-capture.ts +201 -162
- package/src/style-diff.ts +17 -4
- package/src/to-jx.ts +27 -1
- package/src/toggle-algebra.ts +167 -0
- package/src/verify.ts +1 -1
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a control that says it expands something into a real `<details>`.
|
|
3
|
+
*
|
|
4
|
+
* This is the reading that needs no framework knowledge at all: a site that ships correct
|
|
5
|
+
* accessibility markup has already told us which control opens which panel, in `aria-expanded` plus
|
|
6
|
+
* `aria-controls`, and that contract is identical across Bootstrap, jQuery, Webflow and
|
|
7
|
+
* hand-written markup. Where `toggle-algebra.ts` recognises the Alpine and Vue family by the shape
|
|
8
|
+
* of their expressions, this recognises everything that ships WAI-ARIA, which is most of the rest.
|
|
9
|
+
*
|
|
10
|
+
* The panel is concealed and the script that would reveal it is gone, so the content is on disk and
|
|
11
|
+
* unreachable — the single largest body of trapped text on the reference corpus.
|
|
12
|
+
*
|
|
13
|
+
* **A disclosure is not an overlay, and the difference is the flow.** `apply-popovers.ts` claims a
|
|
14
|
+
* concealed panel that is positioned, because being lifted out of the flow is what makes a thing
|
|
15
|
+
* draw ON TOP. A concealed panel still IN the flow is content that belongs where it sits and simply
|
|
16
|
+
* is not shown yet, which is exactly what `<details>` means. The two passes are disjoint by that
|
|
17
|
+
* test rather than by ordering luck.
|
|
18
|
+
*
|
|
19
|
+
* The rewrite MOVES both subtrees and conserves the leaf census, on the same terms as
|
|
20
|
+
* `apply-accordions.ts`: a stale widget is a far smaller defect than deleted content, so a rewrite
|
|
21
|
+
* that would lose a text run or an image is abandoned whole.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { JxElement } from "@jxsuite/schema/types";
|
|
25
|
+
import { countLeaves } from "./apply-accordions.ts";
|
|
26
|
+
|
|
27
|
+
/** Attributes that made the old control a control, and mean nothing once `<summary>` is one. */
|
|
28
|
+
const CONTROL_ATTRIBUTES = ["aria-expanded", "aria-controls", "role", "tabindex"];
|
|
29
|
+
|
|
30
|
+
/** A panel lifted out of the flow is an overlay, and belongs to the popover pass instead. */
|
|
31
|
+
const POSITIONED = new Set(["absolute", "fixed"]);
|
|
32
|
+
|
|
33
|
+
function attributesOf(node: JxElement): Record<string, unknown> | undefined {
|
|
34
|
+
return node.attributes as Record<string, unknown> | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function childrenOf(node: JxElement): (JxElement | string)[] {
|
|
38
|
+
return Array.isArray(node.children) ? (node.children as (JxElement | string)[]) : [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function elementChildren(node: JxElement): JxElement[] {
|
|
42
|
+
return childrenOf(node).filter((child) => typeof child !== "string") as JxElement[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether this control declares that it expands a named panel. */
|
|
46
|
+
export function isDisclosureInvoker(node: JxElement): boolean {
|
|
47
|
+
const attributes = attributesOf(node);
|
|
48
|
+
if (!attributes) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
const controls = attributes["aria-controls"];
|
|
52
|
+
return (
|
|
53
|
+
attributes["aria-expanded"] !== undefined && typeof controls === "string" && controls.length > 0
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether this panel is closed content in the flow, rather than an overlay or ordinary content.
|
|
59
|
+
*
|
|
60
|
+
* The corpus's panels carry no style at all: they are hidden by the `hidden` ATTRIBUTE, which is
|
|
61
|
+
* why a detector looking only at computed style would miss every one of them.
|
|
62
|
+
*/
|
|
63
|
+
export function isDisclosurePanel(node: JxElement): boolean {
|
|
64
|
+
const style = node.style as Record<string, unknown> | undefined;
|
|
65
|
+
if (POSITIONED.has(String(style?.["position"] ?? ""))) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
const attributes = attributesOf(node);
|
|
69
|
+
return attributes?.["hidden"] !== undefined || style?.["display"] === "none";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Every element carrying an `id`, so `aria-controls` can be resolved. */
|
|
73
|
+
function indexById(root: JxElement): Map<string, JxElement> {
|
|
74
|
+
const index = new Map<string, JxElement>();
|
|
75
|
+
const visit = (node: JxElement): void => {
|
|
76
|
+
const id = attributesOf(node)?.["id"];
|
|
77
|
+
if (typeof id === "string" && !index.has(id)) {
|
|
78
|
+
index.set(id, node);
|
|
79
|
+
}
|
|
80
|
+
for (const child of elementChildren(node)) {
|
|
81
|
+
visit(child);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
visit(root);
|
|
85
|
+
return index;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What an element actually holds, whether that is children or a collapsed `textContent`.
|
|
90
|
+
*
|
|
91
|
+
* `htmlToJx` folds a lone text child into `textContent` and emits no `children` array at all, so a
|
|
92
|
+
* control whose label is bare text — which is most of them — looks empty to a children-only reader.
|
|
93
|
+
* Unwrapping one through `childrenOf` alone silently drops the label, and the leaf census then
|
|
94
|
+
* abandons the whole rewrite. Correct, but the wrong outcome.
|
|
95
|
+
*/
|
|
96
|
+
function contentOf(node: JxElement): (JxElement | string)[] {
|
|
97
|
+
const children = childrenOf(node);
|
|
98
|
+
if (children.length > 0) {
|
|
99
|
+
return children;
|
|
100
|
+
}
|
|
101
|
+
return typeof node.textContent === "string" && node.textContent.length > 0
|
|
102
|
+
? [node.textContent]
|
|
103
|
+
: [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Replace the invoker element with its own children, wherever it sits inside `node`.
|
|
108
|
+
*
|
|
109
|
+
* `<summary>` IS the control once the rewrite lands, so leaving the old one inside it would nest an
|
|
110
|
+
* interactive element in another. Unwrapping rather than deleting keeps the label text, which is
|
|
111
|
+
* the only part of the control a reader ever saw. Safe here because these controls carry no `href`:
|
|
112
|
+
* a real link would lose its destination and is refused before this point.
|
|
113
|
+
*/
|
|
114
|
+
function unwrapInvoker(node: JxElement, invoker: JxElement): JxElement {
|
|
115
|
+
const children = childrenOf(node);
|
|
116
|
+
if (children.length === 0) {
|
|
117
|
+
return node;
|
|
118
|
+
}
|
|
119
|
+
const rebuilt: (JxElement | string)[] = [];
|
|
120
|
+
for (const child of children) {
|
|
121
|
+
if (child === invoker) {
|
|
122
|
+
rebuilt.push(...contentOf(child as JxElement));
|
|
123
|
+
} else if (typeof child === "string") {
|
|
124
|
+
rebuilt.push(child);
|
|
125
|
+
} else {
|
|
126
|
+
rebuilt.push(unwrapInvoker(child as JxElement, invoker));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { ...node, children: rebuilt as JxElement[] };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface DisclosureResult {
|
|
133
|
+
/** How many control/panel pairs became a `<details>`. */
|
|
134
|
+
converted: number;
|
|
135
|
+
/** Controls left pointing at a panel another control had already claimed. */
|
|
136
|
+
duplicateInvokers: number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Convert every ARIA-declared disclosure in a page tree into `<details>`.
|
|
141
|
+
*
|
|
142
|
+
* @param {JxElement} root - The page tree, mutated in place
|
|
143
|
+
* @returns {DisclosureResult}
|
|
144
|
+
*/
|
|
145
|
+
export function applyDisclosures(root: JxElement): DisclosureResult {
|
|
146
|
+
const result: DisclosureResult = { converted: 0, duplicateInvokers: 0 };
|
|
147
|
+
const byId = indexById(root);
|
|
148
|
+
const claimedPanels = new Set<JxElement>();
|
|
149
|
+
|
|
150
|
+
/*
|
|
151
|
+
* Walk parents rather than invokers. The rewrite replaces TWO of a parent's children with one
|
|
152
|
+
* `<details>`, so the parent is the unit of work — and the corpus puts the panel BEFORE the
|
|
153
|
+
* control it belongs to ("… hidden extra copy … Read more"), so neither is reliably first.
|
|
154
|
+
*/
|
|
155
|
+
const visit = (parent: JxElement): void => {
|
|
156
|
+
for (;;) {
|
|
157
|
+
const children = childrenOf(parent);
|
|
158
|
+
let applied = false;
|
|
159
|
+
|
|
160
|
+
for (let index = 0; index < children.length && !applied; index += 1) {
|
|
161
|
+
const branch = children[index];
|
|
162
|
+
if (typeof branch === "string") {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const invoker = findInvokerIn(branch as JxElement);
|
|
167
|
+
if (!invoker) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const panelId = String(attributesOf(invoker)!["aria-controls"]);
|
|
171
|
+
const panel = byId.get(panelId);
|
|
172
|
+
if (!panel || !isDisclosurePanel(panel)) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (claimedPanels.has(panel)) {
|
|
176
|
+
/* A second control for a panel already inside a `<details>` is now a lie to a screen
|
|
177
|
+
reader: it reports a collapsed state it no longer governs. Strip what made it a
|
|
178
|
+
control and leave the text. */
|
|
179
|
+
const attributes = attributesOf(invoker)!;
|
|
180
|
+
for (const name of CONTROL_ATTRIBUTES) {
|
|
181
|
+
delete attributes[name];
|
|
182
|
+
}
|
|
183
|
+
result.duplicateInvokers += 1;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const panelIndex = children.indexOf(panel);
|
|
188
|
+
if (panelIndex === -1 || panelIndex === index) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const before = countLeaves(parent);
|
|
193
|
+
const panelAttributes = { ...attributesOf(panel) };
|
|
194
|
+
delete panelAttributes["hidden"];
|
|
195
|
+
delete panelAttributes["aria-hidden"];
|
|
196
|
+
|
|
197
|
+
/* When the control IS the branch, there is no enclosing element to unwrap it out of, so
|
|
198
|
+
its own attributes would ride onto the `<summary>`. Stripping them here covers both
|
|
199
|
+
shapes: `<summary>` is the control now, and a leftover `aria-expanded` on it would report
|
|
200
|
+
a state the element no longer owns. */
|
|
201
|
+
const summaryBranch = unwrapInvoker(branch as JxElement, invoker);
|
|
202
|
+
const summaryAttributes = { ...attributesOf(summaryBranch) };
|
|
203
|
+
for (const name of CONTROL_ATTRIBUTES) {
|
|
204
|
+
delete summaryAttributes[name];
|
|
205
|
+
}
|
|
206
|
+
const summary: JxElement = {
|
|
207
|
+
...summaryBranch,
|
|
208
|
+
...(Object.keys(summaryAttributes).length > 0
|
|
209
|
+
? { attributes: summaryAttributes as Record<string, string> }
|
|
210
|
+
: {}),
|
|
211
|
+
tagName: "summary",
|
|
212
|
+
};
|
|
213
|
+
if (Object.keys(summaryAttributes).length === 0) {
|
|
214
|
+
delete summary.attributes;
|
|
215
|
+
}
|
|
216
|
+
const details: JxElement = {
|
|
217
|
+
children: [summary, { ...panel, attributes: panelAttributes }] as JxElement[],
|
|
218
|
+
tagName: "details",
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/* Placed where the FIRST of the pair sat, so the disclosure keeps the position the reader
|
|
222
|
+
already associated with it. `<details>` always renders its summary above its content, so
|
|
223
|
+
a panel that came first moves below the label it was always labelled by. */
|
|
224
|
+
const rebuilt = children.filter((child) => child !== branch && child !== panel) as (
|
|
225
|
+
| JxElement
|
|
226
|
+
| string
|
|
227
|
+
)[];
|
|
228
|
+
rebuilt.splice(Math.min(index, panelIndex), 0, details);
|
|
229
|
+
|
|
230
|
+
const candidate: JxElement = { ...parent, children: rebuilt as JxElement[] };
|
|
231
|
+
if (countLeaves(candidate).join(" ") !== before.join(" ")) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
parent.children = rebuilt as JxElement[];
|
|
236
|
+
claimedPanels.add(panel);
|
|
237
|
+
result.converted += 1;
|
|
238
|
+
applied = true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!applied) {
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const child of elementChildren(parent)) {
|
|
247
|
+
visit(child);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
visit(root);
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The first disclosure control at or under `node`, but never one already inside a `<details>`.
|
|
257
|
+
*
|
|
258
|
+
* Without that second condition a converted disclosure's own summary would be offered back as a
|
|
259
|
+
* candidate on the next pass over the same parent, and the loop would not terminate.
|
|
260
|
+
*/
|
|
261
|
+
function findInvokerIn(node: JxElement): JxElement | null {
|
|
262
|
+
if (String(node.tagName ?? "").toLowerCase() === "details") {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
if (isDisclosureInvoker(node)) {
|
|
266
|
+
return node;
|
|
267
|
+
}
|
|
268
|
+
for (const child of elementChildren(node)) {
|
|
269
|
+
const found = findInvokerIn(child);
|
|
270
|
+
if (found) {
|
|
271
|
+
return found;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rewrite a captured dropdown, mega-menu or drawer into a real HTML popover.
|
|
3
|
+
*
|
|
4
|
+
* The importer had no concept of an overlay, and the failure was quiet rather than loud: the markup
|
|
5
|
+
* all survived — `aria-haspopup`, `aria-controls`, `data-toggle-panel` and the panel itself — but
|
|
6
|
+
* the panel was captured in its CLOSED state and that state was written down as an unconditional
|
|
7
|
+
* style. The reference corpus emitted its navigation submenu as `{ display: grid, opacity: 0,
|
|
8
|
+
* position: absolute, zIndex: 10 }`, with nothing anywhere left to change the opacity. The menu
|
|
9
|
+
* could never appear again.
|
|
10
|
+
*
|
|
11
|
+
* The target is the platform's own popover, which `@jxsuite/schema` already models. Two of its
|
|
12
|
+
* rules are the two defects that have actually shipped in this repo, so they are obeyed here by
|
|
13
|
+
* construction rather than checked afterwards:
|
|
14
|
+
*
|
|
15
|
+
* 1. **A popover's base rule may not set `display` or `visibility`.** The UA sheet closes a popover
|
|
16
|
+
* with `[popover]:not(:popover-open) { display: none }`, and that rule is UA-origin, so any
|
|
17
|
+
* author `display` beats it and the panel is laid out whether open or not. The captured
|
|
18
|
+
* declarations move into `:popover-open`, which is where they were always describing.
|
|
19
|
+
* 2. **Only `<button>` and `<input>` can invoke one.** `popovertarget` comes from an IDL mixin
|
|
20
|
+
* included into those two interfaces and nothing else; on an `<a>` it parses and does nothing.
|
|
21
|
+
* An anchor that is really a button (`href="#"`) is converted into one. An anchor that actually
|
|
22
|
+
* navigates is left alone, because turning a link into a button would break the link.
|
|
23
|
+
*
|
|
24
|
+
* `opacity` is handled here rather than by `popoverDisplayRepair`, which lifts only `display` and
|
|
25
|
+
* `visibility`. The corpus's mega-menu is concealed with `opacity: 0` alone, so a repair that
|
|
26
|
+
* ignored it would produce a popover that opens into nothing.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { JxElement } from "@jxsuite/schema/types";
|
|
30
|
+
import { POPOVER_DEFAULT_MODE, POPOVER_INVOKER_TAGS } from "@jxsuite/schema/overlays";
|
|
31
|
+
|
|
32
|
+
/** Attributes naming the panel an invoker controls, in the order they are trusted. */
|
|
33
|
+
const TARGET_ATTRIBUTES = ["aria-controls", "data-toggle-panel", "data-target", "href"];
|
|
34
|
+
|
|
35
|
+
/** Attributes that mark a control as opening something, none of which is required on its own. */
|
|
36
|
+
const INVOKER_MARKERS = ["aria-haspopup", "aria-expanded", "data-toggle-panel", "data-target"];
|
|
37
|
+
|
|
38
|
+
/** Declarations that mean "this panel is currently closed" rather than "this is its design". */
|
|
39
|
+
const CONCEALING = new Set(["display", "visibility", "opacity"]);
|
|
40
|
+
|
|
41
|
+
/** A concealed panel is only a popover if it is also lifted out of the flow. */
|
|
42
|
+
const POSITIONED = new Set(["absolute", "fixed"]);
|
|
43
|
+
|
|
44
|
+
function styleOf(node: JxElement): Record<string, unknown> | undefined {
|
|
45
|
+
return node.style as Record<string, unknown> | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function attributesOf(node: JxElement): Record<string, unknown> | undefined {
|
|
49
|
+
return node.attributes as Record<string, unknown> | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function childrenOf(node: JxElement): JxElement[] {
|
|
53
|
+
return Array.isArray(node.children)
|
|
54
|
+
? (node.children.filter((child) => typeof child !== "string") as JxElement[])
|
|
55
|
+
: [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether a style hides the node outright, and by which declaration. */
|
|
59
|
+
function concealedBy(style: Record<string, unknown> | undefined): string[] {
|
|
60
|
+
if (!style) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
const found: string[] = [];
|
|
64
|
+
if (style["display"] === "none") {
|
|
65
|
+
found.push("display");
|
|
66
|
+
}
|
|
67
|
+
if (style["visibility"] === "hidden" || style["visibility"] === "collapse") {
|
|
68
|
+
found.push("visibility");
|
|
69
|
+
}
|
|
70
|
+
const { opacity } = style;
|
|
71
|
+
if (opacity === 0 || opacity === "0") {
|
|
72
|
+
found.push("opacity");
|
|
73
|
+
}
|
|
74
|
+
return found;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether this node looks like an overlay panel rather than ordinary hidden content.
|
|
79
|
+
*
|
|
80
|
+
* Concealment alone is not enough and must not be: a responsive alternate, a decorative SVG
|
|
81
|
+
* definition and a closed accordion row are all hidden too, and staples any of them permanently
|
|
82
|
+
* over the page if mistaken for a popover. Being lifted out of the flow is what distinguishes a
|
|
83
|
+
* thing drawn ON TOP from a thing not drawn at all — with `role="dialog"` and `inert` accepted as
|
|
84
|
+
* explicit statements of the same intent.
|
|
85
|
+
*/
|
|
86
|
+
export function looksLikePanel(node: JxElement): boolean {
|
|
87
|
+
const style = styleOf(node);
|
|
88
|
+
if (concealedBy(style).length === 0) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
const attributes = attributesOf(node);
|
|
92
|
+
if (attributes?.["role"] === "dialog" || attributes?.["inert"] !== undefined) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
return POSITIONED.has(String(style?.["position"] ?? ""));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The id an invoker names, from whichever attribute carries it. */
|
|
99
|
+
export function targetIdOf(node: JxElement): string | null {
|
|
100
|
+
const attributes = attributesOf(node);
|
|
101
|
+
if (!attributes) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
for (const name of TARGET_ATTRIBUTES) {
|
|
105
|
+
const raw = attributes[name];
|
|
106
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const id = raw.startsWith("#") ? raw.slice(1) : name === "href" ? "" : raw;
|
|
110
|
+
if (id.length > 0 && /^[A-Za-z][\w:.-]*$/.test(id)) {
|
|
111
|
+
return id;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Whether this element is a control that opens something. */
|
|
118
|
+
function looksLikeInvoker(node: JxElement): boolean {
|
|
119
|
+
const tag = String(node.tagName ?? "").toLowerCase();
|
|
120
|
+
if (tag !== "button" && tag !== "input" && tag !== "a") {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
const attributes = attributesOf(node);
|
|
124
|
+
if (!attributes) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
return (
|
|
128
|
+
INVOKER_MARKERS.some((name) => attributes[name] !== undefined) || targetIdOf(node) !== null
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Every element in the tree that carries an `id`, so a control can be paired with its panel. */
|
|
133
|
+
function indexById(root: JxElement): Map<string, JxElement> {
|
|
134
|
+
const index = new Map<string, JxElement>();
|
|
135
|
+
const visit = (node: JxElement): void => {
|
|
136
|
+
const id = attributesOf(node)?.["id"];
|
|
137
|
+
if (typeof id === "string" && !index.has(id)) {
|
|
138
|
+
index.set(id, node);
|
|
139
|
+
}
|
|
140
|
+
for (const child of childrenOf(node)) {
|
|
141
|
+
visit(child);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
visit(root);
|
|
145
|
+
return index;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The panel an invoker opens, when no id names it.
|
|
150
|
+
*
|
|
151
|
+
* Takes the ancestry INCLUDING the invoker, so the invoker's own next sibling is considered first
|
|
152
|
+
* and each ancestor's after it.
|
|
153
|
+
*
|
|
154
|
+
* The corpus's mega-menu is the case this exists for: the trigger sits inside a `<span>` and the
|
|
155
|
+
* panel is that span's next sibling, so the panel is the trigger's UNCLE. Walking up from the
|
|
156
|
+
* invoker and checking each ancestor's next sibling finds it, where "the invoker's own next
|
|
157
|
+
* sibling" finds nothing on any of the site's four dropdowns.
|
|
158
|
+
*/
|
|
159
|
+
function panelBesideAncestor(ancestry: readonly JxElement[]): JxElement | null {
|
|
160
|
+
for (let depth = ancestry.length - 1; depth >= 0; depth -= 1) {
|
|
161
|
+
const node = ancestry[depth]!;
|
|
162
|
+
const parent = ancestry[depth - 1];
|
|
163
|
+
if (!parent) {
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
const siblings = childrenOf(parent);
|
|
167
|
+
const next = siblings[siblings.indexOf(node) + 1];
|
|
168
|
+
if (next && looksLikePanel(next)) {
|
|
169
|
+
return next;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The value a concealed property takes when the panel is OPEN.
|
|
177
|
+
*
|
|
178
|
+
* `opacity: 0` and `visibility: hidden` each have an exact inverse, so the open rule can state it
|
|
179
|
+
* rather than rely on a default — which is what makes a transition possible and what stops the
|
|
180
|
+
* schema's `no-open-rule` warning firing on a panel that is genuinely fine.
|
|
181
|
+
*
|
|
182
|
+
* `display: none` has NO inverse. The captured value erased whatever the panel used when open, and
|
|
183
|
+
* writing `block` there would be inventing a fact: the element's own default is the honest answer,
|
|
184
|
+
* so nothing is written and the warning is accepted.
|
|
185
|
+
*/
|
|
186
|
+
function openValueFor(property: string, closed: unknown): string | null {
|
|
187
|
+
if (property === "opacity" && (closed === 0 || closed === "0")) {
|
|
188
|
+
return "1";
|
|
189
|
+
}
|
|
190
|
+
if (property === "visibility" && (closed === "hidden" || closed === "collapse")) {
|
|
191
|
+
return "visible";
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Move a panel's closed-state declarations into `:popover-open`, where they describe the open one. */
|
|
197
|
+
function liftConcealment(node: JxElement): void {
|
|
198
|
+
const style = styleOf(node);
|
|
199
|
+
if (!style) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const open = (style[":popover-open"] as Record<string, unknown> | undefined) ?? {};
|
|
203
|
+
for (const property of Object.keys(style)) {
|
|
204
|
+
if (!CONCEALING.has(property)) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const value = style[property];
|
|
208
|
+
const isConcealment = concealedBy({ [property]: value }).length > 0;
|
|
209
|
+
/* A concealment is replaced by its inverse where it has one; anything else was the panel's
|
|
210
|
+
design and is carried across unchanged, because the base rule is no longer where it belongs. */
|
|
211
|
+
const openValue = isConcealment ? openValueFor(property, value) : value;
|
|
212
|
+
if (openValue !== null) {
|
|
213
|
+
(open as Record<string, unknown>)[property] = openValue;
|
|
214
|
+
}
|
|
215
|
+
delete style[property];
|
|
216
|
+
}
|
|
217
|
+
if (Object.keys(open).length > 0) {
|
|
218
|
+
(style as Record<string, unknown>)[":popover-open"] = open;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface PopoverResult {
|
|
223
|
+
/** How many invoker/panel pairs were converted. */
|
|
224
|
+
converted: number;
|
|
225
|
+
/** Anchors left alone because converting them would have broken a real link. */
|
|
226
|
+
skippedNavigatingLinks: number;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Convert every recognisable overlay in a page tree into a native popover.
|
|
231
|
+
*
|
|
232
|
+
* @param {JxElement} root - The page tree, mutated in place
|
|
233
|
+
* @returns {PopoverResult}
|
|
234
|
+
*/
|
|
235
|
+
export function applyPopovers(root: JxElement): PopoverResult {
|
|
236
|
+
const result: PopoverResult = { converted: 0, skippedNavigatingLinks: 0 };
|
|
237
|
+
const byId = indexById(root);
|
|
238
|
+
const claimed = new Set<JxElement>();
|
|
239
|
+
let generated = 0;
|
|
240
|
+
|
|
241
|
+
const visit = (node: JxElement, ancestry: JxElement[]): void => {
|
|
242
|
+
if (looksLikeInvoker(node)) {
|
|
243
|
+
const id = targetIdOf(node);
|
|
244
|
+
const panel = (id === null ? null : byId.get(id)) ?? panelBesideAncestor([...ancestry, node]);
|
|
245
|
+
|
|
246
|
+
if (panel && panel !== node && !claimed.has(panel) && looksLikePanel(panel)) {
|
|
247
|
+
const tag = String(node.tagName ?? "").toLowerCase();
|
|
248
|
+
const attributes = (node.attributes ??= {}) as Record<string, unknown>;
|
|
249
|
+
|
|
250
|
+
/* `popovertarget` exists only on button and input. An anchor that is really a button
|
|
251
|
+
becomes one; an anchor that navigates keeps its job, because a dead link is a worse
|
|
252
|
+
defect than an unconverted menu. */
|
|
253
|
+
if (!POPOVER_INVOKER_TAGS.has(tag)) {
|
|
254
|
+
const { href } = attributes;
|
|
255
|
+
const navigates = typeof href === "string" && href.length > 0 && href !== "#";
|
|
256
|
+
if (navigates) {
|
|
257
|
+
result.skippedNavigatingLinks += 1;
|
|
258
|
+
for (const child of childrenOf(node)) {
|
|
259
|
+
visit(child, [...ancestry, node]);
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
node.tagName = "button";
|
|
264
|
+
attributes["type"] = "button";
|
|
265
|
+
delete attributes["href"];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const panelAttributes = (panel.attributes ??= {}) as Record<string, unknown>;
|
|
269
|
+
let { id: panelId } = panelAttributes;
|
|
270
|
+
if (typeof panelId !== "string" || panelId.length === 0) {
|
|
271
|
+
generated += 1;
|
|
272
|
+
panelId = `jx-popover-${generated}`;
|
|
273
|
+
panelAttributes["id"] = panelId;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
panelAttributes["popover"] = POPOVER_DEFAULT_MODE;
|
|
277
|
+
delete panelAttributes["inert"];
|
|
278
|
+
delete panelAttributes["aria-hidden"];
|
|
279
|
+
liftConcealment(panel);
|
|
280
|
+
|
|
281
|
+
attributes["popovertarget"] = panelId;
|
|
282
|
+
/* The browser maintains `aria-expanded` on a popover invoker itself; a captured literal
|
|
283
|
+
would be a second, stale writer of the same fact. */
|
|
284
|
+
delete attributes["aria-expanded"];
|
|
285
|
+
|
|
286
|
+
claimed.add(panel);
|
|
287
|
+
result.converted += 1;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
for (const child of childrenOf(node)) {
|
|
292
|
+
visit(child, [...ancestry, node]);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
visit(root, []);
|
|
297
|
+
return result;
|
|
298
|
+
}
|
package/src/apply-styles.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
import type { JxElement, JxStyle } from "@jxsuite/schema/types";
|
|
7
7
|
import type { DiffedStyle } from "./style-diff.ts";
|
|
8
|
+
import { parseWidthQuery } from "./breakpoint-plan.ts";
|
|
9
|
+
import { dropDerivedGeometry } from "./derived-geometry.ts";
|
|
8
10
|
|
|
9
11
|
/** Build a lookup from path key → style object for fast application. */
|
|
10
12
|
function buildStyleMap(diffed: DiffedStyle[]): Map<string, Record<string, string | number>> {
|
|
@@ -15,6 +17,43 @@ function buildStyleMap(diffed: DiffedStyle[]): Map<string, Record<string, string
|
|
|
15
17
|
return map;
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The order breakpoint keys must be WRITTEN in, so the cascade resolves the way the source site
|
|
22
|
+
* did.
|
|
23
|
+
*
|
|
24
|
+
* A node's `@--name` keys become `@media` blocks at equal specificity, and equal specificity means
|
|
25
|
+
* the LAST matching rule wins. So the order is not cosmetic: with `--767` written before `--1024`,
|
|
26
|
+
* a 500px viewport matches both and takes the 1024 rule, which is the wrong one. Breakpoints are
|
|
27
|
+
* planned and iterated ascending, so every multi-breakpoint node came out inverted.
|
|
28
|
+
*
|
|
29
|
+
* Correct order is per direction. `max-width` narrows downward, so the NARROWER query must come
|
|
30
|
+
* last to win at narrow viewports: descending. `min-width` widens upward, so the WIDER query must
|
|
31
|
+
* come last: ascending. A query neither shape (or one the planner could not read) keeps its
|
|
32
|
+
* relative order at the end, where it cannot displace a rule that was understood.
|
|
33
|
+
*
|
|
34
|
+
* This is deliberately NOT the order `$media` itself is written in — `emit.ts` sorts that map
|
|
35
|
+
* ascending for Studio's Contexts list, and a lookup table has no cascade to get wrong.
|
|
36
|
+
*/
|
|
37
|
+
export function orderBreakpointKeys(
|
|
38
|
+
names: readonly string[],
|
|
39
|
+
queries: Record<string, string> | undefined,
|
|
40
|
+
): string[] {
|
|
41
|
+
const rank = (name: string): { band: number; width: number } => {
|
|
42
|
+
const parsed = queries?.[name] === undefined ? null : parseWidthQuery(queries[name]!);
|
|
43
|
+
if (!parsed) {
|
|
44
|
+
return { band: 2, width: 0 };
|
|
45
|
+
}
|
|
46
|
+
return parsed.type === "max"
|
|
47
|
+
? { band: 0, width: -parsed.width }
|
|
48
|
+
: { band: 1, width: parsed.width };
|
|
49
|
+
};
|
|
50
|
+
return names.toSorted((a, b) => {
|
|
51
|
+
const ra = rank(a);
|
|
52
|
+
const rb = rank(b);
|
|
53
|
+
return ra.band === rb.band ? ra.width - rb.width : ra.band - rb.band;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
18
57
|
/**
|
|
19
58
|
* Apply base styles and $media deltas to all nodes in the Jx tree.
|
|
20
59
|
*
|
|
@@ -26,12 +65,13 @@ export function applyStylesToTree(
|
|
|
26
65
|
root: JxElement,
|
|
27
66
|
baseStyles: DiffedStyle[],
|
|
28
67
|
mediaDeltas?: Record<string, DiffedStyle[]>,
|
|
68
|
+
breakpointQueries?: Record<string, string>,
|
|
29
69
|
): void {
|
|
30
70
|
const baseMap = buildStyleMap(baseStyles);
|
|
31
|
-
const mediaMaps:
|
|
71
|
+
const mediaMaps: [string, Map<string, Record<string, string | number>>][] = [];
|
|
32
72
|
if (mediaDeltas) {
|
|
33
|
-
for (const
|
|
34
|
-
mediaMaps[bpName
|
|
73
|
+
for (const bpName of orderBreakpointKeys(Object.keys(mediaDeltas), breakpointQueries)) {
|
|
74
|
+
mediaMaps.push([bpName, buildStyleMap(mediaDeltas[bpName]!)]);
|
|
35
75
|
}
|
|
36
76
|
}
|
|
37
77
|
|
|
@@ -55,24 +95,33 @@ function walkAndApply(
|
|
|
55
95
|
node: JxElement,
|
|
56
96
|
path: number[],
|
|
57
97
|
baseMap: Map<string, Record<string, string | number>>,
|
|
58
|
-
mediaMaps:
|
|
98
|
+
mediaMaps: readonly [string, Map<string, Record<string, string | number>>][],
|
|
59
99
|
): void {
|
|
60
100
|
const key = path.join(",");
|
|
61
101
|
|
|
62
|
-
|
|
102
|
+
/* Measured geometry is filtered against the NODE, because the question is structural: a
|
|
103
|
+
block-level box in normal flow fills its container, so its measured width states a fact the
|
|
104
|
+
layout already implies and pinning it is what stops the clone reflowing. The copy is per node
|
|
105
|
+
because the same captured style object is shared between every instance of a component. */
|
|
63
106
|
const baseStyle = baseMap.get(key);
|
|
64
107
|
if (baseStyle) {
|
|
65
|
-
|
|
108
|
+
const filtered = { ...baseStyle };
|
|
109
|
+
dropDerivedGeometry(node, filtered);
|
|
110
|
+
node.style = mergeStyle(node.style, filtered);
|
|
66
111
|
}
|
|
67
112
|
|
|
68
113
|
// Apply media-responsive deltas as @breakpointName nested objects
|
|
69
|
-
for (const [bpName, deltaMap] of
|
|
114
|
+
for (const [bpName, deltaMap] of mediaMaps) {
|
|
70
115
|
const delta = deltaMap.get(key);
|
|
71
|
-
if (delta
|
|
72
|
-
|
|
73
|
-
|
|
116
|
+
if (delta) {
|
|
117
|
+
const filtered = { ...delta };
|
|
118
|
+
dropDerivedGeometry(node, filtered, (node.style ?? {}) as Record<string, unknown>);
|
|
119
|
+
if (Object.keys(filtered).length > 0) {
|
|
120
|
+
if (!node.style) {
|
|
121
|
+
node.style = {};
|
|
122
|
+
}
|
|
123
|
+
(node.style as Record<string, unknown>)[`@${bpName}`] = filtered;
|
|
74
124
|
}
|
|
75
|
-
(node.style as Record<string, unknown>)[`@${bpName}`] = delta;
|
|
76
125
|
}
|
|
77
126
|
}
|
|
78
127
|
|