Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/Tabs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
8 changes: 3 additions & 5 deletions src/ThemeContext.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,8 +12,7 @@ const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
const saved = localStorage.getItem('callora-theme');
return (saved as Theme) || 'dark';
return getPref('theme');
});

const [actualTheme, setActualTheme] = useState<'light' | 'dark'>(() => {
Expand All @@ -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;

Expand Down
2 changes: 2 additions & 0 deletions src/data/mockApis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export type Review = {
verified: boolean;
};



export type APIItem = {
id: string;
name: string;
Expand Down
24 changes: 4 additions & 20 deletions src/utils/density.ts
Original file line number Diff line number Diff line change
@@ -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);
}
69 changes: 69 additions & 0 deletions src/utils/userPrefs.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
86 changes: 86 additions & 0 deletions src/utils/userPrefs.ts
Original file line number Diff line number Diff line change
@@ -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<UserPrefs>): Partial<UserPrefs> {
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<UserPrefs> = {};

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<K extends keyof UserPrefs>(key: K): UserPrefs[K] {
const prefs = readAllPrefs();
return prefs[key];
}

export function setPref<K extends keyof UserPrefs>(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));
}
}