Anatomical “fugitive sheets” are illustrations of the body designed to display internal organs and structures using paper flaps. Their name arose from the frequency with which the accompanying sheets were torn or misplaced. This site reimagines the fugitive sheet as a misplaced code-snippet, framed within a randomly generated cut-out.
function debounce(callback, wait, context = this) { let timeout = null; let callbackArgs = null; const later = () => callback.apply(context, callbackArgs); return (...args) => { callbackArgs = args; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } const heyModal = (() => { let id = 0; const defaultOptions = { classes: { modal: ['modal'], modalDialog: ['modal__dialog'], modalHeader: ['modal__header'], modalBody: ['modal__body'], modalContent: ['modal__content'], modalClose: ['modal__close'], modalCloseBtnText: ['modal__close-text'], modalTitle: ['modal__title'], confirm: ['modal__confirm'], confirmYes: ['btn', 'btn--positive'], confirmCancel: ['btn', 'btn--negative'], visibleClass: ['modal--is-visible'], bodyOverflowClass: ['modal-body-no-scroll'], }, }; const heyModalProto = { options: defaultOptions, body: null, elem: null, onClick: null, target: null, confirm: false, events: { open: new CustomEvent('heyOpen'), close: new CustomEvent('heyClose'), }, content: { title: null, body: null, }, shared: { isScrollable: null, scrollbarWidth: null, testResults: null, }, init() { this.body = document.querySelector('body'); this.id = id; // Run browser tests this.tests(); // Update shared properties this.setScrollable(); this.setScrollbarWidth(); // Check if the classes passed in are valid this.checkClasses(); // If it's a confirm we flip a switch if (this.elem.hasAttribute('data-hey-confirm')) { this.confirm = true; } // Don't check/set the target on confirms, since they don't have one if (!this.confirm) { this.setTarget(); this.checkTarget(); } this.build(); this.removeTarget(); // if we can't make the body 100% of the remaining height, // we need to manually set a max-height to force a scrollable overflow. if (!this.shared.testResults.flexbox) { this.setMaxHeight(); } this.bindEvents(); }, tests() { // Run the tests is we haven't already if (!this.shared.testResults) { const results = {}; // Transitions results.transitions = ('transition' in document.documentElement.style) || ('WebkitTransition' in document.documentElement.style); // Flexbox results.flexbox = ('flex-wrap' in document.documentElement.style); this.shared.testResults = results; } }, on(event, action) { this.comp.wrapper.addEventListener(event, action); }, checkClasses() { try { // Check if all classes are supplied as arrays const allArrays = Object.keys(this.options.classes) .every(c => this.options.classes[c] instanceof Array); if (!allArrays) { throw new Error('Classes must be set as arrays, e.g. confirm: [\'class-one\']'); } } catch (e) { console.error(e); } }, build() { const c = {}; const classes = this.options.classes; // Wrapper c.wrapper = document.createElement('div'); c.wrapper.classList.add(...classes.modal); c.wrapper.setAttribute('aria-hidden', 'true'); // Dialog c.dialog = document.createElement('div'); c.dialog.classList.add(...classes.modalDialog); c.dialog.setAttribute('role', 'dialog'); c.dialog.setAttribute('aria-labelledby', `modal__title-${this.id}`); // Header c.header = document.createElement('div'); c.header.classList.add(...classes.modalHeader); // Title c.title = document.createElement('h3'); c.title.classList.add(...classes.modalTitle); c.title.id = `modal__title-${this.id}`; // Inner c.inner = document.createElement('div'); c.inner.classList.add(...classes.modalBody); c.inner.style.overflow = 'auto'; // Body c.body = document.createElement('div'); c.body.classList.add(...classes.modalContent); c.inner.appendChild(c.body); // Close button c.closeBtn = document.createElement('button'); c.closeBtn.classList.add(...classes.modalClose); c.closeBtn.setAttribute('type', 'button'); c.closeBtn.setAttribute('aria-label', 'Close'); c.closeText = document.createElement('span'); c.closeText.classList.add(...classes.modalCloseBtnText); c.closeText.innerHTML = 'Close'; c.closeBtn.appendChild(c.closeText); // Add confirm buttons if (this.confirm) { c.confirm = document.createElement('div'); c.confirm.classList.add(...classes.confirm); if (this.isLink()) { c.confirmYes = document.createElement('a'); // If <a> simply copy the href to the confirm link c.confirmYes.setAttribute('href', this.elem.getAttribute('href')); } else { c.confirmYes = document.createElement('button'); } c.confirmYes.innerHTML = 'Proceed'; c.confirmYes.classList.add(...classes.confirmYes); c.confirmCancel = document.createElement('button'); c.confirmCancel.innerHTML = 'Cancel'; c.confirmCancel.setAttribute('data-hey-close', ''); c.confirmCancel.classList.add(...classes.confirmCancel); c.confirm.appendChild(c.confirmYes); c.confirm.appendChild(c.confirmCancel); c.inner.appendChild(c.confirm); } // Build modal c.header.appendChild(c.title); c.header.appendChild(c.closeBtn); c.dialog.appendChild(c.header); c.dialog.appendChild(c.inner); c.wrapper.appendChild(c.dialog); this.comp = c; // Update content this.populate(); // Add to DOM this.body.appendChild(c.wrapper); }, populate() { const self = this; const content = {}; for (const el in this.content) { content[el] = this.content[el]; // If the property is not already defined if (!this.content[el]) { // First check if we have a target if (self.target) { // If so, look for elements with data attributes that match inside it const domElem = self.target.querySelector(`[data-hey-${el}]`); // If found, assign them if (domElem) { content[el] = domElem.innerHTML; } // Otherwise, look for data attributes on the target } else if (self.elem.hasAttribute(`data-hey-${el}`)) { // If found, assign them content[el] = self.elem.getAttribute(`data-hey-${el}`); } } this.comp[el].innerHTML = content[el]; } this.content = content; }, setMaxHeight() { const wrapperStyles = getComputedStyle(this.comp.wrapper); const headerHeight = this.comp.header.offsetHeight; // We can't use 100vh since mobile device support causes issues const wrapperHeight = this.comp.wrapper.offsetHeight; this.comp.inner.style.maxHeight = `calc(${wrapperHeight}px - (${wrapperStyles.paddingTop} + ${wrapperStyles.paddingTop}) - ${headerHeight}px)`; }, setTarget() { // If a data attribute is set with a target if (this.elem.hasAttribute('data-hey')) { this.target = document.querySelector(this.elem.getAttribute('data-hey')); } else if (this.elem.hasAttribute('href') && this.elem.getAttribute('href').indexOf('#') >= 0) { // Otherwise try to use the ID in the link this.target = document.querySelector(this.elem.getAttribute('href')); } }, checkTarget() { let hasTarget; try { // Is the target a valid node if (!(this.target && this.target.nodeType)) { hasTarget = false; throw new Error('No modal target given.'); } else { hasTarget = true; } } catch (e) { console.error(e); } return hasTarget; }, isLink() { return this.elem.nodeName === 'A'; }, // Remove the original target removeTarget() { if (this.target) { this.target.parentElement.removeChild(this.target); } }, bindEvents() { // Check if we need to accommodate scrollbars, which changes depending on viewport window.addEventListener('resize', debounce(this.setScrollable.bind(this), 500)); // Update the max height (if we don't have flexbox) if (!this.shared.testResults.flexbox) { window.addEventListener('resize', debounce(this.setMaxHeight.bind(this), 200)); } // Scrolling on the modal on mobile shouldn't scroll the bg this.comp.wrapper.addEventListener('touchmove', (e) => { e.preventDefault(); }, false); // Allow mobile scrolling on the body this.comp.body.addEventListener('touchmove', (e) => { e.stopPropagation(); }, false); // Open on target click this.onClick = (e) => { e.preventDefault(); this.open(); }; this.elem.addEventListener('click', this.onClick); // Close events on wrapper/close button this.comp.closeBtn.addEventListener('click', this.close.bind(this)); this.comp.wrapper.addEventListener('click', (e) => { if (e.target.classList.contains(this.options.classes.modal.join(' '))) { this.close(); } }); const userCloseBtn = this.comp.dialog.querySelector('[data-hey-close]'); if (userCloseBtn) { // Allow a user to assign a close button inside the body with [data-hey-close] userCloseBtn.addEventListener('click', (e) => { e.preventDefault(); this.close(); }); } if (this.confirm && !this.isLink()) { // When the confirm link is clicked: remove the modal // listener, fire this.elem’s click event, then re-add the listener // and close the modal this.comp.confirmYes.addEventListener('click', (e) => { e.preventDefault(); this.elem.removeEventListener('click', this.onClick); this.elem.click(); this.elem.addEventListener('click', this.onClick); this.close(); }); } // Keybindings this.comp.wrapper.addEventListener('keydown', (e) => { switch (e.keyCode) { // Escape case 27: this.close(); break; // Tab case 9: // If we're tabbing backwards if (e.shiftKey) { // If (pre-event) we were focused on the first element... if (this.firstFocusable === document.activeElement) { e.preventDefault(); // ... send us backwards to the last in the dialog this.lastFocusable.focus(); } // If (pre-event) we were focused on the last element... } else if (this.lastFocusable === document.activeElement) { e.preventDefault(); // ... send us to the first in the dialog this.firstFocusable.focus(); } break; default: break; } }); }, setScrollable() { // Is the document taller than the viewport? const isScrollable = document.body.offsetHeight > window.innerHeight; // This is for IE10, which will overlay scrollbars (depending on @-ms-viewport). // We test to see if the visible area is equal to the client width - if it is, // the scrollbar is probably overlaid, so we shouldn't set the value to scrollable // or we'll actually *create* a shift, instead of preventing it. const scrollOverlaid = document.documentElement.clientWidth === window.innerWidth; this.shared.isScrollable = isScrollable && !scrollOverlaid; }, open() { this.comp.wrapper.classList.add(...this.options.classes.visibleClass); this.setPageScroll(false); this.comp.wrapper.setAttribute('aria-hidden', 'false'); this.setLastFocusedElem(); this.comp.wrapper.dispatchEvent(this.events.open); this.body.style.marginRight = this.shared.isScrollable ? `${this.shared.scrollbarWidth}px` : ''; // Visibility: hidden will stop us setting focus, // so we have to do it after the transition function transitionEnd() { this.setFocus(); this.comp.wrapper.removeEventListener('transitionEnd', transitionEnd); } // We need to check, otherwise this will never fire in IE9 if (this.shared.testResults.transitions) { this.comp.wrapper.addEventListener('transitionend', transitionEnd.bind(this)); } }, setFocus() { // All elements in the dialog that can receive focus const elemsWithFocus = this.comp.dialog.querySelectorAll('a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex="0"]'); this.firstFocusable = elemsWithFocus[0]; this.lastFocusable = elemsWithFocus[elemsWithFocus.length - 1]; // Focus on first element, probably the close button this.firstFocusable.focus(); }, setLastFocusedElem() { this.lastFocused = document.activeElement; }, close() { this.comp.wrapper.classList.remove(...this.options.classes.visibleClass); this.lastFocused.focus(); const closeOver = () => { this.setPageScroll(true); this.body.style.marginRight = 0; this.comp.wrapper.removeEventListener('transitionend', closeOver); this.comp.wrapper.setAttribute('aria-hidden', 'true'); this.comp.wrapper.dispatchEvent(this.events.close); }; // We need to check, otherwise this will never fire in IE9 if (this.shared.testResults.transitions) { this.comp.wrapper.addEventListener('transitionend', closeOver); } else { closeOver(); } }, setPageScroll(scrollable = false) { if (scrollable) { this.body.classList.remove(...this.options.classes.bodyOverflowClass); } else { this.body.classList.add(...this.options.classes.bodyOverflowClass); } }, setScrollbarWidth() { // Cache the value, since it's unlikely to change if (!this.shared.scrollbarWidth) { // Create box to measure scrollbar const measure = document.createElement('div'); // Make sure it triggers overflow measure.style.width = 100; measure.style.height = 100; measure.style.overflow = 'scroll'; measure.style.position = 'absolute'; measure.style.top = -9999; // Add the measure element this.body.appendChild(measure); // Measure the difference between with/without the scrollbar const width = measure.offsetWidth - measure.clientWidth; // Remove from DOM this.body.removeChild(measure); // Update our best guess at the width this.shared.scrollbarWidth = width; } }, }; return (elem, customOptions) => { id += 1; // Use Lodash here, since we need to do a deep merge. // Allows for a single class to be passed in, without removing all the other classes. const options = merge({}, defaultOptions, customOptions); // Create a new modal object const newModal = merge(Object.create(heyModalProto), { elem, }, { options }); // Run initialisation newModal.init(); // Return our new modal return newModal; }; })(); export default heyModal;