Skip to content

Commit da414d6

Browse files
committed
feat(StatCard): add support for negative values in sparkline and update related documentation; enhance styling for zero line visibility
1 parent af242aa commit da414d6

6 files changed

Lines changed: 91 additions & 14 deletions

File tree

docs/content/docs/2.components/stat-card.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ props:
210210
icon: 'i-lucide-trending-up'
211211
title: 'Sales Trend'
212212
value: '$45,231'
213-
data: [20, 35, 30, 45, 50, 40, 55, 60, 55, 65, 70, 75]
213+
data: [20, -35, 30, 45, 50, 40, 55, 60, 55, 65, 70, 75]
214214
class: 'max-w-xl'
215215
---
216216
::

playgrounds/nuxt/app/pages/components/stat-card.vue

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,48 @@ const stat4 = ref({
434434

435435
<USeparator />
436436

437+
<div class="flex flex-col gap-4">
438+
<h2 class="font-semibold text-highlighted">
439+
With Negative Values (Zero Line)
440+
</h2>
441+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full">
442+
<UStatCard
443+
icon="i-lucide-trending-up"
444+
title="Net Profit"
445+
value="$12,450"
446+
:data="[15, -20, 25, -30, 35, 40, 45, 50, 55, 60, 65, 70]"
447+
:trend="366.7"
448+
:color="color"
449+
:size="size"
450+
variant="outline"
451+
/>
452+
<UStatCard
453+
icon="i-lucide-line-chart"
454+
title="Revenue Change"
455+
value="+24.5%"
456+
:data="[-10, -5, 5, -8, 12, 15, 20, 18, 25, 30, 35, 40]"
457+
:trend="500"
458+
show-area
459+
:color="color"
460+
:size="size"
461+
variant="outline"
462+
/>
463+
<UStatCard
464+
icon="i-lucide-bar-chart"
465+
title="Growth Rate"
466+
value="8.2%"
467+
:data="[-15, 10, -5, 20, -10, 15, 25, 30, 35, 40, 45, 50]"
468+
:trend="433.3"
469+
show-area
470+
:color="color"
471+
:size="size"
472+
variant="outline"
473+
/>
474+
</div>
475+
</div>
476+
477+
<USeparator />
478+
437479
<div class="flex flex-col gap-4">
438480
<h2 class="font-semibold text-highlighted">
439481
Dashboard Example

src/runtime/components/StatCard.vue

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export interface StatCardSlots {
9292
value(props: { value?: string | number, ui: StatCard['ui'] }): any
9393
label(props: { current: number, max: number, ui: StatCard['ui'] }): any
9494
progress(props: { current: number, max: number, percent: number, ui: StatCard['ui'] }): any
95-
sparkline(props: { path: string, areaPath: string, viewBox: string, ui: StatCard['ui'] }): any
95+
sparkline(props: { path: string, areaPath: string, viewBox: string, zeroLine: string | null, ui: StatCard['ui'] }): any
9696
trend(props: { trend?: number, trendDirection?: 'up' | 'down', ui: StatCard['ui'] }): any
9797
default(props: { ui: StatCard['ui'] }): any
9898
}
@@ -165,7 +165,7 @@ const progressColor = computed(() => props.progressColor || props.color)
165165
166166
// Sparkline calculations
167167
const sparklineData = computed(() => {
168-
if (!props.data || props.data.length === 0) return { path: '', areaPath: '', viewBox: '0 0 100 40' }
168+
if (!props.data || props.data.length === 0) return { path: '', areaPath: '', viewBox: '0 0 100 40', zeroLine: null }
169169
170170
const values = props.data
171171
const min = Math.min(...values)
@@ -176,6 +176,24 @@ const sparklineData = computed(() => {
176176
const height = props.height || 40
177177
const padding = 2
178178
179+
// Check if we need to show zero line (when there are negative values)
180+
const hasNegativeValues = min < 0
181+
let zeroLineY: number | null = null
182+
183+
if (hasNegativeValues && max > 0 && range > 0) {
184+
// Calculate where zero should be positioned
185+
// Normalize zero value: (0 - min) / range gives us where zero is in the range [0, 1]
186+
const normalizedZero = (0 - min) / range
187+
// Convert to Y coordinate: invert because SVG Y increases downward
188+
// We want zero at the bottom when it's the minimum, at the top when it's the maximum
189+
const availableHeight = height - padding * 2
190+
zeroLineY = height - padding - (normalizedZero * availableHeight)
191+
192+
// Ensure zeroLineY is within bounds
193+
if (zeroLineY < padding) zeroLineY = padding
194+
if (zeroLineY > height - padding) zeroLineY = height - padding
195+
}
196+
179197
const points: string[] = []
180198
181199
values.forEach((value, index) => {
@@ -188,24 +206,24 @@ const sparklineData = computed(() => {
188206
points.push(`${x},${y}`)
189207
})
190208
191-
if (points.length === 0) return { path: '', areaPath: '', viewBox: `0 0 ${width} ${height}` }
209+
if (points.length === 0) return { path: '', areaPath: '', viewBox: `0 0 ${width} ${height}`, zeroLine: null }
192210
193211
const path = `M ${points.join(' L ')}`
194212
const firstPoint = points[0]
195213
const lastPoint = points[points.length - 1]
196214
197-
if (!firstPoint || !lastPoint) return { path: '', areaPath: '', viewBox: `0 0 ${width} ${height}` }
215+
if (!firstPoint || !lastPoint) return { path: '', areaPath: '', viewBox: `0 0 ${width} ${height}`, zeroLine: null }
198216
199217
const firstXStr = firstPoint.split(',')[0]
200218
const lastXStr = lastPoint.split(',')[0]
201219
202-
if (!firstXStr || !lastXStr) return { path: '', areaPath: '', viewBox: `0 0 ${width} ${height}` }
220+
if (!firstXStr || !lastXStr) return { path: '', areaPath: '', viewBox: `0 0 ${width} ${height}`, zeroLine: null }
203221
204222
const firstX = Number.parseFloat(firstXStr)
205223
const areaPath = `M ${firstPoint} L ${points.join(' L ')} L ${lastXStr},${height} L ${firstX},${height} Z`
206224
const viewBox = `0 0 ${width} ${height}`
207225
208-
return { path, areaPath, viewBox }
226+
return { path, areaPath, viewBox, zeroLine: zeroLineY !== null ? `M ${padding},${zeroLineY} L ${width - padding},${zeroLineY}` : null }
209227
})
210228
</script>
211229

@@ -260,14 +278,30 @@ const sparklineData = computed(() => {
260278
</div>
261279

262280
<div v-if="!!slots.sparkline || (data && data.length > 0)" data-slot="sparkline" :class="(ui as any).sparkline({ class: (props.ui as any)?.sparkline })">
263-
<slot name="sparkline" :path="sparklineData.path" :area-path="sparklineData.areaPath" :view-box="sparklineData.viewBox" :ui="ui">
281+
<slot
282+
name="sparkline"
283+
:path="sparklineData.path"
284+
:area-path="sparklineData.areaPath"
285+
:view-box="sparklineData.viewBox"
286+
:zero-line="sparklineData.zeroLine"
287+
:ui="ui"
288+
>
264289
<svg
265290
data-slot="sparklineSvg"
266291
:class="(ui as any).sparklineSvg({ class: (props.ui as any)?.sparklineSvg })"
267292
:viewBox="sparklineData.viewBox"
268293
preserveAspectRatio="none"
269294
:style="{ height: `${props.height || 40}px` }"
270295
>
296+
<path
297+
v-if="sparklineData.zeroLine"
298+
data-slot="sparklineZeroLine"
299+
:d="sparklineData.zeroLine"
300+
:class="(ui as any).sparklineZeroLine?.({ class: (props.ui as any)?.sparklineZeroLine }) || 'stroke-default dark:stroke-default'"
301+
fill="none"
302+
stroke-width="1"
303+
stroke-dasharray="3 3"
304+
/>
271305
<path
272306
v-if="showArea"
273307
data-slot="sparklineArea"

src/theme/stat-card.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export default (options: Required<ModuleOptions>) => ({
1515
sparklineSvg: 'w-full',
1616
sparklinePath: 'fill-none transition-all duration-300',
1717
sparklineArea: 'transition-opacity duration-300',
18+
sparklineZeroLine: 'stroke-default dark:stroke-default',
1819
trend: 'flex items-center gap-1 text-xs font-medium mt-1',
1920
trendIcon: 'shrink-0',
2021
trendValue: ''

test/components/__snapshots__/StatCard-vue.spec.ts.snap

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ exports[`StatCard > renders with all props correctly 1`] = `
88
<div data-slot="title" class="font-medium text-sm text-muted">Users</div>
99
<div data-slot="value" class="font-semibold mt-1 text-3xl text-default">1,234</div>
1010
<!--v-if-->
11-
<div data-slot="trend" class="flex items-center gap-1 text-xs font-medium mt-1 text-success-500 dark:text-success-400"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" data-slot="trendIcon" class="shrink-0 text-primary-500 dark:text-primary-400 text-success-500 dark:text-success-400"></svg><span data-slot="trendValue" class="">+5.2%</span></div>
11+
<div data-slot="trend" class="flex items-center gap-1 text-xs font-medium mt-1 !text-success-500 dark:!text-success-400"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" data-slot="trendIcon" class="shrink-0 text-primary-500 dark:text-primary-400 !text-success-500 dark:!text-success-400"></svg><span data-slot="trendValue" class="">+5.2%</span></div>
1212
</div>
1313
</div>
1414
<!--v-if-->
@@ -312,7 +312,7 @@ exports[`StatCard > renders with trend down correctly 1`] = `
312312
<!--v-if-->
313313
<!--v-if-->
314314
<!--v-if-->
315-
<div data-slot="trend" class="flex items-center gap-1 text-xs font-medium mt-1 text-error-500 dark:text-error-400"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" data-slot="trendIcon" class="shrink-0 text-primary-500 dark:text-primary-400 text-error-500 dark:text-error-400"></svg><span data-slot="trendValue" class="">+8.2%</span></div>
315+
<div data-slot="trend" class="flex items-center gap-1 text-xs font-medium mt-1 !text-error-500 dark:!text-error-400"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" data-slot="trendIcon" class="shrink-0 text-primary-500 dark:text-primary-400 !text-error-500 dark:!text-error-400"></svg><span data-slot="trendValue" class="">+8.2%</span></div>
316316
</div>
317317
</div>
318318
<!--v-if-->
@@ -344,7 +344,7 @@ exports[`StatCard > renders with trend up correctly 1`] = `
344344
<!--v-if-->
345345
<!--v-if-->
346346
<!--v-if-->
347-
<div data-slot="trend" class="flex items-center gap-1 text-xs font-medium mt-1 text-success-500 dark:text-success-400"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" data-slot="trendIcon" class="shrink-0 text-primary-500 dark:text-primary-400 text-success-500 dark:text-success-400"></svg><span data-slot="trendValue" class="">+12.5%</span></div>
347+
<div data-slot="trend" class="flex items-center gap-1 text-xs font-medium mt-1 !text-success-500 dark:!text-success-400"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" data-slot="trendIcon" class="shrink-0 text-primary-500 dark:text-primary-400 !text-success-500 dark:!text-success-400"></svg><span data-slot="trendValue" class="">+12.5%</span></div>
348348
</div>
349349
</div>
350350
<!--v-if-->

test/components/__snapshots__/StatCard.spec.ts.snap

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ exports[`StatCard > renders with all props correctly 1`] = `
88
<div data-slot="title" class="font-medium text-sm text-muted">Users</div>
99
<div data-slot="value" class="font-semibold mt-1 text-3xl text-default">1,234</div>
1010
<!--v-if-->
11-
<div data-slot="trend" class="flex items-center gap-1 text-xs font-medium mt-1 text-success-500 dark:text-success-400"><span class="iconify i-lucide:arrow-up shrink-0 text-primary-500 dark:text-primary-400 text-success-500 dark:text-success-400" aria-hidden="true" data-slot="trendIcon"></span><span data-slot="trendValue" class="">+5.2%</span></div>
11+
<div data-slot="trend" class="flex items-center gap-1 text-xs font-medium mt-1 !text-success-500 dark:!text-success-400"><span class="iconify i-lucide:arrow-up shrink-0 text-primary-500 dark:text-primary-400 !text-success-500 dark:!text-success-400" aria-hidden="true" data-slot="trendIcon"></span><span data-slot="trendValue" class="">+5.2%</span></div>
1212
</div>
1313
</div>
1414
<!--v-if-->
@@ -312,7 +312,7 @@ exports[`StatCard > renders with trend down correctly 1`] = `
312312
<!--v-if-->
313313
<!--v-if-->
314314
<!--v-if-->
315-
<div data-slot="trend" class="flex items-center gap-1 text-xs font-medium mt-1 text-error-500 dark:text-error-400"><span class="iconify i-lucide:arrow-down shrink-0 text-primary-500 dark:text-primary-400 text-error-500 dark:text-error-400" aria-hidden="true" data-slot="trendIcon"></span><span data-slot="trendValue" class="">+8.2%</span></div>
315+
<div data-slot="trend" class="flex items-center gap-1 text-xs font-medium mt-1 !text-error-500 dark:!text-error-400"><span class="iconify i-lucide:arrow-down shrink-0 text-primary-500 dark:text-primary-400 !text-error-500 dark:!text-error-400" aria-hidden="true" data-slot="trendIcon"></span><span data-slot="trendValue" class="">+8.2%</span></div>
316316
</div>
317317
</div>
318318
<!--v-if-->
@@ -344,7 +344,7 @@ exports[`StatCard > renders with trend up correctly 1`] = `
344344
<!--v-if-->
345345
<!--v-if-->
346346
<!--v-if-->
347-
<div data-slot="trend" class="flex items-center gap-1 text-xs font-medium mt-1 text-success-500 dark:text-success-400"><span class="iconify i-lucide:arrow-up shrink-0 text-primary-500 dark:text-primary-400 text-success-500 dark:text-success-400" aria-hidden="true" data-slot="trendIcon"></span><span data-slot="trendValue" class="">+12.5%</span></div>
347+
<div data-slot="trend" class="flex items-center gap-1 text-xs font-medium mt-1 !text-success-500 dark:!text-success-400"><span class="iconify i-lucide:arrow-up shrink-0 text-primary-500 dark:text-primary-400 !text-success-500 dark:!text-success-400" aria-hidden="true" data-slot="trendIcon"></span><span data-slot="trendValue" class="">+12.5%</span></div>
348348
</div>
349349
</div>
350350
<!--v-if-->

0 commit comments

Comments
 (0)