// @ts-check /** * Warn when a JSX element uses Tailwind's `truncate` (or `line-clamp-*` / * `text-ellipsis`) without a paired `title=` or `aria-label=` so users have * no way to reveal full content on hover/focus. * * Pattern E in docs/ui/UI_AUDIT.md. */ const TRUNCATE_RE = /\b(truncate|line-clamp-\d+|text-ellipsis)\b/; function classNameValue(attr) { if (!attr || !attr.value) return null; if (attr.value.type === 'Literal') return String(attr.value.value || ''); if (attr.value.type === 'JSXExpressionContainer') { const e = attr.value.expression; if (e.type === 'TemplateLiteral') return e.quasis.map(q => q.value.cooked).join(' '); if (e.type === 'Literal') return String(e.value || ''); } return null; } export default { meta: { type: 'suggestion', docs: { description: 'truncate without a title/aria-label is a UX regression' }, schema: [], messages: { missing: 'Element uses `truncate`/`line-clamp-*` but has no `title=` or `aria-label=`. Add one so users can reveal the full text on hover/focus.', }, }, create(context) { return { JSXOpeningElement(node) { const cls = node.attributes.find(a => a.type === 'JSXAttribute' && a.name && a.name.name === 'className'); if (!cls) return; const val = classNameValue(cls); if (!val || !TRUNCATE_RE.test(val)) return; const hasAffordance = node.attributes.some(a => a.type === 'JSXAttribute' && a.name && (a.name.name === 'title' || a.name.name === 'aria-label') ); if (!hasAffordance) { context.report({ node: cls, messageId: 'missing' }); } }, }; }, };