Skip to content

Commit cb33bdf

Browse files
committed
scroll anchoring
1 parent 7cde46f commit cb33bdf

File tree

1 file changed

+100
-32
lines changed

1 file changed

+100
-32
lines changed

src/components/tab-container.js

Lines changed: 100 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,40 @@ import React, {
33
cloneElement,
44
useState,
55
useId,
6+
useRef,
7+
useLayoutEffect,
68
useEffect,
9+
useCallback,
10+
useMemo,
711
} from "react";
812

913
export default function TabContainer({ className, syncKey, children }) {
1014
const id = useId();
11-
12-
let implicitSelected = "";
13-
let tabs = [];
14-
15-
children = Children.map(children, (child) => {
16-
if (child && child.props && child.props.mdxType === "Tab") {
17-
// collect information on Tabs, assign identifiers and classnames
18-
const { title, defaultSelected } = child.props;
19-
if (!title) {
20-
console.error("Tab must have a title");
15+
const [implicitSelected, tabs, processedChildren] = useMemo(() => {
16+
const tabs = [];
17+
let implicitSelected = null;
18+
const processedChildren = Children.map(children, (child) => {
19+
if (child && child.props && child.props.mdxType === "Tab") {
20+
// collect information on Tabs, assign identifiers and classnames
21+
const { title, defaultSelected } = child.props;
22+
if (!title) {
23+
console.error("Tab must have a title");
24+
}
25+
const tabIndex = tabs.length + 1;
26+
const tabId = id + "tab" + tabIndex;
27+
tabs.push({ id: tabId, title });
28+
if (defaultSelected || tabIndex === 1) implicitSelected = title;
29+
return cloneElement(child, {
30+
id: tabId + "panel",
31+
labelledById: tabId,
32+
panelClassName: "tab" + tabIndex,
33+
});
2134
}
22-
const tabIndex = tabs.length + 1;
23-
const tabId = id + "tab" + tabIndex;
24-
tabs.push({ id: tabId, title });
25-
if (defaultSelected || tabIndex === 1) implicitSelected = title;
26-
return cloneElement(child, {
27-
id: tabId + "panel",
28-
labelledById: tabId,
29-
panelClassName: "tab" + tabIndex,
30-
});
31-
}
32-
return child;
33-
});
35+
return child;
36+
});
37+
return [implicitSelected, tabs, processedChildren];
38+
}, [children, id]);
3439
const [selected, setSelected] = useState(implicitSelected);
35-
const _tabs = JSON.stringify(tabs);
3640

3741
useEffect(() => {
3842
if (syncKey) {
@@ -41,7 +45,7 @@ export default function TabContainer({ className, syncKey, children }) {
4145
if (
4246
newSelected &&
4347
newSelected !== selected &&
44-
JSON.parse(_tabs).some((tab) => tab.title === newSelected)
48+
tabs.some((tab) => tab.title === newSelected)
4549
) {
4650
setSelected(newSelected);
4751
}
@@ -53,20 +57,60 @@ export default function TabContainer({ className, syncKey, children }) {
5357
window.removeEventListener("tabSelected", updateSelected);
5458
};
5559
}
56-
}, [syncKey, selected, _tabs]);
60+
}, [syncKey, selected, tabs]);
5761

58-
const onTabSelect = (event) => {
59-
setSelected(event.target.value);
60-
sessionStorage.setItem("selectedTab-" + syncKey, event.target.value);
61-
window.dispatchEvent(new Event("tabSelected"));
62-
};
62+
// there's a bit of logic here to do "scroll anchoring"
63+
// If there are multiple, linked (via syncKey) tab containers on a page,
64+
// changing a tab in one might expand or shrink others.
65+
// Even with a single tab container, individual tabs might have different
66+
// intrinsic heights - since the tab bar is sticky, it might be visible
67+
// far down the page for a long tab, but scrolled off for a short one.
68+
// Both of these scenarios are annoying - the tab bar will appear to
69+
// jump around or disappear, moving out from under the reader's cursor or finger
70+
// So *when the tab selection changes*, we record the current position and attempt
71+
// to restore it on the next render.
72+
const tabBarRef = useRef(null);
73+
const scrollAnchorOffset = useRef(null);
74+
75+
const onTabSelect = useCallback(
76+
(event) => {
77+
setSelected(event.target.value);
78+
if (tabBarRef.current) {
79+
scrollAnchorOffset.current = getScrollOffset(tabBarRef.current);
80+
81+
// notify other tab containers of the change in selected tab
82+
// I don't bother passing the syncKey with this - it's just as easy
83+
// to check sessionStorage for the key in the listener
84+
if (syncKey) {
85+
sessionStorage.setItem("selectedTab-" + syncKey, event.target.value);
86+
window.dispatchEvent(new Event("tabSelected"));
87+
}
88+
}
89+
},
90+
[syncKey],
91+
);
92+
93+
useLayoutEffect(() => {
94+
if (!tabBarRef.current || scrollAnchorOffset.current === null) return;
95+
96+
const parent = tabBarRef.current.parentElement;
97+
const scrollParent = getScrollParent(tabBarRef.current);
98+
const newScrollOffset = getScrollOffset(tabBarRef.current);
99+
const limit =
100+
parent.offsetTop + parent.offsetHeight - tabBarRef.current.offsetHeight;
101+
let newScrollTop =
102+
scrollParent.scrollTop + (newScrollOffset - scrollAnchorOffset.current);
103+
if (newScrollTop > limit) newScrollTop = parent.offsetTop;
104+
scrollParent.scrollTop = newScrollTop;
105+
scrollAnchorOffset.current = null;
106+
});
63107

64108
// refer to https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
65109
// warnings disabled for jsx-a11y as they make no sense: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/no-noninteractive-element-to-interactive-role.md
66110
return (
67111
<div className={`tabs ${className || ""}`}>
68112
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role */}
69-
<ul className="tabs__list nav nav-tabs" role="tablist">
113+
<ul ref={tabBarRef} className="tabs__list nav nav-tabs" role="tablist">
70114
{tabs.map((tab, i) => (
71115
<li
72116
className="tabs__tab"
@@ -93,7 +137,7 @@ export default function TabContainer({ className, syncKey, children }) {
93137
</ul>
94138

95139
<div className="tab-content" role="presentation">
96-
{children}
140+
{processedChildren}
97141
</div>
98142
</div>
99143
);
@@ -120,3 +164,27 @@ export function Tab({
120164
</div>
121165
);
122166
}
167+
168+
function getScrollParent(element) {
169+
if (element === null) {
170+
return document?.documentElement;
171+
}
172+
const style = window.getComputedStyle(element);
173+
const overflowY = style.overflowY;
174+
if (overflowY === "auto" || overflowY === "scroll") {
175+
return element;
176+
}
177+
return getScrollParent(element.parentElement);
178+
}
179+
180+
function getScrollOffset(element) {
181+
let scrollOffset = element.getBoundingClientRect().top;
182+
// tab bar is sticky - so it'll "bottom out" at zero, even when the
183+
// top of the parent is scrolled off-screen.
184+
// could just make the tab container the focal element, but that
185+
// then we'd have the opposite problem of having to figure out where
186+
// the tab bar was floating inside of it.
187+
if (scrollOffset === 0)
188+
scrollOffset = element.parentElement.getBoundingClientRect().top;
189+
return Math.ceil(scrollOffset);
190+
}

0 commit comments

Comments
 (0)