From 90ca404593d9a5ddb316da6a9e0beb9fafafa97c Mon Sep 17 00:00:00 2001 From: AgnesSj01 Date: Tue, 13 Jan 2026 08:37:10 +0100 Subject: [PATCH 1/2] Created components for the todolist, todo form, and todo counter, as well as style files for the app. Also moved some code into utils files to improve code readability --- README.md | 26 +++++++++++- package.json | 7 ++- src/App.jsx | 35 +++++++++++++-- src/components/TodoCount.jsx | 8 ++++ src/components/TodoForm.jsx | 39 +++++++++++++++++ src/components/TodoList.jsx | 51 ++++++++++++++++++++++ src/main.jsx | 12 +++--- src/stores/useTodosStore.js | 66 +++++++++++++++++++++++++++++ src/styles/App.styles.js | 45 ++++++++++++++++++++ src/styles/Buttons.styles.js | 40 ++++++++++++++++++ src/styles/GlobalStyles.js | 19 +++++++++ src/styles/TodoForm.styles.js | 60 ++++++++++++++++++++++++++ src/styles/TodoList.styles.js | 80 +++++++++++++++++++++++++++++++++++ src/utils/formatDate.js | 8 ++++ src/utils/todosStorage.js | 12 ++++++ 15 files changed, 497 insertions(+), 11 deletions(-) create mode 100644 src/components/TodoCount.jsx create mode 100644 src/components/TodoForm.jsx create mode 100644 src/components/TodoList.jsx create mode 100644 src/stores/useTodosStore.js create mode 100644 src/styles/App.styles.js create mode 100644 src/styles/Buttons.styles.js create mode 100644 src/styles/GlobalStyles.js create mode 100644 src/styles/TodoForm.styles.js create mode 100644 src/styles/TodoList.styles.js create mode 100644 src/utils/formatDate.js create mode 100644 src/utils/todosStorage.js diff --git a/README.md b/README.md index d1c68b57..4ade84d3 100644 --- a/README.md +++ b/README.md @@ -1 +1,25 @@ -# Todo \ No newline at end of file +# Todo App (React) + +This is a Todo application built with React to demonstrate clean state management, accessibility, and maintainable frontend architecture. + +Features + +Create, toggle, and remove tasks + +Persist tasks using localStorage + +Display task count and creation date + +Toggle all tasks with a single action + +Accessible and keyboard-friendly UI + +Responsive layout + +Technical Overview + +Global state managed with Zustand (no prop-drilling) + +State persistence via localStorage + +Logic separated into stores, components, and utility modules diff --git a/package.json b/package.json index caf62893..f8dd80f9 100644 --- a/package.json +++ b/package.json @@ -11,17 +11,22 @@ }, "dependencies": { "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "styled-components": "^6.3.5", + "zustand": "^5.0.10" }, "devDependencies": { "@eslint/js": "^9.21.0", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.23", "eslint": "^9.21.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.15.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.18", "vite": "^6.2.0" } } diff --git a/src/App.jsx b/src/App.jsx index 54275400..54a10c55 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,34 @@ -export const App = () => { +import { TodoList } from "./components/TodoList"; +import { TodoForm } from "./components/TodoForm"; +import { TodoCount } from "./components/TodoCount"; +import { useTodosStore } from "./stores/useTodosStore"; +import { AppWrapper, Header, BulkButton } from "./styles/App.styles"; +import { GlobalStyle } from "./styles/GlobalStyles"; + +export default function App() { + //Get only the action from the global Zustand store + const completeAll = useTodosStore((state) => state.completeAll); return ( -

React Boilerplate

- ) + <> + {/*Global styles fpr the entire app */} + + {/* Main app container */} + +
+ {/* App title */} +

Todo App

+
+ {/* Form to add new todos */} + + {/* Displays total task */} + + {/* Bulk action to toggle all todos */} + + Select all/deselect all{" "} + + {/* List of all todos */} + +
+ + ); } diff --git a/src/components/TodoCount.jsx b/src/components/TodoCount.jsx new file mode 100644 index 00000000..523677a6 --- /dev/null +++ b/src/components/TodoCount.jsx @@ -0,0 +1,8 @@ +import { useTodosStore } from "../stores/useTodosStore"; + +export function TodoCount() { + // Read todos from the global Zustand store + const todos = useTodosStore((state) => state.todos); + // Display the total number of tasks + return

Number of tasks: {todos.length}

; +} diff --git a/src/components/TodoForm.jsx b/src/components/TodoForm.jsx new file mode 100644 index 00000000..d017b9db --- /dev/null +++ b/src/components/TodoForm.jsx @@ -0,0 +1,39 @@ +import { useState } from "react"; +import { useTodosStore } from "../stores/useTodosStore"; +import { Form, Input, Submit } from "../styles/TodoForm.styles"; + +export function TodoForm() { + // Local state for the input value + const [title, setTitle] = useState(""); + + // Get the addTodo action from the global store + const addTodo = useTodosStore((state) => state.addTodo); + + function handleSumbit(e) { + // Prevent default form submission (page reload) + e.preventDefault(); + + // Add new todo using global state + addTodo(title); + + // Clear the input after submit + setTitle(""); + } + + return ( +
+ {/* Accessible label connected to the input */} + + + {/* Controlled input field */} + setTitle(e.target.value)} + /> + + {/* Submit button triggers form submit */} + Add Task +
+ ); +} diff --git a/src/components/TodoList.jsx b/src/components/TodoList.jsx new file mode 100644 index 00000000..5acecd3f --- /dev/null +++ b/src/components/TodoList.jsx @@ -0,0 +1,51 @@ +import { useTodosStore } from "../stores/useTodosStore"; +import { formatDate } from "../utils/formatDate"; +import { + List, + Item, + TodoButton, + RemoveButton, + Paragraf, +} from "../styles/TodoList.styles"; + +export function TodoList() { + // Read state + actions from the global Zustand store (no prop drilling) + const todos = useTodosStore((state) => state.todos); + const toggleTodo = useTodosStore((state) => state.toggleTodo); + const removeTodo = useTodosStore((state) => state.removeTodo); + + // Empty state UX when there are no tasks + if (todos.length === 0) { + return No tasks yet. Add your first one above.; + } + + return ( + + {todos.map((todo) => ( + + {/* Click the todo to toggle completed/uncompleted */} + toggleTodo(todo.id)} + aria-pressed={todo.completed} + > + + {todo.completed ? "✅ " : "⬜ "} {todo.title} + + {/* Show a formatted creation date */} + Skapad: {formatDate(todo.createdAt)} + + + {/* Separate remove action with an accessible label */} + removeTodo(todo.id)} + > + ✕ Remove + + + ))} + + ); +} diff --git a/src/main.jsx b/src/main.jsx index 1b8ffe9b..5e424eef 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,12 +1,12 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' +import React from "react"; +import ReactDOM from "react-dom/client"; -import { App } from './App.jsx' +import App from "./App.jsx"; -import './index.css' +import "./index.css"; -ReactDOM.createRoot(document.getElementById('root')).render( +ReactDOM.createRoot(document.getElementById("root")).render( -) +); diff --git a/src/stores/useTodosStore.js b/src/stores/useTodosStore.js new file mode 100644 index 00000000..ca853e52 --- /dev/null +++ b/src/stores/useTodosStore.js @@ -0,0 +1,66 @@ +import { create } from "zustand"; +import { getInitialTodos, saveTodos } from "../utils/todosStorage"; + +// Global todo store using Zustand +export const useTodosStore = create((set) => ({ + // Initial state loaded from local storage + todos: getInitialTodos(), + + // Add a new todo item + addTodo: (title) => { + set((state) => { + const newTodos = [ + ...state.todos, + { + id: Date.now().toString(), // simple unique id + title, + completed: false, + createdAt: Date.now(), + }, + ]; + + // Persist updated todos + saveTodos(newTodos); + return { todos: newTodos }; + }); + }, + + // Remove a todo by id + removeTodo: (id) => { + set((state) => { + const newTodos = state.todos.filter((todo) => todo.id !== id); + + // Persist updated todos + saveTodos(newTodos); + return { todos: newTodos }; + }); + }, + + // Toggle all todos as completed / uncompleted + completeAll: () => { + set((state) => { + const allCompleted = state.todos.every((todo) => todo.completed); + const newTodos = state.todos.map((todo) => ({ + ...todo, + completed: !allCompleted, + })); + + // Persist updated todos + saveTodos(newTodos); + return { todos: newTodos }; + }); + }, + + // Toggle completed state for a single todo + toggleTodo: (id) => { + set((state) => { + const newTodos = state.todos.map((todo) => + todo.id === id ? { ...todo, completed: !todo.completed } : todo + ); + + // Persist updated todos + saveTodos(newTodos); + return { todos: newTodos }; + }); + }, +})); diff --git a/src/styles/App.styles.js b/src/styles/App.styles.js new file mode 100644 index 00000000..b44af6f4 --- /dev/null +++ b/src/styles/App.styles.js @@ -0,0 +1,45 @@ +import styled from "styled-components"; +import { BaseButton } from "./Buttons.styles"; + +export const BulkButton = styled(BaseButton)` + margin: 12px 0 20px; + padding: 10px 14px; +`; + +export const AppWrapper = styled.main` + width: min(600px, 100%); + max-width: 100%; + margin: 30px auto; + padding: 0 16px; + box-sizing: border-box; + min-width: 0; + + overflow-x: clip; + + background: #ffffff; + border-radius: 12px; + box-shadow: 0 12px 30px rgba(17, 24, 39, 0.12); + + @media (min-width: 640px) { + margin: 40px auto; + padding: 40px; + } +`; +export const Header = styled.header` + margin-top: 10px; + margin-bottom: 10px; + + h1 { + padding-top: 15px; + padding-bottom: 10px; + margin: 0; + font-size: 24px; + text-align: center; + } + + @media (min-width: 640px) { + h1 { + font-size: 28px; + } + } +`; diff --git a/src/styles/Buttons.styles.js b/src/styles/Buttons.styles.js new file mode 100644 index 00000000..41479873 --- /dev/null +++ b/src/styles/Buttons.styles.js @@ -0,0 +1,40 @@ +import styled from "styled-components"; + +export const BaseButton = styled.button` + border-radius: 10px; + padding: 10px 14px; + + background: #f7f8fc; + border: 1px solid #d7dbe7; + color: #111827; + + font-size: 14px; + font-weight: 500; + line-height: 1.4; + + cursor: pointer; + transition: background-color 0.2s ease, border-color 0.2s ease, + transform 0.05s ease, box-shadow 0.2s ease; + + @media (hover: hover) and (pointer: fine) { + &:hover { + background: #eef1ff; + border-color: #2563eb; + } + } + + &:focus-visible { + outline: 3px solid #93c5fd; + outline-offset: 2px; + border-color: #2563eb; + } + + &:active { + transform: scale(0.98); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +`; diff --git a/src/styles/GlobalStyles.js b/src/styles/GlobalStyles.js new file mode 100644 index 00000000..39f33324 --- /dev/null +++ b/src/styles/GlobalStyles.js @@ -0,0 +1,19 @@ +import { createGlobalStyle } from "styled-components"; + +export const GlobalStyle = createGlobalStyle` + *, *::before, *::after { + box-sizing: border-box; + } + + html, body, #root { + margin: 0; + padding: 0; + width: 100%; + overflow-x: hidden; + overscroll-behavior-x: none; + } + + body { + touch-action: pan-y; + } +`; diff --git a/src/styles/TodoForm.styles.js b/src/styles/TodoForm.styles.js new file mode 100644 index 00000000..e050267f --- /dev/null +++ b/src/styles/TodoForm.styles.js @@ -0,0 +1,60 @@ +import styled from "styled-components"; +import { BaseButton } from "./Buttons.styles"; + +export const Form = styled.form` + display: flex; + flex-direction: column; + gap: 10px; + padding: 0px; + + width: 100%; + max-width: 100%; + overflow: hidden; + + /* Tablet & desktop */ + @media (min-width: 640px) { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + padding: 24px 5px; + align-items: center; + max-width: 500px; + margin: 0 auto; / + } + + label { + @media (min-width: 640px) { + grid-column: 1 / -1; + } + } +`; + +export const Input = styled.input` + width: 100%; + max-width: 100%; + min-width: 0; + + padding: 12px 14px; + border: 1px solid #d7dbe7; + border-radius: 10px; + font-size: 16px; + box-sizing: border-box; + + @media (min-width: 640px) { + grid-column: 1; + } + + &:focus { + border-color: #6b8cff; + box-shadow: 0 0 0 3px rgba(107, 140, 255, 0.25); + } +`; +export const Submit = styled(BaseButton)` + padding: 12px 18px; + width: 100%; + + @media (min-width: 640px) { + width: auto; + white-space: nowrap; + } +`; diff --git a/src/styles/TodoList.styles.js b/src/styles/TodoList.styles.js new file mode 100644 index 00000000..ec230f9c --- /dev/null +++ b/src/styles/TodoList.styles.js @@ -0,0 +1,80 @@ +import styled from "styled-components"; +import { BaseButton } from "./Buttons.styles"; + +export const List = styled.ul` + list-style: none; + margin: 0; + padding: 0; + width: 100%; + padding-bottom: 20px; + padding-top: 20px; +`; + +export const Item = styled.li` + display: flex; + gap: 10px; + align-items: stretch; + width: 100%; + max-width: 100%; + min-width: 0; + margin-bottom: 12px; +`; + +export const TodoButton = styled.button` + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + + text-align: left; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; + + border: 1px solid #d7dbe7; + border-radius: 10px; + padding: 12px 14px; + background: #fff; + + &:focus-visible { + outline: 3px solid #93c5fd; + outline-offset: 2px; + border-color: #2563eb; + } +`; + +export const RemoveButton = styled(BaseButton)` + padding: 8px 12px; + white-space: nowrap; + + display: flex; + align-items: center; + gap: 6px; + + background: #fef2f2; + border-color: #fecaca; + color: #991b1b; + + @media (hover: hover) and (pointer: fine) { + &:hover { + background: #fee2e2; + border-color: #fca5a5; + } + } + + &:focus-visible { + outline: 3px solid #fecaca; + outline-offset: 2px; + border-color: #dc2626; + } +`; + +export const Paragraf = styled.p` + font-size: 16px; + color: #1d4ed8; + text-align: center; + margin: 16px 0; + padding-bottom: 15px; +`; diff --git a/src/utils/formatDate.js b/src/utils/formatDate.js new file mode 100644 index 00000000..c30b0d3b --- /dev/null +++ b/src/utils/formatDate.js @@ -0,0 +1,8 @@ +// Format a timestamp into a readable English date string +export function formatDate(timestamp) { + return new Date(timestamp).toLocaleDateString("en-GB", { + year: "numeric", + month: "short", + day: "numeric", + }); +} diff --git a/src/utils/todosStorage.js b/src/utils/todosStorage.js new file mode 100644 index 00000000..65dcce66 --- /dev/null +++ b/src/utils/todosStorage.js @@ -0,0 +1,12 @@ +// Read saved todos from localStorage on app startup +export function getInitialTodos() { + const saved = localStorage.getItem("todos"); + + // Parse stored JSON or return an empty array if nothing is saved + return saved ? JSON.parse(saved) : []; +} + +// Save the current todos state to localStorage +export function saveTodos(todos) { + localStorage.setItem("todos", JSON.stringify(todos)); +} From 3c93dd894d3132c4bfd1097f5956498ab40d1118 Mon Sep 17 00:00:00 2001 From: AgnesSj01 Date: Tue, 13 Jan 2026 08:58:15 +0100 Subject: [PATCH 2/2] Fixed checkboxes --- src/components/TodoList.jsx | 13 +++++++++---- src/styles/TodoList.styles.js | 6 ++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/components/TodoList.jsx b/src/components/TodoList.jsx index 5acecd3f..7e4bed87 100644 --- a/src/components/TodoList.jsx +++ b/src/components/TodoList.jsx @@ -30,12 +30,17 @@ export function TodoList() { aria-pressed={todo.completed} > - {todo.completed ? "✅ " : "⬜ "} {todo.title} + toggleTodo(todo.id)} + onClick={(e) => e.stopPropagation()} + /> + {todo.title} - {/* Show a formatted creation date */} - Skapad: {formatDate(todo.createdAt)} - + Created: {formatDate(todo.createdAt)} + {/* Separate remove action with an accessible label */}