11import { useLayoutEffect , useRef , useState } from 'react' ;
22
3- import { useWindowSize } from '@openedx/paragon' ;
4-
53const invisibleStyle = {
64 position : 'absolute' ,
75 left : 0 ,
@@ -10,68 +8,70 @@ const invisibleStyle = {
108} ;
119
1210/**
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.
1615 *
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.
1918 *
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+ * ]
3226 */
3327export default function useIndexOfLastVisibleChild ( ) {
3428 const containerElementRef = useRef ( null ) ;
3529 const overflowElementRef = useRef ( null ) ;
36- const containingRectRef = useRef ( { } ) ;
3730 const [ indexOfLastVisibleChild , setIndexOfLastVisibleChild ] = useState ( - 1 ) ;
38- const windowSize = useWindowSize ( ) ;
3931
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 ;
4245
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+ }
4755 }
48- // Update for future comparison
49- containingRectRef . current = containingRect ;
5056
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 ( ) ;
7272
73- setIndexOfLastVisibleChild ( nextIndexOfLastVisibleChild ) ;
74- } , [ windowSize , containerElementRef . current ] ) ;
73+ return ( ) => resizeObserver . disconnect ( ) ;
74+ } , [ ] ) ;
7575
7676 return [ indexOfLastVisibleChild , containerElementRef , invisibleStyle , overflowElementRef ] ;
7777}
0 commit comments