diff --git a/docs/Tabs.md b/docs/Tabs.md index 0d6cbfb..f55b9f7 100644 --- a/docs/Tabs.md +++ b/docs/Tabs.md @@ -2,6 +2,10 @@ ## Overview + + + + The `Tabs` component replaces the inline tab navigation in `ApiDetailPage` with a reusable, fully accessible tab strip featuring a **smooth sliding ink-bar indicator** that animates between tabs using CSS `transition` driven by DOM geometry measurements. No animation libraries required. --- diff --git a/src/ThemeContext.tsx b/src/ThemeContext.tsx index 19c87a1..d1982e7 100644 --- a/src/ThemeContext.tsx +++ b/src/ThemeContext.tsx @@ -1,7 +1,6 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; import './styles/theme-transition.css'; - -type Theme = 'light' | 'dark' | 'system'; +import { getPref, setPref, type Theme } from './utils/userPrefs'; interface ThemeContextType { theme: Theme; @@ -13,8 +12,7 @@ const ThemeContext = createContext(undefined); export function ThemeProvider({ children }: { children: React.ReactNode }) { const [theme, setTheme] = useState(() => { - const saved = localStorage.getItem('callora-theme'); - return (saved as Theme) || 'dark'; + return getPref('theme'); }); const [actualTheme, setActualTheme] = useState<'light' | 'dark'>(() => { @@ -34,7 +32,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { }, []); useEffect(() => { - localStorage.setItem('callora-theme', theme); + setPref('theme', theme); const root = window.document.documentElement; diff --git a/src/data/mockApis.ts b/src/data/mockApis.ts index 959fc30..49a5330 100644 --- a/src/data/mockApis.ts +++ b/src/data/mockApis.ts @@ -7,6 +7,8 @@ export type Review = { verified: boolean; }; + + export type APIItem = { id: string; name: string; diff --git a/src/utils/density.ts b/src/utils/density.ts index bde3426..fb675b5 100644 --- a/src/utils/density.ts +++ b/src/utils/density.ts @@ -1,27 +1,11 @@ -export type DensityPreference = "comfortable" | "compact"; +import { getPref, setPref, type DensityPreference } from './userPrefs'; -export const DENSITY_STORAGE_KEY = "callora.density"; - -const DEFAULT_DENSITY: DensityPreference = "comfortable"; -const VALID_DENSITIES: DensityPreference[] = ["comfortable", "compact"]; +export { type DensityPreference }; export function readDensityPreference(): DensityPreference { - if (typeof window === "undefined") { - return DEFAULT_DENSITY; - } - - const storedValue = window.localStorage.getItem(DENSITY_STORAGE_KEY); - if (storedValue && VALID_DENSITIES.includes(storedValue as DensityPreference)) { - return storedValue as DensityPreference; - } - - return DEFAULT_DENSITY; + return getPref('density'); } export function persistDensityPreference(density: DensityPreference): void { - if (typeof window === "undefined") { - return; - } - - window.localStorage.setItem(DENSITY_STORAGE_KEY, density); + setPref('density', density); } diff --git a/src/utils/userPrefs.test.ts b/src/utils/userPrefs.test.ts new file mode 100644 index 0000000..623ad4c --- /dev/null +++ b/src/utils/userPrefs.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { getPref, setPref, readAllPrefs, PREFS_STORAGE_KEY, DEFAULT_PREFS } from './userPrefs'; + +describe('userPrefs', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + it('returns default preferences when no data in localStorage', () => { + const prefs = readAllPrefs(); + expect(prefs).toEqual(DEFAULT_PREFS); + }); + + it('migrates legacy theme key on first load', () => { + window.localStorage.setItem('callora-theme', 'light'); + const prefs = readAllPrefs(); + + expect(prefs.theme).toBe('light'); + expect(window.localStorage.getItem('callora-theme')).toBeNull(); // Should be removed + + const savedNewPrefs = JSON.parse(window.localStorage.getItem(PREFS_STORAGE_KEY) || '{}'); + expect(savedNewPrefs.theme).toBe('light'); + }); + + it('migrates legacy density key on first load', () => { + window.localStorage.setItem('callora.density', 'compact'); + const prefs = readAllPrefs(); + + expect(prefs.density).toBe('compact'); + expect(window.localStorage.getItem('callora.density')).toBeNull(); // Should be removed + }); + + it('ignores invalid legacy values during migration', () => { + window.localStorage.setItem('callora-theme', 'invalid-theme'); + window.localStorage.setItem('callora.density', 'invalid-density'); + + const prefs = readAllPrefs(); + expect(prefs.theme).toBe('dark'); // Fallback to default + expect(prefs.density).toBe('comfortable'); // Fallback to default + }); + + it('handles JSON parse failures gracefully', () => { + window.localStorage.setItem(PREFS_STORAGE_KEY, '{invalid json'); + const prefs = readAllPrefs(); + + expect(prefs).toEqual(DEFAULT_PREFS); // Should recover and return defaults + }); + + it('merges stored prefs with defaults if fields are missing', () => { + window.localStorage.setItem(PREFS_STORAGE_KEY, JSON.stringify({ theme: 'light' })); + const prefs = readAllPrefs(); + + expect(prefs.theme).toBe('light'); + expect(prefs.density).toBe('comfortable'); // From defaults + }); + + it('allows getting and setting single preferences', () => { + setPref('pageSize', 24); + expect(getPref('pageSize')).toBe(24); + + const raw = JSON.parse(window.localStorage.getItem(PREFS_STORAGE_KEY) || '{}'); + expect(raw.pageSize).toBe(24); + }); +}); diff --git a/src/utils/userPrefs.ts b/src/utils/userPrefs.ts new file mode 100644 index 0000000..6262b6e --- /dev/null +++ b/src/utils/userPrefs.ts @@ -0,0 +1,86 @@ +export type Theme = 'light' | 'dark' | 'system'; +export type DensityPreference = 'comfortable' | 'compact'; + +export interface UserPrefs { + theme: Theme; + density: DensityPreference; + pageSize: number; +} + +export const DEFAULT_PREFS: UserPrefs = { + theme: 'dark', + density: 'comfortable', + pageSize: 12, +}; + +export const PREFS_STORAGE_KEY = 'callora.prefs'; + +function migrateLegacyKeys(prefs: Partial): Partial { + const updatedPrefs = { ...prefs }; + + if (typeof window === 'undefined') return updatedPrefs; + + const legacyTheme = window.localStorage.getItem('callora-theme'); + if (legacyTheme && !updatedPrefs.theme) { + if (['light', 'dark', 'system'].includes(legacyTheme)) { + updatedPrefs.theme = legacyTheme as Theme; + } + window.localStorage.removeItem('callora-theme'); + } + + const legacyDensity = window.localStorage.getItem('callora.density'); + if (legacyDensity && !updatedPrefs.density) { + if (['comfortable', 'compact'].includes(legacyDensity)) { + updatedPrefs.density = legacyDensity as DensityPreference; + } + window.localStorage.removeItem('callora.density'); + } + + return updatedPrefs; +} + +export function readAllPrefs(): UserPrefs { + if (typeof window === 'undefined') return { ...DEFAULT_PREFS }; + + const raw = window.localStorage.getItem(PREFS_STORAGE_KEY); + let parsed: Partial = {}; + + if (raw) { + try { + parsed = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null) { + parsed = {}; + } + } catch (e) { + // Parse failure + parsed = {}; + } + } + + const migrated = migrateLegacyKeys(parsed); + + const finalPrefs: UserPrefs = { + ...DEFAULT_PREFS, + ...migrated, + }; + + // Ensure any migrations or fixes are persisted immediately + if (JSON.stringify(finalPrefs) !== raw && typeof window !== 'undefined') { + window.localStorage.setItem(PREFS_STORAGE_KEY, JSON.stringify(finalPrefs)); + } + + return finalPrefs; +} + +export function getPref(key: K): UserPrefs[K] { + const prefs = readAllPrefs(); + return prefs[key]; +} + +export function setPref(key: K, value: UserPrefs[K]): void { + const prefs = readAllPrefs(); + prefs[key] = value; + if (typeof window !== 'undefined') { + window.localStorage.setItem(PREFS_STORAGE_KEY, JSON.stringify(prefs)); + } +}