@centreon/ui 26.12.2 → 26.12.4
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.tsx +4 -3
- package/src/Form/Inputs/Grid.tsx +1 -2
- package/src/Form/Inputs/index.tsx +10 -52
- 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/Tabs/Tabs.tsx +7 -1
package/package.json
CHANGED
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
|
|
11
11
|
import { useCallback, useState } from 'react';
|
|
12
12
|
import { useTranslation } from 'react-i18next';
|
|
13
|
-
import { makeStyles } from 'tss-react/mui';
|
|
14
13
|
|
|
15
14
|
import type { Group } from './Inputs/models';
|
|
16
15
|
|
|
@@ -23,22 +22,6 @@ interface Props {
|
|
|
23
22
|
isCollapsible: boolean;
|
|
24
23
|
}
|
|
25
24
|
|
|
26
|
-
const useStyles = makeStyles()((theme) => ({
|
|
27
|
-
expandCollapseIcon: {
|
|
28
|
-
justifySelf: 'flex-end'
|
|
29
|
-
},
|
|
30
|
-
groupTitleContainer: {
|
|
31
|
-
alignItems: 'center',
|
|
32
|
-
columnGap: theme.spacing(1),
|
|
33
|
-
display: 'flex',
|
|
34
|
-
flexDirection: 'row'
|
|
35
|
-
},
|
|
36
|
-
title: {},
|
|
37
|
-
tooltip: {
|
|
38
|
-
maxWidth: theme.spacing(60)
|
|
39
|
-
}
|
|
40
|
-
}));
|
|
41
|
-
|
|
42
25
|
const CollapsibleGroup = ({
|
|
43
26
|
children,
|
|
44
27
|
isCollapsible,
|
|
@@ -47,7 +30,6 @@ const CollapsibleGroup = ({
|
|
|
47
30
|
defaultIsOpen = false,
|
|
48
31
|
className
|
|
49
32
|
}: Props): JSX.Element => {
|
|
50
|
-
const { classes, cx } = useStyles();
|
|
51
33
|
const { t } = useTranslation();
|
|
52
34
|
|
|
53
35
|
const [isOpen, setIsOpen] = useState(isCollapsible && defaultIsOpen);
|
|
@@ -56,7 +38,7 @@ const CollapsibleGroup = ({
|
|
|
56
38
|
setIsOpen((currentIsOpen) => !currentIsOpen);
|
|
57
39
|
};
|
|
58
40
|
|
|
59
|
-
const containerClassName = className || ''
|
|
41
|
+
const containerClassName = `flex items-center gap-x-2 ${className || ''}`;
|
|
60
42
|
|
|
61
43
|
const CollapseIcon = isOpen ? ExpandLess : ExpandMore;
|
|
62
44
|
const ContainerComponent = useCallback(
|
|
@@ -66,7 +48,7 @@ const CollapsibleGroup = ({
|
|
|
66
48
|
isCollapsible ? (
|
|
67
49
|
<ListItemButton
|
|
68
50
|
aria-label={group?.name}
|
|
69
|
-
className={`${
|
|
51
|
+
className={`${containerClassName} bg-background-listing-header rounded-lg`}
|
|
70
52
|
dense
|
|
71
53
|
disableGutters
|
|
72
54
|
disableRipple
|
|
@@ -75,18 +57,9 @@ const CollapsibleGroup = ({
|
|
|
75
57
|
{containerComponentChildren}
|
|
76
58
|
</ListItemButton>
|
|
77
59
|
) : (
|
|
78
|
-
<Box className={
|
|
79
|
-
{containerComponentChildren}
|
|
80
|
-
</Box>
|
|
60
|
+
<Box className={containerClassName}>{containerComponentChildren}</Box>
|
|
81
61
|
),
|
|
82
|
-
[
|
|
83
|
-
isCollapsible,
|
|
84
|
-
classes.groupTitleContainer,
|
|
85
|
-
containerClassName,
|
|
86
|
-
cx,
|
|
87
|
-
group?.name,
|
|
88
|
-
toggle
|
|
89
|
-
]
|
|
62
|
+
[isCollapsible, containerClassName, group?.name, toggle]
|
|
90
63
|
);
|
|
91
64
|
|
|
92
65
|
return (
|
|
@@ -100,7 +73,7 @@ const CollapsibleGroup = ({
|
|
|
100
73
|
data-testid={`${group?.name}-header`}
|
|
101
74
|
>
|
|
102
75
|
<Typography
|
|
103
|
-
className="groupText scroll-m-12 snap-start"
|
|
76
|
+
className="groupText scroll-m-12 snap-start text-base font-medium"
|
|
104
77
|
variant="h6"
|
|
105
78
|
{...group?.titleAttributes}
|
|
106
79
|
data-section-group-form-id={group?.name}
|
|
@@ -110,7 +83,7 @@ const CollapsibleGroup = ({
|
|
|
110
83
|
{group?.EndIcon && (
|
|
111
84
|
<Tooltip
|
|
112
85
|
classes={{
|
|
113
|
-
tooltip:
|
|
86
|
+
tooltip: 'max-w-120'
|
|
114
87
|
}}
|
|
115
88
|
placement="top"
|
|
116
89
|
title={group?.TooltipContent ? <group.TooltipContent /> : ''}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { Typography } from '@mui/material';
|
|
2
2
|
|
|
3
|
+
import { ThemeMode, userAtom } from '@centreon/ui-context';
|
|
4
|
+
|
|
3
5
|
import { faker } from '@faker-js/faker';
|
|
4
6
|
import { rs } from '@rstest/core';
|
|
5
7
|
import { render } from '@test-setup/render';
|
|
6
8
|
import { screen, waitFor, within } from '@testing-library/react';
|
|
7
9
|
import userEvent from '@testing-library/user-event';
|
|
8
10
|
import { useFormikContext } from 'formik';
|
|
11
|
+
import { createStore, Provider } from 'jotai';
|
|
9
12
|
import { object } from 'yup';
|
|
10
13
|
|
|
11
14
|
import type { SelectEntry } from '..';
|
|
12
15
|
import { Button } from '../components';
|
|
16
|
+
import ThemeProvider from '../ThemeProvider';
|
|
13
17
|
import { Form } from './Form';
|
|
14
18
|
import { type InputProps, InputType } from './Inputs/models';
|
|
15
19
|
|
|
@@ -443,3 +447,107 @@ describe('Form with sections', () => {
|
|
|
443
447
|
);
|
|
444
448
|
});
|
|
445
449
|
});
|
|
450
|
+
|
|
451
|
+
// The theme mode comes from the user atom, and the Tailwind tokens from the
|
|
452
|
+
// `dark` class on the document.
|
|
453
|
+
const initializeFormTitles = (themeMode: ThemeMode): void => {
|
|
454
|
+
const store = createStore();
|
|
455
|
+
store.set(userAtom, { ...store.get(userAtom), themeMode });
|
|
456
|
+
|
|
457
|
+
document.documentElement.classList.toggle(
|
|
458
|
+
'dark',
|
|
459
|
+
themeMode === ThemeMode.dark
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
render(
|
|
463
|
+
<Provider store={store}>
|
|
464
|
+
<ThemeProvider>
|
|
465
|
+
<Form
|
|
466
|
+
areGroupsOpen
|
|
467
|
+
groups={[{ name: 'Titles section', order: 1 }]}
|
|
468
|
+
initialValues={{ gridField: '', topLevelField: '' }}
|
|
469
|
+
inputs={[
|
|
470
|
+
{
|
|
471
|
+
additionalLabel: 'Top level block title',
|
|
472
|
+
fieldName: 'topLevelField',
|
|
473
|
+
group: 'Titles section',
|
|
474
|
+
label: 'Top level field',
|
|
475
|
+
type: InputType.Text
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
fieldName: '',
|
|
479
|
+
grid: {
|
|
480
|
+
columns: [
|
|
481
|
+
{
|
|
482
|
+
additionalLabel: 'Grid block title',
|
|
483
|
+
fieldName: 'gridField',
|
|
484
|
+
label: 'Grid field',
|
|
485
|
+
type: InputType.Text
|
|
486
|
+
}
|
|
487
|
+
]
|
|
488
|
+
},
|
|
489
|
+
group: 'Titles section',
|
|
490
|
+
label: 'Grid',
|
|
491
|
+
type: InputType.Grid
|
|
492
|
+
}
|
|
493
|
+
]}
|
|
494
|
+
isCollapsible
|
|
495
|
+
submit={rs.fn()}
|
|
496
|
+
validationSchema={object()}
|
|
497
|
+
/>
|
|
498
|
+
</ThemeProvider>
|
|
499
|
+
</Provider>
|
|
500
|
+
);
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const textPrimaryColor = {
|
|
504
|
+
[ThemeMode.dark]: 'rgb(255, 255, 255)',
|
|
505
|
+
[ThemeMode.light]: 'rgb(0, 0, 0)'
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
[ThemeMode.light, ThemeMode.dark].forEach((themeMode) => {
|
|
509
|
+
describe(`Form titles in ${themeMode} mode`, () => {
|
|
510
|
+
beforeEach(() => {
|
|
511
|
+
expect.hasAssertions();
|
|
512
|
+
});
|
|
513
|
+
beforeEach(() => initializeFormTitles(themeMode));
|
|
514
|
+
|
|
515
|
+
afterEach(() => {
|
|
516
|
+
document.documentElement.classList.remove('dark');
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('displays the section title in 16px medium on a 24px line', async () => {
|
|
520
|
+
const title = await screen.findByText('Titles section');
|
|
521
|
+
|
|
522
|
+
expect(title).toHaveStyle({
|
|
523
|
+
fontSize: '16px',
|
|
524
|
+
fontWeight: '500',
|
|
525
|
+
lineHeight: '24px'
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('rounds the collapsible section header with an 8px radius', async () => {
|
|
530
|
+
expect(await screen.findByLabelText('Titles section')).toHaveStyle({
|
|
531
|
+
borderRadius: '8px'
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('displays the grid block title in 16px bold with the text colour', async () => {
|
|
536
|
+
expect(await screen.findByText('Grid block title')).toHaveStyle({
|
|
537
|
+
color: textPrimaryColor[themeMode],
|
|
538
|
+
fontSize: '16px',
|
|
539
|
+
fontWeight: '700',
|
|
540
|
+
lineHeight: '24px'
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('displays the top level block title like the grid block title', async () => {
|
|
545
|
+
expect(await screen.findByText('Top level block title')).toHaveStyle({
|
|
546
|
+
color: textPrimaryColor[themeMode],
|
|
547
|
+
fontSize: '16px',
|
|
548
|
+
fontWeight: '700',
|
|
549
|
+
lineHeight: '24px'
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
});
|
package/src/Form/Form.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
type FormikSharedConfig,
|
|
5
5
|
type FormikValues
|
|
6
6
|
} from 'formik';
|
|
7
|
-
import type
|
|
7
|
+
import { type ComponentType, useRef } from 'react';
|
|
8
8
|
import type { Schema } from 'yup';
|
|
9
9
|
|
|
10
10
|
import { useStyles } from './Form.styles';
|
|
@@ -55,6 +55,7 @@ const Form = <T extends object>({
|
|
|
55
55
|
...formikSharedConfig
|
|
56
56
|
}: FormProps<T>): JSX.Element => {
|
|
57
57
|
const { cx, classes } = useStyles();
|
|
58
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
58
59
|
|
|
59
60
|
if (isLoading) {
|
|
60
61
|
return (
|
|
@@ -79,8 +80,8 @@ const Form = <T extends object>({
|
|
|
79
80
|
validationSchema={validationSchema}
|
|
80
81
|
{...formikSharedConfig}
|
|
81
82
|
>
|
|
82
|
-
<div>
|
|
83
|
-
<FormSection groups={groups} />
|
|
83
|
+
<div ref={containerRef}>
|
|
84
|
+
<FormSection containerRef={containerRef} groups={groups} />
|
|
84
85
|
{children}
|
|
85
86
|
<div className={cx(className, classes.form)}>
|
|
86
87
|
<Inputs
|
package/src/Form/Inputs/Grid.tsx
CHANGED
|
@@ -45,8 +45,7 @@ const Grid = ({
|
|
|
45
45
|
<Box key={key as string} sx={{ width: '100%' }}>
|
|
46
46
|
{field.additionalLabel && (
|
|
47
47
|
<Typography
|
|
48
|
-
className={field?.additionalLabelClassName}
|
|
49
|
-
sx={{ color: 'primary.main', marginBottom: 0.5 }}
|
|
48
|
+
className={`mb-1 text-base font-bold text-text-primary ${field?.additionalLabelClassName || ''}`}
|
|
50
49
|
variant="h6"
|
|
51
50
|
>
|
|
52
51
|
{field.additionalLabel}
|
|
@@ -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>
|
|
@@ -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
|
+
});
|
|
@@ -1,22 +1,35 @@
|
|
|
1
1
|
import { Box, type TabsProps } from '@mui/material';
|
|
2
2
|
|
|
3
3
|
import { isNil } from 'ramda';
|
|
4
|
-
import { useMemo } from 'react';
|
|
4
|
+
import { type RefObject, useMemo } from 'react';
|
|
5
5
|
|
|
6
6
|
import { Tabs } from '../../components/Tabs';
|
|
7
7
|
import type { Group } from '../Inputs/models';
|
|
8
|
-
import { useNavigateToSection } from './navigateToSection';
|
|
9
8
|
import { groupToTab } from './PanelTabs';
|
|
9
|
+
import { useActiveSection } from './useActiveSection';
|
|
10
10
|
|
|
11
11
|
export interface FormSectionProps extends TabsProps {
|
|
12
|
+
containerRef: RefObject<HTMLElement | null>;
|
|
12
13
|
groups?: Array<Group>;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
const FormSection = ({ groups }: FormSectionProps) => {
|
|
16
|
-
const navigateToSection = useNavigateToSection();
|
|
16
|
+
const FormSection = ({ groups, containerRef }: FormSectionProps) => {
|
|
17
17
|
const tabMemo = useMemo(() => groupToTab(groups), [groups]);
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
const hasTabs = !isNil(groups) && groups.length >= 4;
|
|
20
|
+
|
|
21
|
+
const sectionNames = useMemo(
|
|
22
|
+
() => tabMemo.map(({ value }) => value),
|
|
23
|
+
[tabMemo]
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const { activeSection, goToSection } = useActiveSection({
|
|
27
|
+
containerRef,
|
|
28
|
+
isEnabled: hasTabs,
|
|
29
|
+
sectionNames
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!hasTabs) {
|
|
20
33
|
return null;
|
|
21
34
|
}
|
|
22
35
|
|
|
@@ -24,8 +37,9 @@ const FormSection = ({ groups }: FormSectionProps) => {
|
|
|
24
37
|
<Box className="sticky top-0 bg-background-paper z-100">
|
|
25
38
|
<Tabs
|
|
26
39
|
defaultTab={tabMemo[0].value}
|
|
27
|
-
onChange={
|
|
40
|
+
onChange={goToSection}
|
|
28
41
|
scrollButtons={false}
|
|
42
|
+
selectedTab={activeSection}
|
|
29
43
|
tabs={tabMemo}
|
|
30
44
|
variant="scrollable"
|
|
31
45
|
/>
|
|
@@ -1,9 +1,27 @@
|
|
|
1
|
-
export const
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
)
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
export const sectionAnchorAttribute = 'data-section-group-form-id';
|
|
2
|
+
|
|
3
|
+
export const getSectionAnchors = (container: HTMLElement): Array<HTMLElement> =>
|
|
4
|
+
Array.from(
|
|
5
|
+
container.querySelectorAll<HTMLElement>(`[${sectionAnchorAttribute}]`)
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
interface NavigateToSectionProps {
|
|
9
|
+
container: HTMLElement | null;
|
|
10
|
+
sectionName: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The lookup stays inside the form: two forms can share a group name.
|
|
14
|
+
export const navigateToSection = ({
|
|
15
|
+
container,
|
|
16
|
+
sectionName
|
|
17
|
+
}: NavigateToSectionProps): void => {
|
|
18
|
+
if (!container) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const section = getSectionAnchors(container).find(
|
|
23
|
+
(anchor) => anchor.getAttribute(sectionAnchorAttribute) === sectionName
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
section?.scrollIntoView({ behavior: 'smooth' });
|
|
9
27
|
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type RefObject,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState
|
|
7
|
+
} from 'react';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
getSectionAnchors,
|
|
11
|
+
navigateToSection,
|
|
12
|
+
sectionAnchorAttribute
|
|
13
|
+
} from './navigateToSection';
|
|
14
|
+
|
|
15
|
+
// Distance from the top of the scroll area under which a section is the one in
|
|
16
|
+
// view: the pinned tab bar plus the scroll margin of the group titles.
|
|
17
|
+
const sectionOffset = 64;
|
|
18
|
+
// `scrollend` is missing from older browsers, and is not fired when the
|
|
19
|
+
// clicked section is already in place.
|
|
20
|
+
const scrollEndFallbackDelay = 1000;
|
|
21
|
+
|
|
22
|
+
const getScrollParent = (element: HTMLElement | null): HTMLElement | null => {
|
|
23
|
+
let parent = element?.parentElement ?? null;
|
|
24
|
+
|
|
25
|
+
while (parent && parent !== document.body) {
|
|
26
|
+
// An ancestor that can scroll but has nothing to scroll (no height of its
|
|
27
|
+
// own) is not the one the user moves: keep looking further up.
|
|
28
|
+
if (
|
|
29
|
+
/(auto|scroll)/.test(getComputedStyle(parent).overflowY) &&
|
|
30
|
+
parent.scrollHeight > parent.clientHeight
|
|
31
|
+
) {
|
|
32
|
+
return parent;
|
|
33
|
+
}
|
|
34
|
+
parent = parent.parentElement;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
interface ComputeActiveSectionProps {
|
|
41
|
+
container: HTMLElement;
|
|
42
|
+
scrollParent: HTMLElement | null;
|
|
43
|
+
sectionNames: Array<string>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const computeActiveSection = ({
|
|
47
|
+
container,
|
|
48
|
+
scrollParent,
|
|
49
|
+
sectionNames
|
|
50
|
+
}: ComputeActiveSectionProps): string => {
|
|
51
|
+
const scrollingElement = scrollParent ?? document.documentElement;
|
|
52
|
+
|
|
53
|
+
// A tab can have no section in the form: only the sections displayed count.
|
|
54
|
+
const sections = getSectionAnchors(container)
|
|
55
|
+
.filter((anchor) =>
|
|
56
|
+
sectionNames.includes(anchor.getAttribute(sectionAnchorAttribute) || '')
|
|
57
|
+
)
|
|
58
|
+
.map((anchor) => ({
|
|
59
|
+
name: anchor.getAttribute(sectionAnchorAttribute) as string,
|
|
60
|
+
top: anchor.getBoundingClientRect().top
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
if (!sections.length) {
|
|
64
|
+
return sectionNames[0];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const isScrolledToBottom =
|
|
68
|
+
scrollingElement.scrollTop > 0 &&
|
|
69
|
+
scrollingElement.scrollTop + scrollingElement.clientHeight >=
|
|
70
|
+
scrollingElement.scrollHeight - 1;
|
|
71
|
+
|
|
72
|
+
// The last sections can be too short to ever reach the top.
|
|
73
|
+
if (isScrolledToBottom) {
|
|
74
|
+
return (
|
|
75
|
+
[...sectionNames]
|
|
76
|
+
.reverse()
|
|
77
|
+
.find((name) => sections.some((section) => section.name === name)) ||
|
|
78
|
+
sectionNames[0]
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const line =
|
|
83
|
+
(scrollParent ? scrollParent.getBoundingClientRect().top : 0) +
|
|
84
|
+
sectionOffset;
|
|
85
|
+
|
|
86
|
+
const passedSections = sections.filter(({ top }) => top <= line);
|
|
87
|
+
|
|
88
|
+
if (!passedSections.length) {
|
|
89
|
+
return sectionNames.find((name) =>
|
|
90
|
+
sections.some((section) => section.name === name)
|
|
91
|
+
) as string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return passedSections.reduce((closest, section) =>
|
|
95
|
+
section.top > closest.top ? section : closest
|
|
96
|
+
).name;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
interface UseActiveSectionProps {
|
|
100
|
+
containerRef: RefObject<HTMLElement | null>;
|
|
101
|
+
isEnabled: boolean;
|
|
102
|
+
sectionNames: Array<string>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface UseActiveSection {
|
|
106
|
+
activeSection: string;
|
|
107
|
+
goToSection: (sectionName: string) => void;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const useActiveSection = ({
|
|
111
|
+
containerRef,
|
|
112
|
+
isEnabled,
|
|
113
|
+
sectionNames
|
|
114
|
+
}: UseActiveSectionProps): UseActiveSection => {
|
|
115
|
+
const [activeSection, setActiveSection] = useState(sectionNames[0]);
|
|
116
|
+
|
|
117
|
+
const isScrollingToSection = useRef(false);
|
|
118
|
+
const scrollTargetRef = useRef<HTMLElement | Window | null>(null);
|
|
119
|
+
const fallbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
|
120
|
+
undefined
|
|
121
|
+
);
|
|
122
|
+
const stopWaitingRef = useRef<(() => void) | undefined>(undefined);
|
|
123
|
+
const refreshActiveSectionRef = useRef<(() => void) | undefined>(undefined);
|
|
124
|
+
|
|
125
|
+
const sectionNamesKey = sectionNames.join('\n');
|
|
126
|
+
|
|
127
|
+
const stopWaitingForScrollEnd = useCallback((): void => {
|
|
128
|
+
stopWaitingRef.current?.();
|
|
129
|
+
stopWaitingRef.current = undefined;
|
|
130
|
+
clearTimeout(fallbackTimeoutRef.current);
|
|
131
|
+
isScrollingToSection.current = false;
|
|
132
|
+
}, []);
|
|
133
|
+
|
|
134
|
+
// The scroll events fired while waiting are ignored, and none fires when the
|
|
135
|
+
// clicked section is already in place: the position is read again once done.
|
|
136
|
+
const endScrollWait = useCallback((): void => {
|
|
137
|
+
stopWaitingForScrollEnd();
|
|
138
|
+
refreshActiveSectionRef.current?.();
|
|
139
|
+
}, [stopWaitingForScrollEnd]);
|
|
140
|
+
|
|
141
|
+
const goToSection = useCallback(
|
|
142
|
+
(sectionName: string): void => {
|
|
143
|
+
stopWaitingForScrollEnd();
|
|
144
|
+
|
|
145
|
+
// The clicked tab is the active one until the smooth scroll ends, so the
|
|
146
|
+
// highlight does not run through the sections in between.
|
|
147
|
+
setActiveSection(sectionName);
|
|
148
|
+
isScrollingToSection.current = true;
|
|
149
|
+
|
|
150
|
+
const target = scrollTargetRef.current;
|
|
151
|
+
target?.addEventListener('scrollend', endScrollWait, { once: true });
|
|
152
|
+
stopWaitingRef.current = () =>
|
|
153
|
+
target?.removeEventListener('scrollend', endScrollWait);
|
|
154
|
+
fallbackTimeoutRef.current = setTimeout(
|
|
155
|
+
endScrollWait,
|
|
156
|
+
scrollEndFallbackDelay
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
navigateToSection({ container: containerRef.current, sectionName });
|
|
160
|
+
},
|
|
161
|
+
[containerRef, endScrollWait, stopWaitingForScrollEnd]
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
const container = containerRef.current;
|
|
166
|
+
|
|
167
|
+
if (!isEnabled || !container) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const scrollParent = getScrollParent(container);
|
|
172
|
+
const scrollTarget = scrollParent ?? window;
|
|
173
|
+
scrollTargetRef.current = scrollTarget;
|
|
174
|
+
|
|
175
|
+
let animationFrame = 0;
|
|
176
|
+
|
|
177
|
+
const refresh = (): void => {
|
|
178
|
+
setActiveSection(
|
|
179
|
+
computeActiveSection({
|
|
180
|
+
container,
|
|
181
|
+
scrollParent,
|
|
182
|
+
sectionNames: sectionNamesKey.split('\n')
|
|
183
|
+
})
|
|
184
|
+
);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const update = (): void => {
|
|
188
|
+
if (isScrollingToSection.current) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
refresh();
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const onScroll = (): void => {
|
|
196
|
+
cancelAnimationFrame(animationFrame);
|
|
197
|
+
animationFrame = requestAnimationFrame(update);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
refreshActiveSectionRef.current = refresh;
|
|
201
|
+
scrollTarget.addEventListener('scroll', onScroll, { passive: true });
|
|
202
|
+
update();
|
|
203
|
+
|
|
204
|
+
return () => {
|
|
205
|
+
scrollTarget.removeEventListener('scroll', onScroll);
|
|
206
|
+
cancelAnimationFrame(animationFrame);
|
|
207
|
+
refreshActiveSectionRef.current = undefined;
|
|
208
|
+
stopWaitingForScrollEnd();
|
|
209
|
+
};
|
|
210
|
+
}, [containerRef, isEnabled, sectionNamesKey, stopWaitingForScrollEnd]);
|
|
211
|
+
|
|
212
|
+
return { activeSection, goToSection };
|
|
213
|
+
};
|
|
@@ -15,6 +15,7 @@ export interface TabI {
|
|
|
15
15
|
type Props = {
|
|
16
16
|
children?: Array<JSX.Element>;
|
|
17
17
|
defaultTab: string;
|
|
18
|
+
selectedTab?: string;
|
|
18
19
|
tabList?: TabsProps;
|
|
19
20
|
variant?: 'standard' | 'scrollable' | 'fullWidth';
|
|
20
21
|
scrollButtons?: boolean | 'auto';
|
|
@@ -25,6 +26,7 @@ type Props = {
|
|
|
25
26
|
export const Tabs = ({
|
|
26
27
|
children,
|
|
27
28
|
defaultTab,
|
|
29
|
+
selectedTab: controlledTab,
|
|
28
30
|
tabs,
|
|
29
31
|
tabList,
|
|
30
32
|
variant,
|
|
@@ -33,7 +35,11 @@ export const Tabs = ({
|
|
|
33
35
|
}: Props): JSX.Element => {
|
|
34
36
|
const { classes } = useTabsStyles();
|
|
35
37
|
|
|
36
|
-
const [
|
|
38
|
+
const [internalTab, setSelectedTab] = useState(defaultTab);
|
|
39
|
+
|
|
40
|
+
// The parent drives the tab when it passes one (e.g. a form following its
|
|
41
|
+
// scroll); otherwise the tab keeps its own state.
|
|
42
|
+
const selectedTab = controlledTab ?? internalTab;
|
|
37
43
|
|
|
38
44
|
const changeTab = useCallback(
|
|
39
45
|
(_: React.SyntheticEvent, newValue: string): void => {
|