@jxsuite/schema 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/defs/children-value.schema.ts +13 -0
- package/defs/element-def.schema.ts +7 -4
- package/defs/statement.schema.ts +29 -2
- package/defs/style-object.schema.ts +52 -3
- package/package.json +9 -4
- package/project-schema.json +24 -3
- package/schema.json +5065 -13
- package/schemas/project.core.schema.json +24 -3
- package/src/a11y.ts +760 -0
- package/src/dialogs.ts +428 -0
- package/src/doc-ops.ts +348 -0
- package/src/guards.ts +43 -5
- package/src/json-layout.ts +474 -0
- package/src/overlays.ts +825 -0
- package/src/schema.ts +28 -4
- package/types.ts +26 -2
package/src/overlays.ts
ADDED
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Popover correctness rules — one implementation, shared by every surface that judges a document.
|
|
3
|
+
*
|
|
4
|
+
* The HTML Popover API is expressed in Jx entirely through `attributes` and nested `style` keys, so
|
|
5
|
+
* nothing in the toolchain had an opinion about it and three shipped sites drifted into three
|
|
6
|
+
* different wrong answers. These rules are that opinion, and they live in `@jxsuite/schema` because
|
|
7
|
+
* they are facts about a document TREE — no DOM, no renderer, no cascade — which is what lets the
|
|
8
|
+
* Studio's Problems report, `jx build` and the starter conformance test all reach them without any
|
|
9
|
+
* of the three depending on the other two.
|
|
10
|
+
*
|
|
11
|
+
* Two findings here are not intuitions; they are the two defects that actually shipped:
|
|
12
|
+
*
|
|
13
|
+
* 1. **`display` in a popover's base rule.** The UA sheet hides a closed popover with
|
|
14
|
+
* `[popover]:not(:popover-open) { display: none }`. That rule is UA-origin, so ANY author
|
|
15
|
+
* `display` beats it at any specificity, and the panel is laid out on every page whether open or
|
|
16
|
+
* not — usually parked off the side of the viewport by the `transform` that was meant to animate
|
|
17
|
+
* it. Nothing looks wrong until you scroll sideways or read the page with a screen reader.
|
|
18
|
+
* 2. **`popovertarget` on an `<a>`.** `popovertarget` and `popovertargetaction` come from the
|
|
19
|
+
* `PopoverTargetAttributes` IDL mixin, which HTML includes into `HTMLButtonElement` and
|
|
20
|
+
* `HTMLInputElement` and into NOTHING else. On any other element they parse and do nothing. They
|
|
21
|
+
* look like they work because a link inside an open popover dismisses it by navigating away — an
|
|
22
|
+
* alibi that fails for `target="_blank"` and for a same-document `#hash`, both of which exist in
|
|
23
|
+
* the fleet today.
|
|
24
|
+
*
|
|
25
|
+
* @docs framework/concepts/overlays
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { getNestedStyle, isJsonObject, isNestedStyle } from "./guards";
|
|
29
|
+
import type { JxElement, JxStyle } from "../types";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The three keywords the `popover` attribute accepts.
|
|
33
|
+
*
|
|
34
|
+
* It is an ENUMERATED attribute, not a boolean one: the missing-value default is `auto` (so a bare
|
|
35
|
+
* `popover` and `popover=""` are both auto popovers) and the **invalid**-value default is `manual`.
|
|
36
|
+
* That asymmetry is why `popover="true"` is a silent defect rather than a loud one — it parses, and
|
|
37
|
+
* the popover simply stops light-dismissing and stops answering Escape.
|
|
38
|
+
*/
|
|
39
|
+
export const POPOVER_MODES = ["auto", "manual", "hint"] as const;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The only two elements that can invoke a popover.
|
|
43
|
+
*
|
|
44
|
+
* Verified against the committed WHATWG IDL (`@webref/idl/html.idl`): `PopoverTargetAttributes` is
|
|
45
|
+
* `includes`d into `HTMLInputElement` and `HTMLButtonElement`, and no third interface.
|
|
46
|
+
*/
|
|
47
|
+
export const POPOVER_INVOKER_TAGS: ReadonlySet<string> = new Set(["button", "input"]);
|
|
48
|
+
|
|
49
|
+
/** The house spelling. `""` means the same thing but renders as "unset" in the Studio inspector. */
|
|
50
|
+
export const POPOVER_DEFAULT_MODE = "auto";
|
|
51
|
+
|
|
52
|
+
/** Every rule this module can report. One id per defect, stable enough for a Problem key. */
|
|
53
|
+
export type PopoverRule =
|
|
54
|
+
| "base-display"
|
|
55
|
+
| "breakpoint-display"
|
|
56
|
+
| "base-visibility"
|
|
57
|
+
| "invoker-not-button"
|
|
58
|
+
| "invalid-mode"
|
|
59
|
+
| "target-missing"
|
|
60
|
+
| "target-mismatch"
|
|
61
|
+
| "no-invoker"
|
|
62
|
+
| "no-open-rule"
|
|
63
|
+
| "no-open-display"
|
|
64
|
+
| "cut-exit"
|
|
65
|
+
| "link-does-not-dismiss"
|
|
66
|
+
| "unsafe-anchor";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The mechanical repair a finding can carry, or absent when the fix is a judgement.
|
|
70
|
+
*
|
|
71
|
+
* `display` moves the declaration into `:popover-open`; `open-display` adds the one the layout
|
|
72
|
+
* implies; `invoker` removes two attributes that do nothing; `mode` writes the house spelling.
|
|
73
|
+
* Everything else — which popover a link should target, whether a `#hash` belongs in a drawer — is
|
|
74
|
+
* a content decision, and a button that guesses at one is worse than no button (ATAG B.3.2's
|
|
75
|
+
* point).
|
|
76
|
+
*/
|
|
77
|
+
export type PopoverFix = "display" | "open-display" | "invoker" | "mode";
|
|
78
|
+
|
|
79
|
+
/** A path into the document, in the same segment vocabulary the studio's `JxPath` uses. */
|
|
80
|
+
export type PopoverPath = (string | number)[];
|
|
81
|
+
|
|
82
|
+
export interface PopoverDefect {
|
|
83
|
+
rule: PopoverRule;
|
|
84
|
+
/** Where the defect is, so a repair can address the node without searching for it again. */
|
|
85
|
+
path: PopoverPath;
|
|
86
|
+
/** One sentence naming what is wrong, in the author's language. */
|
|
87
|
+
message: string;
|
|
88
|
+
/** Why it is wrong and what to do instead. */
|
|
89
|
+
detail: string;
|
|
90
|
+
/** `error` is certainly broken; `warn` is very probably not what was meant. */
|
|
91
|
+
severity: "error" | "warn";
|
|
92
|
+
fix?: PopoverFix;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Reading a node ─────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/** The attribute bag, whatever shape the author wrote it in. */
|
|
98
|
+
export function attrs(node: JxElement): Record<string, unknown> {
|
|
99
|
+
return isJsonObject(node.attributes) ? (node.attributes as Record<string, unknown>) : {};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** An attribute's literal string value, or null when absent or bound. */
|
|
103
|
+
export function literalAttr(node: JxElement, name: string): string | null {
|
|
104
|
+
const value = attrs(node)[name];
|
|
105
|
+
return typeof value === "string" ? value : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The element's tag, lowercased, or "" when it is a tag expression or absent. */
|
|
109
|
+
export function tagOf(node: JxElement): string {
|
|
110
|
+
return typeof node.tagName === "string" ? node.tagName.toLowerCase() : "";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** How the author addresses this node in a message: its id if it has one, else its tag. */
|
|
114
|
+
export function labelOf(node: JxElement, fallback = "the popover"): string {
|
|
115
|
+
const id = typeof node.id === "string" ? node.id : literalAttr(node, "id");
|
|
116
|
+
if (id) {
|
|
117
|
+
return `#${id}`;
|
|
118
|
+
}
|
|
119
|
+
const tag = tagOf(node);
|
|
120
|
+
return tag === "" ? fallback : `the <${tag}>`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The popover mode this node declares, or null when it is not a popover.
|
|
125
|
+
*
|
|
126
|
+
* Returns the RAW value rather than a normalised one, because the difference between `""`, `"auto"`
|
|
127
|
+
* and `true` is exactly what two of the rules below are about. A bound value (`{"$ref": …}` or a
|
|
128
|
+
* `"${…}"` template) answers `null` for the mode but still counts as a popover — see
|
|
129
|
+
* {@link isPopover} — because the lint must not accuse an author of a defect it cannot see.
|
|
130
|
+
*/
|
|
131
|
+
export function popoverModeOf(node: JxElement): string | null {
|
|
132
|
+
const raw = attrs(node).popover;
|
|
133
|
+
return typeof raw === "string" ? raw : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* What a caller knows about the custom elements in scope, which the schema cannot know by itself.
|
|
138
|
+
*
|
|
139
|
+
* A kit element's `popover` attribute lives in its DEFINITION, so a consumer's node is `{"tagName":
|
|
140
|
+
* "jx-popover", "attributes": {"id": "p1"}}` and every structural rule here read it as an ordinary
|
|
141
|
+
* `<div>`: a command aimed at it was a target mismatch, `documentHasPopover` was false, and an
|
|
142
|
+
* author who dropped one on a page could not open it on the canvas at all. The tags come in rather
|
|
143
|
+
* than out, so the schema keeps no dependency on any particular kit.
|
|
144
|
+
*/
|
|
145
|
+
export interface OverlayScope {
|
|
146
|
+
/** Tags whose definition declares `popover`. Studio passes the kit's; the compiler the project's. */
|
|
147
|
+
popoverTags?: ReadonlySet<string>;
|
|
148
|
+
/**
|
|
149
|
+
* Tags whose definition FORWARDS the invoker attributes to a native button inside itself.
|
|
150
|
+
*
|
|
151
|
+
* `popovertarget` and `commandfor` come from an IDL mixin HTML includes into `HTMLButtonElement`
|
|
152
|
+
* and `HTMLInputElement` and nothing else, so on any other tag they parse and do nothing — which
|
|
153
|
+
* is what `invoker-not-button` is for. A custom element that observes them and passes them to its
|
|
154
|
+
* own inner `<button>` is the exception, and without this the rule fires on the natural spelling
|
|
155
|
+
* of the kit's own documented usage.
|
|
156
|
+
*/
|
|
157
|
+
invokerTags?: ReadonlySet<string>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The invoker attributes an element must observe to be forwarding them to a native button. */
|
|
161
|
+
const INVOKER_ATTRS = ["popovertarget", "popovertargetaction", "command", "commandfor"] as const;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Derive an {@link OverlayScope} from the element definitions a document may use.
|
|
165
|
+
*
|
|
166
|
+
* One definition of what makes a tag each kind, so the kit, `jx validate` and the studio cannot
|
|
167
|
+
* disagree about the same project. A definition IS a popover when its own root carries `popover`,
|
|
168
|
+
* and it FORWARDS invocation when it observes all four invoker attributes — which is the only
|
|
169
|
+
* evidence available from the document alone, and is what a forwarding element must do to receive
|
|
170
|
+
* them at all.
|
|
171
|
+
*
|
|
172
|
+
* @param definitions The element documents in scope.
|
|
173
|
+
* @returns {OverlayScope} The two tag sets, ready to pass to any rule here.
|
|
174
|
+
*/
|
|
175
|
+
export function overlayScopeFor(definitions: Iterable<JxElement>): OverlayScope {
|
|
176
|
+
const popoverTags = new Set<string>();
|
|
177
|
+
const invokerTags = new Set<string>();
|
|
178
|
+
for (const def of definitions) {
|
|
179
|
+
const tag = typeof def?.tagName === "string" ? def.tagName : "";
|
|
180
|
+
if (!tag.includes("-")) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (declaresPopover(def)) {
|
|
184
|
+
popoverTags.add(tag);
|
|
185
|
+
}
|
|
186
|
+
const observed = (def as { observedAttributes?: unknown }).observedAttributes;
|
|
187
|
+
if (Array.isArray(observed) && INVOKER_ATTRS.every((attr) => observed.includes(attr))) {
|
|
188
|
+
invokerTags.add(tag);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { invokerTags, popoverTags };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Whether the node ITSELF declares `popover`, however it was written.
|
|
196
|
+
*
|
|
197
|
+
* The style rules are judged against this rather than {@link isPopover}, because they read the
|
|
198
|
+
* style the node carries and a consumer's `<jx-popover>` carries none — the definition owns it. A
|
|
199
|
+
* scope-aware check there would report `no-open-rule` on every correct consumer, and that rule is a
|
|
200
|
+
* warning the conformance suites assert away entirely.
|
|
201
|
+
*/
|
|
202
|
+
export function declaresPopover(node: JxElement): boolean {
|
|
203
|
+
/* BOTH spellings, which is what "however it was written" has to mean. `popover` is not a reserved
|
|
204
|
+
key, so a top-level `"popover": "auto"` reaches `bindProperty` and is set as the DOM PROPERTY —
|
|
205
|
+
a real popover, reflected back to the attribute — and reading only the bag left one invisible
|
|
206
|
+
to every rule here: no `base-display` finding, no `no-open-rule` finding, and its tag missing
|
|
207
|
+
from the scope, so consumers of that definition went unrecognised too. `idOf` below already
|
|
208
|
+
reads a key either way, for the same reason. */
|
|
209
|
+
return "popover" in attrs(node) || "popover" in (node as Record<string, unknown>);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Whether the node IS a popover: it declares `popover`, or its tag names a definition that does.
|
|
214
|
+
*
|
|
215
|
+
* This is the structural question — what an id refers to, what an invoker may target, what a node
|
|
216
|
+
* is enclosed by — and a kit popover answers yes to all three.
|
|
217
|
+
*/
|
|
218
|
+
export function isPopover(node: JxElement, scope?: OverlayScope): boolean {
|
|
219
|
+
return declaresPopover(node) || (scope?.popoverTags?.has(tagOf(node)) ?? false);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The node's own `id`, from either the top-level key or the attribute bag. */
|
|
223
|
+
export function idOf(node: JxElement): string | null {
|
|
224
|
+
if (typeof node.id === "string" && node.id !== "") {
|
|
225
|
+
return node.id;
|
|
226
|
+
}
|
|
227
|
+
const attr = literalAttr(node, "id");
|
|
228
|
+
return attr === "" ? null : attr;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ─── Walking a document ─────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
export interface Visit {
|
|
234
|
+
node: JxElement;
|
|
235
|
+
path: PopoverPath;
|
|
236
|
+
/** The nearest enclosing popover, so an invoker can be judged against the panel it sits in. */
|
|
237
|
+
enclosing: JxElement | null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Every element in the tree, with its path, in document order.
|
|
242
|
+
*
|
|
243
|
+
* Descends `children` (including a repeater's `map` template) and `$switch` `cases`, matching the
|
|
244
|
+
* reach of `a11y-report.ts`'s own walk. The path segments are the ones the studio's mutators
|
|
245
|
+
* address a node by, so a repair can be applied without a second search.
|
|
246
|
+
*
|
|
247
|
+
* @param root The document or subtree to walk.
|
|
248
|
+
* @yields {Visit} Each element, its path, and the popover it is inside (if any).
|
|
249
|
+
*/
|
|
250
|
+
export function* walk(
|
|
251
|
+
root: JxElement,
|
|
252
|
+
path: PopoverPath = [],
|
|
253
|
+
enclosing: JxElement | null = null,
|
|
254
|
+
scope?: OverlayScope,
|
|
255
|
+
): Generator<Visit> {
|
|
256
|
+
yield { enclosing, node: root, path };
|
|
257
|
+
const inside = isPopover(root, scope) ? root : enclosing;
|
|
258
|
+
const { children } = root;
|
|
259
|
+
if (Array.isArray(children)) {
|
|
260
|
+
for (const [index, child] of children.entries()) {
|
|
261
|
+
if (child && typeof child === "object") {
|
|
262
|
+
yield* walk(child as JxElement, [...path, "children", index], inside, scope);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
} else if (isJsonObject(children) && isJsonObject((children as JxElement).map)) {
|
|
266
|
+
yield* walk(
|
|
267
|
+
(children as JxElement).map as JxElement,
|
|
268
|
+
[...path, "children", "map"],
|
|
269
|
+
inside,
|
|
270
|
+
scope,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
const template = isJsonObject(root.map) ? (root.map as JxElement) : null;
|
|
274
|
+
if (template) {
|
|
275
|
+
yield* walk(template, [...path, "map"], inside, scope);
|
|
276
|
+
}
|
|
277
|
+
const cases = isJsonObject(root.cases) ? (root.cases as Record<string, JxElement>) : {};
|
|
278
|
+
for (const [key, branch] of Object.entries(cases)) {
|
|
279
|
+
if (branch && typeof branch === "object") {
|
|
280
|
+
yield* walk(branch, [...path, "cases", key], inside, scope);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Every `id` in the document that carries `popover`.
|
|
287
|
+
*
|
|
288
|
+
* The Studio's `popovertarget` picker offers exactly this list. It is the OPEN DOCUMENT only, and
|
|
289
|
+
* deliberately so: a `popovertarget` resolves in the rendered DOM, and a page composes components
|
|
290
|
+
* whose internals this document cannot see. The picker therefore offers what it can prove and never
|
|
291
|
+
* refuses what it cannot — which is also why {@link findPopoverDefects}'s `target-missing` rule only
|
|
292
|
+
* fires in a document that declares at least one popover of its own.
|
|
293
|
+
*
|
|
294
|
+
* @param doc The document to scan.
|
|
295
|
+
* @returns The ids, in document order, without duplicates.
|
|
296
|
+
*/
|
|
297
|
+
export function popoverIdsIn(doc: JxElement, scope?: OverlayScope): string[] {
|
|
298
|
+
const ids: string[] = [];
|
|
299
|
+
for (const { node } of walk(doc, [], null, scope)) {
|
|
300
|
+
if (!isPopover(node, scope)) {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const id = idOf(node);
|
|
304
|
+
if (id !== null && !ids.includes(id)) {
|
|
305
|
+
ids.push(id);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return ids;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Whether the document declares any popover at all.
|
|
313
|
+
*
|
|
314
|
+
* What "is this command available?" asks. Availability has to be a fact about the DOCUMENT rather
|
|
315
|
+
* than about the selection: a caller may name a popover explicitly — the palette, the assistant, a
|
|
316
|
+
* screenshot step — and gating on the selection would refuse those before they were read. The
|
|
317
|
+
* specific refusal ("that path is not a popover") belongs to the run, where the argument exists.
|
|
318
|
+
*
|
|
319
|
+
* @param doc The document to scan.
|
|
320
|
+
* @returns True when at least one node declares `popover`.
|
|
321
|
+
*/
|
|
322
|
+
export function documentHasPopover(doc: JxElement, scope?: OverlayScope): boolean {
|
|
323
|
+
for (const { node } of walk(doc, [], null, scope)) {
|
|
324
|
+
if (isPopover(node, scope)) {
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ─── Style inspection ───────────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
/** Whether a style key names an at-rule group (`@--md`, `@(print)`, `@starting-style`). */
|
|
334
|
+
function isAtRule(key: string): boolean {
|
|
335
|
+
return key.startsWith("@");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Whether a style key names a nested selector rather than a property. */
|
|
339
|
+
function isSelector(key: string): boolean {
|
|
340
|
+
return key.startsWith(":") || key.startsWith("&") || key.startsWith(".") || key.startsWith("[");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** A scalar style value as text, or null when the key is absent or holds a nested block. */
|
|
344
|
+
export function scalar(style: JxStyle | undefined, prop: string): string | null {
|
|
345
|
+
const value = style?.[prop];
|
|
346
|
+
/* `Array.isArray` as well as the block guard: several blocks under one key is a rule written more
|
|
347
|
+
than once (spec.md §9.4), and stringifying it gives `[object Object]` — a declaration value
|
|
348
|
+
that is not one, which every caller here would then compare against. */
|
|
349
|
+
if (value === undefined || isNestedStyle(value) || Array.isArray(value)) {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
return String(value);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Whether any nested selector under `style` declares `display`.
|
|
357
|
+
*
|
|
358
|
+
* Accepts both spellings of the open state — `":popover-open"` and `"&:popover-open"` compile to
|
|
359
|
+
* the same selector — and looks one at-rule deep, because `@--md { ":popover-open": {…} }` is a
|
|
360
|
+
* legitimate place to put a breakpoint-specific open display.
|
|
361
|
+
*/
|
|
362
|
+
function declaresOpenDisplay(style: JxStyle | undefined): boolean {
|
|
363
|
+
/* `?? {}` rather than an early return: a bare popover with no style at all reaches here, and a
|
|
364
|
+
guard clause for it would be a branch no test can distinguish from the empty-object case. */
|
|
365
|
+
for (const [key, value] of Object.entries(style ?? {})) {
|
|
366
|
+
if (!isNestedStyle(value)) {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (isSelector(key) && key.includes(":popover-open") && scalar(value, "display") !== null) {
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
if (isAtRule(key) && declaresOpenDisplay(value)) {
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Whether any nested selector under `style` is a `:popover-open` rule, whatever it declares. */
|
|
380
|
+
function declaresOpenRule(style: JxStyle | undefined): boolean {
|
|
381
|
+
/* `?? {}` rather than an early return: a bare popover with no style at all reaches here, and a
|
|
382
|
+
guard clause for it would be a branch no test can distinguish from the empty-object case. */
|
|
383
|
+
for (const [key, value] of Object.entries(style ?? {})) {
|
|
384
|
+
if (!isNestedStyle(value)) {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (isSelector(key) && key.includes(":popover-open")) {
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
if (isAtRule(key) && declaresOpenRule(value)) {
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Properties that only mean anything under `display: flex` or `display: grid`.
|
|
399
|
+
*
|
|
400
|
+
* The list is short on purpose: each of these is inert under `display: block`, so declaring one is
|
|
401
|
+
* a statement about the box the author expects. `gap` is the exception that earns its place — it
|
|
402
|
+
* applies to multi-column layout too, but on a popover panel it never means that.
|
|
403
|
+
*/
|
|
404
|
+
const BOX_LAYOUT_PROPS = [
|
|
405
|
+
"flexDirection",
|
|
406
|
+
"flex-direction",
|
|
407
|
+
"alignItems",
|
|
408
|
+
"align-items",
|
|
409
|
+
"justifyContent",
|
|
410
|
+
"justify-content",
|
|
411
|
+
"flexWrap",
|
|
412
|
+
"flex-wrap",
|
|
413
|
+
"gridTemplateColumns",
|
|
414
|
+
"grid-template-columns",
|
|
415
|
+
"gridTemplateRows",
|
|
416
|
+
"grid-template-rows",
|
|
417
|
+
"gap",
|
|
418
|
+
];
|
|
419
|
+
|
|
420
|
+
/** Whether the base rule is written for a flex or grid box. */
|
|
421
|
+
function usesBoxLayout(style: JxStyle | undefined): boolean {
|
|
422
|
+
return BOX_LAYOUT_PROPS.some((prop) => scalar(style, prop) !== null);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** The at-rule keys under `style` whose block sets `display` directly. */
|
|
426
|
+
function breakpointsSettingDisplay(style: JxStyle | undefined): string[] {
|
|
427
|
+
if (!style) {
|
|
428
|
+
return [];
|
|
429
|
+
}
|
|
430
|
+
const keys: string[] = [];
|
|
431
|
+
for (const [key, value] of Object.entries(style)) {
|
|
432
|
+
if (isAtRule(key) && isNestedStyle(value) && scalar(value, "display") !== null) {
|
|
433
|
+
keys.push(key);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return keys;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Every transition value declared on the element, base rule and at-rule blocks alike. */
|
|
440
|
+
function transitionValues(style: JxStyle | undefined): string[] {
|
|
441
|
+
if (!style) {
|
|
442
|
+
return [];
|
|
443
|
+
}
|
|
444
|
+
const out: string[] = [];
|
|
445
|
+
const own = scalar(style, "transition");
|
|
446
|
+
if (own !== null) {
|
|
447
|
+
out.push(own);
|
|
448
|
+
}
|
|
449
|
+
for (const [key, value] of Object.entries(style)) {
|
|
450
|
+
if (isAtRule(key) && isNestedStyle(value)) {
|
|
451
|
+
out.push(...transitionValues(value));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return out;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Whether the element positions itself with CSS anchor positioning. */
|
|
458
|
+
function usesAnchorPositioning(style: JxStyle | undefined): boolean {
|
|
459
|
+
if (!style) {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
if (scalar(style, "positionAnchor") !== null || scalar(style, "position-anchor") !== null) {
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
return ["top", "left", "right", "bottom", "inset", "insetBlockStart", "insetInlineStart"].some(
|
|
466
|
+
(prop) => (scalar(style, prop) ?? "").includes("anchor("),
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Whether an anchor-positioned element has something to fall back on.
|
|
472
|
+
*
|
|
473
|
+
* `position-area` and `position-try-fallbacks` degrade to nothing rather than to `auto`, and a
|
|
474
|
+
* `@supports` block can restate the position without anchors. A bare `top: anchor(bottom)` has
|
|
475
|
+
* neither: when `anchor()` does not resolve the inset computes to `auto`, and a `position: fixed`
|
|
476
|
+
* box with four `auto` insets falls back to its STATIC position — which, for a panel declared
|
|
477
|
+
* inside a header, is in the middle of the header.
|
|
478
|
+
*/
|
|
479
|
+
function hasAnchorFallback(style: JxStyle | undefined): boolean {
|
|
480
|
+
if (!style) {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
if (
|
|
484
|
+
scalar(style, "positionArea") !== null ||
|
|
485
|
+
scalar(style, "position-area") !== null ||
|
|
486
|
+
scalar(style, "positionTryFallbacks") !== null ||
|
|
487
|
+
scalar(style, "position-try-fallbacks") !== null
|
|
488
|
+
) {
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
return Object.keys(style).some((key) => key.startsWith("@supports"));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ─── The rules ──────────────────────────────────────────────────────────────────
|
|
495
|
+
|
|
496
|
+
const DOCS = "See docs/framework/concepts/overlays.";
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Every popover defect in a document, in document order.
|
|
500
|
+
*
|
|
501
|
+
* One walk. The popover ids are collected on the way, so an invoker naming an id that appears later
|
|
502
|
+
* in the tree still resolves — the rules are about the document, not about reading order.
|
|
503
|
+
*
|
|
504
|
+
* @param doc The document to check.
|
|
505
|
+
* @returns The defects, most structural first within each node.
|
|
506
|
+
*/
|
|
507
|
+
export function findPopoverDefects(doc: JxElement, scope?: OverlayScope): PopoverDefect[] {
|
|
508
|
+
const visits = [...walk(doc, [], null, scope)];
|
|
509
|
+
const ids = popoverIdsIn(doc, scope);
|
|
510
|
+
const hasAnyPopover = visits.some(({ node }) => isPopover(node, scope));
|
|
511
|
+
const targeted = new Set<string>();
|
|
512
|
+
for (const { node } of visits) {
|
|
513
|
+
if (POPOVER_INVOKER_TAGS.has(tagOf(node))) {
|
|
514
|
+
const target = literalAttr(node, "popovertarget");
|
|
515
|
+
if (target !== null) {
|
|
516
|
+
targeted.add(target);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const defects: PopoverDefect[] = [];
|
|
522
|
+
for (const { enclosing, node, path } of visits) {
|
|
523
|
+
defects.push(...invokerDefects(node, path, enclosing, ids, hasAnyPopover, scope));
|
|
524
|
+
// The node's OWN declaration, not the scope-aware one: these rules read the style this node
|
|
525
|
+
// Carries, and a consumer of a kit popover carries none.
|
|
526
|
+
if (declaresPopover(node)) {
|
|
527
|
+
defects.push(...panelDefects(node, path, targeted));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return defects;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** The rules that judge an element carrying `popovertarget`, or a link inside a panel. */
|
|
534
|
+
function invokerDefects(
|
|
535
|
+
node: JxElement,
|
|
536
|
+
path: PopoverPath,
|
|
537
|
+
enclosing: JxElement | null,
|
|
538
|
+
ids: string[],
|
|
539
|
+
hasAnyPopover: boolean,
|
|
540
|
+
scope?: OverlayScope,
|
|
541
|
+
): PopoverDefect[] {
|
|
542
|
+
const out: PopoverDefect[] = [];
|
|
543
|
+
const tag = tagOf(node);
|
|
544
|
+
const target = literalAttr(node, "popovertarget");
|
|
545
|
+
const isInvoker = POPOVER_INVOKER_TAGS.has(tag) || (scope?.invokerTags?.has(tag) ?? false);
|
|
546
|
+
|
|
547
|
+
if (target !== null && !isInvoker) {
|
|
548
|
+
out.push({
|
|
549
|
+
detail:
|
|
550
|
+
"`popovertarget` and `popovertargetaction` come from the `PopoverTargetAttributes` IDL " +
|
|
551
|
+
"mixin, which HTML includes into `HTMLButtonElement` and `HTMLInputElement` and into " +
|
|
552
|
+
"nothing else. Elsewhere they parse and do nothing. A link inside an open popover " +
|
|
553
|
+
"dismisses it by navigating away, which is why this looks like it works — but it does " +
|
|
554
|
+
`not for a new tab or a same-page fragment. Remove both attributes. ${DOCS}`,
|
|
555
|
+
fix: "invoker",
|
|
556
|
+
message: `<${tag || "element"}> carries popovertarget, which only <button> and <input> support.`,
|
|
557
|
+
path,
|
|
558
|
+
rule: "invoker-not-button",
|
|
559
|
+
severity: "error",
|
|
560
|
+
});
|
|
561
|
+
return out;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (target !== null) {
|
|
565
|
+
if (hasAnyPopover && !ids.includes(target)) {
|
|
566
|
+
const known =
|
|
567
|
+
ids.length > 0 ? `The popovers here are: ${ids.map((id) => `"${id}"`).join(", ")}. ` : "";
|
|
568
|
+
out.push({
|
|
569
|
+
detail:
|
|
570
|
+
`No element in this document declares \`popover\` with the id "${target}". ${known}` +
|
|
571
|
+
`A target that names nothing does nothing, silently. ${DOCS}`,
|
|
572
|
+
message: `popovertarget="${target}" names no popover in this document.`,
|
|
573
|
+
path,
|
|
574
|
+
rule: "target-missing",
|
|
575
|
+
severity: "error",
|
|
576
|
+
});
|
|
577
|
+
} else if (enclosing !== null && idOf(enclosing) !== null && idOf(enclosing) !== target) {
|
|
578
|
+
out.push({
|
|
579
|
+
detail:
|
|
580
|
+
`This control is inside ${labelOf(enclosing)} but targets "${target}". Dismissing a ` +
|
|
581
|
+
"different popover leaves the one the reader is looking at open. This is usually a " +
|
|
582
|
+
`copy-paste between two panels in the same file. ${DOCS}`,
|
|
583
|
+
message: `A control inside ${labelOf(enclosing)} targets "${target}" instead.`,
|
|
584
|
+
path,
|
|
585
|
+
rule: "target-mismatch",
|
|
586
|
+
severity: "warn",
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (tag === "a" && enclosing !== null && target === null) {
|
|
592
|
+
const href = literalAttr(node, "href") ?? (typeof node.href === "string" ? node.href : null);
|
|
593
|
+
const blank = literalAttr(node, "target") === "_blank";
|
|
594
|
+
const fragment = href !== null && href.includes("#");
|
|
595
|
+
if (blank || fragment) {
|
|
596
|
+
const why = blank
|
|
597
|
+
? "it opens a new tab, so the panel stays open on the page behind it. "
|
|
598
|
+
: "it is a same-document fragment, so the page scrolls behind the open panel. ";
|
|
599
|
+
out.push({
|
|
600
|
+
detail:
|
|
601
|
+
`A link inside a panel normally dismisses it by navigating away. This one does not: ${why}` +
|
|
602
|
+
"There is no declarative fix — a link cannot carry `popovertarget`. Move it out of the " +
|
|
603
|
+
`panel, make it a <button>, or accept it deliberately. ${DOCS}`,
|
|
604
|
+
message: `A link inside ${labelOf(enclosing)} does not dismiss it.`,
|
|
605
|
+
path,
|
|
606
|
+
rule: "link-does-not-dismiss",
|
|
607
|
+
severity: "warn",
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return out;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** The rules that judge the panel itself. */
|
|
615
|
+
function panelDefects(
|
|
616
|
+
node: JxElement,
|
|
617
|
+
path: PopoverPath,
|
|
618
|
+
targeted: ReadonlySet<string>,
|
|
619
|
+
): PopoverDefect[] {
|
|
620
|
+
const out: PopoverDefect[] = [];
|
|
621
|
+
const { style } = node;
|
|
622
|
+
const label = labelOf(node);
|
|
623
|
+
const mode = popoverModeOf(node);
|
|
624
|
+
|
|
625
|
+
if (mode !== null && mode !== "" && !(POPOVER_MODES as readonly string[]).includes(mode)) {
|
|
626
|
+
out.push({
|
|
627
|
+
detail:
|
|
628
|
+
"`popover` is an enumerated attribute taking `auto`, `manual` or `hint`. Anything else " +
|
|
629
|
+
"falls back to the invalid-value default `manual`, which gives up light dismiss and gives " +
|
|
630
|
+
`up Escape — so the panel opens and there is no way to close it. Write "auto". ${DOCS}`,
|
|
631
|
+
fix: "mode",
|
|
632
|
+
message: `${label} declares popover="${mode}", which is not one of auto, manual or hint.`,
|
|
633
|
+
path,
|
|
634
|
+
rule: "invalid-mode",
|
|
635
|
+
severity: "error",
|
|
636
|
+
});
|
|
637
|
+
} else if (typeof attrs(node).popover === "boolean") {
|
|
638
|
+
out.push({
|
|
639
|
+
detail:
|
|
640
|
+
"`popover` carries its value in its text, so a boolean is not the right shape. Written " +
|
|
641
|
+
'as `true` it reaches two of the compiler\'s emitters as `popover="true"`, which is not ' +
|
|
642
|
+
"one of the three keywords, so HTML falls back to `manual`: no light dismiss, no Escape, " +
|
|
643
|
+
`and the trigger stops toggling. Write "auto". ${DOCS}`,
|
|
644
|
+
fix: "mode",
|
|
645
|
+
message: `${label} writes popover as a boolean.`,
|
|
646
|
+
path,
|
|
647
|
+
rule: "invalid-mode",
|
|
648
|
+
severity: "error",
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const baseDisplay = scalar(style, "display");
|
|
653
|
+
/* `revert` is the one value that does NOT beat the UA rule — it rolls the cascade back TO it, so
|
|
654
|
+
a closed panel still computes `display: none` and an open one still takes its `:popover-open`
|
|
655
|
+
declaration (measured in Chrome 152: closed `none`, open `flex`). It is what an author writes
|
|
656
|
+
when the interpreter would otherwise inject a default `display` on a custom element, which is
|
|
657
|
+
exactly the defect this rule is about — so refusing it would refuse the fix. */
|
|
658
|
+
if (baseDisplay !== null && baseDisplay !== "revert" && baseDisplay !== "revert-layer") {
|
|
659
|
+
out.push({
|
|
660
|
+
detail:
|
|
661
|
+
"A closed popover is hidden by the browser's own `[popover]:not(:popover-open) { display: " +
|
|
662
|
+
"none }`. That rule is UA-origin, so any author `display` beats it at any specificity, and " +
|
|
663
|
+
"the panel is laid out on every page whether it is open or not — usually parked off the " +
|
|
664
|
+
"side of the viewport by the transform meant to animate it, so nothing looks wrong until " +
|
|
665
|
+
`you scroll sideways. \`display\` belongs in \`:popover-open\` and nowhere else. ${DOCS}`,
|
|
666
|
+
fix: "display",
|
|
667
|
+
message: `${label} sets display in its base rule.`,
|
|
668
|
+
path,
|
|
669
|
+
rule: "base-display",
|
|
670
|
+
severity: "error",
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
for (const key of breakpointsSettingDisplay(style)) {
|
|
675
|
+
out.push({
|
|
676
|
+
detail:
|
|
677
|
+
`The same defect as a base \`display\`, arriving through \`${key}\` and therefore only at ` +
|
|
678
|
+
"one width — which is what makes it hard to see. Gate the TRIGGER on a breakpoint, never " +
|
|
679
|
+
`the panel: the popover mechanism is what hides the panel. ${DOCS}`,
|
|
680
|
+
fix: "display",
|
|
681
|
+
message: `${label} sets display inside ${key}.`,
|
|
682
|
+
path,
|
|
683
|
+
rule: "breakpoint-display",
|
|
684
|
+
severity: "error",
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (scalar(style, "visibility") !== null) {
|
|
689
|
+
out.push({
|
|
690
|
+
detail:
|
|
691
|
+
"A second hiding mechanism the browser already provides. Once `display` leaves the base " +
|
|
692
|
+
"rule this one only adds a frame of flicker on open, and it hides the element from " +
|
|
693
|
+
`assistive technology in a way \`display: none\` already did. ${DOCS}`,
|
|
694
|
+
fix: "display",
|
|
695
|
+
message: `${label} also hides itself with visibility.`,
|
|
696
|
+
path,
|
|
697
|
+
rule: "base-visibility",
|
|
698
|
+
severity: "warn",
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (!declaresOpenRule(style)) {
|
|
703
|
+
out.push({
|
|
704
|
+
detail:
|
|
705
|
+
"Nothing changes when it opens, so no transition on it can run and — if `display` is not " +
|
|
706
|
+
"in the base rule — the panel has no `display` at all while open. This is usually a panel " +
|
|
707
|
+
`that was styled before it was made a popover. ${DOCS}`,
|
|
708
|
+
message: `${label} has no :popover-open rule.`,
|
|
709
|
+
path,
|
|
710
|
+
rule: "no-open-rule",
|
|
711
|
+
severity: "warn",
|
|
712
|
+
});
|
|
713
|
+
} else if (!declaresOpenDisplay(style) && baseDisplay === null && usesBoxLayout(style)) {
|
|
714
|
+
/* Narrow deliberately. "The open rule sets no display" fires on every panel that is happy with
|
|
715
|
+
the UA's `display: block` — which is two of jxsuite.com's three, so as a bare rule it was
|
|
716
|
+
noise on the one site that gets popovers right. It is only a defect when the base rule is
|
|
717
|
+
ALREADY written for flex or grid, because then nothing ever turns that layout on. */
|
|
718
|
+
out.push({
|
|
719
|
+
detail:
|
|
720
|
+
`${label} declares flex or grid properties, but neither its base rule nor its ` +
|
|
721
|
+
"`:popover-open` rule sets a `display` for them to apply to — and the base rule must not " +
|
|
722
|
+
"(that is what keeps the closed panel hidden). So it opens with the UA's `display: block` " +
|
|
723
|
+
`and the layout silently does nothing. Put \`display: flex\` in \`:popover-open\`. ${DOCS}`,
|
|
724
|
+
fix: "open-display",
|
|
725
|
+
message: `${label} is laid out with flex or grid but never sets display.`,
|
|
726
|
+
path,
|
|
727
|
+
rule: "no-open-display",
|
|
728
|
+
severity: "error",
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
for (const value of transitionValues(style)) {
|
|
733
|
+
if (value.includes("display") && !value.includes("overlay")) {
|
|
734
|
+
out.push({
|
|
735
|
+
detail:
|
|
736
|
+
"`display …allow-discrete` keeps the box alive while it animates out; `overlay " +
|
|
737
|
+
"…allow-discrete` is what keeps it in the TOP LAYER for those same milliseconds. " +
|
|
738
|
+
"Without it the panel drops behind the page content and the backdrop disappears a frame " +
|
|
739
|
+
`early, so the exit reads as a flicker rather than an animation. ${DOCS}`,
|
|
740
|
+
message: `${label} transitions display but not overlay.`,
|
|
741
|
+
path,
|
|
742
|
+
rule: "cut-exit",
|
|
743
|
+
severity: "warn",
|
|
744
|
+
});
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (usesAnchorPositioning(style) && !hasAnchorFallback(style)) {
|
|
750
|
+
out.push({
|
|
751
|
+
detail:
|
|
752
|
+
"When `anchor()` does not resolve, every inset it feeds computes to `auto` — and a " +
|
|
753
|
+
"`position: fixed` box with four `auto` insets falls back to its STATIC position, which " +
|
|
754
|
+
"for a panel declared inside a header is in the middle of the header. Use `position-area` " +
|
|
755
|
+
"with `position-try-fallbacks`, or add a `@supports` block that positions it without " +
|
|
756
|
+
`anchors. ${DOCS}`,
|
|
757
|
+
message: `${label} is anchor-positioned with no fallback.`,
|
|
758
|
+
path,
|
|
759
|
+
rule: "unsafe-anchor",
|
|
760
|
+
severity: "warn",
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const id = idOf(node);
|
|
765
|
+
if (id !== null && !targeted.has(id)) {
|
|
766
|
+
out.push({
|
|
767
|
+
detail:
|
|
768
|
+
"No `<button>` or `<input>` in this document names it with `popovertarget`. If it is " +
|
|
769
|
+
"opened from another file or from a function that calls `showPopover()`, this is fine; " +
|
|
770
|
+
`otherwise there is no way for a reader to open it. ${DOCS}`,
|
|
771
|
+
message: `${label} has no invoker in this document.`,
|
|
772
|
+
path,
|
|
773
|
+
rule: "no-invoker",
|
|
774
|
+
severity: "warn",
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
return out;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export interface PopoverDisplayRepair {
|
|
781
|
+
/** Properties to delete from the popover's BASE rule. */
|
|
782
|
+
base: string[];
|
|
783
|
+
/** At-rule keys (`@--md`, …) whose block should lose its own `display`. The block itself stays. */
|
|
784
|
+
breakpoints: string[];
|
|
785
|
+
/** The `display` value to write into `:popover-open`, or null when it already declares one. */
|
|
786
|
+
openDisplay: string | null;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* The style edit that repairs a `base-display` / `breakpoint-display` / `base-visibility` finding.
|
|
791
|
+
*
|
|
792
|
+
* Pure, and returned as data rather than applied, because the two callers write documents very
|
|
793
|
+
* differently: the Studio goes through `transactDoc` so the move is one undo step, and a CLI fixer
|
|
794
|
+
* would rewrite JSON on disk. Both need the same ANSWER — which declaration moves where — and that
|
|
795
|
+
* answer is the part worth having one of.
|
|
796
|
+
*
|
|
797
|
+
* `null` when there is nothing to move. When `:popover-open` already declares a `display`, the base
|
|
798
|
+
* one is deleted and the existing open value kept: the open state is the author's intent and the
|
|
799
|
+
* base rule is the accident.
|
|
800
|
+
*
|
|
801
|
+
* @param style The popover's own style object.
|
|
802
|
+
* @returns The properties to remove from the base rule, and the value to write into
|
|
803
|
+
* `:popover-open`.
|
|
804
|
+
*/
|
|
805
|
+
export function popoverDisplayRepair(style: JxStyle | undefined): PopoverDisplayRepair | null {
|
|
806
|
+
if (!style) {
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
const base: string[] = [];
|
|
810
|
+
if (scalar(style, "display") !== null) {
|
|
811
|
+
base.push("display");
|
|
812
|
+
}
|
|
813
|
+
if (scalar(style, "visibility") !== null) {
|
|
814
|
+
base.push("visibility");
|
|
815
|
+
}
|
|
816
|
+
const breakpoints = breakpointsSettingDisplay(style);
|
|
817
|
+
if (base.length === 0 && breakpoints.length === 0) {
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
const existing =
|
|
821
|
+
getNestedStyle(style, ":popover-open") ?? getNestedStyle(style, "&:popover-open");
|
|
822
|
+
const openDisplay =
|
|
823
|
+
scalar(existing, "display") !== null ? null : (scalar(style, "display") ?? null);
|
|
824
|
+
return { base, breakpoints, openDisplay };
|
|
825
|
+
}
|