Skip to content

fix: fixed Tabs menu is not displaying the "more" button #772

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 57 additions & 52 deletions src/components/NavigationBar/tabs/useIndexOfLastVisibleChild.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { useLayoutEffect, useRef, useState } from 'react';

import { useWindowSize } from '@openedx/paragon';

const invisibleStyle = {
position: 'absolute',
left: 0,
Expand All @@ -10,68 +8,75 @@ const invisibleStyle = {
};

/**
* This hook will find the index of the last child of a containing element
* that fits within its bounding rectangle. This is done by summing the widths
* of the children until they exceed the width of the container.
* This hook calculates the index of the last child that can fit into the
* container element without overflowing. All children are rendered, but those
* that exceed the available width are styled with `invisibleStyle` to hide them
* visually while preserving their dimensions for measurement.
*
* The hook returns an array containing:
* [indexOfLastVisibleChild, containerElementRef, invisibleStyle, overflowElementRef]
* It uses ResizeObserver to automatically react to any changes in container
* size or child widths — without requiring a window resize event.
*
* indexOfLastVisibleChild - the index of the last visible child
* containerElementRef - a ref to be added to the containing html node
* invisibleStyle - a set of styles to be applied to child of the containing node
* if it needs to be hidden. These styles remove the element visually, from
* screen readers, and from normal layout flow. But, importantly, these styles
* preserve the width of the element, so that future width calculations will
* still be accurate.
* overflowElementRef - a ref to be added to an html node inside the container
* that is likely to be used to contain a "More" type dropdown or other
* mechanism to reveal hidden children. The width of this element is always
* included when determining which children will fit or not. Usage of this ref
* is optional.
* Returns:
* [
* indexOfLastVisibleChild, // Index of the last tab that fits in the container
* containerElementRef, // Ref to attach to the tabs container
* invisibleStyle, // Style object to apply to "hidden" tabs
* overflowElementRef // Ref to the overflow ("More...") element
* ]
*/
export default function useIndexOfLastVisibleChild() {
const containerElementRef = useRef(null);
const overflowElementRef = useRef(null);
const containingRectRef = useRef({});
const [indexOfLastVisibleChild, setIndexOfLastVisibleChild] = useState(-1);
const windowSize = useWindowSize();

useLayoutEffect(() => {
const containingRect = containerElementRef.current.getBoundingClientRect();
// Measures how many tab elements fit within the container's width
const measureVisibleChildren = () => {
const container = containerElementRef.current;
const overflow = overflowElementRef.current;
if (!container) { return; }

const containingRect = container.getBoundingClientRect();

// Get all children excluding the overflow element
const children = Array.from(container.children).filter(child => child !== overflow);

let sumWidth = overflow ? overflow.getBoundingClientRect().width : 0;
let lastVisibleIndex = -1;

for (let i = 0; i < children.length; i++) {
const width = Math.floor(children[i].getBoundingClientRect().width);
sumWidth += width;

// No-op if the width is unchanged.
// (Assumes tabs themselves don't change count or width).
if (!containingRect.width === containingRectRef.current.width) {
return;
if (sumWidth <= containingRect.width) {
lastVisibleIndex = i;
} else {
break;
}
}
// Update for future comparison
containingRectRef.current = containingRect;

// Get array of child nodes from NodeList form
const childNodesArr = Array.prototype.slice.call(containerElementRef.current.children);
const { nextIndexOfLastVisibleChild } = childNodesArr
// filter out the overflow element
.filter(childNode => childNode !== overflowElementRef.current)
// sum the widths to find the last visible element's index
.reduce((acc, childNode, index) => {
// use floor to prevent rounding errors
acc.sumWidth += Math.floor(childNode.getBoundingClientRect().width);
if (acc.sumWidth <= containingRect.width) {
acc.nextIndexOfLastVisibleChild = index;
}
return acc;
}, {
// Include the overflow element's width to begin with. Doing this means
// sometimes we'll show a dropdown with one item in it when it would fit,
// but allowing this case dramatically simplifies the calculations we need
// to do above.
sumWidth: overflowElementRef.current ? overflowElementRef.current.getBoundingClientRect().width : 0,
nextIndexOfLastVisibleChild: -1,
});
setIndexOfLastVisibleChild(lastVisibleIndex);
};

useLayoutEffect(() => {
const container = containerElementRef.current;
if (!container) {
return () => {};
}

// ResizeObserver tracks size changes of the container or its children
const resizeObserver = new ResizeObserver(() => {
measureVisibleChildren();
});

resizeObserver.observe(container);
// Run once on mount to ensure accurate measurement from the start
measureVisibleChildren();

setIndexOfLastVisibleChild(nextIndexOfLastVisibleChild);
}, [windowSize, containerElementRef.current]);
/* istanbul ignore next */
return () => {
resizeObserver.disconnect();
};
}, []);

return [indexOfLastVisibleChild, containerElementRef, invisibleStyle, overflowElementRef];
}
262 changes: 262 additions & 0 deletions src/components/NavigationBar/tabs/useIndexOfLastVisibleChild.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import React from 'react';

import { act, render, renderHook } from '@testing-library/react';

import useIndexOfLastVisibleChild from './useIndexOfLastVisibleChild';

describe('useIndexOfLastVisibleChild', () => {
let observeMock;
let disconnectMock;

beforeAll(() => {
observeMock = jest.fn();
disconnectMock = jest.fn();
global.ResizeObserver = class {
observe = observeMock;

disconnect = disconnectMock;
};
});

afterAll(() => {
delete global.ResizeObserver;
});

beforeEach(() => {
observeMock.mockReset();
disconnectMock.mockReset();
});

it('calls disconnect on cleanup when container exists', () => {
const TestComponent = () => {
const [, containerRef] = useIndexOfLastVisibleChild();
return <div ref={containerRef} />;
};
const { unmount } = render(<TestComponent />);
unmount();
expect(disconnectMock).toHaveBeenCalled();
});

it('handles missing container gracefully (no observer or disconnect)', () => {
const { unmount } = renderHook(() => useIndexOfLastVisibleChild());
unmount();
expect(disconnectMock).not.toHaveBeenCalled();
});

it('returns -1 if there are no children', () => {
const TestComponent = () => {
const [, containerRef] = useIndexOfLastVisibleChild();
React.useEffect(() => {
const container = document.createElement('div');
container.getBoundingClientRect = () => ({ width: 100 });
containerRef.current = container;
}, []);
return <div ref={containerRef} />;
};
const { unmount } = render(<TestComponent />);
unmount();
});

it('triggers break when child tabs exceed container width', () => {
const TestComponent = () => {
const [, containerRef] = useIndexOfLastVisibleChild();
React.useEffect(() => {
const container = document.createElement('div');
container.getBoundingClientRect = () => ({ width: 100 });
const child1 = document.createElement('div');
child1.getBoundingClientRect = () => ({ width: 80 });
const child2 = document.createElement('div');
child2.getBoundingClientRect = () => ({ width: 80 });
container.appendChild(child1);
container.appendChild(child2);

containerRef.current = container;
}, []);
return <div ref={containerRef} />;
};
const { unmount } = render(<TestComponent />);
unmount();
});

it('calls measureVisibleChildren on mount and when ResizeObserver triggers', () => {
const TestComponent = () => {
const [, containerRef] = useIndexOfLastVisibleChild();
React.useEffect(() => {
const container = document.createElement('div');
container.getBoundingClientRect = () => ({ width: 200 });
containerRef.current = container;
}, []);
return <div ref={containerRef} />;
};

const { unmount } = render(<TestComponent />);

expect(observeMock).toHaveBeenCalled();

act(() => {
const resizeObserverCallback = observeMock.mock.calls[0][0];
if (resizeObserverCallback && typeof resizeObserverCallback === 'function') {
resizeObserverCallback();
}
});

unmount();
});

it('calculates correct last visible index when children fit within container', () => {
let resizeObserverCallback;

global.ResizeObserver = function (callback) {

Check warning on line 109 in src/components/NavigationBar/tabs/useIndexOfLastVisibleChild.test.jsx

View workflow job for this annotation

GitHub Actions / tests

Unexpected unnamed function
resizeObserverCallback = callback;
this.observe = jest.fn();
this.disconnect = jest.fn();
};

const TestComponent = () => {
const [lastVisibleIndex, containerRef, , overflowRef] = useIndexOfLastVisibleChild();

React.useEffect(() => {
const container = document.createElement('div');
container.getBoundingClientRect = () => ({ width: 200 });

const child1 = document.createElement('div');
child1.getBoundingClientRect = () => ({ width: 50 });
const child2 = document.createElement('div');
child2.getBoundingClientRect = () => ({ width: 60 });
const child3 = document.createElement('div');
child3.getBoundingClientRect = () => ({ width: 70 });

container.appendChild(child1);
container.appendChild(child2);
container.appendChild(child3);

containerRef.current = container;
overflowRef.current = null;
}, []);

return (
<div>
<div ref={containerRef} />
<div data-testid="last-visible-index">{lastVisibleIndex}</div>
</div>
);
};

const { getByTestId, unmount } = render(<TestComponent />);

act(() => {
if (resizeObserverCallback) {
resizeObserverCallback();
}
});

// The last visible index should be 2 (all three children fit: 50 + 60 + 70 = 180 <= 200)
expect(getByTestId('last-visible-index').textContent).toBe('2');

unmount();
});

it('handles overflow element in width calculation', () => {
let resizeObserverCallback;

global.ResizeObserver = function (callback) {

Check warning on line 162 in src/components/NavigationBar/tabs/useIndexOfLastVisibleChild.test.jsx

View workflow job for this annotation

GitHub Actions / tests

Unexpected unnamed function
resizeObserverCallback = callback;
this.observe = jest.fn();
this.disconnect = jest.fn();
};

const TestComponent = () => {
const [lastVisibleIndex, containerRef, , overflowRef] = useIndexOfLastVisibleChild();

React.useEffect(() => {
const container = document.createElement('div');
container.getBoundingClientRect = () => ({ width: 150 });

const overflow = document.createElement('div');
overflow.getBoundingClientRect = () => ({ width: 30 });

const child1 = document.createElement('div');
child1.getBoundingClientRect = () => ({ width: 50 });
const child2 = document.createElement('div');
child2.getBoundingClientRect = () => ({ width: 60 });

container.appendChild(child1);
container.appendChild(child2);
container.appendChild(overflow);

containerRef.current = container;
overflowRef.current = overflow;
}, []);

return (
<div>
<div ref={containerRef} />
<div data-testid="last-visible-index">{lastVisibleIndex}</div>
</div>
);
};

const { getByTestId, unmount } = render(<TestComponent />);

act(() => {
if (resizeObserverCallback) {
resizeObserverCallback();
}
});

// With overflow width (30) + child1 (50) + child2 (60) = 140 <= 150
// So last visible index should be 1 (child2)
expect(getByTestId('last-visible-index').textContent).toBe('1');

unmount();
});

it('returns -1 when no children fit within container width', () => {
let resizeObserverCallback;

global.ResizeObserver = function (callback) {

Check warning on line 217 in src/components/NavigationBar/tabs/useIndexOfLastVisibleChild.test.jsx

View workflow job for this annotation

GitHub Actions / tests

Unexpected unnamed function
resizeObserverCallback = callback;
this.observe = jest.fn();
this.disconnect = jest.fn();
};

const TestComponent = () => {
const [lastVisibleIndex, containerRef] = useIndexOfLastVisibleChild();

React.useEffect(() => {
const container = document.createElement('div');
container.getBoundingClientRect = () => ({ width: 50 });

const child1 = document.createElement('div');
child1.getBoundingClientRect = () => ({ width: 100 });
const child2 = document.createElement('div');
child2.getBoundingClientRect = () => ({ width: 80 });

container.appendChild(child1);
container.appendChild(child2);

containerRef.current = container;
}, []);

return (
<div>
<div ref={containerRef} />
<div data-testid="last-visible-index">{lastVisibleIndex}</div>
</div>
);
};

const { getByTestId, unmount } = render(<TestComponent />);

act(() => {
if (resizeObserverCallback) {
resizeObserverCallback();
}
});

// No children fit within 50px width, so should return -1
expect(getByTestId('last-visible-index').textContent).toBe('-1');

unmount();
});
});