feat: implement search history dropdown with liquid glass effect; add hooks for managing search history

This commit is contained in:
kuekhaoyang
2025-11-18 18:21:12 +08:00
parent 58f7c16d7b
commit f03ecb3294
7 changed files with 1007 additions and 0 deletions
+214
View File
@@ -574,5 +574,219 @@ nav {
contain: layout style;
}
/* ===========================================
SEARCH HISTORY DROPDOWN - LIQUID GLASS
=========================================== */
/* Search History Dropdown - Fixed positioning, Liquid Glass effect */
.search-history-dropdown {
position: fixed;
z-index: 9999;
overflow-y: auto;
/* Liquid Glass effect - Core aesthetic */
background: var(--glass-bg);
backdrop-filter: blur(25px) saturate(180%);
-webkit-backdrop-filter: blur(25px) saturate(180%);
/* Rounded-2xl corners - Universal softness */
border-radius: var(--radius-2xl);
/* Depth and definition */
box-shadow: var(--shadow-md);
border: 1px solid var(--glass-border);
/* Content padding */
padding: 0.75rem;
/* Smooth appearance animation */
opacity: 0;
transform: translateY(-10px) scale(0.95);
animation: search-dropdown-appear 0.3s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
/* Transform origin for natural scaling */
transform-origin: top center;
/* Prevent text selection during interaction */
user-select: none;
-webkit-user-select: none;
}
@keyframes search-dropdown-appear {
from {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Header section */
.search-history-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
margin-bottom: 0.5rem;
}
/* Divider with glass effect subtlety */
.search-history-divider {
height: 1px;
width: 100%;
background: var(--glass-border);
margin: 0.5rem 0;
}
/* List container */
.search-history-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* Individual history item */
.search-history-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
/* Universal rounded-2xl for container elements */
border-radius: var(--radius-2xl);
padding: 0.75rem 1rem;
cursor: pointer;
/* Fluid animation - Physics-based transitions */
transition: all var(--transition-fluid);
/* Subtle background for hover/highlight */
background: transparent;
}
/* Hover state - Light interaction */
.search-history-item:hover {
background: color-mix(in srgb, var(--accent-color) 10%, transparent);
transform: translateX(2px);
}
/* Highlighted state - Keyboard navigation */
.search-history-item.highlighted {
background: color-mix(in srgb, var(--accent-color) 15%, transparent);
transform: translateX(4px);
/* Subtle inner-glow effect - Lensing principle */
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-color) 30%, transparent);
}
/* Remove button */
.search-history-remove {
display: flex;
align-items: center;
justify-content: center;
/* Rounded-full for small circular elements */
width: 24px;
height: 24px;
border-radius: var(--radius-full);
background: transparent;
border: none;
color: var(--text-color-secondary);
cursor: pointer;
flex-shrink: 0;
/* Fluid animation */
transition: all 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
/* Prevent triggering parent hover */
z-index: 1;
}
.search-history-remove:hover {
background: color-mix(in srgb, var(--text-color-secondary) 20%, transparent);
color: var(--text-color);
transform: scale(1.1);
}
.search-history-remove:active {
transform: scale(0.95);
}
/* Optimize scrolling performance */
.search-history-dropdown {
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
/* Custom scrollbar for dropdown */
.search-history-dropdown::-webkit-scrollbar {
width: 6px;
}
.search-history-dropdown::-webkit-scrollbar-track {
background: transparent;
}
.search-history-dropdown::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--glass-bg) 80%, transparent);
border-radius: var(--radius-full);
}
.search-history-dropdown::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--accent-color) 50%, transparent);
}
/* Accessibility - Focus visible states */
.search-history-item:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
}
.search-history-remove:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 1px;
border-radius: var(--radius-full);
}
/* Mobile optimizations */
@media (max-width: 640px) {
.search-history-dropdown {
/* Increase touch target sizes */
padding: 0.5rem;
}
.search-history-item {
padding: 1rem 0.75rem;
/* Larger minimum touch target */
min-height: 48px;
}
.search-history-remove {
width: 32px;
height: 32px;
}
}
/* Dark mode enhancements */
body.dark .search-history-dropdown,
.dark .search-history-dropdown {
/* Enhanced glass effect in dark mode */
backdrop-filter: blur(30px) saturate(200%);
-webkit-backdrop-filter: blur(30px) saturate(200%);
/* Stronger shadow for depth */
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
/* ===========================================
END SEARCH HISTORY DROPDOWN
=========================================== */
+76
View File
@@ -5,6 +5,8 @@ import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Icons } from '@/components/ui/Icon';
import { SearchLoadingAnimation } from '@/components/SearchLoadingAnimation';
import { SearchHistoryDropdown } from '@/components/search/SearchHistoryDropdown';
import { useSearchHistory } from '@/lib/hooks/useSearchHistory';
interface SearchFormProps {
onSearch: (query: string) => void;
@@ -28,6 +30,24 @@ export function SearchForm({
const [query, setQuery] = useState(initialQuery);
const inputRef = useRef<HTMLInputElement>(null);
// Search history hook
const {
searchHistory,
isDropdownOpen,
highlightedIndex,
showDropdown,
hideDropdown,
addSearch,
removeSearch,
clearAll,
selectHistoryItem,
navigateDropdown,
resetHighlight,
} = useSearchHistory((selectedQuery) => {
setQuery(selectedQuery);
onSearch(selectedQuery);
});
// Update query when initialQuery changes
useEffect(() => {
setQuery(initialQuery);
@@ -36,7 +56,10 @@ export function SearchForm({
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (query.trim() && !isLoading) {
// Add to search history before searching
addSearch(query.trim());
onSearch(query);
hideDropdown();
}
};
@@ -45,6 +68,42 @@ export function SearchForm({
if (onClear) {
onClear();
}
resetHighlight();
};
const handleInputFocus = () => {
if (query.trim() === '') {
showDropdown();
}
};
const handleInputBlur = () => {
hideDropdown();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!isDropdownOpen) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
navigateDropdown('down');
break;
case 'ArrowUp':
e.preventDefault();
navigateDropdown('up');
break;
case 'Enter':
if (highlightedIndex >= 0 && searchHistory[highlightedIndex]) {
e.preventDefault();
selectHistoryItem(searchHistory[highlightedIndex].query);
}
break;
case 'Escape':
hideDropdown();
inputRef.current?.blur();
break;
}
};
return (
@@ -55,9 +114,15 @@ export function SearchForm({
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
placeholder="搜索电影、电视剧、综艺..."
className="text-base sm:text-lg pr-24 md:pr-32 truncate"
aria-label="搜索视频内容"
aria-expanded={isDropdownOpen}
aria-controls="search-history-dropdown"
aria-autocomplete="list"
/>
{query && (
<button
@@ -80,6 +145,17 @@ export function SearchForm({
<span className="hidden sm:inline"></span>
</span>
</Button>
{/* Search History Dropdown */}
<SearchHistoryDropdown
isOpen={isDropdownOpen}
searchHistory={searchHistory}
highlightedIndex={highlightedIndex}
triggerRef={inputRef}
onSelectItem={selectHistoryItem}
onRemoveItem={removeSearch}
onClearAll={clearAll}
/>
</div>
{/* Loading Animation */}
+166
View File
@@ -0,0 +1,166 @@
/**
* SearchHistoryDropdown Component
* Liquid Glass design system compliant dropdown for search history
* Features: frosted glass effect, rounded-2xl corners, smooth animations
*/
'use client';
import { useEffect, useRef } from 'react';
import { Icons } from '@/components/ui/Icon';
import type { SearchHistoryItem } from '@/lib/store/search-history-store';
interface SearchHistoryDropdownProps {
isOpen: boolean;
searchHistory: SearchHistoryItem[];
highlightedIndex: number;
triggerRef: React.RefObject<HTMLInputElement | null>;
onSelectItem: (query: string) => void;
onRemoveItem: (query: string) => void;
onClearAll: () => void;
}
export function SearchHistoryDropdown({
isOpen,
searchHistory,
highlightedIndex,
triggerRef,
onSelectItem,
onRemoveItem,
onClearAll,
}: SearchHistoryDropdownProps) {
const dropdownRef = useRef<HTMLDivElement>(null);
// Position dropdown below input field
useEffect(() => {
if (!isOpen || !triggerRef.current || !dropdownRef.current) return;
const updatePosition = () => {
if (!triggerRef.current || !dropdownRef.current) return;
const inputRect = triggerRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const dropdownMaxHeight = 400;
const spaceBelow = viewportHeight - inputRect.bottom;
// Position below input
dropdownRef.current.style.top = `${inputRect.bottom + 8}px`;
dropdownRef.current.style.left = `${inputRect.left}px`;
dropdownRef.current.style.width = `${inputRect.width}px`;
// Adjust max height if not enough space
if (spaceBelow < dropdownMaxHeight) {
dropdownRef.current.style.maxHeight = `${spaceBelow - 20}px`;
} else {
dropdownRef.current.style.maxHeight = `${dropdownMaxHeight}px`;
}
};
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, { passive: true });
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition);
};
}, [isOpen, triggerRef]);
// Scroll highlighted item into view
useEffect(() => {
if (highlightedIndex === -1 || !dropdownRef.current) return;
const highlightedElement = dropdownRef.current.querySelector(
`[data-index="${highlightedIndex}"]`
);
if (highlightedElement) {
highlightedElement.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
});
}
}, [highlightedIndex]);
if (!isOpen || searchHistory.length === 0) {
return null;
}
return (
<div
ref={dropdownRef}
className="search-history-dropdown"
role="listbox"
aria-label="搜索历史"
>
{/* Header with clear all button */}
<div className="search-history-header">
<div className="flex items-center gap-2">
<Icons.Clock size={16} className="text-[var(--text-color-secondary)]" />
<span className="text-sm font-medium text-[var(--text-color-secondary)]">
</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClearAll();
}}
className="text-xs text-[var(--accent-color)] hover:underline transition-all"
aria-label="清除所有历史"
>
</button>
</div>
{/* Divider */}
<div className="search-history-divider" />
{/* History items */}
<div className="search-history-list">
{searchHistory.map((item, index) => (
<div
key={`${item.query}-${item.timestamp}`}
data-index={index}
role="option"
aria-selected={index === highlightedIndex}
className={`search-history-item ${
index === highlightedIndex ? 'highlighted' : ''
}`}
onClick={() => onSelectItem(item.query)}
onMouseEnter={() => {
// Visual feedback on hover
}}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<Icons.Search
size={16}
className="flex-shrink-0 text-[var(--text-color-secondary)]"
/>
<span className="text-[var(--text-color)] truncate flex-1">
{item.query}
</span>
{item.resultCount !== undefined && (
<span className="text-xs text-[var(--text-color-secondary)] flex-shrink-0">
{item.resultCount}
</span>
)}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveItem(item.query);
}}
className="search-history-remove"
aria-label={`删除 "${item.query}"`}
>
<Icons.X size={14} />
</button>
</div>
))}
</div>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
{
"timestamp": "2025-11-18T06:40:11.559Z",
"tests": [
{
"component": "Badge",
"variant": "Primary (Light)",
"foreground": "white",
"background": "#0056b3",
"ratio": 7.042135266678601,
"passAA": true,
"passAAA": true
},
{
"component": "Badge",
"variant": "Primary (Dark)",
"foreground": "white",
"background": "#1A6DBF",
"ratio": 5.271486985034207,
"passAA": true,
"passAAA": false
},
{
"component": "Badge",
"variant": "Secondary (Light)",
"foreground": "#1d1d1f",
"background": "#f2f2f7",
"ratio": 15.082365216973475,
"passAA": true,
"passAAA": true,
"notes": "Glass background approximated as solid color"
},
{
"component": "Button",
"variant": "Primary (Light)",
"foreground": "white",
"background": "#0056b3",
"ratio": 7.042135266678601,
"passAA": true,
"passAAA": true
},
{
"component": "Button",
"variant": "Secondary (Light)",
"foreground": "#1d1d1f",
"background": "#f2f2f7",
"ratio": 15.082365216973475,
"passAA": true,
"passAAA": true
},
{
"component": "TypeBadges",
"variant": "Selected (Light)",
"foreground": "white",
"background": "#0056b3",
"ratio": 7.042135266678601,
"passAA": true,
"passAAA": true
},
{
"component": "TypeBadges",
"variant": "Unselected (Light)",
"foreground": "#1d1d1f",
"background": "#f2f2f7",
"ratio": 15.082365216973475,
"passAA": true,
"passAAA": true
}
],
"summary": {
"total": 7,
"passedAA": 7,
"passedAAA": 6,
"failedAA": 0
}
}
+131
View File
@@ -0,0 +1,131 @@
/**
* useSearchHistory Hook
* Manages search history dropdown state and interactions
* Liquid Glass design system compliant
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useSearchHistoryStore } from '@/lib/store/search-history-store';
import type { SearchHistoryItem } from '@/lib/store/search-history-store';
interface UseSearchHistoryReturn {
searchHistory: SearchHistoryItem[];
isDropdownOpen: boolean;
highlightedIndex: number;
showDropdown: () => void;
hideDropdown: () => void;
addSearch: (query: string, resultCount?: number) => void;
removeSearch: (query: string) => void;
clearAll: () => void;
selectHistoryItem: (query: string) => void;
navigateDropdown: (direction: 'up' | 'down') => void;
resetHighlight: () => void;
}
export function useSearchHistory(
onSelectHistory?: (query: string) => void
): UseSearchHistoryReturn {
const {
searchHistory,
addToSearchHistory,
removeFromSearchHistory,
clearSearchHistory,
getRecentSearches,
} = useSearchHistoryStore();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const dropdownTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Get recent searches (limit to 10 for dropdown)
const recentSearches = getRecentSearches(10);
const showDropdown = useCallback(() => {
if (dropdownTimeoutRef.current) {
clearTimeout(dropdownTimeoutRef.current);
}
if (recentSearches.length > 0) {
setIsDropdownOpen(true);
setHighlightedIndex(-1);
}
}, [recentSearches.length]);
const hideDropdown = useCallback(() => {
// Delay hiding to allow click events to fire
dropdownTimeoutRef.current = setTimeout(() => {
setIsDropdownOpen(false);
setHighlightedIndex(-1);
}, 150);
}, []);
const addSearch = useCallback(
(query: string, resultCount?: number) => {
addToSearchHistory(query, resultCount);
},
[addToSearchHistory]
);
const removeSearch = useCallback(
(query: string) => {
removeFromSearchHistory(query);
},
[removeFromSearchHistory]
);
const clearAll = useCallback(() => {
clearSearchHistory();
setIsDropdownOpen(false);
}, [clearSearchHistory]);
const selectHistoryItem = useCallback(
(query: string) => {
if (onSelectHistory) {
onSelectHistory(query);
}
hideDropdown();
},
[onSelectHistory, hideDropdown]
);
const navigateDropdown = useCallback(
(direction: 'up' | 'down') => {
if (!isDropdownOpen || recentSearches.length === 0) return;
setHighlightedIndex((prevIndex) => {
if (direction === 'down') {
return prevIndex < recentSearches.length - 1 ? prevIndex + 1 : 0;
} else {
return prevIndex > 0 ? prevIndex - 1 : recentSearches.length - 1;
}
});
},
[isDropdownOpen, recentSearches.length]
);
const resetHighlight = useCallback(() => {
setHighlightedIndex(-1);
}, []);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (dropdownTimeoutRef.current) {
clearTimeout(dropdownTimeoutRef.current);
}
};
}, []);
return {
searchHistory: recentSearches,
isDropdownOpen,
highlightedIndex,
showDropdown,
hideDropdown,
addSearch,
removeSearch,
clearAll,
selectHistoryItem,
navigateDropdown,
resetHighlight,
};
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Search History Store using Zustand
* Manages search query history with localStorage persistence
* Following Liquid Glass design principles
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const MAX_HISTORY_ITEMS = 20;
export interface SearchHistoryItem {
query: string;
timestamp: number;
resultCount?: number;
}
interface SearchHistoryStore {
searchHistory: SearchHistoryItem[];
// Actions
addToSearchHistory: (query: string, resultCount?: number) => void;
removeFromSearchHistory: (query: string) => void;
clearSearchHistory: () => void;
getRecentSearches: (limit?: number) => SearchHistoryItem[];
}
/**
* Normalize query for comparison (trim, lowercase)
*/
function normalizeQuery(query: string): string {
return query.trim().toLowerCase();
}
export const useSearchHistoryStore = create<SearchHistoryStore>()(
persist(
(set, get) => ({
searchHistory: [],
addToSearchHistory: (query, resultCount) => {
const trimmedQuery = query.trim();
// Don't add empty queries
if (!trimmedQuery) return;
const normalized = normalizeQuery(trimmedQuery);
const timestamp = Date.now();
set((state) => {
// Check if query already exists (case-insensitive)
const existingIndex = state.searchHistory.findIndex(
(item) => normalizeQuery(item.query) === normalized
);
let newHistory: SearchHistoryItem[];
if (existingIndex !== -1) {
// Update existing item and move to top
const updatedItem: SearchHistoryItem = {
query: trimmedQuery, // Keep original casing from new search
timestamp,
resultCount,
};
newHistory = [
updatedItem,
...state.searchHistory.filter((_, index) => index !== existingIndex),
];
} else {
// Add new item at the top
const newItem: SearchHistoryItem = {
query: trimmedQuery,
timestamp,
resultCount,
};
newHistory = [newItem, ...state.searchHistory];
}
// Trim to max items
if (newHistory.length > MAX_HISTORY_ITEMS) {
newHistory = newHistory.slice(0, MAX_HISTORY_ITEMS);
}
return { searchHistory: newHistory };
});
},
removeFromSearchHistory: (query) => {
const normalized = normalizeQuery(query);
set((state) => ({
searchHistory: state.searchHistory.filter(
(item) => normalizeQuery(item.query) !== normalized
),
}));
},
clearSearchHistory: () => {
set({ searchHistory: [] });
},
getRecentSearches: (limit = 10) => {
const history = get().searchHistory;
return history.slice(0, limit);
},
}),
{
name: 'kvideo-search-history',
version: 1,
}
)
);
+232
View File
@@ -0,0 +1,232 @@
/**
* WCAG 2.2 Contrast Testing Script
* Tests color combinations against WCAG AA standards (4.5:1 for normal text, 3:1 for large text)
*/
// Simple contrast ratio calculator
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
function getLuminance(r: number, g: number, b: number): number {
const [rs, gs, bs] = [r, g, b].map(c => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
function getContrastRatio(color1: string, color2: string): number {
const rgb1 = hexToRgb(color1);
const rgb2 = hexToRgb(color2);
if (!rgb1 || !rgb2) return 0;
const lum1 = getLuminance(rgb1.r, rgb1.g, rgb1.b);
const lum2 = getLuminance(rgb2.r, rgb2.g, rgb2.b);
const brightest = Math.max(lum1, lum2);
const darkest = Math.min(lum1, lum2);
return (brightest + 0.05) / (darkest + 0.05);
}
interface ContrastTest {
component: string;
variant: string;
foreground: string;
background: string;
ratio: number;
passAA: boolean;
passAAA: boolean;
notes?: string;
}
// Color definitions from globals.css
const colors = {
light: {
text: '#1d1d1f',
textSecondary: '#6e6e73',
accent: '#0056b3', // Updated for WCAG compliance
glassBg: 'rgba(242, 242, 247, 0.8)', // Approximated as #f2f2f7
white: '#ffffff',
background: '#f0f2f5'
},
dark: {
text: '#f5f5f7',
textSecondary: '#8e8e93',
accent: '#1A6DBF', // Updated for WCAG compliance
glassBg: 'rgba(28, 28, 30, 0.75)', // Approximated as #1c1c1e
white: '#ffffff',
background: '#121212'
}
};
// Tests to run
const tests: ContrastTest[] = [];
console.log('🎨 KVideo WCAG 2.2 Contrast Testing Report\n');
console.log('='.repeat(80));
console.log('\n');
// Badge Tests
console.log('📛 BADGE COMPONENT\n');
// Badge Primary (Light Mode)
let ratio = getContrastRatio(colors.light.white, colors.light.accent);
tests.push({
component: 'Badge',
variant: 'Primary (Light)',
foreground: 'white',
background: colors.light.accent,
ratio: ratio,
passAA: ratio >= 4.5,
passAAA: ratio >= 7
});
console.log(` Primary (Light): ${colors.light.white} on ${colors.light.accent}`);
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
console.log('');
// Badge Primary (Dark Mode)
ratio = getContrastRatio(colors.dark.white, colors.dark.accent);
tests.push({
component: 'Badge',
variant: 'Primary (Dark)',
foreground: 'white',
background: colors.dark.accent,
ratio: ratio,
passAA: ratio >= 4.5,
passAAA: ratio >= 7
});
console.log(` Primary (Dark): ${colors.dark.white} on ${colors.dark.accent}`);
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
console.log('');
// Badge Secondary (Light Mode)
ratio = getContrastRatio(colors.light.text, '#f2f2f7'); // glass-bg approximation
tests.push({
component: 'Badge',
variant: 'Secondary (Light)',
foreground: colors.light.text,
background: '#f2f2f7',
ratio: ratio,
passAA: ratio >= 4.5,
passAAA: ratio >= 7,
notes: 'Glass background approximated as solid color'
});
console.log(` Secondary (Light): ${colors.light.text} on #f2f2f7`);
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
console.log('');
// Button Tests
console.log('🔘 BUTTON COMPONENT\n');
// Button Primary (Light Mode)
ratio = getContrastRatio(colors.light.white, colors.light.accent);
tests.push({
component: 'Button',
variant: 'Primary (Light)',
foreground: 'white',
background: colors.light.accent,
ratio: ratio,
passAA: ratio >= 4.5,
passAAA: ratio >= 7
});
console.log(` Primary (Light): white on ${colors.light.accent}`);
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
console.log('');
// Button Secondary (Light Mode)
ratio = getContrastRatio(colors.light.text, '#f2f2f7');
tests.push({
component: 'Button',
variant: 'Secondary (Light)',
foreground: colors.light.text,
background: '#f2f2f7',
ratio: ratio,
passAA: ratio >= 4.5,
passAAA: ratio >= 7
});
console.log(` Secondary (Light): ${colors.light.text} on #f2f2f7`);
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
console.log('');
// TypeBadges Tests
console.log('🏷️ TYPE BADGES COMPONENT\n');
// Selected state (Light Mode)
ratio = getContrastRatio(colors.light.white, colors.light.accent);
tests.push({
component: 'TypeBadges',
variant: 'Selected (Light)',
foreground: 'white',
background: colors.light.accent,
ratio: ratio,
passAA: ratio >= 4.5,
passAAA: ratio >= 7
});
console.log(` Selected (Light): white on ${colors.light.accent}`);
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
console.log('');
// Unselected state (Light Mode)
ratio = getContrastRatio(colors.light.text, '#f2f2f7');
tests.push({
component: 'TypeBadges',
variant: 'Unselected (Light)',
foreground: colors.light.text,
background: '#f2f2f7',
ratio: ratio,
passAA: ratio >= 4.5,
passAAA: ratio >= 7
});
console.log(` Unselected (Light): ${colors.light.text} on #f2f2f7`);
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
console.log('');
// Summary
console.log('\n');
console.log('='.repeat(80));
console.log('\n📊 SUMMARY\n');
const totalTests = tests.length;
const passedAA = tests.filter(t => t.passAA).length;
const passedAAA = tests.filter(t => t.passAAA).length;
console.log(`Total tests: ${totalTests}`);
console.log(`AA Standard (4.5:1): ${passedAA}/${totalTests} passed (${((passedAA/totalTests)*100).toFixed(1)}%)`);
console.log(`AAA Standard (7:1): ${passedAAA}/${totalTests} passed (${((passedAAA/totalTests)*100).toFixed(1)}%)`);
console.log('');
if (passedAA < totalTests) {
console.log('⚠️ Some color combinations need adjustment to meet WCAG AA standards.\n');
}
// Export results
const results = {
timestamp: new Date().toISOString(),
tests,
summary: {
total: totalTests,
passedAA,
passedAAA,
failedAA: totalTests - passedAA
}
};
console.log('Results exported to: contrast-test-results.json\n');
// This would write to file in a Node environment
// For browser, you'd use different storage methods
if (typeof require !== 'undefined') {
const fs = require('fs');
fs.writeFileSync(
'contrast-test-results.json',
JSON.stringify(results, null, 2)
);
}