eslint-plugin-smarthr 0.3.6 → 0.3.7
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 +7 -0
- package/package.json +1 -1
- 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/test/a11y-clickable-element-has-text.js +20 -1
- package/test/a11y-image-has-alt-attribute.js +10 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
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.7](https://github.com/kufu/eslint-plugin-smarthr/compare/v0.3.6...v0.3.7) (2023-08-24)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* 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))
|
|
11
|
+
|
|
5
12
|
### [0.3.6](https://github.com/kufu/eslint-plugin-smarthr/compare/v0.3.5...v0.3.6) (2023-08-20)
|
|
6
13
|
|
|
7
14
|
|
package/package.json
CHANGED
|
@@ -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
|
},
|
|
@@ -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
|
})
|