@scality/core-ui 0.202.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/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +11 -16
- 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/utils.test.ts +15 -12
- package/src/lib/utils.ts +15 -17
- 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
package/src/lib/utils.test.ts
CHANGED
|
@@ -4,22 +4,28 @@ const LIGHT_TEXT = '#EAEAEA';
|
|
|
4
4
|
const DARK_TEXT = '#000000';
|
|
5
5
|
|
|
6
6
|
describe('getContrastText', () => {
|
|
7
|
-
it('returns
|
|
7
|
+
it('returns light text on dark backgrounds', () => {
|
|
8
8
|
expect(getContrastText('#000000', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
|
|
9
9
|
expect(getContrastText('#1A1A1A', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
|
|
10
10
|
expect(getContrastText('#121219', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
|
|
11
11
|
expect(getContrastText('#2F4185', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
it('returns
|
|
14
|
+
it('returns dark text on light backgrounds', () => {
|
|
15
15
|
expect(getContrastText('#FFFFFF', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
|
|
16
16
|
expect(getContrastText('#F5F5F5', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
|
|
17
17
|
expect(getContrastText('#FCFCFC', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
-
it('
|
|
21
|
-
expect(getContrastText('#
|
|
22
|
-
expect(getContrastText('#E9041E',
|
|
20
|
+
it('returns light text on saturated colors where WCAG luminance is ambiguous', () => {
|
|
21
|
+
expect(getContrastText('#E60028', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
|
|
22
|
+
expect(getContrastText('#E9041E', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
|
|
23
|
+
expect(getContrastText('#E60028', '#0D0D0D', '#EAEAEA')).toBe('#EAEAEA');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('works regardless of which token is lighter', () => {
|
|
27
|
+
expect(getContrastText('#000000', DARK_TEXT, LIGHT_TEXT)).toBe(LIGHT_TEXT);
|
|
28
|
+
expect(getContrastText('#FFFFFF', DARK_TEXT, LIGHT_TEXT)).toBe(DARK_TEXT);
|
|
23
29
|
});
|
|
24
30
|
|
|
25
31
|
it('handles 3-character hex shorthand', () => {
|
|
@@ -27,12 +33,12 @@ describe('getContrastText', () => {
|
|
|
27
33
|
expect(getContrastText('#000', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
|
|
28
34
|
});
|
|
29
35
|
|
|
30
|
-
it('handles
|
|
31
|
-
expect(getContrastText('
|
|
32
|
-
expect(getContrastText('
|
|
36
|
+
it('handles rgb color format', () => {
|
|
37
|
+
expect(getContrastText('rgb(0, 0, 0)', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
|
|
38
|
+
expect(getContrastText('rgb(255, 255, 255)', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
|
|
33
39
|
});
|
|
34
40
|
|
|
35
|
-
it('returns null for
|
|
41
|
+
it('returns null for unparseable values', () => {
|
|
36
42
|
expect(
|
|
37
43
|
getContrastText(
|
|
38
44
|
'linear-gradient(130deg, #9355E7 0%, #2E4AA3 60%)',
|
|
@@ -41,8 +47,5 @@ describe('getContrastText', () => {
|
|
|
41
47
|
),
|
|
42
48
|
).toBeNull();
|
|
43
49
|
expect(getContrastText('not-a-color', LIGHT_TEXT, DARK_TEXT)).toBeNull();
|
|
44
|
-
expect(
|
|
45
|
-
getContrastText('rgb(255, 0, 0)', LIGHT_TEXT, DARK_TEXT),
|
|
46
|
-
).toBeNull();
|
|
47
50
|
});
|
|
48
51
|
});
|
package/src/lib/utils.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getLuminance } from 'polished';
|
|
2
|
+
|
|
1
3
|
const RGB_HEX = /^#?(?:([\da-f]{3})[\da-f]?|([\da-f]{6})(?:[\da-f]{2})?)$/i;
|
|
2
4
|
|
|
3
5
|
/** Ensure the consistency of colors between old and new colors */
|
|
@@ -45,22 +47,12 @@ export const hex2RGB = (str: string): [number, number, number] => {
|
|
|
45
47
|
throw new Error('Invalid hex string provided');
|
|
46
48
|
};
|
|
47
49
|
|
|
48
|
-
// WCAG 2.0 relative luminance
|
|
49
|
-
const relativeLuminance = (r: number, g: number, b: number): number => {
|
|
50
|
-
const [rs, gs, bs] = [r, g, b].map((c) => {
|
|
51
|
-
const s = c / 255;
|
|
52
|
-
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
53
|
-
});
|
|
54
|
-
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
50
|
const wcagContrastRatio = (l1: number, l2: number): number =>
|
|
58
51
|
(Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
59
52
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
};
|
|
53
|
+
// Minimum WCAG contrast ratio to consider a text color readable on a background.
|
|
54
|
+
// 3.0 corresponds to WCAG AA for large text — same threshold used by MUI.
|
|
55
|
+
const CONTRAST_THRESHOLD = 3;
|
|
64
56
|
|
|
65
57
|
export const getContrastText = (
|
|
66
58
|
bgColor: string,
|
|
@@ -68,10 +60,16 @@ export const getContrastText = (
|
|
|
68
60
|
textReverse: string,
|
|
69
61
|
): string | null => {
|
|
70
62
|
try {
|
|
71
|
-
const bgLum =
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
63
|
+
const bgLum = getLuminance(bgColor);
|
|
64
|
+
const primaryLum = getLuminance(textPrimary);
|
|
65
|
+
const reverseLum = getLuminance(textReverse);
|
|
66
|
+
|
|
67
|
+
const lighterText = primaryLum >= reverseLum ? textPrimary : textReverse;
|
|
68
|
+
const darkerText = primaryLum >= reverseLum ? textReverse : textPrimary;
|
|
69
|
+
|
|
70
|
+
const lighterContrast = wcagContrastRatio(primaryLum >= reverseLum ? primaryLum : reverseLum, bgLum);
|
|
71
|
+
|
|
72
|
+
return lighterContrast >= CONTRAST_THRESHOLD ? lighterText : darkerText;
|
|
75
73
|
} catch {
|
|
76
74
|
return null;
|
|
77
75
|
}
|
|
@@ -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;
|