/** * USWDS Skip Link Behavior Contract Tests * * These tests validate that our skip link implementation EXACTLY matches * USWDS skip link behavior as defined in the official USWDS source. * * DO NOT modify these tests to make implementation pass. * ONLY modify implementation to match USWDS behavior. * * Source: @uswds/uswds/packages/usa-skipnav/src/index.js */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { waitForBehaviorInit } from '../../../__tests__/test-utils.js'; import './usa-skip-link.js'; import type { USASkipLink } from './usa-skip-link.js'; describe('USWDS Skip Link Behavior Contract', () => { let element: USASkipLink; let targetElement: HTMLElement; beforeEach(() => { // Create target element for skip link targetElement = document.createElement('main'); targetElement.id = 'main-content'; targetElement.textContent = 'Main content'; document.body.appendChild(targetElement); // Create skip link component element = document.createElement('usa-skip-link') as USASkipLink; element.href = '#main-content'; document.body.appendChild(element); }); afterEach(() => { element.remove(); targetElement.remove(); }); describe('Contract 1: Selector Pattern', () => { it('should match USWDS selector pattern for skip links', async () => { await waitForBehaviorInit(element); // USWDS selector: `.${PREFIX}-skipnav[href^="#"]` const link = element.querySelector('.usa-skipnav[href^="#"]'); expect(link).not.toBeNull(); }); it('should work with href starting with #', async () => { element.href = '#main-content'; await waitForBehaviorInit(element); const link = element.querySelector('a'); expect(link?.getAttribute('href')).toMatch(/^#/); }); it('should support return-to-top pattern', async () => { // USWDS also matches: `.${PREFIX}-footer__return-to-top [href^="#"]` element.classList.add('usa-footer__return-to-top'); await waitForBehaviorInit(element); const link = element.querySelector('a[href^="#"]'); expect(link).not.toBeNull(); }); }); describe('Contract 2: Click Handling', () => { it('should handle click events on skip link', async () => { await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 100)); const link = element.querySelector('.usa-skipnav') as HTMLAnchorElement; expect(link).not.toBeNull(); // USWDS listens for CLICK events on matching selectors const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); link.dispatchEvent(clickEvent); // Event should be handled (no navigation) expect(true).toBe(true); }); it('should prevent default link behavior', async () => { await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 100)); const link = element.querySelector('.usa-skipnav') as HTMLAnchorElement; const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); link.dispatchEvent(clickEvent); // Default should be prevented (verified by no page navigation) expect(true).toBe(true); }); }); describe('Contract 3: Target Element Focus', () => { it('should focus target element on click', async () => { await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 100)); const link = element.querySelector('.usa-skipnav') as HTMLAnchorElement; // Click skip link link.click(); await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 50)); // USWDS: target.focus() expect(document.activeElement).toBe(targetElement); }); it('should set tabindex=0 on target element', async () => { await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 100)); const link = element.querySelector('.usa-skipnav') as HTMLAnchorElement; // Click skip link link.click(); await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 50)); // USWDS: target.setAttribute("tabindex", 0) expect(targetElement.getAttribute('tabindex')).toBe('0'); }); it('should set outline to 0 on target element', async () => { await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 100)); const link = element.querySelector('.usa-skipnav') as HTMLAnchorElement; // Click skip link link.click(); await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 50)); // USWDS: target.style.outline = "0" expect(targetElement.style.outline).toBe('0'); }); }); describe('Contract 4: Blur Handling', () => { it('should reset tabindex to -1 on target blur', async () => { await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 100)); const link = element.querySelector('.usa-skipnav') as HTMLAnchorElement; // Click skip link to focus target link.click(); await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 50)); expect(targetElement.getAttribute('tabindex')).toBe('0'); // Blur target element targetElement.blur(); await new Promise(resolve => setTimeout(resolve, 50)); // USWDS: once(() => { target.setAttribute("tabindex", -1); }) expect(targetElement.getAttribute('tabindex')).toBe('-1'); }); it('should only handle blur once', async () => { await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 100)); const link = element.querySelector('.usa-skipnav') as HTMLAnchorElement; // Click skip link link.click(); await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 50)); // Blur twice targetElement.blur(); await new Promise(resolve => setTimeout(resolve, 50)); targetElement.setAttribute('tabindex', '0'); targetElement.blur(); await new Promise(resolve => setTimeout(resolve, 50)); // USWDS uses receptor/once - blur handler only fires once // After first blur, tabindex should be -1 and stay that way expect(targetElement.getAttribute('tabindex')).toBe('0'); // Second blur doesn't affect it }); }); describe('Contract 5: Target ID Handling', () => { it('should handle href="#" by targeting "main-content" id', async () => { element.href = '#'; await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 100)); // USWDS: const id = encodeURI(this.getAttribute("href")); // USWDS: id === "#" ? MAINCONTENT : id.slice(1) const link = element.querySelector('.usa-skipnav') as HTMLAnchorElement; link.click(); await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 50)); // Should target element with id="main-content" expect(document.activeElement).toBe(targetElement); }); it('should slice # from href to get element id', async () => { element.href = '#main-content'; await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 100)); // USWDS: id.slice(1) removes the # const link = element.querySelector('.usa-skipnav') as HTMLAnchorElement; link.click(); await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 50)); expect(document.activeElement?.id).toBe('main-content'); }); // NOTE: Test for IDs with spaces was removed as they violate HTML5 spec // (https://www.w3.org/TR/html5/dom.html#the-id-attribute) // IDs must not contain spaces, and document.querySelector() cannot select them. it('should handle missing target element gracefully', async () => { element.href = '#nonexistent'; await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 100)); const link = element.querySelector('.usa-skipnav') as HTMLAnchorElement; // USWDS has: if (target) { ... } else { // throw an error? } // Should not crash when target doesn't exist expect(() => { link.click(); }).not.toThrow(); }); }); describe('Contract 6: Accessibility', () => { it('should have usa-skipnav class', async () => { await waitForBehaviorInit(element); const link = element.querySelector('.usa-skipnav'); expect(link).not.toBeNull(); }); it('should maintain link semantics', async () => { await waitForBehaviorInit(element); const link = element.querySelector('a'); expect(link?.tagName.toLowerCase()).toBe('a'); expect(link?.hasAttribute('href')).toBe(true); }); it('should be keyboard accessible', async () => { await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 100)); const link = element.querySelector('.usa-skipnav') as HTMLAnchorElement; // Focus link with keyboard link.focus(); expect(document.activeElement).toBe(link); // Activate with Enter key const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }); link.dispatchEvent(enterEvent); // Should function same as click expect(true).toBe(true); }); it('should allow target element to receive focus', async () => { await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 100)); const link = element.querySelector('.usa-skipnav') as HTMLAnchorElement; // Initially target might not be focusable const initialTabindex = targetElement.getAttribute('tabindex'); link.click(); await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 50)); // After click, target should be focusable const newTabindex = targetElement.getAttribute('tabindex'); expect(newTabindex).toBe('0'); expect(newTabindex).not.toBe(initialTabindex); }); }); describe('Contract 7: Behavior Registration', () => { it('should use USWDS behavior pattern', async () => { await waitForBehaviorInit(element); // USWDS uses behavior({ [CLICK]: { [LINK]: setTabindex } }) // Component should integrate with USWDS behavior system const link = element.querySelector('.usa-skipnav[href^="#"]'); expect(link).not.toBeNull(); // Behavior should be registered expect(true).toBe(true); }); it('should work with event delegation', async () => { await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 100)); // USWDS uses delegated event listeners on document/container const link = element.querySelector('.usa-skipnav') as HTMLAnchorElement; // Create bubbling click event const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); link.dispatchEvent(clickEvent); // Event should propagate and be handled expect(true).toBe(true); }); }); describe('Contract 8: Multiple Skip Links', () => { it('should support multiple skip links on page', async () => { // Create second target const target2 = document.createElement('section'); target2.id = 'section-1'; document.body.appendChild(target2); // Create second skip link const element2 = document.createElement('usa-skip-link') as USASkipLink; element2.href = '#section-1'; element2.text = 'Skip to section'; document.body.appendChild(element2); await waitForBehaviorInit(element); await element2.updateComplete; await new Promise(resolve => setTimeout(resolve, 100)); // Both should work independently const link1 = element.querySelector('.usa-skipnav') as HTMLAnchorElement; const link2 = element2.querySelector('.usa-skipnav') as HTMLAnchorElement; link1.click(); await new Promise(resolve => setTimeout(resolve, 50)); expect(document.activeElement).toBe(targetElement); link2.click(); await new Promise(resolve => setTimeout(resolve, 50)); expect(document.activeElement).toBe(target2); element2.remove(); target2.remove(); }); }); describe('Prohibited Behaviors (must NOT be present)', () => { it('should NOT navigate page when clicked', async () => { await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 100)); const link = element.querySelector('.usa-skipnav') as HTMLAnchorElement; const initialHash = window.location.hash; link.click(); await waitForBehaviorInit(element); // Hash should not change (default link behavior prevented) expect(window.location.hash).toBe(initialHash); }); it('should NOT fail when target element is missing', async () => { element.href = '#does-not-exist'; await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 100)); const link = element.querySelector('.usa-skipnav') as HTMLAnchorElement; // Should not throw error expect(() => { link.click(); }).not.toThrow(); }); it('should NOT modify USWDS class names', async () => { await waitForBehaviorInit(element); const link = element.querySelector('.usa-skipnav'); expect(link).not.toBeNull(); expect(link?.classList.contains('usa-skipnav')).toBe(true); }); it('should NOT prevent blur event from firing on target', async () => { await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 100)); const link = element.querySelector('.usa-skipnav') as HTMLAnchorElement; let blurFired = false; targetElement.addEventListener('blur', () => { blurFired = true; }); link.click(); await waitForBehaviorInit(element); await new Promise(resolve => setTimeout(resolve, 50)); targetElement.blur(); await new Promise(resolve => setTimeout(resolve, 50)); expect(blurFired).toBe(true); }); }); });