mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Fix sr-only text overflow false positive (#197)
This commit is contained in:
@@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
|
||||
@@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
@@ -2312,5 +2379,6 @@ export {
|
||||
checkClippedOverflow,
|
||||
checkElementClippedOverflow,
|
||||
checkElementClippedOverflowDOM,
|
||||
isScreenReaderOnlyTextStyle,
|
||||
checkElementTextOverflowDOM,
|
||||
};
|
||||
|
||||
@@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
|
||||
@@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
@@ -2312,5 +2379,6 @@ export {
|
||||
checkClippedOverflow,
|
||||
checkElementClippedOverflow,
|
||||
checkElementClippedOverflowDOM,
|
||||
isScreenReaderOnlyTextStyle,
|
||||
checkElementTextOverflowDOM,
|
||||
};
|
||||
|
||||
@@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
|
||||
@@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
@@ -2312,5 +2379,6 @@ export {
|
||||
checkClippedOverflow,
|
||||
checkElementClippedOverflow,
|
||||
checkElementClippedOverflowDOM,
|
||||
isScreenReaderOnlyTextStyle,
|
||||
checkElementTextOverflowDOM,
|
||||
};
|
||||
|
||||
@@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
|
||||
@@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
@@ -2312,5 +2379,6 @@ export {
|
||||
checkClippedOverflow,
|
||||
checkElementClippedOverflow,
|
||||
checkElementClippedOverflowDOM,
|
||||
isScreenReaderOnlyTextStyle,
|
||||
checkElementTextOverflowDOM,
|
||||
};
|
||||
|
||||
@@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
|
||||
@@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
@@ -2312,5 +2379,6 @@ export {
|
||||
checkClippedOverflow,
|
||||
checkElementClippedOverflow,
|
||||
checkElementClippedOverflowDOM,
|
||||
isScreenReaderOnlyTextStyle,
|
||||
checkElementTextOverflowDOM,
|
||||
};
|
||||
|
||||
@@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
|
||||
@@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
@@ -2312,5 +2379,6 @@ export {
|
||||
checkClippedOverflow,
|
||||
checkElementClippedOverflow,
|
||||
checkElementClippedOverflowDOM,
|
||||
isScreenReaderOnlyTextStyle,
|
||||
checkElementTextOverflowDOM,
|
||||
};
|
||||
|
||||
@@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
|
||||
@@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
@@ -2312,5 +2379,6 @@ export {
|
||||
checkClippedOverflow,
|
||||
checkElementClippedOverflow,
|
||||
checkElementClippedOverflowDOM,
|
||||
isScreenReaderOnlyTextStyle,
|
||||
checkElementTextOverflowDOM,
|
||||
};
|
||||
|
||||
@@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
|
||||
@@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
@@ -2312,5 +2379,6 @@ export {
|
||||
checkClippedOverflow,
|
||||
checkElementClippedOverflow,
|
||||
checkElementClippedOverflowDOM,
|
||||
isScreenReaderOnlyTextStyle,
|
||||
checkElementTextOverflowDOM,
|
||||
};
|
||||
|
||||
@@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
|
||||
@@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
@@ -2312,5 +2379,6 @@ export {
|
||||
checkClippedOverflow,
|
||||
checkElementClippedOverflow,
|
||||
checkElementClippedOverflowDOM,
|
||||
isScreenReaderOnlyTextStyle,
|
||||
checkElementTextOverflowDOM,
|
||||
};
|
||||
|
||||
@@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
|
||||
@@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
@@ -2312,5 +2379,6 @@ export {
|
||||
checkClippedOverflow,
|
||||
checkElementClippedOverflow,
|
||||
checkElementClippedOverflowDOM,
|
||||
isScreenReaderOnlyTextStyle,
|
||||
checkElementTextOverflowDOM,
|
||||
};
|
||||
|
||||
@@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
|
||||
@@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
@@ -2312,5 +2379,6 @@ export {
|
||||
checkClippedOverflow,
|
||||
checkElementClippedOverflow,
|
||||
checkElementClippedOverflowDOM,
|
||||
isScreenReaderOnlyTextStyle,
|
||||
checkElementTextOverflowDOM,
|
||||
};
|
||||
|
||||
@@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
|
||||
@@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
@@ -2312,5 +2379,6 @@ export {
|
||||
checkClippedOverflow,
|
||||
checkElementClippedOverflow,
|
||||
checkElementClippedOverflowDOM,
|
||||
isScreenReaderOnlyTextStyle,
|
||||
checkElementTextOverflowDOM,
|
||||
};
|
||||
|
||||
@@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
|
||||
@@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
@@ -2312,5 +2379,6 @@ export {
|
||||
checkClippedOverflow,
|
||||
checkElementClippedOverflow,
|
||||
checkElementClippedOverflowDOM,
|
||||
isScreenReaderOnlyTextStyle,
|
||||
checkElementTextOverflowDOM,
|
||||
};
|
||||
|
||||
@@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
|
||||
@@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
@@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
@@ -2312,5 +2379,6 @@ export {
|
||||
checkClippedOverflow,
|
||||
checkElementClippedOverflow,
|
||||
checkElementClippedOverflowDOM,
|
||||
isScreenReaderOnlyTextStyle,
|
||||
checkElementTextOverflowDOM,
|
||||
};
|
||||
|
||||
@@ -154,8 +154,8 @@ describe('detectUrl — browser-only fixtures', () => {
|
||||
// Browser-only: needs scrollWidth vs clientWidth from real layout.
|
||||
// Flag column: a nowrap line and an unbreakable token spilling past a
|
||||
// fixed-width box (overflow visible). Pass column: a genuine
|
||||
// overflow-x:auto scroll region, a <pre>, normally wrapping text, and a
|
||||
// long line living inside a scroll ancestor.
|
||||
// overflow-x:auto scroll region, a <pre>, normally wrapping text, a long
|
||||
// line living inside a scroll ancestor, and sr-only accessible text.
|
||||
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/text-overflow.html`);
|
||||
const hits = f.filter(r => r.antipattern === 'text-overflow');
|
||||
const flagged = new Set();
|
||||
@@ -165,7 +165,16 @@ describe('detectUrl — browser-only fixtures', () => {
|
||||
}
|
||||
assert.ok(flagged.has('flag-nowrap'), 'expected the nowrap overflow case to flag');
|
||||
assert.ok(flagged.has('flag-longword'), 'expected the unbreakable-token overflow case to flag');
|
||||
for (const cls of ['pass-scroll', 'pass-pre', 'pass-wrap', 'pass-inside-scroll']) {
|
||||
for (const cls of [
|
||||
'pass-scroll',
|
||||
'pass-pre',
|
||||
'pass-wrap',
|
||||
'pass-inside-scroll',
|
||||
'pass-sr-only-clip-path',
|
||||
'pass-sr-only-legacy',
|
||||
'pass-sr-only-tiny-hidden',
|
||||
'pass-sr-only-clipped-wide',
|
||||
]) {
|
||||
assert.ok(!flagged.has(cls), `".${cls}" should NOT be flagged as text-overflow`);
|
||||
}
|
||||
assert.equal(hits.length, 2, `expected exactly 2 text-overflow findings, got ${hits.length}: ${JSON.stringify(hits.map(h => h.snippet))}`);
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
buildImportGraph, resolveImport,
|
||||
detectFrameworkConfig, isPortListening, FRAMEWORK_CONFIGS,
|
||||
} from '../cli/engine/detect-antipatterns.mjs';
|
||||
import {
|
||||
checkElementTextOverflowDOM,
|
||||
isScreenReaderOnlyTextStyle,
|
||||
} from '../cli/engine/rules/checks.mjs';
|
||||
|
||||
const FIXTURES = path.join(import.meta.dir, 'fixtures', 'antipatterns');
|
||||
const SCRIPT = path.join(import.meta.dir, '..', 'cli', 'engine', 'detect-antipatterns.mjs');
|
||||
@@ -254,6 +258,151 @@ describe('detectHtml — layout', () => {
|
||||
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text overflow screen-reader-only handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('checkElementTextOverflowDOM', () => {
|
||||
function baseTextStyle(overrides = {}) {
|
||||
return {
|
||||
position: 'static',
|
||||
width: '160px',
|
||||
height: '20px',
|
||||
overflow: 'visible',
|
||||
overflowX: 'visible',
|
||||
overflowY: 'visible',
|
||||
clipPath: 'none',
|
||||
clip: 'auto',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockTextElement({
|
||||
className = 'flag-overflow',
|
||||
style = baseTextStyle(),
|
||||
clientWidth = 24,
|
||||
clientHeight = 20,
|
||||
scrollWidth = 80,
|
||||
rectWidth = clientWidth,
|
||||
rectHeight = clientHeight,
|
||||
} = {}) {
|
||||
return {
|
||||
tagName: 'DIV',
|
||||
className,
|
||||
childNodes: [{ nodeType: 3, textContent: 'A long accessible label that overflows its box' }],
|
||||
parentElement: null,
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
scrollWidth,
|
||||
__style: style,
|
||||
getAttribute(name) {
|
||||
return name === 'class' ? className : null;
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return { width: rectWidth, height: rectHeight };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withMockComputedStyle(callback) {
|
||||
const original = globalThis.getComputedStyle;
|
||||
globalThis.getComputedStyle = (el) => el.__style;
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
if (original === undefined) delete globalThis.getComputedStyle;
|
||||
else globalThis.getComputedStyle = original;
|
||||
}
|
||||
}
|
||||
|
||||
test('classifies clip-path sr-only text as visually hidden', () => {
|
||||
expect(isScreenReaderOnlyTextStyle(baseTextStyle({
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
overflow: 'hidden',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'hidden',
|
||||
clipPath: 'inset(50%)',
|
||||
}), { width: 1, height: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
test('classifies legacy clip rect sr-only text as visually hidden', () => {
|
||||
expect(isScreenReaderOnlyTextStyle(baseTextStyle({
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
overflow: 'hidden',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
}), { width: 1, height: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
test('classifies tiny absolute overflow-hidden text as visually hidden without clip', () => {
|
||||
expect(isScreenReaderOnlyTextStyle(baseTextStyle({
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
overflow: 'hidden',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'hidden',
|
||||
}), { width: 1, height: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
test('classifies fully clipped text as visually hidden without tiny sizing', () => {
|
||||
expect(isScreenReaderOnlyTextStyle(baseTextStyle({
|
||||
position: 'absolute',
|
||||
width: '160px',
|
||||
height: '20px',
|
||||
overflow: 'visible',
|
||||
clipPath: 'inset(50%)',
|
||||
}), { width: 160, height: 20 })).toBe(true);
|
||||
});
|
||||
|
||||
test('flags visible overflowing text', () => {
|
||||
const findings = withMockComputedStyle(() => checkElementTextOverflowDOM(mockTextElement()));
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].id).toBe('text-overflow');
|
||||
expect(findings[0].snippet).toContain('.flag-overflow');
|
||||
});
|
||||
|
||||
test('skips overflowing sr-only text', () => {
|
||||
const srOnly = mockTextElement({
|
||||
className: 'pass-sr-only-clip-path',
|
||||
style: baseTextStyle({
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
overflow: 'hidden',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'hidden',
|
||||
clipPath: 'inset(50%)',
|
||||
}),
|
||||
clientWidth: 1,
|
||||
clientHeight: 1,
|
||||
scrollWidth: 240,
|
||||
rectWidth: 1,
|
||||
rectHeight: 1,
|
||||
});
|
||||
|
||||
const findings = withMockComputedStyle(() => checkElementTextOverflowDOM(srOnly));
|
||||
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does not classify tiny visible text as sr-only', () => {
|
||||
const style = baseTextStyle({
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
});
|
||||
|
||||
expect(isScreenReaderOnlyTextStyle(style, { width: 1, height: 1 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Motion anti-patterns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+25
@@ -8,6 +8,27 @@
|
||||
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; padding: 24px; align-items: start; }
|
||||
.col { padding: 16px; }
|
||||
.box { width: 160px; border: 1px solid #ddd; margin: 0 0 24px; padding: 8px; }
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
.sr-only-clip-path { clip-path: inset(50%); }
|
||||
.sr-only-legacy-clip { clip: rect(0, 0, 0, 0); }
|
||||
.sr-only-tiny-hidden { clip: auto; }
|
||||
.sr-only-clipped-wide {
|
||||
position: absolute;
|
||||
width: 160px;
|
||||
height: 20px;
|
||||
overflow: visible;
|
||||
white-space: nowrap;
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -24,6 +45,10 @@
|
||||
<pre class="box pass-pre" style="overflow: visible">a-very-long-preformatted-code-line-that-intentionally-extends-beyond-the-box-width</pre>
|
||||
<div class="box pass-wrap">Ordinary wrapping paragraph text that stays comfortably within its container.</div>
|
||||
<div class="box" style="overflow-x: auto"><span class="pass-inside-scroll" style="white-space: nowrap; display: inline-block">A long line living inside a scroll ancestor, which must not be flagged.</span></div>
|
||||
<span class="pass-sr-only-clip-path sr-only sr-only-clip-path">A screen-reader-only label with long text that should never draw a visible overflow finding.</span>
|
||||
<span class="pass-sr-only-legacy sr-only sr-only-legacy-clip">A legacy clipped screen-reader-only label with long text that should not be flagged.</span>
|
||||
<span class="pass-sr-only-tiny-hidden sr-only sr-only-tiny-hidden">A tiny overflow-hidden screen-reader-only label without a clip declaration should not be flagged.</span>
|
||||
<span class="pass-sr-only-clipped-wide sr-only-clipped-wide">A fully clipped label with a normal-sized box and overflowing text should not be flagged.</span>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user