@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.
Files changed (34) hide show
  1. package/.github/workflows/github-pages.yml +4 -4
  2. package/.github/workflows/post-release.yml +25 -12
  3. package/__mocks__/fileMock.js +1 -1
  4. package/__mocks__/styleMock.js +1 -1
  5. package/__mocks__/uuid.js +1 -5
  6. package/dist/components/drawer/Drawer.component.d.ts +17 -0
  7. package/dist/components/drawer/Drawer.component.d.ts.map +1 -0
  8. package/dist/components/drawer/Drawer.component.js +132 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/next.d.ts +1 -0
  13. package/dist/next.d.ts.map +1 -1
  14. package/dist/next.js +1 -0
  15. package/dist/style/theme.d.ts +1 -0
  16. package/dist/style/theme.d.ts.map +1 -1
  17. package/dist/style/theme.js +1 -0
  18. package/global-setup.js +1 -1
  19. package/jest.config.js +1 -1
  20. package/package.json +13 -3
  21. package/src/lib/components/drawer/Drawer.component.test.tsx +108 -0
  22. package/src/lib/components/drawer/Drawer.component.tsx +207 -0
  23. package/src/lib/index.ts +1 -0
  24. package/src/lib/next.ts +1 -0
  25. package/src/lib/style/theme.ts +1 -0
  26. package/src/lib/valalint/README.md +50 -0
  27. package/src/lib/valalint/index.js +49 -0
  28. package/src/lib/valalint/rules/modal-button-forbidden-label.js +87 -0
  29. package/src/lib/valalint/rules/modal-button-forbidden-label.test.js +157 -0
  30. package/src/lib/valalint/rules/no-raw-number-in-jsx.js +64 -0
  31. package/src/lib/valalint/rules/no-raw-number-in-jsx.test.js +237 -0
  32. package/src/lib/valalint/rules/technical-sentence-case.js +93 -0
  33. package/src/lib/valalint/rules/technical-sentence-case.test.js +167 -0
  34. 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
 
@@ -310,6 +310,7 @@ export const zIndex = {
310
310
  tooltip: 9990,
311
311
  notification: 9000,
312
312
  modal: 8500,
313
+ drawer: 8200,
313
314
  overlay: 8000,
314
315
  dropdown: 7000,
315
316
  nav: 500,
@@ -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;