@aishware/react-a11y-rules-web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aishwarya Sharma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # @aishware/react-a11y-rules-web
2
+
3
+ The web rule pack for [`@aishware/react-a11y`](https://www.npmjs.com/package/@aishware/react-a11y) —
4
+ the WCAG 2.2 criteria, document structure, focus visibility and project-wide
5
+ checks that [`eslint-plugin-jsx-a11y`](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y)
6
+ does **not** cover. Run jsx-a11y in your ESLint config for standard web a11y
7
+ (alt text, ARIA validity, role/element semantics); this pack covers the gaps
8
+ with no overlap, so the two run together cleanly.
9
+
10
+ Covers: color contrast, target size, label-in-name, pointer cancellation,
11
+ viewport zoom, meta-refresh, reading order, document structure (heading order,
12
+ lists, tables, fieldsets, duplicate landmarks), focus visibility, ARIA
13
+ required-context, the WCAG 2.2 form criteria (accessible authentication, error
14
+ identification, autocomplete-off), and cross-file `htmlFor` ↔ `id` label
15
+ resolution (a project-wide pass).
16
+
17
+ ```ts
18
+ import { analyze } from '@aishware/react-a11y-core';
19
+ import { webRules } from '@aishware/react-a11y-rules-web';
20
+
21
+ const diagnostics = analyze({ code, filename: 'App.tsx', platform: 'web', rules: webRules });
22
+ ```
23
+
24
+ Most users want the CLI instead:
25
+ [`@aishware/react-a11y`](https://www.npmjs.com/package/@aishware/react-a11y).
26
+
27
+ Full rule list: <https://github.com/1aishwaryasharma/react-a11y/blob/main/docs/rules/web.md>
28
+
29
+ ## License
30
+
31
+ MIT
@@ -0,0 +1,30 @@
1
+ import type { A11yConfig, ProjectPass, Rule } from '@aishware/react-a11y-core';
2
+ import { buttonHasName, labelInName, mediaNoAutoplay } from './rules/names.js';
3
+ import { pointerCancellation, noOutlineNone } from './rules/interactions.js';
4
+ import { inputButtonHasName, accessibleAuthentication, errorIdentification, noAutocompleteOff } from './rules/forms.js';
5
+ import { metaViewportZoomable, noMetaRefresh, titleHasContent } from './rules/document.js';
6
+ import { headingOrder, listStructure, tableHasHeader, fieldsetHasLegend, ariaRequiredContext, meaningfulOrder, noDuplicateMain } from './rules/structure.js';
7
+ import { colorContrast, targetSize } from './rules/contrast.js';
8
+ export { createLabelForPass, formControlHasLabel } from './project/labels.js';
9
+ /**
10
+ * The project-wide passes the web pack runs (currently the cross-file label
11
+ * resolution behind `form-control-has-label`). Single source of truth so the
12
+ * CLI, VS Code, and any future consumer wire the same passes — the registered
13
+ * `formControlHasLabel` rule is a no-op without this.
14
+ */
15
+ export declare function webProjectPasses(config: A11yConfig): ProjectPass[];
16
+ export { buttonHasName, labelInName, mediaNoAutoplay, pointerCancellation, noOutlineNone, inputButtonHasName, accessibleAuthentication, errorIdentification, noAutocompleteOff, metaViewportZoomable, noMetaRefresh, titleHasContent, headingOrder, listStructure, tableHasHeader, fieldsetHasLegend, ariaRequiredContext, meaningfulOrder, noDuplicateMain, colorContrast, targetSize, };
17
+ /**
18
+ * The web rule pack — the WCAG 2.2 criteria, document structure, focus
19
+ * visibility and project-aware checks that eslint-plugin-jsx-a11y does NOT
20
+ * cover. Pair this with eslint-plugin-jsx-a11y, which owns the standard web
21
+ * a11y rules (alt text, ARIA validity, role/element semantics, …).
22
+ */
23
+ export declare const webRules: Rule[];
24
+ /**
25
+ * WCAG 2.2 A/AA success criteria that eslint-plugin-jsx-a11y covers. react-a11y
26
+ * defers these to jsx-a11y (run it in your ESLint config), so the conformance
27
+ * report can attribute them rather than counting them as gaps.
28
+ */
29
+ export declare const JSX_A11Y_COVERED_WCAG: string[];
30
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,2BAA2B,CAAC;AAC/E,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAC/E,OAAO,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAC7E,OAAO,EACL,kBAAkB,EAClB,wBAAwB,EACxB,mBAAmB,EACnB,iBAAiB,EAClB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,oBAAoB,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC3F,OAAO,EACL,YAAY,EACZ,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,mBAAmB,EACnB,eAAe,EACf,eAAe,EAChB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAGhE,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAE9E;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,UAAU,GAAG,WAAW,EAAE,CAElE;AAED,OAAO,EACL,aAAa,EACb,WAAW,EACX,eAAe,EACf,mBAAmB,EACnB,aAAa,EACb,kBAAkB,EAClB,wBAAwB,EACxB,mBAAmB,EACnB,iBAAiB,EACjB,oBAAoB,EACpB,aAAa,EACb,eAAe,EACf,YAAY,EACZ,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,aAAa,EACb,UAAU,GACX,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,QAAQ,EAAE,IAAI,EAgC1B,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,EAAE,MAAM,EAGzC,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,66 @@
1
+ import { buttonHasName, labelInName, mediaNoAutoplay } from './rules/names.js';
2
+ import { pointerCancellation, noOutlineNone } from './rules/interactions.js';
3
+ import { inputButtonHasName, accessibleAuthentication, errorIdentification, noAutocompleteOff, } from './rules/forms.js';
4
+ import { metaViewportZoomable, noMetaRefresh, titleHasContent } from './rules/document.js';
5
+ import { headingOrder, listStructure, tableHasHeader, fieldsetHasLegend, ariaRequiredContext, meaningfulOrder, noDuplicateMain, } from './rules/structure.js';
6
+ import { colorContrast, targetSize } from './rules/contrast.js';
7
+ import { createLabelForPass, formControlHasLabel } from './project/labels.js';
8
+ export { createLabelForPass, formControlHasLabel } from './project/labels.js';
9
+ /**
10
+ * The project-wide passes the web pack runs (currently the cross-file label
11
+ * resolution behind `form-control-has-label`). Single source of truth so the
12
+ * CLI, VS Code, and any future consumer wire the same passes — the registered
13
+ * `formControlHasLabel` rule is a no-op without this.
14
+ */
15
+ export function webProjectPasses(config) {
16
+ return [createLabelForPass(config.rules)];
17
+ }
18
+ export { buttonHasName, labelInName, mediaNoAutoplay, pointerCancellation, noOutlineNone, inputButtonHasName, accessibleAuthentication, errorIdentification, noAutocompleteOff, metaViewportZoomable, noMetaRefresh, titleHasContent, headingOrder, listStructure, tableHasHeader, fieldsetHasLegend, ariaRequiredContext, meaningfulOrder, noDuplicateMain, colorContrast, targetSize, };
19
+ /**
20
+ * The web rule pack — the WCAG 2.2 criteria, document structure, focus
21
+ * visibility and project-aware checks that eslint-plugin-jsx-a11y does NOT
22
+ * cover. Pair this with eslint-plugin-jsx-a11y, which owns the standard web
23
+ * a11y rules (alt text, ARIA validity, role/element semantics, …).
24
+ */
25
+ export const webRules = [
26
+ // names jsx-a11y lacks
27
+ buttonHasName,
28
+ inputButtonHasName,
29
+ // document
30
+ titleHasContent,
31
+ metaViewportZoomable,
32
+ noMetaRefresh,
33
+ // media
34
+ mediaNoAutoplay,
35
+ // aria (required-context has no jsx-a11y equivalent)
36
+ ariaRequiredContext,
37
+ // structure
38
+ headingOrder,
39
+ listStructure,
40
+ tableHasHeader,
41
+ fieldsetHasLegend,
42
+ noDuplicateMain,
43
+ // cross-file label resolution (project-wide; implemented as a pass)
44
+ formControlHasLabel,
45
+ // focus visibility
46
+ noOutlineNone,
47
+ // forms (WCAG 2.2)
48
+ accessibleAuthentication,
49
+ errorIdentification,
50
+ noAutocompleteOff,
51
+ // pointer, contrast, target size, reading order, label-in-name (WCAG 2.1/2.2)
52
+ labelInName,
53
+ pointerCancellation,
54
+ colorContrast,
55
+ targetSize,
56
+ meaningfulOrder,
57
+ ];
58
+ /**
59
+ * WCAG 2.2 A/AA success criteria that eslint-plugin-jsx-a11y covers. react-a11y
60
+ * defers these to jsx-a11y (run it in your ESLint config), so the conformance
61
+ * report can attribute them rather than counting them as gaps.
62
+ */
63
+ export const JSX_A11Y_COVERED_WCAG = [
64
+ '1.1.1', '1.2.2', '1.3.1', '1.3.5', '2.1.1', '2.2.2', '2.4.3', '2.4.4',
65
+ '2.4.6', '3.1.1', '3.1.2', '3.2.1', '3.3.2', '4.1.2',
66
+ ];
@@ -0,0 +1,18 @@
1
+ import { type ProjectPass, type RuleSetting } from '@aishware/react-a11y-core';
2
+ /**
3
+ * Registration entry for the cross-file label check. The actual work is the
4
+ * project pass below (`createLabelForPass`); this exists so the rule appears in
5
+ * `--list-rules` / `--coverage` and so `"form-control-has-label": "off"` is a
6
+ * visible, documented config key. The per-element visitor is intentionally
7
+ * empty — the analysis is project-wide, not per-file.
8
+ */
9
+ export declare const formControlHasLabel: import("@aishware/react-a11y-core").Rule;
10
+ /**
11
+ * Cross-file resolution of <label htmlFor> ↔ id. The per-file rule gives an
12
+ * id the benefit of the doubt; this pass closes the loop once the whole
13
+ * project has been seen: an id no <label htmlFor> ever references is an
14
+ * unlabeled control. Any *dynamic* htmlFor in the project disables the pass —
15
+ * it could reference anything.
16
+ */
17
+ export declare function createLabelForPass(ruleSettings?: Record<string, RuleSetting>): ProjectPass;
18
+ //# sourceMappingURL=labels.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"labels.d.ts","sourceRoot":"","sources":["../../src/project/labels.ts"],"names":[],"mappings":"AAAA,OAAO,EAQL,KAAK,WAAW,EAChB,KAAK,WAAW,EAGjB,MAAM,2BAA2B,CAAC;AAKnC;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,0CAU/B,CAAC;AAmBF;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,YAAY,GAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAM,GAAG,WAAW,CAgD9F"}
@@ -0,0 +1,90 @@
1
+ import { attrProvidesValue, findAncestor, isAriaHidden, resolveWcag, staticString, } from '@aishware/react-a11y-core';
2
+ import { defineRule } from '../util.js';
3
+ const RULE_ID = 'form-control-has-label';
4
+ /**
5
+ * Registration entry for the cross-file label check. The actual work is the
6
+ * project pass below (`createLabelForPass`); this exists so the rule appears in
7
+ * `--list-rules` / `--coverage` and so `"form-control-has-label": "off"` is a
8
+ * visible, documented config key. The per-element visitor is intentionally
9
+ * empty — the analysis is project-wide, not per-file.
10
+ */
11
+ export const formControlHasLabel = defineRule({
12
+ id: RULE_ID,
13
+ description: 'Form controls must have a programmatically associated label (resolved across files).',
14
+ severity: 'serious',
15
+ wcag: ['1.3.1', '3.3.2', '4.1.2'],
16
+ partial: true,
17
+ project: true,
18
+ }, () => { });
19
+ const UNLABELED_INPUT_TYPES = new Set(['hidden', 'submit', 'reset', 'button', 'image']);
20
+ function isControl(el) {
21
+ if (el.isComponent || !['input', 'select', 'textarea'].includes(el.name))
22
+ return false;
23
+ if (el.name === 'input') {
24
+ const type = staticString(el, 'type')?.trim().toLowerCase();
25
+ if (type && UNLABELED_INPUT_TYPES.has(type))
26
+ return false;
27
+ }
28
+ return true;
29
+ }
30
+ /**
31
+ * Cross-file resolution of <label htmlFor> ↔ id. The per-file rule gives an
32
+ * id the benefit of the doubt; this pass closes the loop once the whole
33
+ * project has been seen: an id no <label htmlFor> ever references is an
34
+ * unlabeled control. Any *dynamic* htmlFor in the project disables the pass —
35
+ * it could reference anything.
36
+ */
37
+ export function createLabelForPass(ruleSettings = {}) {
38
+ const setting = ruleSettings[RULE_ID];
39
+ const htmlForIds = new Set();
40
+ let sawDynamicHtmlFor = false;
41
+ const candidates = [];
42
+ return {
43
+ collect(model, filename) {
44
+ if (setting === 'off')
45
+ return;
46
+ for (const el of model.elements) {
47
+ // Count htmlFor on DOM <label> and on components (design-system
48
+ // <Label htmlFor> wrappers forward it to a real label).
49
+ const attr = el.attrs.get('htmlFor') ?? (!el.isComponent && el.name === 'label' ? el.attrs.get('for') : undefined);
50
+ if (attr) {
51
+ if (attr.kind === 'static' && typeof attr.value === 'string')
52
+ htmlForIds.add(attr.value);
53
+ else
54
+ sawDynamicHtmlFor = true;
55
+ }
56
+ if (!isControl(el) || el.hasSpread || isAriaHidden(el))
57
+ continue;
58
+ if (attrProvidesValue(el, 'aria-label') ||
59
+ attrProvidesValue(el, 'aria-labelledby') ||
60
+ attrProvidesValue(el, 'title'))
61
+ continue;
62
+ if (findAncestor(el, (a) => !a.isComponent && a.name === 'label'))
63
+ continue;
64
+ const id = staticString(el, 'id');
65
+ if (id === undefined)
66
+ continue; // no-id case is reported by the per-file rule
67
+ candidates.push({ filename, loc: el.loc, id, elName: el.name });
68
+ }
69
+ },
70
+ finalize() {
71
+ if (setting === 'off' || sawDynamicHtmlFor)
72
+ return [];
73
+ const severity = setting ?? 'serious';
74
+ const wcag = resolveWcag(['1.3.1', '3.3.2', '4.1.2']);
75
+ return candidates
76
+ .filter((c) => !htmlForIds.has(c.id))
77
+ .map((c) => ({
78
+ ruleId: RULE_ID,
79
+ message: `<${c.elName} id="${c.id}"> is not referenced by any <label htmlFor="${c.id}"> in the project — the control has no label after all.`,
80
+ severity,
81
+ file: c.filename,
82
+ line: c.loc.line,
83
+ column: c.loc.column,
84
+ endLine: c.loc.endLine,
85
+ endColumn: c.loc.endColumn,
86
+ wcag,
87
+ }));
88
+ },
89
+ };
90
+ }
@@ -0,0 +1,24 @@
1
+ /** All aria-* attributes must exist in ARIA 1.2 and use correct (lowercase) casing. */
2
+ export declare const ariaAttrsValid: import("@react-a11y/core").Rule;
3
+ /** role values must be real, non-abstract ARIA roles. */
4
+ export declare const roleValid: import("@react-a11y/core").Rule;
5
+ /** Roles such as checkbox/slider have required ARIA states the author must set. */
6
+ export declare const ariaRequiredAttrs: import("@react-a11y/core").Rule;
7
+ /** aria-hidden="true" on focusable elements creates invisible tab stops. */
8
+ export declare const ariaHiddenFocusable: import("@react-a11y/core").Rule;
9
+ /** Explicit roles that duplicate the implicit role are noise. */
10
+ export declare const noRedundantRoles: import("@react-a11y/core").Rule;
11
+ /** ARIA attributes with invalid values are ignored or misread by screen readers. */
12
+ export declare const ariaAttrValueValid: import("@react-a11y/core").Rule;
13
+ /** ARIA states/properties must be supported by the element's explicit role. */
14
+ export declare const roleSupportsAriaProps: import("@react-a11y/core").Rule;
15
+ /** role and aria-* are silently ignored on elements that do not support them. */
16
+ export declare const ariaUnsupportedElements: import("@react-a11y/core").Rule;
17
+ /**
18
+ * aria-activedescendant points focus at a child, so the managing element must
19
+ * itself be focusable — otherwise the active descendant is never reached.
20
+ */
21
+ export declare const ariaActivedescendantHasTabindex: import("@react-a11y/core").Rule;
22
+ /** scope is only valid on <th>. */
23
+ export declare const scopeOnTh: import("@react-a11y/core").Rule;
24
+ //# sourceMappingURL=aria.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aria.d.ts","sourceRoot":"","sources":["../../src/rules/aria.ts"],"names":[],"mappings":"AAiBA,uFAAuF;AACvF,eAAO,MAAM,cAAc,iCA8B1B,CAAC;AAEF,yDAAyD;AACzD,eAAO,MAAM,SAAS,iCAkBrB,CAAC;AAEF,mFAAmF;AACnF,eAAO,MAAM,iBAAiB,iCAkB7B,CAAC;AAiBF,4EAA4E;AAC5E,eAAO,MAAM,mBAAmB,iCAe/B,CAAC;AAuBF,iEAAiE;AACjE,eAAO,MAAM,gBAAgB,iCAqB5B,CAAC;AAgCF,oFAAoF;AACpF,eAAO,MAAM,kBAAkB,iCA4B9B,CAAC;AAmBF,+EAA+E;AAC/E,eAAO,MAAM,qBAAqB,iCAqBjC,CAAC;AAQF,iFAAiF;AACjF,eAAO,MAAM,uBAAuB,iCAoBnC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,+BAA+B,iCAgB3C,CAAC;AAEF,mCAAmC;AACnC,eAAO,MAAM,SAAS,iCAgBrB,CAAC"}
@@ -0,0 +1,310 @@
1
+ import { ABSTRACT_ROLES, ARIA_ATTRS, ROLES, ROLE_REQUIRED_ATTRS, fixRemoveAttr, fixRenameAttr, hasAttr, isAriaHidden, staticString, staticValue, } from '@react-a11y/core';
2
+ import { defineRule } from '../util.js';
3
+ const DEPRECATED_ARIA = new Set(['aria-dropeffect', 'aria-grabbed']);
4
+ /** All aria-* attributes must exist in ARIA 1.2 and use correct (lowercase) casing. */
5
+ export const ariaAttrsValid = defineRule({
6
+ id: 'aria-attrs-valid',
7
+ description: 'aria-* attributes must be valid ARIA 1.2 attributes.',
8
+ severity: 'serious',
9
+ wcag: ['4.1.2'],
10
+ fixable: true,
11
+ }, (el, ctx) => {
12
+ for (const name of el.attrs.keys()) {
13
+ if (!/^aria-/i.test(name))
14
+ continue;
15
+ const lower = name.toLowerCase();
16
+ if (!ARIA_ATTRS.has(lower)) {
17
+ ctx.report({ el, message: `"${name}" is not a valid ARIA attribute. Check the spelling against ARIA 1.2.` });
18
+ }
19
+ else if (name !== lower) {
20
+ ctx.report({
21
+ el,
22
+ message: `ARIA attributes are lowercase: use "${lower}" instead of "${name}".`,
23
+ fix: fixRenameAttr(el, name, lower),
24
+ });
25
+ }
26
+ else if (DEPRECATED_ARIA.has(lower)) {
27
+ ctx.report({
28
+ el,
29
+ message: `"${lower}" is deprecated in ARIA 1.2 and should be removed.`,
30
+ severity: 'minor',
31
+ fix: fixRemoveAttr(el, lower),
32
+ });
33
+ }
34
+ }
35
+ });
36
+ /** role values must be real, non-abstract ARIA roles. */
37
+ export const roleValid = defineRule({
38
+ id: 'aria-role-valid',
39
+ description: 'role attributes must use valid, non-abstract ARIA roles.',
40
+ severity: 'serious',
41
+ wcag: ['4.1.2'],
42
+ }, (el, ctx) => {
43
+ const role = staticString(el, 'role');
44
+ if (role === undefined)
45
+ return;
46
+ for (const token of role.trim().split(/\s+/).filter(Boolean)) {
47
+ if (ABSTRACT_ROLES.has(token)) {
48
+ ctx.report({ el, message: `"${token}" is an abstract ARIA role and must not be used in content.` });
49
+ }
50
+ else if (!ROLES.has(token)) {
51
+ ctx.report({ el, message: `"${token}" is not a valid ARIA role.` });
52
+ }
53
+ }
54
+ });
55
+ /** Roles such as checkbox/slider have required ARIA states the author must set. */
56
+ export const ariaRequiredAttrs = defineRule({
57
+ id: 'aria-required-attrs',
58
+ description: 'Elements with ARIA roles must have the states/properties that role requires.',
59
+ severity: 'serious',
60
+ wcag: ['4.1.2'],
61
+ }, (el, ctx) => {
62
+ if (el.hasSpread)
63
+ return;
64
+ const role = staticString(el, 'role')?.trim();
65
+ if (!role)
66
+ return;
67
+ const required = ROLE_REQUIRED_ATTRS[role];
68
+ if (!required)
69
+ return;
70
+ const missing = required.filter((attr) => !hasAttr(el, attr));
71
+ if (missing.length > 0) {
72
+ ctx.report({ el, message: `role="${role}" requires ${missing.join(' and ')} to be set.` });
73
+ }
74
+ });
75
+ const ALWAYS_FOCUSABLE = new Set(['button', 'select', 'textarea', 'summary', 'iframe', 'embed']);
76
+ function isFocusable(el) {
77
+ if (staticValue(el, 'disabled') === true)
78
+ return false;
79
+ const tabIndex = staticValue(el, 'tabIndex');
80
+ if (typeof tabIndex === 'number')
81
+ return tabIndex >= 0;
82
+ if (typeof tabIndex === 'string' && tabIndex.trim() !== '')
83
+ return Number(tabIndex) >= 0;
84
+ if (el.isComponent)
85
+ return false;
86
+ if (ALWAYS_FOCUSABLE.has(el.name))
87
+ return true;
88
+ if ((el.name === 'a' || el.name === 'area') && hasAttr(el, 'href'))
89
+ return true;
90
+ if (el.name === 'input')
91
+ return staticString(el, 'type') !== 'hidden';
92
+ if ((el.name === 'audio' || el.name === 'video') && hasAttr(el, 'controls'))
93
+ return true;
94
+ return false;
95
+ }
96
+ /** aria-hidden="true" on focusable elements creates invisible tab stops. */
97
+ export const ariaHiddenFocusable = defineRule({
98
+ id: 'aria-hidden-focusable',
99
+ description: 'aria-hidden="true" must not be used on focusable elements.',
100
+ severity: 'serious',
101
+ wcag: ['4.1.2', '1.3.1'],
102
+ }, (el, ctx) => {
103
+ if (!isAriaHidden(el))
104
+ return;
105
+ if (!isFocusable(el))
106
+ return;
107
+ ctx.report({
108
+ el,
109
+ message: `<${el.name}> is focusable but aria-hidden — keyboard users land on an element screen readers cannot announce. Add tabIndex={-1} or remove aria-hidden.`,
110
+ });
111
+ });
112
+ const IMPLICIT_ROLES = {
113
+ button: 'button',
114
+ img: 'img',
115
+ nav: 'navigation',
116
+ main: 'main',
117
+ aside: 'complementary',
118
+ header: 'banner',
119
+ footer: 'contentinfo',
120
+ ul: 'list',
121
+ ol: 'list',
122
+ li: 'listitem',
123
+ table: 'table',
124
+ article: 'article',
125
+ dialog: 'dialog',
126
+ hr: 'separator',
127
+ output: 'status',
128
+ progress: 'progressbar',
129
+ textarea: 'textbox',
130
+ h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading',
131
+ };
132
+ /** Explicit roles that duplicate the implicit role are noise. */
133
+ export const noRedundantRoles = defineRule({
134
+ id: 'no-redundant-roles',
135
+ description: 'Elements should not declare a role identical to their implicit role.',
136
+ severity: 'minor',
137
+ wcag: ['4.1.2'],
138
+ fixable: true,
139
+ }, (el, ctx) => {
140
+ if (el.isComponent)
141
+ return;
142
+ const role = staticString(el, 'role')?.trim();
143
+ if (!role)
144
+ return;
145
+ const implicit = el.name === 'a' && hasAttr(el, 'href') ? 'link' : IMPLICIT_ROLES[el.name];
146
+ if (implicit === role) {
147
+ ctx.report({
148
+ el,
149
+ message: `role="${role}" is redundant — <${el.name}> already has that implicit role.`,
150
+ fix: fixRemoveAttr(el, 'role'),
151
+ });
152
+ }
153
+ });
154
+ /** Enumerated ARIA attributes and their allowed tokens (ARIA 1.2). */
155
+ const ARIA_ENUM_VALUES = {
156
+ 'aria-atomic': new Set(['true', 'false']),
157
+ 'aria-autocomplete': new Set(['inline', 'list', 'both', 'none']),
158
+ 'aria-busy': new Set(['true', 'false']),
159
+ 'aria-checked': new Set(['true', 'false', 'mixed']),
160
+ 'aria-current': new Set(['page', 'step', 'location', 'date', 'time', 'true', 'false']),
161
+ 'aria-disabled': new Set(['true', 'false']),
162
+ 'aria-expanded': new Set(['true', 'false']),
163
+ 'aria-haspopup': new Set(['false', 'true', 'menu', 'listbox', 'tree', 'grid', 'dialog']),
164
+ 'aria-hidden': new Set(['true', 'false']),
165
+ 'aria-invalid': new Set(['true', 'false', 'grammar', 'spelling']),
166
+ 'aria-live': new Set(['off', 'polite', 'assertive']),
167
+ 'aria-modal': new Set(['true', 'false']),
168
+ 'aria-multiline': new Set(['true', 'false']),
169
+ 'aria-multiselectable': new Set(['true', 'false']),
170
+ 'aria-orientation': new Set(['horizontal', 'vertical', 'undefined']),
171
+ 'aria-pressed': new Set(['true', 'false', 'mixed']),
172
+ 'aria-readonly': new Set(['true', 'false']),
173
+ 'aria-required': new Set(['true', 'false']),
174
+ 'aria-selected': new Set(['true', 'false']),
175
+ 'aria-sort': new Set(['ascending', 'descending', 'none', 'other']),
176
+ };
177
+ const ARIA_NUMERIC = new Set([
178
+ 'aria-level', 'aria-valuemax', 'aria-valuemin', 'aria-valuenow',
179
+ 'aria-colcount', 'aria-colindex', 'aria-colspan', 'aria-rowcount',
180
+ 'aria-rowindex', 'aria-rowspan', 'aria-posinset', 'aria-setsize',
181
+ ]);
182
+ /** ARIA attributes with invalid values are ignored or misread by screen readers. */
183
+ export const ariaAttrValueValid = defineRule({
184
+ id: 'aria-attr-value-valid',
185
+ description: 'ARIA attribute values must be valid for the attribute type.',
186
+ severity: 'serious',
187
+ wcag: ['4.1.2'],
188
+ }, (el, ctx) => {
189
+ for (const [name, attr] of el.attrs) {
190
+ if (attr.kind !== 'static')
191
+ continue;
192
+ const enums = ARIA_ENUM_VALUES[name];
193
+ if (enums) {
194
+ const v = typeof attr.value === 'boolean' ? String(attr.value) : attr.value;
195
+ if (typeof v !== 'string' || !enums.has(v.trim().toLowerCase())) {
196
+ ctx.report({
197
+ el,
198
+ message: `${name}=${JSON.stringify(attr.value)} is not valid — allowed: ${[...enums].join(', ')}.`,
199
+ });
200
+ }
201
+ }
202
+ else if (ARIA_NUMERIC.has(name)) {
203
+ const v = attr.value;
204
+ const ok = typeof v === 'number' || (typeof v === 'string' && v.trim() !== '' && !Number.isNaN(Number(v)));
205
+ if (!ok) {
206
+ ctx.report({ el, message: `${name}=${JSON.stringify(v)} must be a number.` });
207
+ }
208
+ }
209
+ }
210
+ });
211
+ /**
212
+ * Role-specific ARIA states/properties and the roles that support them.
213
+ * Global states (aria-label, aria-busy, …) are allowed everywhere and omitted.
214
+ */
215
+ const PROP_ALLOWED_ROLES = {
216
+ 'aria-checked': new Set(['checkbox', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'switch', 'treeitem']),
217
+ 'aria-selected': new Set(['columnheader', 'gridcell', 'option', 'row', 'rowheader', 'tab', 'treeitem']),
218
+ 'aria-pressed': new Set(['button']),
219
+ 'aria-valuenow': new Set(['meter', 'progressbar', 'scrollbar', 'separator', 'slider', 'spinbutton']),
220
+ 'aria-valuemin': new Set(['meter', 'progressbar', 'scrollbar', 'separator', 'slider', 'spinbutton']),
221
+ 'aria-valuemax': new Set(['meter', 'progressbar', 'scrollbar', 'separator', 'slider', 'spinbutton']),
222
+ 'aria-valuetext': new Set(['meter', 'progressbar', 'scrollbar', 'separator', 'slider', 'spinbutton']),
223
+ 'aria-level': new Set(['heading', 'listitem', 'row', 'treeitem']),
224
+ 'aria-sort': new Set(['columnheader', 'rowheader']),
225
+ 'aria-multiline': new Set(['textbox', 'searchbox']),
226
+ };
227
+ /** ARIA states/properties must be supported by the element's explicit role. */
228
+ export const roleSupportsAriaProps = defineRule({
229
+ id: 'role-supports-aria-props',
230
+ description: 'ARIA states/properties must be supported by the element role.',
231
+ severity: 'serious',
232
+ wcag: ['4.1.2'],
233
+ }, (el, ctx) => {
234
+ if (el.hasSpread)
235
+ return;
236
+ const role = staticString(el, 'role')?.trim();
237
+ if (!role || !ROLES.has(role))
238
+ return;
239
+ for (const name of el.attrs.keys()) {
240
+ const allowed = PROP_ALLOWED_ROLES[name.toLowerCase()];
241
+ if (allowed && !allowed.has(role)) {
242
+ ctx.report({
243
+ el,
244
+ message: `${name} is not supported by role="${role}" — it is ignored. Supported on: ${[...allowed].join(', ')}.`,
245
+ });
246
+ }
247
+ }
248
+ });
249
+ /** HTML elements (per HTML-AAM) that do not support role or aria-* at all. */
250
+ const ARIA_RESERVED_TAGS = new Set([
251
+ 'base', 'col', 'colgroup', 'head', 'html', 'link', 'meta', 'noscript',
252
+ 'param', 'script', 'source', 'style', 'title', 'track',
253
+ ]);
254
+ /** role and aria-* are silently ignored on elements that do not support them. */
255
+ export const ariaUnsupportedElements = defineRule({
256
+ id: 'aria-unsupported-elements',
257
+ description: 'role and aria-* must not be placed on elements that do not support them.',
258
+ severity: 'serious',
259
+ wcag: ['4.1.2'],
260
+ fixable: true,
261
+ }, (el, ctx) => {
262
+ if (el.isComponent || !ARIA_RESERVED_TAGS.has(el.name))
263
+ return;
264
+ for (const name of el.attrs.keys()) {
265
+ if (name === 'role' || /^aria-/i.test(name)) {
266
+ ctx.report({
267
+ el,
268
+ message: `<${el.name}> does not support "${name}" — it is ignored by assistive technology. Remove it.`,
269
+ fix: fixRemoveAttr(el, name),
270
+ });
271
+ }
272
+ }
273
+ });
274
+ /**
275
+ * aria-activedescendant points focus at a child, so the managing element must
276
+ * itself be focusable — otherwise the active descendant is never reached.
277
+ */
278
+ export const ariaActivedescendantHasTabindex = defineRule({
279
+ id: 'aria-activedescendant-has-tabindex',
280
+ description: 'Elements with aria-activedescendant must be focusable.',
281
+ severity: 'serious',
282
+ wcag: ['4.1.2', '2.1.1'],
283
+ }, (el, ctx) => {
284
+ if (el.isComponent || el.hasSpread)
285
+ return;
286
+ if (!hasAttr(el, 'aria-activedescendant'))
287
+ return;
288
+ if (isFocusable(el) || hasAttr(el, 'tabIndex'))
289
+ return;
290
+ ctx.report({
291
+ el,
292
+ message: `<${el.name}> has aria-activedescendant but is not focusable, so the active descendant is unreachable. Add tabIndex={0}.`,
293
+ });
294
+ });
295
+ /** scope is only valid on <th>. */
296
+ export const scopeOnTh = defineRule({
297
+ id: 'scope-attr-valid',
298
+ description: 'The scope attribute is only valid on <th> elements.',
299
+ severity: 'minor',
300
+ wcag: ['1.3.1'],
301
+ fixable: true,
302
+ }, (el, ctx) => {
303
+ if (el.isComponent || !hasAttr(el, 'scope') || el.name === 'th')
304
+ return;
305
+ ctx.report({
306
+ el,
307
+ message: `scope has no effect on <${el.name}> — it is only valid on <th>.`,
308
+ fix: fixRemoveAttr(el, 'scope'),
309
+ });
310
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * WCAG 1.4.3 contrast for inline styles where both colors are literal.
3
+ * Below 3:1 fails even for large text → serious. Between 3:1 and 4.5:1 is
4
+ * only flagged when the font size is also known to be small, so unknown-size
5
+ * text that might be large never false-positives.
6
+ */
7
+ export declare const colorContrast: import("@aishware/react-a11y-core").Rule;
8
+ /**
9
+ * WCAG 2.5.8 (AA, new in 2.2): pointer targets need 24×24 CSS px minimum.
10
+ * Only statically-sized inline styles are checked.
11
+ */
12
+ export declare const targetSize: import("@aishware/react-a11y-core").Rule;
13
+ //# sourceMappingURL=contrast.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contrast.d.ts","sourceRoot":"","sources":["../../src/rules/contrast.ts"],"names":[],"mappings":"AAWA;;;;;GAKG;AACH,eAAO,MAAM,aAAa,0CAazB,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,UAAU,0CAkCtB,CAAC"}