소스 검색

padronizacao indicador de multiplos cards

Gustavo Zanatta 1 주 전
부모
커밋
f7ca60b223

+ 16 - 2
src/components/dashboard/DashboardFavoriteProviders.vue

@@ -6,7 +6,11 @@
       {{ $t("dashboard_client.favorites.title") }}
     </div>
 
-    <div class="scroll-track">
+    <div
+      ref="trackRef"
+      class="scroll-track"
+      @scroll="onTrackScroll"
+    >
       <q-card
         v-for="item in data"
         :key="item.provider_id"
@@ -75,6 +79,11 @@
         </q-card-section>
       </q-card>
     </div>
+
+    <DefaultCarouselIndicator
+      :active="activeIndex"
+      :count="pageCount"
+    />
   </div>
 </template>
 
@@ -82,14 +91,19 @@
 import { avatarColors } from "src/helpers/avatarColors";
 import { getFirstName } from "src/helpers/utils";
 import { getProfileMediaUrl } from "src/helpers/profileMedia";
+import { useCarouselIndicator } from "src/composables/useCarouselIndicator";
 import { useQuasar } from "quasar";
 
+import DefaultCarouselIndicator from "src/components/defaults/DefaultCarouselIndicator.vue";
 import SchedulingDialog from "src/pages/search/components/SchedulingDialog.vue";
 
-defineProps({ data: { default: () => [], type: Array } });
+const props = defineProps({ data: { default: () => [], type: Array } });
 
 const $q = useQuasar();
 
+const { activeIndex, onTrackScroll, pageCount, trackRef } =
+  useCarouselIndicator(() => props.data);
+
 const goToScheduling = (provider) => {
   $q.dialog({
     component: SchedulingDialog,

+ 16 - 2
src/components/dashboard/DashboardLastDoneSchedules.vue

@@ -6,7 +6,11 @@
       {{ $t("dashboard_client.last_schedules.title") }}
     </div>
 
-    <div class="scroll-track">
+    <div
+      ref="trackRef"
+      class="scroll-track"
+      @scroll="onTrackScroll"
+    >
       <q-card
         v-for="item in data"
         :key="item.id"
@@ -54,20 +58,30 @@
         </q-card-section>
       </q-card>
     </div>
+
+    <DefaultCarouselIndicator
+      :active="activeIndex"
+      :count="pageCount"
+    />
   </div>
 </template>
 
 <script setup>
 import { avatarColors } from "src/helpers/avatarColors";
 import { getFirstName } from "src/helpers/utils";
+import { useCarouselIndicator } from "src/composables/useCarouselIndicator";
 import { useQuasar } from "quasar";
 
+import DefaultCarouselIndicator from "src/components/defaults/DefaultCarouselIndicator.vue";
 import SchedulingDialog from "src/pages/search/components/SchedulingDialog.vue";
 
-defineProps({ data: { type: Array, default: () => [] } });
+const props = defineProps({ data: { type: Array, default: () => [] } });
 
 const $q = useQuasar();
 
+const { activeIndex, onTrackScroll, pageCount, trackRef } =
+  useCarouselIndicator(() => props.data);
+
 const goToScheduling = (provider) => {
   $q.dialog({
     component: SchedulingDialog,

+ 18 - 2
src/components/dashboard/DashboardNextSchedules.vue

@@ -7,7 +7,11 @@
     </div>
 
     <div class="scroll-wrapper">
-      <div class="scroll-track">
+      <div
+        ref="trackRef"
+        class="scroll-track"
+        @scroll="onTrackScroll"
+      >
         <q-card
           v-for="item in data"
           :key="item.id"
@@ -146,6 +150,11 @@
         </q-card>
       </div>
     </div>
+
+    <DefaultCarouselIndicator
+      :active="activeIndex"
+      :count="pageCount"
+    />
   </div>
 </template>
 
@@ -159,12 +168,18 @@ import {
 } from "src/helpers/paymentPlatformFees";
 
 import { onMounted } from "vue";
+import { useCarouselIndicator } from "src/composables/useCarouselIndicator";
 import { useI18n } from "vue-i18n";
 import { usePaymentPlatformFees } from "src/composables/usePaymentPlatformFees";
 
-defineProps({ data: { type: Array, default: () => [] } });
+import DefaultCarouselIndicator from "src/components/defaults/DefaultCarouselIndicator.vue";
+
+const props = defineProps({ data: { type: Array, default: () => [] } });
 const emit = defineEmits(["view-details"]);
 
+const { activeIndex, onTrackScroll, pageCount, trackRef } =
+  useCarouselIndicator(() => props.data);
+
 const { platformFees, loadPlatformFees } = usePaymentPlatformFees();
 const { t } = useI18n();
 
@@ -211,6 +226,7 @@ onMounted(() => {
 }
 
 .schedule-card {
+  scroll-snap-align: start;
   min-width: 90%;
 }
 

+ 10 - 35
src/components/dashboard/DashboardPendingSchedules.vue

@@ -166,27 +166,23 @@
       </div>
     </div>
 
-    <div
-      v-if="data.length > 1"
-      class="row justify-center q-gutter-x-xs q-mt-sm"
-    >
-      <span
-        v-for="(item, index) in data"
-        :key="`dot-${item.id}`"
-        class="carousel-dot"
-        :class="{ 'carousel-dot--active': index === activeIndex }"
-      />
-    </div>
+    <DefaultCarouselIndicator
+      :active="activeIndex"
+      :count="pageCount"
+    />
   </div>
 </template>
 
 <script setup>
 import { avatarColors } from "src/helpers/avatarColors";
-import { computed, ref } from "vue";
+import { computed } from "vue";
 import { formatAddress, getFirstName } from "src/helpers/utils";
 import { formatNumericDate, formatTimeRange } from "src/helpers/scheduleDate";
+import { useCarouselIndicator } from "src/composables/useCarouselIndicator";
 import { useI18n } from "vue-i18n";
 
+import DefaultCarouselIndicator from "src/components/defaults/DefaultCarouselIndicator.vue";
+
 const emit = defineEmits(["view-details", "cancel"]);
 
 const props = defineProps({
@@ -198,8 +194,8 @@ const props = defineProps({
 
 const { t } = useI18n();
 
-const activeIndex = ref(0);
-const trackRef = ref(null);
+const { activeIndex, onTrackScroll, pageCount, trackRef } =
+  useCarouselIndicator(() => props.data);
 
 const isServicePackage = computed(() => props.type === 'servicePackage');
 
@@ -217,14 +213,6 @@ const addressFor = (item) => {
   };
 };
 
-const onTrackScroll = () => {
-  const track = trackRef.value;
-
-  if (!track || !track.clientWidth) return;
-
-  activeIndex.value = Math.round(track.scrollLeft / track.clientWidth);
-};
-
 const providerName = (item) => {
   if (isServicePackage.value) {
     return item.provider?.user?.name ?? item.provider_name ?? '';
@@ -286,19 +274,6 @@ const seeDetails = (item) => {
   }
 }
 
-.carousel-dot {
-  width: 6px;
-  height: 6px;
-  border-radius: 50%;
-  background: #cbd5e1;
-  transition: background 0.3s ease, width 0.3s ease;
-}
-
-.carousel-dot--active {
-  width: 16px;
-  border-radius: 999px;
-  background: #8b5cf6;
-}
 .pending-card {
   flex: 0 0 100%;
   width: 100%;

+ 16 - 1
src/components/dashboard/DashboardScrollAreaSchedules.vue

@@ -1,6 +1,10 @@
 <template>
   <section class="promo-scroll-wrapper q-mx-md q-mb-md">
-    <div class="promo-scroll">
+    <div
+      ref="trackRef"
+      class="promo-scroll"
+      @scroll="onTrackScroll"
+    >
       <div
         v-for="card in cards"
         :key="card.id"
@@ -11,11 +15,19 @@
         <img :src="card.image" :alt="card.alt" class="promo-card__img" />
       </div>
     </div>
+
+    <DefaultCarouselIndicator
+      :active="activeIndex"
+      :count="pageCount"
+    />
   </section>
 </template>
 
 <script setup>
+import { useCarouselIndicator } from 'src/composables/useCarouselIndicator';
 import { useRouter } from 'vue-router';
+
+import DefaultCarouselIndicator from 'src/components/defaults/DefaultCarouselIndicator.vue';
 import Banner1 from 'src/assets/banner_1.svg';
 import Banner2 from 'src/assets/banner_2.svg';
 
@@ -25,6 +37,9 @@ const cards = [
   { id: 1, image: Banner1, alt: 'Diária sob medida', route: 'SobMedidaPage' },
   { id: 2, image: Banner2, alt: 'Escolha profissionais', route: 'SearchPage' },
 ];
+
+const { activeIndex, onTrackScroll, pageCount, trackRef } =
+  useCarouselIndicator(() => cards);
 </script>
 
 <style scoped lang="scss">

+ 15 - 1
src/components/dashboard/DashboardTodaySchedules.vue

@@ -11,7 +11,11 @@
     </div>
 
     <div class="scroll-wrapper">
-      <div class="scroll-track">
+      <div
+        ref="trackRef"
+        class="scroll-track"
+        @scroll="onTrackScroll"
+      >
         <q-card
           v-for="item in visibleItems"
           :key="item.id"
@@ -235,6 +239,11 @@
         </q-card>
       </div>
     </div>
+
+    <DefaultCarouselIndicator
+      :active="activeIndex"
+      :count="pageCount"
+    />
   </div>
 </template>
 
@@ -243,7 +252,9 @@ import { avatarColors } from "src/helpers/avatarColors";
 import { getFirstName } from "src/helpers/utils";
 import { useQuasar } from "quasar";
 import { ref, onMounted, onBeforeUnmount, computed } from "vue";
+import { useCarouselIndicator } from "src/composables/useCarouselIndicator";
 
+import DefaultCarouselIndicator from "src/components/defaults/DefaultCarouselIndicator.vue";
 import ProfileHelpDialog from "src/components/profile/ProfileHelpDialog.vue";
 
 
@@ -332,6 +343,9 @@ const visibleItems = computed(() => {
   return props.data.filter((item) => !item.client_reviewed);
 });
 
+const { activeIndex, onTrackScroll, pageCount, trackRef } =
+  useCarouselIndicator(() => visibleItems.value);
+
 const progressByState = (item) => {
   const state = cardState(item);
 

+ 72 - 0
src/components/defaults/DefaultCarouselIndicator.vue

@@ -0,0 +1,72 @@
+<template>
+  <div
+    v-if="count > 1"
+    aria-hidden="true"
+    class="row justify-center q-gutter-x-xs q-mt-sm"
+  >
+    <span
+      v-for="index in visibleIndexes"
+      :key="`dot-${index}`"
+      class="carousel-dot"
+      :class="{
+        'carousel-dot--active': index === active,
+        'carousel-dot--edge': isEdge(index),
+      }"
+    />
+  </div>
+</template>
+
+<script setup>
+import { computed } from "vue";
+
+const props = defineProps({
+  active: { type: Number, default: 0 },
+  count: { type: Number, default: 0 },
+
+  max: { type: Number, default: 7 },
+});
+
+const visibleIndexes = computed(() => {
+  const size = Math.min(props.max, props.count);
+
+  const start = Math.min(
+    Math.max(props.active - Math.floor(size / 2), 0),
+    props.count - size,
+  );
+
+  return Array.from({ length: size }, (_, index) => start + index);
+});
+
+const isEdge = (index) => {
+  if (index === props.active) return false;
+
+  const indexes = visibleIndexes.value;
+
+  return (
+    (index === indexes[0] && index > 0) ||
+    (index === indexes[indexes.length - 1] && index < props.count - 1)
+  );
+};
+</script>
+
+<style scoped lang="scss">
+.carousel-dot {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: #cbd5e1;
+  transition: background 0.3s ease, width 0.3s ease, height 0.3s ease;
+}
+
+.carousel-dot--active {
+  width: 16px;
+  border-radius: 999px;
+  background: #8b5cf6;
+}
+
+.carousel-dot--edge {
+  width: 4px;
+  height: 4px;
+  align-self: center;
+}
+</style>

+ 121 - 0
src/composables/useCarouselIndicator.js

@@ -0,0 +1,121 @@
+import { nextTick, onBeforeUnmount, onMounted, ref, unref, watch } from "vue";
+
+const ITEM_MODE_RATIO = 0.8;
+
+const closestCardIndex = (cards, scrollLeft) => {
+  const origin = cards[0].offsetLeft;
+
+  let closest = 0;
+  let closestDistance = Infinity;
+
+  cards.forEach((card, index) => {
+    const distance = Math.abs(card.offsetLeft - origin - scrollLeft);
+
+    if (distance < closestDistance) {
+      closestDistance = distance;
+      closest = index;
+    }
+  });
+
+  return closest;
+};
+
+/**
+ * @param {Ref<Array>|Function} source - The rendered items (ref or getter)
+ * @returns {{ trackRef: Ref, activeIndex: Ref<number>, pageCount: Ref<number>, onTrackScroll: Function }}
+ */
+export const useCarouselIndicator = (source) => {
+  const activeIndex = ref(0);
+  const pageCount = ref(0);
+  const trackRef = ref(null);
+
+  let resizeObserver = null;
+
+  const resolveItems = () => {
+    const value = typeof source === "function" ? source() : unref(source);
+
+    return Array.isArray(value) ? value : [];
+  };
+
+  const measure = () => {
+    const track = trackRef.value;
+
+    if (!track || !track.clientWidth) return;
+
+    const cards = Array.from(track.children).filter(
+      (element) => element.nodeType === Node.ELEMENT_NODE,
+    );
+
+    if (!cards.length) {
+      activeIndex.value = 0;
+      pageCount.value = 0;
+
+      return;
+    }
+
+    const isItemMode =
+      cards[0].offsetWidth >= track.clientWidth * ITEM_MODE_RATIO;
+
+    pageCount.value = isItemMode
+      ? cards.length
+      : Math.max(1, Math.ceil(track.scrollWidth / track.clientWidth));
+
+    if (pageCount.value <= 1) {
+      activeIndex.value = 0;
+
+      return;
+    }
+
+    const maxScroll = track.scrollWidth - track.clientWidth;
+
+    if (maxScroll > 0 && track.scrollLeft >= maxScroll - 1) {
+      activeIndex.value = pageCount.value - 1;
+
+      return;
+    }
+
+    const index = isItemMode
+      ? closestCardIndex(cards, track.scrollLeft)
+      : Math.round(track.scrollLeft / track.clientWidth);
+
+    activeIndex.value = Math.min(Math.max(index, 0), pageCount.value - 1);
+  };
+
+  const stopObserving = () => {
+    resizeObserver?.disconnect();
+    resizeObserver = null;
+  };
+
+  const startObserving = (track) => {
+    if (!track || typeof ResizeObserver === "undefined") return;
+
+    resizeObserver = new ResizeObserver(() => measure());
+    resizeObserver.observe(track);
+  };
+
+  watch(
+    trackRef,
+    (track) => {
+      stopObserving();
+
+      if (track) startObserving(track);
+    },
+    { flush: "post" },
+  );
+
+  watch(
+    () => resolveItems().length,
+    () => nextTick(measure),
+  );
+
+  onMounted(() => nextTick(measure));
+
+  onBeforeUnmount(stopObserving);
+
+  return {
+    activeIndex,
+    onTrackScroll: measure,
+    pageCount,
+    trackRef,
+  };
+};

+ 16 - 2
src/pages/dashboard/components/DashboardClientProposals.vue

@@ -6,7 +6,11 @@
       {{ $t("dashboard_client.client_proposals.title") }}
     </div>
 
-    <div class="scroll-track q-pb-md q-px-md text-text">
+    <div
+      ref="trackRef"
+      class="scroll-track q-pb-md q-px-md text-text"
+      @scroll="onTrackScroll"
+    >
       <q-card
         v-for="item in data"
         :key="item.id"
@@ -151,6 +155,11 @@
         </div>
       </q-card>
     </div>
+
+    <DefaultCarouselIndicator
+      :active="activeIndex"
+      :count="pageCount"
+    />
   </div>
 </template>
 
@@ -167,11 +176,13 @@ import {
 import { formatDayMonth, formatWeekday } from "src/helpers/scheduleDate";
 import { refuseProposal } from "src/api/customSchedules";
 import { usePaymentStore } from "src/stores/payment";
+import { useCarouselIndicator } from "src/composables/useCarouselIndicator";
 import { useQuasar } from "quasar";
 
+import DefaultCarouselIndicator from "src/components/defaults/DefaultCarouselIndicator.vue";
 import SchedulePaymentDialog from "./schedule/SchedulePaymentDialog.vue";
 
-defineProps({
+const props = defineProps({
   data: {
     type: Array,
     default: () => [],
@@ -186,6 +197,9 @@ const emit = defineEmits(["refreshData"]);
 const $q = useQuasar();
 const paymentStore = usePaymentStore();
 
+const { activeIndex, onTrackScroll, pageCount, trackRef } =
+  useCarouselIndicator(() => props.data);
+
 const handleAcceptProposal = (item) => {
   const servicePackage = {
     id: item.id,

+ 18 - 0
src/pages/search/components/SchedulingDialog.vue

@@ -202,7 +202,9 @@
 
           <div
             v-else
+            ref="reviewsTrackRef"
             class="reviews-scroll"
+            @scroll="onReviewsScroll"
           >
             <q-card
               v-for="review in reviews"
@@ -257,6 +259,11 @@
               </q-card-section>
             </q-card>
           </div>
+
+          <DefaultCarouselIndicator
+            :active="reviewsActiveIndex"
+            :count="reviewsPageCount"
+          />
         </div>
       </div>
     </div>
@@ -285,6 +292,7 @@ import {
 } from "src/api/providerAvailability";
 
 import { getProviderReceivedReviews } from "src/api/review";
+import { useCarouselIndicator } from "src/composables/useCarouselIndicator";
 import { useI18n } from "vue-i18n";
 import { useRouter } from "vue-router";
 import { userStore } from "src/stores/user";
@@ -294,6 +302,7 @@ import ServicePackageSummaryDialog from "./ServicePackageSummaryDialog.vue";
 import ServiceSelectionSheet from "./ServiceSelectionSheet.vue";
 import ServiceTimeSelectionDialog from "./ServiceTimeSelectionDialog.vue";
 import ProfileInfoDialog from "src/components/profile/ProfileInfoDialog.vue";
+import DefaultCarouselIndicator from "src/components/defaults/DefaultCarouselIndicator.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
@@ -324,6 +333,13 @@ const providerClientBlocks = ref({
   fully_blocked_weeks: [],
 });
 const reviews = ref([]);
+
+const {
+  activeIndex: reviewsActiveIndex,
+  onTrackScroll: onReviewsScroll,
+  pageCount: reviewsPageCount,
+  trackRef: reviewsTrackRef,
+} = useCarouselIndicator(() => reviews.value);
 const selectedDate = ref(null);
 const workingDays = ref([]);
 
@@ -844,6 +860,7 @@ onMounted(() => {
   display: flex;
   flex-direction: row;
   overflow-x: auto;
+  scroll-snap-type: x proximity;
   -webkit-overflow-scrolling: touch;
   scrollbar-width: none;
   padding-bottom: 8px;
@@ -854,6 +871,7 @@ onMounted(() => {
 
 .review-card {
   flex-shrink: 0;
+  scroll-snap-align: start;
   min-width: 220px;
   max-width: 240px;
   border-radius: 12px;