@@ -3,36 +3,40 @@ import React, {
3
3
cloneElement ,
4
4
useState ,
5
5
useId ,
6
+ useRef ,
7
+ useLayoutEffect ,
6
8
useEffect ,
9
+ useCallback ,
10
+ useMemo ,
7
11
} from "react" ;
8
12
9
13
export default function TabContainer ( { className, syncKey, children } ) {
10
14
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
+ } ) ;
21
34
}
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 ] ) ;
34
39
const [ selected , setSelected ] = useState ( implicitSelected ) ;
35
- const _tabs = JSON . stringify ( tabs ) ;
36
40
37
41
useEffect ( ( ) => {
38
42
if ( syncKey ) {
@@ -41,7 +45,7 @@ export default function TabContainer({ className, syncKey, children }) {
41
45
if (
42
46
newSelected &&
43
47
newSelected !== selected &&
44
- JSON . parse ( _tabs ) . some ( ( tab ) => tab . title === newSelected )
48
+ tabs . some ( ( tab ) => tab . title === newSelected )
45
49
) {
46
50
setSelected ( newSelected ) ;
47
51
}
@@ -53,20 +57,60 @@ export default function TabContainer({ className, syncKey, children }) {
53
57
window . removeEventListener ( "tabSelected" , updateSelected ) ;
54
58
} ;
55
59
}
56
- } , [ syncKey , selected , _tabs ] ) ;
60
+ } , [ syncKey , selected , tabs ] ) ;
57
61
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
+ } ) ;
63
107
64
108
// refer to https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
65
109
// 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
66
110
return (
67
111
< div className = { `tabs ${ className || "" } ` } >
68
112
{ /* 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" >
70
114
{ tabs . map ( ( tab , i ) => (
71
115
< li
72
116
className = "tabs__tab"
@@ -93,7 +137,7 @@ export default function TabContainer({ className, syncKey, children }) {
93
137
</ ul >
94
138
95
139
< div className = "tab-content" role = "presentation" >
96
- { children }
140
+ { processedChildren }
97
141
</ div >
98
142
</ div >
99
143
) ;
@@ -120,3 +164,27 @@ export function Tab({
120
164
</ div >
121
165
) ;
122
166
}
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