Skip to content

Commit 7a1ae5e

Browse files
committed
Add e2e test and sample page for Icon component
1 parent c8a0cf9 commit 7a1ae5e

8 files changed

Lines changed: 205 additions & 11 deletions

File tree

File renamed without changes.

src/components/Icon/constants.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export const colorClasses = {
2+
default: 'text-text fill-text',
3+
muted: 'text-text-offset fill-text-offset',
4+
primary: 'text-primary fill-primary',
5+
accent: 'text-accent fill-accent',
6+
info: 'text-info fill-info',
7+
success: 'text-success fill-success',
8+
warning: 'text-warning fill-warning',
9+
danger: 'text-danger fill-danger',
10+
inherit: 'text-inherit fill-inherit',
11+
custom: '',
12+
} as const
13+
14+
export type IconVariant = keyof typeof colorClasses

src/components/Icon/index.astro

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,44 @@
44
* Refactored to use Tailwind classes instead of CSS classes.
55
*/
66
import { Icon } from 'astro-icon/components'
7+
import { colorClasses, type IconVariant } from './constants'
8+
9+
export { colorClasses, type IconVariant } from './constants'
710
811
export interface Props {
912
class?: string
1013
name: string
1114
size?: number | string
1215
/** Color variant for the icon */
13-
variant?: 'default' | 'muted' | 'success' | 'warning' | 'inherit' | 'custom'
16+
variant?: IconVariant
1417
}
1518
16-
const { class: className, name, size = 24, variant = 'default' } = Astro.props
19+
const { class: className, name, size = 24, variant = 'default' as IconVariant } = Astro.props
20+
const iconTitle = formatIconTitle(name)
21+
22+
function formatIconTitle(value: string): string {
23+
const normalizedValue = value
24+
.replace(/[-_]+/g, ' ')
25+
.replace(/\s+/g, ' ')
26+
.trim()
27+
28+
if (!normalizedValue.length) {
29+
return 'Icon'
30+
}
31+
32+
const formattedValue = normalizedValue
33+
.split(' ')
34+
.map(segment => segment.charAt(0).toUpperCase() + segment.slice(1))
35+
.join(' ')
1736
18-
// Map color variants to Tailwind classes using CSS custom properties
19-
const colorClasses = {
20-
default: 'text-icons fill-icons',
21-
muted: 'text-text-muted fill-text-muted',
22-
success: 'text-success fill-success',
23-
warning: 'text-warning fill-warning',
24-
inherit: 'text-inherit fill-inherit',
25-
custom: '',
37+
return `${formattedValue} icon`
2638
}
2739
---
2840

2941
<Icon
3042
name={name}
3143
class:list={['inline-block align-middle', colorClasses[variant], `icon--${name}`, className]}
3244
size={size}
45+
title={iconTitle}
3346
aria-hidden="true"
3447
/>

src/icons/pause.svg

Lines changed: 4 additions & 0 deletions
Loading

src/icons/play.svg

Lines changed: 4 additions & 0 deletions
Loading

src/pages/testing/icons.astro

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
import Icon, { colorClasses } from '@components/Icon/index.astro'
3+
import type { IconVariant } from '@components/Icon/index.astro'
4+
import BaseLayout from '@layouts/BaseLayout.astro'
5+
6+
const variantNames = Object.keys(colorClasses) as IconVariant[]
7+
const iconModules = import.meta.glob('../../icons/*.svg')
8+
const iconNames = Object.keys(iconModules)
9+
.map(path => path.split('/').pop()?.replace('.svg', ''))
10+
.filter((name): name is string => Boolean(name))
11+
.sort()
12+
13+
const pageTitle = 'Icon Variant Gallery'
14+
const description = 'Preview every icon across each color variant to verify styling and availability.'
15+
---
16+
17+
<BaseLayout pageTitle={pageTitle} path="testing/icons" description={description}>
18+
<main class="mx-auto flex max-w-7xl flex-col gap-12 px-6 py-12">
19+
<header class="text-center">
20+
<p class="text-sm uppercase tracking-wide text-text-muted">Design System</p>
21+
<h1 class="mt-2 text-4xl font-bold text-text">{pageTitle}</h1>
22+
<p class="mt-4 text-text-muted">
23+
Every icon from <code class="rounded bg-bg-offset px-1 py-0.5 text-sm">src/icons</code> rendered across each color variant.
24+
</p>
25+
</header>
26+
27+
{
28+
variantNames.map(variant => (
29+
<section class="space-y-6" aria-labelledby={`icon-variant-${variant}`}>
30+
<div class="flex items-baseline justify-between flex-wrap gap-3">
31+
<div>
32+
<p class="text-sm uppercase tracking-wide text-text-muted">Variant</p>
33+
<h2 id={`icon-variant-${variant}`} class="text-2xl font-semibold text-text capitalize">
34+
{variant}
35+
</h2>
36+
</div>
37+
<p class="text-sm text-text-muted">
38+
{iconNames.length} icons
39+
</p>
40+
</div>
41+
<div class="flex flex-wrap gap-6">
42+
{
43+
iconNames.map(iconName => (
44+
<div class="flex min-w-[200px] flex-1 basis-[220px] flex-col items-center justify-between rounded-xl border border-border bg-bg p-8 text-center">
45+
<Icon name={iconName} variant={variant} size={56} />
46+
<span class="mt-6 w-full border-t border-dashed border-border pt-4 text-sm font-mono text-text">
47+
{iconName}
48+
</span>
49+
</div>
50+
))
51+
}
52+
</div>
53+
</section>
54+
))
55+
}
56+
</main>
57+
</BaseLayout>

src/styles/themes.css

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@
4242
--shadow-hover: var(--theme-shadow-hover);
4343
--shadow-active: var(--theme-shadow-active);
4444
--shadow-text: var(--theme-shadow-text);
45-
--color-icons: var(--theme-color-primary);
4645
}
4746

4847
/**
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* Icon component regression tests
3+
* Ensures our gallery page mirrors the local svg set and that each icon remains accessible.
4+
*/
5+
6+
import { BasePage, expect, test } from '@test/e2e/helpers'
7+
import { colorClasses } from '@components/Icon/constants'
8+
import type { Page } from '@playwright/test'
9+
import { promises as fs } from 'node:fs'
10+
import path from 'node:path'
11+
12+
const iconVariants = Object.keys(colorClasses) as Array<keyof typeof colorClasses>
13+
const firstVariant = (iconVariants[0] ?? 'default') as keyof typeof colorClasses
14+
const selectors = {
15+
variantSection: (variant: string) => `section[aria-labelledby="icon-variant-${variant}"]`,
16+
iconSvg: 'svg[data-icon]'
17+
}
18+
19+
async function readLocalIconNames(): Promise<string[]> {
20+
const iconsDir = path.resolve(process.cwd(), 'src/icons')
21+
const entries = await fs.readdir(iconsDir, { withFileTypes: true })
22+
23+
return entries
24+
.filter(entry => entry.isFile() && entry.name.endsWith('.svg'))
25+
.map(entry => entry.name.replace(/\.svg$/i, ''))
26+
.sort((a, b) => a.localeCompare(b))
27+
}
28+
29+
async function setupIconGallery(playwrightPage: Page): Promise<BasePage> {
30+
const page = await BasePage.init(playwrightPage)
31+
await page.goto('/testing/icons')
32+
await page.waitForSelector(selectors.variantSection(firstVariant))
33+
await page.waitForSelector(`${selectors.variantSection(firstVariant)} ${selectors.iconSvg}`)
34+
return page
35+
}
36+
37+
test.describe('Icon Component', () => {
38+
test('default variant renders every local icon exactly once', async ({ page: playwrightPage }) => {
39+
const page = await setupIconGallery(playwrightPage)
40+
const localIconNames = await readLocalIconNames()
41+
const defaultSection = page.locator(selectors.variantSection(firstVariant))
42+
const defaultVariantSvgs = defaultSection.locator(selectors.iconSvg)
43+
44+
await expect(defaultVariantSvgs).toHaveCount(localIconNames.length)
45+
46+
const renderedNames = (await defaultSection.locator('span.font-mono').allTextContents())
47+
.map(name => name.trim())
48+
.filter(Boolean)
49+
.sort((a, b) => a.localeCompare(b))
50+
51+
expect(renderedNames).toEqual(localIconNames)
52+
})
53+
54+
test('each icon exposes a title element and valid SVG markup', async ({ page: playwrightPage }) => {
55+
const page = await setupIconGallery(playwrightPage)
56+
57+
const auditResults = await page.evaluate((sectionSelector) => {
58+
const section = document.querySelector(sectionSelector)
59+
const missingTitles: string[] = []
60+
const invalidSvg: string[] = []
61+
62+
if (!section) {
63+
return { missingTitles: ['icon-gallery-missing'], invalidSvg: ['icon-gallery-missing'] }
64+
}
65+
66+
const svgNodes = Array.from(section.querySelectorAll<SVGSVGElement>('svg[data-icon]'))
67+
68+
for (const svg of svgNodes) {
69+
const iconName = svg.getAttribute('data-icon') ?? 'unknown'
70+
const titleText = svg.querySelector('title')?.textContent?.trim()
71+
if (!titleText) {
72+
missingTitles.push(iconName)
73+
}
74+
75+
const useElement = svg.querySelector('use')
76+
const href = useElement?.getAttribute('href') ?? ''
77+
const symbolId = href.startsWith('#') ? href.slice(1) : href
78+
const symbolElement = symbolId.length ? document.getElementById(symbolId) : null
79+
const isSvgElement = svg.namespaceURI === 'http://www.w3.org/2000/svg'
80+
const hasValidSymbol = Boolean(symbolElement && symbolElement.tagName.toLowerCase() === 'symbol')
81+
82+
if (!isSvgElement || !hasValidSymbol) {
83+
invalidSvg.push(iconName)
84+
}
85+
}
86+
87+
return { missingTitles, invalidSvg }
88+
}, selectors.variantSection(firstVariant))
89+
90+
expect(auditResults.missingTitles, 'icons missing <title> text').toEqual([])
91+
expect(auditResults.invalidSvg, 'icons missing valid <symbol> references').toEqual([])
92+
})
93+
94+
test('every color variant renders the complete icon set', async ({ page: playwrightPage }) => {
95+
const page = await setupIconGallery(playwrightPage)
96+
const expectedCount = await page.locator(selectors.variantSection(firstVariant)).locator(selectors.iconSvg).count()
97+
98+
for (const variant of iconVariants) {
99+
const section = page.locator(selectors.variantSection(variant))
100+
await expect(section.locator(selectors.iconSvg)).toHaveCount(expectedCount)
101+
}
102+
})
103+
})

0 commit comments

Comments
 (0)