| 1 | /**
|
|---|
| 2 | * @fileoverview Enforce label tags have an associated control.
|
|---|
| 3 | * @author Jesse Beach
|
|---|
| 4 | */
|
|---|
| 5 |
|
|---|
| 6 | // -----------------------------------------------------------------------------
|
|---|
| 7 | // Requirements
|
|---|
| 8 | // -----------------------------------------------------------------------------
|
|---|
| 9 |
|
|---|
| 10 | import { RuleTester } from 'eslint';
|
|---|
| 11 | import parserOptionsMapper from '../../__util__/parserOptionsMapper';
|
|---|
| 12 | import parsers from '../../__util__/helpers/parsers';
|
|---|
| 13 | import rule from '../../../src/rules/label-has-associated-control';
|
|---|
| 14 | import ruleOptionsMapperFactory from '../../__util__/ruleOptionsMapperFactory';
|
|---|
| 15 |
|
|---|
| 16 | // -----------------------------------------------------------------------------
|
|---|
| 17 | // Tests
|
|---|
| 18 | // -----------------------------------------------------------------------------
|
|---|
| 19 |
|
|---|
| 20 | const ruleTester = new RuleTester();
|
|---|
| 21 |
|
|---|
| 22 | const ruleName = 'label-has-associated-control';
|
|---|
| 23 |
|
|---|
| 24 | const expectedError = {
|
|---|
| 25 | message: 'A form label must be associated with a control.',
|
|---|
| 26 | type: 'JSXOpeningElement',
|
|---|
| 27 | };
|
|---|
| 28 |
|
|---|
| 29 | const expectedErrorNoLabel = {
|
|---|
| 30 | message: 'A form label must have accessible text.',
|
|---|
| 31 | type: 'JSXOpeningElement',
|
|---|
| 32 | };
|
|---|
| 33 |
|
|---|
| 34 | const componentsSettings = {
|
|---|
| 35 | 'jsx-a11y': {
|
|---|
| 36 | components: {
|
|---|
| 37 | CustomInput: 'input',
|
|---|
| 38 | CustomLabel: 'label',
|
|---|
| 39 | },
|
|---|
| 40 | },
|
|---|
| 41 | };
|
|---|
| 42 |
|
|---|
| 43 | const attributesSettings = {
|
|---|
| 44 | 'jsx-a11y': {
|
|---|
| 45 | attributes: {
|
|---|
| 46 | for: ['htmlFor', 'for'],
|
|---|
| 47 | },
|
|---|
| 48 | },
|
|---|
| 49 | };
|
|---|
| 50 |
|
|---|
| 51 | const htmlForValid = [
|
|---|
| 52 | { code: '<label htmlFor="js_id"><span><span><span>A label</span></span></span></label>', options: [{ depth: 4 }] },
|
|---|
| 53 | { code: '<label htmlFor="js_id" aria-label="A label" />' },
|
|---|
| 54 | { code: '<label htmlFor="js_id" aria-labelledby="A label" />' },
|
|---|
| 55 | { code: '<div><label htmlFor="js_id">A label</label><input id="js_id" /></div>' },
|
|---|
| 56 | { code: '<label for="js_id"><span><span><span>A label</span></span></span></label>', options: [{ depth: 4 }], settings: attributesSettings },
|
|---|
| 57 | { code: '<label for="js_id" aria-label="A label" />', settings: attributesSettings },
|
|---|
| 58 | { code: '<label for="js_id" aria-labelledby="A label" />', settings: attributesSettings },
|
|---|
| 59 | { code: '<div><label for="js_id">A label</label><input id="js_id" /></div>', settings: attributesSettings },
|
|---|
| 60 | // Custom label component.
|
|---|
| 61 | { code: '<CustomLabel htmlFor="js_id" aria-label="A label" />', options: [{ labelComponents: ['CustomLabel'] }] },
|
|---|
| 62 | { code: '<CustomLabel htmlFor="js_id" label="A label" />', options: [{ labelAttributes: ['label'], labelComponents: ['CustomLabel'] }] },
|
|---|
| 63 | { code: '<CustomLabel htmlFor="js_id" aria-label="A label" />', settings: componentsSettings },
|
|---|
| 64 | // Custom label attributes.
|
|---|
| 65 | { code: '<label htmlFor="js_id" label="A label" />', options: [{ labelAttributes: ['label'] }] },
|
|---|
| 66 | // Glob support for controlComponents option.
|
|---|
| 67 | { code: '<CustomLabel htmlFor="js_id" aria-label="A label" />', options: [{ controlComponents: ['Custom*'] }] },
|
|---|
| 68 | { code: '<CustomLabel htmlFor="js_id" aria-label="A label" />', options: [{ controlComponents: ['*Label'] }] },
|
|---|
| 69 | // Rule does not error if presence of accessible label cannot be determined
|
|---|
| 70 | { code: '<div><label htmlFor="js_id"><CustomText /></label><input id="js_id" /></div>' },
|
|---|
| 71 | ];
|
|---|
| 72 | const nestingValid = [
|
|---|
| 73 | { code: '<label>A label<input /></label>' },
|
|---|
| 74 | { code: '<label>A label<textarea /></label>' },
|
|---|
| 75 | { code: '<label><img alt="A label" /><input /></label>' },
|
|---|
| 76 | { code: '<label><img aria-label="A label" /><input /></label>' },
|
|---|
| 77 | { code: '<label><span>A label<input /></span></label>' },
|
|---|
| 78 | { code: '<label><span><span>A label<input /></span></span></label>', options: [{ depth: 3 }] },
|
|---|
| 79 | { code: '<label><span><span><span>A label<input /></span></span></span></label>', options: [{ depth: 4 }] },
|
|---|
| 80 | { code: '<label><span><span><span><span>A label</span><input /></span></span></span></label>', options: [{ depth: 5 }] },
|
|---|
| 81 | { code: '<label><span><span><span><span aria-label="A label" /><input /></span></span></span></label>', options: [{ depth: 5 }] },
|
|---|
| 82 | { code: '<label><span><span><span><input aria-label="A label" /></span></span></span></label>', options: [{ depth: 5 }] },
|
|---|
| 83 | // Other controls
|
|---|
| 84 | { code: '<label>foo<meter /></label>' },
|
|---|
| 85 | { code: '<label>foo<output /></label>' },
|
|---|
| 86 | { code: '<label>foo<progress /></label>' },
|
|---|
| 87 | { code: '<label>foo<textarea /></label>' },
|
|---|
| 88 | // Custom controlComponents.
|
|---|
| 89 | { code: '<label>A label<CustomInput /></label>', options: [{ controlComponents: ['CustomInput'] }] },
|
|---|
| 90 | { code: '<label><span>A label<CustomInput /></span></label>', options: [{ controlComponents: ['CustomInput'] }] },
|
|---|
| 91 | { code: '<label><span>A label<CustomInput /></span></label>', settings: componentsSettings },
|
|---|
| 92 | { code: '<CustomLabel><span>A label<CustomInput /></span></CustomLabel>', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'] }] },
|
|---|
| 93 | { code: '<CustomLabel><span label="A label"><CustomInput /></span></CustomLabel>', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'], labelAttributes: ['label'] }] },
|
|---|
| 94 | // Glob support for controlComponents option.
|
|---|
| 95 | { code: '<label><span>A label<CustomInput /></span></label>', options: [{ controlComponents: ['Custom*'] }] },
|
|---|
| 96 | { code: '<label><span>A label<CustomInput /></span></label>', options: [{ controlComponents: ['*Input'] }] },
|
|---|
| 97 | // Rule does not error if presence of accessible label cannot be determined
|
|---|
| 98 | { code: '<label><CustomText /><input /></label>' },
|
|---|
| 99 | ];
|
|---|
| 100 |
|
|---|
| 101 | const bothValid = [
|
|---|
| 102 | { code: '<label htmlFor="js_id"><span><span><span>A label<input /></span></span></span></label>', options: [{ depth: 4 }] },
|
|---|
| 103 | { code: '<label htmlFor="js_id" aria-label="A label"><input /></label>' },
|
|---|
| 104 | { code: '<label htmlFor="js_id" aria-labelledby="A label"><input /></label>' },
|
|---|
| 105 | { code: '<label htmlFor="js_id" aria-labelledby="A label"><textarea /></label>' },
|
|---|
| 106 | // Custom label component.
|
|---|
| 107 | { code: '<CustomLabel htmlFor="js_id" aria-label="A label"><input /></CustomLabel>', options: [{ labelComponents: ['CustomLabel'] }] },
|
|---|
| 108 | { code: '<CustomLabel htmlFor="js_id" label="A label"><input /></CustomLabel>', options: [{ labelAttributes: ['label'], labelComponents: ['CustomLabel'] }] },
|
|---|
| 109 | { code: '<CustomLabel htmlFor="js_id" aria-label="A label"><input /></CustomLabel>', settings: componentsSettings },
|
|---|
| 110 | { code: '<CustomLabel htmlFor="js_id" aria-label="A label"><CustomInput /></CustomLabel>', settings: componentsSettings },
|
|---|
| 111 | // Custom label attributes.
|
|---|
| 112 | { code: '<label htmlFor="js_id" label="A label"><input /></label>', options: [{ labelAttributes: ['label'] }] },
|
|---|
| 113 | { code: '<label htmlFor="selectInput">Some text<select id="selectInput" /></label>' },
|
|---|
| 114 | ];
|
|---|
| 115 |
|
|---|
| 116 | const alwaysValid = [
|
|---|
| 117 | { code: '<div />' },
|
|---|
| 118 | { code: '<CustomElement />' },
|
|---|
| 119 | { code: '<input type="hidden" />' },
|
|---|
| 120 | ];
|
|---|
| 121 |
|
|---|
| 122 | const htmlForInvalid = [
|
|---|
| 123 | { code: '<label htmlFor="js_id"><span><span><span>A label</span></span></span></label>', options: [{ depth: 4 }], errors: [expectedError] },
|
|---|
| 124 | { code: '<label htmlFor="js_id" aria-label="A label" />', errors: [expectedError] },
|
|---|
| 125 | { code: '<label htmlFor="js_id" aria-labelledby="A label" />', errors: [expectedError] },
|
|---|
| 126 | // Custom label component.
|
|---|
| 127 | { code: '<CustomLabel htmlFor="js_id" aria-label="A label" />', options: [{ labelComponents: ['CustomLabel'] }], errors: [expectedError] },
|
|---|
| 128 | { code: '<CustomLabel htmlFor="js_id" label="A label" />', options: [{ labelAttributes: ['label'], labelComponents: ['CustomLabel'] }], errors: [expectedError] },
|
|---|
| 129 | { code: '<CustomLabel htmlFor="js_id" aria-label="A label" />', settings: componentsSettings, errors: [expectedError] },
|
|---|
| 130 | // Custom label attributes.
|
|---|
| 131 | { code: '<label htmlFor="js_id" label="A label" />', options: [{ labelAttributes: ['label'] }], errors: [expectedError] },
|
|---|
| 132 | ];
|
|---|
| 133 | const nestingInvalid = [
|
|---|
| 134 | { code: '<label>A label<input /></label>', errors: [expectedError] },
|
|---|
| 135 | { code: '<label>A label<textarea /></label>', errors: [expectedError] },
|
|---|
| 136 | { code: '<label><img alt="A label" /><input /></label>', errors: [expectedError] },
|
|---|
| 137 | { code: '<label><img aria-label="A label" /><input /></label>', errors: [expectedError] },
|
|---|
| 138 | { code: '<label><span>A label<input /></span></label>', errors: [expectedError] },
|
|---|
| 139 | { code: '<label><span><span>A label<input /></span></span></label>', options: [{ depth: 3 }], errors: [expectedError] },
|
|---|
| 140 | { code: '<label><span><span><span>A label<input /></span></span></span></label>', options: [{ depth: 4 }], errors: [expectedError] },
|
|---|
| 141 | { code: '<label><span><span><span><span>A label</span><input /></span></span></span></label>', options: [{ depth: 5 }], errors: [expectedError] },
|
|---|
| 142 | { code: '<label><span><span><span><span aria-label="A label" /><input /></span></span></span></label>', options: [{ depth: 5 }], errors: [expectedError] },
|
|---|
| 143 | { code: '<label><span><span><span><input aria-label="A label" /></span></span></span></label>', options: [{ depth: 5 }], errors: [expectedError] },
|
|---|
| 144 | // Custom controlComponents.
|
|---|
| 145 | { code: '<label>A label<OtherCustomInput /></label>', options: [{ controlComponents: ['CustomInput'] }], errors: [expectedError] },
|
|---|
| 146 | { code: '<label><span>A label<CustomInput /></span></label>', options: [{ controlComponents: ['CustomInput'] }], errors: [expectedError] },
|
|---|
| 147 | { code: '<CustomLabel><span>A label<CustomInput /></span></CustomLabel>', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'] }], errors: [expectedError] },
|
|---|
| 148 | { code: '<CustomLabel><span label="A label"><CustomInput /></span></CustomLabel>', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'], labelAttributes: ['label'] }], errors: [expectedError] },
|
|---|
| 149 | { code: '<label><span>A label<CustomInput /></span></label>', settings: componentsSettings, errors: [expectedError] },
|
|---|
| 150 | { code: '<CustomLabel><span>A label<CustomInput /></span></CustomLabel>', settings: componentsSettings, errors: [expectedError] },
|
|---|
| 151 | ];
|
|---|
| 152 |
|
|---|
| 153 | const neverValid = [
|
|---|
| 154 | { code: '<label htmlFor="js_id" />', errors: [expectedErrorNoLabel] },
|
|---|
| 155 | { code: '<label htmlFor="js_id"><input /></label>', errors: [expectedErrorNoLabel] },
|
|---|
| 156 | { code: '<label htmlFor="js_id"><textarea /></label>', errors: [expectedErrorNoLabel] },
|
|---|
| 157 | { code: '<label></label>', errors: [expectedErrorNoLabel] },
|
|---|
| 158 | { code: '<label>A label</label>', errors: [expectedError] },
|
|---|
| 159 | { code: '<div><label /><input /></div>', errors: [expectedErrorNoLabel] },
|
|---|
| 160 | { code: '<div><label>A label</label><input /></div>', errors: [expectedError] },
|
|---|
| 161 | // Custom label component.
|
|---|
| 162 | { code: '<CustomLabel aria-label="A label" />', options: [{ labelComponents: ['CustomLabel'] }], errors: [expectedError] },
|
|---|
| 163 | { code: '<CustomLabel label="A label" />', options: [{ labelAttributes: ['label'], labelComponents: ['CustomLabel'] }], errors: [expectedError] },
|
|---|
| 164 | { code: '<CustomLabel aria-label="A label" />', settings: componentsSettings, errors: [expectedError] },
|
|---|
| 165 | // Custom label attributes.
|
|---|
| 166 | { code: '<label label="A label" />', options: [{ labelAttributes: ['label'] }], errors: [expectedError] },
|
|---|
| 167 | // Custom controlComponents.
|
|---|
| 168 | { code: '<label><span><CustomInput /></span></label>', options: [{ controlComponents: ['CustomInput'] }], errors: [expectedErrorNoLabel] },
|
|---|
| 169 | { code: '<CustomLabel><span><CustomInput /></span></CustomLabel>', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'] }], errors: [expectedErrorNoLabel] },
|
|---|
| 170 | { code: '<CustomLabel><span><CustomInput /></span></CustomLabel>', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'], labelAttributes: ['label'] }], errors: [expectedErrorNoLabel] },
|
|---|
| 171 | { code: '<label><span><CustomInput /></span></label>', settings: componentsSettings, errors: [expectedErrorNoLabel] },
|
|---|
| 172 | { code: '<CustomLabel><span><CustomInput /></span></CustomLabel>', settings: componentsSettings, errors: [expectedErrorNoLabel] },
|
|---|
| 173 | ];
|
|---|
| 174 | // htmlFor valid
|
|---|
| 175 | ruleTester.run(ruleName, rule, {
|
|---|
| 176 | valid: parsers.all([].concat(
|
|---|
| 177 | ...alwaysValid,
|
|---|
| 178 | ...htmlForValid,
|
|---|
| 179 | ))
|
|---|
| 180 | .map(ruleOptionsMapperFactory({
|
|---|
| 181 | assert: 'htmlFor',
|
|---|
| 182 | }))
|
|---|
| 183 | .map(parserOptionsMapper),
|
|---|
| 184 | invalid: parsers.all([].concat(
|
|---|
| 185 | ...neverValid,
|
|---|
| 186 | ...nestingInvalid,
|
|---|
| 187 | ))
|
|---|
| 188 | .map(ruleOptionsMapperFactory({
|
|---|
| 189 | assert: 'htmlFor',
|
|---|
| 190 | }))
|
|---|
| 191 | .map(parserOptionsMapper),
|
|---|
| 192 | });
|
|---|
| 193 |
|
|---|
| 194 | // nesting valid
|
|---|
| 195 | ruleTester.run(ruleName, rule, {
|
|---|
| 196 | valid: parsers.all([].concat(
|
|---|
| 197 | ...alwaysValid,
|
|---|
| 198 | ...nestingValid,
|
|---|
| 199 | ))
|
|---|
| 200 | .map(ruleOptionsMapperFactory({
|
|---|
| 201 | assert: 'nesting',
|
|---|
| 202 | }))
|
|---|
| 203 | .map(parserOptionsMapper),
|
|---|
| 204 | invalid: parsers.all([].concat(
|
|---|
| 205 | ...neverValid,
|
|---|
| 206 | ...htmlForInvalid,
|
|---|
| 207 | ))
|
|---|
| 208 | .map(ruleOptionsMapperFactory({
|
|---|
| 209 | assert: 'nesting',
|
|---|
| 210 | }))
|
|---|
| 211 | .map(parserOptionsMapper),
|
|---|
| 212 | });
|
|---|
| 213 |
|
|---|
| 214 | // either valid
|
|---|
| 215 | ruleTester.run(ruleName, rule, {
|
|---|
| 216 | valid: parsers.all([].concat(
|
|---|
| 217 | ...alwaysValid,
|
|---|
| 218 | ...htmlForValid,
|
|---|
| 219 | ...nestingValid,
|
|---|
| 220 | ))
|
|---|
| 221 | .map(ruleOptionsMapperFactory({
|
|---|
| 222 | assert: 'either',
|
|---|
| 223 | }))
|
|---|
| 224 | .map(parserOptionsMapper),
|
|---|
| 225 | invalid: parsers.all([].concat(
|
|---|
| 226 | ...neverValid,
|
|---|
| 227 | )).map(parserOptionsMapper),
|
|---|
| 228 | });
|
|---|
| 229 |
|
|---|
| 230 | // both valid
|
|---|
| 231 | ruleTester.run(ruleName, rule, {
|
|---|
| 232 | valid: parsers.all([].concat(
|
|---|
| 233 | ...alwaysValid,
|
|---|
| 234 | ...bothValid,
|
|---|
| 235 | ))
|
|---|
| 236 | .map(ruleOptionsMapperFactory({
|
|---|
| 237 | assert: 'both',
|
|---|
| 238 | }))
|
|---|
| 239 | .map(parserOptionsMapper),
|
|---|
| 240 | invalid: parsers.all([].concat(
|
|---|
| 241 | ...neverValid,
|
|---|
| 242 | )).map(parserOptionsMapper),
|
|---|
| 243 | });
|
|---|