diff --git a/docs/rules/prefer-event-key.md b/docs/rules/prefer-event-key.md new file mode 100644 index 0000000000..1d9f864a89 --- /dev/null +++ b/docs/rules/prefer-event-key.md @@ -0,0 +1,39 @@ +# Prefer `KeyboardEvent#key` over `KeyboardEvent#keyCode` + +Enforces the use of [`KeyboardEvent#key`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key) over [`KeyboardEvent#keyCode`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode) which is deprecated. The `.key` property is also more semantic and readable. + +This rule is partly fixable. It can only fix direct property access. + + +## Fail + +```js +window.addEventListener('keydown', event => { + console.log(event.keyCode); +}); +``` + +```js +window.addEventListener('keydown', event => { + if (event.keyCode === 8) { + console.log('Backspace was pressed'); + } +}); +``` + + +## Pass + +```js +window.addEventListener('click', event => { + console.log(event.key); +}); +``` + +```js +window.addEventListener('keydown', event => { + if (event.key === 'Backspace') { + console.log('Backspace was pressed'); + } +}); +``` diff --git a/index.js b/index.js index 3a9cd9d883..5ba12cf750 100644 --- a/index.js +++ b/index.js @@ -39,6 +39,7 @@ module.exports = { 'unicorn/no-zero-fractions': 'error', 'unicorn/number-literal-case': 'error', 'unicorn/prefer-add-event-listener': 'error', + 'unicorn/prefer-event-key': 'error', 'unicorn/prefer-exponentiation-operator': 'error', 'unicorn/prefer-flat-map': 'error', 'unicorn/prefer-includes': 'error', diff --git a/readme.md b/readme.md index dd8342967a..23ec8687bf 100644 --- a/readme.md +++ b/readme.md @@ -57,6 +57,7 @@ Configure it in `package.json`. "unicorn/no-zero-fractions": "error", "unicorn/number-literal-case": "error", "unicorn/prefer-add-event-listener": "error", + "unicorn/prefer-event-key": "error", "unicorn/prefer-exponentiation-operator": "error", "unicorn/prefer-flat-map": "error", "unicorn/prefer-includes": "error", @@ -100,6 +101,7 @@ Configure it in `package.json`. - [no-zero-fractions](docs/rules/no-zero-fractions.md) - Disallow number literals with zero fractions or dangling dots. *(fixable)* - [number-literal-case](docs/rules/number-literal-case.md) - Enforce lowercase identifier and uppercase value for number literals. *(fixable)* - [prefer-add-event-listener](docs/rules/prefer-add-event-listener.md) - Prefer `.addEventListener()` and `.removeEventListener()` over `on`-functions. *(partly fixable)* +- [prefer-event-key](docs/rules/prefer-event-key.md) - Prefer `KeyboardEvent#key` over `KeyboardEvent#keyCode`. *(partly fixable)* - [prefer-exponentiation-operator](docs/rules/prefer-exponentiation-operator.md) - Prefer the exponentiation operator over `Math.pow()` *(fixable)* - [prefer-flat-map](docs/rules/prefer-flat-map.md) - Prefer `.flatMap(…)` over `.map(…).flat()`. *(fixable)* - [prefer-includes](docs/rules/prefer-includes.md) - Prefer `.includes()` over `.indexOf()` when checking for existence or non-existence. *(fixable)* diff --git a/rules/prefer-event-key.js b/rules/prefer-event-key.js new file mode 100644 index 0000000000..ef2006532d --- /dev/null +++ b/rules/prefer-event-key.js @@ -0,0 +1,225 @@ +'use strict'; +const getDocsUrl = require('./utils/get-docs-url'); + +const keys = [ + 'keyCode', + 'charCode', + 'which' +]; + +// https://github.com/facebook/react/blob/b87aabd/packages/react-dom/src/events/getEventKey.js#L36 +// Only meta characters which can't be deciphered from `String.fromCharCode()` +const translateToKey = { + 8: 'Backspace', + 9: 'Tab', + 12: 'Clear', + 13: 'Enter', + 16: 'Shift', + 17: 'Control', + 18: 'Alt', + 19: 'Pause', + 20: 'CapsLock', + 27: 'Escape', + 32: ' ', + 33: 'PageUp', + 34: 'PageDown', + 35: 'End', + 36: 'Home', + 37: 'ArrowLeft', + 38: 'ArrowUp', + 39: 'ArrowRight', + 40: 'ArrowDown', + 45: 'Insert', + 46: 'Delete', + 112: 'F1', + 113: 'F2', + 114: 'F3', + 115: 'F4', + 116: 'F5', + 117: 'F6', + 118: 'F7', + 119: 'F8', + 120: 'F9', + 121: 'F10', + 122: 'F11', + 123: 'F12', + 144: 'NumLock', + 145: 'ScrollLock', + 186: ';', + 187: '=', + 188: ',', + 189: '-', + 190: '.', + 191: '/', + 219: '[', + 220: '\\', + 221: ']', + 222: '\'', + 224: 'Meta' +}; + +const isPropertyNamedAddEventListener = node => + node && + node.type === 'CallExpression' && + node.callee && + node.callee.type === 'MemberExpression' && + node.callee.property && + node.callee.property.name === 'addEventListener'; + +const getEventNodeAndReferences = (context, node) => { + const eventListener = getMatchingAncestorOfType(node, 'CallExpression', isPropertyNamedAddEventListener); + const callback = eventListener && eventListener.arguments && eventListener.arguments[1]; + switch (callback && callback.type) { + case 'ArrowFunctionExpression': + case 'FunctionExpression': { + const eventVariable = context.getDeclaredVariables(callback)[0]; + const references = eventVariable && eventVariable.references; + return { + event: callback.params && callback.params[0], + references + }; + } + + default: + return {}; + } +}; + +const isPropertyOf = (node, eventNode) => { + return ( + node && + node.parent && + node.parent.type === 'MemberExpression' && + node.parent.object && + 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, fn = n => n || true) => { + let current = node; + while (current) { + if (current.type === type && fn(current)) { + return current; + } + + current = current.parent; + } + + return null; +}; + +const getParentByLevel = (node, level) => { + let current = node; + while (current && level) { + level--; + current = current.parent; + } + + 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 {right = {}, operator} = nearestIf.test; + const isTestingEquality = operator === '==' || operator === '==='; + const isRightValid = isTestingEquality && right.type === 'Literal' && typeof right.value === 'number'; + // Either a meta key or a printable character + const keyCode = translateToKey[right.value] || String.fromCharCode(right.value); + // And if we recognize the `.keyCode` + if (!isRightValid || !keyCode) { + return; + } + + // Apply fixes + return [ + fixer.replaceText(node, 'key'), + fixer.replaceText(right, `'${keyCode}'`) + ]; +}; + +const create = context => { + const report = node => { + context.report({ + message: `Use \`.key\` instead of \`.${node.name}\``, + node, + fix: fix(node) + }); + }; + + return { + '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; + } + + const isPropertyOfEvent = Boolean(references && references.find(r => isPropertyOf(node, r.identifier))); + if (isPropertyOfEvent) { + report(node); + } + }, + + Property(node) { + // Destructured case + const propertyName = node.value && node.value.name; + if (!keys.includes(propertyName)) { + return; + } + + const {event, references} = getEventNodeAndReferences(context, node); + if (!event) { + return; + } + + const nearestVariableDeclarator = getMatchingAncestorOfType( + node, + 'VariableDeclarator' + ); + const initObject = + nearestVariableDeclarator && + nearestVariableDeclarator.init && + nearestVariableDeclarator.init; + + // Make sure initObject is a reference of eventVariable + const isReferenceOfEvent = Boolean( + references && references.find(r => r.identifier === initObject) + ); + if (isReferenceOfEvent) { + report(node.value); + return; + } + + // When the event parameter itself is destructured directly + const isEventParamDestructured = event.type === 'ObjectPattern'; + if (isEventParamDestructured) { + // Check for properties + for (const prop of event.properties) { + if (prop === node) { + report(node.value); + } + } + } + } + }; +}; + +module.exports = { + create, + meta: { + type: 'suggestion', + docs: { + url: getDocsUrl(__filename) + }, + fixable: 'code' + } +}; diff --git a/test/prefer-event-key.js b/test/prefer-event-key.js new file mode 100644 index 0000000000..51fa1b6493 --- /dev/null +++ b/test/prefer-event-key.js @@ -0,0 +1,747 @@ +import test from 'ava'; +import avaRuleTester from 'eslint-ava-rule-tester'; +import rule from '../rules/prefer-event-key'; + +const ruleTester = avaRuleTester(test, { + env: { + es6: true + } +}); + +const error = key => ({ + ruleId: 'prefer-event-key', + message: `Use \`.key\` instead of \`.${key}\`` +}); + +ruleTester.run('prefer-event-key', rule, { + valid: [ + `window.addEventListener('click', e => { + console.log(e.key); + })`, + `window.addEventListener('click', () => { + console.log(keyCode, which, charCode); + console.log(window.keyCode); + })`, + `foo.addEventListener('click', (e, r, fg) => { + function a() { + if (true) { + { + { + const e = {}; + const { charCode } = e; + console.log(e.keyCode, charCode); + } + } + } + } + });`, + ` + const e = {} + foo.addEventListener('click', function (event) { + function a() { + if (true) { + { + { + console.log(e.keyCode); + } + } + } + } + }); + `, + 'const { keyCode } = e', + 'const { charCode } = e', + 'const {a, b, c} = event', + 'const keyCode = () => 4', + 'const which = keyCode => 5', + 'function which(abc) { const {keyCode} = abc; return keyCode}', + 'const { which } = e', + 'const { keyCode: key } = e', + 'const { keyCode: abc } = e', + `foo.addEventListener('keydown', e => { + (function (abc) { + if (e.key === 'ArrowLeft') return true; + const { charCode } = abc; + }()) + })`, + `foo.addEventListener('keydown', e => { + if (e.key === 'ArrowLeft') return true; + })`, + `a.addEventListener('keyup', function (event) { + const key = event.key; + })`, + `a.addEventListener('keyup', function (event) { + const { key } = event; + })`, + `foo.addEventListener('click', e => { + const good = {}; + good.keyCode = '34'; + });`, + `foo.addEventListener('click', e => { + const good = {}; + good.charCode = '34'; + });`, + `foo.addEventListener('click', e => { + const good = {}; + good.which = '34'; + });`, + `foo.addEventListener('click', e => { + const {keyCode: a, charCode: b, charCode: c} = e; + });`, + `add.addEventListener('keyup', event => { + f.addEventList('some', e => { + const {charCode} = e; + console.log(event.key) + }) + })`, + `foo.addEventListener('click', e => { + { + const e = {}; + console.log(e.keyCode); + } + });` + ], + + invalid: [ + { + code: ` + window.addEventListener('click', e => { + console.log(e.keyCode); + }) + `, + errors: [error('keyCode')] + }, + { + code: ` + window.addEventListener('click', ({keyCode}) => { + console.log(keyCode); + }) + `, + errors: [error('keyCode')] + }, + { + code: ` + window.addEventListener('click', ({which}) => { + if (which === 23) { + console.log('Wrong!') + } + }) + `, + errors: [error('which')] + }, + { + code: ` + foo123.addEventListener('click', event => { + if (event.keyCode === 27) { + } + }); + `, + output: ` + foo123.addEventListener('click', event => { + if (event.key === 'Escape') { + } + }); + `, + errors: [error('keyCode')] + }, + { + code: ` + foo.addEventListener('click', a => { + if (a.keyCode === 27) { + } + }); + `, + output: ` + foo.addEventListener('click', a => { + if (a.key === 'Escape') { + } + }); + `, + errors: [error('keyCode')] + }, + { + code: ` + foo.addEventListener('click', (a, b, c) => { + if (a.keyCode === 27) { + } + }); + `, + output: ` + foo.addEventListener('click', (a, b, c) => { + if (a.key === 'Escape') { + } + }); + `, + errors: [error('keyCode')] + }, + { + code: ` + foo.addEventListener('click', function(a, b, c) { + if (a.keyCode === 27) { + } + }); + `, + output: ` + foo.addEventListener('click', function(a, b, c) { + if (a.key === 'Escape') { + } + }); + `, + errors: [error('keyCode')] + }, + { + code: ` + foo.addEventListener('click', function(b) { + if (b.keyCode === 27) { + } + }); + `, + output: ` + foo.addEventListener('click', function(b) { + if (b.key === 'Escape') { + } + }); + `, + errors: [error('keyCode')] + }, + { + code: ` + foo.addEventListener('click', e => { + const {keyCode, a, b} = e; + }); + `, + errors: [error('keyCode')] + }, + { + code: ` + foo.addEventListener('click', e => { + const {a: keyCode, a, b} = e; + }); + `, + errors: [error('keyCode')] + }, + { + code: ` + add.addEventListener('keyup', event => { + f.addEventList('some', e => { + const {keyCode} = event; + console.log(event.key) + }) + }) + `, + errors: [error('keyCode')] + }, + { + code: ` + window.addEventListener('click', e => { + console.log(e.charCode); + }) + `, + errors: [error('charCode')] + }, + { + code: ` + foo11111111.addEventListener('click', event => { + if (event.charCode === 27) { + } + }); + `, + output: ` + foo11111111.addEventListener('click', event => { + if (event.key === 'Escape') { + } + }); + `, + errors: [error('charCode')] + }, + { + code: ` + foo.addEventListener('click', a => { + if (a.charCode === 27) { + } + }); + `, + errors: [error('charCode')], + output: ` + foo.addEventListener('click', a => { + if (a.key === 'Escape') { + } + }); + ` + }, + { + code: ` + foo.addEventListener('click', (a, b, c) => { + if (a.charCode === 27) { + } + }); + `, + errors: [error('charCode')], + output: ` + foo.addEventListener('click', (a, b, c) => { + if (a.key === 'Escape') { + } + }); + ` + }, + { + code: ` + foo.addEventListener('click', function(a, b, c) { + if (a.charCode === 27) { + } + }); + `, + output: ` + foo.addEventListener('click', function(a, b, c) { + if (a.key === 'Escape') { + } + }); + `, + errors: [error('charCode')] + }, + { + code: ` + foo.addEventListener('click', function(b) { + if (b.charCode === 27) { + } + }); + `, + errors: [error('charCode')], + output: ` + foo.addEventListener('click', function(b) { + if (b.key === 'Escape') { + } + }); + ` + }, + { + code: ` + foo.addEventListener('click', e => { + const {charCode, a, b} = e; + }); + `, + errors: [error('charCode')] + }, + { + code: ` + foo.addEventListener('click', e => { + const {a: charCode, a, b} = e; + }); + `, + errors: [error('charCode')] + }, + { + code: ` + window.addEventListener('click', e => { + console.log(e.which); + }) + `, + errors: [error('which')] + }, + { + code: ` + foo.addEventListener('click', event => { + if (event.which === 27) { + } + }); + `, + errors: [error('which')], + output: ` + foo.addEventListener('click', event => { + if (event.key === 'Escape') { + } + }); + ` + }, + { + code: ` + foo.addEventListener('click', a => { + if (a.which === 27) { + } + }); + `, + errors: [error('which')], + output: ` + foo.addEventListener('click', a => { + if (a.key === 'Escape') { + } + }); + ` + }, + { + code: ` + foo.addEventListener('click', (a, b, c) => { + if (a.which === 27) { + } + }); + `, + errors: [error('which')], + output: ` + foo.addEventListener('click', (a, b, c) => { + if (a.key === 'Escape') { + } + }); + ` + }, + { + code: ` + foo.addEventListener('click', function(a, b, c) { + if (a.which === 27) { + } + }); + `, + errors: [error('which')], + output: ` + foo.addEventListener('click', function(a, b, c) { + if (a.key === 'Escape') { + } + }); + ` + }, + { + code: ` + foo.addEventListener('click', function(b) { + if (b.which === 27) { + } + }); + `, + errors: [error('which')], + output: ` + foo.addEventListener('click', function(b) { + if (b.key === 'Escape') { + } + }); + ` + }, + { + code: ` + foo.addEventListener('click', e => { + const {which, a, b} = e; + }); + `, + errors: [error('which')] + }, + { + code: ` + foo.addEventListener('click', e => { + const {a: which, a, b} = e; + }); + `, + errors: [error('which')] + }, + { + code: ` + foo.addEventListener('click', function(b) { + if (b.which === 27) { + } + const {keyCode} = b; + if (keyCode === 32) return 4; + }); + `, + errors: [error('which'), error('keyCode')], + output: ` + foo.addEventListener('click', function(b) { + if (b.key === 'Escape') { + } + const {keyCode} = b; + if (keyCode === 32) return 4; + }); + ` + }, + { + code: ` + foo.addEventListener('click', function(b) { + if (b.which > 27) { + } + const {keyCode} = b; + if (keyCode === 32) return 4; + }); + `, + errors: [error('which'), error('keyCode')], + output: ` + foo.addEventListener('click', function(b) { + if (b.which > 27) { + } + const {keyCode} = b; + if (keyCode === 32) return 4; + }); + ` + }, + { + code: ` + const e = {} + foo.addEventListener('click', (e, r, fg) => { + function a() { + if (true) { + { + { + const { charCode } = e; + console.log(e.keyCode, charCode); + } + } + } + } + }); + `, + errors: [error('charCode'), error('keyCode')], + output: ` + const e = {} + foo.addEventListener('click', (e, r, fg) => { + function a() { + if (true) { + { + { + const { charCode } = e; + console.log(e.keyCode, charCode); + } + } + } + } + }); + ` + }, + { + code: ` + const e = {} + foo.addEventListener('click', (e, r, fg) => { + function a() { + if (true) { + { + { + const { charCode } = e; + console.log(e.keyCode, charCode); + } + } + } + } + }); + `, + errors: [error('charCode'), error('keyCode')], + output: ` + const e = {} + foo.addEventListener('click', (e, r, fg) => { + function a() { + if (true) { + { + { + const { charCode } = e; + console.log(e.keyCode, charCode); + } + } + } + } + }); + ` + }, + { + code: ` + foo123.addEventListener('click', event => { + if (event.keyCode === 13) { + } + }); + `, + output: ` + foo123.addEventListener('click', event => { + if (event.key === 'Enter') { + } + }); + `, + errors: [error('keyCode')] + }, + { + code: ` + foo123.addEventListener('click', event => { + if (event.keyCode === 38) { + } + }); + `, + output: ` + foo123.addEventListener('click', event => { + if (event.key === 'ArrowUp') { + } + }); + `, + errors: [error('keyCode')] + }, + { + code: ` + foo123.addEventListener('click', event => { + if (event.keyCode === 40) { + } + }); + `, + output: ` + foo123.addEventListener('click', event => { + if (event.key === 'ArrowDown') { + } + }); + `, + errors: [error('keyCode')] + }, + { + code: ` + foo123.addEventListener('click', event => { + if (event.keyCode === 37) { + } + }); + `, + output: ` + foo123.addEventListener('click', event => { + if (event.key === 'ArrowLeft') { + } + }); + `, + errors: [error('keyCode')] + }, + { + code: ` + foo123.addEventListener('click', event => { + if (event.keyCode === 39) { + } + }); + `, + output: ` + foo123.addEventListener('click', event => { + if (event.key === 'ArrowRight') { + } + }); + `, + errors: [error('keyCode')] + }, + { + code: ` + foo123.addEventListener('click', event => { + if (event.keyCode === 221) { + } + }); + `, + output: ` + foo123.addEventListener('click', event => { + if (event.key === ']') { + } + }); + `, + errors: [error('keyCode')] + }, + { + code: ` + foo123.addEventListener('click', event => { + if (event.keyCode === 186) { + } + }); + `, + output: ` + foo123.addEventListener('click', event => { + if (event.key === ';') { + } + }); + `, + errors: [error('keyCode')] + }, + { + code: ` + foo123.addEventListener('click', event => { + if (event.keyCode === 187) { + } + }); + `, + output: ` + foo123.addEventListener('click', event => { + if (event.key === '=') { + } + }); + `, + errors: [error('keyCode')] + }, + { + code: ` + foo123.addEventListener('click', event => { + if (event.keyCode === 188) { + } + }); + `, + output: ` + foo123.addEventListener('click', event => { + if (event.key === ',') { + } + }); + `, + errors: [error('keyCode')] + }, + { + code: ` + foo123.addEventListener('click', event => { + if (event.keyCode === 189) { + } + }); + `, + output: ` + foo123.addEventListener('click', event => { + if (event.key === '-') { + } + }); + `, + errors: [error('keyCode')] + }, + { + code: ` + foo123.addEventListener('click', event => { + if (event.keyCode === 190) { + } + }); + `, + output: ` + foo123.addEventListener('click', event => { + if (event.key === '.') { + } + }); + `, + errors: [error('keyCode')] + }, + { + code: ` + foo123.addEventListener('click', event => { + if (event.keyCode === 191) { + } + }); + `, + output: ` + foo123.addEventListener('click', event => { + if (event.key === '/') { + } + }); + `, + errors: [error('keyCode')] + }, + { + code: ` + foo123.addEventListener('click', event => { + if (event.keyCode === 219) { + } + }); + `, + output: ` + foo123.addEventListener('click', event => { + if (event.key === '[') { + } + }); + `, + errors: [error('keyCode')] + }, + { + code: ` + foo123.addEventListener('click', event => { + if (event.keyCode === 222) { + } + }); + `, + output: ` + foo123.addEventListener('click', event => { + if (event.key === ''') { + } + }); + `, + errors: [error('keyCode')] + } + ] +});