eslint-plugin-smarthr 0.3.6 → 0.3.8
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/CHANGELOG.md +15 -0
- package/package.json +1 -1
- package/rules/a11y-anchor-has-href-attribute/README.md +3 -9
- package/rules/a11y-anchor-has-href-attribute/index.js +51 -46
- package/rules/a11y-clickable-element-has-text/README.md +14 -2
- package/rules/a11y-clickable-element-has-text/index.js +56 -45
- package/rules/a11y-image-has-alt-attribute/index.js +29 -20
- package/rules/a11y-input-has-name-attribute/index.js +33 -32
- package/rules/best-practice-for-date/index.js +9 -2
- package/test/a11y-anchor-has-href-attribute.js +13 -11
- package/test/a11y-clickable-element-has-text.js +20 -1
- package/test/a11y-image-has-alt-attribute.js +10 -3
- package/test/a11y-input-has-name-attribute.js +19 -13
- package/test/best-practice-for-date.js +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [0.3.8](https://github.com/kufu/eslint-plugin-smarthr/compare/v0.3.7...v0.3.8) (2023-09-01)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* a11y-anchor-has-href-attribute の next, react-router-dom用オプションをpackage.jsonを解析して自動設定するように修正 ([#71](https://github.com/kufu/eslint-plugin-smarthr/issues/71)) ([8321433](https://github.com/kufu/eslint-plugin-smarthr/commit/832143385dd92bfd6fe45acd959038deea5cd1fe))
|
|
11
|
+
* a11y-anchor-has-href-attributeをhref="" や href="#" の場合、エラーとなるように修正 ([#75](https://github.com/kufu/eslint-plugin-smarthr/issues/75)) ([738ab65](https://github.com/kufu/eslint-plugin-smarthr/commit/738ab6598111dcf573a35d24f9d1baeda0506b4f))
|
|
12
|
+
|
|
13
|
+
### [0.3.7](https://github.com/kufu/eslint-plugin-smarthr/compare/v0.3.6...v0.3.7) (2023-08-24)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* a11y-clickable-element-has-text のチェック時、リンク内部に名称の末尾がTextがつくコンポーネントがある場合、チェックを通過するように修正 ([#69](https://github.com/kufu/eslint-plugin-smarthr/issues/69)) ([182b5d5](https://github.com/kufu/eslint-plugin-smarthr/commit/182b5d5e52c1faee26011572c48271e4c03512e1))
|
|
19
|
+
|
|
5
20
|
### [0.3.6](https://github.com/kufu/eslint-plugin-smarthr/compare/v0.3.5...v0.3.6) (2023-08-20)
|
|
6
21
|
|
|
7
22
|
|
package/package.json
CHANGED
|
@@ -12,13 +12,7 @@
|
|
|
12
12
|
```js
|
|
13
13
|
{
|
|
14
14
|
rules: {
|
|
15
|
-
'smarthr/a11y-anchor-has-href-attribute':
|
|
16
|
-
'error', // 'warn', 'off'
|
|
17
|
-
// {
|
|
18
|
-
// nextjs: true,
|
|
19
|
-
// react_router: true,
|
|
20
|
-
// },
|
|
21
|
-
]
|
|
15
|
+
'smarthr/a11y-anchor-has-href-attribute': 'error', // 'warn', 'off'
|
|
22
16
|
},
|
|
23
17
|
}
|
|
24
18
|
```
|
|
@@ -39,9 +33,9 @@
|
|
|
39
33
|
<XxxAnchor href={hoge}>any</XxxAnchor>
|
|
40
34
|
<XxxLink href={undefined}>any</XxxLink>
|
|
41
35
|
|
|
42
|
-
//
|
|
36
|
+
// nextを利用している場合
|
|
43
37
|
<Link href={hoge}><a>any</a></Link>
|
|
44
38
|
|
|
45
|
-
//
|
|
39
|
+
// react-router-domを利用している場合
|
|
46
40
|
<Link to={hoge}>any</Link>
|
|
47
41
|
```
|
|
@@ -1,5 +1,27 @@
|
|
|
1
|
+
const JSON5 = require('json5')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
|
|
1
4
|
const { generateTagFormatter } = require('../../libs/format_styled_components')
|
|
2
5
|
|
|
6
|
+
const OPTION = (() => {
|
|
7
|
+
const file = `${process.cwd()}/package.json`
|
|
8
|
+
|
|
9
|
+
if (!fs.existsSync(file)) {
|
|
10
|
+
return {}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const json = JSON5.parse(fs.readFileSync(file))
|
|
14
|
+
const dependencies = [
|
|
15
|
+
...Object.keys(json.dependencies || {}),
|
|
16
|
+
...Object.keys(json.devDependencies || {}),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
nextjs: dependencies.includes('next'),
|
|
21
|
+
react_router: dependencies.includes('react-router-dom'),
|
|
22
|
+
}
|
|
23
|
+
})()
|
|
24
|
+
|
|
3
25
|
const EXPECTED_NAMES = {
|
|
4
26
|
'Anchor$': 'Anchor$',
|
|
5
27
|
'Link$': 'Link$',
|
|
@@ -7,59 +29,47 @@ const EXPECTED_NAMES = {
|
|
|
7
29
|
}
|
|
8
30
|
|
|
9
31
|
const REGEX_TARGET = /(Anchor|Link|^a)$/
|
|
10
|
-
const check = (node
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (
|
|
14
|
-
result && (
|
|
15
|
-
(option.nextjs && !nextCheck(node)) ||
|
|
16
|
-
(option.react_router && !reactRouterCheck(node))
|
|
17
|
-
)
|
|
18
|
-
) {
|
|
19
|
-
result = null
|
|
20
|
-
}
|
|
32
|
+
const check = (node) => {
|
|
33
|
+
const result = baseCheck(node)
|
|
21
34
|
|
|
22
|
-
return result
|
|
35
|
+
return result && ((OPTION.nextjs && !nextCheck(node)) || (OPTION.react_router && !reactRouterCheck(node))) ? null : result
|
|
23
36
|
}
|
|
24
37
|
const baseCheck = (node) => {
|
|
25
38
|
const nodeName = node.name.name || ''
|
|
26
39
|
|
|
27
|
-
|
|
28
|
-
const href = node.attributes.find((a) => a.name?.name == 'href')
|
|
29
|
-
|
|
30
|
-
if (!href || !href.value) {
|
|
31
|
-
return nodeName
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return false
|
|
40
|
+
return nodeName.match(REGEX_TARGET) && checkExistAttribute(node, findHrefAttribute) ? nodeName : false
|
|
36
41
|
}
|
|
37
42
|
const nextCheck = (node) => {
|
|
38
43
|
// HINT: next/link で `Link>a` という構造がありえるので直上のJSXElementを調べる
|
|
39
44
|
const target = node.parent.parent.openingElement
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
return baseCheck(target)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
return false
|
|
46
|
+
return target ? baseCheck(target) : false
|
|
46
47
|
}
|
|
47
|
-
const reactRouterCheck = (node) =>
|
|
48
|
-
|
|
48
|
+
const reactRouterCheck = (node) => checkExistAttribute(node, findToAttribute)
|
|
49
|
+
|
|
50
|
+
const checkExistAttribute = (node, find) => {
|
|
51
|
+
const attr = node.attributes.find(find)?.value
|
|
49
52
|
|
|
50
|
-
return
|
|
53
|
+
return (
|
|
54
|
+
!attr ||
|
|
55
|
+
isNullTextHref(attr) ||
|
|
56
|
+
(attr.type === 'JSXExpressionContainer' && isNullTextHref(attr.expression))
|
|
57
|
+
)
|
|
51
58
|
}
|
|
59
|
+
const isNullTextHref = (attr) => attr.type === 'Literal' && (attr.value === '' || attr.value === '#')
|
|
52
60
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
const findHrefAttribute = (a) => a.name?.name == 'href'
|
|
62
|
+
const findToAttribute = (a) => a.name?.name == 'to'
|
|
63
|
+
|
|
64
|
+
const MESSAGE_SUFFIX = ` に href 属性を正しく設定してください
|
|
65
|
+
- onClickなどでページ遷移する場合でもhref属性に遷移先のURIを設定してください
|
|
66
|
+
- Cmd + clickなどのキーボードショートカットに対応出来ます
|
|
67
|
+
- onClickなどの動作がURLの変更を行わない場合、button要素でマークアップすることを検討してください
|
|
68
|
+
- href属性に空文字(""など)や '#' が設定されている場合、実質画面遷移を行わないため、同様にbutton要素でマークアップすることを検討してください
|
|
69
|
+
- リンクが存在せず無効化されていることを表したい場合、href属性に undefined を設定してください
|
|
70
|
+
- button要素のdisabled属性が設定された場合に相当します`
|
|
71
|
+
|
|
72
|
+
const SCHEMA = []
|
|
63
73
|
|
|
64
74
|
module.exports = {
|
|
65
75
|
meta: {
|
|
@@ -67,20 +77,15 @@ module.exports = {
|
|
|
67
77
|
schema: SCHEMA,
|
|
68
78
|
},
|
|
69
79
|
create(context) {
|
|
70
|
-
const option = context.options[0] || {}
|
|
71
|
-
|
|
72
80
|
return {
|
|
73
81
|
...generateTagFormatter({ context, EXPECTED_NAMES }),
|
|
74
82
|
JSXOpeningElement: (node) => {
|
|
75
|
-
const nodeName = check(node
|
|
83
|
+
const nodeName = check(node)
|
|
76
84
|
|
|
77
85
|
if (nodeName) {
|
|
78
86
|
context.report({
|
|
79
87
|
node,
|
|
80
|
-
message: `${nodeName}
|
|
81
|
-
- onClickなどでページ遷移する場合、href属性に遷移先のURIを設定してください。Cmd + clickなどのキーボードショートカットに対応出来ます。
|
|
82
|
-
- onClickなどの動作がURLの変更を行わない場合、リンクではなくbuttonでマークアップすることを検討してください。
|
|
83
|
-
- リンクを無効化することを表したい場合、href属性に undefined を設定してください。`,
|
|
88
|
+
message: `${nodeName}${MESSAGE_SUFFIX}`,
|
|
84
89
|
})
|
|
85
90
|
}
|
|
86
91
|
},
|
|
@@ -37,6 +37,12 @@
|
|
|
37
37
|
</XxxButton>
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
```jsx
|
|
41
|
+
<XxxAnchor>>
|
|
42
|
+
<XxxTextYyyy />
|
|
43
|
+
</XxxAnchor>
|
|
44
|
+
```
|
|
45
|
+
|
|
40
46
|
## ✅ Correct
|
|
41
47
|
|
|
42
48
|
```jsx
|
|
@@ -65,19 +71,25 @@
|
|
|
65
71
|
<YyyAnchoor />
|
|
66
72
|
```
|
|
67
73
|
|
|
74
|
+
```jsx
|
|
75
|
+
<XxxAnchor>>
|
|
76
|
+
<XxxText />
|
|
77
|
+
</XxxAnchor>
|
|
78
|
+
```
|
|
79
|
+
|
|
68
80
|
```jsx
|
|
69
81
|
/*
|
|
70
82
|
rules: {
|
|
71
83
|
'smarthr/a11y-clickable-element-has-text': [
|
|
72
84
|
'error',
|
|
73
85
|
{
|
|
74
|
-
componentsWithText: ['
|
|
86
|
+
componentsWithText: ['Hoge'],
|
|
75
87
|
},
|
|
76
88
|
]
|
|
77
89
|
},
|
|
78
90
|
*/
|
|
79
91
|
|
|
80
92
|
<XxxButton>
|
|
81
|
-
<
|
|
93
|
+
<Hoge />
|
|
82
94
|
</XxxButton>
|
|
83
95
|
```
|
|
@@ -15,12 +15,27 @@ const EXPECTED_NAMES = {
|
|
|
15
15
|
'(b|B)utton$': 'Button$',
|
|
16
16
|
'Anchor$': 'Anchor$',
|
|
17
17
|
'Link$': 'Link$',
|
|
18
|
+
'Text$': 'Text$',
|
|
19
|
+
'Message$': 'Message$',
|
|
18
20
|
'^a$': '(Anchor|Link)$',
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
const REGEX_NLSP = /^\s*\n+\s*$/
|
|
24
|
+
const REGEX_CLICKABLE_ELEMENT = /^(a|(.*?)Anchor(Button)?|(.*?)Link|(b|B)utton)$/
|
|
25
|
+
const REGEX_SMARTHR_LOGO = /SmartHRLogo$/
|
|
26
|
+
const REGEX_TEXT_COMPONENT = /(Text|Message)$/
|
|
27
|
+
|
|
28
|
+
const HIT_TYPES_RECURSICVE_SEARCH = ['JSXText', 'JSXExpressionContainer']
|
|
29
|
+
const HIT_TEXT_ATTRS = ['visuallyHiddenText', 'alt']
|
|
30
|
+
|
|
31
|
+
const filterFalsyJSXText = (cs) => cs.filter(checkFalsyJSXText)
|
|
32
|
+
const checkFalsyJSXText = (c) => (
|
|
33
|
+
!(c.type === 'JSXText' && c.value.match(REGEX_NLSP))
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const message = `a, buttonなどのクリッカブルな要素内にはテキストを設定してください。
|
|
37
|
+
- 要素内にアイコン、画像のみを設置する場合はaltなどの代替テキスト用属性を指定してください
|
|
38
|
+
- クリッカブルな要素内に設置しているコンポーネントがテキストを含んでいる場合、"XxxxText" のように末尾に "Text" もしくは "Message" という名称を設定してください`
|
|
24
39
|
|
|
25
40
|
module.exports = {
|
|
26
41
|
meta: {
|
|
@@ -41,69 +56,65 @@ module.exports = {
|
|
|
41
56
|
|
|
42
57
|
const node = parentNode.openingElement
|
|
43
58
|
|
|
44
|
-
if (!node.name.name || !node.name.name.match(
|
|
59
|
+
if (!node.name.name || !node.name.name.match(REGEX_CLICKABLE_ELEMENT)) {
|
|
45
60
|
return
|
|
46
61
|
}
|
|
47
62
|
|
|
48
63
|
const recursiveSearch = (c) => {
|
|
49
|
-
if (
|
|
64
|
+
if (HIT_TYPES_RECURSICVE_SEARCH.includes(c.type)) {
|
|
50
65
|
return true
|
|
51
66
|
}
|
|
52
67
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return false
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (c.type === 'JSXElement') {
|
|
62
|
-
// // HINT: SmartHRLogo コンポーネントは内部でaltを持っているため対象外にする
|
|
63
|
-
if (c.openingElement.name.name.match(/SmartHRLogo$/)) {
|
|
64
|
-
return true
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (componentsWithText.includes(c.openingElement.name.name)) {
|
|
68
|
-
return true
|
|
68
|
+
switch (c.type) {
|
|
69
|
+
case 'JSXFragment': {
|
|
70
|
+
return c.children && filterFalsyJSXText(c.children).some(recursiveSearch)
|
|
69
71
|
}
|
|
72
|
+
case 'JSXElement': {
|
|
73
|
+
// // HINT: SmartHRLogo コンポーネントは内部でaltを持っているため対象外にする
|
|
74
|
+
if (c.openingElement.name.name.match(REGEX_SMARTHR_LOGO)) {
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
70
77
|
|
|
71
|
-
|
|
72
|
-
let existRole = false
|
|
73
|
-
let existAriaLabel = false
|
|
74
|
-
const result = c.openingElement.attributes.reduce((prev, a) => {
|
|
75
|
-
existRole = existRole || (a.name.name === 'role' && a.value.value === 'img')
|
|
76
|
-
existAriaLabel = existAriaLabel || a.name.name === 'aria-label'
|
|
78
|
+
const tagName = c.openingElement.name.name
|
|
77
79
|
|
|
78
|
-
if (
|
|
79
|
-
return
|
|
80
|
+
if (tagName.match(REGEX_TEXT_COMPONENT) || componentsWithText.includes(tagName)) {
|
|
81
|
+
return true
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
// HINT: role & aria-label を同時に設定されている場合は許可
|
|
85
|
+
let existRole = false
|
|
86
|
+
let existAriaLabel = false
|
|
87
|
+
const result = c.openingElement.attributes.reduce((prev, a) => {
|
|
88
|
+
existRole = existRole || (a.name.name === 'role' && a.value.value === 'img')
|
|
89
|
+
existAriaLabel = existAriaLabel || a.name.name === 'aria-label'
|
|
90
|
+
|
|
91
|
+
if (
|
|
92
|
+
prev ||
|
|
93
|
+
!HIT_TEXT_ATTRS.includes(a.name.name)
|
|
94
|
+
) {
|
|
95
|
+
return prev
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (!!a.value.value || a.value.type === 'JSXExpressionContainer') ? a : prev
|
|
99
|
+
}, null)
|
|
100
|
+
|
|
101
|
+
if (
|
|
102
|
+
result ||
|
|
103
|
+
(existRole && existAriaLabel) ||
|
|
104
|
+
(c.children && filterFalsyJSXText(c.children).some(recursiveSearch))
|
|
105
|
+
) {
|
|
106
|
+
return true
|
|
84
107
|
}
|
|
85
|
-
|
|
86
|
-
return (!!a.value.value || a.value.type === 'JSXExpressionContainer') ? a : prev
|
|
87
|
-
}, null)
|
|
88
|
-
|
|
89
|
-
if (result || (existRole && existAriaLabel)) {
|
|
90
|
-
return true
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (c.children && filterFalsyJSXText(c.children).some(recursiveSearch)) {
|
|
94
|
-
return true
|
|
95
108
|
}
|
|
96
109
|
}
|
|
97
110
|
|
|
98
111
|
return false
|
|
99
112
|
}
|
|
100
113
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (!child) {
|
|
114
|
+
if (!filterFalsyJSXText(parentNode.children).find(recursiveSearch)) {
|
|
104
115
|
context.report({
|
|
105
116
|
node,
|
|
106
|
-
message
|
|
117
|
+
message,
|
|
107
118
|
});
|
|
108
119
|
}
|
|
109
120
|
},
|
|
@@ -7,6 +7,9 @@ const EXPECTED_NAMES = {
|
|
|
7
7
|
'^(img|svg)$': '(Img|Image|Icon)$',
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
const REGEX_IMG = /(img|image)$/i // HINT: Iconは別途テキストが存在する場合が多いためチェックの対象外とする
|
|
11
|
+
|
|
12
|
+
const findAltAttr = (a) => a.name?.name === 'alt'
|
|
10
13
|
const isWithinSvgJsxElement = (node) => {
|
|
11
14
|
if (
|
|
12
15
|
node.type === 'JSXElement' &&
|
|
@@ -15,13 +18,16 @@ const isWithinSvgJsxElement = (node) => {
|
|
|
15
18
|
return true
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
return false
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
return isWithinSvgJsxElement(node.parent)
|
|
21
|
+
return node.parent ? isWithinSvgJsxElement(node.parent) : false
|
|
23
22
|
}
|
|
24
23
|
|
|
24
|
+
const MESSAGE_NOT_EXIST_ALT = `画像にはalt属性を指定してください。
|
|
25
|
+
- コンポーネントが画像ではない場合、img or image を末尾に持たない名称に変更してください。
|
|
26
|
+
- ボタンやリンクの先頭・末尾などに設置するアイコンとしての役割を持つ画像の場合、コンポーネント名の末尾を "Icon" に変更してください。
|
|
27
|
+
- SVG component の場合、altを属性として受け取れるようにした上で '<svg role="img" aria-label={alt}>' のように指定してください。`
|
|
28
|
+
const MESSAGE_NULL_ALT = `画像の情報をテキストにした代替テキスト('alt')を設定してください。
|
|
29
|
+
- 装飾目的の画像など、alt属性に指定すべき文字がない場合は背景画像にすることを検討してください。`
|
|
30
|
+
|
|
25
31
|
module.exports = {
|
|
26
32
|
meta: {
|
|
27
33
|
type: 'problem',
|
|
@@ -31,25 +37,28 @@ module.exports = {
|
|
|
31
37
|
return {
|
|
32
38
|
...generateTagFormatter({ context, EXPECTED_NAMES }),
|
|
33
39
|
JSXOpeningElement: (node) => {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const alt = node.attributes.find((a) => a.name?.name === 'alt')
|
|
40
|
+
if (node.name.name) {
|
|
41
|
+
const matcher = node.name.name.match(REGEX_IMG)
|
|
37
42
|
|
|
38
|
-
|
|
43
|
+
if (matcher) {
|
|
44
|
+
const alt = node.attributes.find(findAltAttr)
|
|
39
45
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
let message = ''
|
|
47
|
+
|
|
48
|
+
if (!alt) {
|
|
49
|
+
if (matcher.input !== 'image' || !isWithinSvgJsxElement(node.parent)) {
|
|
50
|
+
message = MESSAGE_NOT_EXIST_ALT
|
|
51
|
+
}
|
|
52
|
+
} else if (alt.value.value === '') {
|
|
53
|
+
message = MESSAGE_NULL_ALT
|
|
43
54
|
}
|
|
44
|
-
} else if (alt.value.value === '') {
|
|
45
|
-
message = '画像の情報をテキストにした代替テキスト(`alt`)を設定してください。装飾目的の画像など、alt属性に指定すべき文字がない場合は背景画像にすることを検討してください。'
|
|
46
|
-
}
|
|
47
55
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
if (message) {
|
|
57
|
+
context.report({
|
|
58
|
+
node,
|
|
59
|
+
message,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
53
62
|
}
|
|
54
63
|
}
|
|
55
64
|
},
|
|
@@ -13,15 +13,26 @@ const EXPECTED_NAMES = {
|
|
|
13
13
|
}
|
|
14
14
|
const TARGET_TAG_NAME_REGEX = new RegExp(`(${Object.keys(EXPECTED_NAMES).join('|')})`)
|
|
15
15
|
const INPUT_NAME_REGEX = /^[a-zA-Z0-9_\[\]]+$/
|
|
16
|
+
const INPUT_TAG_REGEX = /(i|I)nput$/
|
|
17
|
+
|
|
18
|
+
const findNameAttr = (a) => a?.name?.name === 'name'
|
|
19
|
+
const findRadioInput = (a) => a.name?.name === 'type' && a.value.value === 'radio'
|
|
20
|
+
|
|
21
|
+
const MESSAGE_PART_FORMAT = `"${INPUT_NAME_REGEX.toString()}"にmatchするフォーマットで命名してください`
|
|
22
|
+
const MESSAGE_UNDEFINED_NAME_PART = `
|
|
23
|
+
- ブラウザの自動補完が有効化されるなどのメリットがあります
|
|
24
|
+
- より多くのブラウザが自動補完を行える可能性を上げるため、${MESSAGE_PART_FORMAT}`
|
|
25
|
+
const MESSAGE_UNDEFINED_FOR_RADIO = `にグループとなる他のinput[radio]と同じname属性を指定してください
|
|
26
|
+
- 適切に指定することで同じname属性を指定したinput[radio]とグループが確立され、適切なキーボード操作を行えるようになります${MESSAGE_UNDEFINED_NAME_PART}`
|
|
27
|
+
const MESSAGE_UNDEFINED_FOR_NOT_RADIO = `にname属性を指定してください${MESSAGE_UNDEFINED_NAME_PART}`
|
|
28
|
+
const MESSAGE_NAME_FORMAT_SUFFIX = `はブラウザの自動補完が適切に行えない可能性があるため${MESSAGE_PART_FORMAT}`
|
|
29
|
+
|
|
30
|
+
const SCHEMA = []
|
|
16
31
|
|
|
17
32
|
module.exports = {
|
|
18
33
|
meta: {
|
|
19
34
|
type: 'problem',
|
|
20
|
-
|
|
21
|
-
'format-styled-components': '{{ message }}',
|
|
22
|
-
'a11y-input-has-name-attribute': '{{ message }}',
|
|
23
|
-
},
|
|
24
|
-
schema: [],
|
|
35
|
+
schema: SCHEMA,
|
|
25
36
|
},
|
|
26
37
|
create(context) {
|
|
27
38
|
return {
|
|
@@ -29,41 +40,31 @@ module.exports = {
|
|
|
29
40
|
JSXOpeningElement: (node) => {
|
|
30
41
|
const nodeName = node.name.name || '';
|
|
31
42
|
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
}
|
|
43
|
+
if (nodeName.match(TARGET_TAG_NAME_REGEX)) {
|
|
44
|
+
const nameAttr = node.attributes.find(findNameAttr)
|
|
35
45
|
|
|
36
|
-
|
|
46
|
+
if (!nameAttr) {
|
|
47
|
+
const isRadio =
|
|
48
|
+
nodeName.match(/RadioButton$/) ||
|
|
49
|
+
(nodeName.match(INPUT_TAG_REGEX) && node.attributes.some(findRadioInput));
|
|
37
50
|
|
|
38
|
-
if (!nameAttr) {
|
|
39
|
-
const isRadio =
|
|
40
|
-
nodeName.match(/RadioButton$/) ||
|
|
41
|
-
(nodeName.match(/(i|I)nput$/) && node.attributes.some(
|
|
42
|
-
(a) => a.name?.name === 'type' && a.value.value === 'radio'
|
|
43
|
-
));
|
|
44
|
-
|
|
45
|
-
context.report({
|
|
46
|
-
node,
|
|
47
|
-
messageId: 'a11y-input-has-name-attribute',
|
|
48
|
-
data: {
|
|
49
|
-
message: `${nodeName} にname属性を指定してください。適切に指定することで${isRadio ? 'グループが確立され、キーボード操作しやすくなる' : 'ブラウザの自動補完が有効化される'}などのメリットがあります。`,
|
|
50
|
-
},
|
|
51
|
-
});
|
|
52
|
-
} else {
|
|
53
|
-
const nameValue = nameAttr.value?.value || ''
|
|
54
|
-
|
|
55
|
-
if (nameValue && !nameValue.match(INPUT_NAME_REGEX)) {
|
|
56
51
|
context.report({
|
|
57
52
|
node,
|
|
58
|
-
|
|
59
|
-
data: {
|
|
60
|
-
message: `${nodeName} のname属性の値(${nameValue})はブラウザの自動補完が適切に行えない可能性があるため ${INPUT_NAME_REGEX.toString()} にmatchするフォーマットで命名してください。`,
|
|
61
|
-
},
|
|
53
|
+
message: `${nodeName} ${isRadio ? MESSAGE_UNDEFINED_FOR_RADIO : MESSAGE_UNDEFINED_FOR_NOT_RADIO}`,
|
|
62
54
|
});
|
|
55
|
+
} else {
|
|
56
|
+
const nameValue = nameAttr.value?.value || ''
|
|
57
|
+
|
|
58
|
+
if (nameValue && !nameValue.match(INPUT_NAME_REGEX)) {
|
|
59
|
+
context.report({
|
|
60
|
+
node,
|
|
61
|
+
message: `${nodeName} のname属性の値(${nameValue})${MESSAGE_NAME_FORMAT_SUFFIX}`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
},
|
|
66
67
|
};
|
|
67
68
|
},
|
|
68
69
|
};
|
|
69
|
-
module.exports.schema =
|
|
70
|
+
module.exports.schema = SCHEMA;
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
const MESSAGE_NEW_DATE = `'new Date(arg)' のように引数を一つだけ指定したDate instanceの生成は実行環境によって結果が異なるため、以下のいずれかの方法に変更してください
|
|
2
|
+
- 'new Date(2022, 12 - 1, 31)' のように数値を個別に指定する
|
|
3
|
+
- dayjsなど、日付系ライブラリを利用する (例: 'dayjs(arg).toDate()')`
|
|
4
|
+
const MESSAGE_PARSE = `Date.parse は実行環境によって結果が異なるため、以下のいずれかの方法に変更してください
|
|
5
|
+
- 'new Date(2022, 12 - 1, 31).getTime()' のように数値を個別に指定する
|
|
6
|
+
- dayjsなど、日付系ライブラリを利用する (例: 'dayjs(arg).valueOf()')`
|
|
7
|
+
|
|
1
8
|
module.exports = {
|
|
2
9
|
meta: {
|
|
3
10
|
type: 'problem',
|
|
@@ -12,7 +19,7 @@ module.exports = {
|
|
|
12
19
|
) {
|
|
13
20
|
context.report({
|
|
14
21
|
node,
|
|
15
|
-
message:
|
|
22
|
+
message: MESSAGE_NEW_DATE,
|
|
16
23
|
});
|
|
17
24
|
}
|
|
18
25
|
},
|
|
@@ -23,7 +30,7 @@ module.exports = {
|
|
|
23
30
|
) {
|
|
24
31
|
context.report({
|
|
25
32
|
node,
|
|
26
|
-
message:
|
|
33
|
+
message: MESSAGE_PARSE,
|
|
27
34
|
});
|
|
28
35
|
}
|
|
29
36
|
},
|
|
@@ -12,10 +12,13 @@ const ruleTester = new RuleTester({
|
|
|
12
12
|
},
|
|
13
13
|
})
|
|
14
14
|
|
|
15
|
-
const generateErrorText = (name) => `${name} に href
|
|
16
|
-
- onClick
|
|
17
|
-
|
|
18
|
-
-
|
|
15
|
+
const generateErrorText = (name) => `${name} に href 属性を正しく設定してください
|
|
16
|
+
- onClickなどでページ遷移する場合でもhref属性に遷移先のURIを設定してください
|
|
17
|
+
- Cmd + clickなどのキーボードショートカットに対応出来ます
|
|
18
|
+
- onClickなどの動作がURLの変更を行わない場合、button要素でマークアップすることを検討してください
|
|
19
|
+
- href属性に空文字(""など)や '#' が設定されている場合、実質画面遷移を行わないため、同様にbutton要素でマークアップすることを検討してください
|
|
20
|
+
- リンクが存在せず無効化されていることを表したい場合、href属性に undefined を設定してください
|
|
21
|
+
- button要素のdisabled属性が設定された場合に相当します`
|
|
19
22
|
|
|
20
23
|
ruleTester.run('a11y-anchor-has-href-attribute', rule, {
|
|
21
24
|
valid: [
|
|
@@ -42,12 +45,7 @@ ruleTester.run('a11y-anchor-has-href-attribute', rule, {
|
|
|
42
45
|
code: `<Link href="hoge">ほげ</Link>`,
|
|
43
46
|
},
|
|
44
47
|
{
|
|
45
|
-
code: `<Link href="
|
|
46
|
-
options: [{ nextjs: true }],
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
code: `<Link to="hoge">ほげ</Link>`,
|
|
50
|
-
options: [{ react_router: true }],
|
|
48
|
+
code: `<Link href="#fuga">ほげ</Link>`,
|
|
51
49
|
},
|
|
52
50
|
],
|
|
53
51
|
invalid: [
|
|
@@ -61,7 +59,11 @@ ruleTester.run('a11y-anchor-has-href-attribute', rule, {
|
|
|
61
59
|
{ code: `<HogeLink>hoge</HogeLink>`, errors: [{ message: generateErrorText('HogeLink') }] },
|
|
62
60
|
{ code: `<HogeLink href>hoge</HogeLink>`, errors: [{ message: generateErrorText('HogeLink') }] },
|
|
63
61
|
{ code: `<HogeLink href="hoge"><a>hoge</a></HogeLink>`, errors: [{ message: generateErrorText('a') }] },
|
|
64
|
-
{ code: `<HogeLink><a>hoge</a></HogeLink>`, options: [{ nextjs: true }], errors: [{ message: generateErrorText('a') }] },
|
|
65
62
|
{ code: `<HogeLink to="hoge">hoge</HogeLink>`, errors: [{ message: generateErrorText('HogeLink') }] },
|
|
63
|
+
{ code: `<HogeLink href="">hoge</HogeLink>`, errors: [{ message: generateErrorText('HogeLink') }] },
|
|
64
|
+
{ code: `<HogeLink href={""}>hoge</HogeLink>`, errors: [{ message: generateErrorText('HogeLink') }] },
|
|
65
|
+
{ code: `<HogeLink href={''}>hoge</HogeLink>`, errors: [{ message: generateErrorText('HogeLink') }] },
|
|
66
|
+
{ code: `<HogeLink href="#">hoge</HogeLink>`, errors: [{ message: generateErrorText('HogeLink') }] },
|
|
67
|
+
{ code: `<HogeLink href={'#'}>hoge</HogeLink>`, errors: [{ message: generateErrorText('HogeLink') }] },
|
|
66
68
|
]
|
|
67
69
|
})
|
|
@@ -12,7 +12,9 @@ const ruleTester = new RuleTester({
|
|
|
12
12
|
},
|
|
13
13
|
})
|
|
14
14
|
|
|
15
|
-
const defaultErrorMessage =
|
|
15
|
+
const defaultErrorMessage = `a, buttonなどのクリッカブルな要素内にはテキストを設定してください。
|
|
16
|
+
- 要素内にアイコン、画像のみを設置する場合はaltなどの代替テキスト用属性を指定してください
|
|
17
|
+
- クリッカブルな要素内に設置しているコンポーネントがテキストを含んでいる場合、"XxxxText" のように末尾に "Text" もしくは "Message" という名称を設定してください`
|
|
16
18
|
|
|
17
19
|
ruleTester.run('a11y-clickable-element-has-text', rule, {
|
|
18
20
|
valid: [
|
|
@@ -30,6 +32,8 @@ ruleTester.run('a11y-clickable-element-has-text', rule, {
|
|
|
30
32
|
{ code: 'const HogeAnchor = styled.a(() => ``)' },
|
|
31
33
|
{ code: 'const HogeAnchor = styled("a")(() => ``)' },
|
|
32
34
|
{ code: 'const HogeAnchor = styled(Anchor)(() => ``)' },
|
|
35
|
+
{ code: 'const FugaText = styled(HogeText)(() => ``)' },
|
|
36
|
+
{ code: 'const FugaMessage = styled(HogeMessage)(() => ``)' },
|
|
33
37
|
{
|
|
34
38
|
code: `<a>ほげ</a>`,
|
|
35
39
|
},
|
|
@@ -105,6 +109,15 @@ ruleTester.run('a11y-clickable-element-has-text', rule, {
|
|
|
105
109
|
{
|
|
106
110
|
code: `<a><svg role="img" aria-label="hoge" /></a>`,
|
|
107
111
|
},
|
|
112
|
+
{
|
|
113
|
+
code: `<a><Text /></a>`,
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
code: `<a><HogeText /></a>`,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
code: `<a><FormattedMessage /></a>`,
|
|
120
|
+
},
|
|
108
121
|
{
|
|
109
122
|
code: `<a><AnyComponent /></a>`,
|
|
110
123
|
options: [{
|
|
@@ -126,6 +139,8 @@ ruleTester.run('a11y-clickable-element-has-text', rule, {
|
|
|
126
139
|
{ code: 'const Piyo = styled("a")(() => ``)', errors: [ { message: `Piyoを正規表現 "/(Anchor|Link)$/" がmatchする名称に変更してください` } ] },
|
|
127
140
|
{ code: 'const Piyo = styled("a")``', errors: [ { message: `Piyoを正規表現 "/(Anchor|Link)$/" がmatchする名称に変更してください` } ] },
|
|
128
141
|
{ code: 'const Piyo = styled(Anchor)(() => ``)', errors: [ { message: `Piyoを正規表現 "/Anchor$/" がmatchする名称に変更してください` } ] },
|
|
142
|
+
{ code: 'const Hoge = styled(Text)``', errors: [ { message: `Hogeを正規表現 "/Text$/" がmatchする名称に変更してください` } ] },
|
|
143
|
+
{ code: 'const Hoge = styled(HogeMessage)``', errors: [ { message: `Hogeを正規表現 "/Message$/" がmatchする名称に変更してください` } ] },
|
|
129
144
|
{
|
|
130
145
|
code: `<a><img src="hoge.jpg" /></a>`,
|
|
131
146
|
errors: [{ message: defaultErrorMessage }]
|
|
@@ -174,6 +189,10 @@ ruleTester.run('a11y-clickable-element-has-text', rule, {
|
|
|
174
189
|
code: `<a><div role="article" aria-label="hoge" /></a>`,
|
|
175
190
|
errors: [{ message: defaultErrorMessage }]
|
|
176
191
|
},
|
|
192
|
+
{
|
|
193
|
+
code: `<a><TextWithHoge /></a>`,
|
|
194
|
+
errors: [{ message: defaultErrorMessage }]
|
|
195
|
+
},
|
|
177
196
|
{
|
|
178
197
|
code: `<a><AnyComponent /></a>`,
|
|
179
198
|
options: [{
|
|
@@ -12,6 +12,13 @@ const ruleTester = new RuleTester({
|
|
|
12
12
|
},
|
|
13
13
|
})
|
|
14
14
|
|
|
15
|
+
const messageNotExistAlt = `画像にはalt属性を指定してください。
|
|
16
|
+
- コンポーネントが画像ではない場合、img or image を末尾に持たない名称に変更してください。
|
|
17
|
+
- ボタンやリンクの先頭・末尾などに設置するアイコンとしての役割を持つ画像の場合、コンポーネント名の末尾を "Icon" に変更してください。
|
|
18
|
+
- SVG component の場合、altを属性として受け取れるようにした上で '<svg role="img" aria-label={alt}>' のように指定してください。`
|
|
19
|
+
const messageNullAlt = `画像の情報をテキストにした代替テキスト('alt')を設定してください。
|
|
20
|
+
- 装飾目的の画像など、alt属性に指定すべき文字がない場合は背景画像にすることを検討してください。`
|
|
21
|
+
|
|
15
22
|
ruleTester.run('a11y-image-has-alt-attribute', rule, {
|
|
16
23
|
valid: [
|
|
17
24
|
{ code: `import styled from 'styled-components'` },
|
|
@@ -39,8 +46,8 @@ ruleTester.run('a11y-image-has-alt-attribute', rule, {
|
|
|
39
46
|
{ code: 'const Hoge = styled(Icon)``', errors: [ { message: `Hogeを正規表現 "/Icon$/" がmatchする名称に変更してください` } ] },
|
|
40
47
|
{ code: 'const Hoge = styled(Img)``', errors: [ { message: `Hogeを正規表現 "/Img$/" がmatchする名称に変更してください` } ] },
|
|
41
48
|
{ code: 'const Hoge = styled(Image)``', errors: [ { message: `Hogeを正規表現 "/Image$/" がmatchする名称に変更してください` } ] },
|
|
42
|
-
{ code: '<img />', errors: [ { message:
|
|
43
|
-
{ code: '<HogeImage alt="" />', errors: [ { message:
|
|
44
|
-
{ code: '<hoge><image /></hoge>', errors: [ { message:
|
|
49
|
+
{ code: '<img />', errors: [ { message: messageNotExistAlt } ] },
|
|
50
|
+
{ code: '<HogeImage alt="" />', errors: [ { message: messageNullAlt } ] },
|
|
51
|
+
{ code: '<hoge><image /></hoge>', errors: [ { message: messageNotExistAlt } ] },
|
|
45
52
|
]
|
|
46
53
|
})
|
|
@@ -12,6 +12,12 @@ const ruleTester = new RuleTester({
|
|
|
12
12
|
},
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
+
const MESSAGE_SUFFIX = `
|
|
16
|
+
- ブラウザの自動補完が有効化されるなどのメリットがあります
|
|
17
|
+
- より多くのブラウザが自動補完を行える可能性を上げるため、\"/^[a-zA-Z0-9_\\[\\]]+$/\"にmatchするフォーマットで命名してください`
|
|
18
|
+
const MESSAGE_RADIO_SUFFIX = `
|
|
19
|
+
- 適切に指定することで同じname属性を指定したinput[radio]とグループが確立され、適切なキーボード操作を行えるようになります${MESSAGE_SUFFIX}`
|
|
20
|
+
|
|
15
21
|
ruleTester.run('a11y-input-has-name-attribute', rule, {
|
|
16
22
|
valid: [
|
|
17
23
|
{ code: `import styled from 'styled-components'` },
|
|
@@ -37,18 +43,18 @@ ruleTester.run('a11y-input-has-name-attribute', rule, {
|
|
|
37
43
|
{ code: 'const Hoge = styled.input``', errors: [ { message: `Hogeを正規表現 "/Input$/" がmatchする名称に変更してください` } ] },
|
|
38
44
|
{ code: 'const Hoge = styled.Input``', errors: [ { message: `Hogeを正規表現 "/Input$/" がmatchする名称に変更してください` } ] },
|
|
39
45
|
{ code: 'const Hoge = styled(RadioButton)``', errors: [ { message: `Hogeを正規表現 "/RadioButton$/" がmatchする名称に変更してください` } ] },
|
|
40
|
-
{ code: '<input />', errors: [ { message:
|
|
41
|
-
{ code: '<input type="date" />', errors: [ { message:
|
|
42
|
-
{ code: '<Input type="checkbox" />', errors: [ { message:
|
|
43
|
-
{ code: '<input type="radio" />', errors: [ { message:
|
|
44
|
-
{ code: '<HogeInput type="radio" />', errors: [ { message:
|
|
45
|
-
{ code: '<HogeInput type="text" />', errors: [ { message:
|
|
46
|
-
{ code: '<HogeRadioButton />', errors: [ { message:
|
|
47
|
-
{ code: '<select />', errors: [ { message:
|
|
48
|
-
{ code: '<HogeSelect />', errors: [ { message:
|
|
49
|
-
{ code: '<textarea />', errors: [ { message:
|
|
50
|
-
{ code: '<HogeTextarea />', errors: [ { message:
|
|
51
|
-
{ code: '<input type="radio" name="ほげ" />', errors: [ { message: 'input のname属性の値(ほげ)はブラウザの自動補完が適切に行えない可能性があるため
|
|
52
|
-
{ code: '<select name="hoge[fuga][0][あいうえお]" />', errors: [ { message: 'select のname属性の値(hoge[fuga][0][あいうえお])はブラウザの自動補完が適切に行えない可能性があるため
|
|
46
|
+
{ code: '<input />', errors: [ { message: `input にname属性を指定してください${MESSAGE_SUFFIX}` } ] },
|
|
47
|
+
{ code: '<input type="date" />', errors: [ { message: `input にname属性を指定してください${MESSAGE_SUFFIX}` } ] },
|
|
48
|
+
{ code: '<Input type="checkbox" />', errors: [ { message: `Input にname属性を指定してください${MESSAGE_SUFFIX}` } ] },
|
|
49
|
+
{ code: '<input type="radio" />', errors: [ { message: `input にグループとなる他のinput[radio]と同じname属性を指定してください${MESSAGE_RADIO_SUFFIX}` } ] },
|
|
50
|
+
{ code: '<HogeInput type="radio" />', errors: [ { message: `HogeInput にグループとなる他のinput[radio]と同じname属性を指定してください${MESSAGE_RADIO_SUFFIX}` } ] },
|
|
51
|
+
{ code: '<HogeInput type="text" />', errors: [ { message: `HogeInput にname属性を指定してください${MESSAGE_SUFFIX}` } ] },
|
|
52
|
+
{ code: '<HogeRadioButton />', errors: [ { message: `HogeRadioButton にグループとなる他のinput[radio]と同じname属性を指定してください${MESSAGE_RADIO_SUFFIX}` } ] },
|
|
53
|
+
{ code: '<select />', errors: [ { message: `select にname属性を指定してください${MESSAGE_SUFFIX}` } ] },
|
|
54
|
+
{ code: '<HogeSelect />', errors: [ { message: `HogeSelect にname属性を指定してください${MESSAGE_SUFFIX}` } ] },
|
|
55
|
+
{ code: '<textarea />', errors: [ { message: `textarea にname属性を指定してください${MESSAGE_SUFFIX}` } ] },
|
|
56
|
+
{ code: '<HogeTextarea />', errors: [ { message: `HogeTextarea にname属性を指定してください${MESSAGE_SUFFIX}` } ] },
|
|
57
|
+
{ code: '<input type="radio" name="ほげ" />', errors: [ { message: 'input のname属性の値(ほげ)はブラウザの自動補完が適切に行えない可能性があるため"/^[a-zA-Z0-9_\\[\\]]+$/"にmatchするフォーマットで命名してください' } ] },
|
|
58
|
+
{ code: '<select name="hoge[fuga][0][あいうえお]" />', errors: [ { message: 'select のname属性の値(hoge[fuga][0][あいうえお])はブラウザの自動補完が適切に行えない可能性があるため"/^[a-zA-Z0-9_\\[\\]]+$/"にmatchするフォーマットで命名してください' } ] },
|
|
53
59
|
],
|
|
54
60
|
});
|
|
@@ -12,8 +12,12 @@ const ruleTester = new RuleTester({
|
|
|
12
12
|
},
|
|
13
13
|
})
|
|
14
14
|
|
|
15
|
-
const errorNewDate =
|
|
16
|
-
|
|
15
|
+
const errorNewDate = `'new Date(arg)' のように引数を一つだけ指定したDate instanceの生成は実行環境によって結果が異なるため、以下のいずれかの方法に変更してください
|
|
16
|
+
- 'new Date(2022, 12 - 1, 31)' のように数値を個別に指定する
|
|
17
|
+
- dayjsなど、日付系ライブラリを利用する (例: 'dayjs(arg).toDate()')`
|
|
18
|
+
const errorDateParse = `Date.parse は実行環境によって結果が異なるため、以下のいずれかの方法に変更してください
|
|
19
|
+
- 'new Date(2022, 12 - 1, 31).getTime()' のように数値を個別に指定する
|
|
20
|
+
- dayjsなど、日付系ライブラリを利用する (例: 'dayjs(arg).valueOf()')`
|
|
17
21
|
|
|
18
22
|
ruleTester.run('best-practice-for-date', rule, {
|
|
19
23
|
valid: [
|