diff --git a/packages/compiler/src/a11y.test.ts b/packages/compiler/src/a11y.test.ts new file mode 100644 index 0000000..7ca255e --- /dev/null +++ b/packages/compiler/src/a11y.test.ts @@ -0,0 +1,307 @@ +// ============================================================================ +// a11y.test.ts — Tests for compile-time accessibility checking +// ============================================================================ + +import { describe, it, expect } from 'vitest'; +import { parseTemplate } from './template-compiler'; +import { checkA11y } from './a11y'; +import { compile } from './index'; + +// Helper: parse and check in one call. +function check(template: string, options?: { disable?: string[] }) { + const ast = parseTemplate(template); + return checkA11y(ast, options); +} + +// --------------------------------------------------------------------------- +// img-alt +// --------------------------------------------------------------------------- + +describe('img-alt', () => { + it('warns on without alt', () => { + const w = check(''); + expect(w).toHaveLength(1); + expect(w[0].rule).toBe('img-alt'); + }); + + it('passes with static alt', () => { + expect(check('A photo')).toHaveLength(0); + }); + + it('passes with bound :alt', () => { + expect(check('')).toHaveLength(0); + }); + + it('passes with aria-label', () => { + expect(check('')).toHaveLength(0); + }); + + it('passes with role="presentation" (decorative)', () => { + expect(check('')).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// click-keyboard +// --------------------------------------------------------------------------- + +describe('click-keyboard', () => { + it('warns on non-interactive element with @click but no keyboard handler', () => { + const w = check('
click me
'); + expect(w.some((w) => w.rule === 'click-keyboard')).toBe(true); + }); + + it('does not warn on '); + expect(w.filter((w) => w.rule === 'click-keyboard')).toHaveLength(0); + }); + + it('does not warn on with @click', () => { + const w = check('link'); + expect(w.filter((w) => w.rule === 'click-keyboard')).toHaveLength(0); + }); + + it('does not warn when @keydown is present', () => { + const w = check('
go
'); + expect(w.filter((w) => w.rule === 'click-keyboard')).toHaveLength(0); + }); + + it('warns about missing tabindex on non-interactive element', () => { + const w = check('
go
'); + expect(w.some((w) => w.message.includes('tabindex'))).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// anchor-content +// --------------------------------------------------------------------------- + +describe('anchor-content', () => { + it('warns on empty ', () => { + const w = check(''); + expect(w.some((w) => w.rule === 'anchor-content')).toBe(true); + }); + + it('passes with text content', () => { + const w = check('Home'); + expect(w.filter((w) => w.rule === 'anchor-content')).toHaveLength(0); + }); + + it('passes with aria-label', () => { + const w = check(''); + expect(w.filter((w) => w.rule === 'anchor-content')).toHaveLength(0); + }); + + it('passes with child elements', () => { + const w = check('Home'); + expect(w.filter((w) => w.rule === 'anchor-content')).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// form-label +// --------------------------------------------------------------------------- + +describe('form-label', () => { + it('warns on without id or aria-label', () => { + const w = check(''); + expect(w.some((w) => w.rule === 'form-label')).toBe(true); + }); + + it('passes with id', () => { + const w = check(''); + expect(w.filter((w) => w.rule === 'form-label')).toHaveLength(0); + }); + + it('passes with aria-label', () => { + const w = check(''); + expect(w.filter((w) => w.rule === 'form-label')).toHaveLength(0); + }); + + it('does not warn on hidden inputs', () => { + const w = check(''); + expect(w.filter((w) => w.rule === 'form-label')).toHaveLength(0); + }); + + it('warns on '); + expect(w.some((w) => w.rule === 'form-label')).toBe(true); + }); + + it('warns on '); + expect(w.some((w) => w.rule === 'form-label')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// no-distracting +// --------------------------------------------------------------------------- + +describe('no-distracting', () => { + it('warns on ', () => { + const w = check('scrolling text'); + expect(w).toHaveLength(1); + expect(w[0].rule).toBe('no-distracting'); + }); + + it('warns on ', () => { + const w = check('blinking text'); + expect(w).toHaveLength(1); + expect(w[0].rule).toBe('no-distracting'); + }); +}); + +// --------------------------------------------------------------------------- +// heading-order +// --------------------------------------------------------------------------- + +describe('heading-order', () => { + it('warns when heading level is skipped', () => { + const w = check('

Title

Subtitle

'); + expect(w.some((w) => w.rule === 'heading-order')).toBe(true); + }); + + it('does not warn on sequential headings', () => { + const w = check('

Title

Subtitle

'); + expect(w.filter((w) => w.rule === 'heading-order')).toHaveLength(0); + }); + + it('does not warn on same-level headings', () => { + const w = check('

A

B

'); + expect(w.filter((w) => w.rule === 'heading-order')).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// aria-role +// --------------------------------------------------------------------------- + +describe('aria-role', () => { + it('warns on invalid role', () => { + const w = check('
content
'); + expect(w).toHaveLength(1); + expect(w[0].rule).toBe('aria-role'); + expect(w[0].message).toContain('foobar'); + }); + + it('passes on valid role', () => { + const w = check('
content
'); + expect(w.filter((w) => w.rule === 'aria-role')).toHaveLength(0); + }); + + it('passes on role="navigation"', () => { + const w = check(''); + expect(w.filter((w) => w.rule === 'aria-role')).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// no-positive-tabindex +// --------------------------------------------------------------------------- + +describe('no-positive-tabindex', () => { + it('warns on positive tabindex', () => { + const w = check('
content
'); + expect(w.some((w) => w.rule === 'no-positive-tabindex')).toBe(true); + }); + + it('does not warn on tabindex="0"', () => { + const w = check('
content
'); + expect(w.filter((w) => w.rule === 'no-positive-tabindex')).toHaveLength(0); + }); + + it('does not warn on tabindex="-1"', () => { + const w = check('
content
'); + expect(w.filter((w) => w.rule === 'no-positive-tabindex')).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// media-captions +// --------------------------------------------------------------------------- + +describe('media-captions', () => { + it('warns on