@centreon/ui 26.10.9 → 26.10.10
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/package.json +1 -1
- package/src/Form/CollapsibleGroup.tsx +6 -33
- package/src/Form/Form.browser.test.tsx +108 -0
- package/src/Form/Form.stories.tsx +102 -1
- package/src/Form/Form.tsx +4 -3
- package/src/Form/Inputs/Grid.browser.test.tsx +186 -0
- package/src/Form/Inputs/Grid.tsx +59 -12
- package/src/Form/Inputs/PasswordInput.browser.test.tsx +80 -0
- package/src/Form/Inputs/SegmentedButtons.browser.test.tsx +109 -25
- package/src/Form/Inputs/SegmentedButtons.tsx +38 -29
- package/src/Form/Inputs/Text.tsx +13 -1
- package/src/Form/Inputs/index.tsx +10 -52
- package/src/Form/Inputs/models.ts +6 -0
- package/src/Form/Section/FormSection.browser.test.tsx +174 -0
- package/src/Form/Section/FormSection.tsx +20 -6
- package/src/Form/Section/navigateToSection.ts +26 -8
- package/src/Form/Section/useActiveSection.ts +213 -0
- package/src/components/Header/PageHeader/PageHeader.stories.tsx +9 -0
- package/src/components/Header/PageHeader/PageHeaderMain.tsx +4 -2
- package/src/components/Header/PageHeader/PageHeaderTitle.tsx +61 -18
- package/src/components/Tabs/Tabs.tsx +7 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { rs } from '@rstest/core';
|
|
2
|
+
import { render } from '@test-setup/render';
|
|
3
|
+
import { screen } from '@testing-library/react';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import { object } from 'yup';
|
|
6
|
+
|
|
7
|
+
import { Form } from '../Form';
|
|
8
|
+
import { InputType } from './models';
|
|
9
|
+
|
|
10
|
+
const initialize = (): void => {
|
|
11
|
+
render(
|
|
12
|
+
<Form
|
|
13
|
+
initialValues={{ community: '', note: '' }}
|
|
14
|
+
inputs={[
|
|
15
|
+
{
|
|
16
|
+
dataTestId: 'note',
|
|
17
|
+
fieldName: 'note',
|
|
18
|
+
group: '',
|
|
19
|
+
label: 'Note',
|
|
20
|
+
type: InputType.Text
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
dataTestId: 'community',
|
|
24
|
+
fieldName: 'community',
|
|
25
|
+
group: '',
|
|
26
|
+
label: 'SNMP community',
|
|
27
|
+
type: InputType.Password
|
|
28
|
+
}
|
|
29
|
+
]}
|
|
30
|
+
submit={rs.fn()}
|
|
31
|
+
validationSchema={object()}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const getInput = async (testId: string): Promise<HTMLInputElement> =>
|
|
37
|
+
(await screen.findAllByTestId(testId)).find(
|
|
38
|
+
(element) => element.tagName === 'INPUT'
|
|
39
|
+
) as HTMLInputElement;
|
|
40
|
+
|
|
41
|
+
describe('Password input', () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
expect.hasAssertions();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('asks the browser and the password managers not to fill it', async () => {
|
|
47
|
+
initialize();
|
|
48
|
+
|
|
49
|
+
const password = await getInput('community');
|
|
50
|
+
|
|
51
|
+
expect(password).toHaveAttribute('type', 'password');
|
|
52
|
+
expect(password).toHaveAttribute('autocomplete', 'new-password');
|
|
53
|
+
expect(password).toHaveAttribute('data-1p-ignore', 'true');
|
|
54
|
+
expect(password).toHaveAttribute('data-bwignore', 'true');
|
|
55
|
+
expect(password).toHaveAttribute('data-lpignore', 'true');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('keeps those hints while the password is shown', async () => {
|
|
59
|
+
initialize();
|
|
60
|
+
|
|
61
|
+
await userEvent.click(
|
|
62
|
+
await screen.findByRole('button', { name: /password|show|visib/i })
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const password = await getInput('community');
|
|
66
|
+
|
|
67
|
+
expect(password).toHaveAttribute('type', 'text');
|
|
68
|
+
expect(password).toHaveAttribute('autocomplete', 'new-password');
|
|
69
|
+
expect(password).toHaveAttribute('data-1p-ignore', 'true');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('leaves the other text inputs as they were', async () => {
|
|
73
|
+
initialize();
|
|
74
|
+
|
|
75
|
+
const note = await getInput('note');
|
|
76
|
+
|
|
77
|
+
expect(note).toHaveAttribute('autocomplete', 'off');
|
|
78
|
+
expect(note).not.toHaveAttribute('data-1p-ignore');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -27,38 +27,44 @@ const ValueDisplay = (): JSX.Element => {
|
|
|
27
27
|
};
|
|
28
28
|
|
|
29
29
|
interface InitializeProps {
|
|
30
|
+
columnWidth?: number;
|
|
30
31
|
disabled?: boolean;
|
|
31
32
|
enabled?: string;
|
|
33
|
+
segmentedOptions?: Array<{ label: string; value: string }>;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
const initialize = ({
|
|
35
37
|
enabled = 'use_default',
|
|
36
|
-
disabled = false
|
|
38
|
+
disabled = false,
|
|
39
|
+
columnWidth = 800,
|
|
40
|
+
segmentedOptions = options
|
|
37
41
|
}: InitializeProps = {}): void => {
|
|
38
42
|
render(
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
{
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
43
|
+
<div style={{ width: columnWidth }}>
|
|
44
|
+
<Form
|
|
45
|
+
initialValues={{ enabled }}
|
|
46
|
+
inputs={[
|
|
47
|
+
{
|
|
48
|
+
dataTestId: 'enabled',
|
|
49
|
+
fieldName: 'enabled',
|
|
50
|
+
getDisabled: () => disabled,
|
|
51
|
+
group: '',
|
|
52
|
+
label: 'Notification Enabled',
|
|
53
|
+
segmentedButtons: { options: segmentedOptions },
|
|
54
|
+
type: InputType.SegmentedButtons
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
custom: { Component: ValueDisplay },
|
|
58
|
+
fieldName: 'value',
|
|
59
|
+
group: '',
|
|
60
|
+
label: 'value',
|
|
61
|
+
type: InputType.Custom
|
|
62
|
+
}
|
|
63
|
+
]}
|
|
64
|
+
submit={rs.fn()}
|
|
65
|
+
validationSchema={object()}
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
62
68
|
);
|
|
63
69
|
};
|
|
64
70
|
|
|
@@ -73,7 +79,11 @@ describe('Segmented buttons', () => {
|
|
|
73
79
|
it('displays every option and marks the current one', async () => {
|
|
74
80
|
initialize();
|
|
75
81
|
|
|
76
|
-
|
|
82
|
+
const label = await screen.findByText('Notification Enabled');
|
|
83
|
+
|
|
84
|
+
expect(label).toBeVisible();
|
|
85
|
+
// Bold, as the design shows the label above the buttons.
|
|
86
|
+
expect(getComputedStyle(label).fontWeight).toBe('700');
|
|
77
87
|
expect(await button('Yes')).toHaveAttribute('aria-pressed', 'false');
|
|
78
88
|
expect(await button('No')).toHaveAttribute('aria-pressed', 'false');
|
|
79
89
|
expect(await button('Default')).toHaveAttribute('aria-pressed', 'true');
|
|
@@ -104,6 +114,80 @@ describe('Segmented buttons', () => {
|
|
|
104
114
|
expect(await button('No')).toHaveAttribute('aria-pressed', 'true');
|
|
105
115
|
});
|
|
106
116
|
|
|
117
|
+
it('sizes each segment to its label, at least 104px, whatever the column', async () => {
|
|
118
|
+
initialize({
|
|
119
|
+
columnWidth: 800,
|
|
120
|
+
segmentedOptions: [
|
|
121
|
+
{ label: 'Yes', value: 'true' },
|
|
122
|
+
{ label: 'No', value: 'false' },
|
|
123
|
+
{ label: 'Inherit from template', value: 'use_default' }
|
|
124
|
+
]
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const group = await screen.findByTestId('enabled');
|
|
128
|
+
const [yes, no, inherit] = (
|
|
129
|
+
await Promise.all(['Yes', 'No', 'Inherit from template'].map(button))
|
|
130
|
+
).map((element) => element.getBoundingClientRect().width);
|
|
131
|
+
|
|
132
|
+
// As in the design: Yes and No keep 104px, the longer label grows alone
|
|
133
|
+
// (the group overlaps the borders of its buttons, a pixel at most).
|
|
134
|
+
expect(yes).toBeGreaterThanOrEqual(104);
|
|
135
|
+
expect(Math.abs(yes - no)).toBeLessThanOrEqual(1);
|
|
136
|
+
expect(yes).toBeLessThan(110);
|
|
137
|
+
expect(inherit).toBeGreaterThan(yes + 20);
|
|
138
|
+
expect(group.getBoundingClientRect().width).toBeLessThan(500);
|
|
139
|
+
expect(group.getBoundingClientRect().left).toBe(
|
|
140
|
+
(await screen.findByText('Notification Enabled')).getBoundingClientRect()
|
|
141
|
+
.left
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('keeps three segments of 104px when the labels are short', async () => {
|
|
146
|
+
initialize({ columnWidth: 800 });
|
|
147
|
+
|
|
148
|
+
const widths = (
|
|
149
|
+
await Promise.all(['Yes', 'No', 'Default'].map(button))
|
|
150
|
+
).map((element) => element.getBoundingClientRect().width);
|
|
151
|
+
|
|
152
|
+
expect(Math.min(...widths)).toBeGreaterThanOrEqual(104);
|
|
153
|
+
expect(Math.max(...widths) - Math.min(...widths)).toBeLessThanOrEqual(1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('cuts a label wider than the room left with an ellipsis', async () => {
|
|
157
|
+
const longLabel =
|
|
158
|
+
'Vorlage erben und alle Werte der Vorlage ohne Ausnahme übernehmen';
|
|
159
|
+
|
|
160
|
+
initialize({
|
|
161
|
+
columnWidth: 420,
|
|
162
|
+
segmentedOptions: [
|
|
163
|
+
{ label: 'Yes', value: 'true' },
|
|
164
|
+
{ label: 'No', value: 'false' },
|
|
165
|
+
{ label: longLabel, value: 'use_default' }
|
|
166
|
+
]
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const long = await button(longLabel);
|
|
170
|
+
const text = long.firstElementChild as HTMLElement;
|
|
171
|
+
|
|
172
|
+
expect(text.scrollWidth).toBeGreaterThan(text.clientWidth);
|
|
173
|
+
expect(getComputedStyle(text).textOverflow).toBe('ellipsis');
|
|
174
|
+
expect(long).toHaveAttribute('title', longLabel);
|
|
175
|
+
expect(long.getBoundingClientRect().height).toBe(28);
|
|
176
|
+
expect(
|
|
177
|
+
(await screen.findByTestId('enabled')).getBoundingClientRect().right
|
|
178
|
+
).toBeLessThanOrEqual(420 + 1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('stays inside a column narrower than its segments', async () => {
|
|
182
|
+
initialize({ columnWidth: 200 });
|
|
183
|
+
|
|
184
|
+
const group = await screen.findByTestId('enabled');
|
|
185
|
+
const wrapper = group.parentElement as HTMLElement;
|
|
186
|
+
|
|
187
|
+
expect(wrapper.getBoundingClientRect().width).toBeLessThanOrEqual(200);
|
|
188
|
+
expect(wrapper.scrollWidth).toBeGreaterThan(wrapper.clientWidth);
|
|
189
|
+
});
|
|
190
|
+
|
|
107
191
|
it('does not change when disabled', async () => {
|
|
108
192
|
initialize({ disabled: true });
|
|
109
193
|
|
|
@@ -32,36 +32,45 @@ const SegmentedButtons = ({
|
|
|
32
32
|
return useMemoComponent({
|
|
33
33
|
Component: (
|
|
34
34
|
<FormGroup>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
35
|
+
{!segmentedButtons?.hideLabel && (
|
|
36
|
+
<FormLabel className="font-bold">{t(label)}</FormLabel>
|
|
37
|
+
)}
|
|
38
|
+
{/* In a column narrower than the three minimum widths, the group scrolls
|
|
39
|
+
inside it rather than overflowing into what is next to it. */}
|
|
40
|
+
<div className="max-w-full self-start overflow-x-auto">
|
|
41
|
+
<ButtonGroup
|
|
42
|
+
aria-label={t(label)}
|
|
43
|
+
className="max-w-full"
|
|
44
|
+
color="primary"
|
|
45
|
+
data-testid={dataTestId}
|
|
46
|
+
disabled={disabled}
|
|
47
|
+
size="small"
|
|
48
|
+
>
|
|
49
|
+
{segmentedButtons?.options.map(
|
|
50
|
+
({ label: optionLabel, value: optionValue }) => {
|
|
51
|
+
const isSelected = equals(value, optionValue);
|
|
49
52
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
53
|
+
// As in the design: every segment is 26 spacing units (104px)
|
|
54
|
+
// wide at least, and grows with its own label. A label wider
|
|
55
|
+
// than the room left is cut with an ellipsis, its full text
|
|
56
|
+
// in the tooltip.
|
|
57
|
+
return (
|
|
58
|
+
<Button
|
|
59
|
+
aria-pressed={isSelected}
|
|
60
|
+
className="h-7 min-w-26 px-3 py-0 font-medium text-sm leading-[21px] normal-case"
|
|
61
|
+
data-testid={`${dataTestId}-${optionValue}`}
|
|
62
|
+
key={optionValue}
|
|
63
|
+
onClick={select(optionValue)}
|
|
64
|
+
title={t(optionLabel)}
|
|
65
|
+
variant={isSelected ? 'contained' : 'outlined'}
|
|
66
|
+
>
|
|
67
|
+
<span className="min-w-0 truncate">{t(optionLabel)}</span>
|
|
68
|
+
</Button>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
)}
|
|
72
|
+
</ButtonGroup>
|
|
73
|
+
</div>
|
|
65
74
|
</FormGroup>
|
|
66
75
|
),
|
|
67
76
|
memoProps: [value, disabled, segmentedButtons, additionalMemoProps]
|
package/src/Form/Inputs/Text.tsx
CHANGED
|
@@ -17,6 +17,17 @@ import { TextField, useMemoComponent } from '../..';
|
|
|
17
17
|
import { type InputPropsWithoutGroup, InputType } from './models';
|
|
18
18
|
import PasswordEndAdornment from './PasswordEndAdornment';
|
|
19
19
|
|
|
20
|
+
// Keeps password managers from taking the text field before a password field
|
|
21
|
+
// for a user name and filling it in, and from filling the password itself:
|
|
22
|
+
// `new-password` for the browsers, the opt-out attributes for the extensions
|
|
23
|
+
// (1Password, Bitwarden, LastPass).
|
|
24
|
+
const passwordManagerOptOut = {
|
|
25
|
+
autoComplete: 'new-password',
|
|
26
|
+
'data-1p-ignore': true,
|
|
27
|
+
'data-bwignore': true,
|
|
28
|
+
'data-lpignore': true
|
|
29
|
+
};
|
|
30
|
+
|
|
20
31
|
const Text = ({
|
|
21
32
|
dataTestId,
|
|
22
33
|
label,
|
|
@@ -140,7 +151,8 @@ const Text = ({
|
|
|
140
151
|
htmlInput: {
|
|
141
152
|
'aria-label': label,
|
|
142
153
|
'data-testid': dataTestId || label,
|
|
143
|
-
min: text?.min
|
|
154
|
+
min: text?.min,
|
|
155
|
+
...(equals(type, InputType.Password) && passwordManagerOptOut)
|
|
144
156
|
}
|
|
145
157
|
}
|
|
146
158
|
}}
|
|
@@ -26,7 +26,6 @@ import {
|
|
|
26
26
|
toPairs
|
|
27
27
|
} from 'ramda';
|
|
28
28
|
import { Fragment, useMemo } from 'react';
|
|
29
|
-
import { makeStyles } from 'tss-react/mui';
|
|
30
29
|
|
|
31
30
|
import { GroupDirection } from '..';
|
|
32
31
|
import CollapsibleGroup from '../CollapsibleGroup';
|
|
@@ -104,42 +103,6 @@ export const getInput = cond<
|
|
|
104
103
|
[T, always(TextInput)]
|
|
105
104
|
]);
|
|
106
105
|
|
|
107
|
-
interface StylesProps {
|
|
108
|
-
groupDirection?: GroupDirection;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const useStyles = makeStyles<StylesProps>()((theme, { groupDirection }) => ({
|
|
112
|
-
additionalLabel: {
|
|
113
|
-
marginBottom: theme.spacing(0.5)
|
|
114
|
-
},
|
|
115
|
-
buttons: {
|
|
116
|
-
columnGap: theme.spacing(2),
|
|
117
|
-
display: 'flex',
|
|
118
|
-
flexDirection: 'row',
|
|
119
|
-
justifyContent: 'flex-end'
|
|
120
|
-
},
|
|
121
|
-
divider: {
|
|
122
|
-
margin: equals(groupDirection, GroupDirection.Horizontal)
|
|
123
|
-
? theme.spacing(0, 2)
|
|
124
|
-
: theme.spacing(2, 0)
|
|
125
|
-
},
|
|
126
|
-
groups: {
|
|
127
|
-
display: 'flex',
|
|
128
|
-
flexDirection: equals(groupDirection, GroupDirection.Horizontal)
|
|
129
|
-
? 'row'
|
|
130
|
-
: 'column'
|
|
131
|
-
},
|
|
132
|
-
inputs: {
|
|
133
|
-
display: 'flex',
|
|
134
|
-
flexDirection: 'column',
|
|
135
|
-
margin: theme.spacing(2, 0),
|
|
136
|
-
rowGap: theme.spacing(2)
|
|
137
|
-
},
|
|
138
|
-
inputWrapper: {
|
|
139
|
-
width: '100%'
|
|
140
|
-
}
|
|
141
|
-
}));
|
|
142
|
-
|
|
143
106
|
interface Props {
|
|
144
107
|
areGroupsOpen?: boolean;
|
|
145
108
|
groupDirection?: GroupDirection;
|
|
@@ -161,7 +124,7 @@ const Inputs = ({
|
|
|
161
124
|
areGroupsOpen,
|
|
162
125
|
inputsClassName
|
|
163
126
|
}: Props): JSX.Element => {
|
|
164
|
-
const
|
|
127
|
+
const isHorizontal = equals(groupDirection, GroupDirection.Horizontal);
|
|
165
128
|
const formikContext = useFormikContext<FormikValues>();
|
|
166
129
|
|
|
167
130
|
const groupsName = pluck('name', groups);
|
|
@@ -217,7 +180,7 @@ const Inputs = ({
|
|
|
217
180
|
) as Array<[string | null, Array<InputProps>]>;
|
|
218
181
|
|
|
219
182
|
return (
|
|
220
|
-
<div className={
|
|
183
|
+
<div className={`flex ${isHorizontal ? 'flex-row' : 'flex-col'}`}>
|
|
221
184
|
{normalizedInputsByGroup.map(([groupName, groupedInputs], index) => {
|
|
222
185
|
const hasGroupTitle = not(isNil(groupName));
|
|
223
186
|
|
|
@@ -238,7 +201,9 @@ const Inputs = ({
|
|
|
238
201
|
hasGroupTitle={hasGroupTitle}
|
|
239
202
|
isCollapsible={isCollapsible}
|
|
240
203
|
>
|
|
241
|
-
<div
|
|
204
|
+
<div
|
|
205
|
+
className={`flex flex-col my-4 gap-y-4 ${inputsClassName || ''}`}
|
|
206
|
+
>
|
|
242
207
|
{groupedInputs.map((inputProps) => {
|
|
243
208
|
const key =
|
|
244
209
|
isNotNil(inputProps.label) || isNotEmpty(inputProps.label)
|
|
@@ -257,14 +222,11 @@ const Inputs = ({
|
|
|
257
222
|
const Input = getInput(inputProps.type);
|
|
258
223
|
|
|
259
224
|
return (
|
|
260
|
-
<div className=
|
|
225
|
+
<div className="w-full" key={key as string}>
|
|
261
226
|
{inputProps.additionalLabel && (
|
|
262
227
|
<Typography
|
|
263
|
-
className={
|
|
264
|
-
|
|
265
|
-
inputProps?.additionalLabelClassName
|
|
266
|
-
)}
|
|
267
|
-
variant="body1"
|
|
228
|
+
className={`mb-1 text-base font-bold text-text-primary ${inputProps?.additionalLabelClassName || ''}`}
|
|
229
|
+
variant="h6"
|
|
268
230
|
>
|
|
269
231
|
{inputProps.additionalLabel}
|
|
270
232
|
</Typography>
|
|
@@ -282,13 +244,9 @@ const Inputs = ({
|
|
|
282
244
|
hasGroupTitle &&
|
|
283
245
|
not(equals(lastGroup, groupName as string)) && (
|
|
284
246
|
<Divider
|
|
285
|
-
className={
|
|
247
|
+
className={isHorizontal ? 'my-0 mx-4' : 'my-4 mx-0'}
|
|
286
248
|
flexItem
|
|
287
|
-
orientation={
|
|
288
|
-
equals(groupDirection, GroupDirection.Horizontal)
|
|
289
|
-
? 'vertical'
|
|
290
|
-
: 'horizontal'
|
|
291
|
-
}
|
|
249
|
+
orientation={isHorizontal ? 'vertical' : 'horizontal'}
|
|
292
250
|
/>
|
|
293
251
|
)}
|
|
294
252
|
</Fragment>
|
|
@@ -146,6 +146,10 @@ export interface InputProps {
|
|
|
146
146
|
grid?: {
|
|
147
147
|
alignItems?: string;
|
|
148
148
|
className?: string;
|
|
149
|
+
// A dashed vertical line in the gap between two columns, never left of
|
|
150
|
+
// the first one, whatever the number of columns the layout shows. To set
|
|
151
|
+
// on the grids that place groups of fields side by side.
|
|
152
|
+
columnSeparator?: boolean;
|
|
149
153
|
columns: Array<Omit<InputProps, 'group'>>;
|
|
150
154
|
gridTemplateColumns?: string;
|
|
151
155
|
};
|
|
@@ -171,6 +175,8 @@ export interface InputProps {
|
|
|
171
175
|
};
|
|
172
176
|
required?: boolean;
|
|
173
177
|
segmentedButtons?: {
|
|
178
|
+
// Leaves the label out of the display; it stays the group's accessible name.
|
|
179
|
+
hideLabel?: boolean;
|
|
174
180
|
options: Array<{
|
|
175
181
|
label: string;
|
|
176
182
|
value: string;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { rs } from '@rstest/core';
|
|
2
|
+
import { render } from '@test-setup/render';
|
|
3
|
+
import { screen, waitFor, within } from '@testing-library/react';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import { object } from 'yup';
|
|
6
|
+
|
|
7
|
+
import { Form } from '../Form';
|
|
8
|
+
import { InputType } from '../Inputs/models';
|
|
9
|
+
|
|
10
|
+
const sectionNames = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo'];
|
|
11
|
+
|
|
12
|
+
const SectionContent = (): JSX.Element => <div style={{ height: 400 }} />;
|
|
13
|
+
|
|
14
|
+
const groups = sectionNames.map((name, index) => ({
|
|
15
|
+
name,
|
|
16
|
+
order: index + 1
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const inputs = sectionNames.map((name) => ({
|
|
20
|
+
custom: { Component: SectionContent },
|
|
21
|
+
fieldName: name,
|
|
22
|
+
group: name,
|
|
23
|
+
label: name,
|
|
24
|
+
type: InputType.Custom
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const FormInScrollArea = ({ testId }: { testId: string }): JSX.Element => (
|
|
28
|
+
<div
|
|
29
|
+
data-testid={testId}
|
|
30
|
+
style={{ height: 300, overflowY: 'auto', position: 'relative' }}
|
|
31
|
+
>
|
|
32
|
+
<Form
|
|
33
|
+
groups={groups}
|
|
34
|
+
initialValues={{}}
|
|
35
|
+
inputs={inputs}
|
|
36
|
+
submit={rs.fn()}
|
|
37
|
+
validationSchema={object()}
|
|
38
|
+
/>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const getScrollArea = (testId = 'scroll-area'): HTMLElement =>
|
|
43
|
+
screen.getByTestId(testId);
|
|
44
|
+
|
|
45
|
+
const getTab = (name: string, testId = 'scroll-area'): HTMLElement =>
|
|
46
|
+
within(getScrollArea(testId)).getByRole('tab', { name });
|
|
47
|
+
|
|
48
|
+
const getSelectedTabs = (testId = 'scroll-area'): Array<string> =>
|
|
49
|
+
within(getScrollArea(testId))
|
|
50
|
+
.getAllByRole('tab')
|
|
51
|
+
.filter((tab) => tab.getAttribute('aria-selected') === 'true')
|
|
52
|
+
.map((tab) => tab.getAttribute('aria-label') as string);
|
|
53
|
+
|
|
54
|
+
const scrollToSection = (name: string, testId = 'scroll-area'): void => {
|
|
55
|
+
const scrollArea = getScrollArea(testId);
|
|
56
|
+
const anchor = within(scrollArea).getByTestId(`${name}-header`);
|
|
57
|
+
|
|
58
|
+
scrollArea.scrollTop +=
|
|
59
|
+
anchor.getBoundingClientRect().top -
|
|
60
|
+
scrollArea.getBoundingClientRect().top -
|
|
61
|
+
48;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
describe('Form section navigation', () => {
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
expect.hasAssertions();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('activates the first tab when the form is displayed', async () => {
|
|
70
|
+
render(<FormInScrollArea testId="scroll-area" />);
|
|
71
|
+
|
|
72
|
+
expect(await screen.findByRole('tab', { name: 'Alpha' })).toBeVisible();
|
|
73
|
+
expect(getSelectedTabs()).toStrictEqual(['Alpha']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('activates the tab of the section in view when the form is scrolled down then up', async () => {
|
|
77
|
+
render(<FormInScrollArea testId="scroll-area" />);
|
|
78
|
+
await screen.findByRole('tab', { name: 'Alpha' });
|
|
79
|
+
|
|
80
|
+
scrollToSection('Charlie');
|
|
81
|
+
|
|
82
|
+
await waitFor(() => {
|
|
83
|
+
expect(getSelectedTabs()).toStrictEqual(['Charlie']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
scrollToSection('Bravo');
|
|
87
|
+
|
|
88
|
+
await waitFor(() => {
|
|
89
|
+
expect(getSelectedTabs()).toStrictEqual(['Bravo']);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('activates the last tab when the form is scrolled to the bottom', async () => {
|
|
94
|
+
render(<FormInScrollArea testId="scroll-area" />);
|
|
95
|
+
await screen.findByRole('tab', { name: 'Alpha' });
|
|
96
|
+
|
|
97
|
+
const scrollArea = getScrollArea();
|
|
98
|
+
scrollArea.scrollTop = scrollArea.scrollHeight;
|
|
99
|
+
|
|
100
|
+
await waitFor(() => {
|
|
101
|
+
expect(getSelectedTabs()).toStrictEqual(['Echo']);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('scrolls to the section and keeps its tab active all along when a tab is clicked', async () => {
|
|
106
|
+
render(<FormInScrollArea testId="scroll-area" />);
|
|
107
|
+
await screen.findByRole('tab', { name: 'Alpha' });
|
|
108
|
+
|
|
109
|
+
const tablist = within(getScrollArea()).getByRole('tablist');
|
|
110
|
+
const selectedWhileScrolling = new Set<string>();
|
|
111
|
+
const observer = new MutationObserver(() => {
|
|
112
|
+
for (const tab of getSelectedTabs()) {
|
|
113
|
+
selectedWhileScrolling.add(tab);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
observer.observe(tablist, {
|
|
117
|
+
attributeFilter: ['aria-selected'],
|
|
118
|
+
attributes: true,
|
|
119
|
+
subtree: true
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await userEvent.click(getTab('Delta'));
|
|
123
|
+
|
|
124
|
+
await waitFor(() => {
|
|
125
|
+
expect(getScrollArea().scrollTop).toBeGreaterThan(0);
|
|
126
|
+
});
|
|
127
|
+
await waitFor(() => {
|
|
128
|
+
expect(
|
|
129
|
+
within(getScrollArea())
|
|
130
|
+
.getByTestId('Delta-header')
|
|
131
|
+
.getBoundingClientRect().top -
|
|
132
|
+
getScrollArea().getBoundingClientRect().top
|
|
133
|
+
).toBeLessThan(100);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Reaching the section is not the end of the smooth scroll: keep observing
|
|
137
|
+
// until the scroll position stops moving.
|
|
138
|
+
let previousScrollTop = -1;
|
|
139
|
+
await waitFor(() => {
|
|
140
|
+
const { scrollTop } = getScrollArea();
|
|
141
|
+
const hasStopped = scrollTop === previousScrollTop;
|
|
142
|
+
previousScrollTop = scrollTop;
|
|
143
|
+
|
|
144
|
+
expect(hasStopped).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
observer.disconnect();
|
|
147
|
+
|
|
148
|
+
await waitFor(() => {
|
|
149
|
+
expect(getSelectedTabs()).toStrictEqual(['Delta']);
|
|
150
|
+
});
|
|
151
|
+
expect([...selectedWhileScrolling]).toStrictEqual(['Delta']);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('only scrolls its own form when two forms share the same group names', async () => {
|
|
155
|
+
render(
|
|
156
|
+
<>
|
|
157
|
+
<FormInScrollArea testId="first-form" />
|
|
158
|
+
<FormInScrollArea testId="second-form" />
|
|
159
|
+
</>
|
|
160
|
+
);
|
|
161
|
+
await waitFor(() => {
|
|
162
|
+
expect(screen.getAllByRole('tab', { name: 'Alpha' })).toHaveLength(2);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await userEvent.click(getTab('Charlie', 'second-form'));
|
|
166
|
+
|
|
167
|
+
await waitFor(() => {
|
|
168
|
+
expect(getScrollArea('second-form').scrollTop).toBeGreaterThan(0);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(getScrollArea('first-form').scrollTop).toBe(0);
|
|
172
|
+
expect(getSelectedTabs('first-form')).toStrictEqual(['Alpha']);
|
|
173
|
+
});
|
|
174
|
+
});
|