Mobile responsive sequence navigation (#28)
[TNL-7072] When a sequence navigation would overflow, convert it to a dropdown.
This commit is contained in:
@@ -1,86 +1,39 @@
|
||||
import React, {
|
||||
useLayoutEffect, useRef, useMemo, useState,
|
||||
} from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import classNames from 'classnames';
|
||||
import useWindowSize from './useWindowSize';
|
||||
import useIndexOfLastVisibleChild from './useIndexOfLastVisibleChild';
|
||||
|
||||
const invisibleStyle = {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
visibility: 'hidden',
|
||||
};
|
||||
export default function Tabs({ children, className, ...attrs }) {
|
||||
const [cutOffIndex, setCutOffIndex] = useState(React.Children.count(children));
|
||||
const windowSize = useWindowSize();
|
||||
const navElementRef = useRef(null);
|
||||
const tabsRectRef = useRef({});
|
||||
const overflowEl = useRef(null);
|
||||
|
||||
// eslint-disable-next-line prefer-arrow-callback
|
||||
useLayoutEffect(function findCutOffIndex() {
|
||||
const tabsRect = navElementRef.current.getBoundingClientRect();
|
||||
|
||||
// No-op if the width is unchanged.
|
||||
// (Assumes tabs themselves don't change count or width).
|
||||
if (!tabsRect.width === tabsRectRef.current.width) {
|
||||
return;
|
||||
}
|
||||
// Update for future comparison
|
||||
tabsRectRef.current = tabsRect;
|
||||
|
||||
// Get array of child nodes from NodeList form
|
||||
const childNodesArray = Array.prototype.slice.call(navElementRef.current.children);
|
||||
// Use reduce to sum the widths of child nodes and determine the new cutoff index
|
||||
const { lastFittingChildIndex } = childNodesArray.reduce((acc, childNode) => {
|
||||
const isOverflowElement = childNode === overflowEl.current;
|
||||
if (isOverflowElement) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.sumWidth += childNode.getBoundingClientRect().width;
|
||||
|
||||
if (acc.sumWidth <= tabsRect.width) {
|
||||
acc.lastFittingChildIndex += 1;
|
||||
}
|
||||
|
||||
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: overflowEl.current.getBoundingClientRect().width,
|
||||
lastFittingChildIndex: 0,
|
||||
});
|
||||
|
||||
setCutOffIndex(lastFittingChildIndex);
|
||||
}, [windowSize]);
|
||||
const [
|
||||
indexOfLastVisibleChild,
|
||||
containerElementRef,
|
||||
invisibleStyle,
|
||||
overflowElementRef,
|
||||
] = useIndexOfLastVisibleChild();
|
||||
|
||||
const tabChildren = useMemo(() => {
|
||||
const childrenArray = React.Children.toArray(children);
|
||||
const indexOfOverflowStart = indexOfLastVisibleChild + 1;
|
||||
|
||||
// All tabs will be rendered. Those that would overflow are set to invisible.
|
||||
const wrappedChildren = childrenArray.map((child, index) => (
|
||||
<li className="nav-item flex-shrink-0" style={cutOffIndex <= index ? invisibleStyle : null}>
|
||||
{React.cloneElement(child)}
|
||||
</li>
|
||||
));
|
||||
const wrappedChildren = childrenArray.map((child, index) => React.cloneElement(child, {
|
||||
style: index > indexOfLastVisibleChild ? invisibleStyle : null,
|
||||
}));
|
||||
|
||||
// Build the list of items to put in the overflow menu
|
||||
const overflowChildren = childrenArray.slice(cutOffIndex)
|
||||
.map((overflowChild) => React.cloneElement(overflowChild, { className: 'dropdown-item' }));
|
||||
const overflowChildren = childrenArray.slice(indexOfOverflowStart)
|
||||
.map(overflowChild => React.cloneElement(overflowChild, { className: 'dropdown-item' }));
|
||||
|
||||
// Insert the overflow menu at the cut off index (even if it will be hidden
|
||||
// it so it can be part of measurements)
|
||||
wrappedChildren.splice(cutOffIndex, 0, (
|
||||
<li
|
||||
wrappedChildren.splice(indexOfOverflowStart, 0, (
|
||||
<div
|
||||
className="nav-item flex-shrink-0"
|
||||
style={cutOffIndex >= React.Children.count(children) ? invisibleStyle : null}
|
||||
ref={overflowEl}
|
||||
style={indexOfOverflowStart >= React.Children.count(children) ? invisibleStyle : null}
|
||||
ref={overflowElementRef}
|
||||
key="overflow"
|
||||
>
|
||||
<Dropdown>
|
||||
<Dropdown.Button className="nav-link font-weight-normal">
|
||||
@@ -92,19 +45,19 @@ export default function Tabs({ children, className, ...attrs }) {
|
||||
</Dropdown.Button>
|
||||
<Dropdown.Menu className="dropdown-menu-right">{overflowChildren}</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</li>
|
||||
</div>
|
||||
));
|
||||
return wrappedChildren;
|
||||
}, [children, cutOffIndex]);
|
||||
}, [children, indexOfLastVisibleChild]);
|
||||
|
||||
return (
|
||||
<ul
|
||||
<nav
|
||||
{...attrs}
|
||||
className={classNames('nav flex-nowrap', className)}
|
||||
ref={navElementRef}
|
||||
ref={containerElementRef}
|
||||
>
|
||||
{tabChildren}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
77
src/tabs/useIndexOfLastVisibleChild.js
Normal file
77
src/tabs/useIndexOfLastVisibleChild.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import useWindowSize from './useWindowSize';
|
||||
|
||||
const invisibleStyle = {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
visibility: 'hidden',
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* The hook returns an array containing:
|
||||
* [indexOfLastVisibleChild, containerElementRef, invisibleStyle, overflowElementRef]
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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();
|
||||
|
||||
// No-op if the width is unchanged.
|
||||
// (Assumes tabs themselves don't change count or width).
|
||||
if (!containingRect.width === containingRectRef.current.width) {
|
||||
return;
|
||||
}
|
||||
// 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(nextIndexOfLastVisibleChild);
|
||||
}, [windowSize, containerElementRef.current]);
|
||||
|
||||
return [indexOfLastVisibleChild, containerElementRef, invisibleStyle, overflowElementRef];
|
||||
}
|
||||
Reference in New Issue
Block a user