279 lines
8.3 KiB
Vue
Executable File
279 lines
8.3 KiB
Vue
Executable File
<template>
|
||
<div class="relative flex items-center" :style="{ width: customWidth }">
|
||
<!-- نسخه دسکتاپ / تبلت: همان قبلی -->
|
||
<div v-if="!isMobile" class="w-full">
|
||
<UButton
|
||
v-if="hasOverflow"
|
||
icon="i-heroicons-chevron-left"
|
||
variant="ghost"
|
||
size="xs"
|
||
class="absolute -left-10 top-1/2 -translate-y-1/2 z-10 flex-shrink-0 bg-light-primary dark:bg-dark-primary-800 shadow-md border border-gray-200 dark:border-dark-primary-700"
|
||
@click="scrollLeft"
|
||
/>
|
||
|
||
<div
|
||
ref="tabsContainer"
|
||
class="flex overflow-x-auto scrollbar-hide w-full justify-start"
|
||
@wheel.prevent="handleWheelScroll"
|
||
>
|
||
<div class="flex items-center">
|
||
<button
|
||
v-for="tab in getListTabs()"
|
||
:key="tab.id"
|
||
@click="handleTabClick(tab)"
|
||
:class="getTabClass(activeTab === tab.id)"
|
||
:data-active="activeTab === tab.id"
|
||
>
|
||
<UIcon
|
||
v-if="tab.icon"
|
||
:name="tab.icon"
|
||
class="w-5 h-5 flex-shrink-0"
|
||
/>
|
||
<span class="text-sm font-medium">{{ tab.label }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<UButton
|
||
v-if="hasOverflow"
|
||
icon="i-heroicons-chevron-right"
|
||
variant="ghost"
|
||
size="xs"
|
||
class="absolute -right-10 top-1/2 -translate-y-1/2 z-10 flex-shrink-0 bg-light-primary dark:bg-dark-primary-800 shadow-md border border-gray-200 dark:border-dark-primary-700"
|
||
@click="scrollRight"
|
||
/>
|
||
</div>
|
||
|
||
<!-- نسخه موبایل -->
|
||
<div
|
||
v-else
|
||
class="fixed inset-x-0 bottom-0 z-50 bg-light-primary dark:bg-dark-primary-800 border-t border-gray-200 dark:border-dark-primary-700 shadow-lg pb-safe"
|
||
>
|
||
<!-- اگر ۵ یا کمتر → justify-around و بدون اسکرول -->
|
||
<!-- اگر بیشتر از ۵ → اسکرول افقی با flex-nowrap -->
|
||
|
||
<div
|
||
class="flex items-center"
|
||
:class="getListTabs().length <= 5 ? '' : 'flex-nowrap'"
|
||
>
|
||
<button
|
||
v-for="tab in getListTabs()"
|
||
:key="tab.id"
|
||
@click="handleTabClick(tab)"
|
||
class="flex flex-col items-center justify-center py-3 transition-colors relative mx-4 "
|
||
:class="[
|
||
getListTabs().length <= 5
|
||
? 'flex-1 min-w-0 min-h-[56px]'
|
||
: 'px-4 min-w-[80px] min-h-[56px] flex-shrink-0',
|
||
]"
|
||
>
|
||
<!-- آیکن -->
|
||
<UIcon
|
||
v-if="tab.icon"
|
||
:name="tab.icon"
|
||
class="w-6 h-6 mb-1"
|
||
:class="
|
||
activeTab === tab.id
|
||
? 'text-primary-600 dark:text-primary-400'
|
||
: 'text-gray-600 dark:text-gray-400'
|
||
"
|
||
/>
|
||
<!-- اگر آیکن نبود، فقط متن با فاصله مناسب -->
|
||
<div v-else class="h-7"></div>
|
||
|
||
<!-- متن -->
|
||
<span
|
||
class="text-xs font-medium whitespace-nowrap"
|
||
:class="
|
||
activeTab === tab.id
|
||
? 'text-primary-600 dark:text-primary-400'
|
||
: 'text-gray-600 dark:text-gray-400'
|
||
"
|
||
>
|
||
{{ tab.label }}
|
||
</span>
|
||
|
||
<!-- اندیکاتور فعال (خط زیر تب) -->
|
||
<div
|
||
v-if="activeTab === tab.id"
|
||
class="absolute bottom-0 w-12 h-1 bg-primary-600 dark:bg-primary-400 rounded-t-full"
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted, onUnmounted, nextTick, watch } from "vue";
|
||
|
||
const props = defineProps({
|
||
tabs: { type: Array, default: () => [] },
|
||
activeTab: { type: String, default: "" },
|
||
width: { type: String, default: "40em" },
|
||
mode: {
|
||
type: String,
|
||
default: "soft",
|
||
validator: (v) => ["soft", "chrome"].includes(v),
|
||
},
|
||
});
|
||
|
||
const emit = defineEmits(["tab-change", "update:activeTab"]);
|
||
|
||
const tabsContainer = ref(null);
|
||
const hasOverflow = ref(false);
|
||
const isMobile = ref(false);
|
||
|
||
const customWidth =
|
||
props.width.includes("px") ||
|
||
props.width.includes("em") ||
|
||
props.width.includes("%")
|
||
? props.width
|
||
: `${props.width}px`;
|
||
|
||
// بروزرسانی وضعیت موبایل (امن برای SSR)
|
||
const updateMobileStatus = () => {
|
||
if (typeof window === "undefined") return;
|
||
isMobile.value = window.innerWidth < 768;
|
||
};
|
||
|
||
|
||
function getListTabs(){
|
||
|
||
const config = useRuntimeConfig();
|
||
const IS_DEVLOP_MODE = config.public.IS_DEVLOP_MODE || 1;
|
||
|
||
|
||
let result = {}
|
||
|
||
result = props.tabs.filter((el) => (!el.develop || el.develop == IS_DEVLOP_MODE) )
|
||
|
||
// console.log("TabBar IS_DEVLOP_MODE ", IS_DEVLOP_MODE, result);
|
||
|
||
return result
|
||
}
|
||
const getTabClass = (isActive) => {
|
||
const base =
|
||
"flex items-center justify-center gap-2 px-6 py-3 cursor-pointer whitespace-nowrap transition-all duration-200 flex-shrink-0 min-w-[140px] w-[140px]";
|
||
if (!isActive) {
|
||
return `${base} border-transparent text-dark-primary-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-light-primary hover:bg-gray-50 dark:hover:bg-dark-primary-800`;
|
||
}
|
||
if (props.mode === "chrome") {
|
||
return `${base} py-4 text-gray-900 dark:text-light-primary bg-light-primary dark:bg-dark-primary-800 rounded-t-xl`;
|
||
}
|
||
return `${base} border-primary-500 text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/20 border-b-2`;
|
||
};
|
||
|
||
const handleTabClick = (tab) => {
|
||
emit("update:activeTab", tab.id);
|
||
emit("tab-change", tab);
|
||
if (!isMobile.value) {
|
||
nextTick(scrollToActiveTab);
|
||
}
|
||
};
|
||
|
||
// توابع دسکتاپ (همان قبلی)
|
||
const checkOverflow = () => {
|
||
if (!tabsContainer.value || isMobile.value) {
|
||
hasOverflow.value = false;
|
||
return;
|
||
}
|
||
const container = tabsContainer.value;
|
||
hasOverflow.value = container.scrollWidth > container.clientWidth + 1;
|
||
};
|
||
|
||
const scrollLeft = () => {
|
||
if (!tabsContainer.value) return;
|
||
tabsContainer.value.scrollLeft -= 200;
|
||
requestAnimationFrame(checkOverflow);
|
||
};
|
||
|
||
const scrollRight = () => {
|
||
if (!tabsContainer.value) return;
|
||
const container = tabsContainer.value;
|
||
const maxScroll = container.scrollWidth - container.clientWidth;
|
||
container.scrollLeft = Math.min(maxScroll, container.scrollLeft + 200);
|
||
requestAnimationFrame(checkOverflow);
|
||
};
|
||
|
||
const scrollToActiveTab = () => {
|
||
if (!tabsContainer.value || isMobile.value) return;
|
||
const container = tabsContainer.value;
|
||
const activeEl = container.querySelector('[data-active="true"]');
|
||
if (!activeEl) return;
|
||
|
||
const containerRect = container.getBoundingClientRect();
|
||
const elRect = activeEl.getBoundingClientRect();
|
||
|
||
if (elRect.left < containerRect.left) {
|
||
container.scrollLeft += elRect.left - containerRect.left - 16;
|
||
} else if (elRect.right > containerRect.right) {
|
||
container.scrollLeft += elRect.right - containerRect.right + 16;
|
||
}
|
||
};
|
||
|
||
const handleWheelScroll = (event) => {
|
||
if (!tabsContainer.value || isMobile.value) return;
|
||
tabsContainer.value.scrollLeft += event.deltaY;
|
||
requestAnimationFrame(checkOverflow);
|
||
};
|
||
|
||
const handleResize = () => {
|
||
updateMobileStatus();
|
||
nextTick(() => {
|
||
checkOverflow();
|
||
if (!isMobile.value) scrollToActiveTab();
|
||
});
|
||
};
|
||
|
||
watch(
|
||
() => props.activeTab,
|
||
() => {
|
||
if (!isMobile.value) nextTick(scrollToActiveTab);
|
||
}
|
||
);
|
||
|
||
watch(
|
||
() => props.tabs,
|
||
() => {
|
||
nextTick(checkOverflow);
|
||
}
|
||
);
|
||
|
||
onMounted(() => {
|
||
updateMobileStatus();
|
||
|
||
window.addEventListener("resize", handleResize);
|
||
if (tabsContainer.value) {
|
||
tabsContainer.value.addEventListener("scroll", checkOverflow);
|
||
}
|
||
|
||
requestAnimationFrame(() => {
|
||
checkOverflow();
|
||
if (!isMobile.value) scrollToActiveTab();
|
||
});
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener("resize", handleResize);
|
||
if (tabsContainer.value) {
|
||
tabsContainer.value.removeEventListener("scroll", checkOverflow);
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.scrollbar-hide {
|
||
-ms-overflow-style: none;
|
||
scrollbar-width: none;
|
||
scroll-behavior: smooth;
|
||
}
|
||
.scrollbar-hide::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
|
||
.pb-safe {
|
||
padding-bottom: env(safe-area-inset-bottom);
|
||
}
|
||
</style>
|