@scality/core-ui 0.205.0 → 0.207.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.
@@ -89,7 +89,7 @@ export const parameters = {
89
89
  'Navigation',
90
90
  'Data Display',
91
91
  'Inputs',
92
- 'Feedback',
92
+ ['Feedback', [['Modal', ['Guideline', '*']]]],
93
93
  'Progress & loading',
94
94
  'Styling',
95
95
  'Deprecated',
package/jest.config.js CHANGED
@@ -10,5 +10,10 @@ export default {
10
10
  '^@fortawesome/free-regular-svg-icons/(.+)\\.js$':
11
11
  '@fortawesome/free-regular-svg-icons/$1',
12
12
  },
13
+ // Transform both .js and .mjs files with Babel
14
+ transform: {
15
+ '^.+\\.(js|jsx|mjs)$': 'babel-jest',
16
+ '^.+\\.(ts|tsx)$': 'babel-jest',
17
+ },
13
18
  testEnvironment: 'jsdom',
14
19
  };
package/package.json CHANGED
@@ -1,11 +1,10 @@
1
1
  {
2
2
  "name": "@scality/core-ui",
3
- "version": "0.205.0",
3
+ "version": "0.207.0",
4
4
  "description": "Scality common React component library",
5
5
  "author": "Scality Engineering",
6
6
  "license": "SEE LICENSE IN LICENSE",
7
7
  "main": "dist/index.js",
8
- "type": "module",
9
8
  "types": "dist/index.d.ts",
10
9
  "mainSrc": "src/lib/index.js",
11
10
  "exports": {
@@ -14,7 +13,9 @@
14
13
  "require": "./dist/index.js",
15
14
  "types": "./dist/index.d.ts"
16
15
  },
17
- "./eslint-plugin": "./src/lib/valalint/index.js"
16
+ "./eslint-plugin": "./src/lib/valalint/index.mjs",
17
+ "./dist/*.css": "./dist/*.css",
18
+ "./dist/*": "./dist/*"
18
19
  },
19
20
  "sideEffects": false,
20
21
  "peerDependencies": {
@@ -0,0 +1,49 @@
1
+ import tsParser from "@typescript-eslint/parser";
2
+ import modalButtonForbiddenLabel from "./rules/modal-button-forbidden-label.mjs";
3
+ import noRawNumberInJsx from "./rules/no-raw-number-in-jsx.mjs";
4
+ import technicalSentenceCase from "./rules/technical-sentence-case.mjs";
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;
@@ -1,5 +1,5 @@
1
1
  import { RuleTester } from 'eslint';
2
- import rule from './modal-button-forbidden-label.js';
2
+ import rule from './modal-button-forbidden-label.mjs';
3
3
  import * as tsParser from '@typescript-eslint/parser';
4
4
 
5
5
  const tester = new RuleTester({
@@ -2,7 +2,7 @@
2
2
  * @jest-environment node
3
3
  */
4
4
  import { RuleTester } from '@typescript-eslint/rule-tester';
5
- import rule from './no-raw-number-in-jsx.js';
5
+ import rule from './no-raw-number-in-jsx.mjs';
6
6
  import path from 'path';
7
7
 
8
8
  // Jest runs from the project root, so process.cwd() is the workspace root
@@ -1,5 +1,5 @@
1
1
  import { RuleTester } from 'eslint';
2
- import rule from './technical-sentence-case.js';
2
+ import rule from './technical-sentence-case.mjs';
3
3
  import * as tsParser from '@typescript-eslint/parser';
4
4
 
5
5
  const tester = new RuleTester({
@@ -0,0 +1,103 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/blocks';
2
+ import * as ModalStories from './modal.stories';
3
+
4
+ <Meta of={ModalStories} name="Guideline" />
5
+
6
+ # Modal
7
+
8
+ A modal interrupts the user to focus on a single task or decision. It blocks the rest of the UI until explicitly dismissed. Every modal has an action — there are no purely informational modals.
9
+
10
+ ## When to use / When not to use
11
+
12
+ **✅ Use a modal for:**
13
+ - A confirmation before a consequential action (delete, reset, publish)
14
+ - A short, focused form that doesn't warrant a full page
15
+ - A required decision the user must address before continuing
16
+
17
+ **❌ Do not use a modal for:**
18
+ - Multi-step flows — use a full page or wizard instead
19
+ - Anything that would trigger on top of another modal
20
+ - Events triggered by background conditions (new version available, license missing) — race conditions between modals create a broken experience
21
+
22
+ ## Size
23
+
24
+ - **Min-width:** 480px — never narrower than this regardless of content
25
+ - **Max-width:** 90vw — the modal adapts to its content between these two bounds
26
+ - Avoid scroll whenever possible. The modal keeps vertical padding against the viewport edges at all times. If the body content overflows, it scrolls with a visible scrollbar.
27
+
28
+ ## Role: `dialog` vs `alertdialog`
29
+
30
+ The `role` prop controls how assistive technologies treat the modal.
31
+
32
+ | `role` | When to use | Close button |
33
+ |---|---|---|
34
+ | `dialog` (default) | Standard interaction — form, optional confirmation | Present |
35
+ | `alertdialog` | Critical or destructive action requiring a forced decision | Omit |
36
+
37
+ Use `alertdialog` when accidentally dismissing the modal would be dangerous or meaningless (e.g. a delete confirmation — ESC should not silently cancel the user's intent).
38
+
39
+ ## Close button and backdrop
40
+
41
+ - The **close button** (×) in the header is present by default via the `close` prop. Omit it only when using `role="alertdialog"` — the user must explicitly choose an action.
42
+ - **Clicking the backdrop never closes the modal.** This prevents accidental data loss, especially in forms.
43
+ - **ESC** closes the modal when the `close` prop is provided, regardless of the role.
44
+
45
+ ## Title and subtitle
46
+
47
+ - `title` is always required and always text. It mirrors the verb of the action that triggered the modal: the button "Delete node" opens a modal titled "Delete node?".
48
+ - For standard modals, the title describes the action: "Edit settings", "Create bucket".
49
+ - For destructive or irreversible actions, the title is phrased as a question: "Delete node?", "Revoke access?". The question mark signals that a decision is required.
50
+ - `subTitle` occupies the header slot when no close button is present. Use it for contextual information (e.g. a step indicator or a required-fields disclaimer).
51
+ - `subTitle` occupies the same slot as the close button. When `subTitle` is used, `close` must be omitted and navigation must be handled in the footer.
52
+
53
+ ## Body content
54
+
55
+ - When the action targets a specific resource, name it explicitly in the body: **"my-node-name** will be permanently deleted, this action is irreversible." This prevents the user from acting on the wrong resource.
56
+ - For destructive or irreversible actions without a named resource, the body should still convey the consequence clearly.
57
+ - Links are allowed in the body. They must open in a new tab — the modal stays open.
58
+
59
+ ## Footer and actions
60
+
61
+ - **Every modal must have a footer** with at least one action button. A modal without a footer is not valid.
62
+ - All buttons are **right-aligned** — the primary or danger action is the rightmost, the cancel/outline action sits immediately to its left.
63
+ - **2 buttons** is the norm. **3 buttons** is acceptable. **4 buttons** should be avoided.
64
+ - Footer is reserved for actions only — no links in the footer.
65
+ - Destructive action: `variant="danger"` — it replaces `primary`, never coexists with it.
66
+
67
+ <Canvas of={ModalStories.SimpleModal} layout="fullscreen" />
68
+
69
+ <Canvas of={ModalStories.DestructiveModal} layout="fullscreen" />
70
+
71
+ ## Destructive and irreversible modals
72
+
73
+ Use `role="alertdialog"`, omit the close button, and use `variant="danger"` on the confirm action. Only the button carries the danger signal — the modal chrome does not change.
74
+
75
+ If the action targets a named resource, that name must appear in the body. If there is no named resource (e.g. resetting a configuration), it is optional.
76
+
77
+ ## Async actions
78
+
79
+ | Operation duration | Pattern |
80
+ |---|---|
81
+ | < 3s | Use `isLoading` on the action button. Keep the modal open until resolved. |
82
+ | > 3s | Add a contextual message: "This may take a few moments." |
83
+ | Minutes or more | Do not use a modal. Use a background job with status feedback elsewhere in the UI. |
84
+
85
+ On error: stay in the modal and display the error inline. Do not close the modal silently. Do not rely on a toast as the only error signal.
86
+
87
+ ## Forbidden patterns
88
+
89
+ - **No modal on top of another modal.** If a flow requires a second modal, redesign as a full page or wizard.
90
+ - **Avoid event-triggered modals** at the application level (login, WebSocket events, etc.) — race conditions between modals create an uncontrollable stacking experience.
91
+ - **No complex multi-step flows inside a modal.** Move to a full page. Exception: short download or export wizards.
92
+
93
+ ## Within a table
94
+
95
+ Modals triggered from table row actions are valid. Use `size="inline"` for the trigger button.
96
+
97
+ <Canvas of={ModalStories.WithinTable} layout="fullscreen" />
98
+
99
+ ## Playground
100
+
101
+ <Canvas of={ModalStories.SimpleModal} layout="fullscreen" />
102
+
103
+ <Controls of={ModalStories.SimpleModal} />
@@ -5,6 +5,7 @@ import { Table } from '../src/lib/components/tablev2/Tablev2.component';
5
5
  import { IconHelp } from '../src/lib/components/iconhelper/IconHelper';
6
6
  import { Stack } from '../src/lib/spacing';
7
7
  import { Button } from '../src/lib/components/buttonv2/Buttonv2.component';
8
+ import { Icon } from '../src/lib/components/icon/Icon.component';
8
9
  import { useArgs } from '@storybook/preview-api';
9
10
 
10
11
  export default {
@@ -15,6 +16,12 @@ export default {
15
16
  ],
16
17
  };
17
18
 
19
+ const FooterActions = ({ children }) => (
20
+ <Stack gap="r8" style={{ justifyContent: 'flex-end' }}>
21
+ {children}
22
+ </Stack>
23
+ );
24
+
18
25
  export const SimpleModal = {
19
26
  render: (args) => {
20
27
  const [{ isOpen }, updateArgs] = useArgs();
@@ -22,31 +29,66 @@ export const SimpleModal = {
22
29
  <>
23
30
  <Button
24
31
  onClick={() => updateArgs({ isOpen: true })}
25
- label={'Show Modal'}
32
+ label={'Open modal'}
33
+ variant="primary"
26
34
  />
27
35
  <Modal
28
36
  close={() => updateArgs({ isOpen: false })}
29
37
  isOpen={isOpen}
30
38
  footer={
31
- <div
32
- style={{
33
- display: 'flex',
34
- justifyContent: 'space-between',
35
- }}
36
- >
39
+ <FooterActions>
40
+ <Button
41
+ label="Cancel"
42
+ variant="outline"
43
+ onClick={() => updateArgs({ isOpen: false })}
44
+ />
45
+ <Button
46
+ variant="primary"
47
+ label="Save changes"
48
+ icon={<Icon name="Save" />}
49
+ onClick={action('Save changes clicked')}
50
+ />
51
+ </FooterActions>
52
+ }
53
+ {...args}
54
+ />
55
+ </>
56
+ );
57
+ },
58
+ args: {
59
+ title: 'Edit settings',
60
+ children: <span>Make your changes below.</span>,
61
+ },
62
+ };
63
+
64
+ export const DestructiveModal = {
65
+ render: (args) => {
66
+ const [{ isOpen }, updateArgs] = useArgs();
67
+ return (
68
+ <>
69
+ <Button
70
+ onClick={() => updateArgs({ isOpen: true })}
71
+ label={'Delete node'}
72
+ variant="danger"
73
+ icon={<Icon name="Delete" />}
74
+ />
75
+ <Modal
76
+ role="alertdialog"
77
+ isOpen={isOpen}
78
+ footer={
79
+ <FooterActions>
37
80
  <Button
38
- label="No"
39
- size="default"
81
+ label="Cancel"
40
82
  variant="outline"
41
83
  onClick={() => updateArgs({ isOpen: false })}
42
84
  />
43
85
  <Button
44
- variant="secondary"
45
- label="Yes"
46
- size="inline"
47
- onClick={action('Yes clicked')}
86
+ variant="danger"
87
+ label="Delete node"
88
+ icon={<Icon name="Delete" />}
89
+ onClick={action('Delete node clicked')}
48
90
  />
49
- </div>
91
+ </FooterActions>
50
92
  }
51
93
  {...args}
52
94
  />
@@ -54,8 +96,13 @@ export const SimpleModal = {
54
96
  );
55
97
  },
56
98
  args: {
57
- title: 'Hello',
58
- children: <span>Do you want a cookie?</span>,
99
+ title: 'Delete node?',
100
+ children: (
101
+ <span>
102
+ <strong>my-node-name</strong> will be permanently deleted, this action
103
+ is irreversible.
104
+ </span>
105
+ ),
59
106
  },
60
107
  };
61
108
 
@@ -63,20 +110,21 @@ export const CustomizeTitle = {
63
110
  ...SimpleModal,
64
111
  args: {
65
112
  close: null,
66
- title: 'Hello there',
67
- children: <span>Do you want a cookie?</span>,
113
+ title: 'Create bucket',
114
+ children: <span>Fill in the details below.</span>,
68
115
  subTitle: (
69
116
  <Stack>
70
117
  <>Step 1/2</>
71
118
  <IconHelp
72
119
  tooltipMessage={
73
120
  <ul>
74
- <li>Hello, this is the tooltip of the modal</li>
121
+ <li>Complete all required fields before proceeding.</li>
75
122
  </ul>
76
123
  }
77
124
  />
78
125
  </Stack>
79
126
  ),
127
+ isOpen: false,
80
128
  },
81
129
  };
82
130
 
@@ -86,31 +134,28 @@ const Demo = (myargs, args) => () => {
86
134
  <>
87
135
  <Button
88
136
  onClick={() => updateArgs({ isOpen: true })}
89
- label={'Show Modal'}
137
+ label={'Delete'}
138
+ variant="danger"
139
+ icon={<Icon name="Delete" />}
140
+ size="inline"
90
141
  />
91
142
  <Modal
92
- close={() => updateArgs({ isOpen: false })}
143
+ role="alertdialog"
93
144
  isOpen={isOpen}
94
145
  footer={
95
- <div
96
- style={{
97
- display: 'flex',
98
- justifyContent: 'space-between',
99
- }}
100
- >
146
+ <FooterActions>
101
147
  <Button
102
- label="No"
103
- size="default"
148
+ label="Cancel"
104
149
  variant="outline"
105
150
  onClick={() => updateArgs({ isOpen: false })}
106
151
  />
107
152
  <Button
108
- variant="secondary"
109
- label="Yes"
110
- size="inline"
153
+ variant="danger"
154
+ label="Delete"
155
+ icon={<Icon name="Delete" />}
111
156
  onClick={() => updateArgs({ isOpen: false })}
112
157
  />
113
- </div>
158
+ </FooterActions>
114
159
  }
115
160
  {...args}
116
161
  />
@@ -126,22 +171,17 @@ export const WithinTable = {
126
171
  {
127
172
  Header: 'First Name',
128
173
  accessor: 'firstName',
129
- cellStyle: {
130
- textAlign: 'left',
131
- },
174
+ cellStyle: { textAlign: 'left' },
132
175
  },
133
176
  {
134
177
  Header: 'Last Name',
135
178
  accessor: 'lastName',
136
- cellStyle: {
137
- textAlign: 'left',
138
- },
179
+ cellStyle: { textAlign: 'left' },
139
180
  },
140
181
  {
141
182
  Header: 'Actions',
142
183
  accessor: 'health',
143
184
  Cell: Demo(myArgs, args),
144
- // disable the sorting on this column
145
185
  disableSortBy: true,
146
186
  },
147
187
  ];
@@ -153,12 +193,7 @@ export const WithinTable = {
153
193
  },
154
194
  ];
155
195
  return (
156
- <div
157
- style={{
158
- height: '300px',
159
- paddingTop: '20px',
160
- }}
161
- >
196
+ <div style={{ height: '300px', paddingTop: '20px' }}>
162
197
  <Table columns={columns} data={data} defaultSortingKey={'firstName'}>
163
198
  <Table.SingleSelectableContent
164
199
  rowHeight="h32"
@@ -169,7 +204,7 @@ export const WithinTable = {
169
204
  );
170
205
  },
171
206
  args: {
172
- title: 'Hello',
173
- children: <span>Do you want a cookie?</span>,
207
+ title: 'Delete node?',
208
+ children: <span>This action cannot be undone.</span>,
174
209
  },
175
210
  };
@@ -1,49 +0,0 @@
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;
package/stories/modal.mdx DELETED
@@ -1,17 +0,0 @@
1
- import { Meta, Story, Canvas, Controls, Stories } from '@storybook/blocks';
2
- import { Modal } from '../src/lib/components/modal/Modal.component.tsx';
3
- import * as ModalStories from './modal.stories';
4
-
5
- <Meta of={ModalStories} />
6
-
7
- <Canvas of={ModalStories.SimpleModal} />
8
-
9
- <Controls of={ModalStories.SimpleModal} />
10
-
11
- #### With a customize Title
12
-
13
- <Story of={ModalStories.CustomizeTitle} />
14
-
15
- #### Inside a Table
16
-
17
- <Story of={ModalStories.WithinTable} />