Skip to content

Commit f716beb

Browse files
committed
feat: create split parklines component
1 parent 834aebb commit f716beb

File tree

2 files changed

+280
-0
lines changed

2 files changed

+280
-0
lines changed
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
<script setup lang="ts">
2+
import { VueUiSparkline } from 'vue-data-ui/vue-ui-sparkline'
3+
import { useCssVariables } from '~/composables/useColors'
4+
import { getPalette, type VueUiXyDatasetItem } from "vue-data-ui";
5+
import type { VueUiSparklineConfig, VueUiSparklineDatasetItem } from 'vue-data-ui'
6+
7+
import('vue-data-ui/style.css')
8+
9+
const props = defineProps<{
10+
dataset?: Array<VueUiXyDatasetItem & {
11+
color?: string
12+
series: number[]
13+
dashIndices?: number[]
14+
}>
15+
dates: number[],
16+
datetimeFormatterOptions: {
17+
year: string,
18+
month: string,
19+
day: string
20+
}
21+
}>()
22+
23+
const { locale } = useI18n()
24+
const colorMode = useColorMode()
25+
const resolvedMode = shallowRef<'light' | 'dark'>('light')
26+
const rootEl = shallowRef<HTMLElement | null>(null)
27+
const palette = getPalette('')
28+
29+
const step = ref(0)
30+
31+
onMounted(() => {
32+
rootEl.value = document.documentElement
33+
resolvedMode.value = colorMode.value === 'dark' ? 'dark' : 'light'
34+
})
35+
36+
watch(
37+
() => colorMode.value,
38+
value => {
39+
resolvedMode.value = value === 'dark' ? 'dark' : 'light'
40+
},
41+
{ flush: 'sync' },
42+
)
43+
44+
const { colors } = useCssVariables(
45+
[
46+
'--bg',
47+
'--fg',
48+
'--bg-subtle',
49+
'--bg-elevated',
50+
'--border-hover',
51+
'--fg-subtle',
52+
'--border',
53+
'--border-subtle',
54+
],
55+
{
56+
element: rootEl,
57+
watchHtmlAttributes: true,
58+
watchResize: false, // set to true only if a var changes color on resize
59+
},
60+
)
61+
62+
const isDarkMode = computed(() => resolvedMode.value === 'dark')
63+
64+
const datasets = computed<VueUiSparklineDatasetItem[][]>(() => {
65+
return (props.dataset ?? []).map((unit) => {
66+
return props.dates.map((period, i) => {
67+
return {
68+
period,
69+
value: unit.series[i] ?? 0,
70+
}
71+
})
72+
})
73+
})
74+
75+
const selectedIndex = ref<number | undefined | null>(null)
76+
77+
function hoverIndex({ index }: { index: number | undefined | null }) {
78+
if (typeof index === 'number') {
79+
selectedIndex.value = index
80+
}
81+
}
82+
83+
function resetHover() {
84+
selectedIndex.value = null
85+
step.value += 1 // required to reset all chart instances
86+
}
87+
88+
const configs = computed(() => {
89+
return (props.dataset || []).map<VueUiSparklineConfig>((unit, i) => {
90+
return {
91+
a11y: {
92+
translations: {
93+
keyboardNavigation: $t(
94+
'package.trends.chart_assistive_text.keyboard_navigation_horizontal',
95+
),
96+
tableAvailable: $t('package.trends.chart_assistive_text.table_available'),
97+
tableCaption: $t('package.trends.chart_assistive_text.table_caption'),
98+
},
99+
},
100+
theme: isDarkMode.value ? 'dark' : '',
101+
skeletonConfig: {
102+
style: {
103+
backgroundColor: 'transparent',
104+
dataLabel: {
105+
show: true,
106+
color: 'transparent',
107+
},
108+
area: {
109+
color: colors.value.borderHover,
110+
useGradient: false,
111+
opacity: 10,
112+
},
113+
line: {
114+
color: colors.value.borderHover,
115+
},
116+
},
117+
},
118+
skeletonDataset: Array.from({ length: unit.series.length }, () => 0),
119+
style: {
120+
backgroundColor: 'transparent',
121+
animation: { show: false },
122+
area: {
123+
color: colors.value.borderHover,
124+
useGradient: false,
125+
opacity: 10,
126+
},
127+
dataLabel: {
128+
offsetX: -12,
129+
fontSize: 24,
130+
bold: false,
131+
color: colors.value.fg,
132+
datetimeFormatter: {
133+
enable: true,
134+
locale: locale.value,
135+
useUTC: true,
136+
options: props.datetimeFormatterOptions,
137+
},
138+
},
139+
line: {
140+
color: unit.color ?? palette[i],
141+
},
142+
plot: {
143+
radius: 6,
144+
stroke: isDarkMode.value ? 'oklch(0.985 0 0)' : 'oklch(0.145 0 0)',
145+
},
146+
title: {
147+
fontSize: 12,
148+
color: colors.value.fgSubtle,
149+
bold: false,
150+
},
151+
152+
verticalIndicator: {
153+
strokeDasharray: 5,
154+
color: colors.value.fgSubtle,
155+
},
156+
padding: {
157+
left: 0,
158+
right: 0,
159+
top: 0,
160+
bottom: 0,
161+
},
162+
},
163+
}
164+
})
165+
})
166+
</script>
167+
168+
<template>
169+
<div>
170+
<div class="grid gap-8 sm:grid-cols-2">
171+
<ClientOnly v-for="(config, i) in configs" :key="`config_${i}`">
172+
<div @mouseleave="resetHover" class="w-full max-w-[400px] mx-auto">
173+
<div class="flex gap-2 place-items-center">
174+
<div class="h-3 w-3">
175+
<svg viewBox="0 0 2 2" class="w-full">
176+
<rect x="0" y="0" width="2" height="2" rx="0.3" :fill="dataset?.[i]?.color ?? palette[i]" />
177+
</svg>
178+
</div>
179+
{{ applyEllipsis(dataset?.[i]?.name ?? '', 28) }}
180+
</div>
181+
<VueUiSparkline
182+
:key="`${i}_${step}`"
183+
:config
184+
:dataset="datasets?.[i]"
185+
:selectedIndex
186+
@hoverIndex="hoverIndex"
187+
>
188+
<!-- Keyboard navigation hint -->
189+
<template #hint="{ isVisible }">
190+
<p v-if="isVisible" class="text-accent text-xs text-center mt-2" aria-hidden="true">
191+
{{ $t('package.downloads.sparkline_nav_hint') }}
192+
</p>
193+
</template>
194+
195+
<template #skeleton>
196+
<!-- This empty div overrides the default built-in scanning animation on load -->
197+
<div />
198+
</template>
199+
</VueUiSparkline>
200+
</div>
201+
202+
<template #fallback>
203+
<!-- Skeleton matching VueUiSparkline layout (title 24px + SVG aspect 500:80) -->
204+
<div class="max-w-xs">
205+
<!-- Title row: fontSize * 2 = 24px -->
206+
<div class="h-6 flex items-center ps-3">
207+
<SkeletonInline class="h-3 w-36" />
208+
</div>
209+
<!-- Chart area: matches SVG viewBox 500:80 -->
210+
<div class="aspect-[500/80] flex items-center">
211+
<!-- Data label (covers ~42% width, matching dataLabel.offsetX) -->
212+
<div class="w-[42%] flex items-center ps-0.5">
213+
<SkeletonInline class="h-7 w-24" />
214+
</div>
215+
<!-- Sparkline line placeholder -->
216+
<div class="flex-1 flex items-end pe-3">
217+
<SkeletonInline class="h-px w-full" />
218+
</div>
219+
</div>
220+
</div>
221+
</template>
222+
</ClientOnly>
223+
</div>
224+
</div>
225+
</template>

test/nuxt/a11y.spec.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ import {
233233
PackageSelectionView,
234234
PackageSelectionCheckbox,
235235
PackageExternalLinks,
236+
ChartSplitSparkline,
236237
} from '#components'
237238

238239
// Server variant components must be imported directly to test the server-side render
@@ -246,6 +247,7 @@ import FacetBarChart from '~/components/Compare/FacetBarChart.vue'
246247
import PackageLikeCard from '~/components/Package/LikeCard.vue'
247248
import SizeIncrease from '~/components/Package/SizeIncrease.vue'
248249
import Likes from '~/components/Package/Likes.vue'
250+
import type { VueUiXyDatasetItem } from 'vue-data-ui'
249251

250252
describe('component accessibility audits', () => {
251253
describe('DateTime', () => {
@@ -941,6 +943,59 @@ describe('component accessibility audits', () => {
941943
})
942944
})
943945

946+
describe('ChartSplitSparkline', () => {
947+
const dataset = [
948+
{
949+
color: 'oklch(0.7025 0.132 160.37)',
950+
name: 'vue',
951+
series: [100_000, 200_000, 150_000],
952+
type: 'line',
953+
dashIndices: []
954+
},
955+
{
956+
color: 'oklch(0.6917 0.1865 35.04)',
957+
name: 'svelte',
958+
series: [100_000, 200_000, 150_000],
959+
type: 'line',
960+
dashIndices: []
961+
}
962+
] as Array<VueUiXyDatasetItem & {
963+
color?: string
964+
series: number[]
965+
dashIndices?: number[]
966+
}>
967+
const dates = [1743465600000, 1744070400000, 1744675200000]
968+
const datetimeFormatterOptions = {
969+
year: 'yyyy-MM-dd',
970+
month: 'yyyy-MM-dd',
971+
day: 'yyyy-MM-dd'
972+
}
973+
974+
it('should have no accessibility violations', async () => {
975+
const component = await mountSuspended(ChartSplitSparkline, {
976+
props: {
977+
dataset,
978+
dates,
979+
datetimeFormatterOptions
980+
}
981+
})
982+
const results = await runAxe(component)
983+
expect(results.violations).toEqual([])
984+
})
985+
986+
it('should have no accessibility violations when empty', async () => {
987+
const component = await mountSuspended(ChartSplitSparkline, {
988+
props: {
989+
dataset: [],
990+
dates: [],
991+
datetimeFormatterOptions
992+
}
993+
})
994+
const results = await runAxe(component)
995+
expect(results.violations).toEqual([])
996+
})
997+
})
998+
944999
describe('PackagePlaygrounds', () => {
9451000
it('should have no accessibility violations with single link', async () => {
9461001
const links = [

0 commit comments

Comments
 (0)