diff --git a/app/components/Chart/SplitSparkline.vue b/app/components/Chart/SplitSparkline.vue new file mode 100644 index 0000000000..f3c5537e8e --- /dev/null +++ b/app/components/Chart/SplitSparkline.vue @@ -0,0 +1,254 @@ + + + + + + + + + + + + + {{ applyEllipsis(dataset?.[i]?.name ?? '', 28) }} + + + + + + {{ $t('package.downloads.sparkline_nav_hint') }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index 21bddcf2f7..005583283e 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -1622,6 +1622,15 @@ watch(selectedMetric, value => { if (!isMounted.value) return loadMetric(value) }) + +// Sparkline charts (a11y alternative display for multi series) +const chartLayout = usePermalink<'combined' | 'split'>('layout', 'combined') +const isSparklineLayout = computed({ + get: () => chartLayout.value === 'split', + set: (v: boolean) => { + chartLayout.value = v ? 'split' : 'combined' + }, +}) @@ -1630,6 +1639,26 @@ watch(selectedMetric, value => { id="trends-chart" :aria-busy="activeMetricState.pending ? 'true' : 'false'" > + + + + {{ $t('package.trends.chart_view_combined') }} + + + {{ $t('package.trends.chart_view_split') }} + + + + { " > - + + + + + +import type { IconClass } from '~/types' + +defineOptions({ name: 'TabItem', inheritAttrs: false }) + +const props = withDefaults( + defineProps<{ + value: string + icon?: IconClass + tabId?: string + variant?: 'primary' | 'secondary' + size?: 'sm' | 'md' + }>(), + { + variant: 'secondary', + size: 'md', + }, +) + +const attrs = useAttrs() + +const selected = inject>('tabs-selected') +const getTabId = inject<(value: string) => string>('tabs-tab-id') +const getPanelId = inject<(value: string) => string>('tabs-panel-id') +if (!selected || !getTabId || !getPanelId) { + throw new Error('TabItem must be used inside a TabRoot component') +} +const isSelected = computed(() => selected.value === props.value) +const resolvedTabId = computed(() => props.tabId ?? getTabId(props.value)) +const resolvedPanelId = computed(() => getPanelId(props.value)) +const select = () => { + selected.value = props.value +} + + + + + + + + + + diff --git a/app/components/Tab/List.vue b/app/components/Tab/List.vue new file mode 100644 index 0000000000..f121ea94d2 --- /dev/null +++ b/app/components/Tab/List.vue @@ -0,0 +1,48 @@ + + + + + + + diff --git a/app/components/Tab/Panel.vue b/app/components/Tab/Panel.vue new file mode 100644 index 0000000000..af38532099 --- /dev/null +++ b/app/components/Tab/Panel.vue @@ -0,0 +1,33 @@ + + + + + + + diff --git a/app/components/Tab/Root.vue b/app/components/Tab/Root.vue new file mode 100644 index 0000000000..e6fbba2915 --- /dev/null +++ b/app/components/Tab/Root.vue @@ -0,0 +1,31 @@ + + + + + + + diff --git a/app/pages/compare.vue b/app/pages/compare.vue index db383e0e03..4d37296971 100644 --- a/app/pages/compare.vue +++ b/app/pages/compare.vue @@ -77,7 +77,7 @@ const columnLoading = computed(() => packages.value.map((_, i) => isColumnLoadin // Check if we have enough packages to compare const canCompare = computed(() => packages.value.length >= 2) -const comparisonView = ref<'table' | 'charts'>('table') +const comparisonView = usePermalink<'table' | 'charts'>('view', 'table') // Extract headers from columns for facet rows const gridHeaders = computed(() => @@ -274,57 +274,50 @@ useSeoMeta({ - - - - {{ $t('compare.packages.table_view') }} - - - - - {{ $t('compare.packages.charts_view') }} - - - - - - - - - + + {{ $t('compare.packages.table_view') }} + + + {{ $t('compare.packages.charts_view') }} + + + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + - + + + - - + + {{ $t('compare.packages.no_chartable_data') }} + + + =3.0.1' vue: '>=3.3.0' @@ -23600,7 +23600,7 @@ snapshots: vue-component-type-helpers@3.2.6: {} - vue-data-ui@3.16.1(vue@3.5.30(typescript@6.0.2)): + vue-data-ui@3.16.5(vue@3.5.30(typescript@6.0.2)): dependencies: vue: 3.5.30(typescript@6.0.2) diff --git a/server/utils/readme.ts b/server/utils/readme.ts index 67345a65cd..b5befe67b7 100644 --- a/server/utils/readme.ts +++ b/server/utils/readme.ts @@ -458,6 +458,17 @@ function renderFrontmatterTable(data: Record): string { return `KeyValue\n${rows}\n\n` } +// Extract and preserve allowed attributes from HTML heading tags +function extractHeadingAttrs(attrsString: string): string { + if (!attrsString) return '' + const preserved: string[] = [] + const alignMatch = /\balign=(["']?)([^"'\s>]+)\1/i.exec(attrsString) + if (alignMatch?.[2]) { + preserved.push(`align="${alignMatch[2]}"`) + } + return preserved.length > 0 ? ` ${preserved.join(' ')}` : '' +} + export async function renderReadmeHtml( content: string, packageName: string, @@ -530,17 +541,6 @@ export async function renderReadmeHtml( return processHeading(depth, displayHtml, plainText, slugSource) } - // Extract and preserve allowed attributes from HTML heading tags - function extractHeadingAttrs(attrsString: string): string { - if (!attrsString) return '' - const preserved: string[] = [] - const alignMatch = /\balign=(["']?)([^"'\s>]+)\1/i.exec(attrsString) - if (alignMatch?.[2]) { - preserved.push(`align="${alignMatch[2]}"`) - } - return preserved.length > 0 ? ` ${preserved.join(' ')}` : '' - } - // Intercept HTML headings so they get id, TOC entry, and correct semantic level. // Also intercept raw HTML tags so playground links are collected in the same pass. const htmlHeadingRe = /]*)?>([\s\S]*?)<\/h\1>/gi diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 35df44b805..36ee338d8b 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -233,6 +233,11 @@ import { PackageSelectionView, PackageSelectionCheckbox, PackageExternalLinks, + ChartSplitSparkline, + TabRoot, + TabList, + TabItem, + TabPanel, } from '#components' // Server variant components must be imported directly to test the server-side render @@ -246,6 +251,7 @@ import FacetBarChart from '~/components/Compare/FacetBarChart.vue' import PackageLikeCard from '~/components/Package/LikeCard.vue' import SizeIncrease from '~/components/Package/SizeIncrease.vue' import Likes from '~/components/Package/Likes.vue' +import type { VueUiXyDatasetItem } from 'vue-data-ui' describe('component accessibility audits', () => { describe('DateTime', () => { @@ -941,6 +947,93 @@ describe('component accessibility audits', () => { }) }) + describe('ChartSplitSparkline', () => { + const dataset = [ + { + color: 'oklch(0.7025 0.132 160.37)', + name: 'vue', + series: [100_000, 200_000, 150_000], + type: 'line', + dashIndices: [], + }, + { + color: 'oklch(0.6917 0.1865 35.04)', + name: 'svelte', + series: [100_000, 200_000, 150_000], + type: 'line', + dashIndices: [], + }, + ] as Array< + VueUiXyDatasetItem & { + color?: string + series: number[] + dashIndices?: number[] + } + > + const dates = [1743465600000, 1744070400000, 1744675200000] + const datetimeFormatterOptions = { + year: 'yyyy-MM-dd', + month: 'yyyy-MM-dd', + day: 'yyyy-MM-dd', + } + + it('should have no accessibility violations', async () => { + const component = await mountSuspended(ChartSplitSparkline, { + props: { + dataset, + dates, + datetimeFormatterOptions, + showLastDatapointEstimation: false, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations when empty', async () => { + const component = await mountSuspended(ChartSplitSparkline, { + props: { + dataset: [], + dates: [], + datetimeFormatterOptions, + showLastDatapointEstimation: false, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + + describe('TabRoot + TabList + TabItem + TabPanel', () => { + function createTabsFixture(modelValue: string, idPrefix: string) { + return defineComponent({ + setup() { + return () => + h(TabRoot, { modelValue, idPrefix }, () => [ + h(TabList, { ariaLabel: 'Test tabs' }, () => [ + h(TabItem, { value: 'first' }, () => 'First'), + h(TabItem, { value: 'second' }, () => 'Second'), + ]), + h(TabPanel, { value: 'first' }, () => 'First content'), + h(TabPanel, { value: 'second' }, () => 'Second content'), + ]) + }, + }) + } + + it('should have no accessibility violations', async () => { + const component = await mountSuspended(createTabsFixture('first', 'a11y-test')) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with second tab selected', async () => { + const component = await mountSuspended(createTabsFixture('second', 'a11y-test2')) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + describe('PackagePlaygrounds', () => { it('should have no accessibility violations with single link', async () => { const links = [ diff --git a/test/nuxt/components/Tab.spec.ts b/test/nuxt/components/Tab.spec.ts new file mode 100644 index 0000000000..3af2882735 --- /dev/null +++ b/test/nuxt/components/Tab.spec.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from 'vitest' +import { mountSuspended } from '@nuxt/test-utils/runtime' +import { defineComponent, h } from 'vue' +import { TabRoot, TabList, TabItem, TabPanel } from '#components' + +function createTabsWrapper(props: { modelValue: string; idPrefix?: string }) { + return defineComponent({ + components: { TabRoot, TabList, TabItem, TabPanel }, + setup() { + return () => + h( + TabRoot, + { + 'modelValue': props.modelValue, + 'idPrefix': props.idPrefix ?? 'test', + 'onUpdate:modelValue': () => {}, + }, + () => [ + h(TabList, { ariaLabel: 'Test tabs' }, () => [ + h(TabItem, { value: 'one' }, () => 'One'), + h(TabItem, { value: 'two' }, () => 'Two'), + h(TabItem, { value: 'three' }, () => 'Three'), + ]), + h(TabPanel, { value: 'one' }, () => 'Content one'), + h(TabPanel, { value: 'two' }, () => 'Content two'), + h(TabPanel, { value: 'three' }, () => 'Content three'), + ], + ) + }, + }) +} + +async function mountTabs({ modelValue = 'one', idPrefix = 'test' } = {}) { + const Wrapper = createTabsWrapper({ modelValue, idPrefix }) + return mountSuspended(Wrapper, { attachTo: document.body }) +} + +describe('Tab components', () => { + describe('TabRoot', () => { + it('renders tablist', async () => { + const wrapper = await mountTabs() + expect(wrapper.find('[role="tablist"]').exists()).toBe(true) + wrapper.unmount() + }) + + it('provides selected value to children', async () => { + const wrapper = await mountTabs({ modelValue: 'two' }) + const tabs = wrapper.findAll('[role="tab"]') + const selected = tabs.find(t => t.attributes('aria-selected') === 'true') + expect(selected?.text()).toBe('Two') + wrapper.unmount() + }) + }) + + describe('TabList', () => { + it('has tablist role and aria-label', async () => { + const wrapper = await mountTabs() + const tablist = wrapper.find('[role="tablist"]') + expect(tablist.exists()).toBe(true) + expect(tablist.attributes('aria-label')).toBe('Test tabs') + wrapper.unmount() + }) + + it('supports arrow key navigation', async () => { + const wrapper = await mountTabs() + const tablist = wrapper.find('[role="tablist"]') + const tabs = wrapper.findAll('[role="tab"]') + + ;(tabs[0]!.element as HTMLElement).focus() + expect(document.activeElement).toBe(tabs[0]!.element) + + await tablist.trigger('keydown', { key: 'ArrowRight' }) + expect(document.activeElement).toBe(tabs[1]!.element) + + // Wraps around + await tablist.trigger('keydown', { key: 'ArrowRight' }) + await tablist.trigger('keydown', { key: 'ArrowRight' }) + expect(document.activeElement).toBe(tabs[0]!.element) + + // ArrowLeft wraps backwards + await tablist.trigger('keydown', { key: 'ArrowLeft' }) + expect(document.activeElement).toBe(tabs[2]!.element) + + wrapper.unmount() + }) + + it('supports Home and End keys', async () => { + const wrapper = await mountTabs() + const tablist = wrapper.find('[role="tablist"]') + const tabs = wrapper.findAll('[role="tab"]') + + ;(tabs[1]!.element as HTMLElement).focus() + + await tablist.trigger('keydown', { key: 'Home' }) + expect(document.activeElement).toBe(tabs[0]!.element) + + await tablist.trigger('keydown', { key: 'End' }) + expect(document.activeElement).toBe(tabs[2]!.element) + + wrapper.unmount() + }) + }) + + describe('TabItem', () => { + it('has correct ARIA attributes when selected', async () => { + const wrapper = await mountTabs({ modelValue: 'one' }) + const tabs = wrapper.findAll('[role="tab"]') + const first = tabs[0]! + const second = tabs[1]! + + expect(first.attributes('aria-selected')).toBe('true') + expect(first.attributes('tabindex')).toBe('-1') + expect(first.attributes('data-selected')).toBeDefined() + + expect(second.attributes('aria-selected')).toBe('false') + expect(second.attributes('tabindex')).toBe('0') + expect(second.attributes('data-selected')).toBeUndefined() + + wrapper.unmount() + }) + + it('emits update:modelValue on click', async () => { + const wrapper = await mountTabs({ modelValue: 'one' }) + const tabs = wrapper.findAll('[role="tab"]') + + await tabs[1]!.trigger('click') + const root = wrapper.findComponent(TabRoot) + expect(root.emitted('update:modelValue')?.[0]).toEqual(['two']) + + wrapper.unmount() + }) + + it('generates aria-controls pointing to panel id', async () => { + const wrapper = await mountTabs({ idPrefix: 'my-tabs' }) + const tab = wrapper.findAll('[role="tab"]')[0]! + expect(tab.attributes('aria-controls')).toBe('my-tabs-panel-one') + wrapper.unmount() + }) + }) + + describe('TabPanel', () => { + it('shows panel matching selected value', async () => { + const wrapper = await mountTabs({ modelValue: 'one' }) + const panels = wrapper.findAll('[role="tabpanel"]') + + const visible = panels.filter(p => (p.element as HTMLElement).style.display !== 'none') + expect(visible).toHaveLength(1) + expect(visible[0]!.text()).toBe('Content one') + + wrapper.unmount() + }) + + it('hides panels not matching selected value', async () => { + const wrapper = await mountTabs({ modelValue: 'two' }) + const panels = wrapper.findAll('[role="tabpanel"]') + + const visible = panels.filter(p => (p.element as HTMLElement).style.display !== 'none') + expect(visible).toHaveLength(1) + expect(visible[0]!.text()).toBe('Content two') + + wrapper.unmount() + }) + + it('has aria-labelledby pointing to tab id', async () => { + const wrapper = await mountTabs({ idPrefix: 'demo' }) + const panel = wrapper.findAll('[role="tabpanel"]')[0]! + expect(panel.attributes('aria-labelledby')).toBe('demo-one') + wrapper.unmount() + }) + }) +}) diff --git a/test/unit/app/utils/versions.spec.ts b/test/unit/app/utils/versions.spec.ts index 2108d4f265..47abe40825 100644 --- a/test/unit/app/utils/versions.spec.ts +++ b/test/unit/app/utils/versions.spec.ts @@ -425,11 +425,11 @@ describe('isSameVersionGroup', () => { }) }) -describe('compareTagRows', () => { - function row(version: string, tags: string[]) { - return { id: `version:${version}`, primaryTag: tags[0]!, tags, version } - } +function row(version: string, tags: string[]) { + return { id: `version:${version}`, primaryTag: tags[0]!, tags, version } +} +describe('compareTagRows', () => { it('sorts by tag priority ascending (rc before beta)', () => { const rc = row('2.0.0-rc.1', ['rc']) const beta = row('2.0.0-beta.1', ['beta'])
+ {{ $t('package.downloads.sparkline_nav_hint') }} +
+ {{ $t('compare.packages.no_chartable_data') }} +