Newer
Older
vmk-ui-kit / src / js / components / tabs.js
/**
 * VMK UI Kit browser tabs helper.
 *
 * Mirrors the public surface of gnexus-ui-kit/src/js/components/tabs.js:
 *   init(root), activate(tab)
 */

const initializedRoots = new WeakSet();

function getTabs(root) {
  return [...root.querySelectorAll('[role="tab"], .tab')];
}

function getPanels(root) {
  return [...root.querySelectorAll('[role="tabpanel"], .tab-panel')];
}

function getPanel(root, tab) {
  const panelId = tab.getAttribute("aria-controls");
  if (!panelId) return null;
  return root.querySelector(`#${CSS.escape(panelId)}`);
}

function setTabState(tab, isActive) {
  tab.classList.toggle("tab-active", isActive);
  tab.setAttribute("aria-selected", isActive ? "true" : "false");
  tab.setAttribute("tabindex", isActive ? "0" : "-1");
}

function setPanelState(panel, isActive) {
  if (!panel) return;
  panel.classList.toggle("tab-panel-active", isActive);
  panel.toggleAttribute("hidden", !isActive);
}

function activate(tab, options = {}) {
  if (!tab || tab.disabled || tab.getAttribute("aria-disabled") === "true") {
    return;
  }

  const root = tab.closest(".tabs") || tab.closest('[role="tablist"]')?.parentElement;
  if (!root) return;

  getTabs(root).forEach(item => setTabState(item, item === tab));
  getPanels(root).forEach(panel => setPanelState(panel, false));

  const panel = getPanel(root, tab);
  setPanelState(panel, true);

  if (options.focus !== false) {
    tab.focus();
  }
}

function getNextEnabledTab(tabs, activeIndex, direction) {
  for (let offset = 1; offset <= tabs.length; offset++) {
    const index = (activeIndex + (offset * direction) + tabs.length) % tabs.length;
    const tab = tabs[index];
    if (!tab.disabled && tab.getAttribute("aria-disabled") !== "true") {
      return tab;
    }
  }
  return tabs[activeIndex];
}

function handleKeydown(event) {
  const tab = event.target.closest('[role="tab"], .tab');
  if (!tab) return;

  const root = tab.closest(".tabs") || tab.closest('[role="tablist"]')?.parentElement;
  const tabs = root ? getTabs(root) : [];
  const activeIndex = tabs.indexOf(tab);
  if (activeIndex < 0) return;

  let nextTab = null;

  if (event.key === "ArrowRight" || event.key === "ArrowDown") {
    nextTab = getNextEnabledTab(tabs, activeIndex, 1);
  } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
    nextTab = getNextEnabledTab(tabs, activeIndex, -1);
  } else if (event.key === "Home") {
    nextTab = getNextEnabledTab(tabs, -1, 1);
  } else if (event.key === "End") {
    nextTab = getNextEnabledTab(tabs, 0, -1);
  }

  if (!nextTab) return;

  event.preventDefault();
  activate(nextTab);
}

function prepare(root) {
  const tabs = getTabs(root);
  const activeTab = tabs.find(tab => tab.classList.contains("tab-active") || tab.getAttribute("aria-selected") === "true")
    || tabs.find(tab => !tab.disabled && tab.getAttribute("aria-disabled") !== "true");

  tabs.forEach(tab => {
    tab.setAttribute("role", "tab");
    setTabState(tab, tab === activeTab);
  });

  root.querySelectorAll(".tabs-list").forEach(list => {
    list.setAttribute("role", "tablist");
  });

  getPanels(root).forEach(panel => {
    panel.setAttribute("role", "tabpanel");
    const isActive = activeTab ? panel === getPanel(root, activeTab) : panel.classList.contains("tab-panel-active");
    setPanelState(panel, isActive);
  });
}

function init(root = document) {
  if (initializedRoots.has(root)) return;

  root.querySelectorAll(".tabs").forEach(prepare);

  root.addEventListener("click", event => {
    const tab = event.target.closest('[role="tab"], .tab');
    if (!tab || !root.contains(tab)) return;
    event.preventDefault();
    activate(tab, { focus: false });
  });

  root.addEventListener("keydown", handleKeydown);

  initializedRoots.add(root);
}

export default {
  init,
  activate
};