Mobile layout for breadcrumbs and tabs (#26)
TNL-7072 mobile layout updates. Breadcrumbs truncate section and subsection titles with ellipsis. Tabs that would overflow are tucked under a "more" dropdown.
This commit is contained in:
119
src/tabs/Tabs.jsx
Normal file
119
src/tabs/Tabs.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React, {
|
||||
useLayoutEffect, useRef, useMemo, useState,
|
||||
} 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';
|
||||
|
||||
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 tabChildren = useMemo(() => {
|
||||
const childrenArray = React.Children.toArray(children);
|
||||
|
||||
// All tabs will be rendered. Those that would overflow are set to invisible.
|
||||
const wrappedChildren = childrenArray.map((child, index) => (
|
||||
<li className="nav-item" style={cutOffIndex <= index ? invisibleStyle : null}>
|
||||
{React.cloneElement(child)}
|
||||
</li>
|
||||
));
|
||||
|
||||
// Build the list of items to put in the overflow menu
|
||||
const overflowChildren = childrenArray.slice(cutOffIndex)
|
||||
.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
|
||||
className="nav-item"
|
||||
style={cutOffIndex >= React.Children.count(children) ? invisibleStyle : null}
|
||||
ref={overflowEl}
|
||||
>
|
||||
<Dropdown>
|
||||
<Dropdown.Button className="nav-link font-weight-normal">
|
||||
<FormattedMessage
|
||||
id="learn.course.tabs.navigation.overflow.menu"
|
||||
description="The title of the overflow menu for course tabs"
|
||||
defaultMessage="More..."
|
||||
/>
|
||||
</Dropdown.Button>
|
||||
<Dropdown.Menu className="dropdown-menu-right">{overflowChildren}</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</li>
|
||||
));
|
||||
return wrappedChildren;
|
||||
}, [children, cutOffIndex]);
|
||||
|
||||
return (
|
||||
<ul
|
||||
{...attrs}
|
||||
className={classNames('nav flex-nowrap', className)}
|
||||
ref={navElementRef}
|
||||
>
|
||||
{tabChildren}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
Tabs.propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
Tabs.defaultProps = {
|
||||
children: null,
|
||||
className: undefined,
|
||||
};
|
||||
26
src/tabs/useWindowSize.js
Normal file
26
src/tabs/useWindowSize.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export default function useWindowSize() {
|
||||
const isClient = typeof global === 'object';
|
||||
|
||||
const getSize = () => ({
|
||||
width: isClient ? global.innerWidth : undefined,
|
||||
height: isClient ? global.innerHeight : undefined,
|
||||
});
|
||||
|
||||
const [windowSize, setWindowSize] = useState(getSize);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isClient) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
setWindowSize(getSize());
|
||||
};
|
||||
global.addEventListener('resize', handleResize);
|
||||
return () => global.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
return windowSize;
|
||||
}
|
||||
Reference in New Issue
Block a user