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.
enum El { Container = 'container', Main = 'main', PrimaryNavWrapper = 'primary-nav-wrapper', PrimaryNav = 'primary-nav', OverflowNav = 'overflow-nav', ToggleBtn = 'toggle-btn', NavItems = 'nav-item', } type NavType = El.PrimaryNav | El.OverflowNav; enum StateModifiers { ButtonVisible = 'is-showing-toggle', OverflowVisible = 'is-showing-overflow', PrimaryHidden = 'is-hiding-primary', } interface ElementRefs { [El.Container]: HTMLElement; clone: { [El.Main]: HTMLElement; [El.NavItems]: HTMLElement[]; [El.ToggleBtn]: HTMLElement; }; primary: { [El.Main]: HTMLElement; [El.PrimaryNavWrapper]: HTMLElement; [El.PrimaryNav]: HTMLElement; [El.NavItems]: HTMLLIElement[]; [El.OverflowNav]: HTMLElement; [El.ToggleBtn]: HTMLElement; }; } interface Instance { eventListeners: Map<((eventDetail: CustomEvent<{}>) => void), { eventType: Events; wrappedCallback: (eventDetail: CustomEvent<{}>) => void; }>; itemMap: WeakMap<HTMLElement|Element, NavType>; observer: IntersectionObserver; } interface Options { classNames: { [El.Container]: string[]; [El.Main]: string[]; [El.PrimaryNavWrapper]: string[]; [El.PrimaryNav]: string[]; [El.OverflowNav]: string[]; [El.ToggleBtn]: string[]; [El.NavItems]: string[]; }; collapseAtCount: number; defaultOverflowVisible: boolean; openOnToggle: boolean; innerToggleTemplate: string|((args: object) => string); } const defaultOptions: Options = { classNames: { [El.Container]: ['p-plus-container'], [El.Main]: ['p-plus'], [El.PrimaryNavWrapper]: ['p-plus__primary-wrapper'], [El.PrimaryNav]: ['p-plus__primary'], [El.OverflowNav]: ['p-plus__overflow'], [El.ToggleBtn]: ['p-plus__toggle-btn'], [El.NavItems]: ['p-plus__primary-nav-item'], }, collapseAtCount: -1, openOnToggle: true, defaultOverflowVisible: false, innerToggleTemplate: 'More', }; function priorityPlus(targetElem: HTMLElement, userOptions: DeepPartial<Options> = {}) { /** * @todo: We shouldn't have to cast this as Options, however DeepPartial creates * breaks the type of innerToggleTemplate (?). */ const options = { ...defaultOptions, ...userOptions, classNames: { ...defaultOptions.classNames, ...userOptions.classNames }, } as Options; const { classNames } = options; /** * The instance's event emitter. */ const eventHandler = createEventHandler(); /** * 'Instance' state variables and misc references. * Force a cast as we know we will initialise these. */ const inst: Instance = { eventListeners: new Map(), itemMap: new WeakMap(), } as Instance; /** * References to DOM elements so we can easily retrieve them. * Force a cast as we know we will initialise these. */ const el: ElementRefs = { clone: {}, primary: {}, } as ElementRefs; /** * Gets an element's 'mirror' Map for the clone/primary navigation - e.g. * if you pass a clone Map, you get the original Map and vice-versa. */ const getElemMirror = createMirror(); /** * Generates classes based on an element name. */ function cn(key: El): string { return classNames[key].join(' '); } /** * Generates data-attributes based on an element name. These are used to query * the generated DOM and populate the 'el' object. */ function dv(key: El): string { return `data-${key}`; } /** * Generates the HTML wrapper to use in-place of the user's supplied menu. */ function createMarkup(): string { return ` <div ${dv(El.Main)} class="${cn(El.Main)}"> <div class="${cn(El.PrimaryNavWrapper)}" ${dv(El.PrimaryNavWrapper)}></div> <button ${dv(El.ToggleBtn)} class="${cn(El.ToggleBtn)}" aria-expanded="false" >${processTemplate(options.innerToggleTemplate)}</button> <${targetElem.tagName} ${dv(El.OverflowNav)} class="${cn(El.OverflowNav)}" aria-hidden="true" > </${targetElem.tagName}> </div> `; } /** * Clones the target menu and enhances it with additional properties, such * as data attributes and classes. */ function cloneNav(elem: HTMLElement): HTMLElement { const targetClone = elem.cloneNode(true) as HTMLElement; enhanceOriginalMenu(targetClone); const navItems = Array.from(targetClone.children) as HTMLLIElement[]; navItems.forEach(enhanceOriginalNavItem) return targetClone; } /** * Enhance the original list element with classes/attributes. */ function enhanceOriginalMenu(elem: HTMLElement) { elem.classList.add(...classNames[El.PrimaryNav]) elem.setAttribute(dv(El.PrimaryNav), ''); } /** * Enhance an original menu list-item with classes/attributes. */ function enhanceOriginalNavItem(elem: HTMLLIElement) { elem.classList.add(...classNames[El.NavItems]) elem.setAttribute(dv(El.NavItems), ''); } /** * Replaces the navigation with the two clones and populates the 'el' object. */ function setupEl() { const { itemMap } = inst; const markup = createMarkup(); const container = document.createElement('div'); container.classList.add(...classNames[El.Container]); container.setAttribute(dv(El.Container), 'true'); el[El.Container] = container; const original = document.createRange().createContextualFragment(markup); // Setup the wrapper and clone/enhance the original menu. el.primary[El.PrimaryNavWrapper] = original.querySelector(`[${dv(El.PrimaryNavWrapper)}]`) as HTMLElement; el.primary[El.PrimaryNavWrapper].appendChild(cloneNav(targetElem)) const cloned = original.cloneNode(true) as Element; // Establish references. By this point the menu is fully built. el.primary[El.Main] = original.querySelector(`[${dv(El.Main)}]`) as HTMLElement; el.primary[El.PrimaryNav] = original.querySelector(`[${dv(El.PrimaryNav)}]`) as HTMLElement; el.primary[El.NavItems] = Array.from(original.querySelectorAll(`[${dv(El.NavItems)}]`)) as HTMLLIElement[]; el.primary[El.OverflowNav] = original.querySelector(`[${dv(El.OverflowNav)}]`) as HTMLElement; el.primary[El.ToggleBtn] = original.querySelector(`[${dv(El.ToggleBtn)}]`) as HTMLElement; el.clone[El.Main] = cloned.querySelector(`[${dv(El.Main)}]`) as HTMLElement; el.clone[El.NavItems] = Array.from(cloned.querySelectorAll(`[${dv(El.NavItems)}]`)) as HTMLElement[]; el.clone[El.ToggleBtn] = cloned.querySelector(`[${dv(El.ToggleBtn)}]`) as HTMLElement; el.clone[El.Main].setAttribute('aria-hidden', 'true'); el.clone[El.Main].setAttribute('data-clone', 'true'); el.clone[El.Main].classList.add(`${classNames[El.Main][0]}--clone`); el.clone[El.Main].classList.add(`${classNames[El.Main][0]}--${StateModifiers.ButtonVisible}`); container.appendChild(original); container.appendChild(cloned); // By default every item belongs in the primary nav, since the intersection // observer will run on-load anyway. el.clone[El.NavItems].forEach(item => itemMap.set(item, El.PrimaryNav)); const parent = targetElem.parentNode as HTMLElement; parent.replaceChild(container, targetElem); } /** * Sets the toggle button visibility. */ function updateBtnDisplay(show: boolean = true) { el.primary[El.Main].classList[show ? 'add' : 'remove']( `${classNames[El.Main][0]}--${StateModifiers.ButtonVisible}`, ); if (typeof options.innerToggleTemplate !== 'string') { // We need to do it for both, as layout is affected [el.primary[El.ToggleBtn], el.clone[El.ToggleBtn]].forEach(btn => { btn.innerHTML = processTemplate(options.innerToggleTemplate, { toggleCount: el.primary[El.OverflowNav].children.length, totalCount: el.clone[El.NavItems].length, }); }); } } /** * Get array of cloned navItems from primary/overflow. */ function getCloneItemsByType(navType: NavType) { const { itemMap } = inst; // Always use the clone as the base for our new nav, // since the order is canonical and it is never filtered. return el.clone[El.NavItems].filter(item => itemMap.get(item) === navType); } /** * Get a list of cloned elements that we need to * render for our navType. */ function getRenderableItems(navType: NavType) { const { collapseAtCount } = options; if (navType === El.PrimaryNav || collapseAtCount < 0) { return getCloneItemsByType(navType); } const primaryCount = getCloneItemsByType(El.PrimaryNav).length; if (primaryCount > 0 && primaryCount <= collapseAtCount) { return el.clone[El.NavItems]; } return getCloneItemsByType(navType); } /** * (Re) generate the navigation list for either the visible or the overflow nav. * We use this to completely recreate the nav each time we update it, * avoiding ordering complexity and having to run append multiple times on * the mounted nav. */ function generateNav(navType: NavType): HTMLElement { const newNav = el.primary[navType].cloneNode(); getRenderableItems(navType) .forEach(item => { const elem = getElemMirror( el.clone[El.NavItems], el.primary[El.NavItems], ).get(item) as HTMLElement; newNav.appendChild(elem); }); return newNav as HTMLElement; } /** * Replaces the passed in nav type with a newly generated copy in the DOM. */ function updateNav(navType: NavType) { const newNav = generateNav(navType); const parent = el.primary[navType].parentNode as HTMLElement; // Replace the existing nav element in the DOM parent.replaceChild( newNav, el.primary[navType], ); // Update our reference to it el.primary[navType] = newNav; } /** * Run every time a nav item intersects with the parent container. * We use this opportunity to check which type of nav the items belong to. */ function onIntersect({ target, intersectionRatio }: IntersectionObserverEntry) { inst.itemMap.set(target, intersectionRatio < 0.99 ? El.OverflowNav : El.PrimaryNav); } /** * The IO callback, which collects intersection events. */ function intersectionCallback(events: IntersectionObserverEntry[]) { // Update the designation events.forEach(onIntersect); // Update the navs to reflect the new changes ([El.PrimaryNav, El.OverflowNav] as NavType[]).forEach(updateNav); eventHandler.trigger(createItemsChangedEvent({ overflowCount: el.primary[El.OverflowNav].children.length, })); /** * Once this callback is run, we can be confident that we are ready to pass on * events to users. If we do so beforehand, internal initialisation events (e.g. * the first itemsChanged and showOverflow events) will be sent to user-defined * listeners. */ eventHandler.setEventReady(true); } /** * Sets the visibility of the overflow navigation. */ function setOverflowNavOpen(open = true) { const openClass = `${classNames[El.Main][0]}--${StateModifiers.OverflowVisible}`; el.primary[El.Main].classList[open ? 'add' : 'remove'](openClass); el.primary[El.OverflowNav].setAttribute('aria-hidden', open ? 'false' : 'true'); el.primary[El.ToggleBtn].setAttribute('aria-expanded', open ? 'true' : 'false'); eventHandler.trigger( open ? createShowOverflowEvent() : createHideOverflowEvent(), ); return this; } /** * Toggles the visibility of the overflow navigation. */ function toggleOverflowNav() { const openClass = `${classNames[El.Main][0]}--${StateModifiers.OverflowVisible}`; setOverflowNavOpen(!el.primary[El.Main].classList.contains(openClass)); return this; } /** * Sets the visibility of the primary navigation (we hide the primary nav * when all the navigation items are hidden in the overflow nav). */ function setPrimaryHidden(hidden = true) { const hiddenClass = `${classNames[El.Main][0]}--${StateModifiers.PrimaryHidden}`; el.primary[El.Main].classList[hidden ? 'add' : 'remove'](hiddenClass); el.primary[El.PrimaryNav].setAttribute('aria-hidden', String(hidden)); } /** * Handle the overflow-nav toggle btn click. */ function onToggleClick(e: Event) { e.preventDefault(); eventHandler.trigger( createToggleClickedEvent({ original: e }) ); } /** * Callback for when either nav is updated. */ function onItemsChanged({ detail: { overflowCount } = {} }: CustomEvent<{[x: string]: any}>) { updateBtnDisplay(overflowCount > 0); if (overflowCount === 0) { setOverflowNavOpen(false); } setPrimaryHidden(overflowCount === el.clone[El.NavItems].length); } /** * Retrieves an index of the primary nav elements. */ function getNavElements() { // Clone it to avoid users changing the el references, // e.g. inst.getNavElements()['toggle-btn'] = null; return {...el.primary}; } /** * Establishes initial event listeners. */ function bindListeners() { inst.observer = new IntersectionObserver(intersectionCallback, { root: el.clone[El.Main], rootMargin: '0px 0px 0px 0px', threshold: [0.99], }); el.clone[El.NavItems].forEach(elem => inst.observer.observe(elem)); el.primary[El.ToggleBtn].addEventListener('click', onToggleClick); eventHandler.on(Events.ItemsChanged, onItemsChanged, false); if (options.openOnToggle) { eventHandler.on(Events.ToggleClicked, toggleOverflowNav, false) } } /** * Remove listeners and attempt to reset the DOM. */ function destroy() { if (inst.observer) inst.observer.disconnect(); el.primary[El.ToggleBtn].removeEventListener('click', onToggleClick); // Unhook instance based event listeners Array.from(inst.eventListeners.entries()) .forEach(([cb, { eventType }]) => { eventHandler.off(eventType, cb); }); // Attempt to reset the DOM back to how it was const parent = el[El.Container].parentNode as HTMLElement; parent.replaceChild(targetElem, el[El.Container]); } (function init() { validateAndThrow(targetElem, userOptions, defaultOptions); setupEl(); bindListeners(); if (options.defaultOverflowVisible) setOverflowNavOpen(true); }()); return { destroy, getNavElements, off: eventHandler.off, on: eventHandler.on, setOverflowNavOpen, toggleOverflowNav, }; } export default priorityPlus;