@zag-js/tabs
Version:
Core logic for the tabs widget implemented as a state machine
507 lines (503 loc) • 16.7 kB
JavaScript
import { createAnatomy } from '@zag-js/anatomy';
import { isAnchorElement, resizeObserverBorderBox, raf, getFocusables, prevById, nextById, clickIfLink, queryAll, itemById, dataAttr, isOpeningInNewTab, isSafari, isComposingEvent, contains, getEventTarget, getEventKey } from '@zag-js/dom-query';
import { callAll, last, first, createSplitProps, isEqual, toPx } from '@zag-js/utils';
import { setup } from '@zag-js/core';
import { createProps } from '@zag-js/types';
// src/tabs.anatomy.ts
var anatomy = createAnatomy("tabs").parts("root", "list", "trigger", "content", "indicator");
var parts = anatomy.build();
var getRootId = (ctx) => ctx.ids?.root ?? `tabs:${ctx.id}`;
var getListId = (ctx) => ctx.ids?.list ?? `tabs:${ctx.id}:list`;
var getContentId = (ctx, value) => ctx.ids?.content?.(value) ?? `tabs:${ctx.id}:content-${value}`;
var getTriggerId = (ctx, value) => ctx.ids?.trigger?.(value) ?? `tabs:${ctx.id}:trigger-${value}`;
var getIndicatorId = (ctx) => ctx.ids?.indicator ?? `tabs:${ctx.id}:indicator`;
var getListEl = (ctx) => ctx.getById(getListId(ctx));
var getContentEl = (ctx, value) => ctx.getById(getContentId(ctx, value));
var getTriggerEl = (ctx, value) => value != null ? ctx.getById(getTriggerId(ctx, value)) : null;
var getIndicatorEl = (ctx) => ctx.getById(getIndicatorId(ctx));
var getElements = (ctx) => {
const ownerId = CSS.escape(getListId(ctx));
const selector = `[role=tab][data-ownedby='${ownerId}']:not([disabled])`;
return queryAll(getListEl(ctx), selector);
};
var getFirstTriggerEl = (ctx) => first(getElements(ctx));
var getLastTriggerEl = (ctx) => last(getElements(ctx));
var getNextTriggerEl = (ctx, opts) => nextById(getElements(ctx), getTriggerId(ctx, opts.value), opts.loopFocus);
var getPrevTriggerEl = (ctx, opts) => prevById(getElements(ctx), getTriggerId(ctx, opts.value), opts.loopFocus);
var getOffsetRect = (el) => ({
x: el?.offsetLeft ?? 0,
y: el?.offsetTop ?? 0,
width: el?.offsetWidth ?? 0,
height: el?.offsetHeight ?? 0
});
var getRectByValue = (ctx, value) => {
const tab = itemById(getElements(ctx), getTriggerId(ctx, value));
return getOffsetRect(tab);
};
// src/tabs.connect.ts
function connect(service, normalize) {
const { state, send, context, prop, scope } = service;
const translations = prop("translations");
const focused = state.matches("focused");
const isVertical = prop("orientation") === "vertical";
const isHorizontal = prop("orientation") === "horizontal";
const composite = prop("composite");
function getTriggerState(props2) {
return {
selected: context.get("value") === props2.value,
focused: context.get("focusedValue") === props2.value,
disabled: !!props2.disabled
};
}
return {
value: context.get("value"),
focusedValue: context.get("focusedValue"),
setValue(value) {
send({ type: "SET_VALUE", value });
},
clearValue() {
send({ type: "CLEAR_VALUE" });
},
setIndicatorRect(value) {
const id = getTriggerId(scope, value);
send({ type: "SET_INDICATOR_RECT", id });
},
syncTabIndex() {
send({ type: "SYNC_TAB_INDEX" });
},
selectNext(fromValue) {
send({ type: "TAB_FOCUS", value: fromValue, src: "selectNext" });
send({ type: "ARROW_NEXT", src: "selectNext" });
},
selectPrev(fromValue) {
send({ type: "TAB_FOCUS", value: fromValue, src: "selectPrev" });
send({ type: "ARROW_PREV", src: "selectPrev" });
},
focus() {
const value = context.get("value");
if (!value) return;
getTriggerEl(scope, value)?.focus();
},
getRootProps() {
return normalize.element({
...parts.root.attrs,
id: getRootId(scope),
"data-orientation": prop("orientation"),
"data-focus": dataAttr(focused),
dir: prop("dir")
});
},
getListProps() {
return normalize.element({
...parts.list.attrs,
id: getListId(scope),
role: "tablist",
dir: prop("dir"),
"data-focus": dataAttr(focused),
"aria-orientation": prop("orientation"),
"data-orientation": prop("orientation"),
"aria-label": translations?.listLabel,
onKeyDown(event) {
if (event.defaultPrevented) return;
if (isComposingEvent(event)) return;
if (!contains(event.currentTarget, getEventTarget(event))) return;
const keyMap = {
ArrowDown() {
if (isHorizontal) return;
send({ type: "ARROW_NEXT", key: "ArrowDown" });
},
ArrowUp() {
if (isHorizontal) return;
send({ type: "ARROW_PREV", key: "ArrowUp" });
},
ArrowLeft() {
if (isVertical) return;
send({ type: "ARROW_PREV", key: "ArrowLeft" });
},
ArrowRight() {
if (isVertical) return;
send({ type: "ARROW_NEXT", key: "ArrowRight" });
},
Home() {
send({ type: "HOME" });
},
End() {
send({ type: "END" });
}
};
let key = getEventKey(event, {
dir: prop("dir"),
orientation: prop("orientation")
});
const exec = keyMap[key];
if (exec) {
event.preventDefault();
exec(event);
return;
}
}
});
},
getTriggerState,
getTriggerProps(props2) {
const { value, disabled } = props2;
const triggerState = getTriggerState(props2);
return normalize.button({
...parts.trigger.attrs,
role: "tab",
type: "button",
disabled,
dir: prop("dir"),
"data-orientation": prop("orientation"),
"data-disabled": dataAttr(disabled),
"aria-disabled": disabled,
"data-value": value,
"aria-selected": triggerState.selected,
"data-selected": dataAttr(triggerState.selected),
"data-focus": dataAttr(triggerState.focused),
"aria-controls": triggerState.selected ? getContentId(scope, value) : void 0,
"data-ownedby": getListId(scope),
"data-ssr": dataAttr(context.get("ssr")),
id: getTriggerId(scope, value),
tabIndex: triggerState.selected && composite ? 0 : -1,
onFocus() {
send({ type: "TAB_FOCUS", value });
},
onBlur(event) {
const target = event.relatedTarget;
if (target?.getAttribute("role") !== "tab") {
send({ type: "TAB_BLUR" });
}
},
onClick(event) {
if (event.defaultPrevented) return;
if (isOpeningInNewTab(event)) return;
if (disabled) return;
if (isSafari()) {
event.currentTarget.focus();
}
send({ type: "TAB_CLICK", value });
}
});
},
getContentProps(props2) {
const { value } = props2;
const selected = context.get("value") === value;
return normalize.element({
...parts.content.attrs,
dir: prop("dir"),
id: getContentId(scope, value),
tabIndex: composite ? 0 : -1,
"aria-labelledby": getTriggerId(scope, value),
role: "tabpanel",
"data-ownedby": getListId(scope),
"data-selected": dataAttr(selected),
"data-orientation": prop("orientation"),
hidden: !selected
});
},
getIndicatorProps() {
const rect = context.get("indicatorRect");
const rectIsEmpty = rect == null || rect.width === 0 && rect.height === 0 && rect.x === 0 && rect.y === 0;
return normalize.element({
id: getIndicatorId(scope),
...parts.indicator.attrs,
dir: prop("dir"),
"data-orientation": prop("orientation"),
hidden: rectIsEmpty,
style: {
"--transition-property": "left, right, top, bottom, width, height",
"--left": toPx(rect?.x),
"--top": toPx(rect?.y),
"--width": toPx(rect?.width),
"--height": toPx(rect?.height),
position: "absolute",
willChange: "var(--transition-property)",
transitionProperty: "var(--transition-property)",
transitionDuration: "var(--transition-duration, 150ms)",
transitionTimingFunction: "var(--transition-timing-function)",
[isHorizontal ? "left" : "top"]: isHorizontal ? "var(--left)" : "var(--top)"
}
});
}
};
}
var { createMachine } = setup();
var machine = createMachine({
props({ props: props2 }) {
return {
dir: "ltr",
orientation: "horizontal",
activationMode: "automatic",
loopFocus: true,
composite: true,
navigate(details) {
clickIfLink(details.node);
},
defaultValue: null,
...props2
};
},
initialState() {
return "idle";
},
context({ prop, bindable }) {
return {
value: bindable(() => ({
defaultValue: prop("defaultValue"),
value: prop("value"),
onChange(value) {
prop("onValueChange")?.({ value });
}
})),
focusedValue: bindable(() => ({
defaultValue: prop("value") || prop("defaultValue"),
sync: true,
onChange(value) {
prop("onFocusChange")?.({ focusedValue: value });
}
})),
ssr: bindable(() => ({ defaultValue: true })),
indicatorRect: bindable(() => ({
defaultValue: null
}))
};
},
watch({ context, prop, track, action }) {
track([() => context.get("value")], () => {
action(["syncIndicatorRect", "syncTabIndex", "navigateIfNeeded"]);
});
track([() => prop("dir"), () => prop("orientation")], () => {
action(["syncIndicatorRect"]);
});
},
on: {
SET_VALUE: {
actions: ["setValue"]
},
CLEAR_VALUE: {
actions: ["clearValue"]
},
SET_INDICATOR_RECT: {
actions: ["setIndicatorRect"]
},
SYNC_TAB_INDEX: {
actions: ["syncTabIndex"]
}
},
entry: ["syncIndicatorRect", "syncTabIndex", "syncSsr"],
exit: ["cleanupObserver"],
states: {
idle: {
on: {
TAB_FOCUS: {
target: "focused",
actions: ["setFocusedValue"]
},
TAB_CLICK: {
target: "focused",
actions: ["setFocusedValue", "setValue"]
}
}
},
focused: {
on: {
TAB_CLICK: {
actions: ["setFocusedValue", "setValue"]
},
ARROW_PREV: [
{
guard: "selectOnFocus",
actions: ["focusPrevTab", "selectFocusedTab"]
},
{
actions: ["focusPrevTab"]
}
],
ARROW_NEXT: [
{
guard: "selectOnFocus",
actions: ["focusNextTab", "selectFocusedTab"]
},
{
actions: ["focusNextTab"]
}
],
HOME: [
{
guard: "selectOnFocus",
actions: ["focusFirstTab", "selectFocusedTab"]
},
{
actions: ["focusFirstTab"]
}
],
END: [
{
guard: "selectOnFocus",
actions: ["focusLastTab", "selectFocusedTab"]
},
{
actions: ["focusLastTab"]
}
],
TAB_FOCUS: {
actions: ["setFocusedValue"]
},
TAB_BLUR: {
target: "idle",
actions: ["clearFocusedValue"]
}
}
}
},
implementations: {
guards: {
selectOnFocus: ({ prop }) => prop("activationMode") === "automatic"
},
actions: {
selectFocusedTab({ context, prop }) {
raf(() => {
const focusedValue = context.get("focusedValue");
if (!focusedValue) return;
const nullable = prop("deselectable") && context.get("value") === focusedValue;
const value = nullable ? null : focusedValue;
context.set("value", value);
});
},
setFocusedValue({ context, event, flush }) {
if (event.value == null) return;
flush(() => {
context.set("focusedValue", event.value);
});
},
clearFocusedValue({ context }) {
context.set("focusedValue", null);
},
setValue({ context, event, prop }) {
const nullable = prop("deselectable") && context.get("value") === context.get("focusedValue");
context.set("value", nullable ? null : event.value);
},
clearValue({ context }) {
context.set("value", null);
},
focusFirstTab({ scope }) {
raf(() => {
getFirstTriggerEl(scope)?.focus();
});
},
focusLastTab({ scope }) {
raf(() => {
getLastTriggerEl(scope)?.focus();
});
},
focusNextTab({ context, prop, scope, event }) {
const focusedValue = event.value ?? context.get("focusedValue");
if (!focusedValue) return;
const triggerEl = getNextTriggerEl(scope, {
value: focusedValue,
loopFocus: prop("loopFocus")
});
raf(() => {
if (prop("composite")) {
triggerEl?.focus();
} else if (triggerEl?.dataset.value != null) {
context.set("focusedValue", triggerEl.dataset.value);
}
});
},
focusPrevTab({ context, prop, scope, event }) {
const focusedValue = event.value ?? context.get("focusedValue");
if (!focusedValue) return;
const triggerEl = getPrevTriggerEl(scope, {
value: focusedValue,
loopFocus: prop("loopFocus")
});
raf(() => {
if (prop("composite")) {
triggerEl?.focus();
} else if (triggerEl?.dataset.value != null) {
context.set("focusedValue", triggerEl.dataset.value);
}
});
},
syncTabIndex({ context, scope }) {
raf(() => {
const value = context.get("value");
if (!value) return;
const contentEl = getContentEl(scope, value);
if (!contentEl) return;
const focusables = getFocusables(contentEl);
if (focusables.length > 0) {
contentEl.removeAttribute("tabindex");
} else {
contentEl.setAttribute("tabindex", "0");
}
});
},
cleanupObserver({ refs }) {
const cleanup = refs.get("indicatorCleanup");
if (cleanup) cleanup();
},
setIndicatorRect({ context, event, scope }) {
const value = event.id ?? context.get("value");
const indicatorEl = getIndicatorEl(scope);
if (!indicatorEl) return;
if (!value) return;
const triggerEl = getTriggerEl(scope, value);
if (!triggerEl) return;
context.set("indicatorRect", getRectByValue(scope, value));
},
syncSsr({ context }) {
context.set("ssr", false);
},
syncIndicatorRect({ context, refs, scope }) {
const cleanup = refs.get("indicatorCleanup");
if (cleanup) cleanup();
const indicatorEl = getIndicatorEl(scope);
if (!indicatorEl) return;
const exec = () => {
const triggerEl = getTriggerEl(scope, context.get("value"));
if (!triggerEl) return;
const rect = getOffsetRect(triggerEl);
context.set("indicatorRect", (prev) => isEqual(prev, rect) ? prev : rect);
};
exec();
const triggerEls = getElements(scope);
const indicatorCleanup = callAll(...triggerEls.map((el) => resizeObserverBorderBox.observe(el, exec)));
refs.set("indicatorCleanup", indicatorCleanup);
},
navigateIfNeeded({ context, prop, scope }) {
const value = context.get("value");
if (!value) return;
const triggerEl = getTriggerEl(scope, value);
if (isAnchorElement(triggerEl)) {
prop("navigate")?.({ value, node: triggerEl, href: triggerEl.href });
}
}
}
}
});
var props = createProps()([
"activationMode",
"composite",
"deselectable",
"dir",
"getRootNode",
"id",
"ids",
"loopFocus",
"navigate",
"onFocusChange",
"onValueChange",
"orientation",
"translations",
"value",
"defaultValue"
]);
var splitProps = createSplitProps(props);
var triggerProps = createProps()(["disabled", "value"]);
var splitTriggerProps = createSplitProps(triggerProps);
var contentProps = createProps()(["value"]);
var splitContentProps = createSplitProps(contentProps);
export { anatomy, connect, contentProps, machine, props, splitContentProps, splitProps, splitTriggerProps, triggerProps };