Skip to content
This repository was archived by the owner on Mar 7, 2026. It is now read-only.
Merged
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: 2 additions & 2 deletions docs/project_plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ A pragmatic breakdown into **four one‑week sprints** plus a preparatory **Spri
| # | Task | DoD | Status |
| --- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ------ |
| 2.1 | Add NumberInput, Select, Checkbox, RadioGroup components. | Unit & a11y tests pass; form story displays all. | ✓ |
| 2.2 | Integrate **React Hook Form + Zod**; create `FormGrid` + `FormGroup`. | Story "Form Example" submits & reports validation errors in Storybook interaction test. | PR |
| 2.3 | Implement Zustand session store skeleton with dark‑mode flag. | Vitest verifies default state + setter actions. | |
| 2.2 | Integrate **React Hook Form + Zod**; create `FormGrid` + `FormGroup`. | Story "Form Example" submits & reports validation errors in Storybook interaction test. | |
| 2.3 | Implement Zustand session store skeleton with dark‑mode flag. | Vitest verifies default state + setter actions. | PR |
| 2.4 | ESLint rule enforcing named `useEffect` & cleanup. | Failing example in test repo triggers lint error; real code passes. | |
| 2.5 | Extend CI to run axe‑core on all stories. | Pipeline fails if any new a11y violations introduced. | |

Expand Down
87 changes: 87 additions & 0 deletions docs/task-planning/task-2.3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Task 2.3 Planning: Zustand Session Store with Dark Mode

## Overview

This task involves implementing a Zustand session store to manage application state, with a specific focus on implementing a dark mode toggle feature. Zustand is a small, fast, and scalable state management solution that will help keep UI state synchronized across components.

## Task Breakdown

| Task Description | Definition of Done (DoD) | Status |
| ------------------------------------- | -------------------------------------------------------- | -------- |
| Set up Zustand dependency | Package installed and configured | complete |
| Create basic store structure | Store created with typed state and actions | complete |
| Implement dark mode state and actions | Store includes dark mode flag and toggle/set actions | complete |
| Add persistence middleware | Dark mode preference persists across page refreshes | complete |
| Create useTheme hook | Hook provides easy access to theme state and actions | complete |
| Create ThemeProvider component | Provider sets up theme context and initial state | complete |
| Add theme toggle component | UI component allows switching between light/dark modes | complete |
| Implement system preference detection | Auto-detect user's system preference for dark/light mode | complete |
| Add unit tests for store and hooks | Tests verify state changes and persistence | complete |
| Update theme CSS variables | CSS variables change based on theme state | complete |
| Document usage patterns | Documentation added for theme store and components | complete |

## Implementation Strategy

### Store Architecture

The Zustand store will follow a slice pattern to keep code modular:

```typescript
interface ThemeState {
isDarkMode: boolean;
setDarkMode: (isDark: boolean) => void;
toggleDarkMode: () => void;
}

interface UIState {
// Other UI state can go here in the future (sidebar, modals, etc.)
}

// Combined store type
type SessionState = ThemeState & UIState;
```

### Persistence Strategy

We'll use Zustand's persist middleware to save preferences to localStorage:

```typescript
import { create } from "zustand";
import { persist } from "zustand/middleware";

export const useSessionStore = create<SessionState>()(
persist(
(set) => ({
// Initial state and actions
isDarkMode: false,
setDarkMode: (isDark) => set({ isDarkMode: isDark }),
toggleDarkMode: () => set((state) => ({ isDarkMode: !state.isDarkMode })),
}),
{
name: "ui-kit-session", // localStorage key
partialize: (state) => ({ isDarkMode: state.isDarkMode }), // Only persist theme settings
},
),
);
```

### Theme Provider Implementation

The ThemeProvider will handle:

1. Reading initial state (localStorage + system preference)
2. Setting the correct class on the HTML/body element
3. Providing theme context to components

### Testing Approach

- Unit tests with Vitest for store and actions
- Mock localStorage for persistence tests
- Test system preference detection with window.matchMedia mocks
- Test ThemeProvider initial state and updates

## Integration with UI Components

- Theme toggle component will use the store
- Base components will adapt to theme via CSS variables
- No explicit theme prop needed on components (CSS-based theming)
3 changes: 2 additions & 1 deletion packages/ui-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"react-hook-form": "^7.56.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.25.7"
"zod": "^3.25.7",
"zustand": "^5.0.4"
},
"devDependencies": {
"@chromatic-com/storybook": "^1.9.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { Meta, StoryObj } from '@storybook/react';
import { ThemeToggle } from './ThemeToggle';

const meta: Meta<typeof ThemeToggle> = {
title: 'Components/Primitives/ThemeToggle',
component: ThemeToggle,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
argTypes: {
size: {
control: {
type: 'select',
options: ['sm', 'md', 'lg'],
},
description: 'Size of the toggle button',
table: {
defaultValue: { summary: 'md' },
},
},
onToggle: {
action: 'toggled',
description: 'Callback when theme is toggled',
},
className: {
control: 'text',
description: 'Additional CSS classes',
},
},
};

export default meta;
type Story = StoryObj<typeof ThemeToggle>;

export const Default: Story = {
args: {},
};

export const Small: Story = {
args: {
size: 'sm',
},
};

export const Large: Story = {
args: {
size: 'lg',
},
};

export const CustomStyle: Story = {
args: {
className: 'bg-secondary text-secondary-content',
},
};

export const WithCallback: Story = {
args: {},
render: (args) => (
<ThemeToggle
{...args}
onToggle={(isDark) => {
console.log(`Theme toggled to ${isDark ? 'dark' : 'light'} mode`);
args.onToggle?.(isDark);
}}
/>
),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useTheme } from '../../../hooks/useTheme';
import { cn } from '../../../utils';

export interface ThemeToggleProps {
/**
* Additional class names to apply to the toggle
*/
className?: string;

/**
* Callback when theme is toggled
*/
onToggle?: (isDarkMode: boolean) => void;

/**
* Size of the toggle button
* @default 'md'
*/
size?: 'sm' | 'md' | 'lg';
}

/**
* A toggle button that allows users to switch between light and dark modes
*/
export function ThemeToggle({
className,
onToggle,
size = 'md',
}: ThemeToggleProps) {
const { isDarkMode, toggleDarkMode } = useTheme();

const handleToggle = () => {
toggleDarkMode();
if (onToggle) {
onToggle(!isDarkMode); // Pass the new state
}
};

const sizeClasses = {
sm: 'h-8 w-8',
md: 'h-10 w-10',
lg: 'h-12 w-12',
};

return (
<button
type="button"
onClick={handleToggle}
className={cn(
'rounded-full p-2 flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary',
isDarkMode ? 'text-primary-content bg-primary' : 'text-base-content bg-base-200',
sizeClasses[size],
className
)}
aria-label={isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'}
>
{isDarkMode ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
</svg>
)}
</button>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { render, fireEvent } from '@testing-library/react';
import { ThemeToggle } from '../ThemeToggle';
import { vi } from 'vitest';
import { useTheme } from '../../../../hooks/useTheme';

// Mock the useTheme hook
vi.mock('../../../../hooks/useTheme', () => ({
useTheme: vi.fn(),
}));

describe('ThemeToggle', () => {
const mockToggleDarkMode = vi.fn();

beforeEach(() => {
vi.clearAllMocks();

// Default mock implementation
(useTheme as jest.Mock).mockReturnValue({
isDarkMode: false,
toggleDarkMode: mockToggleDarkMode,
});
});

it('renders correctly in light mode', () => {
const { container } = render(<ThemeToggle />);

// In light mode, it should show the moon icon (to switch to dark)
const button = container.querySelector('button');
expect(button).toHaveAttribute('aria-label', 'Switch to dark mode');
});

it('renders correctly in dark mode', () => {
// Mock dark mode
(useTheme as jest.Mock).mockReturnValue({
isDarkMode: true,
toggleDarkMode: mockToggleDarkMode,
});

const { container } = render(<ThemeToggle />);

// In dark mode, it should show the sun icon (to switch to light)
const button = container.querySelector('button');
expect(button).toHaveAttribute('aria-label', 'Switch to light mode');
});

it('calls toggleDarkMode when clicked', () => {
const { container } = render(<ThemeToggle />);

const button = container.querySelector('button');
fireEvent.click(button!);

expect(mockToggleDarkMode).toHaveBeenCalledTimes(1);
});

it('calls onToggle prop with the new state when clicked', () => {
const mockOnToggle = vi.fn();
const { container } = render(<ThemeToggle onToggle={mockOnToggle} />);

// Starting in light mode, clicking should pass true (new dark mode state)
const button = container.querySelector('button');
fireEvent.click(button!);

expect(mockOnToggle).toHaveBeenCalledWith(true);
});

it('applies size classes correctly', () => {
// Small size
const { container: smallContainer } = render(<ThemeToggle size="sm" />);
const smallButton = smallContainer.querySelector('button');
expect(smallButton).toHaveClass('h-8 w-8');

// Medium size
const { container: mediumContainer } = render(<ThemeToggle size="md" />);
const mediumButton = mediumContainer.querySelector('button');
expect(mediumButton).toHaveClass('h-10 w-10');

// Large size
const { container: largeContainer } = render(<ThemeToggle size="lg" />);
const largeButton = largeContainer.querySelector('button');
expect(largeButton).toHaveClass('h-12 w-12');
});

it('applies custom className', () => {
const { container } = render(<ThemeToggle className="test-class" />);
const button = container.querySelector('button');
expect(button).toHaveClass('test-class');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './ThemeToggle';
3 changes: 2 additions & 1 deletion packages/ui-kit/src/components/primitives/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export * from './TextInput'
export * from './NumberInput'
export * from './Select'
export * from './Checkbox'
export * from './RadioGroup'
export * from './RadioGroup'
export * from './ThemeToggle'
Loading
Loading