Skip to content

Commit 91bd41d

Browse files
committed
fix(svg): add aria attributes and desc to pulse badge SVG functions
1 parent e3369ea commit 91bd41d

2 files changed

Lines changed: 121 additions & 2 deletions

File tree

lib/svg/generator.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
generateNotFoundSVG,
66
generateRateLimitSVG,
77
generateHeatmapSVG,
8+
generatePulseSVG,
89
particleCount,
910
escapeXML,
1011
getSizeScale,
@@ -1778,3 +1779,111 @@ describe('buildTowerPaths', () => {
17781779
expect(paths.top).toBe('M0 -9 L7.2 -4.5 L0 0 L-7.2 -4.5 Z');
17791780
});
17801781
});
1782+
1783+
// ── generatePulseSVG accessibility ─────────────────────────────────────────
1784+
1785+
describe('generatePulseSVG accessibility', () => {
1786+
const mockStats: StreakStats = {
1787+
currentStreak: 3,
1788+
longestStreak: 7,
1789+
totalContributions: 50,
1790+
todayDate: '2024-06-12',
1791+
};
1792+
1793+
// Build 5 weeks × 6 days = 30 days of deterministic contribution data
1794+
const weeks: ContributionCalendar['weeks'] = [];
1795+
for (let w = 0; w < 5; w++) {
1796+
const contributionDays = [];
1797+
for (let d = 0; d < 6; d++) {
1798+
const idx = w * 6 + d;
1799+
contributionDays.push({
1800+
contributionCount: idx % 5,
1801+
date: `2024-05-${String(idx + 1).padStart(2, '0')}`,
1802+
});
1803+
}
1804+
weeks.push({ contributionDays } as ContributionCalendar['weeks'][number]);
1805+
}
1806+
const mockCalendar = { weeks } as ContributionCalendar;
1807+
1808+
const baseParams: BadgeParams = {
1809+
user: 'octocat',
1810+
} as unknown as BadgeParams;
1811+
1812+
it('includes role="img" on the root svg', () => {
1813+
const svg = generatePulseSVG(mockStats, baseParams, mockCalendar);
1814+
expect(svg).toContain('role="img"');
1815+
});
1816+
1817+
it('includes aria-labelledby referencing cp-title-<safeId>', () => {
1818+
const svg = generatePulseSVG(mockStats, baseParams, mockCalendar);
1819+
expect(svg).toContain('aria-labelledby="cp-title-octocat"');
1820+
});
1821+
1822+
it('includes aria-describedby referencing cp-desc-<safeId>', () => {
1823+
const svg = generatePulseSVG(mockStats, baseParams, mockCalendar);
1824+
expect(svg).toContain('aria-describedby="cp-desc-octocat"');
1825+
});
1826+
1827+
it('includes <title> with the correct id attribute', () => {
1828+
const svg = generatePulseSVG(mockStats, baseParams, mockCalendar);
1829+
expect(svg).toContain('<title id="cp-title-octocat">Heartbeat Sparkline for octocat</title>');
1830+
});
1831+
1832+
it('includes <desc> with the correct id attribute and pulseTotal in its text', () => {
1833+
const svg = generatePulseSVG(mockStats, baseParams, mockCalendar);
1834+
expect(svg).toContain('<desc id="cp-desc-octocat">');
1835+
expect(svg).toContain('showing commit activity over the last 30 days');
1836+
expect(svg).toMatch(/total commits: \d+/);
1837+
});
1838+
1839+
it('sanitizes special characters in username for the safeId', () => {
1840+
const svg = generatePulseSVG(
1841+
mockStats,
1842+
{ ...baseParams, user: 'user.name+test' } as unknown as BadgeParams,
1843+
mockCalendar
1844+
);
1845+
expect(svg).toContain('aria-labelledby="cp-title-user_name_test"');
1846+
expect(svg).toContain('aria-describedby="cp-desc-user_name_test"');
1847+
expect(svg).toContain('id="cp-title-user_name_test"');
1848+
expect(svg).toContain('id="cp-desc-user_name_test"');
1849+
});
1850+
1851+
it('produces valid SVG markup (no parsererror)', () => {
1852+
const svg = generatePulseSVG(mockStats, baseParams, mockCalendar);
1853+
assertValidSVG(svg);
1854+
});
1855+
1856+
describe('auto-theme variant', () => {
1857+
const autoParams: BadgeParams = {
1858+
user: 'octocat',
1859+
autoTheme: true,
1860+
} as unknown as BadgeParams;
1861+
1862+
it('includes aria-labelledby in auto-theme output', () => {
1863+
const svg = generatePulseSVG(mockStats, autoParams, mockCalendar);
1864+
expect(svg).toContain('aria-labelledby="cp-title-octocat"');
1865+
});
1866+
1867+
it('includes aria-describedby in auto-theme output', () => {
1868+
const svg = generatePulseSVG(mockStats, autoParams, mockCalendar);
1869+
expect(svg).toContain('aria-describedby="cp-desc-octocat"');
1870+
});
1871+
1872+
it('includes id-bearing <title> in auto-theme output', () => {
1873+
const svg = generatePulseSVG(mockStats, autoParams, mockCalendar);
1874+
expect(svg).toContain('<title id="cp-title-octocat">Heartbeat Sparkline for octocat</title>');
1875+
});
1876+
1877+
it('includes id-bearing <desc> with pulseTotal in auto-theme output', () => {
1878+
const svg = generatePulseSVG(mockStats, autoParams, mockCalendar);
1879+
expect(svg).toContain('<desc id="cp-desc-octocat">');
1880+
expect(svg).toContain('showing commit activity over the last 30 days');
1881+
expect(svg).toMatch(/total commits: \d+/);
1882+
});
1883+
1884+
it('produces valid SVG markup in auto-theme mode (no parsererror)', () => {
1885+
const svg = generatePulseSVG(mockStats, autoParams, mockCalendar);
1886+
assertValidSVG(svg);
1887+
});
1888+
});
1889+
});

lib/svg/generator.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2290,6 +2290,8 @@ export function generatePulseSVG(
22902290
const lastNormalized = (lastCount - minCount) / range;
22912291
const lastY = paddingYTop + graphHeight - lastNormalized * graphHeight;
22922292

2293+
const safeId = safeUser.replace(/[^a-zA-Z0-9-]/g, '_').toLowerCase();
2294+
22932295
return `
22942296
<svg
22952297
xmlns="http://www.w3.org/2000/svg"
@@ -2298,8 +2300,11 @@ export function generatePulseSVG(
22982300
viewBox="0 0 ${width} ${height}"
22992301
fill="none"
23002302
role="img"
2303+
aria-labelledby="cp-title-${safeId}"
2304+
aria-describedby="cp-desc-${safeId}"
23012305
>
2302-
<title>Heartbeat Sparkline for ${safeUser}</title>
2306+
<title id="cp-title-${safeId}">Heartbeat Sparkline for ${safeUser}</title>
2307+
<desc id="cp-desc-${safeId}">Heartbeat sparkline for ${safeUser} showing commit activity over the last 30 days (total commits: ${pulseTotal}).</desc>
23032308
<style>
23042309
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&amp;family=JetBrains+Mono&amp;family=Roboto&amp;family=Syncopate:wght@400;700&amp;family=Space+Grotesk:wght@400;500;600;700&amp;display=swap');
23052310
${googleFontsImport}
@@ -2469,6 +2474,8 @@ function generateAutoThemePulseSVG(
24692474
const lastNormalized = (lastCount - minCount) / range;
24702475
const lastY = paddingYTop + graphHeight - lastNormalized * graphHeight;
24712476

2477+
const safeId = safeUser.replace(/[^a-zA-Z0-9-]/g, '_').toLowerCase();
2478+
24722479
return `
24732480
<svg
24742481
xmlns="http://www.w3.org/2000/svg"
@@ -2477,8 +2484,11 @@ function generateAutoThemePulseSVG(
24772484
viewBox="0 0 ${width} ${height}"
24782485
fill="none"
24792486
role="img"
2487+
aria-labelledby="cp-title-${safeId}"
2488+
aria-describedby="cp-desc-${safeId}"
24802489
>
2481-
<title>Heartbeat Sparkline for ${safeUser}</title>
2490+
<title id="cp-title-${safeId}">Heartbeat Sparkline for ${safeUser}</title>
2491+
<desc id="cp-desc-${safeId}">Heartbeat sparkline for ${safeUser} showing commit activity over the last 30 days (total commits: ${pulseTotal}).</desc>
24822492
<style>
24832493
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&amp;family=JetBrains+Mono&amp;family=Roboto&amp;family=Syncopate:wght@400;700&amp;family=Space+Grotesk:wght@400;500;600;700&amp;display=swap');
24842494
${googleFontsImport}

0 commit comments

Comments
 (0)