import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import './usa-tag.ts';
import type { USATag } from './usa-tag.js';
import {
testComponentAccessibility,
USWDS_A11Y_CONFIG,
} from '../../../__tests__/accessibility-utils.js';
import { validateComponentJavaScript } from '../../../__tests__/test-utils.js';
describe('USATag', () => {
let element: USATag;
beforeEach(() => {
element = document.createElement('usa-tag') as USATag;
document.body.appendChild(element);
});
afterEach(() => {
element.remove();
});
describe('Default Properties', () => {
it('should have correct default properties', async () => {
await element.updateComplete;
expect(element.text).toBe('');
expect(element.big).toBe(false);
expect(element.removable).toBe(false);
expect(element.value).toBe('');
});
});
describe('Basic Rendering', () => {
it('should render a span with usa-tag class', async () => {
await element.updateComplete;
const span = element.querySelector('span');
expect(span).toBeTruthy();
expect(span?.classList.contains('usa-tag')).toBe(true);
});
it('should display text content when text property is set', async () => {
element.text = 'Government';
await element.updateComplete;
const span = element.querySelector('span');
expect(span?.textContent?.trim()).toBe('Government');
});
it('should render slot content when no text property is set', async () => {
const slotContent = document.createElement('span');
slotContent.textContent = 'Slotted Content';
element.appendChild(slotContent);
await element.updateComplete;
expect(element.textContent?.includes('Slotted Content')).toBe(true);
});
it('should prioritize text property over slot content', async () => {
element.text = 'Text Property';
const slotContent = document.createElement('span');
slotContent.textContent = 'Slotted Content';
element.appendChild(slotContent);
await element.updateComplete;
const span = element.querySelector('span');
expect(span?.textContent?.trim()).toBe('Text Property');
});
});
describe('Big Variant', () => {
it('should apply big class when big property is true', async () => {
element.big = true;
await element.updateComplete;
const span = element.querySelector('span');
expect(span?.classList.contains('usa-tag--big')).toBe(true);
});
it('should not apply big class when big property is false', async () => {
element.big = false;
await element.updateComplete;
const span = element.querySelector('span');
expect(span?.classList.contains('usa-tag--big')).toBe(false);
});
it('should toggle big class when property changes', async () => {
element.big = false;
await element.updateComplete;
let span = element.querySelector('span');
expect(span?.classList.contains('usa-tag--big')).toBe(false);
element.big = true;
await element.updateComplete;
span = element.querySelector('span');
expect(span?.classList.contains('usa-tag--big')).toBe(true);
});
});
describe('Removable Functionality', () => {
it('should apply removable class when removable property is true', async () => {
element.removable = true;
await element.updateComplete;
const span = element.querySelector('span');
expect(span?.classList.contains('usa-tag--removable')).toBe(true);
});
it('should render remove button when removable is true', async () => {
element.text = 'Removable Tag';
element.removable = true;
await element.updateComplete;
const removeButton = element.querySelector('.usa-button--unstyled');
expect(removeButton).toBeTruthy();
expect(removeButton?.tagName.toLowerCase()).toBe('button');
});
it('should not render remove button when removable is false', async () => {
element.removable = false;
await element.updateComplete;
const removeButton = element.querySelector('.usa-button--unstyled');
expect(removeButton).toBeNull();
});
it('should have proper ARIA label on remove button', async () => {
element.text = 'Test Tag';
element.removable = true;
await element.updateComplete;
const removeButton = element.querySelector('.usa-button--unstyled') as HTMLButtonElement;
expect(removeButton?.getAttribute('aria-label')).toBe('Remove tag: Test Tag');
});
it('should have proper button type', async () => {
element.removable = true;
await element.updateComplete;
const removeButton = element.querySelector('.usa-button--unstyled') as HTMLButtonElement;
expect(removeButton?.type).toBe('button');
});
it('should have ✕ content in remove button (USWDS pattern)', async () => {
element.removable = true;
await element.updateComplete;
const removeButton = element.querySelector('.usa-button--unstyled');
expect(removeButton).toBeTruthy();
// Button should contain ✕ symbol for remove functionality
expect(removeButton?.textContent).toBe('✕'); // Check for ✕ symbol
// USWDS provides the icon via CSS, so no SVG should be present
const svg = element.querySelector('.usa-tag__remove svg');
expect(svg).toBeFalsy();
});
});
describe('Remove Event Handling', () => {
it('should dispatch tag-remove event when remove button is clicked', async () => {
element.text = 'Test Tag';
element.value = 'test-value';
element.removable = true;
await element.updateComplete;
const eventSpy = vi.fn();
element.addEventListener('tag-remove', eventSpy);
const removeButton = element.querySelector('.usa-button--unstyled') as HTMLButtonElement;
removeButton?.click();
expect(eventSpy).toHaveBeenCalledOnce();
expect(eventSpy).toHaveBeenCalledWith(
expect.objectContaining({
detail: {
text: 'Test Tag',
value: 'test-value',
},
})
);
});
it('should dispatch event with correct detail when value is empty', async () => {
element.text = 'Simple Tag';
element.removable = true;
await element.updateComplete;
const eventSpy = vi.fn();
element.addEventListener('tag-remove', eventSpy);
const removeButton = element.querySelector('.usa-button--unstyled') as HTMLButtonElement;
removeButton?.click();
expect(eventSpy).toHaveBeenCalledWith(
expect.objectContaining({
detail: {
text: 'Simple Tag',
value: '',
},
})
);
});
it('should create bubbling and composed event', async () => {
element.removable = true;
await element.updateComplete;
const eventSpy = vi.fn();
element.addEventListener('tag-remove', eventSpy);
const removeButton = element.querySelector('.usa-button--unstyled') as HTMLButtonElement;
removeButton?.click();
const event = eventSpy.mock.calls[0][0];
expect(event.bubbles).toBe(true);
expect(event.composed).toBe(true);
});
it('should stop event propagation when remove button is clicked', async () => {
element.removable = true;
await element.updateComplete;
const parentSpy = vi.fn();
document.body.addEventListener('click', parentSpy);
const removeButton = element.querySelector('.usa-button--unstyled') as HTMLButtonElement;
removeButton?.click();
// The click should not bubble to the parent
expect(parentSpy).not.toHaveBeenCalled();
document.body.removeEventListener('click', parentSpy);
});
it('should remove element from DOM after dispatching event', async () => {
element.text = 'Remove Me';
element.removable = true;
await element.updateComplete;
expect(document.body.contains(element)).toBe(true);
const removeButton = element.querySelector('.usa-button--unstyled') as HTMLButtonElement;
removeButton?.click();
expect(document.body.contains(element)).toBe(false);
});
});
describe('Combined Classes', () => {
it('should apply both big and removable classes', async () => {
element.big = true;
element.removable = true;
await element.updateComplete;
const span = element.querySelector('span');
expect(span?.classList.contains('usa-tag')).toBe(true);
expect(span?.classList.contains('usa-tag--big')).toBe(true);
expect(span?.classList.contains('usa-tag--removable')).toBe(true);
});
it('should handle class changes dynamically', async () => {
element.big = false;
element.removable = false;
await element.updateComplete;
let span = element.querySelector('span');
expect(span?.className).toBe('usa-tag');
element.big = true;
element.removable = true;
await element.updateComplete;
span = element.querySelector('span');
expect(span?.classList.contains('usa-tag')).toBe(true);
expect(span?.classList.contains('usa-tag--big')).toBe(true);
expect(span?.classList.contains('usa-tag--removable')).toBe(true);
});
});
describe('Value Property', () => {
it('should store value property for event data', async () => {
element.value = 'tag-identifier';
expect(element.value).toBe('tag-identifier');
});
it('should include value in remove event even if different from text', async () => {
element.text = 'Display Text';
element.value = 'internal-value';
element.removable = true;
await element.updateComplete;
const eventSpy = vi.fn();
element.addEventListener('tag-remove', eventSpy);
const removeButton = element.querySelector('.usa-button--unstyled') as HTMLButtonElement;
removeButton?.click();
expect(eventSpy).toHaveBeenCalledWith(
expect.objectContaining({
detail: {
text: 'Display Text',
value: 'internal-value',
},
})
);
});
});
describe('Accessibility Features', () => {
it('should have proper button role for remove button', async () => {
element.removable = true;
await element.updateComplete;
const removeButton = element.querySelector('.usa-button--unstyled');
expect(removeButton?.tagName.toLowerCase()).toBe('button');
expect(removeButton?.getAttribute('type')).toBe('button');
});
it('should have descriptive aria-label for remove button', async () => {
element.text = 'Accessibility Tag';
element.removable = true;
await element.updateComplete;
const removeButton = element.querySelector('.usa-button--unstyled');
expect(removeButton?.getAttribute('aria-label')).toBe('Remove tag: Accessibility Tag');
});
it('should use USWDS CSS-provided icon (no custom SVG)', async () => {
element.removable = true;
await element.updateComplete;
// USWDS provides the icon via CSS, so no SVG should be present
const svg = element.querySelector('.usa-tag__remove svg');
expect(svg).toBeFalsy();
// Button should only contain ✕ symbol as per USWDS pattern
const removeButton = element.querySelector('.usa-button--unstyled');
expect(removeButton?.textContent).toBe('✕');
});
it('should be keyboard accessible', async () => {
element.removable = true;
await element.updateComplete;
const removeButton = element.querySelector('.usa-button--unstyled') as HTMLButtonElement;
// Should be focusable
removeButton?.focus();
expect(document.activeElement).toBe(removeButton);
// Should respond to keyboard events
const eventSpy = vi.fn();
element.addEventListener('tag-remove', eventSpy);
// Simulate keyboard activation
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter' });
removeButton?.dispatchEvent(enterEvent);
// Note: In a real browser, Enter on a button triggers click
// In tests, we can verify the button is properly set up for keyboard access
expect(removeButton?.tagName.toLowerCase()).toBe('button');
});
});
describe('Light DOM Rendering', () => {
it('should render in light DOM for USWDS compatibility', async () => {
await element.updateComplete;
expect(element.shadowRoot).toBeNull();
expect(element.querySelector('span')).toBeTruthy();
});
});
describe('Property Updates and Re-rendering', () => {
it('should re-render when text changes', async () => {
element.text = 'Original Text';
await element.updateComplete;
let span = element.querySelector('span');
expect(span?.textContent?.trim()).toBe('Original Text');
element.text = 'Updated Text';
await element.updateComplete;
span = element.querySelector('span');
expect(span?.textContent?.trim()).toBe('Updated Text');
});
it('should re-render when big property changes', async () => {
element.big = false;
await element.updateComplete;
let span = element.querySelector('span');
expect(span?.classList.contains('usa-tag--big')).toBe(false);
element.big = true;
await element.updateComplete;
span = element.querySelector('span');
expect(span?.classList.contains('usa-tag--big')).toBe(true);
});
it('should re-render when removable property changes', async () => {
element.removable = false;
await element.updateComplete;
expect(element.querySelector('.usa-button--unstyled')).toBeNull();
element.removable = true;
await element.updateComplete;
expect(element.querySelector('.usa-button--unstyled')).toBeTruthy();
});
it('should update aria-label when text changes on removable tag', async () => {
element.text = 'Initial';
element.removable = true;
await element.updateComplete;
let removeButton = element.querySelector('.usa-button--unstyled');
expect(removeButton?.getAttribute('aria-label')).toBe('Remove tag: Initial');
element.text = 'Changed';
await element.updateComplete;
removeButton = element.querySelector('.usa-button--unstyled');
expect(removeButton?.getAttribute('aria-label')).toBe('Remove tag: Changed');
});
});
describe('Edge Cases', () => {
it('should handle empty text gracefully', async () => {
element.text = '';
await element.updateComplete;
const span = element.querySelector('span');
expect(span).toBeTruthy();
});
it('should handle whitespace-only text', async () => {
element.text = ' ';
await element.updateComplete;
const span = element.querySelector('span');
expect(span?.textContent?.trim()).toBe('');
});
it('should handle special characters in text', async () => {
element.text = '';
await element.updateComplete;
const span = element.querySelector('span');
expect(span?.textContent?.trim()).toBe('');
// Ensure it's treated as text, not HTML
expect(element.querySelector('script')).toBeNull();
});
it('should handle long text content', async () => {
const longText = 'A'.repeat(100);
element.text = longText;
await element.updateComplete;
const span = element.querySelector('span');
expect(span?.textContent?.trim()).toBe(longText);
});
it('should handle unicode characters', async () => {
element.text = '🏛️ Government 中文 العربية';
await element.updateComplete;
const span = element.querySelector('span');
expect(span?.textContent?.trim()).toBe('🏛️ Government 中文 العربية');
});
it('should handle aria-label with special characters', async () => {
element.text = 'Tag with "quotes" & ';
element.removable = true;
await element.updateComplete;
const removeButton = element.querySelector('.usa-button--unstyled');
expect(removeButton?.getAttribute('aria-label')).toBe(
'Remove tag: Tag with "quotes" & '
);
});
});
describe('USWDS CSS Classes', () => {
it('should always have base usa-tag class', async () => {
await element.updateComplete;
const span = element.querySelector('span');
expect(span?.classList.contains('usa-tag')).toBe(true);
});
it('should use proper USWDS tag structure', async () => {
element.text = 'Test';
element.removable = true;
await element.updateComplete;
expect(element.querySelector('span.usa-tag')).toBeTruthy();
expect(element.querySelector('.usa-button--unstyled')).toBeTruthy();
// USWDS pattern: no SVG in remove button, only ✕ symbol with standard button styling
expect(element.querySelector('.usa-tag__remove svg')).toBeFalsy();
const removeButton = element.querySelector('.usa-button--unstyled');
expect(removeButton?.textContent).toBe('✕');
});
it('should apply proper USWDS modifier classes', async () => {
element.big = true;
element.removable = true;
await element.updateComplete;
const span = element.querySelector('span');
expect(span?.classList.contains('usa-tag--big')).toBe(true);
expect(span?.classList.contains('usa-tag--removable')).toBe(true);
});
});
describe('Event Delegation and Cleanup', () => {
it('should not leak event listeners after removal', async () => {
element.removable = true;
element.text = 'Test';
await element.updateComplete;
const removeButton = element.querySelector('.usa-button--unstyled') as HTMLButtonElement;
removeButton?.click(); // This removes the element
// Element should be removed and no memory leaks
expect(document.body.contains(element)).toBe(false);
});
it('should handle multiple rapid remove events', async () => {
element.removable = true;
await element.updateComplete;
const eventSpy = vi.fn();
element.addEventListener('tag-remove', eventSpy);
const removeButton = element.querySelector('.usa-button--unstyled') as HTMLButtonElement;
// Rapid clicks should only trigger once (element gets removed)
removeButton?.click();
expect(eventSpy).toHaveBeenCalledOnce();
});
});
describe('Performance Considerations', () => {
it('should handle rapid property changes efficiently', async () => {
// Simulate rapid changes
for (let i = 0; i < 10; i++) {
element.text = `Tag ${i}`;
element.big = i % 2 === 0;
element.removable = i % 3 === 0;
}
await element.updateComplete;
const span = element.querySelector('span');
expect(span?.classList.contains('usa-tag')).toBe(true);
// When removable is true (9 % 3 === 0), the span contains both text and remove button
expect(span?.textContent?.includes('Tag 9')).toBe(true);
});
describe('JavaScript Implementation Validation', () => {
it('should pass JavaScript implementation validation', async () => {
// Validate USWDS JavaScript implementation patterns
const componentPath =
`${process.cwd()}/src/components/tag/usa-tag.ts`;
const validation = validateComponentJavaScript(componentPath, 'tag');
if (!validation.isValid) {
console.warn('JavaScript validation issues:', validation.issues);
}
// JavaScript validation should pass for critical integration patterns
expect(validation.score).toBeGreaterThan(50); // Allow some non-critical issues
// Critical USWDS integration should be present
const criticalIssues = validation.issues.filter((issue) =>
issue.includes('Missing USWDS JavaScript integration')
);
expect(criticalIssues.length).toBe(0);
});
});
});
describe('Accessibility', () => {
it('should pass comprehensive accessibility tests (same as Storybook)', async () => {
element.textContent = 'Test Tag';
await element.updateComplete;
await testComponentAccessibility(element, USWDS_A11Y_CONFIG.FULL_COMPLIANCE);
});
});
describe('Color/Contrast Accessibility (WCAG 1.4)', () => {
it('should verify tag has USWDS classes for contrast', async () => {
const { testColorContrast } = await import('../../../__tests__/contrast-utils.js');
await element.updateComplete;
const tag = element.querySelector('.usa-tag');
expect(tag).toBeTruthy();
const result = testColorContrast(tag as Element);
// Structure validation
expect(result).toBeDefined();
expect(result.foreground).toBeDefined();
expect(result.background).toBeDefined();
});
it('should verify big variant has USWDS classes', async () => {
element.big = true;
await element.updateComplete;
const tag = element.querySelector('.usa-tag');
expect(tag).toBeTruthy();
// Verify USWDS classes
expect(tag?.classList.contains('usa-tag--big')).toBe(true);
});
it('should calculate contrast correctly for tag colors', async () => {
const { calculateContrastRatio } = await import('../../../__tests__/contrast-utils.js');
// USWDS tag uses dark background with white text
// Test typical tag color combinations
// Dark background with white text (typical USWDS tag)
const tagContrast = calculateContrastRatio('#ffffff', '#1b1b1b');
expect(tagContrast).toBeGreaterThan(4.5);
expect(tagContrast).toBeGreaterThan(7); // Should pass AAA
// Verify high contrast
const highContrast = calculateContrastRatio('#000000', '#ffffff');
expect(highContrast).toBeCloseTo(21, 0);
});
it('should verify tag text has adequate contrast', async () => {
const { testColorContrast } = await import('../../../__tests__/contrast-utils.js');
await element.updateComplete;
const tag = element.querySelector('.usa-tag');
expect(tag).toBeTruthy();
const result = testColorContrast(tag as Element);
// Structure validation
expect(result).toBeDefined();
});
it('should test large text identification for big tags', async () => {
const { testColorContrast } = await import('../../../__tests__/contrast-utils.js');
element.big = true;
await element.updateComplete;
const tag = element.querySelector('.usa-tag');
expect(tag).toBeTruthy();
const result = testColorContrast(tag as Element);
// Big tags may be identified as large text
expect(result).toBeDefined();
expect(result.isLargeText).toBeDefined();
});
it('should verify tag maintains contrast with explicit colors', async () => {
const { testColorContrast } = await import('../../../__tests__/contrast-utils.js');
await element.updateComplete;
const tag = element.querySelector('.usa-tag') as HTMLElement;
expect(tag).toBeTruthy();
// Apply explicit colors for testing
tag.style.color = '#ffffff';
tag.style.backgroundColor = '#1b1b1b';
const result = testColorContrast(tag);
expect(result.passesAA).toBe(true);
expect(result.passesAAA).toBe(true);
expect(result.ratio).toBeGreaterThan(7);
});
});
});