import type { Rule } from 'eslint' import { ESCAPE_MESSAGES, isEscaped } from './escape' interface JSXAttributeValue { type: string value?: unknown expression?: { type: string; value?: unknown; expressions?: unknown[]; quasis?: { value: { cooked: string | null } }[] } } interface JSXAttribute { type: 'JSXAttribute' name: { type: string; name?: string } value: JSXAttributeValue | null } interface JSXOpeningElement { name: { type: string; name?: string } attributes: (JSXAttribute | { type: 'JSXSpreadAttribute' })[] loc: NonNullable range: [number, number] } /** * What the package has instead of one raw element: the component the message * names, and where one element does more than one job, the others after it. * `null` is an element the package has no part for yet. */ export interface RawElementPart { use: string more?: string } /** * The mapping (UIG-7), derived from the package's exports: every interactive * element in the HTML standard's list ("interactive content"), plus ``, * `
` and ``, which have a part here. docs/GATES.md prints it. * * Not here, on purpose: `` (it lives inside a `
`, which is * reported), `` and `` (they live inside a * `` is as many controls as its `type`. A type missing from here * (text, email, password, url, tel, number, or one the browser does not know) * is a text box. */ export const RAW_INPUT_PARTS: Record = { search: { use: 'SearchInput' }, checkbox: { use: 'Checkbox' }, file: { use: 'FilePicker' }, submit: { use: 'Button' }, button: { use: 'Button' }, reset: { use: 'Button' }, image: { use: 'Button' }, radio: null, range: null, color: null, date: null, 'datetime-local': null, month: null, week: null, time: null, } const TEXT_INPUT: RawElementPart = { use: 'TextInput' } const ANY_INPUT: RawElementPart = { use: 'TextInput', more: ' For a search, `SearchInput`; for a tick box, `Checkbox`; to pick files, `FilePicker`.', } function attribute(element: JSXOpeningElement, name: string): JSXAttribute | undefined { return element.attributes.find((a): a is JSXAttribute => a.type === 'JSXAttribute' && a.name.name === name) } /** An attribute's value when the code spells it out, `undefined` when it is computed. */ function literal(attr: JSXAttribute): string | boolean | undefined { const value = attr.value if (value === null) return true if (value.type === 'Literal') return typeof value.value === 'string' ? value.value : undefined if (value.type !== 'JSXExpressionContainer' || !value.expression) return undefined const expression = value.expression if (expression.type === 'Literal' && (typeof expression.value === 'string' || typeof expression.value === 'boolean')) return expression.value if (expression.type === 'TemplateLiteral' && expression.expressions?.length === 0) return expression.quasis?.[0]?.value.cooked ?? undefined return undefined } /** Present, and not written as `={false}`. */ function isOn(element: JSXOpeningElement, name: string): boolean { const attr = attribute(element, name) return attr !== undefined && literal(attr) !== false } type Found = { report: 'raw'; element: string; part: RawElementPart } | { report: 'noPart'; element: string } | { report: 'clickable'; element: string } | null /** The pointer's press, as a handler on a plain element makes it a control (R5). */ const CLICK_HANDLERS = ['onClick', 'onMouseDown', 'onMouseUp', 'onPointerDown', 'onPointerUp', 'onDoubleClick'] interface Expression { type: string [key: string]: unknown } /** `e.stopPropagation()` or `e.preventDefault()`: a handler that only keeps the click from what is behind. */ function onlyStops(handler: Expression | undefined): boolean { if (!handler || (handler.type !== 'ArrowFunctionExpression' && handler.type !== 'FunctionExpression')) return false const body = handler.body as Expression const calls = body.type === 'BlockStatement' ? (body.body as Expression[]).map((s) => (s.type === 'ExpressionStatement' ? (s.expression as Expression) : s)) : [body] return ( calls.length > 0 && calls.every((call) => { if (call.type !== 'CallExpression') return false const callee = call.callee as Expression const property = callee.type === 'MemberExpression' ? (callee.property as Expression) : undefined return property?.type === 'Identifier' && (property.name === 'stopPropagation' || property.name === 'preventDefault') }) ) } /** A plain element made clickable by hand: the first press handler that does more than stop the click. */ function clickHandler(element: JSXOpeningElement): string | undefined { for (const name of CLICK_HANDLERS) { const attr = attribute(element, name) if (!attr) continue const value = attr.value const handler = value?.type === 'JSXExpressionContainer' ? (value.expression as Expression | undefined) : undefined if (!onlyStops(handler)) return name } return undefined } /** What this element is to the rule: a raw element with a part, one with none yet, or none of its business. */ export function classify(element: JSXOpeningElement): Found { if (element.name.type !== 'JSXIdentifier' || !element.name.name) return null const name = element.name.name if (name === 'input') { const attr = attribute(element, 'type') const type = attr ? literal(attr) : 'text' if (typeof type !== 'string') return { report: 'raw', element: '', part: ANY_INPUT } const key = type.toLowerCase() // A hidden input draws nothing and takes no focus: it is data, not a control. if (key === 'hidden') return null if (!(key in RAW_INPUT_PARTS)) return { report: 'raw', element: '', part: TEXT_INPUT } const part = RAW_INPUT_PARTS[key] const shown = `` return part ? { report: 'raw', element: shown, part } : { report: 'noPart', element: shown } } if (!Object.hasOwn(RAW_ELEMENT_PARTS, name)) { // A plain element with a press handler is a button drawn by hand (Katerina's ruling R5, // 25 September): no focus, no keys, no look anybody keeps in step. `