Mobile responsive sequence navigation (#28)

[TNL-7072] When a sequence navigation would overflow, convert it to a dropdown.
This commit is contained in:
Adam Butterworth
2020-03-13 12:57:08 -04:00
committed by GitHub
parent 24ca1aa730
commit a4c978a303
8 changed files with 299 additions and 91 deletions

View File

@@ -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>
);
}

View 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];
}