@scality/core-ui 0.203.0 → 0.204.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/.github/workflows/github-pages.yml +4 -4
- package/.github/workflows/post-release.yml +25 -12
- package/__mocks__/fileMock.js +1 -1
- package/__mocks__/styleMock.js +1 -1
- package/__mocks__/uuid.js +1 -5
- package/dist/components/drawer/Drawer.component.d.ts +17 -0
- package/dist/components/drawer/Drawer.component.d.ts.map +1 -0
- package/dist/components/drawer/Drawer.component.js +132 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/next.d.ts +1 -0
- package/dist/next.d.ts.map +1 -1
- package/dist/next.js +1 -0
- package/dist/style/theme.d.ts +1 -0
- package/dist/style/theme.d.ts.map +1 -1
- package/dist/style/theme.js +1 -0
- package/global-setup.js +1 -1
- package/jest.config.js +1 -1
- package/package.json +13 -3
- package/src/lib/components/drawer/Drawer.component.test.tsx +108 -0
- package/src/lib/components/drawer/Drawer.component.tsx +207 -0
- package/src/lib/index.ts +1 -0
- package/src/lib/next.ts +1 -0
- package/src/lib/style/theme.ts +1 -0
- package/src/lib/valalint/README.md +50 -0
- package/src/lib/valalint/index.js +49 -0
- package/src/lib/valalint/rules/modal-button-forbidden-label.js +87 -0
- package/src/lib/valalint/rules/modal-button-forbidden-label.test.js +157 -0
- package/src/lib/valalint/rules/no-raw-number-in-jsx.js +64 -0
- package/src/lib/valalint/rules/no-raw-number-in-jsx.test.js +237 -0
- package/src/lib/valalint/rules/technical-sentence-case.js +93 -0
- package/src/lib/valalint/rules/technical-sentence-case.test.js +167 -0
- package/stories/drawer.stories.tsx +135 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { CSSProperties, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import styled from 'styled-components';
|
|
4
|
+
import { spacing } from '../../spacing';
|
|
5
|
+
import { zIndex } from '../../style/theme';
|
|
6
|
+
import { getThemePropSelector } from '../../utils';
|
|
7
|
+
import { Button } from '../buttonv2/Buttonv2.component';
|
|
8
|
+
import { Icon } from '../icon/Icon.component';
|
|
9
|
+
import { Text } from '../text/Text.component';
|
|
10
|
+
|
|
11
|
+
type DrawerPosition = 'left' | 'right' | 'top' | 'bottom';
|
|
12
|
+
|
|
13
|
+
type Props = {
|
|
14
|
+
isOpen: boolean;
|
|
15
|
+
close: () => void;
|
|
16
|
+
title: ReactNode;
|
|
17
|
+
position?: DrawerPosition;
|
|
18
|
+
size?: CSSProperties['width'];
|
|
19
|
+
footer?: ReactNode;
|
|
20
|
+
overlay?: boolean;
|
|
21
|
+
showCloseButton?: boolean;
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const TRANSITION_DURATION = '250ms';
|
|
26
|
+
|
|
27
|
+
const DrawerBackdrop = styled.div<{ $overlay: boolean }>`
|
|
28
|
+
position: fixed;
|
|
29
|
+
inset: 0;
|
|
30
|
+
z-index: ${zIndex.overlay};
|
|
31
|
+
background: ${({ $overlay }) => ($overlay ? 'rgba(0, 0, 0, 0.3)' : 'transparent')};
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
function getTransform(position: DrawerPosition, isOpen: boolean): string {
|
|
35
|
+
if (isOpen) return 'translate3d(0, 0, 0)';
|
|
36
|
+
switch (position) {
|
|
37
|
+
case 'left':
|
|
38
|
+
return 'translate3d(-100%, 0, 0)';
|
|
39
|
+
case 'right':
|
|
40
|
+
return 'translate3d(100%, 0, 0)';
|
|
41
|
+
case 'top':
|
|
42
|
+
return 'translate3d(0, -100%, 0)';
|
|
43
|
+
case 'bottom':
|
|
44
|
+
return 'translate3d(0, 100%, 0)';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DrawerPanel = styled.div<{
|
|
49
|
+
$position: DrawerPosition;
|
|
50
|
+
$size: CSSProperties['width'];
|
|
51
|
+
$isOpen: boolean;
|
|
52
|
+
}>`
|
|
53
|
+
position: fixed;
|
|
54
|
+
z-index: ${zIndex.drawer};
|
|
55
|
+
background-color: ${getThemePropSelector('backgroundLevel2')};
|
|
56
|
+
color: ${getThemePropSelector('textPrimary')};
|
|
57
|
+
display: flex;
|
|
58
|
+
flex-direction: column;
|
|
59
|
+
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.3);
|
|
60
|
+
transition: transform ${TRANSITION_DURATION} ease-in-out;
|
|
61
|
+
transform: ${({ $position, $isOpen }) => getTransform($position, $isOpen)};
|
|
62
|
+
|
|
63
|
+
${({ $position, $size, theme }) => {
|
|
64
|
+
const borderSide = $position === 'left' ? 'right' : $position === 'right' ? 'left' : $position === 'top' ? 'bottom' : 'top';
|
|
65
|
+
const border = `border-${borderSide}: 1px solid ${theme.border};`;
|
|
66
|
+
switch ($position) {
|
|
67
|
+
case 'left':
|
|
68
|
+
return `top: 0; left: 0; bottom: 0; width: ${$size}; ${border}`;
|
|
69
|
+
case 'right':
|
|
70
|
+
return `top: 0; right: 0; bottom: 0; width: ${$size}; ${border}`;
|
|
71
|
+
case 'top':
|
|
72
|
+
return `top: 0; left: 0; right: 0; height: ${$size}; ${border}`;
|
|
73
|
+
case 'bottom':
|
|
74
|
+
return `bottom: 0; left: 0; right: 0; height: ${$size}; ${border}`;
|
|
75
|
+
}
|
|
76
|
+
}}
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
const DrawerHeader = styled.div`
|
|
80
|
+
display: flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
justify-content: space-between;
|
|
83
|
+
padding: ${spacing.r16};
|
|
84
|
+
background-color: ${getThemePropSelector('backgroundLevel3')};
|
|
85
|
+
`;
|
|
86
|
+
|
|
87
|
+
const DrawerBody = styled.div`
|
|
88
|
+
flex: 1;
|
|
89
|
+
overflow-y: auto;
|
|
90
|
+
padding: ${spacing.r16};
|
|
91
|
+
background-color: ${getThemePropSelector('backgroundLevel4')};
|
|
92
|
+
`;
|
|
93
|
+
|
|
94
|
+
const DrawerFooter = styled.div`
|
|
95
|
+
padding: ${spacing.r16};
|
|
96
|
+
background-color: ${getThemePropSelector('backgroundLevel3')};
|
|
97
|
+
display: flex;
|
|
98
|
+
gap: ${spacing.r8};
|
|
99
|
+
justify-content: flex-end;
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
const Drawer = ({
|
|
103
|
+
isOpen,
|
|
104
|
+
close,
|
|
105
|
+
title,
|
|
106
|
+
position = 'left',
|
|
107
|
+
size = '400px',
|
|
108
|
+
footer,
|
|
109
|
+
overlay = false,
|
|
110
|
+
showCloseButton = true,
|
|
111
|
+
children,
|
|
112
|
+
}: Props) => {
|
|
113
|
+
const drawerContainer = useRef(document.createElement('div'));
|
|
114
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
115
|
+
const [mounted, setMounted] = useState(isOpen);
|
|
116
|
+
const [active, setActive] = useState(false);
|
|
117
|
+
|
|
118
|
+
useLayoutEffect(() => {
|
|
119
|
+
const container = drawerContainer.current;
|
|
120
|
+
document.body?.prepend(container);
|
|
121
|
+
return () => {
|
|
122
|
+
document.body?.removeChild(container);
|
|
123
|
+
};
|
|
124
|
+
}, []);
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (isOpen) {
|
|
128
|
+
setMounted(true);
|
|
129
|
+
} else {
|
|
130
|
+
setActive(false);
|
|
131
|
+
}
|
|
132
|
+
}, [isOpen]);
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (mounted && isOpen) {
|
|
136
|
+
const frame = requestAnimationFrame(() => {
|
|
137
|
+
setActive(true);
|
|
138
|
+
panelRef.current?.focus();
|
|
139
|
+
});
|
|
140
|
+
return () => cancelAnimationFrame(frame);
|
|
141
|
+
}
|
|
142
|
+
}, [mounted, isOpen]);
|
|
143
|
+
|
|
144
|
+
const stableClose = useCallback(close, [close]);
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (isOpen) {
|
|
148
|
+
const handleEsc = (event: KeyboardEvent) => {
|
|
149
|
+
if (event.key === 'Escape') {
|
|
150
|
+
stableClose();
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
document.addEventListener('keydown', handleEsc);
|
|
154
|
+
return () => {
|
|
155
|
+
document.removeEventListener('keydown', handleEsc);
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}, [isOpen, stableClose]);
|
|
159
|
+
|
|
160
|
+
const handleTransitionEnd = () => {
|
|
161
|
+
if (!isOpen) setMounted(false);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
if (!mounted) return null;
|
|
165
|
+
|
|
166
|
+
return createPortal(
|
|
167
|
+
<>
|
|
168
|
+
<DrawerBackdrop $overlay={overlay} onClick={stableClose} />
|
|
169
|
+
<DrawerPanel
|
|
170
|
+
ref={panelRef}
|
|
171
|
+
className="sc-drawer"
|
|
172
|
+
$position={position}
|
|
173
|
+
$size={size}
|
|
174
|
+
$isOpen={active}
|
|
175
|
+
role="dialog"
|
|
176
|
+
tabIndex={-1}
|
|
177
|
+
aria-modal={overlay ? 'true' : undefined}
|
|
178
|
+
aria-labelledby="drawer_label"
|
|
179
|
+
onTransitionEnd={handleTransitionEnd}
|
|
180
|
+
>
|
|
181
|
+
<DrawerHeader className="sc-drawer-header">
|
|
182
|
+
<Text variant="Larger" id="drawer_label">
|
|
183
|
+
{title}
|
|
184
|
+
</Text>
|
|
185
|
+
{showCloseButton && (
|
|
186
|
+
<Button
|
|
187
|
+
icon={<Icon name="Close" />}
|
|
188
|
+
onClick={stableClose}
|
|
189
|
+
tooltip={{ overlay: 'Close' }}
|
|
190
|
+
aria-label="Close"
|
|
191
|
+
/>
|
|
192
|
+
)}
|
|
193
|
+
</DrawerHeader>
|
|
194
|
+
<DrawerBody className="sc-drawer-body">{children}</DrawerBody>
|
|
195
|
+
{footer && (
|
|
196
|
+
<DrawerFooter className="sc-drawer-footer">
|
|
197
|
+
{footer}
|
|
198
|
+
</DrawerFooter>
|
|
199
|
+
)}
|
|
200
|
+
</DrawerPanel>
|
|
201
|
+
</>,
|
|
202
|
+
drawerContainer.current,
|
|
203
|
+
);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export { Drawer };
|
|
207
|
+
export type { DrawerPosition };
|
package/src/lib/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ export {
|
|
|
14
14
|
} from './components/constants';
|
|
15
15
|
export type { Status } from './components/constants';
|
|
16
16
|
export { Layout } from './components/layout/Layout.component';
|
|
17
|
+
export { Drawer } from './components/drawer/Drawer.component';
|
|
17
18
|
export { Loader } from './components/loader/Loader.component';
|
|
18
19
|
export { Modal } from './components/modal/Modal.component';
|
|
19
20
|
export { Navbar } from './components/navbar/Navbar.component';
|
package/src/lib/next.ts
CHANGED
|
@@ -17,6 +17,7 @@ export { CoreUiThemeProvider } from './components/coreuithemeprovider/CoreUiThem
|
|
|
17
17
|
export { Box } from './components/box/Box';
|
|
18
18
|
export { Input } from './components/inputv2/inputv2';
|
|
19
19
|
export { Accordion } from './components/accordion/Accordion.component';
|
|
20
|
+
export { Drawer } from './components/drawer/Drawer.component';
|
|
20
21
|
export { Editor } from './components/editor';
|
|
21
22
|
export type { EditorProps } from './components/editor';
|
|
22
23
|
|
package/src/lib/style/theme.ts
CHANGED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# eslint-plugin-valalint
|
|
2
|
+
|
|
3
|
+
A custom ESLint plugin shipped as part of `@scality/core-ui`.
|
|
4
|
+
It enforces Scality UI coding conventions (e.g. capitalisation rules for user-facing text).
|
|
5
|
+
|
|
6
|
+
## Using the plugin in a project
|
|
7
|
+
|
|
8
|
+
Install (or depend on) `@scality/core-ui`, then import the plugin from its sub-path:
|
|
9
|
+
|
|
10
|
+
### Example config (`eslint.config.mjs`)
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
import valalint from '@scality/core-ui/eslint-plugin';
|
|
14
|
+
|
|
15
|
+
export default [
|
|
16
|
+
{
|
|
17
|
+
files: ['**/*.{js,jsx,ts,tsx}'],
|
|
18
|
+
ignores: ['src/gen/**'],
|
|
19
|
+
...valalint.configs['flat/recommended'],
|
|
20
|
+
},
|
|
21
|
+
];
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Adding a new rule
|
|
25
|
+
|
|
26
|
+
### 1. Create the rule file
|
|
27
|
+
|
|
28
|
+
Add a new file under `src/lib/valalint/rules/`. Use the existing
|
|
29
|
+
[`technical-sentence-case.js`](./rules/technical-sentence-case.js) as a reference.
|
|
30
|
+
|
|
31
|
+
Refer to the [ESLint rule authoring docs](https://eslint.org/docs/latest/extend/custom-rules) for the full API.
|
|
32
|
+
|
|
33
|
+
### 2. Register the rule in the plugin
|
|
34
|
+
|
|
35
|
+
Open [`src/lib/valalint/index.js`](./index.js) and add two lines to import your rule and add it to the rules map.
|
|
36
|
+
|
|
37
|
+
### 3. Set a default severity in the recommended config
|
|
38
|
+
|
|
39
|
+
Still in `index.js`, add the rule to `recommendedRules` with its desired default severity
|
|
40
|
+
(`'off'`, `'warn'`, or `'error'`):
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
const recommendedRules = {
|
|
44
|
+
'valalint/technical-sentence-case': 'warn',
|
|
45
|
+
'valalint/my-new-rule': 'warn', // ← add this
|
|
46
|
+
};
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
That's it. The rule is now available to all consumers via both the `recommended` and
|
|
50
|
+
`flat/recommended` configs, and individually as `'valalint/my-new-rule'`.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import tsParser from '@typescript-eslint/parser';
|
|
2
|
+
import technicalSentenceCase from './rules/technical-sentence-case.js';
|
|
3
|
+
import modalButtonForbiddenLabel from './rules/modal-button-forbidden-label.js';
|
|
4
|
+
import noRawNumberInJsx from './rules/no-raw-number-in-jsx.js';
|
|
5
|
+
|
|
6
|
+
const rules = {
|
|
7
|
+
'technical-sentence-case': technicalSentenceCase,
|
|
8
|
+
'modal-button-forbidden-label': modalButtonForbiddenLabel,
|
|
9
|
+
'no-raw-number-in-jsx': noRawNumberInJsx,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Default rule severity for the recommended config. */
|
|
13
|
+
const recommendedRules = {
|
|
14
|
+
'valalint/technical-sentence-case': 'warn',
|
|
15
|
+
'valalint/modal-button-forbidden-label': 'warn',
|
|
16
|
+
'valalint/no-raw-number-in-jsx': 'warn',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const plugin = {
|
|
20
|
+
meta: {
|
|
21
|
+
name: 'valalint',
|
|
22
|
+
version: '1.0.0',
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
rules,
|
|
26
|
+
configs: {
|
|
27
|
+
/** Legacy eslintrc-style recommended config. */
|
|
28
|
+
recommended: {
|
|
29
|
+
plugins: ['valalint'],
|
|
30
|
+
rules: recommendedRules,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
plugin.configs['flat/recommended'] = {
|
|
36
|
+
plugins: { valalint: plugin },
|
|
37
|
+
rules: recommendedRules,
|
|
38
|
+
languageOptions: {
|
|
39
|
+
parser: tsParser,
|
|
40
|
+
parserOptions: {
|
|
41
|
+
ecmaFeatures: {
|
|
42
|
+
jsx: true,
|
|
43
|
+
},
|
|
44
|
+
project: true,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export default plugin;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matches whole words "yes", "no", or "ok" case-insensitively.
|
|
3
|
+
* "Okay", "Book", "Note", "Yesterday" etc. are intentionally NOT matched.
|
|
4
|
+
*/
|
|
5
|
+
const FORBIDDEN_PATTERN = /\b(yes|no|ok)\b/i;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Walks up the ancestor chain to determine whether `node` is a descendant
|
|
9
|
+
* of a <Modal> JSX element (including elements nested inside JSX attribute
|
|
10
|
+
* expression containers such as the `footer` prop).
|
|
11
|
+
*/
|
|
12
|
+
function isInsideModal(node) {
|
|
13
|
+
let current = node.parent;
|
|
14
|
+
while (current) {
|
|
15
|
+
if (
|
|
16
|
+
current.type === 'JSXElement' &&
|
|
17
|
+
current.openingElement?.name?.name === 'Modal'
|
|
18
|
+
) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
current = current.parent;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const modalButtonForbiddenLabel = {
|
|
27
|
+
meta: {
|
|
28
|
+
type: 'suggestion',
|
|
29
|
+
docs: {
|
|
30
|
+
description:
|
|
31
|
+
'Disallow Button labels containing "Yes", "No", or "Ok" inside a Modal',
|
|
32
|
+
category: 'Best Practices',
|
|
33
|
+
recommended: false,
|
|
34
|
+
},
|
|
35
|
+
schema: [],
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
create(context) {
|
|
39
|
+
return {
|
|
40
|
+
JSXElement(node) {
|
|
41
|
+
if (node.openingElement.name.name !== 'Button') return;
|
|
42
|
+
if (!isInsideModal(node)) return;
|
|
43
|
+
|
|
44
|
+
// ── Check JSX text children ─────────────────────────────────────────
|
|
45
|
+
node.children.forEach(child => {
|
|
46
|
+
if (child.type !== 'JSXText') return;
|
|
47
|
+
const text = child.value.trim();
|
|
48
|
+
if (text && FORBIDDEN_PATTERN.test(text)) {
|
|
49
|
+
context.report({
|
|
50
|
+
node: child,
|
|
51
|
+
message:
|
|
52
|
+
'Button inside Modal should not use "Yes", "No", or "Ok" as a label.',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ── Check label prop ────────────────────────────────────────────────
|
|
58
|
+
const labelProp = node.openingElement.attributes.find(
|
|
59
|
+
attr => attr.type === 'JSXAttribute' && attr.name.name === 'label',
|
|
60
|
+
);
|
|
61
|
+
if (!labelProp?.value) return;
|
|
62
|
+
|
|
63
|
+
let labelText = null;
|
|
64
|
+
if (labelProp.value.type === 'Literal') {
|
|
65
|
+
// label="Ok, delete"
|
|
66
|
+
labelText = String(labelProp.value.value);
|
|
67
|
+
} else if (
|
|
68
|
+
labelProp.value.type === 'JSXExpressionContainer' &&
|
|
69
|
+
labelProp.value.expression.type === 'Literal'
|
|
70
|
+
) {
|
|
71
|
+
// label={"Ok, delete"}
|
|
72
|
+
labelText = String(labelProp.value.expression.value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (labelText && FORBIDDEN_PATTERN.test(labelText)) {
|
|
76
|
+
context.report({
|
|
77
|
+
node: labelProp,
|
|
78
|
+
message:
|
|
79
|
+
'Button inside Modal should not use "Yes", "No", or "Ok" as a label.',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export default modalButtonForbiddenLabel;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { RuleTester } from 'eslint';
|
|
2
|
+
import rule from './modal-button-forbidden-label.js';
|
|
3
|
+
import * as tsParser from '@typescript-eslint/parser';
|
|
4
|
+
|
|
5
|
+
const tester = new RuleTester({
|
|
6
|
+
parser: tsParser,
|
|
7
|
+
parserOptions: {
|
|
8
|
+
ecmaFeatures: { jsx: true },
|
|
9
|
+
ecmaVersion: 2020,
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const ERROR = 'Button inside Modal should not use "Yes", "No", or "Ok" as a label.';
|
|
14
|
+
|
|
15
|
+
tester.run('modal-button-forbidden-label', rule, {
|
|
16
|
+
// ─── Valid ────────────────────────────────────────────────────────────────
|
|
17
|
+
valid: [
|
|
18
|
+
// Buttons outside a Modal are never checked, even if the label is forbidden
|
|
19
|
+
{ code: '<Button>Yes</Button>' },
|
|
20
|
+
{ code: '<Button>No</Button>' },
|
|
21
|
+
{ code: '<Button>Ok</Button>' },
|
|
22
|
+
{ code: '<Button label="Ok" />' },
|
|
23
|
+
|
|
24
|
+
// Non-Modal wrappers do not trigger the rule
|
|
25
|
+
{ code: '<Dialog><Button>Yes</Button></Dialog>' },
|
|
26
|
+
|
|
27
|
+
// Buttons inside Modal with acceptable labels
|
|
28
|
+
{ code: '<Modal><Button>Cancel</Button></Modal>' },
|
|
29
|
+
{ code: '<Modal><Button>Confirm</Button></Modal>' },
|
|
30
|
+
{ code: '<Modal><Button>Delete item</Button></Modal>' },
|
|
31
|
+
{ code: '<Modal><Button label="Cancel" /></Modal>' },
|
|
32
|
+
{ code: '<Modal><Button label="Delete item" /></Modal>' },
|
|
33
|
+
{ code: '<Modal footer={<Button label="Cancel" />}>Content</Modal>' },
|
|
34
|
+
|
|
35
|
+
// Non-Button elements inside Modal are not checked
|
|
36
|
+
{ code: '<Modal><Text>Yes</Text></Modal>' },
|
|
37
|
+
{ code: '<Modal><span>No</span></Modal>' },
|
|
38
|
+
|
|
39
|
+
// Words that merely contain the forbidden substring are NOT matched
|
|
40
|
+
// ("Okay" → "ok" is not a whole word because "a" follows it)
|
|
41
|
+
{ code: '<Modal><Button>Okay</Button></Modal>' },
|
|
42
|
+
{ code: '<Modal><Button label="Rebook" /></Modal>' },
|
|
43
|
+
{ code: '<Modal><Button label="Nobody" /></Modal>' },
|
|
44
|
+
|
|
45
|
+
// Empty / whitespace-only children are ignored
|
|
46
|
+
{ code: '<Modal><Button> </Button></Modal>' },
|
|
47
|
+
{ code: '<Modal><Button>{variable}</Button></Modal>' },
|
|
48
|
+
],
|
|
49
|
+
|
|
50
|
+
// ─── Invalid ──────────────────────────────────────────────────────────────
|
|
51
|
+
invalid: [
|
|
52
|
+
// ── JSX text children ─────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
code: '<Modal><Button>Yes</Button></Modal>',
|
|
56
|
+
errors: [{ message: ERROR }],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
code: '<Modal><Button>No</Button></Modal>',
|
|
60
|
+
errors: [{ message: ERROR }],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
code: '<Modal><Button>Ok</Button></Modal>',
|
|
64
|
+
errors: [{ message: ERROR }],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
// case-insensitive matching
|
|
68
|
+
code: '<Modal><Button>yes</Button></Modal>',
|
|
69
|
+
errors: [{ message: ERROR }],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
code: '<Modal><Button>no</Button></Modal>',
|
|
73
|
+
errors: [{ message: ERROR }],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
code: '<Modal><Button>ok</Button></Modal>',
|
|
77
|
+
errors: [{ message: ERROR }],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
// forbidden word embedded in a longer label
|
|
81
|
+
code: '<Modal><Button>Ok, got it</Button></Modal>',
|
|
82
|
+
errors: [{ message: ERROR }],
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
// ── label prop — string literal ────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
{
|
|
88
|
+
code: '<Modal><Button label="Yes" /></Modal>',
|
|
89
|
+
errors: [{ message: ERROR }],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
code: '<Modal><Button label="No" /></Modal>',
|
|
93
|
+
errors: [{ message: ERROR }],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
code: '<Modal><Button label="Ok" /></Modal>',
|
|
97
|
+
errors: [{ message: ERROR }],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
// real-world example from the rule description
|
|
101
|
+
code: '<Modal><Button label="Ok, delete" /></Modal>',
|
|
102
|
+
errors: [{ message: ERROR }],
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
// ── label prop — JSX expression container ─────────────────────────────
|
|
106
|
+
|
|
107
|
+
{
|
|
108
|
+
code: '<Modal><Button label={"Yes"} /></Modal>',
|
|
109
|
+
errors: [{ message: ERROR }],
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
// ── Button nested inside a JSX attribute expression (e.g. footer) ─────
|
|
113
|
+
|
|
114
|
+
{
|
|
115
|
+
code: '<Modal footer={<Button label="Ok, delete" />}>Content</Modal>',
|
|
116
|
+
errors: [{ message: ERROR }],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
code: '<Modal footer={<Button label="No" />}>Content</Modal>',
|
|
120
|
+
errors: [{ message: ERROR }],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
// Button nested deeper inside the footer expression
|
|
124
|
+
code: '<Modal footer={<Box><Button label="Yes" /></Box>}>Content</Modal>',
|
|
125
|
+
errors: [{ message: ERROR }],
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
// ── All three targeted components (Button) are covered ────────────────
|
|
129
|
+
|
|
130
|
+
{
|
|
131
|
+
// Mirrors the example from the rule description
|
|
132
|
+
code: `
|
|
133
|
+
<Modal
|
|
134
|
+
isOpen={isModalOpen}
|
|
135
|
+
title="Remove deployment"
|
|
136
|
+
footer={
|
|
137
|
+
<Box>
|
|
138
|
+
<Button variant="secondary" label="Cancel" />
|
|
139
|
+
<Button variant="danger" label="Ok, delete" />
|
|
140
|
+
</Box>
|
|
141
|
+
}
|
|
142
|
+
>
|
|
143
|
+
Are you sure?
|
|
144
|
+
</Modal>`,
|
|
145
|
+
errors: [{ message: ERROR }],
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// RuleTester.run() throws if any case fails, so reaching this line means all
|
|
151
|
+
// cases passed. Jest needs at least one explicit assertion per test file.
|
|
152
|
+
describe('modal-button-forbidden-label', () => {
|
|
153
|
+
it('passes all RuleTester cases', () => {
|
|
154
|
+
// Execution of tester.run() above already validated everything.
|
|
155
|
+
expect(true).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns true when the TypeScript type — or any member of a union — is a
|
|
5
|
+
* numeric type (number primitive or a number literal type such as `42`).
|
|
6
|
+
*/
|
|
7
|
+
function typeIncludesNumber(type) {
|
|
8
|
+
if (type.flags & ts.TypeFlags.Number) return true;
|
|
9
|
+
if (type.flags & ts.TypeFlags.NumberLiteral) return true;
|
|
10
|
+
// Handle union types: `number | undefined`, `number | null`, `string | number`, …
|
|
11
|
+
if (type.flags & ts.TypeFlags.Union) {
|
|
12
|
+
return type.types.some(t => typeIncludesNumber(t));
|
|
13
|
+
}
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const noRawNumberInJsx = {
|
|
18
|
+
meta: {
|
|
19
|
+
type: 'suggestion',
|
|
20
|
+
docs: {
|
|
21
|
+
description:
|
|
22
|
+
'Disallow raw numeric expressions in JSX text; use formatISONumber() instead',
|
|
23
|
+
category: 'Best Practices',
|
|
24
|
+
recommended: false,
|
|
25
|
+
},
|
|
26
|
+
schema: [],
|
|
27
|
+
messages: {
|
|
28
|
+
rawNumber:
|
|
29
|
+
'Avoid rendering raw numbers in JSX text. ' +
|
|
30
|
+
'Use formatISONumber(value) to apply proper ISO formatting ' +
|
|
31
|
+
'(space as thousands separator, leading zero before decimal point).',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
create(context) {
|
|
36
|
+
// Type-aware rule: opt out gracefully when no TypeScript program is available.
|
|
37
|
+
const services = context.sourceCode?.parserServices ?? context.parserServices;
|
|
38
|
+
if (!services?.program) return {};
|
|
39
|
+
|
|
40
|
+
const checker = services.program.getTypeChecker();
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
JSXExpressionContainer(node) {
|
|
44
|
+
// Only flag children positions — attribute values are intentionally excluded.
|
|
45
|
+
// A child container's direct parent is JSXElement; an attribute value's is JSXAttribute.
|
|
46
|
+
if (node.parent.type !== 'JSXElement') return;
|
|
47
|
+
|
|
48
|
+
const { expression } = node;
|
|
49
|
+
|
|
50
|
+
// Skip {/* JSX comments */}
|
|
51
|
+
if (expression.type === 'JSXEmptyExpression') return;
|
|
52
|
+
|
|
53
|
+
const tsNode = services.esTreeNodeToTSNodeMap.get(expression);
|
|
54
|
+
const type = checker.getTypeAtLocation(tsNode);
|
|
55
|
+
|
|
56
|
+
if (typeIncludesNumber(type)) {
|
|
57
|
+
context.report({ node, messageId: 'rawNumber' });
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export default noRawNumberInJsx;
|