Ver Fonte

padronizacao indicador de multiplos cards

Gustavo Zanatta há 1 semana atrás
pai
commit
c52c326ed4

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

@@ -25,8 +25,10 @@
       class="scroll-wrapper"
     >
       <div
+        ref="trackRef"
         class="scroll-track"
         :class="{ 'scroll-track--single': data.length === 1 }"
+        @scroll="onTrackScroll"
       >
         <q-card
           v-for="item in data"
@@ -170,6 +172,11 @@
         </q-card>
       </div>
     </div>
+
+    <DefaultCarouselIndicator
+      :active="activeIndex"
+      :count="pageCount"
+    />
   </div>
 </template>
 
@@ -177,10 +184,13 @@
 import { avatarColors, formatAddress, formatCurrency, getFirstName } from "src/helpers/utils";
 import { labelsPeriodTypes } from "src/helpers/labelsPeriodTypes.js";
 import { formatDayMonth, formatWeekday } from "src/helpers/scheduleDate";
+import { useCarouselIndicator } from "src/composables/useCarouselIndicator";
 import { useI18n } from "vue-i18n";
 import { useQuasar } from "quasar";
 
-defineProps({
+import DefaultCarouselIndicator from "src/components/defaults/DefaultCarouselIndicator.vue";
+
+const props = defineProps({
   data: {
     default: () => [],
     type: Array,
@@ -189,6 +199,9 @@ defineProps({
 
 const emit = defineEmits(["view-details"]);
 
+const { activeIndex, onTrackScroll, pageCount, trackRef } =
+  useCarouselIndicator(() => props.data);
+
 const $q = useQuasar();
 
 const { t } = useI18n();
@@ -250,6 +263,7 @@ const copyAddress = (address) => {
 .schedule-card {
   border-radius: 12px;
   min-width: 82vw;
+  scroll-snap-align: start;
 }
 .schedule-card--single {
   min-width: 100%;

+ 20 - 2
src/components/dashboard/DashboardOpportunities.vue

@@ -48,8 +48,9 @@
 
     <div v-else class="scroll-wrapper">
       <div
+        ref="trackRef"
         class="scroll-track row no-wrap"
-        :class="{ 'q-gutter-x-md': data.length > 1 }"
+        @scroll="onTrackScroll"
       >
         <q-card
           v-for="item in data"
@@ -180,6 +181,11 @@
         </q-card>
       </div>
     </div>
+
+    <DefaultCarouselIndicator
+      :active="activeIndex"
+      :count="pageCount"
+    />
   </div>
 </template>
 
@@ -187,12 +193,14 @@
 import { avatarColors, formatCurrency, getFirstName } from "src/helpers/utils";
 import { labelsPeriodTypes } from "src/helpers/labelsPeriodTypes.js";
 import { formatDayMonth, formatWeekday } from "src/helpers/scheduleDate";
+import { useCarouselIndicator } from "src/composables/useCarouselIndicator";
 import { useQuasar } from "quasar";
 import { useRouter } from "vue-router";
 
+import DefaultCarouselIndicator from "src/components/defaults/DefaultCarouselIndicator.vue";
 import OpportunityDetailsDialog from "src/pages/opportunities/components/OpportunityDetailsDialog.vue";
 
-defineProps({
+const props = defineProps({
   data: {
     default: () => [],
     type: Array,
@@ -203,6 +211,9 @@ const $q = useQuasar();
 
 const router = useRouter();
 
+const { activeIndex, onTrackScroll, pageCount, trackRef } =
+  useCarouselIndicator(() => props.data);
+
 
 
 
@@ -240,8 +251,14 @@ const openOpportunityDetails = (item) => {
   padding-top: 1px;
 }
 .scroll-wrapper {
+  overflow: hidden;
+}
+.scroll-track {
+  gap: 12px;
   overflow-x: auto;
+  overscroll-behavior-x: contain;
   padding-bottom: 8px;
+  scroll-snap-type: x proximity;
 
   &::-webkit-scrollbar {
     display: none;
@@ -250,6 +267,7 @@ const openOpportunityDetails = (item) => {
 .opportunity-card {
   border-radius: 12px;
   min-width: 82vw;
+  scroll-snap-align: start;
 }
 .opportunity-card--single {
   min-width: 100%;

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

@@ -25,8 +25,10 @@
       class="scroll-wrapper"
     >
       <div
+        ref="trackRef"
         class="scroll-track"
         :class="{ 'scroll-track--single': data.length === 1 }"
+        @scroll="onTrackScroll"
       >
         <q-card
           v-for="item in data"
@@ -170,6 +172,11 @@
         </q-card>
       </div>
     </div>
+
+    <DefaultCarouselIndicator
+      :active="activeIndex"
+      :count="pageCount"
+    />
   </div>
 </template>
 
@@ -177,10 +184,13 @@
 import { avatarColors, formatAddress, formatCurrency, getFirstName } from "src/helpers/utils";
 import { labelsPeriodTypes } from "src/helpers/labelsPeriodTypes.js";
 import { formatDayMonth, formatWeekday } from "src/helpers/scheduleDate";
+import { useCarouselIndicator } from "src/composables/useCarouselIndicator";
 import { useI18n } from "vue-i18n";
 import { useQuasar } from "quasar";
 
-defineProps({
+import DefaultCarouselIndicator from "src/components/defaults/DefaultCarouselIndicator.vue";
+
+const props = defineProps({
   data: {
     default: () => [],
     type: Array,
@@ -189,6 +199,9 @@ defineProps({
 
 const emit = defineEmits(["view-details"]);
 
+const { activeIndex, onTrackScroll, pageCount, trackRef } =
+  useCarouselIndicator(() => props.data);
+
 const $q = useQuasar();
 
 const { t } = useI18n();
@@ -250,6 +263,7 @@ const copyAddress = (address) => {
 .schedule-card {
   border-radius: 12px;
   min-width: 82vw;
+  scroll-snap-align: start;
 }
 .schedule-card--single {
   min-width: 100%;

+ 29 - 14
src/components/dashboard/DashboardSolicitations.vue

@@ -29,11 +29,10 @@
       :class="{ 'scroll-wrapper--single': visibleSolicitations.length === 1 }"
     >
       <div
+        ref="trackRef"
         class="no-wrap row scroll-track"
-        :class="{
-          'q-gutter-x-md': visibleSolicitations.length > 1,
-          'scroll-track--single': visibleSolicitations.length === 1,
-        }"
+        :class="{ 'scroll-track--single': visibleSolicitations.length === 1 }"
+        @scroll="onTrackScroll"
       >
         <q-card
           v-for="item in visibleSolicitations"
@@ -189,6 +188,11 @@
         </q-card>
       </div>
     </div>
+
+    <DefaultCarouselIndicator
+      :active="activeIndex"
+      :count="pageCount"
+    />
   </div>
 </template>
 
@@ -196,8 +200,11 @@
 import { avatarColors, formatCurrency, getFirstName } from "src/helpers/utils";
 import { computed } from "vue";
 import { labelsPeriodTypes } from "src/helpers/labelsPeriodTypes.js";
+import { useCarouselIndicator } from "src/composables/useCarouselIndicator";
 import { useI18n } from "vue-i18n";
 
+import DefaultCarouselIndicator from "src/components/defaults/DefaultCarouselIndicator.vue";
+
 const props = defineProps({
   data: {
     default: () => [],
@@ -233,6 +240,9 @@ const visibleSolicitations = computed(() =>
   groupedSolicitations.value.slice(0, 3)
 );
 
+const { activeIndex, onTrackScroll, pageCount, trackRef } =
+  useCarouselIndicator(() => visibleSolicitations.value);
+
 const periodTypeLabel = (item) => {
   const label = labelsPeriodTypes.find(
     (periodType) => periodType.value == item.period_type,
@@ -306,21 +316,24 @@ const formatTimeSinceRequest = (value) => {
 
 <style lang="scss" scoped>
 .scroll-wrapper {
-  overflow-x: auto;
-  padding: 0 0 8px 8px;
-
-  &::-webkit-scrollbar {
-    display: none;
-  }
+  overflow: hidden;
 }
 
 .scroll-wrapper--single {
-  overflow-x: visible;
-  padding: 0 0 8px 0;
+  overflow: visible;
 }
 
 .scroll-track {
   align-items: flex-start;
+  gap: 12px;
+  overflow-x: auto;
+  overscroll-behavior-x: contain;
+  padding: 0 0 8px 0;
+  scroll-snap-type: x mandatory;
+
+  &::-webkit-scrollbar {
+    display: none;
+  }
 }
 
 .solicitation-card {
@@ -335,15 +348,17 @@ const formatTimeSinceRequest = (value) => {
 }
 
 .solicitation-card--multiple {
+  scroll-snap-align: start;
   flex: 0 0 82vw;
-  margin-left: 8px !important;
-  margin-right: 8px !important;
+  margin-left: 0 !important;
+  margin-right: 0 !important;
   max-width: 82vw;
   min-width: 82vw;
   width: 82vw;
 }
 
 .scroll-track--single {
+  overflow-x: visible;
   width: 100%;
 }
 

+ 12 - 0
src/components/dashboard/DashboardTodayServices.vue

@@ -15,8 +15,10 @@
 
     <div class="scroll-wrapper">
       <div
+        ref="trackRef"
         class="scroll-track"
         :class="{ 'scroll-track--single': visibleItems.length === 1 }"
+        @scroll="onTrackScroll"
       >
         <q-card
           v-for="item in visibleItems"
@@ -257,16 +259,23 @@
         </q-card>
       </div>
     </div>
+
+    <DefaultCarouselIndicator
+      :active="activeIndex"
+      :count="pageCount"
+    />
   </div>
 </template>
 
 <script setup>
 import { avatarColors, formatAddress, getFirstName } from 'src/helpers/utils';
 import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
+import { useCarouselIndicator } from 'src/composables/useCarouselIndicator'
 import { useI18n } from 'vue-i18n'
 import { useQuasar } from 'quasar'
 import { verifyScheduleCode } from 'src/api/schedule'
 
+import DefaultCarouselIndicator from 'src/components/defaults/DefaultCarouselIndicator.vue'
 import ProfileHelpDialog from 'src/components/profile/ProfileHelpDialog.vue'
 
 const props = defineProps({ data: { type: Array, default: () => [] } })
@@ -421,6 +430,9 @@ const visibleItems = computed(() =>
   props.data.filter((item) => !item.provider_reviewed)
 )
 
+const { activeIndex, onTrackScroll, pageCount, trackRef } =
+  useCarouselIndicator(() => visibleItems.value)
+
 const copyAddress = (address) => {
   const formatted = formatAddress(address);
 

+ 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,
+  };
+};