JlsBottomNav.vue 5.63 KB
<template>
  <div v-if="visible" class="jls-bottom-nav">
    <div ref="panelRef" class="jls-bottom-nav__panel" @scroll="syncScrollState">
      <div class="jls-bottom-nav__content" :class="{ 'is-scrollable': isScrollable }">
        <button
          v-for="item in tabItems"
          :key="item.key"
          type="button"
          class="jls-bottom-nav__item"
          :class="{ 'is-active': isActive(item.key), 'is-scrollable': isScrollable }"
          :style="itemStyle"
          @click="navigate(item)"
        >
          <span class="jls-bottom-nav__item-inner">
            <span class="jls-bottom-nav__icon">
              <i class="jls-bottom-nav__icon-font" :class="getIconClasses(item)" aria-hidden="true"></i>
            </span>
            <span class="jls-bottom-nav__label">{{ item.title }}</span>
          </span>
        </button>
      </div>
    </div>

    <div v-if="showScrollFade" class="jls-bottom-nav__fade" />
  </div>
</template>

<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import wx from 'weixin-js-sdk';
import './iconfont.css';
import { useJlsTabbar } from './useTabbar';
import { getTabbarIconClasses } from './tabbar.utils';
import { resolveJlsCheckinActiveTab } from './nav-state';

const SCROLLABLE_ITEM_WIDTH = 92;
const defaultLoadOptions = Object.freeze({
  useMock: false,
  mockData: null,
});

const props = defineProps({
  visible: {
    type: Boolean,
    default: false,
  },
  loadOptions: {
    type: Object,
    default: () => ({
      useMock: false,
      mockData: null,
    }),
  },
});

const route = useRoute();
const panelRef = ref(null);
const isScrolledToEnd = ref(false);
const tabbar = useJlsTabbar();

const tabItems = computed(() => tabbar.visibleTabItems.value);
const isScrollable = computed(() => tabItems.value.length > 4);
const itemStyle = computed(() => {
  if (!isScrollable.value) {
    return null;
  }

  return {
    width: `${SCROLLABLE_ITEM_WIDTH}px`,
    minWidth: `${SCROLLABLE_ITEM_WIDTH}px`,
  };
});
const showScrollFade = computed(() => isScrollable.value && !isScrolledToEnd.value);
const currentActiveTab = computed(() => resolveJlsCheckinActiveTab(route.query || {}));

const isActive = (key) => key === currentActiveTab.value;

const getIconClasses = (item) => getTabbarIconClasses(item);

const syncScrollState = () => {
  const panel = panelRef.value;

  if (!panel || !isScrollable.value) {
    isScrolledToEnd.value = false;
    return;
  }

  const maxScrollLeft = Math.max(panel.scrollWidth - panel.clientWidth, 0);
  isScrolledToEnd.value = panel.scrollLeft >= maxScrollLeft - 4;
};

const navigate = (item) => {
  const targetUrl = tabbar.resolveTargetUrl(item);

  if (!targetUrl || isActive(item?.key)) {
    return;
  }

  if (wx?.miniProgram?.redirectTo) {
    wx.miniProgram.redirectTo({
      url: targetUrl,
      fail: () => {
        wx?.miniProgram?.reLaunch?.({ url: targetUrl });
      },
    });
    return;
  }

  wx?.miniProgram?.reLaunch?.({ url: targetUrl });
};

watch(
  () => tabItems.value.length,
  async () => {
    await nextTick();
    syncScrollState();
  },
  { immediate: true },
);

onMounted(async () => {
  if (!props.visible) {
    return;
  }

  await tabbar.ensureLoaded(props.loadOptions || defaultLoadOptions);
  await nextTick();
  syncScrollState();
});
</script>

<style scoped>
.jls-bottom-nav {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 50;
  height: 80px;
  min-height: 80px;
  background: rgba(255, 255, 255, 0.98);
  border-top: 1px solid rgba(166, 121, 57, 0.12);
  backdrop-filter: blur(6px);
  overflow: hidden;
}

.jls-bottom-nav__panel {
  height: 100%;
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
  -ms-overflow-style: none;
}

.jls-bottom-nav__panel::-webkit-scrollbar {
  display: none;
}

.jls-bottom-nav__content {
  display: flex;
  align-items: stretch;
  justify-content: space-around;
  box-sizing: border-box;
  height: 100%;
  padding: 8px 12px;
}

.jls-bottom-nav__content.is-scrollable {
  justify-content: flex-start;
  width: max-content;
  min-width: 100%;
}

.jls-bottom-nav__item {
  display: flex;
  flex: 1;
  min-width: 0;
  align-items: stretch;
  justify-content: center;
  padding: 0;
  border: 0;
  background: transparent;
  color: #8b95a7;
  cursor: pointer;
}

.jls-bottom-nav__item.is-scrollable {
  flex: 0 0 auto;
}

.jls-bottom-nav__item-inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  gap: 5px;
  border-radius: 10px;
  color: inherit;
  transition: color 0.2s ease, transform 0.2s ease;
}

.jls-bottom-nav__item.is-active {
  color: #a67939;
}

.jls-bottom-nav__icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  line-height: 1;
}

.jls-bottom-nav__icon-font {
  font-size: 18px;
  line-height: 1;
}

.jls-bottom-nav__label {
  font-size: 11px;
  line-height: 1.2;
  font-weight: 600;
}

.jls-bottom-nav__fade {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  width: 48px;
  pointer-events: none;
  background: linear-gradient(
    90deg,
    rgba(255, 255, 255, 0) 0%,
    rgba(255, 255, 255, 0.36) 42%,
    rgba(255, 255, 255, 0.98) 100%
  );
}

@media screen and (min-width: 768px) {
  .jls-bottom-nav__content {
    padding: 8px 16px;
  }

  .jls-bottom-nav__item-inner {
    gap: 6px;
  }

  .jls-bottom-nav__icon {
    width: 20px;
    height: 20px;
  }

  .jls-bottom-nav__icon-font {
    font-size: 20px;
  }

  .jls-bottom-nav__label {
    font-size: 12px;
  }
}

@media (hover: hover) {
  .jls-bottom-nav__item:hover .jls-bottom-nav__item-inner {
    transform: translateY(-1px);
  }
}
</style>