@centreon/ui 26.12.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@centreon/ui",
3
- "version": "26.12.3",
3
+ "version": "26.12.4",
4
4
  "description": "Centreon UI Components",
5
5
  "scripts": {
6
6
  "update:deps": "pnpx npm-check-updates -i --format group",
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 { ComponentType } from 'react';
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
@@ -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
- if (isNil(groups) || groups.length < 4) {
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={navigateToSection}
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 useNavigateToSection = () => {
2
- return (sectionName: string) => {
3
- const section = document.querySelector(
4
- `[data-section-group-form-id="${sectionName}"]`
5
- );
6
-
7
- section?.scrollIntoView({ behavior: 'smooth' });
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 [selectedTab, setSelectedTab] = useState(defaultTab);
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 => {