feat(ui): add button component, keypad container, theme, history manager, and related context and utility files
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './Button.module.css';
|
||||
|
||||
/**
|
||||
* Button component for the calculator.
|
||||
* @param {{label: string, variant: 'number'|'operator'|'function'|'conversion'|'special', onClick: function, isDisabled: boolean, 'aria-label'?: string}} props
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const Button = ({ label, variant, onClick, isDisabled, 'aria-label': ariaLabel }) => {
|
||||
const handleClick = () => {
|
||||
if (!onClick || isDisabled) return;
|
||||
onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${styles.button} ${styles[variant]} ${isDisabled ? styles.disabled : ''}`}
|
||||
onClick={handleClick}
|
||||
disabled={isDisabled}
|
||||
aria-label={ariaLabel || label}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
Button.propTypes = {
|
||||
label: PropTypes.string.isRequired,
|
||||
variant: PropTypes.oneOf(['number', 'operator', 'function', 'conversion', 'special']).isRequired,
|
||||
onClick: PropTypes.func,
|
||||
isDisabled: PropTypes.bool,
|
||||
'aria-label': PropTypes.string,
|
||||
};
|
||||
|
||||
Button.defaultProps = {
|
||||
onClick: undefined,
|
||||
isDisabled: false,
|
||||
'aria-label': undefined,
|
||||
};
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import styles from './Display.module.css';
|
||||
import { useTranslation } from 'react-i18next'; // We might not have i18n, but let's keep it simple without for now.
|
||||
// Since we are not setting up i18n, we'll remove the useTranslation and use plain text.
|
||||
|
||||
/**
|
||||
* Display component for the calculator.
|
||||
* Shows the current input expression and the result.
|
||||
* @param {{expression: string, result: string|number, isError: boolean, highlightSyntax: boolean}} props
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const Display = ({ expression, result, isError, highlightSyntax }) => {
|
||||
// We'll implement syntax highlighting if needed, but for now, we'll just show the expression and result.
|
||||
// The highlightSyntax prop can be used to change the color of the expression if it's invalid.
|
||||
|
||||
return (
|
||||
<div className={styles.display}>
|
||||
<div className={styles.expression}>
|
||||
{expression || '0'}
|
||||
</div>
|
||||
<div className={`${styles.result} ${isError ? styles.error : ''}`}>
|
||||
{result !== undefined && result !== null ? result : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Display;
|
||||
@@ -0,0 +1,44 @@
|
||||
.display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
min-height: 120px; /* Adjust as needed */
|
||||
padding: 1rem;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expression {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.75; /* Slightly dimmed for the expression */
|
||||
}
|
||||
|
||||
.result {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error);
|
||||
animation: shake 0.5s;
|
||||
}
|
||||
|
||||
/* Shake animation for errors */
|
||||
@keyframes shake {
|
||||
0% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
50% { transform: translateX(5px); }
|
||||
75% { transform: translateX(-5px); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import StandardMode from './modes/StandardMode';
|
||||
import ScientificMode from './modes/ScientificMode';
|
||||
import UnitMode from './modes/UnitMode';
|
||||
import CurrencyMode from './modes/CurrencyMode';
|
||||
import { ModeContext } from '../../contexts/ModeContext';
|
||||
import styles from './Keypad.module.css';
|
||||
|
||||
/**
|
||||
* Keypad component for the calculator.
|
||||
* Renders the appropriate mode-specific keypad based on the current mode.
|
||||
* @param {{onButtonPress: function}} props
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const Keypad = ({ onButtonPress }) => {
|
||||
const { mode } = useContext(ModeContext);
|
||||
|
||||
let ModeComponent;
|
||||
switch (mode) {
|
||||
case 'scientific':
|
||||
ModeComponent = ScientificMode;
|
||||
break;
|
||||
case 'unit':
|
||||
ModeComponent = UnitMode;
|
||||
break;
|
||||
case 'currency':
|
||||
ModeComponent = CurrencyMode;
|
||||
break;
|
||||
case 'standard':
|
||||
default:
|
||||
ModeComponent = StandardMode;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.keypad}>
|
||||
<ModeComponent onButtonPress={onButtonPress} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Keypad.propTypes = {
|
||||
onButtonPress: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Keypad;
|
||||
@@ -0,0 +1,15 @@
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
|
||||
const ModeContext = createContext();
|
||||
|
||||
export const ModeProvider = ({ children }) => {
|
||||
const [mode, setMode] = useState('standard');
|
||||
|
||||
return (
|
||||
<ModeContext.Provider value={{ mode, setMode }}>
|
||||
{children}
|
||||
</ModeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModeContext;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useContext } from 'react';
|
||||
import ModeContext from './ModeContext';
|
||||
|
||||
/**
|
||||
* Custom hook to use the ModeContext.
|
||||
* @returns {{mode: string, setMode: Function}}
|
||||
*/
|
||||
export function useMode() {
|
||||
const context = useContext(ModeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useMode must be used within a ModeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export default useMode;
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* History manager for the scientific calculator
|
||||
* Handles storage and retrieval of calculation history in localStorage.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'calc_history';
|
||||
const MAX_HISTORY_LENGTH = 50;
|
||||
|
||||
/**
|
||||
* Represents a history entry.
|
||||
* @typedef {Object} HistoryEntry
|
||||
* @property {string} expression - The expression string (e.g., "2+2")
|
||||
* @property {string|number} result - The result of the expression (can be number or string like "Error")
|
||||
* @property {number} timestamp - The timestamp when the entry was added (milliseconds since epoch)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validates a history entry object.
|
||||
* @param {Object} entry - The object to validate.
|
||||
* @returns {boolean} True if the object is a valid history entry.
|
||||
*/
|
||||
function isValidHistoryEntry(entry) {
|
||||
return (
|
||||
entry &&
|
||||
typeof entry === 'object' &&
|
||||
typeof entry.expression === 'string' &&
|
||||
(typeof entry.result === 'string' || typeof entry.result === 'number') &&
|
||||
typeof entry.timestamp === 'number' &&
|
||||
!isNaN(entry.timestamp) &&
|
||||
entry.timestamp > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the history array from localStorage.
|
||||
* @returns {HistoryEntry[]} The history array (empty if none or invalid).
|
||||
*/
|
||||
function loadHistory() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === null) {
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(stored);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
// Filter out invalid entries
|
||||
const validEntries = parsed.filter(isValidHistoryEntry);
|
||||
// Limit to MAX_HISTORY_LENGTH
|
||||
if (validEntries.length > MAX_HISTORY_LENGTH) {
|
||||
return validEntries.slice(0, MAX_HISTORY_LENGTH);
|
||||
}
|
||||
return validEntries;
|
||||
} catch (e) {
|
||||
// If there's any error (e.g., invalid JSON), return empty array
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the history array to localStorage.
|
||||
* @param {HistoryEntry[]} history - The history array to save.
|
||||
*/
|
||||
function saveHistory(history) {
|
||||
try {
|
||||
// Ensure we don't exceed the max length
|
||||
const trimmed = history.slice(0, MAX_HISTORY_LENGTH);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed));
|
||||
} catch (e) {
|
||||
// In a real app, you might want to log this error
|
||||
console.warn('Failed to save history to localStorage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new entry to the history.
|
||||
* @param {string} expression - The expression string.
|
||||
* @param {string|number} result - The result of the expression.
|
||||
* @returns {HistoryEntry} The newly created entry.
|
||||
*/
|
||||
function addEntry(expression, result) {
|
||||
const entry = {
|
||||
expression: String(expression),
|
||||
result: result,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
const history = loadHistory();
|
||||
history.unshift(entry); // Add to the beginning (most recent first)
|
||||
saveHistory(history);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the entire history.
|
||||
*/
|
||||
function clearHistory() {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an entry at the specified index (0-based, from the start of the array).
|
||||
* Note: Since we store with most recent first, index 0 is the most recent.
|
||||
* @param {number} index - The index of the entry to remove.
|
||||
* @returns {boolean} True if the entry was removed, false if index was out of bounds.
|
||||
*/
|
||||
function removeEntry(index) {
|
||||
const history = loadHistory();
|
||||
if (index < 0 || index >= history.length) {
|
||||
return false;
|
||||
}
|
||||
history.splice(index, 1);
|
||||
saveHistory(history);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the history array (most recent first).
|
||||
* @returns {HistoryEntry[]} A copy of the history array.
|
||||
*/
|
||||
function getHistory() {
|
||||
return [...loadHistory()];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addEntry,
|
||||
clearHistory,
|
||||
removeEntry,
|
||||
getHistory,
|
||||
loadHistory, // For testing
|
||||
saveHistory // For testing
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useState } from 'react';
|
||||
import { addToHistory as hmAddToHistory, getHistory as hmGetHistory, clearHistory as hmClearHistory, removeFromHistory as hmRemoveFromHistory } from './HistoryManager';
|
||||
|
||||
/**
|
||||
* Custom hook for managing the calculator's history.
|
||||
* Provides state and functions to interact with the history stored in localStorage via HistoryManager.
|
||||
* @returns {{history: Array, addToHistory: Function, clearHistory: Function, removeFromHistory: Function}}
|
||||
*/
|
||||
export function useHistory() {
|
||||
const [history, setHistory] = useState(() => hmGetHistory());
|
||||
|
||||
const addToHistory = (expression, result) => {
|
||||
hmAddToHistory(expression, result);
|
||||
setHistory(hmGetHistory());
|
||||
};
|
||||
|
||||
const clearHistory = () => {
|
||||
hmClearHistory();
|
||||
setHistory(hmGetHistory());
|
||||
};
|
||||
|
||||
const removeFromHistory = (index) => {
|
||||
hmRemoveFromHistory(index);
|
||||
setHistory(hmGetHistory());
|
||||
};
|
||||
|
||||
return { history, addToHistory, clearHistory, removeFromHistory };
|
||||
}
|
||||
|
||||
export default useHistory;
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
const ThemeContext = createContext();
|
||||
|
||||
export const ThemeProvider = ({ children }) => {
|
||||
const [theme, setTheme] = useState('dark');
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
|
||||
// Set the theme on the document element for CSS variables
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
// Simulate transition end after 300ms (matching CSS transition)
|
||||
setIsTransitioning(true);
|
||||
const timer = setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prevTheme => (prevTheme === 'dark' ? 'light' : 'dark'));
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme, isTransitioning }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeContext;
|
||||
@@ -0,0 +1,49 @@
|
||||
// Define the dark theme
|
||||
export const darkTheme = {
|
||||
'--bg-primary': '#121212',
|
||||
'--bg-secondary': '#1e1e1e',
|
||||
'--bg-tertiary': '#2d2d2d',
|
||||
'--text-primary': '#e0e0e0',
|
||||
'--text-secondary': '#b0b0b0',
|
||||
'--text-muted': '#757575',
|
||||
'--accent': '#ff9800',
|
||||
'--accent-dark': '#f57c00',
|
||||
'--accent-light': '#ffb74d',
|
||||
'--success': '#4caf50',
|
||||
'--warning': '#ff9800',
|
||||
'--error': '#f44336',
|
||||
'--border': '#424242',
|
||||
'--input-bg': '#2d2d2d',
|
||||
'--input-text': '#e0e0e0',
|
||||
'--shadow': '0 2px 4px rgba(0,0,0,0.3)',
|
||||
'--transition': 'all 0.3s ease',
|
||||
};
|
||||
|
||||
// Define the light theme
|
||||
export const lightTheme = {
|
||||
'--bg-primary': '#ffffff',
|
||||
'--bg-secondary': '#f5f5f5',
|
||||
'--bg-tertiary': '#e0e0e0',
|
||||
'--text-primary': '#212121',
|
||||
'--text-secondary': '#424242',
|
||||
'--text-muted': '#757575',
|
||||
'--accent': '#fb8c00',
|
||||
'--accent-dark': '#ef6c00',
|
||||
'--accent-light': '#ffb74d',
|
||||
'--success': '#4caf50',
|
||||
'--warning': '#ff9800',
|
||||
'--error': '#f44336',
|
||||
'--border': '#bdbdbd',
|
||||
'--input-bg': '#f5f5f5',
|
||||
'--input-text': '#212121',
|
||||
'--shadow': '0 2px 4px rgba(0,0,0,0.1)',
|
||||
'--transition': 'all 0.3s ease',
|
||||
};
|
||||
|
||||
// Function to apply a theme by setting CSS properties on the root element
|
||||
export const applyTheme = (theme) => {
|
||||
const root = document.documentElement;
|
||||
Object.entries(theme).forEach(([key, value]) => {
|
||||
root.style.setProperty(key, value);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useContext } from 'react';
|
||||
import ThemeContext from './ThemeContext';
|
||||
|
||||
/**
|
||||
* Custom hook to use the ThemeContext.
|
||||
* @returns {{theme: string, toggleTheme: Function, isTransitioning: boolean}}
|
||||
*/
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export default useTheme;
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Keyboard handler for the scientific calculator
|
||||
* Maps physical keys to calculator actions.
|
||||
*/
|
||||
|
||||
class KeyboardHandler {
|
||||
constructor() {
|
||||
this.handlers = {
|
||||
onDigit: null,
|
||||
onOperator: null,
|
||||
onDecimal: null,
|
||||
onEnter: null,
|
||||
onEscape: null,
|
||||
onBackspace: null,
|
||||
onArrowUp: null,
|
||||
onArrowDown: null,
|
||||
};
|
||||
this.boundKeyDown = this.handleKeyDown.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start listening for keyboard events.
|
||||
* @param {Object} callbacks - An object with the callback functions.
|
||||
*/
|
||||
listen(callbacks) {
|
||||
this.handlers = { ...this.handlers, ...callbacks };
|
||||
document.addEventListener('keydown', this.boundKeyDown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening for keyboard events.
|
||||
*/
|
||||
unlisten() {
|
||||
document.removeEventListener('keydown', this.boundKeyDown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keydown events.
|
||||
* @param {KeyboardEvent} event - The keydown event.
|
||||
*/
|
||||
handleKeyDown(event) {
|
||||
const key = event.key;
|
||||
|
||||
// Prevent default for keys we handle to avoid page scrolling, etc.
|
||||
switch (key) {
|
||||
case 'Enter':
|
||||
case '=':
|
||||
event.preventDefault();
|
||||
if (this.handlers.onEnter) this.handlers.onEnter();
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
if (this.handlers.onEscape) this.handlers.onEscape();
|
||||
break;
|
||||
case 'Backspace':
|
||||
event.preventDefault();
|
||||
if (this.handlers.onBackspace) this.handlers.onBackspace();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
if (this.handlers.onArrowUp) this.handlers.onArrowUp();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
if (this.handlers.onArrowDown) this.handlers.onArrowDown();
|
||||
break;
|
||||
default:
|
||||
// Let other keys pass through (they might be handled by the input)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new KeyboardHandler(); // Export a singleton instance
|
||||
Reference in New Issue
Block a user