feat(ui): add button component, keypad container, theme, history manager, and related context and utility files

This commit is contained in:
JARVIS
2026-07-27 18:46:38 +00:00
parent c3ad97fa42
commit 3c58fea509
12 changed files with 524 additions and 0 deletions
+42
View File
@@ -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;
+28
View File
@@ -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;
+44
View File
@@ -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); }
}
+46
View File
@@ -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;
+15
View File
@@ -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;
+16
View File
@@ -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;
+133
View File
@@ -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
};
+30
View File
@@ -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;
+31
View File
@@ -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;
+49
View File
@@ -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);
});
};
+16
View File
@@ -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;
+74
View File
@@ -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