Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: github/eslint-plugin-github
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v4.8.0
Choose a base ref
...
head repository: github/eslint-plugin-github
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: v4.9.0
Choose a head ref

Commits on Jul 5, 2023

  1. Copy the full SHA
    7ddd508 View commit details

Commits on Jul 7, 2023

  1. pass params

    kendallgassner committed Jul 7, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    6b767a5 View commit details
  2. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    7f76868 View commit details

Commits on Jul 10, 2023

  1. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    982b103 View commit details
  2. bump aria-query to 5.3.0

    khiga8 committed Jul 10, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    951feea View commit details
  3. Don't delete all of attributes

    khiga8 committed Jul 10, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    95c60cf View commit details
  4. a and area without href are now generic,

    - generic does not list `aria-checked`.
    khiga8 committed Jul 10, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    762f7e1 View commit details
  5. link tag maps to nothing

    - according to https://www.w3.org/TR/html-aam-1.0/, link tag doesn't map to anything even with an href.
    - therefore, it no longer makes sense to try to determine role and evaluate, so it should be skipped.
    - accordingly, tests are deleted since we don't want to evaluate it.
    khiga8 committed Jul 10, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    2747f2d View commit details
  6. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    d21a25e View commit details
  7. update docs

    kendallgassner committed Jul 10, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    c916e88 View commit details
  8. Merge pull request #446 from github/kendallg/sr-only

    Create rule: github/a11y-no-visually-hidden-interactive-element
    kendallgassner authored Jul 10, 2023
    Copy the full SHA
    1e0d857 View commit details
  9. Copy the full SHA
    281fa95 View commit details
  10. Add aria-label and aria-labelledby and alt and name

    Add additional dis-ambiguating attributes.
    
    When `aria-label` or `aria-labelledby` is set at all, constraints should be set.
    khiga8 committed Jul 10, 2023
    Copy the full SHA
    33fe0b0 View commit details
  11. update tests to reflect truth

    khiga8 committed Jul 10, 2023
    Copy the full SHA
    985fa01 View commit details
  12. Copy the full SHA
    de463b3 View commit details

Commits on Jul 11, 2023

  1. Copy the full SHA
    7d3c8b1 View commit details
  2. updates text

    lindseywild committed Jul 11, 2023
    Copy the full SHA
    46fa4a6 View commit details
  3. add more valid test cases

    lindseywild committed Jul 11, 2023
    Copy the full SHA
    c2c7e07 View commit details
  4. Copy the full SHA
    4a59a5b View commit details
  5. 1:1 mapping

    kendallgassner committed Jul 11, 2023
    Copy the full SHA
    61a2d19 View commit details
  6. adds docs

    lindseywild committed Jul 11, 2023
    Copy the full SHA
    ce88c19 View commit details
  7. adds to react preset

    lindseywild committed Jul 11, 2023
    Copy the full SHA
    485a104 View commit details
  8. adds to index

    lindseywild committed Jul 11, 2023
    Copy the full SHA
    c633deb View commit details
  9. Extract test helpers

    khiga8 committed Jul 11, 2023
    Copy the full SHA
    247eea2 View commit details
  10. Copy the full SHA
    5c98a44 View commit details
  11. Copy the full SHA
    fc80126 View commit details
  12. Add tests for getRole

    khiga8 committed Jul 11, 2023
    Copy the full SHA
    ba52aca View commit details
  13. Add more tests for getRole

    khiga8 committed Jul 11, 2023
    Copy the full SHA
    b12cda5 View commit details
  14. Apply suggestions from code review

    Co-authored-by: Keith Cirkel <keithamus@users.noreply.github.com>
    lindseywild and keithamus authored Jul 11, 2023
    Copy the full SHA
    e77c35e View commit details
  15. Merge pull request #449 from github/polymorphic-get-element-type

    Add polymorphic component check in `getElementType`
    kendallgassner authored Jul 11, 2023
    Copy the full SHA
    3844902 View commit details
  16. chore(deps): bump semver from 5.7.1 to 5.7.2

    Bumps [semver](https://github.com/npm/node-semver) from 5.7.1 to 5.7.2.
    - [Release notes](https://github.com/npm/node-semver/releases)
    - [Changelog](https://github.com/npm/node-semver/blob/v5.7.2/CHANGELOG.md)
    - [Commits](npm/node-semver@v5.7.1...v5.7.2)
    
    ---
    updated-dependencies:
    - dependency-name: semver
      dependency-type: indirect
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    dependabot[bot] authored Jul 11, 2023
    Copy the full SHA
    a5efde6 View commit details
  17. Copy the full SHA
    eb8024c View commit details
  18. Copy the full SHA
    1cce11e View commit details
  19. Copy the full SHA
    b358515 View commit details
  20. Copy the full SHA
    2941475 View commit details
  21. Add more test

    khiga8 committed Jul 11, 2023
    Copy the full SHA
    9136431 View commit details
  22. better docs

    kendallgassner committed Jul 11, 2023
    Copy the full SHA
    92f656d View commit details
  23. Copy the full SHA
    40089c3 View commit details
  24. Merge pull request #450 from github/lw/adds-svg-rule

    Adds `svg-has-accessible-name` rule
    lindseywild authored Jul 11, 2023
    Copy the full SHA
    efe4c95 View commit details
  25. Merge pull request #451 from github/dependabot/npm_and_yarn/semver-5.7.2

    chore(deps): bump semver from 5.7.1 to 5.7.2
    jfuchs authored Jul 11, 2023
    Copy the full SHA
    86d1fa5 View commit details
  26. clean

    kendallgassner committed Jul 11, 2023
    Copy the full SHA
    35ef90c View commit details
  27. Copy the full SHA
    0d95201 View commit details

Commits on Jul 12, 2023

  1. Copy the full SHA
    2fa5eca View commit details
  2. Merge pull request #453 from github/add-a11y-no-title-attribute

    Create rule: a11y-no-title-attribute
    kendallgassner authored Jul 12, 2023
    Copy the full SHA
    65fa6f2 View commit details
  3. Rename helpers to mocks

    khiga8 committed Jul 12, 2023
    Copy the full SHA
    b2ed30e View commit details
  4. Copy the full SHA
    8fb6419 View commit details
  5. update tests

    khiga8 committed Jul 12, 2023
    Copy the full SHA
    5a66033 View commit details
  6. Copy the full SHA
    aaca700 View commit details
  7. Copy the full SHA
    40c0b2b View commit details
  8. Copy the full SHA
    f1eb769 View commit details
58 changes: 30 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
@@ -47,15 +47,16 @@ _Note: This is experimental and subject to change._

The `react` config includes rules which target specific HTML elements. You may provide a mapping of custom components to an HTML element in your `eslintrc` configuration to increase linter coverage.

For each component, you may specify a `default` and/or `props`. `default` may make sense if there's a 1:1 mapping between a component and an HTML element. However, if the HTML output of a component is dependent on a prop value, you can provide a mapping using the `props` key. To minimize conflicts and complexity, this currently only supports the mapping of a single prop type.
By default, these eslint rules will check the "as" prop for underlying element changes. If your repo uses a different prop name for polymorphic components provide the prop name in your `eslintrc` configuration under `polymorphicPropName`.

```json
{
"settings": {
"github": {
"polymorphicPropName": "asChild",
"components": {
"Box": {"default": "p"},
"Link": {"props": {"as": {"undefined": "a", "a": "a", "button": "button"}}}
"Box": "p",
"Link": "a"
}
}
}
@@ -66,9 +67,7 @@ This config will be interpreted in the following way:

- All `<Box>` elements will be treated as a `p` element type.
- `<Link>` without a defined `as` prop will be treated as a `a`.
- `<Link as='a'>` will treated as an `a` element type.
- `<Link as='button'>` will be treated as a `button` element type.
- `<Link as='summary'>` will be treated as the raw `Link` type because there is no configuration set for `as='summary'`.

### Rules

@@ -82,28 +81,31 @@ This config will be interpreted in the following way:
🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\
❌ Deprecated.

| Name                              | Description | 💼 | 🔧 ||
| :----------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------- | :- | :- | :- |
| [a11y-aria-label-is-well-formatted](docs/rules/a11y-aria-label-is-well-formatted.md) | [aria-label] text should be formatted as you would visual text. | ⚛️ | | |
| [a11y-no-generic-link-text](docs/rules/a11y-no-generic-link-text.md) | disallow generic link text | | ||
| [array-foreach](docs/rules/array-foreach.md) | enforce `for..of` loops over `Array.forEach` || | |
| [async-currenttarget](docs/rules/async-currenttarget.md) | disallow `event.currentTarget` calls inside of async functions | 🔍 | | |
| [async-preventdefault](docs/rules/async-preventdefault.md) | disallow `event.preventDefault` calls inside of async functions | 🔍 | | |
| [authenticity-token](docs/rules/authenticity-token.md) | disallow usage of CSRF tokens in JavaScript | 🔐 | | |
| [get-attribute](docs/rules/get-attribute.md) | disallow wrong usage of attribute names | 🔍 | 🔧 | |
| [js-class-name](docs/rules/js-class-name.md) | enforce a naming convention for js- prefixed classes | 🔐 | | |
| [no-blur](docs/rules/no-blur.md) | disallow usage of `Element.prototype.blur()` | 🔍 | | |
| [no-d-none](docs/rules/no-d-none.md) | disallow usage the `d-none` CSS class | 🔐 | | |
| [no-dataset](docs/rules/no-dataset.md) | enforce usage of `Element.prototype.getAttribute` instead of `Element.prototype.datalist` | 🔍 | | |
| [no-dynamic-script-tag](docs/rules/no-dynamic-script-tag.md) | disallow creating dynamic script tags || | |
| [no-implicit-buggy-globals](docs/rules/no-implicit-buggy-globals.md) | disallow implicit global variables || | |
| [no-inner-html](docs/rules/no-inner-html.md) | disallow `Element.prototype.innerHTML` in favor of `Element.prototype.textContent` | 🔍 | | |
| [no-innerText](docs/rules/no-innerText.md) | disallow `Element.prototype.innerText` in favor of `Element.prototype.textContent` | 🔍 | 🔧 | |
| [no-then](docs/rules/no-then.md) | enforce using `async/await` syntax over Promises || | |
| [no-useless-passive](docs/rules/no-useless-passive.md) | disallow marking a event handler as passive when it has no effect | 🔍 | 🔧 | |
| [prefer-observers](docs/rules/prefer-observers.md) | disallow poorly performing event listeners | 🔍 | | |
| [require-passive-events](docs/rules/require-passive-events.md) | enforce marking high frequency event handlers as passive | 🔍 | | |
| [role-supports-aria-props](docs/rules/role-supports-aria-props.md) | Enforce that elements with explicit or implicit roles defined contain only `aria-*` properties supported by that `role`. | ⚛️ | | |
| [unescaped-html-literal](docs/rules/unescaped-html-literal.md) | disallow unescaped HTML literals | 🔍 | | |
| Name                                        | Description | 💼 | 🔧 ||
| :------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------- | :- | :- | :- |
| [a11y-aria-label-is-well-formatted](docs/rules/a11y-aria-label-is-well-formatted.md) | [aria-label] text should be formatted as you would visual text. | ⚛️ | | |
| [a11y-no-generic-link-text](docs/rules/a11y-no-generic-link-text.md) | disallow generic link text | | ||
| [a11y-no-title-attribute](docs/rules/a11y-no-title-attribute.md) | Guards against developers using the title attribute | ⚛️ | | |
| [a11y-no-visually-hidden-interactive-element](docs/rules/a11y-no-visually-hidden-interactive-element.md) | Ensures that interactive elements are not visually hidden | ⚛️ | | |
| [a11y-role-supports-aria-props](docs/rules/a11y-role-supports-aria-props.md) | Enforce that elements with explicit or implicit roles defined contain only `aria-*` properties supported by that `role`. | ⚛️ | | |
| [a11y-svg-has-accessible-name](docs/rules/a11y-svg-has-accessible-name.md) | SVGs must have an accessible name | ⚛️ | | |
| [array-foreach](docs/rules/array-foreach.md) | enforce `for..of` loops over `Array.forEach` || | |
| [async-currenttarget](docs/rules/async-currenttarget.md) | disallow `event.currentTarget` calls inside of async functions | 🔍 | | |
| [async-preventdefault](docs/rules/async-preventdefault.md) | disallow `event.preventDefault` calls inside of async functions | 🔍 | | |
| [authenticity-token](docs/rules/authenticity-token.md) | disallow usage of CSRF tokens in JavaScript | 🔐 | | |
| [get-attribute](docs/rules/get-attribute.md) | disallow wrong usage of attribute names | 🔍 | 🔧 | |
| [js-class-name](docs/rules/js-class-name.md) | enforce a naming convention for js- prefixed classes | 🔐 | | |
| [no-blur](docs/rules/no-blur.md) | disallow usage of `Element.prototype.blur()` | 🔍 | | |
| [no-d-none](docs/rules/no-d-none.md) | disallow usage the `d-none` CSS class | 🔐 | | |
| [no-dataset](docs/rules/no-dataset.md) | enforce usage of `Element.prototype.getAttribute` instead of `Element.prototype.datalist` | 🔍 | | |
| [no-dynamic-script-tag](docs/rules/no-dynamic-script-tag.md) | disallow creating dynamic script tags || | |
| [no-implicit-buggy-globals](docs/rules/no-implicit-buggy-globals.md) | disallow implicit global variables || | |
| [no-inner-html](docs/rules/no-inner-html.md) | disallow `Element.prototype.innerHTML` in favor of `Element.prototype.textContent` | 🔍 | | |
| [no-innerText](docs/rules/no-innerText.md) | disallow `Element.prototype.innerText` in favor of `Element.prototype.textContent` | 🔍 | 🔧 | |
| [no-then](docs/rules/no-then.md) | enforce using `async/await` syntax over Promises || | |
| [no-useless-passive](docs/rules/no-useless-passive.md) | disallow marking a event handler as passive when it has no effect | 🔍 | 🔧 | |
| [prefer-observers](docs/rules/prefer-observers.md) | disallow poorly performing event listeners | 🔍 | | |
| [require-passive-events](docs/rules/require-passive-events.md) | enforce marking high frequency event handlers as passive | 🔍 | | |
| [unescaped-html-literal](docs/rules/unescaped-html-literal.md) | disallow unescaped HTML literals | 🔍 | | |

<!-- end auto-generated rules list -->
45 changes: 45 additions & 0 deletions docs/rules/a11y-no-title-attribute.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Guards against developers using the title attribute (`github/a11y-no-title-attribute`)

💼 This rule is enabled in the ⚛️ `react` config.

<!-- end auto-generated rule header -->

The title attribute is strongly discouraged. The only exception is on an `<iframe>` element. It is hardly useful and cannot be accessed by multiple groups of users including keyboard-only users and mobile users.

The `title` attribute is commonly set on links, matching the link text. This is redundant and unnecessary so it can be simply be removed.

If you are considering the `title` attribute to provide supplementary description, consider whether the text in question can be persisted in the design. Alternatively, if it's important to display supplementary text that is hidden by default, consider using an accessible tooltip implementation that uses the aria-labelledby or aria-describedby semantics. Even so, proceed with caution: tooltips should only be used on interactive elements like links or buttons. See [Tooltip alternatives](https://primer.style/design/guides/accessibility/tooltip-alternatives) for more accessible alternatives.

### Should I use the title attribute to provide an accessible name for an <svg>?

Use a <title> element instead of the title attribute, or an aria-label.

## Rule Details

👎 Examples of **incorrect** code for this rule:

```jsx
<a src="https://www.github.com" title="A home for all developers">
GitHub
</a>
```

```jsx
<a href="/" title="github.com">
GitHub
</a>
```

```jsx
<span src="https://www.github.com" title="supercalifragilisticexpialidocious">
supercali...
</span>
```

👍 Examples of **correct** code for this rule:

```jsx
<iframe src="https://www.github.com" title="Github"></iframe>
```

## Version
79 changes: 79 additions & 0 deletions docs/rules/a11y-no-visually-hidden-interactive-element.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Ensures that interactive elements are not visually hidden (`github/a11y-no-visually-hidden-interactive-element`)

💼 This rule is enabled in the ⚛️ `react` config.

<!-- end auto-generated rule header -->

## Rule Details

This rule guards against visually hiding interactive elements. If a sighted keyboard user navigates to an interactive element that is visually hidden they might become confused and assume that keyboard focus has been lost.

Note: we are not guarding against visually hidden `input` elements at this time. Some visually hidden inputs might cause a false positive (e.g. some file inputs).

### Why do we visually hide content?

Visually hiding content can be useful when you want to provide information specifically to screen reader users or other assitive technology users while keeping content hidden from sighted users.

Applying the following css will visually hide content while still making it accessible to screen reader users.

```css
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
```

👎 Examples of **incorrect** code for this rule:

```jsx
<button className="visually-hidden">Submit</button>
```

```jsx
<VisuallyHidden>
<button>Submit</button>
</VisuallyHidden>
```

```jsx
<VisuallyHidden as="button">Submit</VisuallyHidden>
```

👍 Examples of **correct** code for this rule:

```jsx
<h2 className="visually-hidden">Welcome to GitHub</h2>
```

```jsx
<VisuallyHidden>
<h2>Welcome to GitHub</h2>
</VisuallyHidden>
```

```jsx
<VisuallyHidden as="h2">Welcome to GitHub</VisuallyHidden>
```

## Options

- className - A css className that visually hides content. Defaults to `sr-only`.
- componentName - A react component name that visually hides content. Defaults to `VisuallyHidden`.
- htmlPropName - A prop name used to replace the semantic element that is rendered. Defaults to `as`.

```json
{
"a11y-no-visually-hidden-interactive-element": [
"error",
{
"className": "visually-hidden",
"componentName": "VisuallyHidden",
"htmlPropName": "as"
}
]
}
```

## Version
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Enforce that elements with explicit or implicit roles defined contain only `aria-*` properties supported by that `role` (`github/role-supports-aria-props`)
# Enforce that elements with explicit or implicit roles defined contain only `aria-*` properties supported by that `role` (`github/a11y-role-supports-aria-props`)

💼 This rule is enabled in the ⚛️ `react` config.

73 changes: 73 additions & 0 deletions docs/rules/a11y-svg-has-accessible-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# SVGs must have an accessible name (`github/a11y-svg-has-accessible-name`)

💼 This rule is enabled in the ⚛️ `react` config.

<!-- end auto-generated rule header -->

## Rule Details

An `<svg>` must have an accessible name. Set `aria-label` or `aria-labelledby`, or nest a `<title>` element as the first child of the `<svg>` element.

However, if the `<svg>` is purely decorative, hide it with `aria-hidden="true"` or `role="presentation"`.

## Resources

- [Accessible SVGs](https://css-tricks.com/accessible-svgs/)

## Examples

### **Incorrect** code for this rule 👎

```html
<svg height='100' width='100'>
<circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/>
</svg>
```

```html
<svg height='100' width='100' title='Circle with a black outline and red fill'>
<circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/>
</svg>
```

```html
<svg height='100' width='100'>
<circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/>
<title>Circle with a black outline and red fill</title>
</svg>
```

### **Correct** code for this rule 👍

```html
<svg height='100' width='100'>
<title>Circle with a black outline and red fill</title>
<circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/>
</svg>
```

```html
<svg aria-label='Circle with a black outline and red fill' height='100' width='100'>
<circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/>
</svg>
```

```html
<svg aria-labelledby='circle_text' height='100' width='100'>
<circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/>
</svg>
```

```html
<svg aria-hidden='true' height='100' width='100'>
<circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/>
</svg>
```

```html
<svg role='presentation' height='100' width='100'>
<circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/>
</svg>
```

## Version
7 changes: 5 additions & 2 deletions lib/configs/react.js
Original file line number Diff line number Diff line change
@@ -8,9 +8,12 @@ module.exports = {
plugins: ['github', 'jsx-a11y'],
extends: ['plugin:jsx-a11y/recommended'],
rules: {
'jsx-a11y/role-supports-aria-props': 'off', // Override with github/role-supports-aria-props until https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/issues/910 is resolved
'jsx-a11y/role-supports-aria-props': 'off', // Override with github/a11y-role-supports-aria-props until https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/issues/910 is resolved
'github/a11y-aria-label-is-well-formatted': 'error',
'github/role-supports-aria-props': 'error',
'github/a11y-no-visually-hidden-interactive-element': 'error',
'github/a11y-no-title-attribute': 'error',
'github/a11y-svg-has-accessible-name': 'error',
'github/a11y-role-supports-aria-props': 'error',
'jsx-a11y/no-aria-hidden-on-focusable': 'error',
'jsx-a11y/no-autofocus': 'off',
'jsx-a11y/anchor-ambiguous-text': [
5 changes: 4 additions & 1 deletion lib/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
module.exports = {
rules: {
'a11y-no-visually-hidden-interactive-element': require('./rules/a11y-no-visually-hidden-interactive-element'),
'a11y-no-generic-link-text': require('./rules/a11y-no-generic-link-text'),
'a11y-no-title-attribute': require('./rules/a11y-no-title-attribute'),
'a11y-aria-label-is-well-formatted': require('./rules/a11y-aria-label-is-well-formatted'),
'a11y-role-supports-aria-props': require('./rules/a11y-role-supports-aria-props'),
'a11y-svg-has-accessible-name': require('./rules/a11y-svg-has-accessible-name'),
'array-foreach': require('./rules/array-foreach'),
'async-currenttarget': require('./rules/async-currenttarget'),
'async-preventdefault': require('./rules/async-preventdefault'),
@@ -18,7 +22,6 @@ module.exports = {
'no-then': require('./rules/no-then'),
'no-useless-passive': require('./rules/no-useless-passive'),
'prefer-observers': require('./rules/prefer-observers'),
'role-supports-aria-props': require('./rules/role-supports-aria-props'),
'require-passive-events': require('./rules/require-passive-events'),
'unescaped-html-literal': require('./rules/unescaped-html-literal'),
},
66 changes: 66 additions & 0 deletions lib/rules/a11y-no-title-attribute.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
const {getProp, getPropValue} = require('jsx-ast-utils')
const {getElementType} = require('../utils/get-element-type')

const SEMANTIC_ELEMENTS = [
'a',
'button',
'summary',
'select',
'option',
'textarea',
'input',
'span',
'div',
'p',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'details',
'summary',
'dialog',
'tr',
'th',
'td',
'label',
]

const ifSemanticElement = (context, node) => {
const elementType = getElementType(context, node.openingElement, true)

for (const semanticElement of SEMANTIC_ELEMENTS) {
if (elementType === semanticElement) {
return true
}
}
return false
}

module.exports = {
meta: {
docs: {
description: 'Guards against developers using the title attribute',
url: require('../url')(module),
},
schema: [],
},

create(context) {
return {
JSXElement: node => {
const elementType = getElementType(context, node.openingElement)
if (elementType !== `iframe` && ifSemanticElement(context, node)) {
const titleProp = getPropValue(getProp(node.openingElement.attributes, `title`))
if (titleProp) {
context.report({
node,
message: 'The title attribute is not accessible and should never be used unless for an `<iframe>`.',
})
}
}
},
}
},
}
83 changes: 83 additions & 0 deletions lib/rules/a11y-no-visually-hidden-interactive-element.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
const {getProp, getPropValue} = require('jsx-ast-utils')
const {getElementType} = require('../utils/get-element-type')
const {generateObjSchema} = require('eslint-plugin-jsx-a11y/lib/util/schemas')

const defaultClassName = 'sr-only'
const defaultcomponentName = 'VisuallyHidden'

const schema = generateObjSchema({
className: {type: 'string'},
componentName: {type: 'string'},
htmlPropName: {type: 'string'},
})

/** Note: we are not including input elements at this time
* because a visually hidden input field might cause a false positive.
* (e.g. fileUpload https://github.com/primer/react/pull/3492)
*/
const INTERACTIVE_ELEMENTS = ['a', 'button', 'summary', 'select', 'option', 'textarea']

const checkIfInteractiveElement = (context, node) => {
const elementType = getElementType(context, node.openingElement)

for (const interactiveElement of INTERACTIVE_ELEMENTS) {
if (elementType === interactiveElement) {
return true
}
}
return false
}

// if the node is visually hidden recursively check if it has interactive children
const checkIfVisuallyHiddenAndInteractive = (context, options, node, isParentVisuallyHidden) => {
const {className, componentName} = options
if (node.type === 'JSXElement') {
const classes = getPropValue(getProp(node.openingElement.attributes, 'className'))
const isVisuallyHiddenElement = node.openingElement.name.name === componentName
const hasSROnlyClass = typeof classes !== 'undefined' && classes.includes(className)
let isHidden = false
if (hasSROnlyClass || isVisuallyHiddenElement || !!isParentVisuallyHidden) {
if (checkIfInteractiveElement(context, node)) {
return true
}
isHidden = true
}
if (node.children && node.children.length > 0) {
return (
typeof node.children?.find(child =>
checkIfVisuallyHiddenAndInteractive(context, options, child, !!isParentVisuallyHidden || isHidden),
) !== 'undefined'
)
}
}
return false
}

module.exports = {
meta: {
docs: {
description: 'Ensures that interactive elements are not visually hidden',
url: require('../url')(module),
},
schema: [schema],
},

create(context) {
const {options} = context
const config = options[0] || {}
const className = config.className || defaultClassName
const componentName = config.componentName || defaultcomponentName

return {
JSXElement: node => {
if (checkIfVisuallyHiddenAndInteractive(context, {className, componentName}, node, false)) {
context.report({
node,
message:
'Avoid visually hidding interactive elements. Visually hiding interactive elements can be confusing to sighted keyboard users as it appears their focus has been lost when they navigate to the hidden element.',
})
}
},
}
},
}
61 changes: 61 additions & 0 deletions lib/rules/a11y-role-supports-aria-props.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// @ts-check
const {aria, roles} = require('aria-query')
const {getPropValue, propName} = require('jsx-ast-utils')
const {getRole} = require('../utils/get-role')

module.exports = {
meta: {
docs: {
description:
'Enforce that elements with explicit or implicit roles defined contain only `aria-*` properties supported by that `role`.',
url: require('../url')(module),
},
schema: [],
},

create(context) {
return {
JSXOpeningElement(node) {
// Get the element’s explicit or implicit role
const role = getRole(context, node)

// Return early if role could not be determined
if (!role) return

// Get allowed ARIA attributes:
// - From the role itself
let allowedProps = Object.keys(roles.get(role)?.props || {})
// - From parent roles
for (const parentRole of roles.get(role)?.superClass.flat() ?? []) {
allowedProps = allowedProps.concat(Object.keys(roles.get(parentRole)?.props || {}))
}
// Dedupe, for performance
allowedProps = Array.from(new Set(allowedProps))

// Get prohibited ARIA attributes:
// - From the role itself
let prohibitedProps = roles.get(role)?.prohibitedProps || []
// - From parent roles
for (const parentRole of roles.get(role)?.superClass.flat() ?? []) {
prohibitedProps = prohibitedProps.concat(roles.get(parentRole)?.prohibitedProps || [])
}
// - From comparing allowed vs all ARIA attributes
prohibitedProps = prohibitedProps.concat(aria.keys().filter(x => !allowedProps.includes(x)))
// Dedupe, for performance
prohibitedProps = Array.from(new Set(prohibitedProps))

for (const prop of node.attributes) {
// Return early if prohibited ARIA attribute is set to an ignorable value
if (getPropValue(prop) == null || prop.type === 'JSXSpreadAttribute') return

if (prohibitedProps?.includes(propName(prop))) {
context.report({
node,
message: `The attribute ${propName(prop)} is not supported by the role ${role}.`,
})
}
}
},
}
},
}
41 changes: 41 additions & 0 deletions lib/rules/a11y-svg-has-accessible-name.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const {hasProp} = require('jsx-ast-utils')
const {getElementType} = require('../utils/get-element-type')

module.exports = {
meta: {
docs: {
description: 'SVGs must have an accessible name',
url: require('../url')(module),
},
schema: [],
},

create(context) {
return {
JSXOpeningElement: node => {
const elementType = getElementType(context, node)
if (elementType !== 'svg') return

// Check if there is a nested title element that is the first child of the `<svg>`
const hasNestedTitleAsFirstChild =
node.parent.children?.[0]?.type === 'JSXElement' &&
node.parent.children?.[0]?.openingElement?.name?.name === 'title'

// Check if `aria-label` or `aria-labelledby` is set
const hasAccessibleName = hasProp(node.attributes, 'aria-label') || hasProp(node.attributes, 'aria-labelledby')

// Check if SVG is decorative
const isDecorative =
hasProp(node.attributes, 'role', 'presentation') || hasProp(node.attributes, 'aria-hidden', 'true')

if (elementType === 'svg' && !hasAccessibleName && !isDecorative && !hasNestedTitleAsFirstChild) {
context.report({
node,
message:
'`<svg>` must have an accessible name. Set `aria-label` or `aria-labelledby`, or nest a `<title>` element. However, if the `<svg>` is purely decorative, hide it with `aria-hidden="true"` or `role="presentation"`.',
})
}
},
}
},
}
101 changes: 0 additions & 101 deletions lib/rules/role-supports-aria-props.js

This file was deleted.

30 changes: 10 additions & 20 deletions lib/utils/get-element-type.js
Original file line number Diff line number Diff line change
@@ -7,30 +7,20 @@ If a prop determines the type, it can be specified with `props`.
For now, we only support the mapping of one prop type to an element type, rather than combinations of props.
*/
function getElementType(context, node) {
function getElementType(context, node, ignoreMap = false) {
const {settings} = context
const rawElement = elementType(node)
if (!settings) return rawElement

const componentMap = settings.github && settings.github.components
if (!componentMap) return rawElement
const component = componentMap[rawElement]
if (!component) return rawElement
let element = component.default ? component.default : rawElement
// check if the node contains a polymorphic prop
const polymorphicPropName = settings?.github?.polymorphicPropName ?? 'as'
const rawElement = getPropValue(getProp(node.attributes, polymorphicPropName)) ?? elementType(node)

if (component.props) {
const props = Object.entries(component.props)
for (const [key, value] of props) {
const propMap = value
const propValue = getPropValue(getProp(node.attributes, key))
const mapValue = propMap[propValue]
// if a component configuration does not exists, return the raw element
if (ignoreMap || !settings?.github?.components?.[rawElement]) return rawElement

if (mapValue) {
element = mapValue
}
}
}
return element
const defaultComponent = settings.github.components[rawElement]

// check if the default component is also defined in the configuration
return defaultComponent ? defaultComponent : defaultComponent
}

module.exports = {getElementType}
108 changes: 108 additions & 0 deletions lib/utils/get-role.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
const {getProp, getPropValue} = require('jsx-ast-utils')
const {elementRoles} = require('aria-query')
const {getElementType} = require('./get-element-type')
const ObjectMap = require('./object-map')

const elementRolesMap = cleanElementRolesMap()

/*
Returns an element roles map which uses `aria-query`'s elementRoles as the foundation.
We additionally clean the data so we're able to fetch a role using a key we construct based on the node we're looking at.
In a few scenarios, we stray from the roles returned by `aria-query` and hard code the mapping.
*/
function cleanElementRolesMap() {
const rolesMap = new ObjectMap()

for (const [key, value] of elementRoles.entries()) {
// - Remove empty `attributes` key
if (!key.attributes || key.attributes?.length === 0) {
delete key.attributes
}
rolesMap.set(key, value)
}
// Remove insufficiently-disambiguated `menuitem` entry
rolesMap.delete({name: 'menuitem'})
// Disambiguate `menuitem` and `menu` roles by `type`
rolesMap.set({name: 'menuitem', attributes: [{name: 'type', value: 'command'}]}, ['menuitem'])
rolesMap.set({name: 'menuitem', attributes: [{name: 'type', value: 'radio'}]}, ['menuitemradio'])
rolesMap.set({name: 'menuitem', attributes: [{name: 'type', value: 'toolbar'}]}, ['toolbar'])
rolesMap.set({name: 'menu', attributes: [{name: 'type', value: 'toolbar'}]}, ['toolbar'])

/* These have constraints defined in aria-query's `elementRoles` which depend on knowledge of ancestor roles which we cant accurately determine in a linter context.
However, we benefit more from assuming the role, than assuming it's generic or undefined so we opt to hard code the mapping */
rolesMap.set({name: 'aside'}, ['complementary']) // `aside` still maps to `complementary` in https://www.w3.org/TR/html-aria/#docconformance.
rolesMap.set({name: 'li'}, ['listitem']) // `li` can be generic if it's not within a list but we would never want to render `li` outside of a list.

return rolesMap
}

/*
Determine role of an element, based on its name and attributes.
We construct a key and look up the element's role in `elementRolesMap`.
If there is no match, we return undefined.
*/
function getRole(context, node) {
// Early return if role is explicitly set
const explicitRole = getPropValue(getProp(node.attributes, 'role'))
if (explicitRole) {
return explicitRole
}

// Assemble a key for looking-up the element’s role in the `elementRolesMap`
// - Get the element’s name
const key = {name: getElementType(context, node)}

for (const prop of [
'aria-label',
'aria-labelledby',
'alt',
'type',
'size',
'role',
'href',
'multiple',
'scope',
'name',
]) {
if ((prop === 'aria-labelledby' || prop === 'aria-label') && !['section', 'form'].includes(key.name)) continue
if (prop === 'name' && key.name !== 'form') continue
if (prop === 'href' && key.name !== 'a' && key.name !== 'area') continue
if (prop === 'alt' && key.name !== 'img') continue

const propOnNode = getProp(node.attributes, prop)

if (!('attributes' in key)) {
key.attributes = []
}
// Disambiguate "undefined" props
if (propOnNode === undefined && prop === 'alt' && key.name === 'img') {
key.attributes.push({name: prop, constraints: ['undefined']})
continue
}

const value = getPropValue(propOnNode)
if (value || (value === '' && prop === 'alt')) {
if (
prop === 'href' ||
prop === 'aria-labelledby' ||
prop === 'aria-label' ||
prop === 'name' ||
(prop === 'alt' && value !== '')
) {
key.attributes.push({name: prop, constraints: ['set']})
} else {
key.attributes.push({name: prop, value})
}
}
}

// - Remove empty `attributes` key
if (!key.attributes || key.attributes?.length === 0) {
delete key.attributes
}

// Get the element’s implicit role
return elementRolesMap.get(key)?.[0]
}

module.exports = {getRole}
216 changes: 52 additions & 164 deletions package-lock.json
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@
"lint:eslint-docs": "npm run update:eslint-docs -- --check",
"lint:js": "eslint .",
"pretest": "mkdir -p node_modules/ && ln -fs $(pwd) node_modules/",
"test": "npm run eslint-check && npm run lint && mocha tests/**/*.js tests/",
"test": "mocha tests/**/*.js tests/",
"update:eslint-docs": "eslint-doc-generator"
},
"repository": {
@@ -32,7 +32,7 @@
"@github/browserslist-config": "^1.0.0",
"@typescript-eslint/eslint-plugin": "^5.1.0",
"@typescript-eslint/parser": "^5.1.0",
"aria-query": "^5.1.3",
"aria-query": "^5.3.0",
"eslint-config-prettier": ">=8.0.0",
"eslint-plugin-escompat": "^3.3.3",
"eslint-plugin-eslint-comments": "^3.2.0",
@@ -65,4 +65,4 @@
"mocha": "^10.0.0",
"npm-run-all": "^4.1.5"
}
}
}
21 changes: 3 additions & 18 deletions tests/a11y-no-generic-link-text.js
Original file line number Diff line number Diff line change
@@ -26,9 +26,7 @@ ruleTester.run('a11y-no-generic-link-text', rule, {
settings: {
github: {
components: {
Link: {
props: {as: {undefined: 'a'}},
},
Link: 'a',
},
},
},
@@ -41,9 +39,7 @@ ruleTester.run('a11y-no-generic-link-text', rule, {
settings: {
github: {
components: {
ButtonLink: {
default: 'a',
},
ButtonLink: 'a',
},
},
},
@@ -54,25 +50,14 @@ ruleTester.run('a11y-no-generic-link-text', rule, {
settings: {
github: {
components: {
Link: {
props: {as: {undefined: 'a'}},
},
Link: 'a',
},
},
},
},
{
code: '<Test as="a" href="#">Read more</Test>',
errors: [{message: errorMessage}],
settings: {
github: {
components: {
Test: {
props: {as: {a: 'a'}},
},
},
},
},
},
{
code: "<Box><a href='#'>Click here</a></Box>;",
59 changes: 59 additions & 0 deletions tests/a11y-no-title-attribute.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
const rule = require('../lib/rules/a11y-no-title-attribute')
const RuleTester = require('eslint').RuleTester

const ruleTester = new RuleTester({
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
})

const errorMessage = 'The title attribute is not accessible and should never be used unless for an `<iframe>`.'

ruleTester.run('a11y-no-title-attribute', rule, {
valid: [
{code: '<button>Submit</button>'},
{code: '<iframe title="an allowed title">GitHub</iframe>'},
{code: '<span>some information</span>'},
{code: '<a href="github.com">GitHub</a>'},
{
code: '<Component title="some title">Submit</Component>',
settings: {
github: {
components: {
Component: 'iframe',
},
},
},
},
{
// Note: we are only checking semantic elements. We cannot make assumptions about how a React Components is using the title prop.
code: '<Link title="some title">Submit</Link>',
settings: {
github: {
components: {
Link: 'a',
},
},
},
},
],
invalid: [
{code: '<a title="some title" href="github.com">GitHub</a>', errors: [{message: errorMessage}]},
{code: '<span><button title="some title">submit</button></span>', errors: [{message: errorMessage}]},
{
code: '<Component as="a" title="some title">Submit</Component>',
errors: [{message: errorMessage}],
settings: {
github: {
components: {
Component: 'iframe',
},
},
},
},
],
})
97 changes: 97 additions & 0 deletions tests/a11y-no-visually-hidden-interactive-element.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
const rule = require('../lib/rules/a11y-no-visually-hidden-interactive-element')
const RuleTester = require('eslint').RuleTester

const ruleTester = new RuleTester({
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
})

const errorMessage =
'Avoid visually hidding interactive elements. Visually hiding interactive elements can be confusing to sighted keyboard users as it appears their focus has been lost when they navigate to the hidden element.'

ruleTester.run('a11y-no-visually-hidden-interactive-element', rule, {
valid: [
{code: '<VisuallyHidden as="h2">Submit</VisuallyHidden>'},
{code: "<div className='sr-only'>Text</div>;"},
{code: '<VisuallyHidden><div>Text</div></VisuallyHidden>'},
{code: "<div className='other visually-hidden'>Text</div>;"},
{code: "<span className='sr-only'>Text</span>;"},
{code: "<button className='other'>Submit</button>"},
{code: "<input className='sr-only' />"},
{code: "<a className='other show-on-focus'>skip to main content</a>"},
{code: '<button>Submit</button>'},
{
code: "<button className='sr-only'>Submit</button>",
options: [
{
className: 'visually-hidden',
},
],
},
{
code: "<VisuallyHidden as='button'>Submit</VisuallyHidden>",
options: [
{
componentName: 'Hidden',
},
],
errors: [{message: errorMessage}],
},
{
code: "<VisuallyHidden as='button'>Submit</VisuallyHidden>",
settings: {
github: {
polymorphicPropName: 'html',
},
},
},
],
invalid: [
{code: '<VisuallyHidden as="button">Submit</VisuallyHidden>', errors: [{message: errorMessage}]},
{code: '<VisuallyHidden><button>Submit</button></VisuallyHidden>', errors: [{message: errorMessage}]},
{
code: '<VisuallyHidden><button class="sr-only">Submit</button></VisuallyHidden>',
errors: [{message: errorMessage}],
},
{code: "<button className='sr-only'>Submit</button>", errors: [{message: errorMessage}]},
{code: '<VisuallyHidden><div><button>Submit</button></div></VisuallyHidden>', errors: [{message: errorMessage}]},
{code: "<a className='other sr-only' href='github.com'>GitHub</a>", errors: [{message: errorMessage}]},
{code: "<summary className='sr-only'>Toggle open</summary>", errors: [{message: errorMessage}]},
{code: "<textarea className='sr-only' />", errors: [{message: errorMessage}]},
{code: "<select className='sr-only' />", errors: [{message: errorMessage}]},
{code: "<option className='sr-only' />", errors: [{message: errorMessage}]},
{code: "<a className='sr-only'>Read more</a>", errors: [{message: errorMessage}]},
{
code: "<button className='visually-hidden'>Submit</button>",
options: [
{
className: 'visually-hidden',
},
],
errors: [{message: errorMessage}],
},
{
code: "<Hidden as='button'>Submit</Hidden>",
options: [
{
componentName: 'Hidden',
},
],
errors: [{message: errorMessage}],
},
{
code: "<VisuallyHidden html='button'>Submit</VisuallyHidden>",
errors: [{message: errorMessage}],
settings: {
github: {
polymorphicPropName: 'html',
},
},
},
],
})
Original file line number Diff line number Diff line change
@@ -10,7 +10,7 @@
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

const rule = require('../lib/rules/role-supports-aria-props')
const rule = require('../lib/rules/a11y-role-supports-aria-props')
const RuleTester = require('eslint').RuleTester

const ruleTester = new RuleTester({
@@ -27,7 +27,7 @@ function getErrorMessage(attribute, role) {
return `The attribute ${attribute} is not supported by the role ${role}.`
}

ruleTester.run('role-supports-aria-props', rule, {
ruleTester.run('a11y-role-supports-aria-props', rule, {
valid: [
{code: '<Foo bar />'},
{code: '<div />'},
@@ -57,9 +57,6 @@ ruleTester.run('role-supports-aria-props', rule, {
{code: '<a href="#" aria-owns />'},
{code: '<a href="#" aria-relevant />'},

// this will have global
{code: '<a aria-checked />'},

// AREA TESTS - implicit role is `link`
{code: '<area href="#" aria-expanded />'},
{code: '<area href="#" aria-atomic />'},
@@ -78,30 +75,6 @@ ruleTester.run('role-supports-aria-props', rule, {
{code: '<area href="#" aria-owns />'},
{code: '<area href="#" aria-relevant />'},

// this will have global
{code: '<area aria-checked />'},

// LINK TESTS - implicit role is `link`
{code: '<link href="#" aria-expanded />'},
{code: '<link href="#" aria-atomic />'},
{code: '<link href="#" aria-busy />'},
{code: '<link href="#" aria-controls />'},
{code: '<link href="#" aria-describedby />'},
{code: '<link href="#" aria-disabled />'},
{code: '<link href="#" aria-dropeffect />'},
{code: '<link href="#" aria-flowto />'},
{code: '<link href="#" aria-grabbed />'},
{code: '<link href="#" aria-hidden />'},
{code: '<link href="#" aria-haspopup />'},
{code: '<link href="#" aria-label />'},
{code: '<link href="#" aria-labelledby />'},
{code: '<link href="#" aria-live />'},
{code: '<link href="#" aria-owns />'},
{code: '<link href="#" aria-relevant />'},

// this will have global
{code: '<link aria-checked />'},

// this will have role of `img`
{code: '<img alt="foobar" aria-busy />'},

@@ -344,20 +317,25 @@ ruleTester.run('role-supports-aria-props', rule, {
{code: '<datalist aria-expanded />'},
{code: '<div role="heading" aria-level />'},
{code: '<div role="heading" aria-level="1" />'},
{code: '<link href="#" aria-expanded />'}, // link maps to nothing
],

invalid: [
// implicit basic checks
{
code: '<a href="#" aria-checked />',
errors: [getErrorMessage('aria-checked', 'link')],
code: '<area aria-checked />',
errors: [getErrorMessage('aria-checked', 'generic')],
},
{
code: '<area href="#" aria-checked />',
code: '<a aria-checked />',
errors: [getErrorMessage('aria-checked', 'generic')],
},
{
code: '<a href="#" aria-checked />',
errors: [getErrorMessage('aria-checked', 'link')],
},
{
code: '<link href="#" aria-checked />',
code: '<area href="#" aria-checked />',
errors: [getErrorMessage('aria-checked', 'link')],
},
{
@@ -394,7 +372,7 @@ ruleTester.run('role-supports-aria-props', rule, {
},
{
code: '<body aria-expanded />',
errors: [getErrorMessage('aria-expanded', 'document')],
errors: [getErrorMessage('aria-expanded', 'generic')],
},
{
code: '<li aria-expanded />',
@@ -414,6 +392,10 @@ ruleTester.run('role-supports-aria-props', rule, {
},
{
code: '<section aria-expanded />',
errors: [getErrorMessage('aria-expanded', 'generic')],
},
{
code: '<section aria-label="something" aria-expanded />',
errors: [getErrorMessage('aria-expanded', 'region')],
},
{
@@ -480,10 +462,6 @@ ruleTester.run('role-supports-aria-props', rule, {
code: '<menu type="toolbar" aria-expanded />',
errors: [getErrorMessage('aria-expanded', 'toolbar')],
},
{
code: '<link href="#" aria-invalid />',
errors: [getErrorMessage('aria-invalid', 'link')],
},
{
code: '<area href="#" aria-invalid />',
errors: [getErrorMessage('aria-invalid', 'link')],
49 changes: 49 additions & 0 deletions tests/a11y-svg-has-accessible-name.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
const rule = require('../lib/rules/a11y-svg-has-accessible-name')
const RuleTester = require('eslint').RuleTester

const ruleTester = new RuleTester({
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
})

const errorMessage =
'`<svg>` must have an accessible name. Set `aria-label` or `aria-labelledby`, or nest a `<title>` element. However, if the `<svg>` is purely decorative, hide it with `aria-hidden="true"` or `role="presentation"`.'

ruleTester.run('a11y-svg-has-accessible-name', rule, {
valid: [
{
code: "<svg height='100' width='100'><title>Circle with a black outline and red fill</title><circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/></svg>",
},
{
code: "<svg aria-label='Circle with a black outline and red fill' height='100' width='100'><circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/></svg>",
},
{
code: "<svg aria-labelledby='circle_text' height='100' width='100'><circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/></svg>",
},
{
code: "<svg aria-hidden='true' height='100' width='100'><circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/></svg>",
},
{
code: "<svg role='presentation' height='100' width='100'><circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/></svg>",
},
],
invalid: [
{
code: "<svg height='100' width='100'><circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/></svg>",
errors: [{message: errorMessage}],
},
{
code: "<svg height='100' width='100' title='Circle with a black outline and red fill'><circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/></svg>",
errors: [{message: errorMessage}],
},
{
code: "<svg height='100' width='100'><circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/><title>Circle with a black outline and red fill</title></svg>",
errors: [{message: errorMessage}],
},
],
})
82 changes: 16 additions & 66 deletions tests/utils/get-element-type.js
Original file line number Diff line number Diff line change
@@ -1,39 +1,17 @@
const {getElementType} = require('../../lib/utils/get-element-type')
const {mockJSXAttribute, mockJSXOpeningElement} = require('./mocks')

const mocha = require('mocha')
const describe = mocha.describe
const it = mocha.it
const expect = require('chai').expect

function mockJSXAttribute(prop, propValue) {
return {
type: 'JSXAttribute',
name: {
type: 'JSXIdentifier',
name: prop,
},
value: {
type: 'Literal',
value: propValue,
},
}
}

function mockJSXOpeningElement(tagName, attributes = []) {
return {
type: 'JSXOpeningElement',
name: {
type: 'JSXIdentifier',
name: tagName,
},
attributes,
}
}

function mockSetting(componentSetting = {}) {
return {
settings: {
github: {
components: componentSetting,
polymorphicPropName: 'as',
},
},
}
@@ -45,64 +23,36 @@ describe('getElementType', function () {
expect(getElementType({}, node)).to.equal('a')
})

it('returns element type from default if set', function () {
const node = mockJSXOpeningElement('Link', [mockJSXAttribute('as', 'summary')])
it('returns polymorphic element type', function () {
const node = mockJSXOpeningElement('Link', [mockJSXAttribute('as', 'button')])
const setting = mockSetting({
Link: {
default: 'button',
},
Link: 'a',
})
expect(getElementType(setting, node)).to.equal('button')
})

it('returns element type from matching props setting if set', function () {
const setting = mockSetting({
Link: {
default: 'a',
props: {
as: {summary: 'summary'},
},
},
})

const node = mockJSXOpeningElement('Link', [mockJSXAttribute('as', 'summary')])
expect(getElementType(setting, node)).to.equal('summary')
})

it('returns raw type if no default or matching prop setting', function () {
const setting = mockSetting({
Link: {
props: {
as: {summary: 'summary'},
},
},
})
const node = mockJSXOpeningElement('Link', [mockJSXAttribute('as', 'p')])
const setting = mockSetting({})

const node = mockJSXOpeningElement('Link')
expect(getElementType(setting, node)).to.equal('Link')
})

it('allows undefined prop to be mapped to a type', function () {
it('returns default type if no polymorphic prop is passed in', function () {
const setting = mockSetting({
Link: {
props: {
as: {undefined: 'a'},
},
},
Link: 'a',
})
const node = mockJSXOpeningElement('Link')
expect(getElementType(setting, node)).to.equal('a')
})

it('returns raw type if prop does not match props setting and no default type', function () {
it('if rendered as another component check its default type', function () {
const setting = mockSetting({
Link: {
props: {
as: {undefined: 'a'},
},
},
Link: 'a',
Button: 'button',
})

const node = mockJSXOpeningElement('Link', [mockJSXAttribute('as', 'p')])
expect(getElementType(setting, node)).to.equal('Link')
const node = mockJSXOpeningElement('Link', [mockJSXAttribute('as', 'Button')])
expect(getElementType(setting, node)).to.equal('button')
})
})
201 changes: 201 additions & 0 deletions tests/utils/get-role.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
const {getRole} = require('../../lib/utils/get-role')
const {mockJSXAttribute, mockJSXOpeningElement} = require('./mocks')
const mocha = require('mocha')
const describe = mocha.describe
const it = mocha.it
const expect = require('chai').expect

describe('getRole', function () {
it('returns generic role for <span> regardless of attribute', function () {
const node = mockJSXOpeningElement('span', [mockJSXAttribute('aria-label', 'something')])
expect(getRole({}, node)).to.equal('generic')
})

it('returns generic role for <div> regardless of attribute', function () {
const node = mockJSXOpeningElement('div', [mockJSXAttribute('aria-describedby', 'something')])
expect(getRole({}, node)).to.equal('generic')
})

it('returns generic role for <a> without href', function () {
const node = mockJSXOpeningElement('a')
expect(getRole({}, node)).to.equal('generic')
})

it('returns link role for <a> with href', function () {
const node = mockJSXOpeningElement('a', [mockJSXAttribute('href', '#')])
expect(getRole({}, node)).to.equal('link')
})

it('returns region role for <section> with aria-label', function () {
const node = mockJSXOpeningElement('section', [mockJSXAttribute('aria-label', 'something')])
expect(getRole({}, node)).to.equal('region')
})

it('returns region role for <section> with aria-labelledby', function () {
const node = mockJSXOpeningElement('section', [mockJSXAttribute('aria-labelledby', 'something')])
expect(getRole({}, node)).to.equal('region')
})

it('returns complementary role for <aside> with aria-label', function () {
const node = mockJSXOpeningElement('aside', [mockJSXAttribute('aria-label', 'something')])
expect(getRole({}, node)).to.equal('complementary')
})

it('returns complementary role for <aside> with aria-labelledby', function () {
const node = mockJSXOpeningElement('aside', [mockJSXAttribute('aria-labelledby', 'something')])
expect(getRole({}, node)).to.equal('complementary')
})

it('returns img role for <img> with alt set explicitly', function () {
const node = mockJSXOpeningElement('img', [mockJSXAttribute('alt', 'Cute cat')])
expect(getRole({}, node)).to.equal('img')
})

it('returns img role for <img> with no alt', function () {
const node = mockJSXOpeningElement('img')
expect(getRole({}, node)).to.equal('img')
})

it('returns presentation role for <img> with alt set to empty', function () {
const node = mockJSXOpeningElement('img', [mockJSXAttribute('alt', '')])
expect(getRole({}, node)).to.equal('presentation')
})

it('returns form role for <form> with aria-label', function () {
const node = mockJSXOpeningElement('form', [mockJSXAttribute('aria-label', 'registration')])
expect(getRole({}, node)).to.equal('form')
})

it('returns form role for <form> with name attrribute', function () {
const node = mockJSXOpeningElement('form', [mockJSXAttribute('name', 'registration')])
expect(getRole({}, node)).to.equal('form')
})

it('returns undefined role for <form> with no attributes', function () {
const node = mockJSXOpeningElement('form')
expect(getRole({}, node)).to.equal(undefined)
})

it('returns explicitly set role', function () {
const spanButton = mockJSXOpeningElement('span', [mockJSXAttribute('role', 'button')])
expect(getRole({}, spanButton)).to.equal('button')

const divNav = mockJSXOpeningElement('div', [mockJSXAttribute('role', 'navigation')])
expect(getRole({}, divNav)).to.equal('navigation')

const listMenu = mockJSXOpeningElement('ul', [mockJSXAttribute('role', 'menu')])
expect(getRole({}, listMenu)).to.equal('menu')
})

it('returns heading role for heading tags', function () {
const h1 = mockJSXOpeningElement('h1')
expect(getRole({}, h1)).to.equal('heading')

const h2 = mockJSXOpeningElement('h2')
expect(getRole({}, h2)).to.equal('heading')

const h3 = mockJSXOpeningElement('h3')
expect(getRole({}, h3)).to.equal('heading')

const h4 = mockJSXOpeningElement('h4')
expect(getRole({}, h4)).to.equal('heading')

const h5 = mockJSXOpeningElement('h5')
expect(getRole({}, h5)).to.equal('heading')

const h6 = mockJSXOpeningElement('h6')
expect(getRole({}, h6)).to.equal('heading')
})

it('returns navigation role for <nav>', function () {
const node = mockJSXOpeningElement('nav')
expect(getRole({}, node)).to.equal('navigation')
})

it('returns option role for <opt>', function () {
const node = mockJSXOpeningElement('option')
expect(getRole({}, node)).to.equal('option')
})

it('returns textbox role for <textarea>', function () {
const node = mockJSXOpeningElement('textarea')
expect(getRole({}, node)).to.equal('textbox')
})

it('returns listbox role for <select>', function () {
const node = mockJSXOpeningElement('textarea')
expect(getRole({}, node)).to.equal('textbox')
})

it('returns group role for <details>', function () {
const node = mockJSXOpeningElement('details')
expect(getRole({}, node)).to.equal('group')
})

it('returns group role for <details>', function () {
const node = mockJSXOpeningElement('details')
expect(getRole({}, node)).to.equal('group')
})

// <input>
it('returns slider role for <input> with type range', function () {
const node = mockJSXOpeningElement('input', [mockJSXAttribute('type', 'range')])
expect(getRole({}, node)).to.equal('slider')
})

it('returns spinbutton for <input> with type number', function () {
const node = mockJSXOpeningElement('input', [mockJSXAttribute('type', 'number')])
expect(getRole({}, node)).to.equal('spinbutton')
})

it('returns checkbox for <input> with type checkbox', function () {
const node = mockJSXOpeningElement('input', [mockJSXAttribute('type', 'checkbox')])
expect(getRole({}, node)).to.equal('checkbox')
})

it('returns button for <input> with type button, image, reset, submit', function () {
const button = mockJSXOpeningElement('input', [mockJSXAttribute('type', 'button')])
expect(getRole({}, button)).to.equal('button')

const image = mockJSXOpeningElement('input', [mockJSXAttribute('type', 'image')])
expect(getRole({}, image)).to.equal('button')

const reset = mockJSXOpeningElement('input', [mockJSXAttribute('type', 'reset')])
expect(getRole({}, reset)).to.equal('button')

const submit = mockJSXOpeningElement('input', [mockJSXAttribute('type', 'submit')])
expect(getRole({}, submit)).to.equal('button')
})

it('returns rowheader role for <th scope="row">', function () {
const node = mockJSXOpeningElement('th', [mockJSXAttribute('scope', 'row')])
expect(getRole({}, node)).to.equal('rowheader')
})

it('returns rowheader role for <th scope="rowgroup">', function () {
const node = mockJSXOpeningElement('th', [mockJSXAttribute('scope', 'rowgroup')])
expect(getRole({}, node)).to.equal('rowheader')
})

// Hard-coded mapping
it('returns listitem role for <li>', function () {
const node = mockJSXOpeningElement('li')
expect(getRole({}, node)).to.equal('listitem')
})

it('returns complementary role for <aside>', function () {
const node = mockJSXOpeningElement('aside')
expect(getRole({}, node)).to.equal('complementary')
})

// <link> does not map to anything.
it('returns undefined role for <link>', function () {
const node = mockJSXOpeningElement('link')
expect(getRole({}, node)).to.equal(undefined)
})

it('returns undefined role for <link href="#">', function () {
const node = mockJSXOpeningElement('link', [mockJSXAttribute('href', '#')])
expect(getRole({}, node)).to.equal(undefined)
})
})
26 changes: 26 additions & 0 deletions tests/utils/mocks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
function mockJSXAttribute(prop, propValue) {
return {
type: 'JSXAttribute',
name: {
type: 'JSXIdentifier',
name: prop,
},
value: {
type: 'Literal',
value: propValue,
},
}
}

function mockJSXOpeningElement(tagName, attributes = []) {
return {
type: 'JSXOpeningElement',
name: {
type: 'JSXIdentifier',
name: tagName,
},
attributes,
}
}

module.exports = {mockJSXAttribute, mockJSXOpeningElement}