Newer
Older
vmk-ui-kit / src / vue / components / GnBottomBar.js
@Eugene Sukhodolskiy Eugene Sukhodolskiy 20 hours ago 3 KB so many changes
/**
 * GnBottomBar — floating pill navigation bar.
 *
 * Figma: Bottom Bar Container.
 *
 * Supports two layouts:
 *   stacked  - icon above label, all items inside a white pill (mobile nav).
 *   inline   - icon left of label, only the active item is a white pill.
 *
 * @typedef {Object} GnBottomBarItem
 * @property {string} label - Item text.
 * @property {string} [icon] - VMK icon key or legacy Phosphor name (ph-*).
 * @property {string|Object} [to] - vue-router route.
 * @property {string} [href] - Plain link URL.
 * @property {string|number} [key] - Stable key used with v-model.
 * @property {boolean} [active=false] - Force active state.
 * @property {boolean} [disabled=false] - Disable the item.
 *
 * @slots item - Replace the default label/icon for an item via item slot (scope { item, active }).
 */

import { computed, defineComponent, h } from "vue";
import { cx, iconNode } from "../utils.js";
import { tryUseRouter, tryUseRoute, isRouteActive } from "../composables/useVueRouter.js";

export default defineComponent({
	name: "GnBottomBar",
	inheritAttrs: false,
	props: {
		items: { type: Array, default: () => [] },
		layout: { type: String, default: "stacked" },
		modelValue: { type: [String, Number], default: undefined },
		activeMatch: { type: String, default: "prefix" }
	},
	emits: ["select", "update:modelValue"],
	setup(props, { attrs, emit, slots }) {
		const router = tryUseRouter();
		const route = tryUseRoute();
		const hasRouter = Boolean(router && route);

		const resolveHref = to => {
			if (!to) {
				return undefined;
			}

			if (typeof to === "string") {
				return to;
			}

			if (to.path) {
				return to.path;
			}

			return undefined;
		};

		const isItemActive = (item, index) => {
			if (item.to && hasRouter) {
				return isRouteActive(route, item.to, props.activeMatch);
			}

			if (item.active) {
				return true;
			}

			if (props.modelValue !== undefined) {
				return (item.key !== undefined && item.key === props.modelValue) || index === props.modelValue;
			}

			return false;
		};

		const navItems = computed(() => props.items.map((item, index) => {
			const hasTo = Boolean(item.to);
			const resolvedHref = hasTo
				? (hasRouter ? router.resolve(item.to).href : resolveHref(item.to))
				: item.href;

			return {
				...item,
				resolvedHref,
				hasTo,
				isActive: isItemActive(item, index)
			};
		}));

		const activate = (item, index, event) => {
			if (item.disabled) {
				event.preventDefault();
				return;
			}

			if (item.hasTo && hasRouter) {
				event.preventDefault();
				router.push(item.to);
			}

			if (props.modelValue !== undefined) {
				const nextValue = item.key !== undefined ? item.key : index;
				emit("update:modelValue", nextValue);
			}

			item.onSelect?.(item, event);
			emit("select", item);
		};

		return () => h("nav", {
			...attrs,
			class: cx("bottom-bar", `bottom-bar--${props.layout}`, attrs.class)
		}, navItems.value.map((item, index) => {
			const tag = item.resolvedHref ? "a" : "button";
			const isActive = item.isActive;

			return h(tag, {
				key: item.key ?? index,
				class: cx("bottom-bar__item", { "is-active": isActive, "is-disabled": item.disabled }),
				href: item.resolvedHref,
				type: item.resolvedHref ? undefined : "button",
				disabled: item.disabled || undefined,
				"aria-current": isActive ? "page" : undefined,
				onClick: event => activate(item, index, event)
			}, [
				slots.item?.({ item, active: isActive }) || [
					h("span", { class: "bottom-bar__icon" }, [iconNode(item.icon)]),
					h("span", { class: "bottom-bar__label" }, item.label)
				]
			]);
		}));
	}
});