1
1
import { useLayoutEffect , useRef , useState } from 'react' ;
2
2
3
- import { useWindowSize } from '@openedx/paragon' ;
4
-
5
3
const invisibleStyle = {
6
4
position : 'absolute' ,
7
5
left : 0 ,
@@ -10,68 +8,70 @@ const invisibleStyle = {
10
8
} ;
11
9
12
10
/**
13
- * This hook will find the index of the last child of a containing element
14
- * that fits within its bounding rectangle. This is done by summing the widths
15
- * of the children until they exceed the width of the container.
11
+ * This hook calculates the index of the last child that can fit into the
12
+ * container element without overflowing. All children are rendered, but those
13
+ * that exceed the available width are styled with `invisibleStyle` to hide them
14
+ * visually while preserving their dimensions for measurement.
16
15
*
17
- * The hook returns an array containing:
18
- * [indexOfLastVisibleChild, containerElementRef, invisibleStyle, overflowElementRef]
16
+ * It uses ResizeObserver to automatically react to any changes in container
17
+ * size or child widths — without requiring a window resize event.
19
18
*
20
- * indexOfLastVisibleChild - the index of the last visible child
21
- * containerElementRef - a ref to be added to the containing html node
22
- * invisibleStyle - a set of styles to be applied to child of the containing node
23
- * if it needs to be hidden. These styles remove the element visually, from
24
- * screen readers, and from normal layout flow. But, importantly, these styles
25
- * preserve the width of the element, so that future width calculations will
26
- * still be accurate.
27
- * overflowElementRef - a ref to be added to an html node inside the container
28
- * that is likely to be used to contain a "More" type dropdown or other
29
- * mechanism to reveal hidden children. The width of this element is always
30
- * included when determining which children will fit or not. Usage of this ref
31
- * is optional.
19
+ * Returns:
20
+ * [
21
+ * indexOfLastVisibleChild, // Index of the last tab that fits in the container
22
+ * containerElementRef, // Ref to attach to the tabs container
23
+ * invisibleStyle, // Style object to apply to "hidden" tabs
24
+ * overflowElementRef // Ref to the overflow ("More...") element
25
+ * ]
32
26
*/
33
27
export default function useIndexOfLastVisibleChild ( ) {
34
28
const containerElementRef = useRef ( null ) ;
35
29
const overflowElementRef = useRef ( null ) ;
36
- const containingRectRef = useRef ( { } ) ;
37
30
const [ indexOfLastVisibleChild , setIndexOfLastVisibleChild ] = useState ( - 1 ) ;
38
- const windowSize = useWindowSize ( ) ;
39
31
40
- useLayoutEffect ( ( ) => {
41
- const containingRect = containerElementRef . current . getBoundingClientRect ( ) ;
32
+ // Measures how many tab elements fit within the container's width
33
+ const measureVisibleChildren = ( ) => {
34
+ const container = containerElementRef . current ;
35
+ const overflow = overflowElementRef . current ;
36
+ if ( ! container ) { return ; }
37
+
38
+ const containingRect = container . getBoundingClientRect ( ) ;
39
+
40
+ // Get all children excluding the overflow element
41
+ const children = Array . from ( container . children ) . filter ( child => child !== overflow ) ;
42
+
43
+ let sumWidth = overflow ? overflow . getBoundingClientRect ( ) . width : 0 ;
44
+ let lastVisibleIndex = - 1 ;
42
45
43
- // No-op if the width is unchanged.
44
- // (Assumes tabs themselves don't change count or width).
45
- if ( ! containingRect . width === containingRectRef . current . width ) {
46
- return ;
46
+ for ( let i = 0 ; i < children . length ; i ++ ) {
47
+ const width = Math . floor ( children [ i ] . getBoundingClientRect ( ) . width ) ;
48
+ sumWidth += width ;
49
+
50
+ if ( sumWidth <= containingRect . width ) {
51
+ lastVisibleIndex = i ;
52
+ } else {
53
+ break ;
54
+ }
47
55
}
48
- // Update for future comparison
49
- containingRectRef . current = containingRect ;
50
56
51
- // Get array of child nodes from NodeList form
52
- const childNodesArr = Array . prototype . slice . call ( containerElementRef . current . children ) ;
53
- const { nextIndexOfLastVisibleChild } = childNodesArr
54
- // filter out the overflow element
55
- . filter ( childNode => childNode !== overflowElementRef . current )
56
- // sum the widths to find the last visible element's index
57
- . reduce ( ( acc , childNode , index ) => {
58
- // use floor to prevent rounding errors
59
- acc . sumWidth += Math . floor ( childNode . getBoundingClientRect ( ) . width ) ;
60
- if ( acc . sumWidth <= containingRect . width ) {
61
- acc . nextIndexOfLastVisibleChild = index ;
62
- }
63
- return acc ;
64
- } , {
65
- // Include the overflow element's width to begin with. Doing this means
66
- // sometimes we'll show a dropdown with one item in it when it would fit,
67
- // but allowing this case dramatically simplifies the calculations we need
68
- // to do above.
69
- sumWidth : overflowElementRef . current ? overflowElementRef . current . getBoundingClientRect ( ) . width : 0 ,
70
- nextIndexOfLastVisibleChild : - 1 ,
71
- } ) ;
57
+ setIndexOfLastVisibleChild ( lastVisibleIndex ) ;
58
+ } ;
59
+
60
+ useLayoutEffect ( ( ) => {
61
+ const container = containerElementRef . current ;
62
+ if ( ! container ) { return undefined ; }
63
+
64
+ // ResizeObserver tracks size changes of the container or its children
65
+ const resizeObserver = new ResizeObserver ( ( ) => {
66
+ measureVisibleChildren ( ) ;
67
+ } ) ;
68
+
69
+ resizeObserver . observe ( container ) ;
70
+ // Run once on mount to ensure accurate measurement from the start
71
+ measureVisibleChildren ( ) ;
72
72
73
- setIndexOfLastVisibleChild ( nextIndexOfLastVisibleChild ) ;
74
- } , [ windowSize , containerElementRef . current ] ) ;
73
+ return ( ) => resizeObserver . disconnect ( ) ;
74
+ } , [ ] ) ;
75
75
76
76
return [ indexOfLastVisibleChild , containerElementRef , invisibleStyle , overflowElementRef ] ;
77
77
}
0 commit comments