'use strict'; const escapeString = require('./utils/escape-string.js'); const translateToKey = require('./shared/event-keys.js'); const {isNumberLiteral} = require('./ast/index.js'); const MESSAGE_ID = 'prefer-keyboard-event-key'; const messages = { [MESSAGE_ID]: 'Use `.key` instead of `.{{name}}`.', }; const keys = new Set([ 'keyCode', 'charCode', 'which', ]); const isPropertyNamedAddEventListener = node => node?.type === 'CallExpression' && node.callee.type === 'MemberExpression' && node.callee.property.name === 'addEventListener'; const getEventNodeAndReferences = (context, node) => { const eventListener = getMatchingAncestorOfType(node, 'CallExpression', isPropertyNamedAddEventListener); const callback = eventListener?.arguments[1]; switch (callback?.type) { case 'ArrowFunctionExpression': case 'FunctionExpression': { const eventVariable = context.getDeclaredVariables(callback)[0]; const references = eventVariable?.references; return { event: callback.params[0], references, }; } default: { return {}; } } }; const isPropertyOf = (node, eventNode) => node?.parent?.type === 'MemberExpression' && node.parent.object === eventNode; // The third argument is a condition function, as one passed to `Array#filter()` // Helpful if nearest node of type also needs to have some other property const getMatchingAncestorOfType = (node, type, testFunction = () => true) => { let current = node; while (current) { if (current.type === type && testFunction(current)) { return current; } current = current.parent; } }; const getParentByLevel = (node, level) => { let current = node; while (current && level) { level--; current = current.parent; } /* c8 ignore next 3 */ if (level === 0) { return current; } }; const fix = node => fixer => { // Since we're only fixing direct property access usages, like `event.keyCode` const nearestIf = getParentByLevel(node, 3); if (!nearestIf || nearestIf.type !== 'IfStatement') { return; } const {type, operator, right} = nearestIf.test; if ( !( type === 'BinaryExpression' && (operator === '==' || operator === '===') && isNumberLiteral(right) ) ) { return; } // Either a meta key or a printable character const key = translateToKey[right.value] || String.fromCodePoint(right.value); // And if we recognize the `.keyCode` if (!key) { return; } // Apply fixes return [ fixer.replaceText(node, 'key'), fixer.replaceText(right, escapeString(key)), ]; }; const getProblem = node => ({ messageId: MESSAGE_ID, data: {name: node.name}, node, fix: fix(node), }); /** @param {import('eslint').Rule.RuleContext} context */ const create = context => ({ 'Identifier:matches([name="keyCode"], [name="charCode"], [name="which"])'(node) { // Normal case when usage is direct -> `event.keyCode` const {event, references} = getEventNodeAndReferences(context, node); if (!event) { return; } if ( references && references.some(reference => isPropertyOf(node, reference.identifier)) ) { return getProblem(node); } }, Property(node) { // Destructured case const propertyName = node.value.name; if (!keys.has(propertyName)) { return; } const {event, references} = getEventNodeAndReferences(context, node); if (!event) { return; } const nearestVariableDeclarator = getMatchingAncestorOfType( node, 'VariableDeclarator', ); const initObject = nearestVariableDeclarator?.init; // Make sure initObject is a reference of eventVariable if ( references && references.some(reference => reference.identifier === initObject) ) { return getProblem(node.value); } // When the event parameter itself is destructured directly const isEventParameterDestructured = event.type === 'ObjectPattern'; if (isEventParameterDestructured) { // Check for properties for (const property of event.properties) { if (property === node) { return getProblem(node.value); } } } }, }); /** @type {import('eslint').Rule.RuleModule} */ module.exports = { create, meta: { type: 'suggestion', docs: { description: 'Prefer `KeyboardEvent#key` over `KeyboardEvent#keyCode`.', }, fixable: 'code', messages, }, };