Skip to content

Commit 569a43e

Browse files
SaidMararZeeshanTamboli
andauthoredJul 23, 2023
[Tabs] Fix and improve visibility of tab scroll buttons using the IntersectionObserver API (#36071)
Signed-off-by: SaidMarar <mararsaid@gmail.com> Co-authored-by: ZeeshanTamboli <zeeshan.tamboli@gmail.com>
1 parent 5a64f5f commit 569a43e

File tree

3 files changed

+189
-153
lines changed

3 files changed

+189
-153
lines changed
 

‎packages/mui-material/src/Tabs/Tabs.js

+58-38
Original file line numberDiff line numberDiff line change
@@ -312,10 +312,9 @@ const Tabs = React.forwardRef(function Tabs(inProps, ref) {
312312

313313
const [mounted, setMounted] = React.useState(false);
314314
const [indicatorStyle, setIndicatorStyle] = React.useState(defaultIndicatorStyle);
315-
const [displayScroll, setDisplayScroll] = React.useState({
316-
start: false,
317-
end: false,
318-
});
315+
const [displayStartScroll, setDisplayStartScroll] = React.useState(false);
316+
const [displayEndScroll, setDisplayEndScroll] = React.useState(false);
317+
const [updateScrollObserver, setUpdateScrollObserver] = React.useState(false);
319318

320319
const [scrollerStyle, setScrollerStyle] = React.useState({
321320
overflow: 'hidden',
@@ -508,7 +507,7 @@ const Tabs = React.forwardRef(function Tabs(inProps, ref) {
508507
/>
509508
) : null;
510509

511-
const scrollButtonsActive = displayScroll.start || displayScroll.end;
510+
const scrollButtonsActive = displayStartScroll || displayEndScroll;
512511
const showScrollButtons =
513512
scrollable && ((scrollButtons === 'auto' && scrollButtonsActive) || scrollButtons === true);
514513

@@ -519,7 +518,7 @@ const Tabs = React.forwardRef(function Tabs(inProps, ref) {
519518
orientation={orientation}
520519
direction={isRtl ? 'right' : 'left'}
521520
onClick={handleStartScrollClick}
522-
disabled={!displayScroll.start}
521+
disabled={!displayStartScroll}
523522
{...TabScrollButtonProps}
524523
className={clsx(classes.scrollButtons, TabScrollButtonProps.className)}
525524
/>
@@ -534,7 +533,7 @@ const Tabs = React.forwardRef(function Tabs(inProps, ref) {
534533
orientation={orientation}
535534
direction={isRtl ? 'left' : 'right'}
536535
onClick={handleEndScrollClick}
537-
disabled={!displayScroll.end}
536+
disabled={!displayEndScroll}
538537
{...TabScrollButtonProps}
539538
className={clsx(classes.scrollButtons, TabScrollButtonProps.className)}
540539
/>
@@ -563,23 +562,7 @@ const Tabs = React.forwardRef(function Tabs(inProps, ref) {
563562

564563
const updateScrollButtonState = useEventCallback(() => {
565564
if (scrollable && scrollButtons !== false) {
566-
const { scrollTop, scrollHeight, clientHeight, scrollWidth, clientWidth } = tabsRef.current;
567-
let showStartScroll;
568-
let showEndScroll;
569-
570-
if (vertical) {
571-
showStartScroll = scrollTop > 1;
572-
showEndScroll = scrollTop < scrollHeight - clientHeight - 1;
573-
} else {
574-
const scrollLeft = getNormalizedScrollLeft(tabsRef.current, theme.direction);
575-
// use 1 for the potential rounding error with browser zooms.
576-
showStartScroll = isRtl ? scrollLeft < scrollWidth - clientWidth - 1 : scrollLeft > 1;
577-
showEndScroll = !isRtl ? scrollLeft < scrollWidth - clientWidth - 1 : scrollLeft > 1;
578-
}
579-
580-
if (showStartScroll !== displayScroll.start || showEndScroll !== displayScroll.end) {
581-
setDisplayScroll({ start: showStartScroll, end: showEndScroll });
582-
}
565+
setUpdateScrollObserver(!updateScrollObserver);
583566
}
584567
});
585568

@@ -593,7 +576,6 @@ const Tabs = React.forwardRef(function Tabs(inProps, ref) {
593576
// replaced by Suspense with a fallback, once React is updated to version 18
594577
if (tabsRef.current) {
595578
updateIndicatorState();
596-
updateScrollButtonState();
597579
}
598580
});
599581
const win = ownerWindow(tabsRef.current);
@@ -615,29 +597,68 @@ const Tabs = React.forwardRef(function Tabs(inProps, ref) {
615597
resizeObserver.disconnect();
616598
}
617599
};
618-
}, [updateIndicatorState, updateScrollButtonState]);
619-
620-
const handleTabsScroll = React.useMemo(
621-
() =>
622-
debounce(() => {
623-
updateScrollButtonState();
624-
}),
625-
[updateScrollButtonState],
626-
);
600+
}, [updateIndicatorState]);
627601

602+
/**
603+
* Toggle visibility of start and end scroll buttons
604+
* Using IntersectionObserver on first and last Tabs.
605+
*/
628606
React.useEffect(() => {
607+
let firstObserver;
608+
let lastObserver;
609+
const tabListChildren = Array.from(tabListRef.current.children);
610+
const length = tabListChildren.length;
611+
const firstTab = tabListChildren[0];
612+
const lastTab = tabListChildren[length - 1];
613+
const threshold = 0.99;
614+
const observerOptions = {
615+
root: tabsRef.current,
616+
threshold,
617+
};
618+
619+
const handleScrollButtonStart = (entries) => {
620+
let display = false;
621+
entries.forEach(({ isIntersecting }) => {
622+
display = !isIntersecting;
623+
});
624+
setDisplayStartScroll(display);
625+
};
626+
627+
const handleScrollButtonEnd = (entries) => {
628+
let display = false;
629+
entries.forEach(({ isIntersecting }) => {
630+
display = !isIntersecting;
631+
});
632+
setDisplayEndScroll(display);
633+
};
634+
635+
if (
636+
typeof IntersectionObserver !== 'undefined' &&
637+
length > 0 &&
638+
scrollable &&
639+
scrollButtons !== false
640+
) {
641+
firstObserver = new IntersectionObserver(handleScrollButtonStart, observerOptions);
642+
firstObserver.observe(firstTab);
643+
644+
if (length > 1) {
645+
lastObserver = new IntersectionObserver(handleScrollButtonEnd, observerOptions);
646+
lastObserver.observe(lastTab);
647+
}
648+
}
649+
629650
return () => {
630-
handleTabsScroll.clear();
651+
firstObserver?.disconnect();
652+
lastObserver?.disconnect();
631653
};
632-
}, [handleTabsScroll]);
654+
}, [scrollable, scrollButtons, updateScrollObserver, childrenProp?.length]);
633655

634656
React.useEffect(() => {
635657
setMounted(true);
636658
}, []);
637659

638660
React.useEffect(() => {
639661
updateIndicatorState();
640-
updateScrollButtonState();
641662
});
642663

643664
React.useEffect(() => {
@@ -763,7 +784,6 @@ const Tabs = React.forwardRef(function Tabs(inProps, ref) {
763784
: -scrollerStyle.scrollbarWidth,
764785
}}
765786
ref={tabsRef}
766-
onScroll={handleTabsScroll}
767787
>
768788
{/* The tablist isn't interactive but the tabs are */}
769789
<FlexContainer

‎packages/mui-material/src/Tabs/Tabs.test.js

+124-115
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
fireEvent,
99
screen,
1010
strictModeDoubleLoggingSuppressed,
11+
waitFor,
1112
} from 'test/utils';
1213
import Tab from '@mui/material/Tab';
1314
import Tabs, { tabsClasses as classes } from '@mui/material/Tabs';
@@ -493,7 +494,6 @@ describe('<Tabs />', () => {
493494
});
494495

495496
describe('prop: variant="scrollable"', () => {
496-
clock.withFakeTimers();
497497
const tabs = (
498498
<Tabs value={0} style={{ width: 200 }} variant="scrollable">
499499
<Tab style={{ width: 120, minWidth: 'auto' }} />
@@ -509,34 +509,6 @@ describe('<Tabs />', () => {
509509
expect(container.querySelectorAll(selector)).to.have.lengthOf(1);
510510
});
511511

512-
it('should response to scroll events', function test() {
513-
if (isJSDOM) {
514-
this.skip();
515-
}
516-
const { container, forceUpdate, getByRole } = render(tabs);
517-
const tablistContainer = getByRole('tablist').parentElement;
518-
519-
Object.defineProperty(tablistContainer, 'clientWidth', { value: 200 - 40 * 2 });
520-
tablistContainer.scrollLeft = 10;
521-
Object.defineProperty(tablistContainer, 'scrollWidth', { value: 216 });
522-
Object.defineProperty(tablistContainer, 'getBoundingClientRect', {
523-
value: () => ({
524-
left: 0,
525-
right: 50,
526-
}),
527-
});
528-
forceUpdate();
529-
clock.tick(1000);
530-
expect(hasLeftScrollButton(container)).to.equal(true);
531-
expect(hasRightScrollButton(container)).to.equal(true);
532-
tablistContainer.scrollLeft = 0;
533-
fireEvent.scroll(container.querySelector(`.${classes.scroller}.${classes.scrollableX}`));
534-
clock.tick(166);
535-
536-
expect(hasLeftScrollButton(container)).to.equal(false);
537-
expect(hasRightScrollButton(container)).to.equal(true);
538-
});
539-
540512
it('should get a scrollbar size listener', () => {
541513
const { setProps, getByRole } = render(
542514
<Tabs value={0}>
@@ -569,8 +541,6 @@ describe('<Tabs />', () => {
569541
});
570542

571543
describe('prop: scrollButtons', () => {
572-
clock.withFakeTimers();
573-
574544
it('should render scroll buttons', () => {
575545
const { container } = render(
576546
<Tabs value={0} variant="scrollable" scrollButtons>
@@ -608,65 +578,27 @@ describe('<Tabs />', () => {
608578
expect(container.querySelectorAll(`.${classes.scrollButtonsHideMobile}`)).to.have.lengthOf(0);
609579
});
610580

611-
it('should handle window resize event', function test() {
612-
if (isJSDOM) {
613-
this.skip();
614-
}
615-
616-
const { container, forceUpdate, getByRole } = render(
617-
<Tabs value={0} variant="scrollable" scrollButtons style={{ width: 200 }}>
618-
<Tab />
619-
<Tab />
620-
<Tab />
621-
</Tabs>,
622-
);
623-
624-
const tablistContainer = getByRole('tablist').parentElement;
625-
626-
Object.defineProperty(tablistContainer, 'clientWidth', { value: 200 - 40 * 2 });
627-
tablistContainer.scrollLeft = 10;
628-
Object.defineProperty(tablistContainer, 'scrollWidth', { value: 216 });
629-
Object.defineProperty(tablistContainer, 'getBoundingClientRect', {
630-
value: () => ({
631-
left: 0,
632-
right: 100,
633-
}),
634-
});
635-
forceUpdate();
636-
clock.tick(1000);
637-
expect(hasLeftScrollButton(container)).to.equal(true);
638-
expect(hasRightScrollButton(container)).to.equal(true);
639-
tablistContainer.scrollLeft = 0;
640-
641-
act(() => {
642-
window.dispatchEvent(new window.Event('resize', {}));
643-
});
644-
clock.tick(166);
645-
646-
expect(hasLeftScrollButton(container)).to.equal(false);
647-
expect(hasRightScrollButton(container)).to.equal(true);
648-
});
649-
650581
describe('scroll button visibility states', () => {
651-
it('should set neither left nor right scroll button state', () => {
652-
const { container, forceUpdate, getByRole } = render(
582+
it('should set neither left nor right scroll button state', function test() {
583+
if (isJSDOM) {
584+
this.skip();
585+
}
586+
const { container } = render(
653587
<Tabs value={0} variant="scrollable" scrollButtons style={{ width: 200 }}>
654588
<Tab style={{ width: 50, minWidth: 'auto' }} />
655589
<Tab style={{ width: 50, minWidth: 'auto' }} />
656590
</Tabs>,
657591
);
658-
const tablistContainer = getByRole('tablist').parentElement;
659-
660-
Object.defineProperty(tablistContainer, 'clientWidth', { value: 200 - 40 * 2 });
661-
Object.defineProperty(tablistContainer, 'scrollWidth', { value: 200 - 40 * 2 });
662592

663-
forceUpdate();
664593
expect(hasLeftScrollButton(container)).to.equal(false);
665594
expect(hasRightScrollButton(container)).to.equal(false);
666595
});
667596

668-
it('should set only left scroll button state', () => {
669-
const { container, forceUpdate, getByRole } = render(
597+
it('should set only left scroll button state', async function test() {
598+
if (isJSDOM) {
599+
this.skip();
600+
}
601+
const { container, getByRole } = render(
670602
<Tabs value={0} variant="scrollable" scrollButtons style={{ width: 200 }}>
671603
<Tab style={{ width: 120, minWidth: 'auto' }} />
672604
<Tab style={{ width: 120, minWidth: 'auto' }} />
@@ -675,17 +607,19 @@ describe('<Tabs />', () => {
675607
);
676608
const tablistContainer = getByRole('tablist').parentElement;
677609

678-
Object.defineProperty(tablistContainer, 'clientWidth', { value: 200 - 40 * 2 });
679-
Object.defineProperty(tablistContainer, 'scrollWidth', { value: 216 });
680-
tablistContainer.scrollLeft = 96;
610+
tablistContainer.scrollLeft = 240;
681611

682-
forceUpdate();
683-
expect(hasLeftScrollButton(container)).to.equal(true);
684-
expect(hasRightScrollButton(container)).to.equal(false);
612+
await waitFor(() => {
613+
expect(hasLeftScrollButton(container)).to.equal(true);
614+
expect(hasRightScrollButton(container)).to.equal(false);
615+
});
685616
});
686617

687-
it('should set only right scroll button state', () => {
688-
const { container, forceUpdate, getByRole } = render(
618+
it('should set only right scroll button state', async function test() {
619+
if (isJSDOM) {
620+
this.skip();
621+
}
622+
const { container, getByRole } = render(
689623
<Tabs value={0} variant="scrollable" scrollButtons style={{ width: 200 }}>
690624
<Tab />
691625
<Tab />
@@ -694,67 +628,70 @@ describe('<Tabs />', () => {
694628
);
695629
const tablistContainer = getByRole('tablist').parentElement;
696630

697-
Object.defineProperty(tablistContainer, 'clientWidth', { value: 200 - 40 * 2 });
698-
Object.defineProperty(tablistContainer, 'scrollWidth', { value: 216 });
699631
tablistContainer.scrollLeft = 0;
700632

701-
forceUpdate();
702-
expect(hasLeftScrollButton(container)).to.equal(false);
703-
expect(hasRightScrollButton(container)).to.equal(true);
633+
await waitFor(() => {
634+
expect(hasLeftScrollButton(container)).to.equal(false);
635+
expect(hasRightScrollButton(container)).to.equal(true);
636+
});
704637
});
705638

706-
it('should set both left and right scroll button state', () => {
707-
const { container, forceUpdate, getByRole } = render(
639+
it('should set both left and right scroll button state', async function test() {
640+
if (isJSDOM) {
641+
this.skip();
642+
}
643+
const { container, getByRole } = render(
708644
<Tabs value={0} variant="scrollable" scrollButtons style={{ width: 200 }}>
709645
<Tab style={{ width: 120, minWidth: 'auto' }} />
710646
<Tab style={{ width: 120, minWidth: 'auto' }} />
711647
</Tabs>,
712648
);
713649
const tablistContainer = getByRole('tablist').parentElement;
714650

715-
Object.defineProperty(tablistContainer, 'clientWidth', { value: 200 - 40 * 2 });
716-
Object.defineProperty(tablistContainer, 'scrollWidth', { value: 216 });
717651
tablistContainer.scrollLeft = 5;
718652

719-
forceUpdate();
720-
expect(hasLeftScrollButton(container)).to.equal(true);
721-
expect(hasRightScrollButton(container)).to.equal(true);
653+
await waitFor(() => {
654+
expect(hasLeftScrollButton(container)).to.equal(true);
655+
expect(hasRightScrollButton(container)).to.equal(true);
656+
});
722657
});
723658
});
724659
});
725660

726661
describe('scroll button behavior', () => {
727662
clock.withFakeTimers();
728663

729-
it('should scroll visible items', () => {
730-
const { container, forceUpdate, getByRole, getAllByRole } = render(
664+
it('should scroll visible items', async function test() {
665+
clock.restore();
666+
if (isJSDOM) {
667+
this.skip();
668+
}
669+
const { container, getByRole } = render(
731670
<Tabs value={0} variant="scrollable" scrollButtons style={{ width: 200 }}>
732671
<Tab style={{ width: 100, minWidth: 'auto' }} />
733672
<Tab style={{ width: 50, minWidth: 'auto' }} />
734673
<Tab style={{ width: 100, minWidth: 'auto' }} />
735674
</Tabs>,
736675
);
737676
const tablistContainer = getByRole('tablist').parentElement;
738-
const tabs = getAllByRole('tab');
739-
Object.defineProperty(tablistContainer, 'clientWidth', { value: 200 - 40 * 2 });
740-
Object.defineProperty(tabs[0], 'clientWidth', { value: 100 });
741-
Object.defineProperty(tabs[1], 'clientWidth', { value: 50 });
742-
Object.defineProperty(tabs[2], 'clientWidth', { value: 100 });
743-
Object.defineProperty(tablistContainer, 'scrollWidth', { value: 100 + 50 + 100 });
677+
744678
tablistContainer.scrollLeft = 20;
745-
forceUpdate();
746-
clock.tick(1000);
747-
expect(hasLeftScrollButton(container)).to.equal(true);
748-
expect(hasRightScrollButton(container)).to.equal(true);
679+
680+
await waitFor(() => {
681+
expect(hasLeftScrollButton(container)).to.equal(true);
682+
expect(hasRightScrollButton(container)).to.equal(true);
683+
});
749684

750685
fireEvent.click(findScrollButton(container, 'left'));
751-
clock.tick(1000);
752-
expect(tablistContainer.scrollLeft).not.to.be.above(0);
686+
await waitFor(() => {
687+
expect(tablistContainer.scrollLeft).not.to.be.above(0);
688+
});
753689

754690
tablistContainer.scrollLeft = 0;
755691
fireEvent.click(findScrollButton(container, 'right'));
756-
clock.tick(1000);
757-
expect(tablistContainer.scrollLeft).equal(100);
692+
await waitFor(() => {
693+
expect(tablistContainer.scrollLeft).equal(100);
694+
});
758695
});
759696

760697
it('should horizontally scroll by width of partially visible item', () => {
@@ -1397,4 +1334,76 @@ describe('<Tabs />', () => {
13971334
]);
13981335
});
13991336
});
1337+
1338+
describe('dynamic tabs', () => {
1339+
const pause = (timeout) =>
1340+
new Promise((resolve) => {
1341+
setTimeout(() => {
1342+
resolve();
1343+
}, timeout);
1344+
});
1345+
1346+
// https://github.com/mui/material-ui/issues/31936
1347+
it('should not show scroll buttons if a tab added or removed in vertical mode', async function test() {
1348+
if (isJSDOM) {
1349+
this.skip();
1350+
}
1351+
function DynamicTabs() {
1352+
const [value, setValue] = React.useState(0);
1353+
const handleChange = (event, newValue) => {
1354+
setValue(newValue);
1355+
};
1356+
const [tabs, setTabs] = React.useState(['item1', 'item2']);
1357+
return (
1358+
<React.Fragment>
1359+
<button
1360+
data-testid="add"
1361+
onClick={() => {
1362+
setTabs([...tabs, `item${tabs.length + 1}`]);
1363+
}}
1364+
>
1365+
add
1366+
</button>
1367+
<button
1368+
data-testid="delete"
1369+
onClick={() => {
1370+
setTabs(tabs.slice(0, tabs.length - 1));
1371+
setValue(0);
1372+
}}
1373+
>
1374+
delete
1375+
</button>
1376+
<Tabs
1377+
onChange={handleChange}
1378+
value={value}
1379+
orientation="vertical"
1380+
variant="scrollable"
1381+
scrollButtons
1382+
style={{ width: '260px' }}
1383+
>
1384+
{tabs.map((label, index) => (
1385+
<Tab key={`tab${index}`} label={label} />
1386+
))}
1387+
</Tabs>
1388+
</React.Fragment>
1389+
);
1390+
}
1391+
const { container, getByTestId, getAllByRole } = render(<DynamicTabs />);
1392+
const addButton = getByTestId('add');
1393+
const deleteButton = getByTestId('delete');
1394+
1395+
fireEvent.click(addButton);
1396+
expect(hasLeftScrollButton(container)).to.equal(false);
1397+
expect(hasRightScrollButton(container)).to.equal(false);
1398+
1399+
const tabs = getAllByRole('tab');
1400+
const lastTab = tabs[tabs.length - 1];
1401+
fireEvent.click(lastTab);
1402+
await pause(400);
1403+
1404+
fireEvent.click(deleteButton);
1405+
expect(hasLeftScrollButton(container)).to.equal(false);
1406+
expect(hasRightScrollButton(container)).to.equal(false);
1407+
});
1408+
});
14001409
});

‎test/utils/createRenderer.tsx

+7
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,10 @@ interface Clock {
357357
* Runs the current test suite (i.e. `describe` block) with fake timers.
358358
*/
359359
withFakeTimers(): void;
360+
/**
361+
* Restore the real timer
362+
*/
363+
restore(): void;
360364
}
361365

362366
type ClockConfig = undefined | number | Date;
@@ -426,6 +430,9 @@ function createClock(defaultMode: 'fake' | 'real', config: ClockConfig): Clock {
426430
mode = defaultMode;
427431
});
428432
},
433+
restore() {
434+
clock?.restore();
435+
},
429436
};
430437
}
431438

0 commit comments

Comments
 (0)
Please sign in to comment.