@centreon/ui 26.12.3 → 26.12.5

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.5",
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
+ };
@@ -51,6 +51,15 @@ export const WithDescription: Story = {
51
51
  render: Default.render
52
52
  };
53
53
 
54
+ export const WithInlineDescription: Story = {
55
+ args: {
56
+ description: 'Configure the hosts monitored by your platform',
57
+ descriptionPosition: 'inline',
58
+ title: 'Hosts'
59
+ },
60
+ render: Default.render
61
+ };
62
+
54
63
  export const WithMenu: Story = {
55
64
  args: {
56
65
  ...WithDescription.args,
@@ -7,9 +7,11 @@ type PageHeaderMainProps = {
7
7
  };
8
8
 
9
9
  const PageHeaderMain = ({ children }: PageHeaderMainProps): ReactElement => {
10
- const { classes } = useStyles();
10
+ const { classes, cx } = useStyles();
11
11
 
12
- return <div className={classes.pageHeaderMain}>{children}</div>;
12
+ return (
13
+ <div className={cx(classes.pageHeaderMain, 'min-w-0')}>{children}</div>
14
+ );
13
15
  };
14
16
 
15
17
  export { PageHeaderMain };
@@ -8,37 +8,80 @@ import { useStyles } from './PageHeader.styles';
8
8
  type PageHeaderTitleProps = {
9
9
  actions?: ReactNode;
10
10
  description?: string;
11
+ descriptionPosition?: 'below' | 'inline';
11
12
  title: string;
12
13
  };
13
14
 
14
15
  const PageHeaderTitle = ({
15
16
  actions,
16
17
  title,
17
- description
18
+ description,
19
+ descriptionPosition = 'below'
18
20
  }: PageHeaderTitleProps): ReactElement => {
19
21
  const { classes } = useStyles();
20
22
 
23
+ const isInline = descriptionPosition === 'inline';
24
+
25
+ const descriptionElement = description && (
26
+ <TextOverflowTooltip label={description}>
27
+ <MuiTypography
28
+ aria-label="page header description"
29
+ className={classes.pageHeaderTitleDescription}
30
+ variant="body2"
31
+ >
32
+ {description}
33
+ </MuiTypography>
34
+ </TextOverflowTooltip>
35
+ );
36
+
37
+ // Inline, the title owns its typography: the `pageHeaderTitle` rules that
38
+ // style the `h1` of the stacked layout only apply to that layout.
39
+ const titleElement = isInline ? (
40
+ <MuiTypography
41
+ aria-label="page header title"
42
+ className="m-0 shrink-0 font-bold leading-none"
43
+ component="h1"
44
+ variant="h5"
45
+ >
46
+ {title}
47
+ </MuiTypography>
48
+ ) : (
49
+ <MuiTypography aria-label="page header title" variant="h1">
50
+ {title}
51
+ </MuiTypography>
52
+ );
53
+
54
+ const actionsElement = actions && (
55
+ <div className={classes.pageHeaderTitleActions}>{actions}</div>
56
+ );
57
+
58
+ if (isInline) {
59
+ return (
60
+ <div className="flex min-w-0 items-baseline gap-4 self-start">
61
+ {titleElement}
62
+ {description && (
63
+ <>
64
+ <span
65
+ aria-hidden
66
+ className="shrink-0 text-2xl font-light leading-none text-header-page-description"
67
+ >
68
+ |
69
+ </span>
70
+ <div className="min-w-0">{descriptionElement}</div>
71
+ </>
72
+ )}
73
+ {actionsElement}
74
+ </div>
75
+ );
76
+ }
77
+
21
78
  return (
22
79
  <div className={classes.pageHeaderTitle}>
23
80
  <span>
24
- <MuiTypography aria-label="page header title" variant="h1">
25
- {title}
26
- </MuiTypography>
27
- {actions && (
28
- <div className={classes.pageHeaderTitleActions}>{actions}</div>
29
- )}
81
+ {titleElement}
82
+ {actionsElement}
30
83
  </span>
31
- {description && (
32
- <TextOverflowTooltip label={description}>
33
- <MuiTypography
34
- aria-label="page header description"
35
- className={classes.pageHeaderTitleDescription}
36
- variant="body2"
37
- >
38
- {description}
39
- </MuiTypography>
40
- </TextOverflowTooltip>
41
- )}
84
+ {descriptionElement}
42
85
  </div>
43
86
  );
44
87
  };
@@ -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 => {