drab
Version:
Interactivity for You
179 lines (147 loc) • 4.83 kB
text/typescript
import { Lifecycle, Trigger, type TriggerAttributes } from "../base/index.js";
export interface TabsAttributes extends TriggerAttributes {
/** Set to `"vertical"` if tabs are displayed vertically. */
orientation?: "horizontal" | "vertical";
}
/**
* Progressively enhance a list of links and content to be tabs by
* wrapping with the `Tabs` element. Each `trigger` should be an
* `HTMLAnchorElement` with the `href` attribute set to the `id` of the
* corresponding tab panel.
*
* > Tip: Set the `height` of the element the `panel`s are contained in with
* > CSS to prevent layout shift when JS is loaded.
*
* This element is based on
* [Chris Ferdinandi's Toggle Tabs](https://gomakethings.com/a-web-component-ui-library-for-people-who-love-html/#toggle-tabs)
* design.
*
* [ARIA Reference](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/)
*
* - Sets the correct ARIA attributes on each element.
* - Supports keyboard navigation based on the `orientation` attribute.
* - First tab is selected by default if no `aria-selected="true"` attribute is
* found on another tab.
* - `tablist` is calculated based on the deepest common parent of the tabs,
* throws an error if not found.
*
* ### Attributes
*
* `orientation`
*
* Set `orientation="vertical"` if the `tablist` element is displayed vertically.
*/
export class Tabs extends Lifecycle(Trigger()) {
/** Supported keys for keyboard navigation. */
#keys = ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp", "Home", "End"];
/** Currently selected tab. */
#selected: { tab?: HTMLAnchorElement; index: number } = { index: 0 };
constructor() {
super();
}
/** User provided orientation of the `tablist`. */
get #orientation() {
return this.getAttribute("orientation") ?? "horizontal";
}
get #tabs() {
return this.triggers(HTMLAnchorElement);
}
/**
* @param tab
* @returns The ancestors of the tab up to `this`.
*/
#ancestors(tab?: HTMLElement) {
const ancestors = new Set<HTMLElement>();
let current: HTMLElement | null | undefined = tab;
while ((current = current?.parentElement) && current !== this) {
ancestors.add(current);
}
return ancestors;
}
/** A map of each `tab` and its corresponding `panel`. */
get #map() {
const map = new Map<HTMLAnchorElement, HTMLElement>();
for (const tab of this.#tabs) {
const panel = this.querySelector(tab.hash);
if (!(panel instanceof HTMLElement))
throw new Error(`Tabs: No HTMLElement with ID of ${tab.hash} found.`);
map.set(tab, panel);
}
return map;
}
override mount() {
// create tablist
const [first, ...rest] = this.#tabs;
let common = this.#ancestors(first);
for (let i = 0; i < rest.length; i++) {
common = common.intersection(this.#ancestors(rest[i]));
}
const [tablist] = common;
if (!tablist)
throw new Error("Tabs: No common parent element found for triggers.");
tablist.role = "tablist";
tablist.ariaOrientation = this.#orientation;
// enhance tabs/panels
let index = 0;
for (const [tab, panel] of this.#map) {
tab.role = "tab";
tab.id = `tab-${panel.id}`;
tab.setAttribute("aria-controls", panel.id);
if (tab.ariaSelected) this.#selected = { tab, index };
panel.role = "tabpanel";
panel.setAttribute("aria-labelledby", tab.id);
tab.addEventListener(this.event, (e) => {
e.preventDefault();
for (const [t, p] of this.#map) {
if (t === tab) {
// show current
t.ariaSelected = "true";
t.tabIndex = 0;
p.hidden = false;
} else {
// hide others
t.ariaSelected = "false";
t.tabIndex = -1;
p.hidden = true;
}
}
});
index++;
}
// fallback to first
if (!this.#selected.tab) this.#selected.tab = this.#tabs[0];
// select the current tab
this.#selected.tab?.click();
// handle keyboard navigation
this.addEventListener("keydown", (e) => {
const i = this.#keys.indexOf(e.key);
if (i === -1) return;
const previousIndex = this.#selected.index;
const vertical = this.#orientation === "vertical";
if (
((!vertical && i === 0) || (vertical && i === 1)) &&
this.#tabs[this.#selected.index + 1]
) {
// next
this.#selected.tab = this.#tabs[++this.#selected.index];
} else if (
((!vertical && i === 2) || (vertical && i === 3)) &&
this.#tabs[this.#selected.index - 1]
) {
// previous
this.#selected.tab = this.#tabs[--this.#selected.index];
} else if (i === 4) {
// home
this.#selected = { tab: this.#tabs[0], index: 0 };
} else if (i === 5) {
// end
const index = this.#tabs.length - 1;
this.#selected = { tab: this.#tabs[index], index };
}
if (this.#selected.index === previousIndex) return;
e.preventDefault();
this.#selected.tab?.click();
this.#selected.tab?.focus();
});
}
}