@viewlint/rules 0.0.1
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 +5 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +69 -0
- package/dist/rules/clipped-content.d.ts +10 -0
- package/dist/rules/clipped-content.d.ts.map +1 -0
- package/dist/rules/clipped-content.js +222 -0
- package/dist/rules/container-overflow.d.ts +9 -0
- package/dist/rules/container-overflow.d.ts.map +1 -0
- package/dist/rules/container-overflow.js +185 -0
- package/dist/rules/corner-radius-coherence.d.ts +13 -0
- package/dist/rules/corner-radius-coherence.d.ts.map +1 -0
- package/dist/rules/corner-radius-coherence.js +171 -0
- package/dist/rules/hit-target-obscured.d.ts +10 -0
- package/dist/rules/hit-target-obscured.d.ts.map +1 -0
- package/dist/rules/hit-target-obscured.js +237 -0
- package/dist/rules/misalignment.d.ts +10 -0
- package/dist/rules/misalignment.d.ts.map +1 -0
- package/dist/rules/misalignment.js +154 -0
- package/dist/rules/overlapped-elements.d.ts +10 -0
- package/dist/rules/overlapped-elements.d.ts.map +1 -0
- package/dist/rules/overlapped-elements.js +252 -0
- package/dist/rules/space-misuse.d.ts +7 -0
- package/dist/rules/space-misuse.d.ts.map +1 -0
- package/dist/rules/space-misuse.js +204 -0
- package/dist/rules/text-contrast.d.ts +14 -0
- package/dist/rules/text-contrast.d.ts.map +1 -0
- package/dist/rules/text-contrast.js +210 -0
- package/dist/rules/text-overflow.d.ts +9 -0
- package/dist/rules/text-overflow.d.ts.map +1 -0
- package/dist/rules/text-overflow.js +86 -0
- package/dist/rules/text-proximity.d.ts +12 -0
- package/dist/rules/text-proximity.d.ts.map +1 -0
- package/dist/rules/text-proximity.js +115 -0
- package/dist/rules/text-ragged-lines.d.ts +10 -0
- package/dist/rules/text-ragged-lines.d.ts.map +1 -0
- package/dist/rules/text-ragged-lines.js +123 -0
- package/dist/rules/unexpected-scrollbar.d.ts +9 -0
- package/dist/rules/unexpected-scrollbar.d.ts.map +1 -0
- package/dist/rules/unexpected-scrollbar.js +77 -0
- package/dist/utils/domHelpers.d.ts +66 -0
- package/dist/utils/domHelpers.d.ts.map +1 -0
- package/dist/utils/domHelpers.js +548 -0
- package/dist/utils/getDomHelpersHandle.d.ts +4 -0
- package/dist/utils/getDomHelpersHandle.d.ts.map +1 -0
- package/dist/utils/getDomHelpersHandle.js +28 -0
- package/package.json +36 -0
package/README.md
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Plugin, RuleDefinition, RuleSchema } from "viewlint";
|
|
2
|
+
declare const plugin: {
|
|
3
|
+
meta: {
|
|
4
|
+
name: string;
|
|
5
|
+
docs: {};
|
|
6
|
+
};
|
|
7
|
+
rules: Record<string, RuleDefinition<RuleSchema | undefined>>;
|
|
8
|
+
configs: {
|
|
9
|
+
recommended: {
|
|
10
|
+
rules: {
|
|
11
|
+
"rules/hit-target-obscured": "error";
|
|
12
|
+
"rules/clipped-content": "error";
|
|
13
|
+
"rules/container-overflow": "error";
|
|
14
|
+
"rules/overlapped-elements": "error";
|
|
15
|
+
"rules/text-overflow": "error";
|
|
16
|
+
"rules/text-contrast": "warn";
|
|
17
|
+
"rules/unexpected-scrollbar": "error";
|
|
18
|
+
};
|
|
19
|
+
plugins: Record<string, Plugin>;
|
|
20
|
+
};
|
|
21
|
+
all: {
|
|
22
|
+
rules: {
|
|
23
|
+
"rules/hit-target-obscured": "error";
|
|
24
|
+
"rules/clipped-content": "error";
|
|
25
|
+
"rules/container-overflow": "error";
|
|
26
|
+
"rules/corner-radius-coherence": "warn";
|
|
27
|
+
"rules/misalignment": "warn";
|
|
28
|
+
"rules/overlapped-elements": "error";
|
|
29
|
+
"rules/space-misuse": "warn";
|
|
30
|
+
"rules/text-overflow": "error";
|
|
31
|
+
"rules/text-contrast": "warn";
|
|
32
|
+
"rules/text-proximity": "warn";
|
|
33
|
+
"rules/text-ragged-lines": "warn";
|
|
34
|
+
"rules/unexpected-scrollbar": "error";
|
|
35
|
+
};
|
|
36
|
+
plugins: Record<string, Plugin>;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
export default plugin;
|
|
41
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAiClE,QAAA,MAAM,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqCX,CAAA;AAKD,eAAe,MAAM,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import clippedContent from "./rules/clipped-content.js";
|
|
2
|
+
import containerOverflow from "./rules/container-overflow.js";
|
|
3
|
+
import cornerRadiusCoherence from "./rules/corner-radius-coherence.js";
|
|
4
|
+
import hitTargetObscured from "./rules/hit-target-obscured.js";
|
|
5
|
+
import misalignment from "./rules/misalignment.js";
|
|
6
|
+
import overlappedElements from "./rules/overlapped-elements.js";
|
|
7
|
+
import spaceMisuse from "./rules/space-misuse.js";
|
|
8
|
+
import textContrast from "./rules/text-contrast.js";
|
|
9
|
+
import textOverflow from "./rules/text-overflow.js";
|
|
10
|
+
import textProximity from "./rules/text-proximity.js";
|
|
11
|
+
import textRaggedLines from "./rules/text-ragged-lines.js";
|
|
12
|
+
import unexpectedScrollbar from "./rules/unexpected-scrollbar.js";
|
|
13
|
+
const rules = {
|
|
14
|
+
"hit-target-obscured": hitTargetObscured,
|
|
15
|
+
"clipped-content": clippedContent,
|
|
16
|
+
"container-overflow": containerOverflow,
|
|
17
|
+
"corner-radius-coherence": cornerRadiusCoherence,
|
|
18
|
+
misalignment: misalignment,
|
|
19
|
+
"overlapped-elements": overlappedElements,
|
|
20
|
+
"space-misuse": spaceMisuse,
|
|
21
|
+
"text-overflow": textOverflow,
|
|
22
|
+
"text-contrast": textContrast,
|
|
23
|
+
"text-proximity": textProximity,
|
|
24
|
+
"text-ragged-lines": textRaggedLines,
|
|
25
|
+
"unexpected-scrollbar": unexpectedScrollbar,
|
|
26
|
+
};
|
|
27
|
+
const recommendedPlugins = {};
|
|
28
|
+
const allPlugins = {};
|
|
29
|
+
const plugin = {
|
|
30
|
+
meta: {
|
|
31
|
+
name: "@viewlint/rules",
|
|
32
|
+
docs: {},
|
|
33
|
+
},
|
|
34
|
+
rules,
|
|
35
|
+
configs: {
|
|
36
|
+
recommended: {
|
|
37
|
+
rules: {
|
|
38
|
+
"rules/hit-target-obscured": "error",
|
|
39
|
+
"rules/clipped-content": "error",
|
|
40
|
+
"rules/container-overflow": "error",
|
|
41
|
+
"rules/overlapped-elements": "error",
|
|
42
|
+
"rules/text-overflow": "error",
|
|
43
|
+
"rules/text-contrast": "warn",
|
|
44
|
+
"rules/unexpected-scrollbar": "error",
|
|
45
|
+
},
|
|
46
|
+
plugins: recommendedPlugins,
|
|
47
|
+
},
|
|
48
|
+
all: {
|
|
49
|
+
rules: {
|
|
50
|
+
"rules/hit-target-obscured": "error",
|
|
51
|
+
"rules/clipped-content": "error",
|
|
52
|
+
"rules/container-overflow": "error",
|
|
53
|
+
"rules/corner-radius-coherence": "warn",
|
|
54
|
+
"rules/misalignment": "warn",
|
|
55
|
+
"rules/overlapped-elements": "error",
|
|
56
|
+
"rules/space-misuse": "warn",
|
|
57
|
+
"rules/text-overflow": "error",
|
|
58
|
+
"rules/text-contrast": "warn",
|
|
59
|
+
"rules/text-proximity": "warn",
|
|
60
|
+
"rules/text-ragged-lines": "warn",
|
|
61
|
+
"rules/unexpected-scrollbar": "error",
|
|
62
|
+
},
|
|
63
|
+
plugins: allPlugins,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
recommendedPlugins.rules = plugin;
|
|
68
|
+
allPlugins.rules = plugin;
|
|
69
|
+
export default plugin;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects elements with overflow:hidden or overflow:clip that are clipping content.
|
|
3
|
+
*
|
|
4
|
+
* Uses scrollWidth/scrollHeight as a signal for overflow, but compares against
|
|
5
|
+
* the element's (subpixel) padding box size derived from getBoundingClientRect
|
|
6
|
+
* to avoid false positives caused by integer rounding of clientWidth/clientHeight.
|
|
7
|
+
*/
|
|
8
|
+
declare const _default: import("viewlint").RuleDefinition<undefined>;
|
|
9
|
+
export default _default;
|
|
10
|
+
//# sourceMappingURL=clipped-content.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"clipped-content.d.ts","sourceRoot":"","sources":["../../src/rules/clipped-content.ts"],"names":[],"mappings":"AAGA;;;;;;GAMG;;AACH,wBAgQE"}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { defineRule } from "viewlint/plugin";
|
|
2
|
+
import { getDomHelpersHandle } from "../utils/getDomHelpersHandle.js";
|
|
3
|
+
/**
|
|
4
|
+
* Detects elements with overflow:hidden or overflow:clip that are clipping content.
|
|
5
|
+
*
|
|
6
|
+
* Uses scrollWidth/scrollHeight as a signal for overflow, but compares against
|
|
7
|
+
* the element's (subpixel) padding box size derived from getBoundingClientRect
|
|
8
|
+
* to avoid false positives caused by integer rounding of clientWidth/clientHeight.
|
|
9
|
+
*/
|
|
10
|
+
export default defineRule({
|
|
11
|
+
meta: {
|
|
12
|
+
severity: "error",
|
|
13
|
+
docs: {
|
|
14
|
+
description: "Detects content clipped by overflow:hidden or overflow:clip",
|
|
15
|
+
recommended: true,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
async run(context) {
|
|
19
|
+
const domHelpers = await getDomHelpersHandle(context.page);
|
|
20
|
+
await context.evaluate(({ report, scope, args: { domHelpers } }) => {
|
|
21
|
+
const CLIP_THRESHOLD = 1;
|
|
22
|
+
const MIN_TEXT_CLIP_THRESHOLD = 3;
|
|
23
|
+
const NEGATIVE_MARGIN_TOLERANCE = 2;
|
|
24
|
+
const hasObviousMediaDescendant = (el) => {
|
|
25
|
+
return Boolean(el.querySelector("img, video, canvas, svg, picture"));
|
|
26
|
+
};
|
|
27
|
+
const hasPseudoContent = (el) => {
|
|
28
|
+
const before = window.getComputedStyle(el, "::before").content;
|
|
29
|
+
const after = window.getComputedStyle(el, "::after").content;
|
|
30
|
+
return ((before !== "none" && before !== "normal") ||
|
|
31
|
+
(after !== "none" && after !== "normal"));
|
|
32
|
+
};
|
|
33
|
+
const matchesNegativeMarginClipping = (container, axis, clippedAmount) => {
|
|
34
|
+
if (container.children.length === 0)
|
|
35
|
+
return false;
|
|
36
|
+
if (domHelpers.getDirectTextNodes(container, 1).length > 0)
|
|
37
|
+
return false;
|
|
38
|
+
const children = Array.from(container.children).slice(0, 25);
|
|
39
|
+
for (const child of children) {
|
|
40
|
+
if (!domHelpers.isHtmlElement(child))
|
|
41
|
+
continue;
|
|
42
|
+
if (!domHelpers.isVisible(child))
|
|
43
|
+
continue;
|
|
44
|
+
const style = window.getComputedStyle(child);
|
|
45
|
+
const first = axis === "x"
|
|
46
|
+
? domHelpers.parsePx(style.marginLeft)
|
|
47
|
+
: domHelpers.parsePx(style.marginTop);
|
|
48
|
+
const second = axis === "x"
|
|
49
|
+
? domHelpers.parsePx(style.marginRight)
|
|
50
|
+
: domHelpers.parsePx(style.marginBottom);
|
|
51
|
+
if (first >= 0 && second >= 0)
|
|
52
|
+
continue;
|
|
53
|
+
const expected = Math.max(0, -first) + Math.max(0, -second);
|
|
54
|
+
if (expected <= 0)
|
|
55
|
+
continue;
|
|
56
|
+
const closeEnough = Math.abs(clippedAmount - expected) <= NEGATIVE_MARGIN_TOLERANCE;
|
|
57
|
+
const likelyFromNegativeMargins = clippedAmount <= expected + NEGATIVE_MARGIN_TOLERANCE;
|
|
58
|
+
if (closeEnough || likelyFromNegativeMargins)
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
};
|
|
63
|
+
const allElements = scope.queryAll("*");
|
|
64
|
+
for (const el of allElements) {
|
|
65
|
+
if (!domHelpers.isHtmlElement(el))
|
|
66
|
+
continue;
|
|
67
|
+
if (!domHelpers.isVisible(el))
|
|
68
|
+
continue;
|
|
69
|
+
if (!domHelpers.hasClientSize(el))
|
|
70
|
+
continue;
|
|
71
|
+
const parent = el.parentElement;
|
|
72
|
+
if (parent &&
|
|
73
|
+
domHelpers.isHtmlElement(parent) &&
|
|
74
|
+
domHelpers.isIntentionallyClipped(parent)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const style = window.getComputedStyle(el);
|
|
78
|
+
const overflowX = style.overflowX;
|
|
79
|
+
const overflowY = style.overflowY;
|
|
80
|
+
const clipsX = domHelpers.isClippingOverflowValue(overflowX);
|
|
81
|
+
const clipsY = domHelpers.isClippingOverflowValue(overflowY);
|
|
82
|
+
const clampsTextVertically = domHelpers.isLineClamped(el);
|
|
83
|
+
const clipsYForCheck = clipsY && !clampsTextVertically;
|
|
84
|
+
if (!clipsX && !clipsY)
|
|
85
|
+
continue;
|
|
86
|
+
if (clipsX && domHelpers.hasTextOverflowEllipsis(el))
|
|
87
|
+
continue;
|
|
88
|
+
const { scrollWidth, scrollHeight } = el;
|
|
89
|
+
const paddingBox = domHelpers.getPaddingBoxSize(el);
|
|
90
|
+
const clippedAmountX = scrollWidth - paddingBox.width;
|
|
91
|
+
const clippedAmountY = scrollHeight - paddingBox.height;
|
|
92
|
+
let clippedX = clipsX && clippedAmountX > CLIP_THRESHOLD;
|
|
93
|
+
let clippedY = clipsYForCheck && clippedAmountY > CLIP_THRESHOLD;
|
|
94
|
+
if (clippedY) {
|
|
95
|
+
const hasVisibleText = el.innerText.trim().length > 0;
|
|
96
|
+
const fontSizePx = domHelpers.getFontSize(el);
|
|
97
|
+
const textClipThreshold = hasVisibleText
|
|
98
|
+
? Math.max(MIN_TEXT_CLIP_THRESHOLD, fontSizePx * 0.2)
|
|
99
|
+
: CLIP_THRESHOLD;
|
|
100
|
+
clippedY = clippedAmountY > textClipThreshold;
|
|
101
|
+
}
|
|
102
|
+
if (clippedY) {
|
|
103
|
+
const isLikelyMinorCrop = paddingBox.height >= 48 &&
|
|
104
|
+
domHelpers.isIntentionallyClipped(el) &&
|
|
105
|
+
hasObviousMediaDescendant(el);
|
|
106
|
+
if (isLikelyMinorCrop) {
|
|
107
|
+
const allowable = Math.max(4, paddingBox.height * 0.02);
|
|
108
|
+
if (clippedAmountY <= allowable) {
|
|
109
|
+
clippedY = false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (clippedX || clippedY) {
|
|
114
|
+
const hasVisibleText = el.innerText.trim().length > 0;
|
|
115
|
+
const hasDirectText = domHelpers.getDirectTextNodes(el, 1).length > 0;
|
|
116
|
+
const canVerifyByChildRects = !hasVisibleText &&
|
|
117
|
+
!hasDirectText &&
|
|
118
|
+
!hasPseudoContent(el) &&
|
|
119
|
+
el.children.length > 0 &&
|
|
120
|
+
el.children.length <= 5;
|
|
121
|
+
if (canVerifyByChildRects) {
|
|
122
|
+
const clipRect = domHelpers.getPaddingBoxRect(el);
|
|
123
|
+
let fitsX = true;
|
|
124
|
+
let fitsY = true;
|
|
125
|
+
for (const child of el.children) {
|
|
126
|
+
if (!domHelpers.isHtmlElement(child))
|
|
127
|
+
continue;
|
|
128
|
+
if (!domHelpers.isVisible(child))
|
|
129
|
+
continue;
|
|
130
|
+
const r = child.getBoundingClientRect();
|
|
131
|
+
if (r.width === 0 || r.height === 0)
|
|
132
|
+
continue;
|
|
133
|
+
if (r.left < clipRect.left - CLIP_THRESHOLD)
|
|
134
|
+
fitsX = false;
|
|
135
|
+
if (r.right > clipRect.right + CLIP_THRESHOLD)
|
|
136
|
+
fitsX = false;
|
|
137
|
+
if (r.top < clipRect.top - CLIP_THRESHOLD)
|
|
138
|
+
fitsY = false;
|
|
139
|
+
if (r.bottom > clipRect.bottom + CLIP_THRESHOLD)
|
|
140
|
+
fitsY = false;
|
|
141
|
+
if (!fitsX && !fitsY)
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
if (clippedX && fitsX)
|
|
145
|
+
clippedX = false;
|
|
146
|
+
if (clippedY && fitsY)
|
|
147
|
+
clippedY = false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (clippedX &&
|
|
151
|
+
matchesNegativeMarginClipping(el, "x", clippedAmountX)) {
|
|
152
|
+
clippedX = false;
|
|
153
|
+
}
|
|
154
|
+
if (clippedY &&
|
|
155
|
+
matchesNegativeMarginClipping(el, "y", clippedAmountY)) {
|
|
156
|
+
clippedY = false;
|
|
157
|
+
}
|
|
158
|
+
// Common layout pattern: horizontal gutters achieved by clipping symmetric overflow.
|
|
159
|
+
if (clippedX) {
|
|
160
|
+
let minLeft = Infinity;
|
|
161
|
+
let maxRight = -Infinity;
|
|
162
|
+
const clipRect = el.getBoundingClientRect();
|
|
163
|
+
for (const child of el.children) {
|
|
164
|
+
if (!domHelpers.isHtmlElement(child))
|
|
165
|
+
continue;
|
|
166
|
+
if (!domHelpers.isVisible(child))
|
|
167
|
+
continue;
|
|
168
|
+
const r = child.getBoundingClientRect();
|
|
169
|
+
minLeft = Math.min(minLeft, r.left);
|
|
170
|
+
maxRight = Math.max(maxRight, r.right);
|
|
171
|
+
}
|
|
172
|
+
if (minLeft !== Infinity && maxRight !== -Infinity) {
|
|
173
|
+
const leftOverflow = Math.max(0, clipRect.left - minLeft);
|
|
174
|
+
const rightOverflow = Math.max(0, maxRight - clipRect.right);
|
|
175
|
+
const isSymmetric = leftOverflow > CLIP_THRESHOLD &&
|
|
176
|
+
rightOverflow > CLIP_THRESHOLD &&
|
|
177
|
+
Math.abs(leftOverflow - rightOverflow) <= 2;
|
|
178
|
+
if (isSymmetric) {
|
|
179
|
+
clippedX = false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (!clippedX && !clippedY)
|
|
184
|
+
continue;
|
|
185
|
+
const containerRect = el.getBoundingClientRect();
|
|
186
|
+
let absoluteChildOverflow = false;
|
|
187
|
+
for (const child of el.children) {
|
|
188
|
+
if (!domHelpers.isHtmlElement(child))
|
|
189
|
+
continue;
|
|
190
|
+
const childStyle = window.getComputedStyle(child);
|
|
191
|
+
if (childStyle.position !== "absolute")
|
|
192
|
+
continue;
|
|
193
|
+
const childRect = child.getBoundingClientRect();
|
|
194
|
+
const overflowsX = childRect.right - containerRect.right > CLIP_THRESHOLD ||
|
|
195
|
+
containerRect.left - childRect.left > CLIP_THRESHOLD;
|
|
196
|
+
const overflowsY = childRect.bottom - containerRect.bottom > CLIP_THRESHOLD ||
|
|
197
|
+
containerRect.top - childRect.top > CLIP_THRESHOLD;
|
|
198
|
+
if ((clippedX && overflowsX) || (clippedY && overflowsY)) {
|
|
199
|
+
absoluteChildOverflow = true;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (absoluteChildOverflow)
|
|
204
|
+
continue;
|
|
205
|
+
let message;
|
|
206
|
+
if (clippedX && clippedY) {
|
|
207
|
+
message = `Content is clipped by ${Math.round(clippedAmountX)}px horizontally and ${Math.round(clippedAmountY)}px vertically`;
|
|
208
|
+
}
|
|
209
|
+
else if (clippedX) {
|
|
210
|
+
message = `Content is clipped by ${Math.round(clippedAmountX)}px horizontally`;
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
message = `Content is clipped by ${Math.round(clippedAmountY)}px vertically`;
|
|
214
|
+
}
|
|
215
|
+
report({
|
|
216
|
+
message,
|
|
217
|
+
element: el,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}, { domHelpers });
|
|
221
|
+
},
|
|
222
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects child elements that overflow their parent container bounds.
|
|
3
|
+
*
|
|
4
|
+
* Compares bounding boxes of parent and child to detect when content
|
|
5
|
+
* extends beyond the visible container area.
|
|
6
|
+
*/
|
|
7
|
+
declare const _default: import("viewlint").RuleDefinition<undefined>;
|
|
8
|
+
export default _default;
|
|
9
|
+
//# sourceMappingURL=container-overflow.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"container-overflow.d.ts","sourceRoot":"","sources":["../../src/rules/container-overflow.ts"],"names":[],"mappings":"AAIA;;;;;GAKG;;AACH,wBAkQE"}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { defineRule } from "viewlint/plugin";
|
|
2
|
+
import { getDomHelpersHandle } from "../utils/getDomHelpersHandle.js";
|
|
3
|
+
/**
|
|
4
|
+
* Detects child elements that overflow their parent container bounds.
|
|
5
|
+
*
|
|
6
|
+
* Compares bounding boxes of parent and child to detect when content
|
|
7
|
+
* extends beyond the visible container area.
|
|
8
|
+
*/
|
|
9
|
+
export default defineRule({
|
|
10
|
+
meta: {
|
|
11
|
+
severity: "error",
|
|
12
|
+
docs: {
|
|
13
|
+
description: "Detects child elements that overflow their parent container",
|
|
14
|
+
recommended: true,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
async run(context) {
|
|
18
|
+
const domHelpers = await getDomHelpersHandle(context.page);
|
|
19
|
+
await context.evaluate(({ report, scope, args: { domHelpers } }) => {
|
|
20
|
+
void report;
|
|
21
|
+
// Small overflow for containers that clip
|
|
22
|
+
const OVERFLOW_THRESHOLD = 1;
|
|
23
|
+
// Larger threshold for visible overflow containers (likely intentional small overlaps)
|
|
24
|
+
const VISIBLE_OVERFLOW_THRESHOLD = 20;
|
|
25
|
+
const NEGATIVE_MARGIN_TOLERANCE = 2;
|
|
26
|
+
const hasVisibleOverflow = (el) => {
|
|
27
|
+
const style = window.getComputedStyle(el);
|
|
28
|
+
return (style.overflow === "visible" &&
|
|
29
|
+
style.overflowX === "visible" &&
|
|
30
|
+
style.overflowY === "visible");
|
|
31
|
+
};
|
|
32
|
+
const isSymmetricHorizontalOverflow = (overflow, threshold) => {
|
|
33
|
+
return (overflow.left > threshold &&
|
|
34
|
+
overflow.right > threshold &&
|
|
35
|
+
Math.abs(overflow.left - overflow.right) <= 2);
|
|
36
|
+
};
|
|
37
|
+
const looksLikeNegativeMarginClipping = (child, parent, overflow, threshold) => {
|
|
38
|
+
const parentStyle = window.getComputedStyle(parent);
|
|
39
|
+
const clipsX = domHelpers.isClippingOverflowValue(parentStyle.overflowX);
|
|
40
|
+
const clipsY = domHelpers.isClippingOverflowValue(parentStyle.overflowY);
|
|
41
|
+
if (!clipsX && !clipsY)
|
|
42
|
+
return false;
|
|
43
|
+
const style = window.getComputedStyle(child);
|
|
44
|
+
const marginLeft = domHelpers.parsePx(style.marginLeft);
|
|
45
|
+
const marginRight = domHelpers.parsePx(style.marginRight);
|
|
46
|
+
const marginTop = domHelpers.parsePx(style.marginTop);
|
|
47
|
+
const marginBottom = domHelpers.parsePx(style.marginBottom);
|
|
48
|
+
const leftMatch = clipsX &&
|
|
49
|
+
marginLeft < 0 &&
|
|
50
|
+
overflow.left > threshold &&
|
|
51
|
+
Math.abs(overflow.left - Math.abs(marginLeft)) <=
|
|
52
|
+
NEGATIVE_MARGIN_TOLERANCE;
|
|
53
|
+
const rightMatch = clipsX &&
|
|
54
|
+
marginRight < 0 &&
|
|
55
|
+
overflow.right > threshold &&
|
|
56
|
+
Math.abs(overflow.right - Math.abs(marginRight)) <=
|
|
57
|
+
NEGATIVE_MARGIN_TOLERANCE;
|
|
58
|
+
const topMatch = clipsY &&
|
|
59
|
+
marginTop < 0 &&
|
|
60
|
+
overflow.top > threshold &&
|
|
61
|
+
Math.abs(overflow.top - Math.abs(marginTop)) <=
|
|
62
|
+
NEGATIVE_MARGIN_TOLERANCE;
|
|
63
|
+
const bottomMatch = clipsY &&
|
|
64
|
+
marginBottom < 0 &&
|
|
65
|
+
overflow.bottom > threshold &&
|
|
66
|
+
Math.abs(overflow.bottom - Math.abs(marginBottom)) <=
|
|
67
|
+
NEGATIVE_MARGIN_TOLERANCE;
|
|
68
|
+
const symmetricMatch = clipsX &&
|
|
69
|
+
marginLeft < 0 &&
|
|
70
|
+
marginRight < 0 &&
|
|
71
|
+
isSymmetricHorizontalOverflow({ left: overflow.left, right: overflow.right }, threshold) &&
|
|
72
|
+
Math.abs(Math.abs(marginLeft) - Math.abs(marginRight)) <=
|
|
73
|
+
NEGATIVE_MARGIN_TOLERANCE;
|
|
74
|
+
return (leftMatch || rightMatch || topMatch || bottomMatch || symmetricMatch);
|
|
75
|
+
};
|
|
76
|
+
const isClippedByIntentionallyClippedAncestor = (el, childRect, overflow, threshold) => {
|
|
77
|
+
const needsX = overflow.left > threshold || overflow.right > threshold;
|
|
78
|
+
const needsY = overflow.top > threshold || overflow.bottom > threshold;
|
|
79
|
+
if (!needsX && !needsY)
|
|
80
|
+
return false;
|
|
81
|
+
let current = el.parentElement;
|
|
82
|
+
while (current) {
|
|
83
|
+
if (!domHelpers.isHtmlElement(current)) {
|
|
84
|
+
current = current.parentElement;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const style = window.getComputedStyle(current);
|
|
88
|
+
const clipsX = domHelpers.isClippingOverflowValue(style.overflowX);
|
|
89
|
+
const clipsY = domHelpers.isClippingOverflowValue(style.overflowY);
|
|
90
|
+
const clipsInNeededAxis = (needsX && clipsX) || (needsY && clipsY);
|
|
91
|
+
if (!clipsInNeededAxis) {
|
|
92
|
+
current = current.parentElement;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!domHelpers.isIntentionallyClipped(current)) {
|
|
96
|
+
current = current.parentElement;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const rect = current.getBoundingClientRect();
|
|
100
|
+
const clippedInX = needsX &&
|
|
101
|
+
clipsX &&
|
|
102
|
+
(childRect.left < rect.left - threshold ||
|
|
103
|
+
childRect.right > rect.right + threshold);
|
|
104
|
+
const clippedInY = needsY &&
|
|
105
|
+
clipsY &&
|
|
106
|
+
(childRect.top < rect.top - threshold ||
|
|
107
|
+
childRect.bottom > rect.bottom + threshold);
|
|
108
|
+
if (clippedInX || clippedInY)
|
|
109
|
+
return true;
|
|
110
|
+
current = current.parentElement;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
};
|
|
114
|
+
const allElements = scope.queryAll("*");
|
|
115
|
+
for (const el of allElements) {
|
|
116
|
+
if (!domHelpers.isHtmlElement(el))
|
|
117
|
+
continue;
|
|
118
|
+
if (!domHelpers.isVisible(el))
|
|
119
|
+
continue;
|
|
120
|
+
if (!domHelpers.hasElementRectSize(el))
|
|
121
|
+
continue;
|
|
122
|
+
if (domHelpers.hasTextOverflowEllipsis(el))
|
|
123
|
+
continue;
|
|
124
|
+
if (domHelpers.isOffscreenPositioned(el))
|
|
125
|
+
continue;
|
|
126
|
+
const elementStyle = window.getComputedStyle(el);
|
|
127
|
+
if (elementStyle.position === "absolute" ||
|
|
128
|
+
elementStyle.position === "fixed" ||
|
|
129
|
+
elementStyle.position === "sticky") {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const parent = el.parentElement;
|
|
133
|
+
if (!parent || !domHelpers.isHtmlElement(parent))
|
|
134
|
+
continue;
|
|
135
|
+
if (!domHelpers.isVisible(parent))
|
|
136
|
+
continue;
|
|
137
|
+
if (!domHelpers.hasElementRectSize(parent))
|
|
138
|
+
continue;
|
|
139
|
+
// Skip body as parent - content overflowing body is normal for scrollable pages
|
|
140
|
+
if (parent.tagName === "BODY" || parent.tagName === "HTML")
|
|
141
|
+
continue;
|
|
142
|
+
if (domHelpers.hasTextOverflowEllipsis(parent))
|
|
143
|
+
continue;
|
|
144
|
+
if (domHelpers.isIntentionallyClipped(parent))
|
|
145
|
+
continue;
|
|
146
|
+
const parentRect = parent.getBoundingClientRect();
|
|
147
|
+
const childRect = el.getBoundingClientRect();
|
|
148
|
+
const parentHasVisibleOverflow = hasVisibleOverflow(parent);
|
|
149
|
+
// For visible overflow containers, use higher threshold and only
|
|
150
|
+
// check if parent looks like a proper layout container
|
|
151
|
+
if (parentHasVisibleOverflow) {
|
|
152
|
+
if (!domHelpers.isLayoutContainer(parent))
|
|
153
|
+
continue;
|
|
154
|
+
const overflow = domHelpers.getOverflow(parentRect, childRect, VISIBLE_OVERFLOW_THRESHOLD);
|
|
155
|
+
if (!overflow)
|
|
156
|
+
continue;
|
|
157
|
+
if (isClippedByIntentionallyClippedAncestor(el, childRect, overflow, VISIBLE_OVERFLOW_THRESHOLD)) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
report({
|
|
161
|
+
message: `Element overflows its container by ${domHelpers.formatOverflow(overflow, VISIBLE_OVERFLOW_THRESHOLD)}`,
|
|
162
|
+
element: el,
|
|
163
|
+
relations: [{ description: "Container", element: parent }],
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
const overflow = domHelpers.getOverflow(parentRect, childRect, OVERFLOW_THRESHOLD);
|
|
168
|
+
if (!overflow)
|
|
169
|
+
continue;
|
|
170
|
+
if (looksLikeNegativeMarginClipping(el, parent, overflow, OVERFLOW_THRESHOLD)) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (isClippedByIntentionallyClippedAncestor(el, childRect, overflow, OVERFLOW_THRESHOLD)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
report({
|
|
177
|
+
message: `Element overflows its container by ${domHelpers.formatOverflow(overflow, OVERFLOW_THRESHOLD)}`,
|
|
178
|
+
element: el,
|
|
179
|
+
relations: [{ description: "Container", element: parent }],
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}, { domHelpers });
|
|
184
|
+
},
|
|
185
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects child elements whose corner radius violates the corner radius law.
|
|
3
|
+
*
|
|
4
|
+
* The corner radius law states that for nested rounded corners to look visually
|
|
5
|
+
* coherent, the child's radius should equal `parent_radius - inset`, where inset
|
|
6
|
+
* is the distance between the parent's inner edge and the child's outer edge.
|
|
7
|
+
*
|
|
8
|
+
* Only reports when the inset is small enough relative to the parent's radius
|
|
9
|
+
* that the relationship matters visually (inset <= 0.5 * parent_radius).
|
|
10
|
+
*/
|
|
11
|
+
declare const _default: import("viewlint").RuleDefinition<undefined>;
|
|
12
|
+
export default _default;
|
|
13
|
+
//# sourceMappingURL=corner-radius-coherence.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"corner-radius-coherence.d.ts","sourceRoot":"","sources":["../../src/rules/corner-radius-coherence.ts"],"names":[],"mappings":"AAGA;;;;;;;;;GASG;;AACH,wBAoNE"}
|