UNPKG

@zag-js/tabs

Version:

Core logic for the tabs widget implemented as a state machine

265 lines (264 loc) • 7.89 kB
// src/tabs.machine.ts import { setup } from "@zag-js/core"; import { clickIfLink, getFocusables, isAnchorElement, raf, resizeObserverBorderBox } from "@zag-js/dom-query"; import { callAll, isEqual } from "@zag-js/utils"; import * as dom from "./tabs.dom.mjs"; var { createMachine } = setup(); var machine = createMachine({ props({ props }) { return { dir: "ltr", orientation: "horizontal", activationMode: "automatic", loopFocus: true, composite: true, navigate(details) { clickIfLink(details.node); }, defaultValue: null, ...props }; }, 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(() => { dom.getFirstTriggerEl(scope)?.focus(); }); }, focusLastTab({ scope }) { raf(() => { dom.getLastTriggerEl(scope)?.focus(); }); }, focusNextTab({ context, prop, scope, event }) { const focusedValue = event.value ?? context.get("focusedValue"); if (!focusedValue) return; const triggerEl = dom.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 = dom.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 = dom.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 = dom.getIndicatorEl(scope); if (!indicatorEl) return; if (!value) return; const triggerEl = dom.getTriggerEl(scope, value); if (!triggerEl) return; context.set("indicatorRect", dom.getRectByValue(scope, value)); }, syncSsr({ context }) { context.set("ssr", false); }, syncIndicatorRect({ context, refs, scope }) { const cleanup = refs.get("indicatorCleanup"); if (cleanup) cleanup(); const indicatorEl = dom.getIndicatorEl(scope); if (!indicatorEl) return; const exec = () => { const triggerEl = dom.getTriggerEl(scope, context.get("value")); if (!triggerEl) return; const rect = dom.getOffsetRect(triggerEl); context.set("indicatorRect", (prev) => isEqual(prev, rect) ? prev : rect); }; exec(); const triggerEls = dom.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 = dom.getTriggerEl(scope, value); if (isAnchorElement(triggerEl)) { prop("navigate")?.({ value, node: triggerEl, href: triggerEl.href }); } } } } }); export { machine };