@@ -533,7 +533,82 @@ function isStylingConstant(variableName: string): boolean {
533533}
534534
535535/**
536- * Checks if a string is a value for an ignored property/attribute.
536+ * Gets the property name if this node is (directly) a property value.
537+ */
538+ function getPropertyName ( node : TSESTree . Node ) : string | null {
539+ const parent = node . parent
540+
541+ // JSX attribute: <div className="..." /> or <div className={...} />
542+ if ( parent ?. type === AST_NODE_TYPES . JSXAttribute && parent . name . type === AST_NODE_TYPES . JSXIdentifier ) {
543+ return parent . name . name
544+ }
545+
546+ // JSX expression container unwrap: className={...}
547+ if ( parent ?. type === AST_NODE_TYPES . JSXExpressionContainer ) {
548+ const attr = parent . parent
549+ if ( attr . type === AST_NODE_TYPES . JSXAttribute && attr . name . type === AST_NODE_TYPES . JSXIdentifier ) {
550+ return attr . name . name
551+ }
552+ }
553+
554+ // Object property: { className: "..." }
555+ if ( parent ?. type === AST_NODE_TYPES . Property && parent . value === node ) {
556+ if ( parent . key . type === AST_NODE_TYPES . Identifier ) {
557+ return parent . key . name
558+ }
559+ if ( parent . key . type === AST_NODE_TYPES . Literal && typeof parent . key . value === "string" ) {
560+ return parent . key . value
561+ }
562+ }
563+
564+ return null
565+ }
566+
567+ /**
568+ * Checks if a property name should be ignored (styling/technical property).
569+ */
570+ function isTechnicalPropertyName ( name : string , ignoreProperties : string [ ] ) : boolean {
571+ return ignoreProperties . includes ( name ) || isStylingProperty ( name )
572+ }
573+
574+ /**
575+ * Checks if a string is anywhere inside the value of a styling property.
576+ *
577+ * This handles complex patterns like:
578+ * className={cn("class1", "class2")}
579+ * className={cn("base", condition && "extra")}
580+ * className={condition ? "a" : "b"}
581+ *
582+ * Walks up the tree looking for a JSXAttribute or Property with a styling name.
583+ */
584+ function isInsideStylingPropertyValue ( node : TSESTree . Node , ignoreProperties : string [ ] ) : boolean {
585+ let current : TSESTree . Node | undefined = node
586+
587+ while ( current !== undefined ) {
588+ // Check if current node is directly the value of a styling property
589+ const propName = getPropertyName ( current )
590+ if ( propName !== null ) {
591+ return isTechnicalPropertyName ( propName , ignoreProperties )
592+ }
593+
594+ // Stop at function declarations/expressions (don't cross function boundaries)
595+ // This prevents: onClick={() => { return "Hello" }} from being ignored
596+ if (
597+ current . type === AST_NODE_TYPES . FunctionDeclaration ||
598+ current . type === AST_NODE_TYPES . FunctionExpression ||
599+ current . type === AST_NODE_TYPES . ArrowFunctionExpression
600+ ) {
601+ return false
602+ }
603+
604+ current = current . parent ?? undefined
605+ }
606+
607+ return false
608+ }
609+
610+ /**
611+ * Checks if a string is a value for an ignored property/attribute (direct value only).
537612 */
538613function isIgnoredProperty ( node : TSESTree . Node , ignoreProperties : string [ ] ) : boolean {
539614 const parent = node . parent
@@ -542,7 +617,7 @@ function isIgnoredProperty(node: TSESTree.Node, ignoreProperties: string[]): boo
542617 if ( parent ?. type === AST_NODE_TYPES . JSXAttribute ) {
543618 if ( parent . name . type === AST_NODE_TYPES . JSXIdentifier ) {
544619 const name = parent . name . name
545- if ( ignoreProperties . includes ( name ) || isStylingProperty ( name ) ) {
620+ if ( isTechnicalPropertyName ( name , ignoreProperties ) ) {
546621 return true
547622 }
548623 }
@@ -552,13 +627,13 @@ function isIgnoredProperty(node: TSESTree.Node, ignoreProperties: string[]): boo
552627 if ( parent ?. type === AST_NODE_TYPES . Property ) {
553628 if ( parent . key . type === AST_NODE_TYPES . Identifier ) {
554629 const name = parent . key . name
555- if ( ignoreProperties . includes ( name ) || isStylingProperty ( name ) ) {
630+ if ( isTechnicalPropertyName ( name , ignoreProperties ) ) {
556631 return true
557632 }
558633 }
559634 if ( parent . key . type === AST_NODE_TYPES . Literal && typeof parent . key . value === "string" ) {
560635 const name = parent . key . value
561- if ( ignoreProperties . includes ( name ) || isStylingProperty ( name ) ) {
636+ if ( isTechnicalPropertyName ( name , ignoreProperties ) ) {
562637 return true
563638 }
564639 }
@@ -938,11 +1013,16 @@ export const noUnlocalizedStrings = createRule<[Options], MessageId>({
9381013 return
9391014 }
9401015
941- // Value for ignored property
1016+ // Value for ignored property (direct)
9421017 if ( isIgnoredProperty ( node , options . ignoreProperties ) ) {
9431018 return
9441019 }
9451020
1021+ // Inside a styling property value (e.g., className={cn("class1", "class2")})
1022+ if ( isInsideStylingPropertyValue ( node , options . ignoreProperties ) ) {
1023+ return
1024+ }
1025+
9461026 // Inside a styling constant (e.g., STATUS_COLORS, BUTTON_CLASSES)
9471027 if ( isInsideStylingConstant ( node ) ) {
9481028 return
0 commit comments