feat(unified-mobile-material-design): Complete US-106 - Dashboard Mobile cu Swipeable KPI Cards
Implemented by Ralph autonomous loop. Iteration: 10 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -169,8 +169,8 @@
|
|||||||
"Fallback la layout grid normal pe desktop",
|
"Fallback la layout grid normal pe desktop",
|
||||||
"npm run build passes"
|
"npm run build passes"
|
||||||
],
|
],
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": ""
|
"notes": "Completed in iteration 10"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-107",
|
"id": "US-107",
|
||||||
|
|||||||
@@ -60,3 +60,9 @@ Mon Jan 12 09:44:54 AM UTC 2026
|
|||||||
[2026-01-12 10:03:03] Working on story: US-112
|
[2026-01-12 10:03:03] Working on story: US-112
|
||||||
[2026-01-12 10:03:03] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_9_US-112.log)
|
[2026-01-12 10:03:03] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_9_US-112.log)
|
||||||
[2026-01-12 10:04:31] SUCCESS: Story US-112 passed!
|
[2026-01-12 10:04:31] SUCCESS: Story US-112 passed!
|
||||||
|
[2026-01-12 10:04:31] Changes committed
|
||||||
|
[2026-01-12 10:04:31] Progress: 8/20 stories completed
|
||||||
|
[2026-01-12 10:04:33] === Iteration 10/100 ===
|
||||||
|
[2026-01-12 10:04:33] Working on story: US-106
|
||||||
|
[2026-01-12 10:04:33] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_10_US-106.log)
|
||||||
|
[2026-01-12 10:07:38] SUCCESS: Story US-106 passed!
|
||||||
|
|||||||
@@ -10,8 +10,64 @@
|
|||||||
|
|
||||||
<!-- Secțiune Carduri Noi - Adăugare -->
|
<!-- Secțiune Carduri Noi - Adăugare -->
|
||||||
<div class="metrics-cards-section" v-if="!isLoading">
|
<div class="metrics-cards-section" v-if="!isLoading">
|
||||||
<!-- Rând 1: Metrici principale -->
|
<!-- Mobile: Swipeable KPI Cards Carousel -->
|
||||||
<div class="metrics-row">
|
<SwipeableCards v-if="isMobile" :totalCards="4" class="mobile-kpi-carousel">
|
||||||
|
<template #card-0>
|
||||||
|
<TreasuryDualCard
|
||||||
|
:casaTotal="treasuryData?.breakdown?.casa?.total || 0"
|
||||||
|
:bancaTotal="treasuryData?.breakdown?.banca?.total || 0"
|
||||||
|
:casaItems="treasuryData?.breakdown?.casa?.items || []"
|
||||||
|
:bancaItems="treasuryData?.breakdown?.banca?.items || []"
|
||||||
|
:casaTrend="casaTrend"
|
||||||
|
:bancaTrend="bancaTrend"
|
||||||
|
:casaSparklineData="casaSparkline"
|
||||||
|
:bancaSparklineData="bancaSparkline"
|
||||||
|
:casaPreviousSparklineData="casaPreviousSparkline"
|
||||||
|
:bancaPreviousSparklineData="bancaPreviousSparkline"
|
||||||
|
:sparklineLabels="sparklineLabels"
|
||||||
|
:previousSparklineLabels="previousSparklineLabels"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #card-1>
|
||||||
|
<CashFlowMetricCard
|
||||||
|
:inflowsValue="monthlyInflows"
|
||||||
|
:outflowsValue="monthlyOutflows"
|
||||||
|
:inflowsTrend="inflowsTrend"
|
||||||
|
:outflowsTrend="outflowsTrend"
|
||||||
|
:inflowsSparkline="inflowsSparkline"
|
||||||
|
:outflowsSparkline="outflowsSparkline"
|
||||||
|
:inflowsPreviousSparkline="inflowsPreviousSparkline"
|
||||||
|
:outflowsPreviousSparkline="outflowsPreviousSparkline"
|
||||||
|
:sparklineLabels="sparklineLabels"
|
||||||
|
:previousSparklineLabels="previousSparklineLabels"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #card-2>
|
||||||
|
<ClientiBalanceCard
|
||||||
|
:total="netBalanceData?.clienti_total || 0"
|
||||||
|
:trend="clientiTrend"
|
||||||
|
:sparklineData="clientiSparkline"
|
||||||
|
:previousSparklineData="clientiPreviousSparkline"
|
||||||
|
:sparklineLabels="sparklineLabels"
|
||||||
|
:previousSparklineLabels="previousSparklineLabels"
|
||||||
|
:breakdown="netBalanceData?.breakdown?.clienti"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #card-3>
|
||||||
|
<FurnizoriBalanceCard
|
||||||
|
:total="netBalanceData?.furnizori_total || 0"
|
||||||
|
:trend="furnizoriTrend"
|
||||||
|
:sparklineData="furnizoriSparkline"
|
||||||
|
:previousSparklineData="furnizoriPreviousSparkline"
|
||||||
|
:sparklineLabels="sparklineLabels"
|
||||||
|
:previousSparklineLabels="previousSparklineLabels"
|
||||||
|
:breakdown="netBalanceData?.breakdown?.furnizori"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</SwipeableCards>
|
||||||
|
|
||||||
|
<!-- Desktop: Grid layout -->
|
||||||
|
<div v-else class="metrics-row">
|
||||||
<TreasuryDualCard
|
<TreasuryDualCard
|
||||||
:casaTotal="treasuryData?.breakdown?.casa?.total || 0"
|
:casaTotal="treasuryData?.breakdown?.casa?.total || 0"
|
||||||
:bancaTotal="treasuryData?.breakdown?.banca?.total || 0"
|
:bancaTotal="treasuryData?.breakdown?.banca?.total || 0"
|
||||||
@@ -82,7 +138,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from "vue";
|
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||||
import { useToast } from "primevue/usetoast";
|
import { useToast } from "primevue/usetoast";
|
||||||
// Import componente noi
|
// Import componente noi
|
||||||
import MetricCard from "@reports/components/dashboard/cards/MetricCard.vue";
|
import MetricCard from "@reports/components/dashboard/cards/MetricCard.vue";
|
||||||
@@ -91,6 +147,8 @@ import MaturityAndDetailsCard from "@reports/components/dashboard/cards/Maturity
|
|||||||
import ClientiBalanceCard from "@reports/components/dashboard/cards/ClientiBalanceCard.vue";
|
import ClientiBalanceCard from "@reports/components/dashboard/cards/ClientiBalanceCard.vue";
|
||||||
import FurnizoriBalanceCard from "@reports/components/dashboard/cards/FurnizoriBalanceCard.vue";
|
import FurnizoriBalanceCard from "@reports/components/dashboard/cards/FurnizoriBalanceCard.vue";
|
||||||
import TreasuryDualCard from "@reports/components/dashboard/cards/TreasuryDualCard.vue";
|
import TreasuryDualCard from "@reports/components/dashboard/cards/TreasuryDualCard.vue";
|
||||||
|
// Mobile carousel component
|
||||||
|
import SwipeableCards from "@shared/components/mobile/SwipeableCards.vue";
|
||||||
import { useCompanyStore } from "@reports/stores/sharedStores";
|
import { useCompanyStore } from "@reports/stores/sharedStores";
|
||||||
import { useDashboardStore } from "@reports/stores/dashboard";
|
import { useDashboardStore } from "@reports/stores/dashboard";
|
||||||
import { useAccountingPeriodStore } from "@reports/stores/sharedStores";
|
import { useAccountingPeriodStore } from "@reports/stores/sharedStores";
|
||||||
@@ -419,8 +477,14 @@ const bancaPreviousSparkline = computed(() => {
|
|||||||
return previousSparklineData.map((v) => v * bancaProportion);
|
return previousSparklineData.map((v) => v * bancaProportion);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detectare mobile
|
// Detectare mobile - reactive with resize listener
|
||||||
const isMobile = computed(() => window.innerWidth < 768);
|
const windowWidth = ref(window.innerWidth);
|
||||||
|
const isMobile = computed(() => windowWidth.value < 768);
|
||||||
|
|
||||||
|
// Handle window resize for mobile detection
|
||||||
|
const handleResize = () => {
|
||||||
|
windowWidth.value = window.innerWidth;
|
||||||
|
};
|
||||||
|
|
||||||
// Computed property pentru luna curentă - folosește perioada din period selector
|
// Computed property pentru luna curentă - folosește perioada din period selector
|
||||||
const currentMonthLabel = computed(() => {
|
const currentMonthLabel = computed(() => {
|
||||||
@@ -849,6 +913,9 @@ watch(
|
|||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// Add resize listener for mobile detection
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
// Load companies first
|
// Load companies first
|
||||||
if (!companyStore.hasCompanies) {
|
if (!companyStore.hasCompanies) {
|
||||||
await companyStore.loadCompanies();
|
await companyStore.loadCompanies();
|
||||||
@@ -871,6 +938,11 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cleanup resize listener
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -1206,4 +1278,9 @@ onMounted(async () => {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile KPI Carousel Styles */
|
||||||
|
.mobile-kpi-carousel {
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
459
src/shared/components/mobile/SwipeableCards.vue
Normal file
459
src/shared/components/mobile/SwipeableCards.vue
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
<template>
|
||||||
|
<div class="swipeable-cards-container">
|
||||||
|
<!-- Cards Track -->
|
||||||
|
<div
|
||||||
|
class="cards-track"
|
||||||
|
ref="trackRef"
|
||||||
|
:style="trackStyle"
|
||||||
|
@touchstart="handleTouchStart"
|
||||||
|
@touchmove="handleTouchMove"
|
||||||
|
@touchend="handleTouchEnd"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(_, index) in totalCards"
|
||||||
|
:key="index"
|
||||||
|
class="card-slide"
|
||||||
|
:class="{ active: index === currentIndex }"
|
||||||
|
>
|
||||||
|
<slot :name="`card-${index}`"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dots Indicator -->
|
||||||
|
<div class="dots-indicator" v-if="showDots && totalCards > 1">
|
||||||
|
<button
|
||||||
|
v-for="index in totalCards"
|
||||||
|
:key="index"
|
||||||
|
class="dot"
|
||||||
|
:class="{ active: index - 1 === currentIndex }"
|
||||||
|
@click="goToCard(index - 1)"
|
||||||
|
:aria-label="`Mergi la cardul ${index}`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SwipeableCards - Touch swipeable carousel for mobile KPI cards
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* - totalCards: Number of cards in the carousel
|
||||||
|
* - showDots: Whether to show the dots indicator (default: true)
|
||||||
|
* - autoPlay: Whether to auto-advance cards (default: false)
|
||||||
|
* - autoPlayInterval: Interval in ms for auto-play (default: 5000)
|
||||||
|
*
|
||||||
|
* Events:
|
||||||
|
* - update:currentIndex: Emitted when current card index changes
|
||||||
|
*
|
||||||
|
* Slots:
|
||||||
|
* - card-0, card-1, card-2, etc.: Named slots for each card
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Horizontal touch swipe (left/right)
|
||||||
|
* - Smooth CSS transitions
|
||||||
|
* - Dots indicator for position
|
||||||
|
* - Visual feedback during drag
|
||||||
|
* - Responsive touch thresholds
|
||||||
|
*/
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/**
|
||||||
|
* Total number of cards in the carousel
|
||||||
|
*/
|
||||||
|
totalCards: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
validator: (value) => value > 0
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Whether to show the navigation dots
|
||||||
|
*/
|
||||||
|
showDots: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Whether to auto-advance cards
|
||||||
|
*/
|
||||||
|
autoPlay: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Auto-play interval in milliseconds
|
||||||
|
*/
|
||||||
|
autoPlayInterval: {
|
||||||
|
type: Number,
|
||||||
|
default: 5000
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:currentIndex'])
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const trackRef = ref(null)
|
||||||
|
const currentIndex = ref(0)
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragOffset = ref(0)
|
||||||
|
|
||||||
|
// Touch tracking state
|
||||||
|
let touchStartX = 0
|
||||||
|
let touchStartY = 0
|
||||||
|
let touchCurrentX = 0
|
||||||
|
let isSwiping = false
|
||||||
|
let autoPlayTimer = null
|
||||||
|
|
||||||
|
// Swipe thresholds
|
||||||
|
const SWIPE_THRESHOLD = 50 // Minimum swipe distance to change card
|
||||||
|
const SWIPE_VELOCITY_THRESHOLD = 0.3 // Minimum velocity for quick swipes
|
||||||
|
const ANGLE_THRESHOLD = 30 // Maximum angle (degrees) for horizontal swipe detection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the track transform based on current index and drag offset
|
||||||
|
*/
|
||||||
|
const trackStyle = computed(() => {
|
||||||
|
const translateX = -currentIndex.value * 100 + (dragOffset.value / getTrackWidth()) * 100
|
||||||
|
return {
|
||||||
|
transform: `translateX(${translateX}%)`,
|
||||||
|
transition: isDragging.value ? 'none' : 'transform var(--transition-normal)'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get track container width for calculations
|
||||||
|
*/
|
||||||
|
const getTrackWidth = () => {
|
||||||
|
return trackRef.value?.clientWidth || window.innerWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to a specific card index
|
||||||
|
*/
|
||||||
|
const goToCard = (index) => {
|
||||||
|
if (index < 0 || index >= props.totalCards) return
|
||||||
|
currentIndex.value = index
|
||||||
|
emit('update:currentIndex', index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the next card
|
||||||
|
*/
|
||||||
|
const nextCard = () => {
|
||||||
|
if (currentIndex.value < props.totalCards - 1) {
|
||||||
|
goToCard(currentIndex.value + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the previous card
|
||||||
|
*/
|
||||||
|
const prevCard = () => {
|
||||||
|
if (currentIndex.value > 0) {
|
||||||
|
goToCard(currentIndex.value - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle touch start event
|
||||||
|
*/
|
||||||
|
const handleTouchStart = (event) => {
|
||||||
|
// Record starting position
|
||||||
|
touchStartX = event.touches[0].clientX
|
||||||
|
touchStartY = event.touches[0].clientY
|
||||||
|
touchCurrentX = touchStartX
|
||||||
|
isSwiping = false
|
||||||
|
|
||||||
|
// Stop auto-play during interaction
|
||||||
|
stopAutoPlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle touch move event
|
||||||
|
*/
|
||||||
|
const handleTouchMove = (event) => {
|
||||||
|
if (!touchStartX) return
|
||||||
|
|
||||||
|
touchCurrentX = event.touches[0].clientX
|
||||||
|
const touchCurrentY = event.touches[0].clientY
|
||||||
|
|
||||||
|
const deltaX = touchCurrentX - touchStartX
|
||||||
|
const deltaY = touchCurrentY - touchStartY
|
||||||
|
|
||||||
|
// Determine if this is a horizontal swipe
|
||||||
|
// Check angle: if angle is too steep, it's a vertical scroll
|
||||||
|
const angle = Math.abs(Math.atan2(deltaY, deltaX) * (180 / Math.PI))
|
||||||
|
|
||||||
|
if (!isSwiping) {
|
||||||
|
// First significant movement - determine direction
|
||||||
|
if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {
|
||||||
|
if (angle < ANGLE_THRESHOLD || angle > (180 - ANGLE_THRESHOLD)) {
|
||||||
|
// Horizontal swipe
|
||||||
|
isSwiping = true
|
||||||
|
isDragging.value = true
|
||||||
|
} else {
|
||||||
|
// Vertical scroll - don't interfere
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSwiping) {
|
||||||
|
// Prevent vertical scroll while swiping horizontally
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
// Apply resistance at boundaries
|
||||||
|
let adjustedDelta = deltaX
|
||||||
|
if (
|
||||||
|
(currentIndex.value === 0 && deltaX > 0) ||
|
||||||
|
(currentIndex.value === props.totalCards - 1 && deltaX < 0)
|
||||||
|
) {
|
||||||
|
// Apply resistance (50% of movement)
|
||||||
|
adjustedDelta = deltaX * 0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
dragOffset.value = adjustedDelta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle touch end event
|
||||||
|
*/
|
||||||
|
const handleTouchEnd = (event) => {
|
||||||
|
if (!isSwiping) {
|
||||||
|
resetTouchState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaX = touchCurrentX - touchStartX
|
||||||
|
const trackWidth = getTrackWidth()
|
||||||
|
const percentMoved = Math.abs(deltaX) / trackWidth
|
||||||
|
|
||||||
|
// Calculate swipe velocity (for quick swipes)
|
||||||
|
const touchDuration = event.timeStamp - (event.changedTouches[0]?.timeStamp || event.timeStamp)
|
||||||
|
const velocity = Math.abs(deltaX) / (touchDuration || 1)
|
||||||
|
|
||||||
|
// Determine if we should change cards
|
||||||
|
const shouldChange =
|
||||||
|
Math.abs(deltaX) > SWIPE_THRESHOLD ||
|
||||||
|
(velocity > SWIPE_VELOCITY_THRESHOLD && percentMoved > 0.1)
|
||||||
|
|
||||||
|
if (shouldChange) {
|
||||||
|
if (deltaX > 0 && currentIndex.value > 0) {
|
||||||
|
// Swiped right - go to previous
|
||||||
|
prevCard()
|
||||||
|
} else if (deltaX < 0 && currentIndex.value < props.totalCards - 1) {
|
||||||
|
// Swiped left - go to next
|
||||||
|
nextCard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
resetTouchState()
|
||||||
|
|
||||||
|
// Restart auto-play after interaction
|
||||||
|
if (props.autoPlay) {
|
||||||
|
startAutoPlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset touch tracking state
|
||||||
|
*/
|
||||||
|
const resetTouchState = () => {
|
||||||
|
touchStartX = 0
|
||||||
|
touchStartY = 0
|
||||||
|
touchCurrentX = 0
|
||||||
|
isSwiping = false
|
||||||
|
isDragging.value = false
|
||||||
|
dragOffset.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start auto-play timer
|
||||||
|
*/
|
||||||
|
const startAutoPlay = () => {
|
||||||
|
if (!props.autoPlay) return
|
||||||
|
|
||||||
|
stopAutoPlay()
|
||||||
|
autoPlayTimer = setInterval(() => {
|
||||||
|
if (currentIndex.value < props.totalCards - 1) {
|
||||||
|
nextCard()
|
||||||
|
} else {
|
||||||
|
goToCard(0) // Loop back to first
|
||||||
|
}
|
||||||
|
}, props.autoPlayInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop auto-play timer
|
||||||
|
*/
|
||||||
|
const stopAutoPlay = () => {
|
||||||
|
if (autoPlayTimer) {
|
||||||
|
clearInterval(autoPlayTimer)
|
||||||
|
autoPlayTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for totalCards changes to reset index if needed
|
||||||
|
watch(() => props.totalCards, (newTotal) => {
|
||||||
|
if (currentIndex.value >= newTotal) {
|
||||||
|
goToCard(newTotal - 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.autoPlay) {
|
||||||
|
startAutoPlay()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopAutoPlay()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Expose methods for parent component
|
||||||
|
defineExpose({
|
||||||
|
goToCard,
|
||||||
|
nextCard,
|
||||||
|
prevCard,
|
||||||
|
currentIndex
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ================================================
|
||||||
|
SwipeableCards Component Styles
|
||||||
|
Touch swipeable carousel for mobile KPI cards
|
||||||
|
================================================ */
|
||||||
|
|
||||||
|
.swipeable-cards-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
/* Padding for dots indicator */
|
||||||
|
padding-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards track - horizontal scrolling container */
|
||||||
|
.cards-track {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
will-change: transform;
|
||||||
|
/* Prevent text selection during swipe */
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
/* Smooth touch scrolling */
|
||||||
|
touch-action: pan-y pinch-zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Individual card slide */
|
||||||
|
.card-slide {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 var(--space-sm);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* First and last card padding adjustments */
|
||||||
|
.card-slide:first-child {
|
||||||
|
padding-left: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-slide:last-child {
|
||||||
|
padding-right: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================
|
||||||
|
Dots Indicator
|
||||||
|
================================================ */
|
||||||
|
|
||||||
|
.dots-indicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Individual dot */
|
||||||
|
.dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--surface-border);
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
/* Expand touch target */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded touch target (invisible) */
|
||||||
|
.dot::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
left: -8px;
|
||||||
|
right: -8px;
|
||||||
|
bottom: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active dot */
|
||||||
|
.dot.active {
|
||||||
|
width: 24px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover state */
|
||||||
|
.dot:hover:not(.active) {
|
||||||
|
background: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus state for accessibility */
|
||||||
|
.dot:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================
|
||||||
|
Dark Mode Support
|
||||||
|
================================================ */
|
||||||
|
|
||||||
|
/* Manual dark mode via data-theme attribute */
|
||||||
|
[data-theme="dark"] .dot {
|
||||||
|
background: var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .dot.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .dot:hover:not(.active) {
|
||||||
|
background: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auto dark mode (when no manual theme is set) */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme]) .dot {
|
||||||
|
background: var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not([data-theme]) .dot.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not([data-theme]) .dot:hover:not(.active) {
|
||||||
|
background: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user