Skip to content

Instantly share code, notes, and snippets.

@telic
Created April 24, 2017 02:04
Show Gist options
  • Save telic/1f409cdb3aebbf5b5cd7d18be934595a to your computer and use it in GitHub Desktop.
Save telic/1f409cdb3aebbf5b5cd7d18be934595a to your computer and use it in GitHub Desktop.
A collapsing-menu enhancement script
/* eslint-env es6 */
/**
* Greedymenu - hides menu items that don't fit in a popup menu
*
* Automatically applies to to elements with MENU_CLASS or MENU_S_CLASS
* on document load. Also exports an initialization function (window.greedymenu)
* that can be used to transform nav lists explicitly.
*
* Inspired by http://codepen.io/lukejacksonn/pen/PwmwWV/
*
* @author Maxwell Terpstra <[email protected]>
*/
window.greedymenu = (function() {
"use strict";
const MENU_CLASS = "greedymenu";
const MENU_S_CLASS = "greedymenu--styled";
const MENU_C_CLASS = "greedymenu--collapsed";
const MTOOL_CLASS = "greedymenu__measureTool";
const BUTTON_CLASS = "greedymenu__button";
const ICON_CLASS = "greedymenu__button__icon";
const FLYOUT_CLASS = "greedymenu__flyout";
const LIST_CLASS = "greedymenu__original";
const PROTECT_CLASS = "greedymenu__fixed";
const RTOOL_CLASS = "greedymenu__resizerTool";
const RTOOL_G_CLASS = "greedymenu__resizerTool__grow";
const RTOOL_S_CLASS = "greedymenu__resizerTool__shrink";
const FLYOUT_SIZE_DATA = "data-greedymenu-hidden";
const ITEM_WIDTH_DATA = "data-greedymenu-width";
const ITEM_INDEX_DATA = "data-greedymenu-index";
// hamburger icon.
// <svg class="greedymenu__buton__icon" viewbox="0 0 6 6">
// <line y1="1" y2="1" x1="0" x2="6" />
// <line y1="3" y2="3" x1="0" x2="6" />
// <line y1="5" y2="5" x1="0" x2="6" />
// </svg>
const SVG = "http://www.w3.org/2000/svg";
const hamburger = createNS(SVG, "svg", {
'class': ICON_CLASS,
'viewBox': "0 0 6 6"
});
for (const i of [1,3,5]) {
hamburger.appendChild(
createNS(SVG, "line", { 'y1': i, 'y2': i, 'x1': 0, 'x2': 6 })
);
}
// predictable element to do width calculations
const measureTool = create("div", { 'class': MTOOL_CLASS });
// resize detector
const resizerClass = { 'class': RTOOL_CLASS };
const resizerTool = create("div", resizerClass, [
create("div", resizerClass, [
create("div", { 'class': RTOOL_G_CLASS })
]),
create("div", resizerClass, [
create("div", { 'class': RTOOL_S_CLASS })
])
]);
// automatically initalize menus on DOM load
if (document.readyState === "complete") {
autoInit();
} else {
document.addEventListener("DOMContentLoaded", autoInit);
}
function autoInit() {
document.querySelectorAll("nav."+MENU_CLASS+", nav."+MENU_S_CLASS)
.forEach(init);
}
let inst = 0; // instance counter; for generating IDs
function init(nav) {
// find the first list child
const oList = (function() {
for (let l of Array.prototype.slice.call(nav.children)) {
if (l.matches("ul,ol")) {
return l;
}
}
})();
if (oList === undefined) return;
// make sure nav has a root class
if (!nav.classList.contains(MENU_CLASS)) {
nav.classList.add(MENU_CLASS);
}
// mark it as the original
oList.classList.add(LIST_CLASS);
// add the flyout menu and button
const flyout = create(oList.nodeName, {
'id': FLYOUT_CLASS+":"+(inst++),
'class': FLYOUT_CLASS,
'aria-role': "menu",
'aria-hidden': true,
});
nav.appendChild(flyout);
const button = create("button", {
'class': BUTTON_CLASS,
'type': "button",
'aria-haspopup': true,
'aria-controls': flyout.id,
'aria-expanded': false,
'aria-hidden': true,
});
button.appendChild(hamburger.cloneNode(true));
nav.appendChild(button);
// wire the button to toggle the flyout menu
button.addEventListener('click', function() {
let expanded = (button.getAttribute('aria-expanded') === "true");
flyout.setAttribute('aria-hidden', expanded);
button.setAttribute('aria-expanded', !expanded);
if (expanded) button.focus();
else flyout.querySelector("li > *").focus();
});
// escape key should also close the flyout
window.addEventListener('keyup', function(e) {
if (e.keyCode === 27) {
// send focus to the button if it is currently inside the flyout
let focus = document.activeElement;
while (focus.parentNode) {
if (focus === flyout) {
button.focus();
break;
}
focus = focus.parentNode;
}
// then close the flyout
flyout.setAttribute('aria-hidden', true);
button.setAttribute('aria-expanded', false);
}
});
// hide any items that don't fit
reflow(nav, oList, flyout, button);
// reflow again whenever nav size changes
subscribe(nav, oList, flyout, button);
}
function subscribe(nav, oList, flyout, button) {
const sensor = resizerTool.cloneNode(true);
const grow = sensor.childNodes[0];
const shrink = sensor.childNodes[1];
let oldWidth = nav.offsetWidth;
let newWidth;
let widthChanged = false;
let rafWaiting = false;
const onScroll = function() {
newWidth = nav.offsetWidth;
widthChanged = (newWidth !== oldWidth);
if (widthChanged && !rafWaiting) {
rafWaiting = true;
requestAnimationFrame(function() {
rafWaiting = false;
if (!widthChanged) return;
oldWidth = newWidth;
reflow(nav, oList, flyout, button);
});
}
grow.scrollLeft = 100000;
shrink.scrollLeft = 100000;
};
nav.appendChild(sensor);
grow.scrollLeft = 100000;
shrink.scrollLeft = 100000;
grow.addEventListener('scroll', onScroll);
shrink.addEventListener('scroll', onScroll);
}
function reflow(nav, oList, flyout, button) {
const n = getComputedStyle(nav);
const pl = convertToUnitlessPx(n.getPropertyValue('padding-left'));
const pr = convertToUnitlessPx(n.getPropertyValue('padding-right'));
const available = nav.clientWidth - pl - pr;
const u = getComputedStyle(oList);
const ml = convertToUnitlessPx(u.getPropertyValue('margin-left'));
const mr = convertToUnitlessPx(u.getPropertyValue('margin-right'));
let actual = oList.offsetWidth + ml + mr;
if (actual > available) {
let items = oList.children;
// show the flyout menu toggle button if we haven't already
if (button.getAttribute('aria-hidden') === "true") {
button.setAttribute('aria-hidden', false);
button.style.right = n.getPropertyValue('padding-right');
oList.style.setProperty("padding-right", button.offsetWidth+"px", "important");
actual += button.offsetWidth;
// also mark the nav, to help with styling
nav.classList.add(MENU_C_CLASS);
}
// move items until everything fits or nothing left to move
for (let i=items.length-1; i>=0 && actual>available; i--) {
if (!items[i].classList.contains(PROTECT_CLASS)) {
const iStyle = getComputedStyle(items[i]);
const width = (
items[i].offsetWidth +
convertToUnitlessPx(iStyle.getPropertyValue('margin-left')) +
convertToUnitlessPx(iStyle.getPropertyValue('margin-right'))
);
// remember where this item came from and how big it was
items[i].setAttribute(ITEM_INDEX_DATA, i);
items[i].setAttribute(ITEM_WIDTH_DATA, width);
// and move it to the flyout menu
actual -= width;
flyout.insertBefore(items[i], flyout.firstChild);
}
}
}
else if (available > actual) {
let items = flyout.children;
// add items back to the original list as long as there is room
while (items.length>0 && available>actual) {
const width = parseFloat(items[0].getAttribute(ITEM_WIDTH_DATA));
if (
(available-actual >= width) ||
(items.length === 1 && available+button.offsetWidth-actual >= width)
) {
oList.insertBefore(
items[0],
oList.children[
parseInt(items[0].getAttribute(ITEM_INDEX_DATA))
]
);
actual += width;
} else {
break;
}
}
// remove the button if nothing left in the flyout
if (flyout.children.length === 0) {
button.setAttribute('aria-hidden', true);
oList.style.paddingRight = 0;
nav.classList.remove(MENU_C_CLASS);
// FIXME: might also need to close the menu
}
}
// track the flyout menu size on a button data attr
button.setAttribute(FLYOUT_SIZE_DATA, flyout.children.length);
}
// ######################
// ### UTILITY FUNCTIONS
// ######################
function convertToUnitlessPx(val) {
if (/^\s*[-+]?\d+(\.\d+)?(px)?\s*$/.test(val)) {
return parseInt(val);
} else {
if (measureTool.parentNode === null) {
document.body.appendChild(measureTool);
}
measureTool.style.width = val;
return measureTool.offsetWidth;
}
}
function createNS(ns, tag, attrMap, children) {
return create(
document.createElementNS(ns, tag),
attrMap,
children
);
}
function create(tag, attrMap, children) {
let n = (tag instanceof Node) ? tag : document.createElement(tag);
for (let a in attrMap) {
n.setAttribute(a, attrMap[a]);
}
if (children) for (let c of children) {
n.appendChild(c);
}
return n;
}
return init;
})();
// in case it isn't already
[aria-hidden="true"] {
visibility: hidden;
}
// root nav
.greedymenu {
position: relative;
& > ul, & > ol {
display: inline-table;
& > li {
display: table-cell;
}
}
}
// reserve right-padding for the button
.greedymenu__original {
padding-right: 0 !important;
}
// overflow list
.greedymenu__flyout {
position: absolute;
display: block;
& > li {
display: block;
}
}
// menu button
.greedymenu__button {
position: absolute;
cursor: pointer;
line-height: 0;
&__icon {
stroke: currentColor;
stroke-width: 1px;
height: 20px;
width: 20px;
}
}
// basic styling (opt-in)
.greedymenu--styled {
@extend .greedymenu;
// as a vertical blocky list
.greedymenu__flyout {
display: block;
margin: 0;
padding: 0;
position: absolute;
top: 100%;
right: 0;
list-style: none;
background-color: white;
border: 1px solid #555;
& > li {
display: block;
margin: 0;
padding: 0.5em 1em;
}
}
// simple black on white icon with badge
.greedymenu__button {
// remove default button appearance
-webkit-appearance: none;
-moz-appearance: none;
bottom: 0;
margin: 0;
border: 0;
padding: 5px;
// make sure black stroke is visible
background: transparentize(white, 0.5);
border-radius: 3px;
color: black;
// show the number of hidden items as a badge on the button
&::before {
content: attr(data-greedymenu-hidden);
position: absolute;
z-index: 2;
left: -3px;
top: 12px;
font-size: 10px;
line-height: 13px;
font-family: Arial;
background-color: black;
color: white;
height: 13px;
width: 13px;
border-radius: 13px;
display: block;
border: 1px solid white;
}
}
}
// non-visible elements used for JS tasks
.greedymenu__resizerTool {
position: absolute;
left: 0;
top: 0;
right: 0;
height: 1px;
overflow: hidden;
z-index: -1;
visibility: hidden;
}
.greedymenu__resizerTool__grow,
.greedymenu__resizerTool__shrink {
position: absolute;
left: 0;
top: 0;
bottom: 0;
transition: 0s;
}
.greedymenu__resizerTool__grow {
width: 100000px;
}
.greedymenu__resizerTool__shrink {
width: 200%;
}
.greedymenu__measureTool {
position: absolute;
z-index: -1;
visibility: hidden;
margin: 0;
padding: 0;
border: 0;
height: 1px;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment