eslint-plugin-smarthr 1.10.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
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
+ ## [2.0.0](https://github.com/kufu/tamatebako/compare/eslint-plugin-smarthr-v1.11.0...eslint-plugin-smarthr-v2.0.0) (2025-10-16)
6
+
7
+
8
+ ### ⚠ BREAKING CHANGES
9
+
10
+ * eslint rule の smarthr/a11y-replace-unreadable-symbolを削除 ([#850](https://github.com/kufu/tamatebako/issues/850))
11
+
12
+ ### Code Refactoring
13
+
14
+ * eslint rule の smarthr/a11y-replace-unreadable-symbolを削除 ([#850](https://github.com/kufu/tamatebako/issues/850)) ([68336f8](https://github.com/kufu/tamatebako/commit/68336f8df42fcfe0c3a8375671377cc62b2be711))
15
+
16
+ ## [1.11.0](https://github.com/kufu/tamatebako/compare/eslint-plugin-smarthr-v1.10.0...eslint-plugin-smarthr-v1.11.0) (2025-10-01)
17
+
18
+
19
+ ### Features
20
+
21
+ * a11y-input-in-form-controlのaria-label,aria-labelledbyが設定されている場合、FormControlでラップしなくてもよしとする ([#795](https://github.com/kufu/tamatebako/issues/795)) ([443892f](https://github.com/kufu/tamatebako/commit/443892ffb39732157d77a179484da3f529e43885))
22
+
5
23
  ## [1.10.0](https://github.com/kufu/tamatebako/compare/eslint-plugin-smarthr-v1.9.0...eslint-plugin-smarthr-v1.10.0) (2025-10-01)
6
24
 
7
25
 
package/README.md CHANGED
@@ -14,7 +14,6 @@
14
14
  - [a11y-prohibit-input-placeholder](https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/a11y-prohibit-input-placeholder)
15
15
  - [a11y-prohibit-sectioning-content-in-form](https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/a11y-prohibit-sectioning-content-in-form)
16
16
  - [a11y-prohibit-useless-sectioning-fragment](https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/a11y-prohibit-useless-sectioning-fragment)
17
- - [a11y-replace-unreadable-symbol](https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/a11y-replace-unreadable-symbol)
18
17
  - [a11y-required-layout-as-attribute](https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/a11y-required-layout-as-attribute)
19
18
  - [a11y-trigger-has-button](https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/a11y-trigger-has-button)
20
19
  - [best-practice-for-async-current-target](https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-async-current-target)
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "eslint-plugin-smarthr",
3
- "version": "1.10.0",
3
+ "version": "2.0.0",
4
4
  "author": "SmartHR",
5
5
  "license": "MIT",
6
6
  "description": "A sharable ESLint plugin for SmartHR",
7
7
  "main": "index.js",
8
8
  "engines": {
9
- "node": ">=22.18.0"
9
+ "node": ">=22.20.0"
10
10
  },
11
11
  "scripts": {
12
12
  "test": "jest"
@@ -26,7 +26,7 @@
26
26
  "json5": "^2.2.3"
27
27
  },
28
28
  "devDependencies": {
29
- "typescript-eslint": "^8.40.0"
29
+ "typescript-eslint": "^8.46.0"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "eslint": "^9"
@@ -37,5 +37,5 @@
37
37
  "eslintplugin",
38
38
  "smarthr"
39
39
  ],
40
- "gitHead": "343b735f1202c536e8bf9c897fdb4d5289455b8e"
40
+ "gitHead": "dd35a6760dd9022d4b28747996c2016022e94dc6"
41
41
  }
@@ -4,90 +4,52 @@ const fs = require('fs')
4
4
  const OPTION = (() => {
5
5
  const file = `${process.cwd()}/package.json`
6
6
 
7
- if (!fs.existsSync(file)) {
8
- return {}
9
- }
7
+ if (fs.existsSync(file)) {
8
+ const json = JSON5.parse(fs.readFileSync(file))
10
9
 
11
- const json = JSON5.parse(fs.readFileSync(file))
12
- const dependencies = [
13
- ...(json.dependencies ? Object.keys(json.dependencies) : []),
14
- ...(json.devDependencies ? Object.keys(json.devDependencies) : []),
15
- ]
10
+ if (json.dependencies){
11
+ const dependencies = Object.keys(json.dependencies)
16
12
 
17
- let nextjs = false
18
- let react_router = false
19
- const result = () => ({
20
- nextjs,
21
- react_router,
22
- })
13
+ let nextjs = false
14
+ let react_router = false
15
+ const result = () => ({
16
+ nextjs,
17
+ react_router,
18
+ })
23
19
 
24
- for (let i = 0; i < dependencies.length; i++) {
25
- switch (dependencies[i]) {
26
- case 'next':
27
- nextjs = true
20
+ for (let i = 0; i < dependencies.length; i++) {
21
+ switch (dependencies[i]) {
22
+ case 'next':
23
+ nextjs = true
28
24
 
29
- if (react_router) {
30
- return result()
31
- }
25
+ if (react_router) {
26
+ return result()
27
+ }
32
28
 
33
- break
34
- case 'react-router':
35
- react_router = true
29
+ break
30
+ case 'react-router':
31
+ react_router = true
36
32
 
37
- if (nextjs) {
38
- return result()
39
- }
33
+ if (nextjs) {
34
+ return result()
35
+ }
40
36
 
41
- break
37
+ break
38
+ }
39
+ }
42
40
  }
43
41
  }
44
42
 
45
- return result()
43
+ return {}
46
44
  })()
47
45
 
48
- const REGEX_TARGET = /(Anchor|Link|^a)$/
49
- const check = (node, checkType) => {
50
- const result = baseCheck(node, checkType)
51
-
52
- return result && ((OPTION.nextjs && !nextCheck(node, checkType)) || (OPTION.react_router && !reactRouterCheck(node))) ? null : result
53
- }
54
- const baseCheck = (node, checkType) => {
55
- const nodeName = node.name.name || ''
56
-
57
- if (
58
- REGEX_TARGET.test(nodeName) &&
59
- checkExistAttribute(node, findHrefAttribute) &&
60
- (checkType !== 'allow-spread-attributes' || !node.attributes.some(findSpreadAttr))
61
- ) {
62
- return nodeName
63
- }
64
-
65
- return false
66
- }
67
- const nextCheck = (node, checkType) => {
68
- // HINT: next/link で `Link>a` という構造がありえるので直上のJSXElementを調べる
69
- const target = node.parent.parent.openingElement
70
-
71
- return target ? baseCheck(target, checkType) : false
72
- }
73
- const reactRouterCheck = (node) => checkExistAttribute(node, findToAttribute)
46
+ const ANCHOR_ELEMENT = 'JSXOpeningElement[name.name=/(Anchor|Link|^a)$/]'
47
+ const HREF_ATTRIBUTE = `JSXAttribute[name.name=${OPTION.react_router ? '/^(href|to)$/' : '"href"'}]`
48
+ const NEXT_LINK_REGEX = /Link$/
49
+ // HINT: next/link で `Link>a` という構造がありえるので直上のJSXElementを調べる
50
+ const nextCheck = (node) => ((node.parent.parent.openingElement.name.name || '').test(NEXT_LINK_REGEX))
74
51
 
75
- const checkExistAttribute = (node, find) => {
76
- const attr = node.attributes.find(find)?.value
77
-
78
- return (
79
- !attr ||
80
- isNullTextHref(attr) ||
81
- (attr.type === 'JSXExpressionContainer' && isNullTextHref(attr.expression))
82
- )
83
- }
84
- const isNullTextHref = (attr) => attr.type === 'Literal' && (attr.value === '' || attr.value === '#')
85
- const findSpreadAttr = (a) => a.type === 'JSXSpreadAttribute'
86
-
87
- const findHrefAttribute = (a) => a.name?.name == 'href'
88
- const findToAttribute = (a) => a.name?.name == 'to'
89
-
90
- const MESSAGE_SUFFIX = ` に href 属性を正しく設定してください
52
+ const MESSAGE_SUFFIX = ` href${OPTION.react_router ? '、もしくはto' : ''} 属性を正しく設定してください
91
53
  - onClickなどでページ遷移する場合でもhref属性に遷移先のURIを設定してください
92
54
  - Cmd + clickなどのキーボードショートカットに対応出来ます
93
55
  - onClickなどの動作がURLの変更を行わない場合、button要素でマークアップすることを検討してください
@@ -115,19 +77,21 @@ module.exports = {
115
77
  },
116
78
  create(context) {
117
79
  const option = context.options[0] || {}
118
- const checkType = option.checkType || 'always'
80
+ const spreadAttributeSelector = option.checkType === 'allow-spread-attributes' ? ':not(:has(JSXSpreadAttribute))' : ''
81
+ const reporter = (node) => {
82
+ context.report({
83
+ node,
84
+ message: `${node.name.name}${MESSAGE_SUFFIX}`,
85
+ })
86
+ }
119
87
 
120
88
  return {
121
- JSXOpeningElement: (node) => {
122
- const nodeName = check(node, checkType)
123
-
124
- if (nodeName) {
125
- context.report({
126
- node,
127
- message: `${nodeName}${MESSAGE_SUFFIX}`,
128
- })
89
+ [`${ANCHOR_ELEMENT}:not(:has(${HREF_ATTRIBUTE}))${spreadAttributeSelector}`]: (node) => {
90
+ if (!OPTION.nextjs || !nextCheck(node)) {
91
+ reporter(node)
129
92
  }
130
93
  },
94
+ [`${ANCHOR_ELEMENT}:has(${HREF_ATTRIBUTE}:matches([value=null],:has(Literal[value=/^(|#)$/])))`]: reporter,
131
95
  }
132
96
  },
133
97
  }
@@ -8,14 +8,8 @@ const SCHEMA = [
8
8
  }
9
9
  ]
10
10
 
11
- const REGEX_NLSP = /^\s*\n+\s*$/
12
- const REGEX_CLICKABLE_ELEMENT = /^(a|(.*?)Anchor(Button)?|(.*?)Link|(b|B)utton)$/
13
- const REGEX_SMARTHR_LOGO = /SmartHRLogo$/
14
- const REGEX_TEXT_COMPONENT = /(Text|Message)$/
15
- const REGEX_JSX_TYPE = /^(JSXText|JSXExpressionContainer)$/
16
-
17
- const filterFalsyJSXText = (cs) => cs.filter(checkFalsyJSXText)
18
- const checkFalsyJSXText = (c) => c.type !== 'JSXText' || !REGEX_NLSP.test(c.value)
11
+ const CLICKABLE_ELEMENT = 'JSXElement[openingElement.name.name=/((^b|B)utton|Anchor|Link|^a)/]:has(JSXClosingElement)'
12
+ const TEXT_LIKE_ATTRIBUTE = 'JSXAttribute[name.name=/^(text|alt|aria-label(ledby)?)$/]:not(:matches([value=null],[value.value=""])))'
19
13
 
20
14
  const message = `a, buttonなどのクリッカブルな要素内にはテキストを設定してください
21
15
  - 要素内にアイコン、画像のみを設置する場合はaltなどの代替テキスト用属性を指定してください
@@ -33,76 +27,15 @@ module.exports = {
33
27
  create(context) {
34
28
  const option = context.options[0] || {}
35
29
  const componentsWithText = option.componentsWithText || []
30
+ // HINT: SmartHRLogo コンポーネントは内部でaltを持っているため対象外にする
31
+ const elementWithText = `JSXOpeningElement[name.name=/(SmartHRLogo|Text|Message|^(${componentsWithText.join('|')}))$/]`
36
32
 
37
33
  return {
38
- JSXElement: (parentNode) => {
39
- // HINT: 閉じタグが存在しない === テキストノードが存在しない
40
- if (!parentNode.closingElement) {
41
- return
42
- }
43
-
44
- const node = parentNode.openingElement
45
-
46
- if (!node.name.name || !REGEX_CLICKABLE_ELEMENT.test(node.name.name)) {
47
- return
48
- }
49
-
50
- const recursiveSearch = (c) => {
51
- if (REGEX_JSX_TYPE.test(c.type)) {
52
- return true
53
- }
54
-
55
- switch (c.type) {
56
- case 'JSXFragment': {
57
- return c.children && filterFalsyJSXText(c.children).some(recursiveSearch)
58
- }
59
- case 'JSXElement': {
60
- // // HINT: SmartHRLogo コンポーネントは内部でaltを持っているため対象外にする
61
- if (REGEX_SMARTHR_LOGO.test(c.openingElement.name.name)) {
62
- return true
63
- }
64
-
65
- const tagName = c.openingElement.name.name
66
-
67
- if (REGEX_TEXT_COMPONENT.test(tagName) || componentsWithText.includes(tagName)) {
68
- return true
69
- }
70
-
71
- // HINT: role & aria-label を同時に設定されている場合か、text属性が設定されている場合許可
72
- let existRole = false
73
- let existAriaLabel = false
74
- const result = c.openingElement.attributes.reduce((prev, a) => {
75
- const n = a.name?.name
76
-
77
- if (prev || n === 'text') {
78
- return true
79
- }
80
-
81
- const v = a.value?.value
82
-
83
- existRole = existRole || (n === 'role' && v === 'img')
84
- existAriaLabel = existAriaLabel || n === 'aria-label'
85
-
86
- if (existRole && existAriaLabel) {
87
- return true
88
- }
89
-
90
- return n === 'alt' && (v || a.value.type === 'JSXExpressionContainer') || false
91
- }, null)
92
-
93
- return result || (c.children && filterFalsyJSXText(c.children).some(recursiveSearch))
94
- }
95
- }
96
-
97
- return false
98
- }
99
-
100
- if (!filterFalsyJSXText(parentNode.children).find(recursiveSearch)) {
101
- context.report({
102
- node,
103
- message,
104
- });
105
- }
34
+ [`${CLICKABLE_ELEMENT}:not(:has(:matches(${TEXT_LIKE_ATTRIBUTE},JSXText,JSXExpressionContainer,${elementWithText}))`]: (node) => {
35
+ context.report({
36
+ node,
37
+ message,
38
+ });
106
39
  },
107
40
  }
108
41
  },
@@ -1,18 +1,6 @@
1
- const REGEX_IMG = /(img|image)$/i // HINT: Iconは別途テキストが存在する場合が多いためチェックの対象外とする
2
-
3
- const findAltAttr = (a) => a.name?.name === 'alt'
4
- const findAriaDescribedbyAttr = (a) => a.name?.name === 'aria-describedby'
5
- const findSpreadAttr = (a) => a.type === 'JSXSpreadAttribute'
6
- const isWithinSvgJsxElement = (node) => {
7
- if (
8
- node.type === 'JSXElement' &&
9
- node.openingElement.name?.name === 'svg'
10
- ) {
11
- return true
12
- }
13
-
14
- return node.parent ? isWithinSvgJsxElement(node.parent) : false
15
- }
1
+ const REGEX_IMG = /((i|I)mg|Image)$/ // HINT: Iconは別途テキストが存在する場合が多いためチェックの対象外とする
2
+ const IMG_ELEMENT = 'JSXOpeningElement[name.name=/((i|I)mg|Image)$/]'
3
+ const ALT_LIKE_ATTRIBUTE = 'JSXAttribute[name.name=/^(alt|aria-describedby)$/]'
16
4
 
17
5
  const MESSAGE_NOT_EXIST_ALT = `画像にはalt属性を指定してください。
18
6
  - コンポーネントが画像ではない場合、img or image を末尾に持たない名称に変更してください。
@@ -43,44 +31,20 @@ module.exports = {
43
31
  },
44
32
  create(context) {
45
33
  const option = context.options[0] || {}
46
- const checkType = option.checkType || 'always'
34
+ const notHasSpreadAttr = option.checkType === 'allow-spread-attributes' ? ':not(:has(JSXSpreadAttribute))' : ''
47
35
 
48
36
  return {
49
- JSXOpeningElement: (node) => {
50
- if (node.name.name) {
51
- const matcher = node.name.name.match(REGEX_IMG)
52
-
53
- if (matcher) {
54
- const alt = node.attributes.find(findAltAttr)
55
-
56
- let message = ''
57
-
58
- if (!alt) {
59
- if (
60
- !node.attributes.find(findAriaDescribedbyAttr) &&
61
- (
62
- matcher.input !== 'image' ||
63
- !isWithinSvgJsxElement(node.parent)
64
- ) &&
65
- (
66
- checkType !== 'allow-spread-attributes' ||
67
- !node.attributes.some(findSpreadAttr)
68
- )
69
- ) {
70
- message = MESSAGE_NOT_EXIST_ALT
71
- }
72
- } else if (alt.value.value === '') {
73
- message = MESSAGE_NULL_ALT
74
- }
75
-
76
- if (message) {
77
- context.report({
78
- node,
79
- message,
80
- });
81
- }
82
- }
83
- }
37
+ [`${IMG_ELEMENT}:not(:has(${ALT_LIKE_ATTRIBUTE}))${notHasSpreadAttr}`]: (node) => {
38
+ context.report({
39
+ node,
40
+ message: MESSAGE_NOT_EXIST_ALT,
41
+ });
42
+ },
43
+ [`${IMG_ELEMENT}:has(${ALT_LIKE_ATTRIBUTE}:matches([value.value=""],[value=null]))${notHasSpreadAttr}`]: (node) => {
44
+ context.report({
45
+ node,
46
+ message: MESSAGE_NULL_ALT,
47
+ });
84
48
  },
85
49
  }
86
50
  },
@@ -5,31 +5,26 @@ const JSON5 = require('json5')
5
5
  const OPTION = (() => {
6
6
  const file = `${process.cwd()}/package.json`
7
7
 
8
- if (!fs.existsSync(file)) {
9
- return {}
8
+ if (fs.existsSync(file)) {
9
+ return {
10
+ react_hook_form: Object.keys(JSON5.parse(fs.readFileSync(file)).dependencies).includes('react-hook-form'),
11
+ }
10
12
  }
11
13
 
12
- const json = JSON5.parse(fs.readFileSync(file))
13
- const dependencies = [...Object.keys(json.dependencies || {}), ...Object.keys(json.devDependencies || {})]
14
-
15
- return {
16
- react_hook_form: dependencies.includes('react-hook-form'),
17
- }
14
+ return {}
18
15
  })()
19
16
 
20
- const TARGET_TAG_NAME_REGEX = /((I|^i)nput|(T|^t)extarea|(S|^s)elect|InputFile|RadioButton(Panel)?|(Check|Combo)(B|b)ox|(Date|Wareki|Time)Picker|DropZone)$/
21
- const INPUT_NAME_REGEX = /^[a-zA-Z0-9_\[\]]+$/
22
- const INPUT_TAG_REGEX = /(i|I)nput$/
23
- const RADIO_BUTTON_REGEX = /RadioButton(Panel)?$/
17
+ const INPUT_ELEMENT = 'JSXOpeningElement[name.name=/((I|^i)nput|(T|^t)extarea|(S|^s)elect|InputFile|RadioButton(Panel)?|(Check|Combo)(B|b)ox|(Date|Wareki|Time)Picker|DropZone)$/]'
18
+ const INPUT_ELEMENT_WITHOUT_RADIO = 'JSXOpeningElement[name.name=/((I|^i)nput|(T|^t)extarea|(S|^s)elect|InputFile|(Check|Combo)(B|b)ox|(Date|Wareki|Time)Picker|DropZone)$/]'
19
+ const RADIO_ELEMENT = 'JSXOpeningElement:matches([name.name=/RadioButton(Panel)?$/],[name.name=/(I|^i)nput?$/]:has(JSXAttribute[name.name="type"][value.value="radio"]))'
20
+ const NAME_ATTRIBUTE = 'JSXAttribute[name.name="name"]'
21
+
22
+ const INPUT_NAME_REGEX = /^[a-zA-Z0-9_\[\]]+$/.toString()
24
23
 
25
24
  const MESSAGE_PART_FORMAT = `"${INPUT_NAME_REGEX.toString()}"にmatchするフォーマットで命名してください`
26
25
  const MESSAGE_UNDEFINED_NAME_PART = `
27
26
  - ブラウザの自動補完が有効化されるなどのメリットがあります
28
27
  - より多くのブラウザが自動補完を行える可能性を上げるため、${MESSAGE_PART_FORMAT}`
29
- const MESSAGE_UNDEFINED_FOR_RADIO = `にグループとなる他のinput[radio]と同じname属性を指定してください
30
- - 適切に指定することで同じname属性を指定したinput[radio]とグループが確立され、適切なキーボード操作を行えるようになります${MESSAGE_UNDEFINED_NAME_PART}`
31
- const MESSAGE_UNDEFINED_FOR_NOT_RADIO = `にname属性を指定してください${MESSAGE_UNDEFINED_NAME_PART}`
32
- const MESSAGE_NAME_FORMAT_SUFFIX = `はブラウザの自動補完が適切に行えない可能性があるため${MESSAGE_PART_FORMAT}`
33
28
 
34
29
  const SCHEMA = [
35
30
  {
@@ -53,61 +48,30 @@ module.exports = {
53
48
  const option = context.options[0] || {}
54
49
  const checkType = option.checkType || 'always'
55
50
 
56
- return {
57
- JSXOpeningElement: (node) => {
58
- const { name, attributes } = node
59
- const nodeName = name.name || ''
60
-
61
- if (TARGET_TAG_NAME_REGEX.test(nodeName)) {
62
- let nameAttr = null
63
- let hasSpreadAttr = false
64
- let hasReactHookFormRegisterSpreadAttr = false
65
- let hasRadioInput = false
51
+ const notHasSpreadAttribute =
52
+ option.checkType == 'allow-spread-attributes'
53
+ ? ':not(:has(JSXSpreadAttribute))'
54
+ : OPTION.react_hook_form ? ':not(:has(JSXSpreadAttribute CallExpression[callee.name="register"]))' : ''
66
55
 
67
- attributes.forEach((a) => {
68
- if (a.type === 'JSXSpreadAttribute') {
69
- hasSpreadAttr = true
70
-
71
- if (hasReactHookFormRegisterSpreadAttr === false && a.argument?.callee?.name === 'register') {
72
- hasReactHookFormRegisterSpreadAttr = true
73
- }
74
- } else {
75
- switch (a.name?.name) {
76
- case 'name': {
77
- nameAttr = a
78
- break
79
- }
80
- case 'type': {
81
- if (a.value.value === 'radio') {
82
- hasRadioInput = true
83
- }
84
- break
85
- }
86
- }
87
- }
88
- })
89
-
90
- if (nameAttr) {
91
- const nameValue = nameAttr.value?.value
92
-
93
- if (nameValue && !INPUT_NAME_REGEX.test(nameValue)) {
94
- context.report({
95
- node,
96
- message: `${nodeName} のname属性の値(${nameValue})${MESSAGE_NAME_FORMAT_SUFFIX}`,
97
- })
98
- }
99
- } else if (
100
- !(OPTION.react_hook_form && hasReactHookFormRegisterSpreadAttr) &&
101
- (attributes.length === 0 || checkType !== 'allow-spread-attributes' || !hasSpreadAttr)
102
- ) {
103
- const isRadio = RADIO_BUTTON_REGEX.test(nodeName) || INPUT_TAG_REGEX.test(nodeName) && hasRadioInput
104
-
105
- context.report({
106
- node,
107
- message: `${nodeName} ${isRadio ? MESSAGE_UNDEFINED_FOR_RADIO : MESSAGE_UNDEFINED_FOR_NOT_RADIO}`,
108
- })
109
- }
110
- }
56
+ return {
57
+ [`${INPUT_ELEMENT_WITHOUT_RADIO}:not(:has(:matches(${NAME_ATTRIBUTE},JSXAttribute[name.name="type"][value.value="radio"])))${notHasSpreadAttribute}`]: (node) => {
58
+ context.report({
59
+ node,
60
+ message: `${node.name.name} にname属性を指定してください${MESSAGE_UNDEFINED_NAME_PART}`,
61
+ })
62
+ },
63
+ [`${RADIO_ELEMENT}:not(:has(${NAME_ATTRIBUTE}))${notHasSpreadAttribute}`]: (node) => {
64
+ context.report({
65
+ node,
66
+ message: `${node.name.name} にグループとなる他のinput[radio]と同じname属性を指定してください
67
+ - 適切に指定することで同じname属性を指定したinput[radio]とグループが確立され、適切なキーボード操作を行えるようになります${MESSAGE_UNDEFINED_NAME_PART}`,
68
+ })
69
+ },
70
+ [`${INPUT_ELEMENT}${notHasSpreadAttribute} ${NAME_ATTRIBUTE}:not([value.value=${INPUT_NAME_REGEX}])`]: (node) => {
71
+ context.report({
72
+ node,
73
+ message: `${node.parent.name.name} のname属性の値(${node.value.value})はブラウザの自動補完が適切に行えない可能性があるため${MESSAGE_PART_FORMAT}`,
74
+ })
111
75
  },
112
76
  }
113
77
  },
@@ -77,8 +77,11 @@ module.exports = {
77
77
  for (const i of node.attributes) {
78
78
  if (i.name) {
79
79
  // HINT: idが設定されている場合、htmlForでlabelと紐づく可能性が高いため無視する
80
+ // aria-label, aria-labelledbyが設定されている場合は疑似ラベルが設定されているため許容する
80
81
  switch (i.name.name) {
81
82
  case 'id':
83
+ case 'aria-label':
84
+ case 'aria-labelledby':
82
85
  isPseudoLabel = true
83
86
  break
84
87
  case 'type':
@@ -1,9 +1,5 @@
1
- const INPUT_COMPONENT_NAMES = /((I|^i)nput|(T|^t)extarea)$/
2
-
3
1
  const SCHEMA = []
4
2
 
5
- const checkHasMaxLength = (attr) => attr.name?.name === 'maxLength'
6
-
7
3
  /**
8
4
  * @type {import('@typescript-eslint/utils').TSESLint.RuleModule<''>}
9
5
  */
@@ -14,17 +10,15 @@ module.exports = {
14
10
  },
15
11
  create(context) {
16
12
  return {
17
- JSXOpeningElement: (node) => {
18
- if (node.name.type === 'JSXIdentifier' && INPUT_COMPONENT_NAMES.test(node.name.name) && node.attributes.find(checkHasMaxLength)) {
19
- context.report({
20
- node,
21
- message: `${node.name.name}にmaxLength属性を設定しないでください。
13
+ [`JSXOpeningElement[name.name=/((I|^i)nput|(T|^t)extarea)$/]:has(JSXAttribute[name.name="maxLength"])`]: (node) => {
14
+ context.report({
15
+ node,
16
+ message: `${node.name.name}にmaxLength属性を設定しないでください。
22
17
  - maxLength属性がついた要素に、テキストをペーストすると、maxLength属性の値を超えた範囲が意図せず切り捨てられてしまう場合があります
23
18
  - 以下のいずれかの方法で修正をおこなってください
24
19
  - 方法1: pattern属性とtitle属性を組み合わせ、form要素でラップする
25
20
  - 方法2: JavaScriptを用いたバリデーションを実装する`,
26
- })
27
- }
21
+ })
28
22
  },
29
23
  }
30
24
  },
@@ -1,6 +1,7 @@
1
- const INPUT_TAG_REGEX = /((I|^i)nput|(T|^t)extarea|FieldSet|Combo(B|b)ox|(Date|Wareki|Time)Picker)$/
2
- const SEARCH_INPUT_REGEX = /SearchInput$/
3
- const COMBOBOX_REGEX = /Combo(B|b)ox$/
1
+ const PLACEHOLDER_ATTRIBUTE = 'JSXAttribute[name.name="placeholder"]'
2
+ const COMBOBOX_ELEMENT = 'JSXOpeningElement[name.name=/Combo(B|b)ox$/]'
3
+ const DEFAULT_ITEM_ATTRIBUTE = 'JSXAttribute[name.name="defaultItem"]'
4
+ const SEARCH_INPUT_NAME = '[name.name=/SearchInput$/]'
4
5
 
5
6
  /**
6
7
  * @type {import('@typescript-eslint/utils').TSESLint.RuleModule<''>}
@@ -12,59 +13,37 @@ module.exports = {
12
13
  },
13
14
  create(context) {
14
15
  return {
15
- JSXOpeningElement: (node) => {
16
- const name = node.name.name
17
-
18
- if (name && INPUT_TAG_REGEX.test(name)) {
19
- const placeholder = node.attributes.find((a) => a.name?.name === 'placeholder')
20
-
21
- if (placeholder) {
22
- if (SEARCH_INPUT_REGEX.test(name)) {
23
- const tooltipMessage = node.attributes.find((a) => a.name?.name === 'tooltipMessage')
24
-
25
- if (!tooltipMessage) {
26
- context.report({
27
- node: placeholder,
28
- message: `${name} にはplaceholder属性を単独で利用せず、tooltipMessageオプションのみ、もしくはplaceholderとtooltipMessageの併用を検討してください。 (例: '<${name} tooltipMessage="ヒント" />', '<${name} tooltipMessage={hint} placeholder={hint} />')`,
29
- })
30
- }
31
- } else if (COMBOBOX_REGEX.test(name)) {
32
- let defaultItem
33
- let dropdownHelpMessage
34
-
35
- node.attributes.forEach((a) => {
36
- switch(a.name?.name) {
37
- case 'defaultItem':
38
- defaultItem = a
39
- break
40
- case 'dropdownHelpMessage':
41
- dropdownHelpMessage = a
42
- break
43
- }
44
- })
16
+ [`${COMBOBOX_ELEMENT}:has(${DEFAULT_ITEM_ATTRIBUTE}) ${PLACEHOLDER_ATTRIBUTE}`]: (node) => {
17
+ context.report({
18
+ node,
19
+ message: `${node.parent.name.name} にはdefaultItemが設定されているため、placeholder属性を閲覧出来ません。削除してください。`,
20
+ })
21
+ },
22
+ [`${COMBOBOX_ELEMENT}:not(:has(${DEFAULT_ITEM_ATTRIBUTE}, JSXAttribute[name.name="dropdownHelpMessage"])) ${PLACEHOLDER_ATTRIBUTE}`]: (node) => {
23
+ const name = node.parent.name.name
45
24
 
46
- if (defaultItem) {
47
- context.report({
48
- node: placeholder,
49
- message: `${name} にはdefaultItemが設定されているため、placeholder属性を閲覧出来ません。削除してください。`,
50
- })
51
- } else if (!dropdownHelpMessage) {
52
- context.report({
53
- node: placeholder,
54
- message: `${name} にはplaceholder属性は設定せず、以下のいずれか、もしくは組み合わせての対応を検討してください。
25
+ context.report({
26
+ node,
27
+ message: `${name} にはplaceholder属性は設定せず、以下のいずれか、もしくは組み合わせての対応を検討してください。
55
28
  - 選択肢をどんな値で絞り込めるかの説明をしたい場合は dropdownHelpMessage 属性に変更してください。
56
29
  - 空の値の説明のためにplaceholderを利用している場合は defaultItem 属性に変更してください。
57
30
  - 上記以外の説明を行いたい場合、ヒント用要素を設置してください。(例: '<div><${name} /><Hint>ヒント</Hint></div>')`,
58
- })
59
- }
60
- } else {
61
- context.report({
62
- node: placeholder,
63
- message: `${name} にはplaceholder属性は設定せず、別途ヒント用要素の利用を検討してください。(例: '<div><${name} /><Hint>ヒント</Hint></div>')`,
64
- })
65
- }
66
- }
67
- }
31
+ })
32
+ },
33
+ [`JSXOpeningElement${SEARCH_INPUT_NAME}:not(:has(JSXAttribute[name.name="tooltipMessage"])) ${PLACEHOLDER_ATTRIBUTE}`]: (node) => {
34
+ const inputName = node.parent.name.name
35
+ context.report({
36
+ node,
37
+ message: `${inputName} にはplaceholder属性を単独で利用せず、tooltipMessageオプションのみ、もしくはplaceholderとtooltipMessageの併用を検討してください。 (例: '<${inputName} tooltipMessage="ヒント" />', '<${inputName} tooltipMessage={hint} placeholder={hint} />')`,
38
+ })
39
+ },
40
+ [`JSXOpeningElement[name.name=/((I|^i)nput|(T|^t)extarea|FieldSet|(Date|Wareki|Time)Picker)$/]:not(${SEARCH_INPUT_NAME}) ${PLACEHOLDER_ATTRIBUTE}`]: (node) => {
41
+ const name = node.parent.name.name
42
+
43
+ context.report({
44
+ node,
45
+ message: `${name} にはplaceholder属性は設定せず、別途ヒント用要素の利用を検討してください。(例: '<div><${name} /><Hint>ヒント</Hint></div>')`,
46
+ })
68
47
  },
69
48
  }
70
49
  },
@@ -142,10 +142,6 @@ ruleTester.run('a11y-clickable-element-has-text', rule, {
142
142
  code: `<button><SmartHRLogoSuffix /></button>`,
143
143
  errors: [{ message: defaultErrorMessage }]
144
144
  },
145
- {
146
- code: `<a><div role="article" aria-label="hoge" /></a>`,
147
- errors: [{ message: defaultErrorMessage }]
148
- },
149
145
  {
150
146
  code: `<a><TextWithHoge /></a>`,
151
147
  errors: [{ message: defaultErrorMessage }]
@@ -33,7 +33,6 @@ ruleTester.run('a11y-image-has-alt-attribute', rule, {
33
33
  invalid: [
34
34
  { code: '<img />', errors: [ { message: messageNotExistAlt } ] },
35
35
  { code: '<HogeImage alt="" />', errors: [ { message: messageNullAlt } ] },
36
- { code: '<hoge><image /></hoge>', errors: [ { message: messageNotExistAlt } ] },
37
36
  { code: '<AnyImg {...hoge} />', errors: [ { message: messageNotExistAlt } ] },
38
37
  { code: '<AnyImg {...hoge} />', options: [{ checkType: 'always' }], errors: [ { message: messageNotExistAlt } ] },
39
38
  ]
@@ -111,6 +111,8 @@ ruleTester.run('a11y-input-in-form-control', rule, {
111
111
  { code: '<Fieldset><HogeCheckBoxs /></Fieldset>' },
112
112
  { code: '<Fieldset><HogeCheckBoxes /></Fieldset>' },
113
113
  { code: '<HogeFormControl>{ dateInput ? <DateInput /> : <Input /> }</HogeFormControl>'},
114
+ { code: '<Input aria-label="hoge" />' },
115
+ { code: '<Select aria-labelledby="hoge" />' },
114
116
  ],
115
117
  invalid: [
116
118
  { code: `<input />`, errors: [ { message: noLabeledInput('input') } ] },
@@ -1,38 +0,0 @@
1
- # smarthr/a11y-replace-unreadable-symbol
2
-
3
- - 一部記号はスクリーンリーダーで読み上げられない、もしくは記号名そのままで読み上げられてしまい、意図が正しく伝えられない場合があります
4
- - それらの記号を適切に読み上げられるコンポーネントに置き換えることを促すルールです
5
-
6
- ## rules
7
-
8
- ```js
9
- {
10
- rules: {
11
- 'smarthr/a11y-replace-unreadable-symbol': 'error', // 'warn', 'off',
12
- },
13
- }
14
- ```
15
-
16
- ## ❌ Incorrect
17
-
18
- ```jsx
19
- <>XXXX年YY月ZZ日 〜 XXXX年YY月ZZ日</>
20
- // スクリーンリーダーは "XXXX年YY月ZZ日XXXX年YY月ZZ日" と読み上げる場合があります
21
-
22
- <p>選択できる数値の範囲は 0 ~ 9999 です</p>
23
- // スクリーンリーダーは "選択できる数値の範囲は09999です" と読み上げる場合があります
24
- ```
25
-
26
- ## ✅ Correct
27
-
28
- ```jsx
29
- //
30
- <>XXXX年YY月ZZ日 <RangeSeparator /> XXXX年YY月ZZ日</>
31
- // スクリーンリーダーは "XXXX年YY月ZZ日からXXXX年YY月ZZ日" と読み上げます
32
-
33
- <p>選択できる数値の範囲は 0 <RangeSeparator /> 9999 です</p>
34
- // スクリーンリーダーは "選択できる数値の範囲は0から9999です" と読み上げます
35
-
36
- <p>入力できる記号は <RangeSeparator decorators={{ text: '~', visuallyHiddenText: '半角チルダ' }} /> です</p>
37
- // スクリーンリーダーは "入力できる記号は半角チルダです" と読み上げます
38
- ```
@@ -1,31 +0,0 @@
1
- const TILDE_REGEX = /([~〜])/
2
-
3
- const SCHEMA = []
4
-
5
- /**
6
- * @type {import('@typescript-eslint/utils').TSESLint.RuleModule<''>}
7
- */
8
- module.exports = {
9
- meta: {
10
- type: 'problem',
11
- schema: SCHEMA,
12
- },
13
- create(context) {
14
- return {
15
- JSXText: (node) => {
16
- const matcher = node.value.match(TILDE_REGEX)
17
-
18
- if (matcher) {
19
- context.report({
20
- node,
21
- message: `"${matcher[1]}"はスクリーンリーダーが正しく読み上げることができない場合があるため、smarthr-ui/RangeSeparatorに置き換えてください。
22
- - エラー表示されている行に"${matcher[1]}"が存在しない場合、改行文字を含む関係で行番号がずれている場合があります。数行下の範囲を確認してください
23
- - smarthr-ui/RangeSeparatorに置き換えることでスクリーンリーダーが "から" と読み上げることができます
24
- - 前後の文脈などで "から" と読まれることが不適切な場合 \`<RangeSeparator decorators={{ visuallyHiddenText: () => "ANY" }} />\` のようにdecoratorsを指定してください`,
25
- })
26
- }
27
- },
28
- }
29
- },
30
- }
31
- module.exports.schema = SCHEMA
@@ -1,35 +0,0 @@
1
- const rule = require('../rules/a11y-replace-unreadable-symbol')
2
- const RuleTester = require('eslint').RuleTester
3
-
4
- const generateErrorText = (symbol, replaced, read) => `"${symbol}"はスクリーンリーダーが正しく読み上げることができない場合があるため、smarthr-ui/${replaced}に置き換えてください。
5
- - エラー表示されている行に"${symbol}"が存在しない場合、改行文字を含む関係で行番号がずれている場合があります。数行下の範囲を確認してください
6
- - smarthr-ui/${replaced}に置き換えることでスクリーンリーダーが "${read}" と読み上げることができます
7
- - 前後の文脈などで "${read}" と読まれることが不適切な場合 \`<RangeSeparator decorators={{ visuallyHiddenText: () => "ANY" }} />\` のようにdecoratorsを指定してください`
8
-
9
- const ruleTester = new RuleTester({
10
- languageOptions: {
11
- parserOptions: {
12
- ecmaFeatures: {
13
- jsx: true,
14
- },
15
- },
16
- },
17
- })
18
-
19
- ruleTester.run('a11y-replace-unreadable-symbol', rule, {
20
- valid: [
21
- { code: `<>ほげふが</>` },
22
- { code: `<RangeSeparator />` },
23
- { code: `<p>ほげ<RangeSeparator />ふが</p>` },
24
- { code: `<p>
25
- ほげ
26
- <RangeSeparator />
27
- ふが
28
- </p>` },
29
- ],
30
- invalid: [
31
- { code: `<>~</>`, errors: [ { message: generateErrorText('~', 'RangeSeparator', 'から') } ] },
32
- { code: `<>ほげ~ふが</>`, errors: [ { message: generateErrorText('~', 'RangeSeparator', 'から') } ] },
33
- { code: `<p>ほげ〜ふが</p>`, errors: [ { message: generateErrorText('〜', 'RangeSeparator', 'から') } ] },
34
- ]
35
- })