@@ -14,26 +14,31 @@ import { elementType } from "jsx-ast-utils";
1414import { JSXOpeningElement } from "estree-jsx" ;
1515import { hasToolTipParent } from "./hasTooltipParent" ;
1616import { hasLabeledChild } from "./hasLabeledChild" ;
17+ import { hasTextContentChild } from "./hasTextContentChild" ;
18+ import { hasTriggerProp } from "./hasTriggerProp" ;
1719
1820export type LabeledControlConfig = {
1921 component : string | RegExp ;
2022 messageId : string ;
2123 description : string ;
2224 labelProps : string [ ] ; // e.g. ["aria-label", "title", "label"]
23- /** Accept a parent <Field label="..."> wrapper as providing the label. */
24- allowFieldParent : boolean ; // default false
25- allowHtmlFor : boolean /** Accept <label htmlFor="..."> association. */ ;
26- allowLabelledBy : boolean /** Accept aria-labelledby association. */ ;
27- allowWrappingLabel : boolean /** Accept being wrapped in a <label> element. */ ;
28- allowTooltipParent : boolean /** Accept a parent <Tooltip content="..."> wrapper as providing the label. */ ;
25+ allowFieldParent : boolean ; // Accept a parent <Field label="..."> wrapper as providing the label.
26+ allowHtmlFor : boolean ; // Accept <label htmlFor="..."> association.
27+ allowLabelledBy : boolean ; // Accept aria-labelledby association.
28+ allowWrappingLabel : boolean ; // Accept being wrapped in a <label> element.
29+ allowTooltipParent : boolean ; // Accept a parent <Tooltip content="..."> wrapper as providing the label.
2930 /**
3031 * Accept aria-describedby as a labeling strategy.
3132 * NOTE: This is discouraged for *primary* labeling; prefer text/aria-label/labelledby.
3233 * Keep this off unless a specific component (e.g., Icon-only buttons) intentionally uses it.
3334 */
3435 allowDescribedBy : boolean ;
35- // NEW: treat labeled child content (img alt, svg title, aria-label on role="img") as the name
36- allowLabeledChild : boolean ;
36+ allowLabeledChild : boolean ; // Accept labeled child elements to provide the label e.g. <Button><img alt="..." /></Button>
37+ allowTextContentChild ?: boolean ; // Accept text children to provide the label e.g. <Button>Click me</Button>
38+ /** Only apply rule when this trigger prop is present (e.g., "dismissible", "disabled") */
39+ triggerProp ?: string ;
40+ /** Custom validation function for complex scenarios */
41+ customValidator ?: Function ;
3742} ;
3843
3944/**
@@ -49,6 +54,8 @@ export type LabeledControlConfig = {
4954 * 6) Parent <Tooltip content="..."> context .......................... (allowTooltipParent)
5055 * 7) aria-describedby association (opt-in; discouraged as primary) .... (allowDescribedBy)
5156 * 8) treat labeled child content (img alt, svg title, aria-label on role="img") as the name
57+ * 9) Conditional application based on trigger prop ................... (triggerProp)
58+ * 10) Custom validation for complex scenarios ......................... (customValidator)
5259 *
5360 * This checks for presence of an accessible *name* only; not contrast or UX.
5461 */
@@ -92,19 +99,52 @@ export function makeLabeledControlRule(config: LabeledControlConfig): TSESLint.R
9299 defaultOptions : [ ] ,
93100
94101 create ( context : TSESLint . RuleContext < string , [ ] > ) {
95- return {
96- JSXOpeningElement ( node : TSESTree . JSXOpeningElement ) {
97- // elementType expects an ESTree JSX node — cast is fine
98- const name = elementType ( node as unknown as JSXOpeningElement ) ;
99- const matches = typeof config . component === "string" ? name === config . component : config . component . test ( name ) ;
102+ const validateElement = ( node : TSESTree . JSXOpeningElement , parentElement ?: TSESTree . JSXElement ) => {
103+ // elementType expects an ESTree JSX node — cast is fine
104+ const name = elementType ( node as unknown as JSXOpeningElement ) ;
105+ const matches = typeof config . component === "string" ? name === config . component : config . component . test ( name ) ;
100106
101- if ( ! matches ) return ;
107+ if ( ! matches ) return ;
102108
103- if ( ! hasAccessibleLabel ( node , context , config ) ) {
104- context . report ( { node, messageId : config . messageId } ) ;
109+ // If trigger prop is specified, only apply rule when it's present
110+ if ( config . triggerProp && ! hasTriggerProp ( node , config . triggerProp ) ) {
111+ return ;
112+ }
113+
114+ // Use custom validator if provided, otherwise use standard accessibility check
115+ let isValid : boolean ;
116+ if ( config . customValidator ) {
117+ isValid = config . customValidator ( node ) ;
118+ } else {
119+ // For text content checking, we need the parent element
120+ if ( config . allowTextContentChild && parentElement ) {
121+ // Create a modified config for hasAccessibleLabel that includes text content checking
122+ const modifiedConfig = { ...config } ;
123+ isValid = hasAccessibleLabel ( node , context , modifiedConfig ) || hasTextContentChild ( parentElement ) ;
124+ } else {
125+ isValid = hasAccessibleLabel ( node , context , config ) ;
105126 }
106127 }
128+
129+ if ( ! isValid ) {
130+ context . report ( { node, messageId : config . messageId } ) ;
131+ }
107132 } ;
133+
134+ // If we need text content checking, we must visit JSXElement to get access to children
135+ if ( config . allowTextContentChild ) {
136+ return {
137+ JSXElement ( node : TSESTree . JSXElement ) {
138+ validateElement ( node . openingElement , node ) ;
139+ }
140+ } ;
141+ } else {
142+ return {
143+ JSXOpeningElement ( node : TSESTree . JSXOpeningElement ) {
144+ validateElement ( node ) ;
145+ }
146+ } ;
147+ }
108148 }
109149 } ;
110150}
0 commit comments