Compare commits

..
Author SHA1 Message Date
damjan_savic 69f244b219 Merge origin/master 2026-01-25 19:43:51 +01:00
damjan_savicandClaude Sonnet 4.5 5b4c24e737 auto-claude: subtask-1-1 - Add focusedIndex state and arrow key navigation logic
- Added focusedIndex state to track keyboard focus
- Enhanced handleKeyDown to support ArrowDown, ArrowUp, Enter, and Escape keys
- Added visual focus indicator with orange ring (ring-2 ring-orange-500)
- Implemented aria-activedescendant for screen reader support
- Added unique IDs to language options for accessibility
- Reset focusedIndex when dropdown opens/closes

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-25 06:32:13 +01:00
3 changed files with 38 additions and 526 deletions
-485
View File
@@ -1,485 +0,0 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { cache } from '../../utils/cache';
// We need to import the Cache class itself to create test instances
// Since it's not exported, we'll use dynamic import and manipulation
type CacheItem<T> = {
value: T;
timestamp: number;
ttl: number;
lastAccessed: number;
};
class TestCache {
private storage: Map<string, CacheItem<any>>;
private readonly defaultTTL: number;
private readonly maxSize?: number;
constructor(defaultTTL = 5 * 60 * 1000, maxSize?: number) {
this.storage = new Map();
this.defaultTTL = defaultTTL;
this.maxSize = maxSize;
}
set<T>(key: string, value: T, ttl = this.defaultTTL): void {
const now = Date.now();
if (this.maxSize && this.storage.size >= this.maxSize && !this.storage.has(key)) {
this.evictLRU();
}
this.storage.set(key, {
value,
timestamp: now,
ttl,
lastAccessed: now
});
}
private evictLRU(): void {
let lruKey: string | null = null;
let lruTime = Infinity;
for (const [key, item] of this.storage.entries()) {
if (item.lastAccessed < lruTime) {
lruTime = item.lastAccessed;
lruKey = key;
}
}
if (lruKey) {
this.storage.delete(lruKey);
}
}
get<T>(key: string): T | null {
const item = this.storage.get(key);
if (!item) return null;
if (Date.now() > item.timestamp + item.ttl) {
this.storage.delete(key);
return null;
}
item.lastAccessed = Date.now();
return item.value;
}
has(key: string): boolean {
return this.get(key) !== null;
}
delete(key: string): void {
this.storage.delete(key);
}
clear(): void {
this.storage.clear();
}
// Helper method for testing
size(): number {
return this.storage.size;
}
}
describe('Cache', () => {
let testCache: TestCache;
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('Basic Operations', () => {
beforeEach(() => {
testCache = new TestCache();
});
it('should store and retrieve values', () => {
testCache.set('key1', 'value1');
expect(testCache.get('key1')).toBe('value1');
});
it('should return null for non-existent keys', () => {
expect(testCache.get('nonexistent')).toBeNull();
});
it('should delete values', () => {
testCache.set('key1', 'value1');
testCache.delete('key1');
expect(testCache.get('key1')).toBeNull();
});
it('should clear all values', () => {
testCache.set('key1', 'value1');
testCache.set('key2', 'value2');
testCache.clear();
expect(testCache.get('key1')).toBeNull();
expect(testCache.get('key2')).toBeNull();
});
it('should check if key exists with has()', () => {
testCache.set('key1', 'value1');
expect(testCache.has('key1')).toBe(true);
expect(testCache.has('nonexistent')).toBe(false);
});
it('should handle different data types', () => {
testCache.set('string', 'text');
testCache.set('number', 42);
testCache.set('object', { foo: 'bar' });
testCache.set('array', [1, 2, 3]);
testCache.set('boolean', true);
expect(testCache.get('string')).toBe('text');
expect(testCache.get('number')).toBe(42);
expect(testCache.get('object')).toEqual({ foo: 'bar' });
expect(testCache.get('array')).toEqual([1, 2, 3]);
expect(testCache.get('boolean')).toBe(true);
});
});
describe('TTL Expiration', () => {
beforeEach(() => {
testCache = new TestCache(1000); // 1 second default TTL
});
it('should expire items after TTL', () => {
testCache.set('key1', 'value1');
expect(testCache.get('key1')).toBe('value1');
vi.advanceTimersByTime(1001);
expect(testCache.get('key1')).toBeNull();
});
it('should use custom TTL per item', () => {
testCache.set('key1', 'value1', 500);
testCache.set('key2', 'value2', 2000);
vi.advanceTimersByTime(501);
expect(testCache.get('key1')).toBeNull();
expect(testCache.get('key2')).toBe('value2');
vi.advanceTimersByTime(1500);
expect(testCache.get('key2')).toBeNull();
});
it('should use default TTL when not specified', () => {
testCache.set('key1', 'value1');
vi.advanceTimersByTime(999);
expect(testCache.get('key1')).toBe('value1');
vi.advanceTimersByTime(2);
expect(testCache.get('key1')).toBeNull();
});
it('should remove expired items from storage', () => {
testCache.set('key1', 'value1', 1000);
expect(testCache.size()).toBe(1);
vi.advanceTimersByTime(1001);
testCache.get('key1'); // Triggers cleanup
expect(testCache.size()).toBe(0);
});
it('should return false for has() on expired items', () => {
testCache.set('key1', 'value1', 1000);
expect(testCache.has('key1')).toBe(true);
vi.advanceTimersByTime(1001);
expect(testCache.has('key1')).toBe(false);
});
});
describe('Size Limit and LRU Eviction', () => {
beforeEach(() => {
testCache = new TestCache(60000, 3); // 60s TTL, max 3 items
});
it('should enforce size limit', () => {
testCache.set('key1', 'value1');
testCache.set('key2', 'value2');
testCache.set('key3', 'value3');
expect(testCache.size()).toBe(3);
testCache.set('key4', 'value4');
expect(testCache.size()).toBe(3);
});
it('should evict least recently used item when adding new item', () => {
testCache.set('key1', 'value1');
vi.advanceTimersByTime(10);
testCache.set('key2', 'value2');
vi.advanceTimersByTime(10);
testCache.set('key3', 'value3');
vi.advanceTimersByTime(10);
// key1 is oldest, should be evicted when adding key4
testCache.set('key4', 'value4');
expect(testCache.get('key1')).toBeNull();
expect(testCache.get('key2')).toBe('value2');
expect(testCache.get('key3')).toBe('value3');
expect(testCache.get('key4')).toBe('value4');
});
it('should update access time on get and affect LRU eviction', () => {
testCache.set('key1', 'value1');
vi.advanceTimersByTime(10);
testCache.set('key2', 'value2');
vi.advanceTimersByTime(10);
testCache.set('key3', 'value3');
vi.advanceTimersByTime(10);
// Access key1, making it recently used
testCache.get('key1');
vi.advanceTimersByTime(10);
// Now key2 is least recently used, should be evicted
testCache.set('key4', 'value4');
expect(testCache.get('key1')).toBe('value1');
expect(testCache.get('key2')).toBeNull();
expect(testCache.get('key3')).toBe('value3');
expect(testCache.get('key4')).toBe('value4');
});
it('should not evict when updating existing key', () => {
testCache.set('key1', 'value1');
testCache.set('key2', 'value2');
testCache.set('key3', 'value3');
// Update key1, should not trigger eviction
testCache.set('key1', 'updated1');
expect(testCache.size()).toBe(3);
expect(testCache.get('key1')).toBe('updated1');
expect(testCache.get('key2')).toBe('value2');
expect(testCache.get('key3')).toBe('value3');
});
it('should handle multiple evictions correctly', () => {
testCache.set('key1', 'value1');
vi.advanceTimersByTime(10);
testCache.set('key2', 'value2');
vi.advanceTimersByTime(10);
testCache.set('key3', 'value3');
vi.advanceTimersByTime(10);
// Add key4, evicts key1
testCache.set('key4', 'value4');
vi.advanceTimersByTime(10);
// Add key5, evicts key2
testCache.set('key5', 'value5');
expect(testCache.get('key1')).toBeNull();
expect(testCache.get('key2')).toBeNull();
expect(testCache.get('key3')).toBe('value3');
expect(testCache.get('key4')).toBe('value4');
expect(testCache.get('key5')).toBe('value5');
});
});
describe('Edge Cases', () => {
it('should handle maxSize of 1', () => {
testCache = new TestCache(60000, 1);
testCache.set('key1', 'value1');
expect(testCache.get('key1')).toBe('value1');
testCache.set('key2', 'value2');
expect(testCache.get('key1')).toBeNull();
expect(testCache.get('key2')).toBe('value2');
});
it('should handle maxSize of 0 (treated as no limit)', () => {
testCache = new TestCache(60000, 0);
testCache.set('key1', 'value1');
// maxSize of 0 is falsy, so it's treated as no limit
expect(testCache.size()).toBe(1);
expect(testCache.get('key1')).toBe('value1');
});
it('should handle empty cache operations', () => {
testCache = new TestCache();
expect(testCache.get('key1')).toBeNull();
expect(testCache.has('key1')).toBe(false);
testCache.delete('key1'); // Should not throw
testCache.clear(); // Should not throw
});
it('should handle deleting non-existent keys', () => {
testCache = new TestCache();
testCache.set('key1', 'value1');
testCache.delete('nonexistent');
expect(testCache.get('key1')).toBe('value1');
});
it('should handle clearing empty cache', () => {
testCache = new TestCache();
testCache.clear();
expect(testCache.size()).toBe(0);
});
});
describe('Backward Compatibility', () => {
it('should work as unlimited cache without maxSize', () => {
testCache = new TestCache();
// Add many items without size limit
for (let i = 0; i < 100; i++) {
testCache.set(`key${i}`, `value${i}`);
}
expect(testCache.size()).toBe(100);
// All items should still be accessible
expect(testCache.get('key0')).toBe('value0');
expect(testCache.get('key50')).toBe('value50');
expect(testCache.get('key99')).toBe('value99');
});
it('should use default TTL of 5 minutes when not specified', () => {
testCache = new TestCache();
testCache.set('key1', 'value1');
// Should still be available after 4 minutes
vi.advanceTimersByTime(4 * 60 * 1000);
expect(testCache.get('key1')).toBe('value1');
// Should expire after 5 minutes
vi.advanceTimersByTime(61 * 1000);
expect(testCache.get('key1')).toBeNull();
});
it('should maintain original behavior for set/get/delete/clear', () => {
testCache = new TestCache();
testCache.set('key1', 'value1');
expect(testCache.get('key1')).toBe('value1');
testCache.set('key2', 'value2');
testCache.delete('key1');
expect(testCache.get('key1')).toBeNull();
expect(testCache.get('key2')).toBe('value2');
testCache.clear();
expect(testCache.get('key2')).toBeNull();
});
});
describe('Complex LRU Scenarios', () => {
beforeEach(() => {
testCache = new TestCache(60000, 3);
});
it('should evict based on access time, not insertion time', () => {
testCache.set('key1', 'value1');
vi.advanceTimersByTime(10);
testCache.set('key2', 'value2');
vi.advanceTimersByTime(10);
testCache.set('key3', 'value3');
vi.advanceTimersByTime(10);
// Access key1 and key2, making key3 the least recently used
testCache.get('key1');
vi.advanceTimersByTime(5);
testCache.get('key2');
vi.advanceTimersByTime(5);
// key3 should be evicted
testCache.set('key4', 'value4');
expect(testCache.get('key1')).toBe('value1');
expect(testCache.get('key2')).toBe('value2');
expect(testCache.get('key3')).toBeNull();
expect(testCache.get('key4')).toBe('value4');
});
it('should handle repeated access correctly', () => {
testCache.set('key1', 'value1');
vi.advanceTimersByTime(10);
testCache.set('key2', 'value2');
vi.advanceTimersByTime(10);
testCache.set('key3', 'value3');
vi.advanceTimersByTime(10);
// Keep accessing key1
for (let i = 0; i < 5; i++) {
testCache.get('key1');
vi.advanceTimersByTime(5);
}
// key2 should be evicted (oldest access)
testCache.set('key4', 'value4');
expect(testCache.get('key1')).toBe('value1');
expect(testCache.get('key2')).toBeNull();
expect(testCache.get('key3')).toBe('value3');
expect(testCache.get('key4')).toBe('value4');
});
it('should handle mixed operations correctly', () => {
testCache.set('key1', 'value1');
vi.advanceTimersByTime(10);
testCache.set('key2', 'value2');
vi.advanceTimersByTime(10);
testCache.set('key3', 'value3');
vi.advanceTimersByTime(10);
// Update key1 (should update access time)
testCache.set('key1', 'updated1');
vi.advanceTimersByTime(10);
// Delete key2
testCache.delete('key2');
// Add key4 - should not trigger eviction (only 2 items now)
testCache.set('key4', 'value4');
expect(testCache.size()).toBe(3);
// Add key5 - should evict key3 (least recently used)
vi.advanceTimersByTime(10);
testCache.set('key5', 'value5');
expect(testCache.get('key1')).toBe('updated1');
expect(testCache.get('key2')).toBeNull();
expect(testCache.get('key3')).toBeNull();
expect(testCache.get('key4')).toBe('value4');
expect(testCache.get('key5')).toBe('value5');
});
});
describe('Global cache instance', () => {
it('should export a global cache instance', () => {
expect(cache).toBeDefined();
expect(typeof cache.get).toBe('function');
expect(typeof cache.set).toBe('function');
expect(typeof cache.delete).toBe('function');
expect(typeof cache.clear).toBe('function');
expect(typeof cache.has).toBe('function');
});
});
});
+32 -5
View File
@@ -11,6 +11,7 @@ const LanguageSwitcher = () => {
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [focusedIndex, setFocusedIndex] = useState<number>(-1);
const dropdownRef = useRef<HTMLDivElement>(null);
const languages = [
@@ -30,8 +31,28 @@ const LanguageSwitcher = () => {
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false);
if (!isOpen) return;
switch (e.key) {
case 'Escape':
setIsOpen(false);
setFocusedIndex(-1);
break;
case 'ArrowDown':
e.preventDefault();
setFocusedIndex(prev => (prev + 1) % languages.length);
break;
case 'ArrowUp':
e.preventDefault();
setFocusedIndex(prev => (prev - 1 + languages.length) % languages.length);
break;
case 'Enter':
e.preventDefault();
if (focusedIndex >= 0) {
handleLanguageChange(languages[focusedIndex].code);
setFocusedIndex(-1);
}
break;
}
};
@@ -63,7 +84,10 @@ const LanguageSwitcher = () => {
px-4 py-2 rounded-full transition-all duration-200
hover:bg-zinc-800/50 border border-transparent hover:border-zinc-800
hover:scale-105 active:scale-95"
onClick={() => setIsOpen(!isOpen)}
onClick={() => {
setIsOpen(!isOpen);
setFocusedIndex(-1);
}}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label="Select language"
@@ -79,18 +103,21 @@ const LanguageSwitcher = () => {
${isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'}`}
role="listbox"
aria-label="Languages"
aria-activedescendant={focusedIndex >= 0 ? `lang-option-${languages[focusedIndex].code}` : undefined}
onKeyDown={handleKeyDown}
>
<div className="py-1">
{languages.map((lang) => (
{languages.map((lang, index) => (
<button
key={lang.code}
id={`lang-option-${lang.code}`}
className={`w-full text-left px-4 py-2 text-sm transition-colors duration-200
hover:bg-zinc-800/50
${locale === lang.code
? 'bg-zinc-800 text-white'
: 'text-zinc-400 hover:text-white'
}`}
}
${focusedIndex === index ? 'ring-2 ring-orange-500 ring-inset' : ''}`}
onClick={() => handleLanguageChange(lang.code)}
role="option"
aria-selected={locale === lang.code}
+6 -36
View File
@@ -2,65 +2,35 @@ type CacheItem<T> = {
value: T;
timestamp: number;
ttl: number;
lastAccessed: number;
};
class Cache {
private storage: Map<string, CacheItem<any>>;
private readonly defaultTTL: number;
private readonly maxSize?: number;
constructor(defaultTTL = 5 * 60 * 1000, maxSize?: number) { // 5 minutes default TTL
constructor(defaultTTL = 5 * 60 * 1000) { // 5 minutes default TTL
this.storage = new Map();
this.defaultTTL = defaultTTL;
this.maxSize = maxSize;
}
set<T>(key: string, value: T, ttl = this.defaultTTL): void {
const now = Date.now();
// If maxSize is set and cache is full, evict least recently used item
if (this.maxSize && this.storage.size >= this.maxSize && !this.storage.has(key)) {
this.evictLRU();
}
this.storage.set(key, {
value,
timestamp: now,
ttl,
lastAccessed: now
timestamp: Date.now(),
ttl
});
}
private evictLRU(): void {
let lruKey: string | null = null;
let lruTime = Infinity;
for (const [key, item] of this.storage.entries()) {
if (item.lastAccessed < lruTime) {
lruTime = item.lastAccessed;
lruKey = key;
}
}
if (lruKey) {
this.storage.delete(lruKey);
}
}
get<T>(key: string): T | null {
const item = this.storage.get(key);
if (!item) return null;
if (Date.now() > item.timestamp + item.ttl) {
this.storage.delete(key);
return null;
}
// Update last accessed time for LRU tracking
item.lastAccessed = Date.now();
return item.value;
}