## What is it

A tabs component that provides different sections of content that are displayed one at a time when the user selects that information.

## When to use it

The tabbed user interface enables users to jump to their target section quickly. Tabs present like logically group information on the same page. Information should

- be logically chunked and ordered
- be arallel in nature
- show user's context
- obvious where they begin and end

Users should not need to see content of multiple tabs simultaneously and the user should be able to easily recognise where they are within the content.

## How to use it

The structure of the tab set is defined in html. There are two forms supported. Adding a class of `.tab-group` to the container element will work in place of the `tabset` tag, and the tab panels can be defined using either `tab=""` or `data-tab=""`. Passing an optional element to the init function will initialise tabs within that element. Adding a `order=""` or `data-order=""` element to the tabset you can have the tabs sorted in a consistent order across tabsets.

## Example

    tabset(order="tab2, tab1")
    	div(tab="tab1")
    	div(tab="tab2")

    tabset#uniqueID(order="tab title 2,tab title 1")
    	div(tab="[tab title 1]")
    	div(tab="[tab title 2]")

    tabset, .tab-group {
    	margin: 2rem 0 1rem 0;
    }
    tabset [role=tablist] li.selected, .tab-group [role=tablist] li.selected {
    	background-color: var(--colour-green);
    	color: var(--colour-white);
    }
    tabset > ul,
    tabset .tab-scroll > ul, .tab-group > ul,
    .tab-group .tab-scroll > ul {
    	display: -webkit-box;
    	display: -ms-flexbox;
    	display: flex;
    	margin: 0;
    	padding: 0;
    	-webkit-user-select: none;
    	   -moz-user-select: none;
    	    -ms-user-select: none;
    	        user-select: none;
    }
    tabset > ul li.separator,
    tabset .tab-scroll > ul li.separator, .tab-group > ul li.separator,
    .tab-group .tab-scroll > ul li.separator {
    	border-bottom: 1px solid #7f7f7f;
    	border-left: 1px solid #7f7f7f;
    	display: inline-block;
    	-webkit-box-flex: 1;
    	    -ms-flex: 1 1 auto;
    	        flex: 1 1 auto;
    	margin: 0.45rem 0 0 0;
    	width: auto;
    }
    tabset .tab-hidden, .tab-group .tab-hidden {
    	display: none;
    }
    tabset [role=tab], .tab-group [role=tab] {
    	background-color: #FFF;
    	border-left: 1px solid #7f7f7f;
    	border-radius: 0.5rem 0.5rem 0 0;
    	border-top: 1px solid #7f7f7f;
    	cursor: pointer;
    	display: block;
    	-webkit-box-flex: 0;
    	    -ms-flex: 0 0 auto;
    	        flex: 0 0 auto;
    	margin: 0;
    	max-width: 100vw;
    	overflow: hidden;
    	padding: 1rem 1.5rem 0.14rem 1.5rem;
    	-webkit-user-select: none;
    	   -moz-user-select: none;
    	    -ms-user-select: none;
    	        user-select: none;
    	white-space: normal;
    	word-break: break-word;
    }
    tabset [role=tab]:last-of-type, .tab-group [role=tab]:last-of-type {
    	border-right: 1px solid #7f7f7f;
    }
    tabset [role=tab]:not(.selected), .tab-group [role=tab]:not(.selected) {
    	background-color: #f0f0f0;
    	border-bottom: 1px solid #7f7f7f;
    }
    tabset [role=tab] span, .tab-group [role=tab] span {
    	display: block;
    	margin: 0 0 0.5rem 0;
    }
    tabset [role=tabpanel], .tab-group [role=tabpanel] {
    	background-color: #FFF;
    	border: 1px solid #7f7f7f;
    	border-top: none;
    	padding: 1rem;
    	z-index: 1;
    }
    tabset [role=tabpanel]:not(.open), .tab-group [role=tabpanel]:not(.open) {
    	display: none;
    }

    .tab-scroll {
    	width: 100%;
    	overflow-x: auto;
    	overflow-y: hidden;
    	scrollbar-width: none;
    	-ms-overflow-style: none;
    	-webkit-user-select: none;
    	   -moz-user-select: none;
    	    -ms-user-select: none;
    	        user-select: none;
    }
    .tab-scroll::-webkit-scrollbar {
    	display: none;
    }
    .tab-scroll > ul {
    	-ms-flex-wrap: nowrap;
    	    flex-wrap: nowrap;
    	min-width: 100%;
    	width: -webkit-max-content;
    	width: -moz-max-content;
    	width: max-content;
    }

    .tab-scroll-wrapper {
    	position: relative;
    	-webkit-user-select: none;
    	   -moz-user-select: none;
    	    -ms-user-select: none;
    	        user-select: none;
    }
    .tab-scroll-wrapper::before, .tab-scroll-wrapper::after {
    	content: "";
    	position: absolute;
    	top: 0;
    	width: 2.5rem;
    	height: 100%;
    	pointer-events: none;
    	z-index: 2;
    	opacity: 0;
    	-webkit-transition: opacity 0.2s ease;
    	transition: opacity 0.2s ease;
    }
    .tab-scroll-wrapper::before {
    	background: radial-gradient(circle at left center, rgb(255, 255, 255) 0%, rgba(255, 255, 255, 0.9) 50%, rgba(255, 255, 255, 0.5) 75%, rgba(255, 255, 255, 0.25) 95%, transparent 100%);
    	border-top-right-radius: 2.5rem;
    	border-bottom-right-radius: 2.5rem;
    	left: 0;
    }
    .tab-scroll-wrapper::after {
    	right: 0;
    	background: radial-gradient(circle at right center, rgb(255, 255, 255) 0%, rgba(255, 255, 255, 0.9) 50%, rgba(255, 255, 255, 0.5) 75%, rgba(255, 255, 255, 0.25) 95%, transparent 100%);
    	border-top-left-radius: 2.5rem;
    	border-bottom-left-radius: 2.5rem;
    }
    .tab-scroll-wrapper.is-overflowing::before, .tab-scroll-wrapper.is-overflowing::after {
    	opacity: 0;
    	-webkit-transition: opacity 0.15s ease-out;
    	transition: opacity 0.15s ease-out;
    	-webkit-transition: opacity 0.12s cubic-bezier(0.4, 0, 0.2, 1);
    	transition: opacity 0.12s cubic-bezier(0.4, 0, 0.2, 1);
    }
    .tab-scroll-wrapper.is-overflowing.show-left::before {
    	opacity: 1;
    }
    .tab-scroll-wrapper.is-overflowing.show-right::after {
    	opacity: 1;
    }
    .tab-scroll-wrapper .tab-scroll-arrow {
    	-webkit-box-align: center;
    	    -ms-flex-align: center;
    	        align-items: center;
    	cursor: pointer;
    	display: none;
    	height: 100%;
    	-webkit-box-pack: center;
    	    -ms-flex-pack: center;
    	        justify-content: center;
    	position: absolute;
    	top: 0;
    	-webkit-user-select: none;
    	   -moz-user-select: none;
    	    -ms-user-select: none;
    	        user-select: none;
    	width: 2.5rem;
    	z-index: 3;
    }
    .tab-scroll-wrapper .tab-scroll-arrow path {
    	fill: #7f7f7f;
    }
    .tab-scroll-wrapper .tab-scroll-arrow.left {
    	left: 0;
    }
    .tab-scroll-wrapper .tab-scroll-arrow.right {
    	right: 0;
    }
    .tab-scroll-wrapper .tab-scroll-arrow.hidden {
    	display: none !important;
    	pointer-events: none;
    	visibility: hidden;
    }
    .tab-scroll-wrapper.is-overflowing .tab-scroll-arrow {
    	display: -webkit-box;
    	display: -ms-flexbox;
    	display: flex;
    }

## Example

    @use "scss/core/tabs/_tabs";
    @include tabs{
    	// optional content block
    };

    $tab-border: #7f7f7f !default;

    $tab-selected: #FFF !default;
    $tab-selected-text: #000 !default;

    $tab-notselected: #f0f0f0 !default;
    $tab-notselected-text: #000 !default;

    @mixin tabs {

    	tabset, .tab-group {

    		margin: 2rem 0 1rem 0;

    		[role="tablist"] li.selected {
    			background-color: var(--colour-green);
    			color: var(--colour-white);

    		}

    		> ul,
    		.tab-scroll > ul {
    			display: flex;
    			margin: 0;
    			padding: 0;
    			user-select: none;

    			li.separator {
    				border-bottom: 1px solid $tab-border;
    				border-left: 1px solid $tab-border;
    				display: inline-block;
    				flex: 1 1 auto;
    				margin: .45rem 0 0 0;
    				width: auto;
    			}
    		}

    		.tab-hidden {
    			display: none;
    		}

    		[role="tab"] {
    			background-color: $tab-selected;
    			border-left: 1px solid $tab-border;
    			border-radius: .5rem .5rem 0 0;
    			border-top: 1px solid $tab-border;
    			cursor: pointer;
    			display: block;
    			flex: 0 0 auto;
    			margin: 0;
    			max-width: 100vw;
    			overflow: hidden;
    			padding: 1rem 1.5rem .14rem 1.5rem;
    			user-select: none;
    			white-space: normal;
    			word-break: break-word;

    			&:last-of-type {
    				border-right: 1px solid $tab-border;
    			}

    			&:not(.selected) {
    				background-color: $tab-notselected;
    				border-bottom: 1px solid $tab-border;
    			}

    			span {
    				display: block;
    				margin: 0 0 .5rem 0;
    			}
    		}

    		[role="tabpanel"] {
    			background-color: $tab-selected;
    			border: 1px solid $tab-border;
    			border-top: none;
    			padding: 1rem;
    			z-index: 1;

    			&:not(.open) {
    				display: none;
    			}

    			@content;
    		}
    	}

    	// scroller
    	.tab-scroll {
    		width: 100%;
    		overflow-x: auto;
    		overflow-y: hidden;
    		scrollbar-width: none;
    		-ms-overflow-style: none;
    		user-select: none;

    		&::-webkit-scrollbar {
    			display: none;
    		}

    		> ul {
    			flex-wrap: nowrap;
    			min-width: 100%;
    			width: max-content;
    		}
    	}

    	// wrapper (now owns the fade)
    	.tab-scroll-wrapper {
    		position: relative;
    		user-select: none;
    		$radial: 2.5rem;
    		// edge fades (this is the fix)
    		&::before,
    		&::after {
    			content: "";
    			position: absolute;
    			top: 0;
    			width: 2.5rem;
    			height: 100%;
    			pointer-events: none;
    			z-index: 2;
    			opacity: 0; // hidden by default
    			transition: opacity 0.2s ease;
    		}

    		// left fade
    		&::before {
    			background: radial-gradient(
    				circle at left center,
    				rgba(255,255,255,1) 0%,
    				rgba(255,255,255,0.9) 50%,
    				rgba(255,255,255,0.5) 75%,
    				rgba(255,255,255,0.25) 95%,
    				transparent 100%
    			);
    			border-top-right-radius: $radial;
    			border-bottom-right-radius: $radial;
    			left: 0;
    		}

    		// right fade
    		&::after {
    			right: 0;
    				background: radial-gradient(
    					circle at right center,
    					rgba(255,255,255,1) 0%,
    					rgba(255,255,255,0.9) 50%,
    					rgba(255,255,255,0.5) 75%,
    					rgba(255,255,255,0.25) 95%,
    					transparent 100%
    				);
    				border-top-left-radius: $radial;
    				border-bottom-left-radius: $radial;
    		}

    		// show fade only when overflowing
    		&.is-overflowing {
    			&::before,
    			&::after {
    				opacity: 0;
    				transition: opacity 0.15s ease-out;
    				transition: opacity 0.12s cubic-bezier(0.4, 0, 0.2, 1);
    			}

    			&.show-left {
    				&::before {
    					opacity: 1;
    				}
    			}
    			&.show-right {
    				&::after {
    					opacity: 1;
    				}
    			}

    		}

    		// arrows
    		.tab-scroll-arrow {
    			align-items: center;
    			cursor: pointer;
    			display: none;
    			height: 100%;
    			justify-content: center;
    			position: absolute;
    			top: 0;
    			user-select: none;
    			width: 2.5rem;
    			z-index: 3;
    			path {
    				fill: $tab-border;
    			}
    			&.left {
    				left: 0;
    			}

    			&.right {
    				right: 0;
    			}

    			&.hidden {
    				display: none !important;
    				pointer-events: none;
    				visibility: hidden;
    			}
    		}

    		// only show arrows when overflow
    		&.is-overflowing {
    			.tab-scroll-arrow {
    				display: flex;
    			}
    		}
    	}
    }

## Example

    import * as tabs from "./js/core/tabs/_tabs.js";
    tabs.init();

    /* DS2 core (c) 2024-2026 Alexander McIlwraith
       Released under Creative Commons Attribution-ShareAlike 4.0 International
    */

    // create a pure JS mouse click event
    const click = new MouseEvent('click', {
    	view: window,
    	bubbles: false,
    	cancelable: true
    });

    // wait for an element to appear in the DOM
    const waitForElement = (selector) => {
    	return new Promise(resolve => {
    		if (document.querySelector(selector)) {
    			return resolve(document.querySelector(selector));
    		}
    		const observer = new MutationObserver(() => {
    			if (document.querySelector(selector)) {
    				observer.disconnect();
    				resolve(document.querySelector(selector));
    			}
    		});
    		observer.observe(document.body, {
    			childList: true,
    			subtree: true
    		});
    	});
    };

    // Tab logic
    const tabNamespace = "#tab:";

    const chooseTab = (tab) => {
    	const siblings = Array.from(
    		tab.closest('[role="tablist"]').querySelectorAll('[role="tab"]')
    	);
    	siblings.forEach(sibling => sibling.classList.remove("selected"));
    	tab.classList.add("selected");

    	const tabGroup = tab.closest("tabset, .tab-group");
    	const tabPanels = Array.from(
    		tabGroup.querySelectorAll('[role="tabpanel"]')
    	);

    	tabPanels.forEach(panel => panel.classList.remove("open"));
    	const tabPanelId = tab.getAttribute("id").replace("tab", "tab-panel");
    	document.getElementById(tabPanelId).classList.add("open");
    };

    let pushStateCount = 0;
    let tabsetCount = 0;

    export function init(container = document, spacer = true) {
    	container.querySelectorAll(".tab-group, tabset").forEach(tabGroup => {

    		if (tabGroup.querySelector('[role="tablist"]') === null) {

    			if (tabGroup.getAttribute("id") == null) {
    				tabGroup.setAttribute("id", `tab-group-${tabsetCount++}`);
    			}

    			const tabgroup = tabGroup.getAttribute("id");
    			let tablist = "";

    			Array.from(tabGroup.children).forEach(child => {

    				const isDetails = child.nodeName === "DETAILS";
    				const tabLabel = isDetails ? child.querySelector("summary")?.innerHTML : child.getAttribute("tab") || child.getAttribute("data-tab");

    				if (tabLabel !== null) {

    					const tabID = tabLabel.replace(/\W+/g, "-").toLowerCase();
    					let tabPanel;

    					if (isDetails) {
    						tabPanel = child;
    						tabPanel.setAttribute("open", "");
    					} else {
    						tabPanel = document.createElement("div");
    						tabPanel.appendChild(child.cloneNode(true));
    					}

    					tabPanel.id = `tab-panel-${tabgroup}-${tabID}`;
    					tabPanel.className = tablist === "" ? "open" : "";
    					tabPanel.setAttribute("role", "tabpanel");
    					tabPanel.setAttribute("tabindex", "0");
    					tabPanel.setAttribute(
    						"aria-labelledby",
    						`tab-${tabgroup}-${tabID}`
    					);

    					child.parentNode.replaceChild(tabPanel, child);

    					tablist += `
    							${tabLabel}
    						`;
    				} else {
    					child.classList.add("tab-hidden");
    				}
    			});

    			const ul = document.createElement("ul");
    			ul.setAttribute("role", "tablist");
    			tabGroup.insertBefore(ul, tabGroup.firstChild);

    			const wrapper = document.createElement("div");
    			wrapper.className = "tab-scroll-wrapper";

    			const scroller = document.createElement("div");
    			scroller.className = "tab-scroll";

    			const leftArrow = document.createElement("div");
    			leftArrow.className = "tab-scroll-arrow left";

    			leftArrow.setAttribute("aria-hidden", "true");

    			leftArrow.innerHTML = `

    			`;

    			const rightArrow = document.createElement("div");
    			rightArrow.className = "tab-scroll-arrow right";

    			rightArrow.setAttribute("aria-hidden", "true");

    			rightArrow.innerHTML = `

    			`;

    			ul.parentNode.insertBefore(wrapper, ul);
    			wrapper.appendChild(scroller);
    			wrapper.appendChild(leftArrow);
    			wrapper.appendChild(rightArrow);
    			scroller.appendChild(ul);

    			const updateScrollState = () => {
    				const isOverflowing = scroller.scrollWidth > scroller.clientWidth;

    				wrapper.classList.toggle("is-overflowing", isOverflowing);

    				const atStart = scroller.scrollLeft <= 1;
    				const atEnd = scroller.scrollLeft + scroller.clientWidth >= scroller.scrollWidth - 2;

    				leftArrow.classList.toggle("hidden", !isOverflowing || atStart);
    				rightArrow.classList.toggle("hidden", !isOverflowing || atEnd);

        wrapper.classList.toggle("show-left", isOverflowing && !atStart);
        wrapper.classList.toggle("show-right", isOverflowing && !atEnd);

    			};

    			const scrollAmount = 15;   // smaller = smoother per frame
    			const scrollInterval = 16; // ~60fps

    			let scrollTimer = null;

    			const startScrolling = (direction) => {
    				if (scrollTimer) return;

    				let speed = 0.1;

    				scrollTimer = setInterval(() => {
    					speed = Math.min(speed + 0.01, 5); // ramp up
    					scroller.scrollBy({
    						left: direction * scrollAmount * speed,
    						behavior: "auto"
    					});
    				}, scrollInterval);
    			};

    			const stopScrolling = () => {
    				clearInterval(scrollTimer);
    				scrollTimer = null;
    			};
    			leftArrow.addEventListener("mousedown", () => startScrolling(-1));
    			leftArrow.addEventListener("mouseup", stopScrolling);
    			leftArrow.addEventListener("mouseleave", stopScrolling);

    			leftArrow.addEventListener("touchstart", () => startScrolling(-1));
    			leftArrow.addEventListener("touchend", stopScrolling);

    			rightArrow.addEventListener("mousedown", () => startScrolling(1));
    			rightArrow.addEventListener("mouseup", stopScrolling);
    			rightArrow.addEventListener("mouseleave", stopScrolling);

    			rightArrow.addEventListener("touchstart", () => startScrolling(1));
    			rightArrow.addEventListener("touchend", stopScrolling);

    			const ARROW_ZONE = 40;

    			wrapper.addEventListener("contextmenu", (e) => {
    				const isOverflowing = wrapper.classList.contains("is-overflowing");
    				if (!isOverflowing) return;

    				const rect = wrapper.getBoundingClientRect();

    				const isLeftZone = e.clientX < rect.left + ARROW_ZONE;
    				const isRightZone = e.clientX > rect.right - ARROW_ZONE;

    				if (isLeftZone || isRightZone) {
    					e.preventDefault();
    				}
    			});

    			const scrollClickAmount = 150;

    			leftArrow.addEventListener("click", () => {
    					scroller.scrollBy({ left: -scrollClickAmount, behavior: "smooth" });
    			});

    			rightArrow.addEventListener("click", () => {
    					scroller.scrollBy({ left: scrollClickAmount, behavior: "smooth" });
    			});

    			scroller.addEventListener("scroll", updateScrollState);
    			window.addEventListener("resize", updateScrollState);

    			updateScrollState();
    			ul.innerHTML = spacer !== true ? tablist : `${tablist}`;

    			requestAnimationFrame(updateScrollState);
    			setTimeout(updateScrollState, 50);

    			ul.innerHTML = spacer !== true ? tablist : `${tablist}`;

    			// Tab ordering
    			if (tabGroup.hasAttribute("order") || tabGroup.hasAttribute("data-order")) {

    				const order = (
    					tabGroup.getAttribute("order") ||
    					tabGroup.getAttribute("data-order")
    				).split(",");

    				const items = Array.from(ul.getElementsByTagName("li"));

    				items.sort((a, b) => {
    					const aa = order.indexOf(a.textContent.trim());
    					const bb = order.indexOf(b.textContent.trim());
    					if (aa === -1) return 1;
    					if (bb === -1) return -1;
    					return aa - bb;
    				});

    				ul.innerHTML = "";
    				items.forEach(item => ul.appendChild(item));
    				chooseTab(items[0]);
    			}

    			// Tab event handlers
    			tabGroup.querySelectorAll('[role="tab"]').forEach(tab => {

    				tab.addEventListener("click", (evt) => {

    					const tabId = tab.getAttribute("id");
    					const hash = `${tabNamespace}${tabId}`;

    					chooseTab(evt.currentTarget);

    					window.history.pushState(
    						{ tab: tabId },
    						"",
    						`${location.pathname}${location.search}${hash}`
    					);

    					pushStateCount++;
    				});

    				tab.addEventListener("keypress", (e) => {
    					if (e.which === 32 || e.which === 13) {
    						e.preventDefault();
    						e.currentTarget.dispatchEvent(click);
    					}
    				});
    			});

    			ul.querySelector("li")?.classList.add("selected");
    		}
    	});

    	// Initial hash handling (tabs only)
    	if (location.hash.startsWith(tabNamespace)) {
    		const tabId = location.hash.replace(tabNamespace, "");
    		waitForElement(`#${tabId}`).then(el => {
    			el.focus();
    			el.dispatchEvent(click);
    		});
    	}

    	// History navigation (tabs only)
    	window.addEventListener("popstate", (e) => {
    		if (!location.hash.startsWith(tabNamespace)) return;
    		if (e.state?.tab) {
    			const tab = document.querySelector(`#${e.state.tab}`);
    			if (tab) chooseTab(tab);
    		}
    	});
    }