Skip to content

Commit 44fffff

Browse files
authored
Merge branch 'npmx-dev:main' into feat/changelog-1
2 parents 3a86ab8 + 873562d commit 44fffff

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

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

app/components/Compare/FacetSelector.vue

Lines changed: 61 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script setup lang="ts">
2+
import { debounce } from 'perfect-debounce'
23
const {
34
isFacetSelected,
45
toggleFacet,
@@ -22,61 +23,94 @@ function isCategoryNoneSelected(category: string): boolean {
2223
const selectableFacets = facets.filter(f => !f.comingSoon)
2324
return selectableFacets.length > 0 && selectableFacets.every(f => !isFacetSelected(f.id))
2425
}
26+
27+
const liveRegionText = ref('')
28+
const clearLiveRegion = debounce(() => {
29+
liveRegionText.value = ''
30+
}, 250)
31+
const updateLiveRegion = debounce((message: string) => {
32+
liveRegionText.value = message
33+
clearLiveRegion()
34+
}, 250)
35+
36+
function selectAllFacet(category: string) {
37+
if (!isCategoryAllSelected(category)) {
38+
updateLiveRegion($t('compare.facets.selected_all_category_facets', { category }))
39+
selectCategory(category)
40+
}
41+
}
42+
43+
function deselectAllFacet(category: string) {
44+
if (!isCategoryNoneSelected(category)) {
45+
updateLiveRegion($t('compare.facets.deselected_all_category_facets', { category }))
46+
deselectCategory(category)
47+
}
48+
}
2549
</script>
2650

2751
<template>
28-
<div class="space-y-3" role="group" :aria-label="$t('compare.facets.group_label')">
52+
<div role="status" aria-live="polite" class="sr-only">{{ liveRegionText }}</div>
53+
<div class="space-y-3">
2954
<div v-for="category in categoryOrder" :key="category">
30-
<!-- Category header with all/none buttons -->
3155
<div class="flex items-center gap-2 mb-2">
32-
<span class="text-3xs text-fg-subtle uppercase tracking-wider">
56+
<span
57+
:id="`facet-category-label-${category}`"
58+
class="text-3xs text-fg-subtle uppercase tracking-wider"
59+
>
3360
{{ getCategoryLabel(category) }}
3461
</span>
35-
<!-- TODO: These should be radios, since they are mutually exclusive, and currently this behavior is faked with buttons -->
62+
3663
<ButtonBase
64+
size="sm"
65+
data-facet-category-action="all"
66+
:data-facet-category="category"
3767
:aria-label="
38-
$t('compare.facets.select_category', { category: getCategoryLabel(category) })
68+
$t('compare.facets.select_all_category_facets', {
69+
category: getCategoryLabel(category),
70+
})
3971
"
40-
:aria-pressed="isCategoryAllSelected(category)"
41-
:disabled="isCategoryAllSelected(category)"
42-
@click="selectCategory(category)"
43-
size="sm"
72+
:aria-disabled="isCategoryAllSelected(category)"
73+
class="aria-disabled:(opacity-40 border-transparent)"
74+
@click="selectAllFacet(category)"
4475
>
4576
{{ $t('compare.facets.all') }}
4677
</ButtonBase>
47-
<span class="text-2xs text-fg-muted/40">/</span>
78+
79+
<span class="text-2xs text-fg-muted/40" aria-hidden="true">/</span>
80+
4881
<ButtonBase
82+
size="sm"
83+
data-facet-category-action="none"
84+
:data-facet-category="category"
4985
:aria-label="
50-
$t('compare.facets.deselect_category', { category: getCategoryLabel(category) })
86+
$t('compare.facets.deselect_all_category_facets', {
87+
category: getCategoryLabel(category),
88+
})
5189
"
52-
:aria-pressed="isCategoryNoneSelected(category)"
53-
:disabled="isCategoryNoneSelected(category)"
54-
@click="deselectCategory(category)"
55-
size="sm"
90+
:aria-disabled="isCategoryNoneSelected(category)"
91+
class="aria-disabled:(opacity-40 border-transparent)"
92+
@click="deselectAllFacet(category)"
5693
>
5794
{{ $t('compare.facets.none') }}
5895
</ButtonBase>
5996
</div>
6097

61-
<!-- Facet buttons -->
62-
<div class="flex items-center gap-1.5 flex-wrap" role="group">
63-
<!-- TODO: These should be checkboxes -->
98+
<div
99+
class="flex items-center gap-1.5 flex-wrap"
100+
role="group"
101+
:aria-labelledby="`facet-category-label-${category}`"
102+
data-facet-category-facets
103+
>
64104
<ButtonBase
65105
v-for="facet in facetsByCategory[category]"
66106
:key="facet.id"
67107
size="sm"
108+
role="checkbox"
68109
:title="facet.comingSoon ? $t('compare.facets.coming_soon') : facet.description"
69110
:disabled="facet.comingSoon"
70-
:aria-pressed="isFacetSelected(facet.id)"
111+
:aria-checked="isFacetSelected(facet.id)"
71112
:aria-label="facet.label"
72-
class="gap-1 px-1.5 rounded transition-colors focus-visible:outline-accent/70"
73-
:class="
74-
facet.comingSoon
75-
? 'text-fg-subtle/50 bg-bg-subtle border-border-subtle cursor-not-allowed'
76-
: isFacetSelected(facet.id)
77-
? 'text-fg-muted bg-bg-muted'
78-
: 'text-fg-subtle bg-bg-subtle border-border-subtle hover:text-fg-muted hover:border-border'
79-
"
113+
class="gap-1 px-1.5 rounded transition-colors text-fg-subtle bg-bg-subtle border-border-subtle enabled:hover:(text-fg-muted border-border) aria-checked:(text-fg-muted bg-fg/10 border-fg/20 hover:enabled:(bg-fg/20 text-fg/50)) focus-visible:outline-accent/70 disabled:(text-fg-subtle/50 bg-bg-subtle border-border-subtle)"
80114
@click="!facet.comingSoon && toggleFacet(facet.id)"
81115
:classicon="
82116
facet.comingSoon

app/components/Compare/PackageSelector.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const props = defineProps<{
88
max?: number
99
}>()
1010
11-
const maxPackages = computed(() => props.max ?? 4)
11+
const maxPackages = computed(() => props.max ?? MAX_PACKAGE_SELECTION)
1212
1313
// Input state
1414
const inputValue = shallowRef('')

0 commit comments

Comments
 (0)