diff --git a/.changeset/eighty-crews-end.md b/.changeset/eighty-crews-end.md new file mode 100644 index 0000000000..1bec7d8563 --- /dev/null +++ b/.changeset/eighty-crews-end.md @@ -0,0 +1,5 @@ +--- +"@sit-onyx/eslint-plugin": minor +--- + +feat(no-shadow-native-events): duplicated implementation here until it is [released offically](https://github.com/vuejs/eslint-plugin-vue/issues/2557) diff --git a/packages/eslint-plugin/src/index.cjs b/packages/eslint-plugin/src/index.cjs index a10b354665..5ce2e202f3 100644 --- a/packages/eslint-plugin/src/index.cjs +++ b/packages/eslint-plugin/src/index.cjs @@ -8,5 +8,6 @@ module.exports = { }, rules: { "import-playwright-a11y": require("./rules/import-playwright-a11y.cjs"), + "no-shadow-native": require("./rules/no-shadow-native-events.cjs"), }, }; diff --git a/packages/eslint-plugin/src/rules/dom-events.json b/packages/eslint-plugin/src/rules/dom-events.json new file mode 100644 index 0000000000..c3ae470894 --- /dev/null +++ b/packages/eslint-plugin/src/rules/dom-events.json @@ -0,0 +1,85 @@ +[ + "copy", + "cut", + "paste", + "compositionend", + "compositionstart", + "compositionupdate", + "drag", + "dragend", + "dragenter", + "dragexit", + "dragleave", + "dragover", + "dragstart", + "drop", + "focus", + "focusin", + "focusout", + "blur", + "change", + "beforeinput", + "input", + "reset", + "submit", + "invalid", + "load", + "error", + "keydown", + "keypress", + "keyup", + "auxclick", + "click", + "contextmenu", + "dblclick", + "mousedown", + "mouseenter", + "mouseleave", + "mousemove", + "mouseout", + "mouseover", + "mouseup", + "abort", + "canplay", + "canplaythrough", + "durationchange", + "emptied", + "encrypted", + "ended", + "loadeddata", + "loadedmetadata", + "loadstart", + "pause", + "play", + "playing", + "progress", + "ratechange", + "seeked", + "seeking", + "stalled", + "suspend", + "timeupdate", + "volumechange", + "waiting", + "select", + "scroll", + "scrollend", + "touchcancel", + "touchend", + "touchmove", + "touchstart", + "pointerdown", + "pointermove", + "pointerup", + "pointercancel", + "pointerenter", + "pointerleave", + "pointerover", + "pointerout", + "wheel", + "animationstart", + "animationend", + "animationiteration", + "transitionend", + "transitionstart" +] diff --git a/packages/eslint-plugin/src/rules/no-shadow-native-events.cjs b/packages/eslint-plugin/src/rules/no-shadow-native-events.cjs new file mode 100644 index 0000000000..e4ccd0bcc6 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-shadow-native-events.cjs @@ -0,0 +1,330 @@ +/** + * @author Jonathan Carle + * See LICENSE file in root directory for full license. + */ +"use strict"; + +const utils = require("eslint-plugin-vue/lib/utils"); +const domEvents = require("./dom-events.json"); +const { findVariable } = require("@eslint-community/eslint-utils"); +/** + * @typedef {import('eslint-plugin-vue/lib/utils').ComponentEmit} ComponentEmit + * @typedef {import('eslint-plugin-vue/lib/utils').ComponentProp} ComponentProp + * @typedef {import('eslint-plugin-vue/lib/utils').VueObjectData} VueObjectData + * @typedef {import('./require-explicit-emits.js').NameWithLoc} NameWithLoc + */ + +/** + * Get the name param node from the given CallExpression + * @param {CallExpression} node CallExpression + * @returns { NameWithLoc | null } + */ +function getNameParamNode(node) { + const nameLiteralNode = node.arguments[0]; + if (nameLiteralNode && utils.isStringLiteral(nameLiteralNode)) { + const name = utils.getStringLiteralValue(nameLiteralNode); + if (name != null) { + return { name, loc: nameLiteralNode.loc, range: nameLiteralNode.range }; + } + } + + // cannot check + return null; +} + +/** + * Check if the given name matches defineEmitsNode variable name + * @param {string} name + * @param {CallExpression | undefined} defineEmitsNode + * @returns {boolean} + */ +function isEmitVariableName(name, defineEmitsNode) { + const node = defineEmitsNode?.parent; + + if (node?.type === "VariableDeclarator" && node.id.type === "Identifier") { + return name === node.id.name; + } + + return false; +} + +/** + * @type {import('eslint').Rule.RuleModule} + */ +module.exports = { + meta: { + type: "problem", + docs: { + description: "disallow the use of event names that collide with native web event names", + categories: undefined, + url: "https://eslint.vuejs.org/rules/no-shadow-native-events.html", + }, + schema: [], + messages: { + violation: + 'Use a different emit name to avoid shadowing the native event with name "{{ name }}". Consider an emit name which communicates the users intent, if applicable.', + }, + }, + /** @param {RuleContext} context */ + create(context) { + /** @type {Map, emitReferenceIds: Set }>} */ + const setupContexts = new Map(); + + /** + * Tracks violating emit definitions, so that calls of this emit are not reported additionally. + * @type {Set} + * */ + const definedAndReportedEmits = new Set(); + + /** + * @typedef {object} VueTemplateDefineData + * @property {'export' | 'mark' | 'definition' | 'setup'} type + * @property {ObjectExpression | Program} define + * @property {CallExpression} [defineEmits] + */ + /** @type {VueTemplateDefineData | null} */ + let vueTemplateDefineData = null; + + const programNode = context.getSourceCode().ast; + if (utils.isScriptSetup(context)) { + // init + vueTemplateDefineData = { + type: "setup", + define: programNode, + }; + } + + /** + * Verify if an emit call violates the rule of not using a native dom event name. + * @param {NameWithLoc} nameWithLoc + */ + function verifyEmit(nameWithLoc) { + const name = nameWithLoc.name.toLowerCase(); + if (!domEvents.includes(name) || definedAndReportedEmits.has(name)) { + return; + } + context.report({ + loc: nameWithLoc.loc, + messageId: "violation", + data: { + name, + }, + }); + } + + /** + * Verify if an emit declaration violates the rule of not using a native dom event name. + * @param {ComponentEmit[]} emits + */ + const verifyEmitDeclaration = (emits) => { + for (const { node, emitName } of emits) { + if (!node || !emitName || !domEvents.includes(emitName.toLowerCase())) { + continue; + } + + definedAndReportedEmits.add(emitName); + context.report({ + messageId: "violation", + data: { name: emitName }, + loc: node.loc, + }); + } + }; + + const callVisitor = { + /** + * @param {CallExpression} node + * @param {VueObjectData} [info] + */ + CallExpression(node, info) { + const callee = utils.skipChainExpression(node.callee); + const nameWithLoc = getNameParamNode(node); + if (!nameWithLoc) { + // cannot check + return; + } + const vueDefineNode = info ? info.node : programNode; + + let emit; + if (callee.type === "MemberExpression") { + const name = utils.getStaticPropertyName(callee); + if (name === "emit" || name === "$emit") { + emit = { name, member: callee }; + } + } + + // verify setup context + const setupContext = setupContexts.get(vueDefineNode); + if (setupContext) { + const { contextReferenceIds, emitReferenceIds } = setupContext; + if (callee.type === "Identifier" && emitReferenceIds.has(callee)) { + // verify setup(props,{emit}) {emit()} + verifyEmit(nameWithLoc); + } else if (emit && emit.name === "emit") { + const memObject = utils.skipChainExpression(emit.member.object); + if (memObject.type === "Identifier" && contextReferenceIds.has(memObject)) { + // verify setup(props,context) {context.emit()} + verifyEmit(nameWithLoc); + } + } + } + + // verify $emit + if (emit && emit.name === "$emit") { + const memObject = utils.skipChainExpression(emit.member.object); + if (utils.isThis(memObject, context)) { + // verify this.$emit() + verifyEmit(nameWithLoc); + } + } + }, + }; + + return utils.compositingVisitors( + utils.defineTemplateBodyVisitor( + context, + { + /** @param { CallExpression } node */ + CallExpression(node) { + const callee = utils.skipChainExpression(node.callee); + const nameWithLoc = getNameParamNode(node); + if (!nameWithLoc) { + // cannot check + return; + } + + // e.g. $emit() / emit() in template + if ( + callee.type === "Identifier" && + (callee.name === "$emit" || + (vueTemplateDefineData?.defineEmits && + isEmitVariableName(callee.name, vueTemplateDefineData.defineEmits))) + ) { + verifyEmit(nameWithLoc); + } + }, + }, + utils.compositingVisitors( + utils.defineScriptSetupVisitor(context, { + onDefineEmitsEnter: (node, emits) => { + verifyEmitDeclaration(emits); + if (vueTemplateDefineData && vueTemplateDefineData.type === "setup") { + vueTemplateDefineData.defineEmits = node; + } + + if ( + !node.parent || + node.parent.type !== "VariableDeclarator" || + node.parent.init !== node + ) { + return; + } + + const emitParam = node.parent.id; + const variable = + emitParam.type === "Identifier" + ? findVariable(utils.getScope(context, emitParam), emitParam) + : null; + if (!variable) { + return; + } + /** @type {Set} */ + const emitReferenceIds = new Set(); + for (const reference of variable.references) { + if (!reference.isRead()) { + continue; + } + + emitReferenceIds.add(reference.identifier); + } + setupContexts.set(programNode, { + contextReferenceIds: new Set(), + emitReferenceIds, + }); + }, + ...callVisitor, + }), + utils.defineVueVisitor(context, { + onSetupFunctionEnter(node, { node: vueNode }) { + const contextParam = node.params[1]; + if (!contextParam) { + // no arguments + return; + } + if (contextParam.type === "RestElement") { + // cannot check + return; + } + if (contextParam.type === "ArrayPattern") { + // cannot check + return; + } + /** @type {Set} */ + const contextReferenceIds = new Set(); + /** @type {Set} */ + const emitReferenceIds = new Set(); + if (contextParam.type === "ObjectPattern") { + const emitProperty = utils.findAssignmentProperty(contextParam, "emit"); + if (!emitProperty) { + return; + } + const emitParam = emitProperty.value; + // `setup(props, {emit})` + const variable = + emitParam.type === "Identifier" + ? findVariable(utils.getScope(context, emitParam), emitParam) + : null; + if (!variable) { + return; + } + for (const reference of variable.references) { + if (!reference.isRead()) { + continue; + } + + emitReferenceIds.add(reference.identifier); + } + } else if (contextParam.type === "Identifier") { + // `setup(props, context)` + const variable = findVariable(utils.getScope(context, contextParam), contextParam); + if (!variable) { + return; + } + for (const reference of variable.references) { + if (!reference.isRead()) { + continue; + } + + contextReferenceIds.add(reference.identifier); + } + } + setupContexts.set(vueNode, { + contextReferenceIds, + emitReferenceIds, + }); + }, + onVueObjectEnter(node) { + const emits = utils.getComponentEmitsFromOptions(node); + verifyEmitDeclaration(emits); + }, + onVueObjectExit(node, { type }) { + if ( + (!vueTemplateDefineData || + (vueTemplateDefineData.type !== "export" && + vueTemplateDefineData.type !== "setup")) && + (type === "mark" || type === "export" || type === "definition") + ) { + vueTemplateDefineData = { + type, + define: node, + }; + } + setupContexts.delete(node); + }, + ...callVisitor, + }), + ), + ), + ); + }, +}; diff --git a/packages/sit-onyx/.eslintrc.cjs b/packages/sit-onyx/.eslintrc.cjs index e1012a12c5..8ba9f8bed4 100644 --- a/packages/sit-onyx/.eslintrc.cjs +++ b/packages/sit-onyx/.eslintrc.cjs @@ -12,6 +12,7 @@ module.exports = { "no-debugger": "error", "vue/html-button-has-type": "error", "@sit-onyx/import-playwright-a11y": "error", + "@sit-onyx/no-shadow-native": "error", // disallow scoped or module CSS for components // see https://onyx.schwarz/principles/technical-vision.html#css "vue-scoped-css/enforce-style-type": ["error", { allows: ["plain"] }], diff --git a/packages/sit-onyx/src/components/OnyxIconButton/OnyxIconButton.ct.tsx b/packages/sit-onyx/src/components/OnyxIconButton/OnyxIconButton.ct.tsx index 3d1475fac3..050daf141b 100644 --- a/packages/sit-onyx/src/components/OnyxIconButton/OnyxIconButton.ct.tsx +++ b/packages/sit-onyx/src/components/OnyxIconButton/OnyxIconButton.ct.tsx @@ -36,6 +36,7 @@ test("should behave correctly", async ({ page, mount }) => { // ARRANGE await component.update({ ...setup, props: { disabled: true } }); // ACT // ACT + // eslint-disable-next-line playwright/no-force-option await buttonElement.click({ force: true }); // ASSERT await expect(buttonElement).toBeDisabled(); diff --git a/packages/sit-onyx/src/components/OnyxNavBar/modules/OnyxNavButton/MobileComponentTestWrapper.vue b/packages/sit-onyx/src/components/OnyxNavBar/modules/OnyxNavButton/MobileComponentTestWrapper.vue index d77a362c9c..d91b67864c 100644 --- a/packages/sit-onyx/src/components/OnyxNavBar/modules/OnyxNavButton/MobileComponentTestWrapper.vue +++ b/packages/sit-onyx/src/components/OnyxNavBar/modules/OnyxNavButton/MobileComponentTestWrapper.vue @@ -6,13 +6,6 @@ import type { OnyxNavButtonProps } from "./types"; const props = defineProps(); -const emit = defineEmits<{ - /** - * An optional slot to override the label content. - */ - click: [href: string]; -}>(); - const slots = defineSlots<{ /** * An optional slot to show additional content behind the label (e.g. a `OnyxBadge`). @@ -36,7 +29,6 @@ provide( :label="props.label" :href="props.href" :active="props.active" - @navigate="emit('click', $event)" > diff --git a/packages/sit-onyx/src/components/examples/GridPlayground/EditGridElementDialog/EditGridElementDialog.ct.tsx b/packages/sit-onyx/src/components/examples/GridPlayground/EditGridElementDialog/EditGridElementDialog.ct.tsx index ec983e7c8b..07a177261d 100644 --- a/packages/sit-onyx/src/components/examples/GridPlayground/EditGridElementDialog/EditGridElementDialog.ct.tsx +++ b/packages/sit-onyx/src/components/examples/GridPlayground/EditGridElementDialog/EditGridElementDialog.ct.tsx @@ -10,7 +10,7 @@ test("should behave correctly", async ({ mount, makeAxeBuilder, page }) => { await mount( submitEvents.push(element)} + onApply={(element) => submitEvents.push(element)} onClose={() => closeEvents++} />, ); diff --git a/packages/sit-onyx/src/components/examples/GridPlayground/EditGridElementDialog/EditGridElementDialog.vue b/packages/sit-onyx/src/components/examples/GridPlayground/EditGridElementDialog/EditGridElementDialog.vue index 8f64538cbf..46363dcc0d 100644 --- a/packages/sit-onyx/src/components/examples/GridPlayground/EditGridElementDialog/EditGridElementDialog.vue +++ b/packages/sit-onyx/src/components/examples/GridPlayground/EditGridElementDialog/EditGridElementDialog.vue @@ -22,7 +22,7 @@ const props = defineProps<{ const emit = defineEmits<{ close: []; - submit: [element: GridElementConfig]; + apply: [element: GridElementConfig]; delete: []; }>(); @@ -42,7 +42,7 @@ watch([() => props.open, () => props.initialValue], () => { }); const handleSubmit = () => { - emit("submit", cloneElement(state.value as GridElementConfig)); + emit("apply", cloneElement(state.value as GridElementConfig)); }; const label = "Column configuration"; diff --git a/packages/sit-onyx/src/components/examples/GridPlayground/GridElement/GridElement.ct.tsx b/packages/sit-onyx/src/components/examples/GridPlayground/GridElement/GridElement.ct.tsx index 86bb04eab4..4aaf99fb00 100644 --- a/packages/sit-onyx/src/components/examples/GridPlayground/GridElement/GridElement.ct.tsx +++ b/packages/sit-onyx/src/components/examples/GridPlayground/GridElement/GridElement.ct.tsx @@ -32,16 +32,12 @@ test.describe("screenshot tests", () => { test("should behave correctly", async ({ mount }) => { // ARRANGE - let clickEventCount = 0; const component = await mount(GridElement, { props: { label: "Label", columnCount: 2, }, - on: { - click: () => clickEventCount++, - }, }); // ACT @@ -50,7 +46,7 @@ test("should behave correctly", async ({ mount }) => { // ASSERT await expect(component).toHaveClass(/onyx-grid-span-2/); await expect(component).toHaveAccessibleName("Label"); - expect(clickEventCount).toBe(1); + await expect(component).toBeEnabled(); // ARRANGE await component.update({ diff --git a/packages/sit-onyx/src/components/examples/GridPlayground/GridElement/GridElement.vue b/packages/sit-onyx/src/components/examples/GridPlayground/GridElement/GridElement.vue index e151d0e694..c9b36a284f 100644 --- a/packages/sit-onyx/src/components/examples/GridPlayground/GridElement/GridElement.vue +++ b/packages/sit-onyx/src/components/examples/GridPlayground/GridElement/GridElement.vue @@ -10,10 +10,6 @@ const props = defineProps< } >(); -const emit = defineEmits<{ - click: []; -}>(); - defineSlots<{ default?(props: { /** @@ -52,7 +48,6 @@ watch(size.width, () => { type="button" :aria-label="props.label" :title="props.label" - @click="emit('click')" > diff --git a/packages/sit-onyx/src/components/examples/GridPlayground/GridPlayground.vue b/packages/sit-onyx/src/components/examples/GridPlayground/GridPlayground.vue index bfe14f2c0e..45594312d2 100644 --- a/packages/sit-onyx/src/components/examples/GridPlayground/GridPlayground.vue +++ b/packages/sit-onyx/src/components/examples/GridPlayground/GridPlayground.vue @@ -226,7 +226,7 @@ const currentBreakpoint = computed(() => { { gridElementIndexToEdit != undefined ? gridElements[gridElementIndexToEdit] : undefined " @close="closeEdit" - @submit="updateElement(gridElementIndexToEdit!, $event)" + @apply="updateElement(gridElementIndexToEdit!, $event)" @delete="deleteElement(gridElementIndexToEdit!)" />