melt
Version:
The next generation of Melt UI. Built for Svelte 5.
113 lines (112 loc) • 4.41 kB
JavaScript
import { dataAttr } from "../utils/attribute";
import { extract } from "../utils/extract";
import { Synced } from "../Synced.svelte";
import { createDataIds, createId } from "../utils/identifiers";
import { isHtmlElement } from "../utils/is";
const TRIGGER_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"];
const identifiers = createDataIds("tabs", ["trigger", "content", "trigger-list"]);
export class Tabs {
#value;
#id = createId();
/* Props */
#props;
selectWhenFocused = $derived(extract(this.#props.selectWhenFocused, true));
loop = $derived(extract(this.#props.loop, true));
orientation = $derived(extract(this.#props.orientation, "horizontal"));
constructor(props) {
this.#props = props;
this.#value = new Synced({
value: props.value,
onChange: props.onValueChange,
});
}
#getTriggerId(value) {
return `${this.#id}-trigger-${value.replace(/\s/g, "_")}`;
}
#getContentId(value) {
return `${this.#id}-content-${value.replace(/\s/g, "_")}`;
}
/** The current selected tab. */
get value() {
return this.#value.current;
}
set value(value) {
this.#value.current = value;
}
/** The attributes for the list that contains the tab triggers. */
get triggerList() {
return {
[identifiers["trigger-list"]]: "",
role: "tablist",
"aria-orientation": this.orientation,
"data-orientation": this.orientation,
};
}
/** Gets the attributes and listeners for a tab trigger. Requires an identifying tab value. */
getTrigger(value) {
return {
[identifiers.trigger]: value,
"data-active": dataAttr(this.value === value),
tabindex: this.value === value ? 0 : -1,
role: "tab",
"aria-selected": this.value === value,
"aria-controls": this.#getContentId(value),
"data-orientation": this.orientation,
onclick: () => (this.value = value),
onkeydown: (e) => {
const el = e.target;
if (!TRIGGER_KEYS.includes(e.key) || !isHtmlElement(el)) {
return;
}
e.preventDefault();
const triggerList = el.closest(`[${identifiers["trigger-list"]}]`);
if (!triggerList)
return;
const triggers = [...triggerList.querySelectorAll(`[${identifiers.trigger}]`)];
const currIndex = triggers.indexOf(el);
let next = el;
const prevKey = this.orientation === "horizontal" ? "ArrowLeft" : "ArrowUp";
const nextKey = this.orientation === "horizontal" ? "ArrowRight" : "ArrowDown";
switch (e.key) {
case prevKey: {
next = this.loop ? triggers.at(currIndex - 1) : triggers.at(Math.max(currIndex - 1, 0));
break;
}
case nextKey: {
next = this.loop
? triggers.at((currIndex + 1) % triggers.length)
: triggers.at(currIndex + 1);
break;
}
case "Home": {
next = triggers[0];
break;
}
case "End": {
next = triggers.at(-1);
break;
}
}
if (!isHtmlElement(next))
return;
next.focus();
if (this.selectWhenFocused) {
this.value = next.getAttribute(identifiers.trigger);
}
},
id: this.#getTriggerId(value),
};
}
/** Gets the attributes and listeners for the tabs contents. Requires an identifying tab value. */
getContent(value) {
return {
[identifiers.content]: "",
hidden: this.value !== value,
"data-active": dataAttr(this.value === value),
role: "tabpanel",
id: this.#getContentId(value),
"aria-labelledby": this.#getTriggerId(value),
"data-orientation": this.orientation,
};
}
}