Newer
Older
vmk-ui-kit / src / vue / components / GnTabs.js
/**
 * GnTabs - Accessible tab switcher with keyboard navigation.
 *
 * Supports two modes:
 *  - `items` mode (reference API): pass an array of { id, label, icon?, disabled? }
 *    and use named slots for panels.
 *  - child mode: place GnTab / GnTabPanel children directly.
 *
 * @emits update:modelValue, change
 */

import { defineComponent, h, provide, ref, computed, onMounted, watch } from "vue";
import { cx, iconNode } from "../utils.js";

export const tabsKey = Symbol("vmk-ui-kit-tabs");

export default defineComponent({
	name: "GnTabs",
	props: {
		modelValue: { type: [String, Number], default: null },
		items: { type: Array, default: null },
		variant: { type: String, default: "default" },
		compact: { type: Boolean, default: false },
		vertical: { type: Boolean, default: false },
		ariaLabel: { type: String, default: "Tabs" }
	},
	emits: ["update:modelValue", "change"],
	setup(props, { emit, slots }) {
		const activeKey = ref(props.modelValue);
		const tablistRef = ref(null);
		let tabItems = [];
		let panelItems = [];

		const hasItems = computed(() => Array.isArray(props.items) && props.items.length > 0);

		const activeId = computed(() => {
			if (!hasItems.value) return activeKey.value;
			return props.modelValue || props.items.find(item => !item.disabled)?.id || props.items[0]?.id;
		});

		const registerTab = (key, label) => {
			tabItems.push({ key, label });
		};

		const registerPanel = key => {
			panelItems.push({ key });
		};

		const select = key => {
			activeKey.value = key;
			emit("update:modelValue", key);
			emit("change", key);
		};

		const isActive = key => activeKey.value === key;

		const classes = computed(() => cx("tabs", {
			"tabs-compact": props.compact || props.variant === "compact",
			"tabs-vertical": props.vertical
		}));

		const focusTab = direction => {
			const enabled = tabItems.filter((_, i) => {
				const el = tablistRef.value?.children[i];
				return el && !el.disabled && el.getAttribute("aria-disabled") !== "true";
			});
			const current = enabled.findIndex(item => item.key === activeKey.value);
			const nextIndex = (current + direction + enabled.length) % enabled.length;
			const next = enabled[nextIndex];
			if (next) select(next.key);
		};

		watch(() => props.modelValue, value => {
			activeKey.value = value;
		});

		provide(tabsKey, {
			activeKey,
			registerTab,
			registerPanel,
			select,
			isActive
		});

		// Items mode: matches the gnexus-ui-kit reference API.
		const renderItemsMode = () => {
			const enabledItems = () => props.items.filter(item => !item.disabled);

			const activate = item => {
				if (!item.disabled) {
					emit("update:modelValue", item.id);
					emit("change", item.id);
				}
			};

			const move = (item, direction) => {
				const items = enabledItems();
				const index = items.findIndex(enabled => enabled.id === item.id);
				const next = items[(index + direction + items.length) % items.length];
				activate(next);
			};

			const handleKeydown = (event, item) => {
				if (event.key === "ArrowRight" || event.key === "ArrowDown") {
					event.preventDefault();
					move(item, 1);
				} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
					event.preventDefault();
					move(item, -1);
				} else if (event.key === "Home") {
					event.preventDefault();
					activate(enabledItems()[0]);
				} else if (event.key === "End") {
					event.preventDefault();
					const items = enabledItems();
					activate(items[items.length - 1]);
				}
			};

			return h("div", { class: classes.value }, [
				h("div", {
					class: "tabs-list",
					role: "tablist",
					"aria-label": props.ariaLabel
				}, props.items.map(item => {
					const active = item.id === activeId.value;
					const panelId = `${item.id}-panel`;

					return h("button", {
						class: cx("tab", { "tab-active": active }),
						type: "button",
						role: "tab",
						"aria-selected": active ? "true" : "false",
						"aria-controls": panelId,
						"aria-disabled": item.disabled ? "true" : undefined,
						tabindex: active ? "0" : "-1",
						onClick: () => activate(item),
						onKeydown: event => handleKeydown(event, item)
					}, [
						iconNode(item.icon),
						item.label
					]);
				})),
				h("div", { class: "tabs-panels" }, props.items.map(item => {
					const active = item.id === activeId.value;

					return h("div", {
						id: `${item.id}-panel`,
						class: cx("tab-panel", { "tab-panel-active": active }),
						role: "tabpanel",
						hidden: !active
					}, slots[item.id]?.({ item, active }) || (active && slots.default?.({ item, active })));
				}))
			]);
		};

		// Child mode: GnTab / GnTabPanel children register themselves via provide.
		const renderChildMode = () => h("div", { class: classes.value }, [
			h("div", {
				ref: tablistRef,
				class: "tabs-list",
				role: "tablist",
				ariaOrientation: props.vertical ? "vertical" : "horizontal",
				"aria-label": props.ariaLabel,
				onKeydown: event => {
					if (event.key === "ArrowRight" || event.key === "ArrowDown") {
						event.preventDefault();
						focusTab(1);
					} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
						event.preventDefault();
						focusTab(-1);
					} else if (event.key === "Home") {
						event.preventDefault();
						focusTab(1 - tabItems.length);
					} else if (event.key === "End") {
						event.preventDefault();
						focusTab(-1);
					}
				}
			}, slots.default?.()),
			slots.panels && h("div", { class: "tabs-panels" }, slots.panels())
		]);

		return () => hasItems.value ? renderItemsMode() : renderChildMode();
	}
});