eslint-plugin-smarthr 2.2.1 → 2.4.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,26 @@
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.4.0](https://github.com/kufu/tamatebako/compare/eslint-plugin-smarthr-v2.3.0...eslint-plugin-smarthr-v2.4.0) (2025-12-02)
6
+
7
+
8
+ ### Features
9
+
10
+ * Modalというコンポーネントの作成を禁止し、Dialogという名称に統一させる ([#915](https://github.com/kufu/tamatebako/issues/915)) ([208c2a0](https://github.com/kufu/tamatebako/commit/208c2a06034bcf24bfe1a3dd0d8633b0c546db11))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * a11y-prohibit-checkbox-or-radio-in-table-cellのautofixを削除 & childrenを持つ場合に警告されないよう修正 ([#914](https://github.com/kufu/tamatebako/issues/914)) ([3962fda](https://github.com/kufu/tamatebako/commit/3962fda01a1966c9624d5cd7056d807f86ccb5bc))
16
+ * trim-props でTemplateLiteralがネストしている場合のチェックを修正 ([#912](https://github.com/kufu/tamatebako/issues/912)) ([2d090c4](https://github.com/kufu/tamatebako/commit/2d090c4ef6d1ac4c9537fdb13a0198552456c10c))
17
+
18
+ ## [2.3.0](https://github.com/kufu/tamatebako/compare/eslint-plugin-smarthr-v2.2.1...eslint-plugin-smarthr-v2.3.0) (2025-12-01)
19
+
20
+
21
+ ### Features
22
+
23
+ * best-practice-for-layout にHeading, FormControl, Fieldsetのタイトル部分でLayout系コンポーネントの仕様を制限するチェックを追加 ([#906](https://github.com/kufu/tamatebako/issues/906)) ([631aeca](https://github.com/kufu/tamatebako/commit/631aecaa20c834b6d603e31af95f2aaee9694bd0))
24
+
5
25
  ## [2.2.1](https://github.com/kufu/tamatebako/compare/eslint-plugin-smarthr-v2.2.0...eslint-plugin-smarthr-v2.2.1) (2025-11-18)
6
26
 
7
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-smarthr",
3
- "version": "2.2.1",
3
+ "version": "2.4.0",
4
4
  "author": "SmartHR",
5
5
  "license": "MIT",
6
6
  "description": "A sharable ESLint plugin for SmartHR",
@@ -37,5 +37,5 @@
37
37
  "eslintplugin",
38
38
  "smarthr"
39
39
  ],
40
- "gitHead": "a730418d8a507045795ce6140a58b5aea18c818b"
40
+ "gitHead": "6b27e405cda722121437718a67b5886cb13ed534"
41
41
  }
@@ -1,12 +1,3 @@
1
- const findClosestThFromAncestor = (node) => {
2
- if (node.type === 'JSXElement' && node.openingElement.name.name === 'Th') {
3
- return node
4
- }
5
- if (node.parent) {
6
- return findClosestThFromAncestor(node.parent)
7
- }
8
- }
9
-
10
1
  /**
11
2
  * @type {import('@typescript-eslint/utils').TSESLint.RuleModule<''>}
12
3
  */
@@ -20,10 +11,8 @@ module.exports = {
20
11
  },
21
12
  },
22
13
  create(context) {
23
- const sourceCode = context.sourceCode
24
-
25
14
  return {
26
- 'JSXElement[openingElement.name.name=/Td$/] JSXElement[openingElement.name.name=/Check(b|B)ox$/]': (node) => {
15
+ 'JSXElement[openingElement.name.name=/Td$/] JSXElement[openingElement.name.name=/Check(b|B)ox$/][children.length=0]': (node) => {
27
16
  context.report({
28
17
  node,
29
18
  messageId: 'default',
@@ -34,7 +23,7 @@ module.exports = {
34
23
  },
35
24
  })
36
25
  },
37
- 'JSXElement[openingElement.name.name=/Td$/] JSXElement[openingElement.name.name=/RadioButton$/]': (node) => {
26
+ 'JSXElement[openingElement.name.name=/Td$/] JSXElement[openingElement.name.name=/RadioButton$/][children.length=0]': (node) => {
38
27
  context.report({
39
28
  node,
40
29
  messageId: 'default',
@@ -45,7 +34,7 @@ module.exports = {
45
34
  },
46
35
  })
47
36
  },
48
- 'JSXElement[openingElement.name.name=/Th$/] JSXElement[openingElement.name.name=/Check(b|B)ox$/]': (node) => {
37
+ 'JSXElement[openingElement.name.name=/Th$/] JSXElement[openingElement.name.name=/Check(b|B)ox$/][children.length=0]': (node) => {
49
38
  context.report({
50
39
  node,
51
40
  messageId: 'default',
@@ -54,14 +43,6 @@ module.exports = {
54
43
  component: 'Checkbox',
55
44
  preferred: 'ThCheckbox',
56
45
  },
57
- *fix(fixer) {
58
- const th = findClosestThFromAncestor(node)
59
- if (th) {
60
- const thCheckbox = sourceCode.getText(node).replace(/<Check(b|B)ox/, '<ThCheckbox')
61
- yield fixer.insertTextAfter(th, thCheckbox)
62
- yield fixer.remove(th)
63
- }
64
- },
65
46
  })
66
47
  },
67
48
  }
@@ -15,13 +15,28 @@
15
15
  ## ❌ Incorrect
16
16
 
17
17
  ```jsx
18
- // 子が複数無いためエラー
18
+ // Cluster, Stackは子要素が複数存在する場合に利用するべきもののため
19
+ // 要素が単一の場合エラーになる
19
20
  <Cluster>
20
- <Any />
21
+ <div>
22
+ hoge
23
+ </div>
21
24
  </Cluster>
22
25
  <StyledStack>
23
- {flg ? 'a' : 'b'}
26
+ <Any />
24
27
  </StyledStack>
28
+
29
+ // Heading, FormControlのlabel, Fieldsetのlegendにsmarthr-ui/Layoutsに属するコンポーネントを設置するとエラー
30
+ <Heading><Cluster><AnyIcon /><Text /></Cluster></Heading>
31
+ <FormControl label={{
32
+ text: <Text prefixIcon={<AnyIcon />}>hoge</Cluster>
33
+ }} />
34
+ <Fieldset legend={
35
+ <Stack>
36
+ <Text />
37
+ <SubText />
38
+ </Stack>
39
+ } />
25
40
  ```
26
41
 
27
42
  ## ✅ Correct
@@ -50,4 +65,33 @@
50
65
  <Any />
51
66
  <Any />
52
67
  </Cluster>
68
+
69
+ // Heading, FormControlのlabel, FieldsetのlegendにIconを設定したい場合はicon属性を利用する
70
+ <Heading icon={<AnyIcon />}><Text /></Heading>
71
+ <FormControl label={{
72
+ text: <Text />,
73
+ icon: <AnyIcon />,
74
+ }} />
75
+ // Stackはas="span"、もしくはforwardedAs="span"を指定すれば利用できる
76
+ <Fieldset legend={
77
+ <Stack as="span">
78
+ <Text />
79
+ <SubText />
80
+ </Stack>
81
+ } />
82
+ <FormControl label={{
83
+ text: (
84
+ <AnyStack forwardedAs="span">
85
+ <Text />
86
+ <SubText />
87
+ </AnyStack>
88
+ ),
89
+ }} />
90
+
91
+ // FormControl、Fieldsetで見出しの右側の領域に要素を設置する場合、statusLabels, subActionAreaを利用する
92
+ <FormControl
93
+ label={<Text />}
94
+ statusLabels={<RequiredLabel />}
95
+ subActionArea={<HelpLink />}
96
+ />
53
97
  ```
@@ -1,8 +1,16 @@
1
1
  const MULTI_CHILDREN_REGEX = /(Cluster|Stack)$/
2
-
3
2
  const REGEX_NLSP = /^\s*\n+\s*$/
4
3
  const FLEX_END_REGEX = /^(flex-)?end$/
5
4
 
5
+ const TARGET_INVALID_COMPONENT_REGEX = /(Center|Cluster|Container|Reel|Sidebar)$/
6
+ const INVALID_ELEMENT = `JSXOpeningElement[name.name=${TARGET_INVALID_COMPONENT_REGEX}]`
7
+ const HEADING_ELEMENT = 'JSXElement[openingElement.name.name=/Heading$/]'
8
+ const STACK_ELEMENT_NOT_SPAN = 'JSXOpeningElement[name.name=/Stack$/]:not(:has(JSXAttribute[name.name=/^(as|forwardedAs)$/][value.value="span"]))'
9
+ const FORM_CONTROL_LABEL_ATTRIBUTE = 'JSXOpeningElement[name.name=/FormControl$/] JSXAttribute[name.name="label"]'
10
+ const FIELDSET_LEGEND_ATTRIBUTE = 'JSXOpeningElement[name.name=/Fieldset$/] JSXAttribute[name.name="legend"]'
11
+ const ICON_ELEMENT_WITH_TEXT = `JSXOpeningElement[name.name=/Icon$/]:has(JSXAttribute[name.name="text"])`
12
+ const TEXT_ELEMENT_WITH_PREFIX = 'JSXOpeningElement[name.name=/Text$/]:has(JSXAttribute[name.name="prefixIcon"])'
13
+
6
14
  const filterFalsyJSXText = (cs) => cs.filter(checkFalsyJSXText)
7
15
  const checkFalsyJSXText = (c) => (
8
16
  !(
@@ -55,39 +63,35 @@ module.exports = {
55
63
  },
56
64
  create(context) {
57
65
  return {
58
- JSXOpeningElement: (node) => {
66
+ [`JSXOpeningElement[selfClosing=false][name.name=${MULTI_CHILDREN_REGEX}]`]: (node) => {
59
67
  const nodeName = node.name.name;
68
+ const matcher = nodeName.match(MULTI_CHILDREN_REGEX)
69
+ const layoutType = matcher[1]
70
+ let justifyAttr = null
71
+ let alignAttr = null
72
+ let gapAttr = null
60
73
 
61
- if (nodeName && !node.selfClosing) {
62
- const matcher = nodeName.match(MULTI_CHILDREN_REGEX)
63
-
64
- if (matcher) {
65
- const layoutType = matcher[1]
66
- let justifyAttr = null
67
- let alignAttr = null
68
- let gapAttr = null
69
-
70
- node.attributes.forEach((a) => {
71
- switch (a.name?.name) {
72
- case 'justify':
73
- justifyAttr = a
74
- break
75
- case 'align':
76
- alignAttr = a
77
- break
78
- case 'gap':
79
- gapAttr = a
80
- break
81
- }
82
- })
74
+ node.attributes.forEach((a) => {
75
+ switch (a.name?.name) {
76
+ case 'justify':
77
+ justifyAttr = a
78
+ break
79
+ case 'align':
80
+ alignAttr = a
81
+ break
82
+ case 'gap':
83
+ gapAttr = a
84
+ break
85
+ }
86
+ })
83
87
 
84
- if (layoutType === 'Stack') {
85
- if (alignAttr && FLEX_END_REGEX.test(alignAttr.value.value)) {
86
- return
87
- } else if (gapAttr?.value.type === 'JSXExpressionContainer' && gapAttr.value.expression.value === 0) {
88
- context.report({
89
- node,
90
- message: `${nodeName} に "gap={0}" が指定されており、smarthr-ui/${layoutType} の利用方法として誤っている可能性があります。以下の修正方法を検討してください。
88
+ if (layoutType === 'Stack') {
89
+ if (alignAttr && FLEX_END_REGEX.test(alignAttr.value.value)) {
90
+ return
91
+ } else if (gapAttr?.value.type === 'JSXExpressionContainer' && gapAttr.value.expression.value === 0) {
92
+ context.report({
93
+ node,
94
+ message: `${nodeName} に "gap={0}" が指定されており、smarthr-ui/${layoutType} の利用方法として誤っている可能性があります。以下の修正方法を検討してください。
91
95
  - 方法1: 子要素を一つにまとめられないか検討してください
92
96
  - 例: "<Stack gap={0}><p>hoge</p><p>fuga</p></Stack>" を "<p>hoge<br />fuga</p>" にするなど
93
97
  - 方法2: 子要素のstyleを確認しgap属性を0以外にできないか検討してください
@@ -95,32 +99,86 @@ module.exports = {
95
99
  - 方法3: 別要素でマークアップし直すか、${nodeName}を削除してください
96
100
  - 親要素に smarthr-ui/Cluster, smarthr-ui/Stack などが存在している場合、div・spanなどで1要素にまとめる必要がある場合があります
97
101
  - as, forwardedAsなどでSectioningContent系要素に変更している場合、対応するsmarthr-ui/Section, Aside, Nav, Article のいずれかに差し替えてください`
98
- })
99
- }
100
- }
102
+ })
103
+ }
104
+ }
101
105
 
102
- const children = node.parent.children.filter(checkFalsyJSXText)
106
+ const children = node.parent.children.filter(checkFalsyJSXText)
103
107
 
104
- if (children.length === 1) {
105
- if (justifyAttr && FLEX_END_REGEX.test(justifyAttr.value.value)) {
106
- return
107
- }
108
+ if (children.length === 1) {
109
+ if (justifyAttr && FLEX_END_REGEX.test(justifyAttr.value.value)) {
110
+ return
111
+ }
108
112
 
109
- if (searchChildren(children[0])) {
110
- context.report({
111
- node,
112
- message:
113
- (justifyAttr?.value.value === 'center' || alignAttr?.value.value === 'center')
114
- ? `${nodeName} は smarthr-ui/${layoutType} ではなく smarthr-ui/Center でマークアップしてください`
115
- : `${nodeName}には子要素が一つしか無いため、${layoutType}でマークアップする意味がありません。
113
+ if (searchChildren(children[0])) {
114
+ context.report({
115
+ node,
116
+ message:
117
+ (justifyAttr?.value.value === 'center' || alignAttr?.value.value === 'center')
118
+ ? `${nodeName} は smarthr-ui/${layoutType} ではなく smarthr-ui/Center でマークアップしてください`
119
+ : `${nodeName}には子要素が一つしか無いため、${layoutType}でマークアップする意味がありません。
116
120
  - styleを確認し、div・spanなど、別要素でマークアップし直すか、${nodeName}を削除してください
117
121
  - as, forwardedAsなどでSectioningContent系要素に変更している場合、対応するsmarthr-ui/Section, Aside, Nav, Article のいずれかに差し替えてください`
118
- })
119
- }
120
- }
122
+ })
121
123
  }
122
124
  }
123
125
  },
126
+ [`${HEADING_ELEMENT} ${INVALID_ELEMENT}`]: (node) => {
127
+ const component = node.name.name.match(TARGET_INVALID_COMPONENT_REGEX)[1]
128
+
129
+ context.report({
130
+ node,
131
+ message: `Headingの子孫に${component}を置くことはできません。Headingの外で${component}を使用するようにマークアップを修正してください。`
132
+ })
133
+ },
134
+ [`${HEADING_ELEMENT} ${STACK_ELEMENT_NOT_SPAN}`]: (node) => {
135
+ context.report({
136
+ node,
137
+ message: 'Headingの子孫にStackを置く場合、as属性、もしくはforwardedAs属性に `span` を指定してください',
138
+ })
139
+ },
140
+ [`${HEADING_ELEMENT} :matches(${ICON_ELEMENT_WITH_TEXT},${TEXT_ELEMENT_WITH_PREFIX})`]: (node) => {
141
+ context.report({
142
+ node,
143
+ message: 'HeadingにIconを設定する場合 <Heading icon={<XxxIcon />}></Heading> のようにicon属性を利用してください',
144
+ })
145
+ },
146
+ [`${FORM_CONTROL_LABEL_ATTRIBUTE} ${INVALID_ELEMENT}`]: (node) => {
147
+ context.report({
148
+ node,
149
+ message: `FormControlのlabel属性に${node.name.name.match(TARGET_INVALID_COMPONENT_REGEX)[1]}を置くことはできません。ラベル用テキスト以外をstatusLabels、subActionArea、もしくはlabel属性のObjectとして '{ text: テキスト, icon: <XxxIcon /> }'に置き換えてください。`,
150
+ })
151
+ },
152
+ [`${FORM_CONTROL_LABEL_ATTRIBUTE} ${STACK_ELEMENT_NOT_SPAN}`]: (node) => {
153
+ context.report({
154
+ node,
155
+ message: 'FormControlのlabel属性にStackを置く場合、as属性、もしくはforwardedAs属性に `span` を指定してください',
156
+ })
157
+ },
158
+ [`${FORM_CONTROL_LABEL_ATTRIBUTE} :matches(${ICON_ELEMENT_WITH_TEXT},${TEXT_ELEMENT_WITH_PREFIX})`]: (node) => {
159
+ context.report({
160
+ node,
161
+ message: "FormControlのlabel属性にアイコンを設定する場合 <FormControl label={{ text: 'テキスト', icon: <XxxIcon /> }} /> のようにlabel.icon属性を利用してください",
162
+ })
163
+ },
164
+ [`${FIELDSET_LEGEND_ATTRIBUTE} ${INVALID_ELEMENT}`]: (node) => {
165
+ context.report({
166
+ node,
167
+ message: `Fieldsetのlegend属性に${node.name.name.match(TARGET_INVALID_COMPONENT_REGEX)[1]}を置くことはできません。ラベル用テキスト以外をstatusLabels、subActionArea、もしくはlabel属性のObjectとして '{ text: テキスト, icon: <XxxIcon /> }'に置き換えてください。`,
168
+ })
169
+ },
170
+ [`${FIELDSET_LEGEND_ATTRIBUTE} ${STACK_ELEMENT_NOT_SPAN}`]: (node) => {
171
+ context.report({
172
+ node,
173
+ message: 'Fieldsetのlegend属性にStackを置く場合、as属性、もしくはforwardedAs属性に `span` を指定してください',
174
+ })
175
+ },
176
+ [`${FIELDSET_LEGEND_ATTRIBUTE} :matches(${ICON_ELEMENT_WITH_TEXT},${TEXT_ELEMENT_WITH_PREFIX})`]: (node) => {
177
+ context.report({
178
+ node,
179
+ message: "Fieldsetのlegend属性にアイコンを設定する場合 <Fieldset legend={{ text: 'テキスト', icon: <XxxIcon /> }} /> のようにlegend.icon属性を利用してください",
180
+ })
181
+ },
124
182
  }
125
183
  },
126
184
  }
@@ -1,4 +1,60 @@
1
- const { generateTagFormatter } = require('../../libs/format_styled_components')
1
+ const STYLED_COMPONENTS_METHOD = 'styled'
2
+ const STYLED_COMPONENTS = `${STYLED_COMPONENTS_METHOD}-components`
3
+
4
+ const findInvalidImportNameNode = (s) => s.type === 'ImportDefaultSpecifier' && s.local.name !== STYLED_COMPONENTS_METHOD
5
+
6
+ const checkImportStyledComponents = (node, context) => {
7
+ if (node.source.value !== STYLED_COMPONENTS) {
8
+ return
9
+ }
10
+
11
+ const invalidNameNode = node.specifiers.find(findInvalidImportNameNode)
12
+
13
+ if (invalidNameNode) {
14
+ context.report({
15
+ node: invalidNameNode,
16
+ message: `${STYLED_COMPONENTS} をimportする際は、名称が"${STYLED_COMPONENTS_METHOD}" となるようにしてください。例: "import ${STYLED_COMPONENTS_METHOD} from '${STYLED_COMPONENTS}'"`,
17
+ });
18
+ }
19
+ }
20
+
21
+ const getStyledComponentBaseName = (node) => {
22
+ let base = null
23
+
24
+ if (!node.init) {
25
+ return base
26
+ }
27
+
28
+ const tag = node.init.tag || node.init
29
+
30
+ if (tag.object?.name === STYLED_COMPONENTS_METHOD) {
31
+ base = tag.property.name
32
+ } else if (tag.callee) {
33
+ const callee = tag.callee
34
+
35
+ switch (STYLED_COMPONENTS_METHOD) {
36
+ case callee.name: {
37
+ const arg = tag.arguments[0]
38
+ base = arg.name || arg.value
39
+ break
40
+ }
41
+ case callee.callee?.name: {
42
+ const arg = callee.arguments[0]
43
+ base = arg.name || arg.value
44
+ break
45
+ }
46
+ case callee.object?.name:
47
+ base = callee.property.name
48
+ break
49
+ case callee.object?.callee?.name:
50
+ const arg = callee.object.arguments[0]
51
+ base = arg.name || arg.value
52
+ break
53
+ }
54
+ }
55
+
56
+ return base
57
+ }
2
58
 
3
59
  const EXPECTED_NAMES = {
4
60
  '(A|^a)rticle$': 'Article$',
@@ -134,7 +190,79 @@ module.exports = {
134
190
  schema: SCHEMA,
135
191
  },
136
192
  create(context) {
137
- return generateTagFormatter({ context, EXPECTED_NAMES, UNEXPECTED_NAMES })
193
+ const entriesesTagNames = Object.entries(EXPECTED_NAMES).map(([b, e]) => [ new RegExp(b), new RegExp(e) ])
194
+ const entriesesUnTagNames = UNEXPECTED_NAMES ? Object.entries(UNEXPECTED_NAMES).map(([b, e]) => {
195
+ const [ auctualE, messageTemplate ] = Array.isArray(e) ? e : [e, '']
196
+
197
+ return [ new RegExp(b), new RegExp(auctualE), messageTemplate ]
198
+ }) : []
199
+
200
+
201
+ const checkImportedNameToLocalName = (node, base, extended, isImport) => {
202
+ entriesesTagNames.forEach(([b, e]) => {
203
+ if (base.match(b) && !extended.match(e)) {
204
+ context.report({
205
+ node,
206
+ message: `${extended}を正規表現 "${e.toString()}" がmatchする名称に変更してください。${isImport ? `
207
+ - ${base}が型の場合、'import type { ${base} as ${extended} }' もしくは 'import { type ${base} as ${extended} }' のように明示的に型であることを宣言してください。名称変更が不要になります` : ''}`,
208
+ });
209
+ }
210
+ })
211
+ }
212
+
213
+ return {
214
+ ImportDeclaration: (node) => {
215
+ checkImportStyledComponents(node, context)
216
+
217
+ if (node.importKind !== 'type') {
218
+ node.specifiers.forEach((s) => {
219
+ if (s.importKind !== 'type' && s.imported && s.imported.name !== s.local.name) {
220
+ checkImportedNameToLocalName(node, s.imported.name, s.local.name, true)
221
+ }
222
+ })
223
+ }
224
+ },
225
+ VariableDeclarator: (node) => {
226
+ const base = getStyledComponentBaseName(node)
227
+
228
+ if (base) {
229
+ const extended = node.id.name
230
+
231
+ checkImportedNameToLocalName(node, base, extended)
232
+
233
+ entriesesUnTagNames.forEach(([b, e, m]) => {
234
+ const matcher = extended.match(e)
235
+
236
+ if (matcher && !base.match(b)) {
237
+ const expected = matcher[1]
238
+ const isBareTag = base === base.toLowerCase()
239
+ const sampleFixBase = `styled${isBareTag ? `.${base}` : `(${base})`}`
240
+
241
+ context.report({
242
+ node,
243
+ message: m ? m
244
+ .replaceAll('{{extended}}', extended)
245
+ .replaceAll('{{expected}}', expected) : `${extended} は ${b.toString()} にmatchする名前のコンポーネントを拡張することを期待している名称になっています
246
+ - ${extended} の名称の末尾が"${expected}" という文字列ではない状態にしつつ、"${base}"を継承していることをわかる名称に変更してください
247
+ - もしくは"${base}"を"${extended}"の継承元であることがわかるような${isBareTag ? '適切なタグや別コンポーネントに差し替えてください' : '名称に変更するか、適切な別コンポーネントに差し替えてください'}
248
+ - 修正例1: const ${extended.replace(expected, '')}Xxxx = ${sampleFixBase}
249
+ - 修正例2: const ${extended}Xxxx = ${sampleFixBase}
250
+ - 修正例3: const ${extended} = styled(Xxxx${expected})`
251
+ })
252
+ }
253
+ })
254
+ }
255
+ },
256
+ 'VariableDeclarator[id.name=/Modal/]': (node) => {
257
+ context.report({
258
+ node,
259
+ message: `コンポーネント名や変数名に"Modal"という名称は使わず、"Dialog"に統一してください
260
+ - Modalとは形容詞であり、かつ"現在の操作から切り離して専用の操作を行わせる" という意味合いを持ちます
261
+ - そのためDialogでなければ正しくない場合がありえます(smarthr-ui/ModelessDialogのように元々の操作も行えるDialogなどが該当)
262
+ - DialogはModalなダイアログ、Modelessなダイアログすべてを含有した名称のため、統一することを推奨しています`
263
+ })
264
+ },
265
+ }
138
266
  },
139
267
  }
140
268
  module.exports.schema = SCHEMA
@@ -1,5 +1,17 @@
1
1
  const SCHEMA = []
2
2
 
3
+ const searchBubbleUp = (node) => {
4
+ switch (node.type) {
5
+ case 'Program':
6
+ case 'JSXAttribute':
7
+ return null
8
+ case 'TemplateLiteral':
9
+ return node
10
+ }
11
+
12
+ return searchBubbleUp(node.parent)
13
+ }
14
+
3
15
  /**
4
16
  * @type {import('@typescript-eslint/utils').TSESLint.RuleModule<''>}
5
17
  */
@@ -10,21 +22,20 @@ module.exports = {
10
22
  fixable: 'whitespace',
11
23
  },
12
24
  create(context) {
13
- return {
14
- 'JSXAttribute Literal[value=/(^ | $)/]': (node) => {
25
+ const checker = (node) => {
26
+ // HINT: TemplateLiteralがネストしている場合、親側だけチェックする
27
+ if (!searchBubbleUp(node.parent)) {
15
28
  return context.report({
16
29
  node,
17
30
  message: '属性に設定している文字列から先頭、末尾の空白文字を削除してください',
18
- fix: (fixer) => fixer.replaceText(node, context.sourceCode.getText(node).replace(/^('|")\s+/, '$1').replace(/\s+('|")$/, '$1')),
31
+ fix: (fixer) => fixer.replaceText(node, context.sourceCode.getText(node).replace(/^('|"|`)\s+/, '$1').replace(/\s+('|"|`)$/, '$1')),
19
32
  })
20
- },
21
- 'JSXAttribute TemplateLiteral:has(TemplateElement:matches(:first-child[value.raw=/^ /],:last-child[value.raw=/ $/]))': (node) => {
22
- return context.report({
23
- node,
24
- message: '属性に設定している文字列から先頭、末尾の空白文字を削除してください',
25
- fix: (fixer) => fixer.replaceText(node, context.sourceCode.getText(node).replace(/(^`\s+|\s+`$)/g, '`')),
26
- })
27
- },
33
+ }
34
+ }
35
+
36
+ return {
37
+ 'JSXAttribute Literal[value=/(^ | $)/]': checker,
38
+ 'JSXAttribute TemplateLiteral:has(>TemplateElement:matches(:first-child[value.raw=/^ /],:last-child[value.raw=/ $/]))': checker,
28
39
  }
29
40
  },
30
41
  }
@@ -13,7 +13,20 @@ const ruleTester = new RuleTester({
13
13
  })
14
14
 
15
15
  ruleTester.run('a11y-prohibit-checkbox-or-radio-in-table-cell', rule, {
16
- valid: ['<TdCheckbox />', '<ThCheckbox />', '<TdRadioButton />', '<Td>hello</Td>', '<Th>hello</Th>'],
16
+ valid: [
17
+ '<TdCheckbox />',
18
+ '<ThCheckbox />',
19
+ '<TdRadioButton />',
20
+ '<Td>hello</Td>',
21
+ '<Th>hello</Th>',
22
+ `
23
+ <Td>
24
+ <Checkbox>
25
+ 可視ラベル
26
+ </Checkbox>
27
+ </Td>
28
+ `,
29
+ ],
17
30
  invalid: [
18
31
  {
19
32
  code: `<Td><Checkbox /></Td>`,
@@ -21,12 +34,10 @@ ruleTester.run('a11y-prohibit-checkbox-or-radio-in-table-cell', rule, {
21
34
  },
22
35
  {
23
36
  code: `<Th><Checkbox /></Th>`,
24
- output: `<ThCheckbox />`,
25
37
  errors: [{ message: 'Th の子孫に Checkbox を置くことはできません。代わりに ThCheckbox を使用してください。' }],
26
38
  },
27
39
  {
28
40
  code: `<Th><Checkbox id="my-checkbox" name="agree" error /></Th>`,
29
- output: `<ThCheckbox id="my-checkbox" name="agree" error />`,
30
41
  errors: [{ message: 'Th の子孫に Checkbox を置くことはできません。代わりに ThCheckbox を使用してください。' }],
31
42
  },
32
43
  {
@@ -49,7 +60,6 @@ ruleTester.run('a11y-prohibit-checkbox-or-radio-in-table-cell', rule, {
49
60
  },
50
61
  {
51
62
  code: `<CustomTh><CustomCheckbox /></CustomTh>`,
52
- output: null,
53
63
  errors: [{ message: 'Th の子孫に Checkbox を置くことはできません。代わりに ThCheckbox を使用してください。' }],
54
64
  },
55
65
  {
@@ -58,6 +58,12 @@ ruleTester.run('best-practice-for-button-element', rule, {
58
58
  { code: `<Cluster>{a}</Cluster>` },
59
59
  { code: `<Cluster>{a.b}</Cluster>` },
60
60
  { code: `<Cluster>{a ? b : c}</Cluster>` },
61
+ { code: `<Heading><Stack as="span"><Hoge /><Fuga /></Stack></Heading>` },
62
+ { code: `<AnyHeading icon={<AnyIcon />}>hoge</AnyHeading>` },
63
+ { code: `<FormControl label={<AnyStack as="span"><span>a</span><span>b</span></AnyStack>} />` },
64
+ { code: `<AnyFormControl label={{ text: 'hoge', icon: <AnyIcon /> }} />` },
65
+ { code: `<AnyFieldset legend={{ text: <AnyStack as="span"><span>a</span><span>b</span></AnyStack> }} />` },
66
+ { code: `<Fieldset legend={{ text: 'hoge', icon: <AnyIcon /> }} />` },
61
67
  ],
62
68
  invalid: [
63
69
  { code: `<Stack><Hoge /></Stack>`, errors: [ { message: errorMessage('Stack', 'Stack') } ] },
@@ -90,6 +96,18 @@ ruleTester.run('best-practice-for-button-element', rule, {
90
96
  - 方法3: 別要素でマークアップし直すか、HogeStackを削除してください
91
97
  - 親要素に smarthr-ui/Cluster, smarthr-ui/Stack などが存在している場合、div・spanなどで1要素にまとめる必要がある場合があります
92
98
  - as, forwardedAsなどでSectioningContent系要素に変更している場合、対応するsmarthr-ui/Section, Aside, Nav, Article のいずれかに差し替えてください` } ] },
99
+ { code: `<Heading><Cluster><Hoge /><Fuga /></Cluster></Heading>`, errors: [ { message: `Headingの子孫にClusterを置くことはできません。Headingの外でClusterを使用するようにマークアップを修正してください。` } ] },
100
+ { code: `<AnyHeading><AnyStack><Hoge /><Fuga /></AnyStack></AnyHeading>`, errors: [ { message: `Headingの子孫にStackを置く場合、as属性、もしくはforwardedAs属性に \`span\` を指定してください` } ] },
101
+ { code: `<Heading><AnyIcon text="hoge" /></Heading>`, errors: [ { message: `HeadingにIconを設定する場合 <Heading icon={<XxxIcon />}></Heading> のようにicon属性を利用してください` } ] },
102
+ { code: `<AnyHeading><Text prefixIcon={<SomeIcon />}>hoge</Text></AnyHeading>`, errors: [ { message: `HeadingにIconを設定する場合 <Heading icon={<XxxIcon />}></Heading> のようにicon属性を利用してください` } ] },
103
+ { code: `<AnyFormControl label={{ text: <Cluster><Hoge /><Fuga /></Cluster> }} />`, errors: [ { message: `FormControlのlabel属性にClusterを置くことはできません。ラベル用テキスト以外をstatusLabels、subActionArea、もしくはlabel属性のObjectとして '{ text: テキスト, icon: <XxxIcon /> }'に置き換えてください。` } ] },
104
+ { code: `<FormControl label={<AnyStack><Hoge /><Fuga /></AnyStack>} />`, errors: [ { message: `FormControlのlabel属性にStackを置く場合、as属性、もしくはforwardedAs属性に \`span\` を指定してください` } ] },
105
+ { code: `<AnyFormControl label={{ text: <AnyIcon text="hoge" /> }} />`, errors: [ { message: `FormControlのlabel属性にアイコンを設定する場合 <FormControl label={{ text: 'テキスト', icon: <XxxIcon /> }} /> のようにlabel.icon属性を利用してください` } ] },
106
+ { code: `<FormControl label={{ text: <Text prefixIcon={<SomeIcon /> }>hoge</Text>}} />`, errors: [ { message: `FormControlのlabel属性にアイコンを設定する場合 <FormControl label={{ text: 'テキスト', icon: <XxxIcon /> }} /> のようにlabel.icon属性を利用してください` } ] },
107
+ { code: `<Fieldset legend={<Cluster><Hoge /><Fuga /></Cluster>} />`, errors: [ { message: `Fieldsetのlegend属性にClusterを置くことはできません。ラベル用テキスト以外をstatusLabels、subActionArea、もしくはlabel属性のObjectとして '{ text: テキスト, icon: <XxxIcon /> }'に置き換えてください。` } ] },
108
+ { code: `<AnyFieldset legend={{ text: <AnyStack><Hoge /><Fuga /></AnyStack> }} />`, errors: [ { message: `Fieldsetのlegend属性にStackを置く場合、as属性、もしくはforwardedAs属性に \`span\` を指定してください` } ] },
109
+ { code: `<Fieldset legend={<AnyIcon text="hoge" />} />`, errors: [ { message: `Fieldsetのlegend属性にアイコンを設定する場合 <Fieldset legend={{ text: 'テキスト', icon: <XxxIcon /> }} /> のようにlegend.icon属性を利用してください` } ] },
110
+ { code: `<AnyFieldset legend={{ text: <Text prefixIcon={<SomeIcon />}>hoge</Text>}} />`, errors: [ { message: `Fieldsetのlegend属性にアイコンを設定する場合 <Fieldset legend={{ text: 'テキスト', icon: <XxxIcon /> }} /> のようにlegend.icon属性を利用してください` } ] },
93
111
  ]
94
112
  })
95
113
 
@@ -243,5 +243,9 @@ ruleTester.run('component-name', rule, {
243
243
 
244
244
  { code: 'const Hoge = styled(RemoteDialogTrigger)``', errors: [ { message: messageInheritance({ extended: 'Hoge', matcher: /DialogTrigger$/ }) }, { message: messageInheritance({ extended: 'Hoge', matcher: /RemoteDialogTrigger$/ }) } ] },
245
245
  { code: 'const Fuga = styled(RemoteTriggerActionDialog)``', errors: [ { message: messageInheritance({ extended: 'Fuga', matcher: /RemoteTrigger(.+)Dialog$/ }) } ] },
246
+ { code: 'const HogeModalFuga = any', errors: [ { message: `コンポーネント名や変数名に"Modal"という名称は使わず、"Dialog"に統一してください
247
+ - Modalとは形容詞であり、かつ"現在の操作から切り離して専用の操作を行わせる" という意味合いを持ちます
248
+ - そのためDialogでなければ正しくない場合がありえます(smarthr-ui/ModelessDialogのように元々の操作も行えるDialogなどが該当)
249
+ - DialogはModalなダイアログ、Modelessなダイアログすべてを含有した名称のため、統一することを推奨しています` } ] },
246
250
  ]
247
251
  })
@@ -19,6 +19,7 @@ ruleTester.run('trim-props', rule, {
19
19
  { code: `<img src={'/sample.jpg'} alt={'sample'} />` },
20
20
  { code: '<div data-spec="info-area">....</div>' },
21
21
  { code: '<div data-spec={`a${b} c`}>....</div>' },
22
+ { code: '<div data-spec={`a${b ? ` ${c} ` : " "} d`}>....</div>' },
22
23
  ],
23
24
  invalid: [
24
25
  {
@@ -87,5 +88,10 @@ ruleTester.run('trim-props', rule, {
87
88
  output: '<div data-spec={`a${b} c`}>....</div>',
88
89
  errors: [{ message: '属性に設定している文字列から先頭、末尾の空白文字を削除してください' }],
89
90
  },
91
+ {
92
+ code: '<div data-spec={` a${b ? ` ${c} ` : " "} d `}>....</div>',
93
+ output: '<div data-spec={`a${b ? ` ${c} ` : " "} d`}>....</div>',
94
+ errors: [{ message: '属性に設定している文字列から先頭、末尾の空白文字を削除してください' }],
95
+ },
90
96
  ],
91
97
  })
@@ -1,126 +0,0 @@
1
- const STYLED_COMPONENTS_METHOD = 'styled'
2
- const STYLED_COMPONENTS = `${STYLED_COMPONENTS_METHOD}-components`
3
-
4
- const findInvalidImportNameNode = (s) => s.type === 'ImportDefaultSpecifier' && s.local.name !== STYLED_COMPONENTS_METHOD
5
-
6
- const checkImportStyledComponents = (node, context) => {
7
- if (node.source.value !== STYLED_COMPONENTS) {
8
- return
9
- }
10
-
11
- const invalidNameNode = node.specifiers.find(findInvalidImportNameNode)
12
-
13
- if (invalidNameNode) {
14
- context.report({
15
- node: invalidNameNode,
16
- message: `${STYLED_COMPONENTS} をimportする際は、名称が"${STYLED_COMPONENTS_METHOD}" となるようにしてください。例: "import ${STYLED_COMPONENTS_METHOD} from '${STYLED_COMPONENTS}'"`,
17
- });
18
- }
19
- }
20
-
21
- const getStyledComponentBaseName = (node) => {
22
- let base = null
23
-
24
- if (!node.init) {
25
- return base
26
- }
27
-
28
- const tag = node.init.tag || node.init
29
-
30
- if (tag.object?.name === STYLED_COMPONENTS_METHOD) {
31
- base = tag.property.name
32
- } else if (tag.callee) {
33
- const callee = tag.callee
34
-
35
- switch (STYLED_COMPONENTS_METHOD) {
36
- case callee.name: {
37
- const arg = tag.arguments[0]
38
- base = arg.name || arg.value
39
- break
40
- }
41
- case callee.callee?.name: {
42
- const arg = callee.arguments[0]
43
- base = arg.name || arg.value
44
- break
45
- }
46
- case callee.object?.name:
47
- base = callee.property.name
48
- break
49
- case callee.object?.callee?.name:
50
- const arg = callee.object.arguments[0]
51
- base = arg.name || arg.value
52
- break
53
- }
54
- }
55
-
56
- return base
57
- }
58
-
59
- const generateTagFormatter = ({ context, EXPECTED_NAMES, UNEXPECTED_NAMES }) => {
60
- const entriesesTagNames = Object.entries(EXPECTED_NAMES).map(([b, e]) => [ new RegExp(b), new RegExp(e) ])
61
- const entriesesUnTagNames = UNEXPECTED_NAMES ? Object.entries(UNEXPECTED_NAMES).map(([b, e]) => {
62
- const [ auctualE, messageTemplate ] = Array.isArray(e) ? e : [e, '']
63
-
64
- return [ new RegExp(b), new RegExp(auctualE), messageTemplate ]
65
- }) : []
66
-
67
-
68
- const checkImportedNameToLocalName = (node, base, extended, isImport) => {
69
- entriesesTagNames.forEach(([b, e]) => {
70
- if (base.match(b) && !extended.match(e)) {
71
- context.report({
72
- node,
73
- message: `${extended}を正規表現 "${e.toString()}" がmatchする名称に変更してください。${isImport ? `
74
- - ${base}が型の場合、'import type { ${base} as ${extended} }' もしくは 'import { type ${base} as ${extended} }' のように明示的に型であることを宣言してください。名称変更が不要になります` : ''}`,
75
- });
76
- }
77
- })
78
- }
79
-
80
- return {
81
- ImportDeclaration: (node) => {
82
- checkImportStyledComponents(node, context)
83
-
84
- if (node.importKind !== 'type') {
85
- node.specifiers.forEach((s) => {
86
- if (s.importKind !== 'type' && s.imported && s.imported.name !== s.local.name) {
87
- checkImportedNameToLocalName(node, s.imported.name, s.local.name, true)
88
- }
89
- })
90
- }
91
- },
92
- VariableDeclarator: (node) => {
93
- const base = getStyledComponentBaseName(node)
94
-
95
- if (base) {
96
- const extended = node.id.name
97
-
98
- checkImportedNameToLocalName(node, base, extended)
99
-
100
- entriesesUnTagNames.forEach(([b, e, m]) => {
101
- const matcher = extended.match(e)
102
-
103
- if (matcher && !base.match(b)) {
104
- const expected = matcher[1]
105
- const isBareTag = base === base.toLowerCase()
106
- const sampleFixBase = `styled${isBareTag ? `.${base}` : `(${base})`}`
107
-
108
- context.report({
109
- node,
110
- message: m ? m
111
- .replaceAll('{{extended}}', extended)
112
- .replaceAll('{{expected}}', expected) : `${extended} は ${b.toString()} にmatchする名前のコンポーネントを拡張することを期待している名称になっています
113
- - ${extended} の名称の末尾が"${expected}" という文字列ではない状態にしつつ、"${base}"を継承していることをわかる名称に変更してください
114
- - もしくは"${base}"を"${extended}"の継承元であることがわかるような${isBareTag ? '適切なタグや別コンポーネントに差し替えてください' : '名称に変更するか、適切な別コンポーネントに差し替えてください'}
115
- - 修正例1: const ${extended.replace(expected, '')}Xxxx = ${sampleFixBase}
116
- - 修正例2: const ${extended}Xxxx = ${sampleFixBase}
117
- - 修正例3: const ${extended} = styled(Xxxx${expected})`
118
- })
119
- }
120
- })
121
- }
122
- },
123
- }
124
- }
125
-
126
- module.exports = { generateTagFormatter }