Skip to content

Commit 990e68a

Browse files
tofrankieota-meshi
andauthored
Add extraClassAttributes option to match additional class-like attributes in templates (#413)
* feat(no-unused-selector): support customClassAttributes option * feat: case-insensitive * feat: rename customClassAttributes to extraClassAttributes * Create afraid-bikes-smile.md --------- Co-authored-by: Yosuke Ota <[email protected]>
1 parent 8399873 commit 990e68a

File tree

7 files changed

+190
-15
lines changed

7 files changed

+190
-15
lines changed

.changeset/afraid-bikes-smile.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"eslint-plugin-vue-scoped-css": minor
3+
---
4+
5+
Add `extraClassAttributes` option to match additional class-like attributes in templates, to `vue-scoped-css/no-unused-selector` rule

docs/rules/no-unused-selector.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,15 @@ This is a limitation of this rule. Without this limitation, the root element can
9191
"ignoreBEMModifier": false,
9292
"captureClassesFromDoc": [],
9393
"checkUnscoped": false,
94+
"extraClassAttributes": [],
9495
}]
9596
}
9697
```
9798

9899
- `ignoreBEMModifier` ... Set `true` if you want to ignore the `BEM` modifier. Default is false.
99100
- `captureClassesFromDoc` ... Specifies the regexp that extracts the class name from the documentation in the comments. Even if there is no matching element, no error is reported if the document of a class name exists in the comments.
100101
- `checkUnscoped` ... The rule only checks `<style scoped>` by default, but if set to `true` it will also check `<style>` without the scoped attribute. If you set it to `true`, be very careful that the warned CSS may actually be used outside the `.vue` file.
102+
- `extraClassAttributes` ... Specifies an array of custom attribute names to check for class names in addition to the standard `class` attribute. Useful for frameworks that use custom attributes like `hover-class`, `placeholder-class`, etc. Default is an empty array.
101103

102104
### `"ignoreBEMModifier": true`
103105

@@ -149,6 +151,38 @@ a.button.star {
149151

150152
</eslint-code-block>
151153

154+
### `"extraClassAttributes": ["hover-class", "placeholder-class"]`
155+
156+
<eslint-code-block :rules="{'vue-scoped-css/no-unused-selector': ['error', {extraClassAttributes: ['hover-class', 'placeholder-class']}]}">
157+
158+
```vue
159+
<template>
160+
<div>
161+
<!-- These attributes will be checked for class names -->
162+
<button class="button" hover-class="button-hover">Button</button>
163+
<input placeholder-class="input-placeholder" >
164+
</div>
165+
</template>
166+
<style scoped>
167+
/* ✓ GOOD - These selectors are used in custom attributes */
168+
.button {}
169+
.button-hover {}
170+
.input-placeholder {}
171+
172+
/* ✗ BAD - This selector is not used anywhere */
173+
.unused-class {}
174+
</style>
175+
<script>
176+
export default {
177+
data() {
178+
return {
179+
dynamicHoverClass: 'button-hover',
180+
}
181+
},
182+
}
183+
</script>
184+
```
185+
152186
## :books: Further reading
153187

154188
- [vue-scoped-css/require-selector-used-inside]

lib/options.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import { toRegExp } from "./utils/regexp";
33
export interface QueryOptions {
44
ignoreBEMModifier?: boolean;
55
captureClassesFromDoc?: string[];
6+
extraClassAttributes?: string[];
67
}
78

89
export interface ParsedQueryOptions {
910
ignoreBEMModifier: boolean;
1011
captureClassesFromDoc: RegExp[];
12+
extraClassAttributes: string[];
1113
}
1214

1315
/**
@@ -16,11 +18,13 @@ export interface ParsedQueryOptions {
1618
export function parseQueryOptions(
1719
options: QueryOptions | undefined,
1820
): ParsedQueryOptions {
19-
const { ignoreBEMModifier, captureClassesFromDoc } = options || {};
21+
const { ignoreBEMModifier, captureClassesFromDoc, extraClassAttributes } =
22+
options || {};
2023

2124
return {
2225
ignoreBEMModifier: ignoreBEMModifier ?? false,
2326
captureClassesFromDoc:
2427
captureClassesFromDoc?.map((s) => toRegExp(s, "g")) ?? [],
28+
extraClassAttributes: extraClassAttributes ?? [],
2529
};
2630
}

lib/rules/no-unused-selector.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,14 @@ export = {
123123
checkUnscoped: {
124124
type: "boolean",
125125
},
126+
extraClassAttributes: {
127+
type: "array",
128+
items: {
129+
type: "string",
130+
},
131+
minItems: 0,
132+
uniqueItems: true,
133+
},
126134
},
127135
additionalProperties: false,
128136
},

lib/styles/selectors/query/attribute-tracker.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@ export function getAttributeValueNodes(
1616
context: RuleContext,
1717
): AttributeValueExpressions[] | null {
1818
const results: AttributeValueExpressions[] = [];
19+
const lowedName = name.toLowerCase();
1920
const { startTag } = element;
2021
for (const attr of startTag.attributes) {
2122
if (!isVDirective(attr)) {
2223
const { key, value } = attr;
2324
if (value == null) {
2425
continue;
2526
}
26-
if (key.name === name) {
27+
if (key.name === lowedName) {
2728
results.push(value);
2829
}
2930
} else {
@@ -39,7 +40,7 @@ export function getAttributeValueNodes(
3940
// bind name is unknown.
4041
return null;
4142
}
42-
if (bindArg !== name) {
43+
if (bindArg !== lowedName) {
4344
continue;
4445
}
4546
const { expression } = value;

lib/styles/selectors/query/index.ts

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import lodash from "lodash";
12
import {
23
isTypeSelector,
34
isIDSelector,
@@ -762,16 +763,13 @@ function matchClassName(
762763
return true;
763764
}
764765
}
765-
const nodes = getAttributeValueNodes(element, "class", document.context);
766-
if (nodes == null) {
767-
return true;
768-
}
769-
for (const node of nodes) {
770-
if (node.type === "VLiteral") {
771-
if (includesClassName(node.value, className)) {
772-
return true;
773-
}
774-
} else if (matchClassNameExpression(node, className, document)) {
766+
767+
const uniquedAttrs = lodash.uniq([
768+
"class",
769+
...document.options.extraClassAttributes,
770+
]);
771+
for (const attrName of uniquedAttrs) {
772+
if (matchClassNameForAttribute(element, attrName, className, document)) {
775773
return true;
776774
}
777775
}
@@ -793,6 +791,31 @@ function matchClassName(
793791
return false;
794792
}
795793

794+
/**
795+
* Checks whether the given element matches the given class name for a specific attribute.
796+
*/
797+
function matchClassNameForAttribute(
798+
element: AST.VElement,
799+
attrName: string,
800+
className: Template,
801+
document: VueDocumentQueryContext,
802+
): boolean {
803+
const nodes = getAttributeValueNodes(element, attrName, document.context);
804+
if (nodes == null) {
805+
return true;
806+
}
807+
for (const node of nodes) {
808+
if (node.type === "VLiteral") {
809+
if (includesClassName(node.value, className)) {
810+
return true;
811+
}
812+
} else if (matchClassNameExpression(node, className, document)) {
813+
return true;
814+
}
815+
}
816+
return false;
817+
}
818+
796819
/**
797820
* Gets the ref name.
798821
*/

tests/lib/rules/no-unused-selector.ts

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ tester.run("no-unused-selector", rule as any, {
326326
</template>
327327
<style scoped lang="scss">
328328
/* ✓ GOOD */
329-
329+
330330
// A button suitable for giving a star to someone.
331331
//
332332
// :hover - Subtle hover highlight.
@@ -473,6 +473,106 @@ tester.run("no-unused-selector", rule as any, {
473473
}
474474
</style>
475475
`,
476+
// extraClassAttributes option
477+
{
478+
code: `
479+
<template>
480+
<div hover-class="foo"></div>
481+
</template>
482+
<style scoped>
483+
.foo {}
484+
</style>
485+
`,
486+
options: [{ extraClassAttributes: ["hover-class"] }],
487+
},
488+
{
489+
code: `
490+
<template>
491+
<div :hover-class="dynamicClass"></div>
492+
</template>
493+
<style scoped>
494+
.foo {}
495+
</style>
496+
<script>
497+
export default {
498+
data () {
499+
return {
500+
dynamicClass: 'foo'
501+
}
502+
}
503+
}
504+
</script>
505+
`,
506+
options: [{ extraClassAttributes: ["hover-class"] }],
507+
},
508+
{
509+
code: `
510+
<template>
511+
<div hover-class="foo" placeholder-class="bar"></div>
512+
</template>
513+
<style scoped>
514+
.foo {}
515+
.bar {}
516+
</style>
517+
`,
518+
options: [{ extraClassAttributes: ["hover-class", "placeholder-class"] }],
519+
},
520+
{
521+
code: `
522+
<template>
523+
<div :hover-class="['foo', 'bar']" :placeholder-class="{baz: true}"></div>
524+
</template>
525+
<style scoped>
526+
.foo {}
527+
.bar {}
528+
.baz {}
529+
</style>
530+
`,
531+
options: [{ extraClassAttributes: ["hover-class", "placeholder-class"] }],
532+
},
533+
{
534+
code: `
535+
<template>
536+
<div data-class="foo"></div>
537+
</template>
538+
<style scoped>
539+
.foo {}
540+
</style>
541+
`,
542+
options: [{ extraClassAttributes: ["data-class"] }],
543+
},
544+
{
545+
code: `
546+
<template>
547+
<div v-bind:data-class="dynamicClass"></div>
548+
</template>
549+
<style scoped>
550+
.foo {}
551+
</style>
552+
<script>
553+
export default {
554+
data () {
555+
return {
556+
dynamicClass: 'foo'
557+
}
558+
}
559+
}
560+
</script>
561+
`,
562+
options: [{ extraClassAttributes: ["data-class"] }],
563+
},
564+
{
565+
code: `
566+
<template>
567+
<div class="foo" hover-class="bar"></div>
568+
</template>
569+
<style scoped>
570+
.foo {}
571+
.bar {}
572+
</style>
573+
`,
574+
options: [{ extraClassAttributes: ["hover-class"] }],
575+
},
476576
],
477577
invalid: [
478578
{
@@ -756,7 +856,7 @@ tester.run("no-unused-selector", rule as any, {
756856
</template>
757857
<style scoped lang="scss">
758858
/* ✓ GOOD */
759-
859+
760860
// A button suitable for giving a star to someone.
761861
//
762862
// :hover - Subtle hover highlight.

0 commit comments

Comments
 (0)