電卓アプリケーションは、Web開発の定番プロジェクトですが、多くの電卓は計算履歴機能が費弱です。このパーツでは、ブラウザの「戻る」ボタンに着想を得た、直感的な履歴機能付き電卓を実装します。
「←」ボタンの短いクリックで履歴を順次閲覧でき、長押しで履歴一覧を開いて直接選択できる仕様です。また、localStorageを活用して計算履歴を永続化し、ブラウザを閉じても履歴が残る実用的な電卓を目指します。
HTML
<section class="shell" aria-label="電卓">
<div class="screen">
<div class="display" role="status" aria-live="polite" aria-atomic="true">
<div id="expr" class="expr"></div>
<div id="value" class="value">0</div>
</div>
</div>
<div class="pad">
<div class="keys" role="group" aria-label="電卓ボタン">
<button id="backBtn" class="key" data-action="back" data-kind="util" type="button" aria-label="前の結果" title="前の結果(長押しで履歴)">
<svg width="22" height="22" viewbox="0 0 24 24" aria-hidden="true" focusable="false" style="display:block;margin:auto">
<path d="M19 12H5M5 12l6-6M5 12l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"></path>
</svg>
</button>
<button class="key" data-action="backspace" data-kind="util" type="button" aria-label="1文字削除">⌫</button>
<button class="key" data-action="clear" data-kind="danger" type="button" aria-label="クリア">C</button>
<button class="key" data-action="op" data-value="/" data-kind="op" type="button" aria-label="割り算">÷</button>
<button class="key" data-action="digit" data-value="7" type="button">7</button>
<button class="key" data-action="digit" data-value="8" type="button">8</button>
<button class="key" data-action="digit" data-value="9" type="button">9</button>
<button class="key" data-action="op" data-value="*" data-kind="op" type="button" aria-label="掛け算">×</button>
<button class="key" data-action="digit" data-value="4" type="button">4</button>
<button class="key" data-action="digit" data-value="5" type="button">5</button>
<button class="key" data-action="digit" data-value="6" type="button">6</button>
<button class="key" data-action="op" data-value="-" data-kind="op" type="button" aria-label="引き算">−</button>
<button class="key" data-action="digit" data-value="1" type="button">1</button>
<button class="key" data-action="digit" data-value="2" type="button">2</button>
<button class="key" data-action="digit" data-value="3" type="button">3</button>
<button class="key" data-action="op" data-value="+" data-kind="op" type="button" aria-label="足し算">+</button>
<button class="key" data-action="digit" data-value="0" type="button">0</button>
<button class="key" data-action="dot" data-value="." type="button">.</button>
<button class="key" data-action="equals" data-kind="equals" type="button" style="grid-column: span 2;">=</button>
</div>
</div>
<aside id="historyPanel" class="history-panel" aria-hidden="true" aria-label="計算履歴">
<div class="history-head">
<h2 class="history-title">計算履歴</h2>
<button id="historyClose" class="history-close" type="button" aria-label="履歴を閉じる">
<svg width="16" height="16" viewbox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"></path>
</svg>
</button>
</div>
<ul id="historyList" class="history-list"></ul>
</aside>
</section> CSS
:root {
--calc-bg: #b8dcc6;
--calc-border: #9bc5a7;
--text: #2c3e3f;
--display-bg: #f8fdf9;
--display-text: #1a2322;
--key-bg: #c8e6d0;
--key-hover: #b5dac3;
--key-border: #a5d0b5;
--op-key: #9bc5a7;
--util-key: #a8ceb5;
--util-key-hover: #95bcaa;
--danger-key: #e8b5b5;
--equals-key: #7ab68f;
--shadow: 0 8px 20px rgba(44, 62, 63, .15);
--radius: 8px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Poppins', system-ui, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.shell {
width: min(340px, 100%);
border-radius: var(--radius);
background: var(--calc-bg);
box-shadow: var(--shadow);
border: 2px solid var(--calc-border);
overflow: hidden;
padding: 16px;
position: relative;
}
.screen {
padding: 0 0 16px 0;
}
.display {
border-radius: 6px;
background: var(--display-bg);
color: var(--display-text);
border: 2px inset var(--calc-border);
padding: 16px 12px;
box-shadow: inset 0 2px 4px rgba(44, 62, 63, .08);
min-height: 70px;
display: grid;
gap: 4px;
align-content: center;
}
.expr {
font-size: 12px;
color: rgba(44, 62, 63, .60);
min-height: 16px;
word-break: break-all;
}
.value {
font-size: clamp(20px, 5vw, 28px);
font-weight: 700;
letter-spacing: .02em;
text-align: right;
min-height: 32px;
word-break: break-all;
color: var(--display-text);
}
.pad {
padding: 0;
display: grid;
gap: 10px;
}
.keys {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.key {
appearance: none;
border: 1px outset var(--key-border);
background: var(--key-bg);
border-radius: var(--radius);
padding: 14px 10px;
min-height: 52px;
font-size: 16px;
font-weight: 600;
color: var(--text);
cursor: pointer;
transition: background .12s ease;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.key:hover {
background: var(--key-hover);
}
.key:active {
border-style: inset;
}
.key:focus-visible {
outline: 3px solid rgba(31, 111, 235, .35);
outline-offset: 2px;
}
.key[data-kind="op"] {
background: var(--op-key);
border-color: #8bb89e;
}
.key[data-kind="util"] {
background: var(--util-key);
border-color: #92b9a7;
color: #2a3837;
}
.key[data-kind="util"]:hover {
background: var(--util-key-hover);
}
.key[data-kind="danger"] {
background: var(--danger-key);
border-color: #d89e9e;
color: #8b4545;
}
.key[data-kind="equals"] {
grid-column: span 4;
background: var(--equals-key);
color: white;
border-color: #6da081;
}
.history-panel {
position: absolute;
left: 12px;
right: 12px;
top: 92px;
background: linear-gradient(180deg, rgba(248, 253, 249, .98), rgba(240, 248, 243, .98));
border: 2px solid rgba(155, 197, 167, .85);
border-radius: 12px;
box-shadow: 0 12px 32px rgba(44, 62, 63, .22), 0 0 0 1px rgba(255, 255, 255, .4) inset;
padding: 0;
display: none;
z-index: 10;
overflow: hidden;
}
.history-panel[aria-hidden="false"] {
display: block;
}
.history-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px 12px;
background: linear-gradient(180deg, rgba(184, 220, 198, .45), rgba(184, 220, 198, .25));
border-bottom: 1px solid rgba(155, 197, 167, .35);
}
.history-title {
margin: 0;
font-size: 14px;
font-weight: 700;
color: rgba(44, 62, 63, .92);
letter-spacing: .01em;
}
.history-close {
appearance: none;
border: 1px solid rgba(155, 197, 167, .6);
background: rgba(255, 255, 255, .75);
border-radius: 8px;
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 400;
color: rgba(44, 62, 63, .76);
cursor: pointer;
transition: background .15s ease, border-color .15s ease;
}
.history-close:hover {
background: rgba(255, 255, 255, .92);
border-color: rgba(155, 197, 167, .8);
color: rgba(44, 62, 63, .92);
}
.history-close:active {
background: rgba(200, 230, 208, .45);
border-color: rgba(155, 197, 167, .9);
}
.history-list {
list-style: none;
padding: 12px 16px 48px;
margin: 0;
display: grid;
gap: 8px;
max-height: 340px;
overflow: auto;
background: rgba(248, 253, 249, .3);
border-bottom: 1px solid rgba(155, 197, 167, .2);
}
.history-list::-webkit-scrollbar {
width: 8px;
}
.history-list::-webkit-scrollbar-track {
background: rgba(155, 197, 167, .15);
border-radius: 4px;
}
.history-list::-webkit-scrollbar-thumb {
background: rgba(155, 197, 167, .45);
border-radius: 4px;
}
.history-list::-webkit-scrollbar-thumb:hover {
background: rgba(155, 197, 167, .65);
}
.history-item {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 8px;
align-items: center;
}
.history-item .equals-sign {
font-size: 16px;
font-weight: 600;
color: var(--text);
text-align: center;
}
.history-item button {
text-align: left;
appearance: none;
cursor: pointer;
font-family: 'Poppins', system-ui, sans-serif;
font-size: 13px;
font-weight: 600;
background: rgba(200, 230, 208, .5);
border: 1px solid var(--key-border);
border-radius: var(--radius);
padding: 12px 14px;
line-height: 1.5;
color: var(--text);
word-break: break-word;
transition: all .12s ease;
box-shadow: none;
}
.history-item button:hover {
background: var(--key-hover);
box-shadow: 0 2px 8px rgba(44, 62, 63, .15), 0 1px 3px rgba(44, 62, 63, .12);
}
.history-item button:active {
background: var(--key-hover);
box-shadow: 0 1px 3px rgba(44, 62, 63, .1) inset;
}
.history-item button:focus-visible {
outline: 3px solid rgba(31, 111, 235, .35);
outline-offset: 2px;
} JavaScript
const exprEl = document.getElementById('expr');
const valueEl = document.getElementById('value');
const backBtn = document.getElementById('backBtn');
const historyPanel = document.getElementById('historyPanel');
const historyList = document.getElementById('historyList');
const historyClose = document.getElementById('historyClose');
let flashTimer = null;
let longPressTimer = null;
let longPressFired = false;
let historyBrowseIndex = -1;
let historyBrowseExpr = '';
let expression = '';
let lastWasResult = false;
function setStatus(message){
console.log('Status:', message);
}
function flashMessage(message, ms = 1200){
if (flashTimer) window.clearTimeout(flashTimer);
const prev = exprEl.textContent;
exprEl.textContent = message;
flashTimer = window.setTimeout(() => {
if (prev === message) {
exprEl.textContent = historyBrowseIndex >= 0 && historyBrowseExpr ? formatExpression(historyBrowseExpr) : formatExpression(expression);
} else {
exprEl.textContent = prev;
}
}, ms);
}
const CALC_HISTORY_KEY = 'pt_calc_history_v1';
const CALC_HISTORY_LIMIT = 30;
function readCalcHistory(){
try {
const raw = localStorage.getItem(CALC_HISTORY_KEY);
return raw ? JSON.parse(raw) : [];
} catch (_){
return [];
}
}
function writeCalcHistory(arr){
try {
localStorage.setItem(CALC_HISTORY_KEY, JSON.stringify(arr));
} catch (_){}
}
function addCalcHistoryItem(item){
const list = readCalcHistory();
const expr = item.expression || '';
const hasOperator = new RegExp('[+*/-]').test(expr);
if (!hasOperator) return;
if (list.length > 0) {
const last = list[0];
if (last.expression === item.expression && last.result === item.result) return;
}
list.unshift(item);
writeCalcHistory(list.slice(0, CALC_HISTORY_LIMIT));
}
function renderCalcHistory(){
const list = readCalcHistory();
historyList.innerHTML = '';
if (!list.length){
const li = document.createElement('li');
li.className = 'history-item';
li.textContent = '履歴がありません';
historyList.appendChild(li);
return;
}
list.forEach((item, index) => {
const li = document.createElement('li');
li.className = 'history-item';
const expr = typeof item?.expression === 'string' ? item.expression : '';
const res = typeof item?.result === 'string' ? item.result : '';
const formattedExpr = expr ? formatExpression(expr) : '';
const exprBtn = document.createElement('button');
exprBtn.type = 'button';
exprBtn.dataset.action = 'resume';
exprBtn.dataset.expr = expr;
exprBtn.textContent = formattedExpr || '—';
exprBtn.title = '計算を続ける';
const equalsSpan = document.createElement('span');
equalsSpan.className = 'equals-sign';
equalsSpan.textContent = '=';
const resBtn = document.createElement('button');
resBtn.type = 'button';
resBtn.dataset.action = 'result';
resBtn.dataset.index = String(index);
resBtn.textContent = res;
resBtn.title = '結果を使用';
li.appendChild(exprBtn);
li.appendChild(equalsSpan);
li.appendChild(resBtn);
historyList.appendChild(li);
});
}
function openHistory(){
renderCalcHistory();
historyPanel.setAttribute('aria-hidden', 'false');
historyClose.focus();
}
function closeHistory(){
historyPanel.setAttribute('aria-hidden', 'true');
backBtn.focus();
}
function resetHistoryBrowse(){
historyBrowseIndex = -1;
historyBrowseExpr = '';
}
function showHistoryItemAt(index){
const list = readCalcHistory();
const item = list[index];
if (!item) return false;
historyBrowseIndex = index;
const resStr = typeof item.result === 'string' ? item.result : '0';
const exprStr = typeof item.expression === 'string' ? item.expression : '';
historyBrowseExpr = exprStr;
expression = resStr;
lastWasResult = true;
// 計算式と結果を両方表示
exprEl.textContent = formatExpression(exprStr);
valueEl.textContent = resStr;
setStatus(`履歴 ${index + 1} を表示`);
return true;
}
function onBackShortPress(){
const list = readCalcHistory();
if (!list.length){
flashMessage('履歴がありません');
return;
}
const nextIndex = Math.min(historyBrowseIndex + 1, list.length - 1);
if (nextIndex === historyBrowseIndex){
flashMessage('これ以上ありません');
return;
}
showHistoryItemAt(nextIndex);
}
function formatExpression(expr){
let formatted = expr.replace(/(\\d+)(\\.\\d*)?/g, (match, integer, decimal) => {
const withComma = integer.replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',');
return decimal ? withComma + decimal : withComma;
});
formatted = formatted.replace(/\\*/g, '×').replace(/\\//g, '÷');
return formatted;
}
function render(){
const formatted = formatExpression(expression);
exprEl.textContent = formatted;
valueEl.textContent = formatted ? formatted : '0';
}
function isOperator(ch){
return ch === '+' || ch === '-' || ch === '*' || ch === '/';
}
function appendDigit(d){
if (lastWasResult){
expression = '';
lastWasResult = false;
resetHistoryBrowse();
}
expression += d;
render();
}
function appendOperator(op){
if (!expression || lastWasResult){
if (lastWasResult && expression){
lastWasResult = false;
resetHistoryBrowse();
} else {
return;
}
}
const last = expression.slice(-1);
if (isOperator(last)){
expression = expression.slice(0, -1) + op;
} else {
expression += op;
}
render();
}
function appendDot(){
if (lastWasResult){
expression = '0';
lastWasResult = false;
resetHistoryBrowse();
}
if (!expression){
expression = '0';
}
const tokens = expression.split(new RegExp('[+*/-]'));
const current = tokens[tokens.length - 1];
if (current.includes('.')) return;
expression += '.';
render();
}
function backspace(){
if (lastWasResult){
lastWasResult = false;
expression = '';
resetHistoryBrowse();
}
expression = expression.slice(0, -1);
render();
}
function clear(){
expression = '';
lastWasResult = false;
resetHistoryBrowse();
render();
setStatus('クリアしました');
}
function tokenize(expr){
const tokens = [];
let num = '';
let expectUnary = true;
for (let i = 0; i < expr.length; i++){
const c = expr[i];
if (c === ' ') continue;
if (/\\d/.test(c) || c === '.'){
num += c;
expectUnary = false;
} else if (isOperator(c)){
if (c === '-' && expectUnary){
num += c;
} else {
if (num) tokens.push({type: 'number', value: parseFloat(num)});
num = '';
tokens.push({type: 'operator', value: c});
expectUnary = true;
}
}
}
if (num) tokens.push({type: 'number', value: parseFloat(num)});
return tokens;
}
function toRpn(tokens){
const out = [];
const ops = [];
const precedence = {'+': 1, '-': 1, '*': 2, '/': 2};
for (const t of tokens){
if (t.type === 'number'){
out.push(t);
} else {
while (ops.length && precedence[ops[ops.length - 1].value] >= precedence[t.value]){
out.push(ops.pop());
}
ops.push(t);
}
}
while (ops.length) out.push(ops.pop());
return out;
}
function evalRpn(rpn){
const stack = [];
for (const t of rpn){
if (t.type === 'number'){
stack.push(t.value);
continue;
}
const b = stack.pop();
const a = stack.pop();
if (typeof a !== 'number' || typeof b !== 'number') throw new Error('Invalid expression');
let r;
switch (t.value){
case '+': r = a + b; break;
case '-': r = a - b; break;
case '*': r = a * b; break;
case '/':
if (b === 0) throw new Error('Division by zero');
r = a / b;
break;
default:
throw new Error('Invalid operator');
}
stack.push(r);
}
if (stack.length !== 1) throw new Error('Invalid expression');
return stack[0];
}
function formatResult(n){
if (!Number.isFinite(n)) return 'Error';
const s = Math.abs(n) < 1e-12 ? '0' : String(n);
if (s.includes('e') || s.includes('E')) return s;
const rounded = Math.round(n * 1e12) / 1e12;
const parts = String(rounded).split('.');
parts[0] = parts[0].replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',');
return parts.join('.');
}
function equals(){
if (!expression) return;
if (lastWasResult) return;
const last = expression.slice(-1);
if (isOperator(last) || last === '.'){
setStatus('式が未完成です');
return;
}
try {
resetHistoryBrowse();
const beforeExpression = expression;
const tokens = tokenize(expression);
const rpn = toRpn(tokens);
const result = evalRpn(rpn);
const out = formatResult(result);
exprEl.textContent = formatExpression(expression);
valueEl.textContent = out;
expression = out === 'Error' ? '' : out;
lastWasResult = true;
setStatus('計算しました');
if (out !== 'Error'){
addCalcHistoryItem({
expression: beforeExpression,
result: out,
ts: Date.now()
});
}
} catch (e){
valueEl.textContent = 'Error';
setStatus('計算できませんでした');
}
}
document.querySelector('.keys').addEventListener('click', (ev) => {
const btn = ev.target.closest('button');
if (!btn) return;
const action = btn.dataset.action;
const value = btn.dataset.value;
switch (action){
case 'digit': appendDigit(value); break;
case 'op': appendOperator(value); break;
case 'dot': appendDot(); break;
case 'equals': equals(); break;
case 'clear': clear(); break;
case 'backspace': backspace(); break;
default: break;
}
});
backBtn.addEventListener('pointerdown', (ev) => {
if (ev.button !== undefined && ev.button !== 0) return;
longPressFired = false;
if (longPressTimer) window.clearTimeout(longPressTimer);
longPressTimer = window.setTimeout(() => {
longPressFired = true;
openHistory();
}, 520);
});
function cancelLongPress(){
if (longPressTimer) window.clearTimeout(longPressTimer);
longPressTimer = null;
}
backBtn.addEventListener('pointerup', () => {
cancelLongPress();
if (!longPressFired) onBackShortPress();
});
backBtn.addEventListener('pointercancel', cancelLongPress);
backBtn.addEventListener('pointerleave', cancelLongPress);
historyClose.addEventListener('click', closeHistory);
document.addEventListener('click', (ev) => {
if (historyPanel.getAttribute('aria-hidden') === 'false'){
const target = ev.target;
const isInsideCalc = target.closest('.shell');
if (!isInsideCalc) closeHistory();
}
});
historyPanel.addEventListener('click', (ev) => {
ev.stopPropagation();
});
historyList.addEventListener('click', (ev) => {
ev.stopPropagation();
const btn = ev.target.closest('button');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'resume') {
const expr = btn.dataset.expr;
if (expr) {
expression = expr;
lastWasResult = false;
resetHistoryBrowse();
render();
closeHistory();
}
} else if (action === 'result') {
const index = Number(btn.dataset.index);
if (Number.isFinite(index)) {
if (showHistoryItemAt(index)) closeHistory();
}
}
});
document.addEventListener('keydown', (ev) => {
const keysToPrevent = ['Enter', 'Backspace'];
if (keysToPrevent.includes(ev.key)) ev.preventDefault();
if (historyPanel.getAttribute('aria-hidden') === 'false'){
if (ev.key === 'Escape'){
ev.preventDefault();
closeHistory();
}
return;
}
if (/^\\d$/.test(ev.key)) appendDigit(ev.key);
else if (ev.key === '+') appendOperator('+');
else if (ev.key === '-') appendOperator('-');
else if (ev.key === '*') appendOperator('*');
else if (ev.key === '/') appendOperator('/');
else if (ev.key === '.') appendDot();
else if (ev.key === 'Enter') equals();
else if (ev.key === 'Backspace') backspace();
else if (ev.key === 'Escape') clear();
});
render(); このパーツの特徴
- ブラウザ風履歴UI 「←」ボタンの短いクリックで順次閲覧、長押しで一覧表示の直感的な操作
- localStorage永続化 計算履歴をローカルストレージに保存し、ブラウザを閉じても残る
- 履歴から再開 過去の計算結果を選択して、そこから計算を続けることが可能
- RPN評価エンジン 逆ポーランド記法で正確な計算順序を実現
- アクセシビリティ対応 キーボード操作とARIA属性で全ユーザーに優しい設計
コードのポイント
pointerdown/pointerupで長押し検知
pointerdownイベントでタイマーを開始し、pointerupで短いクリックか長押しかを判定します。
backBtn.addEventListener('pointerdown', (ev) => {
ev.preventDefault();
longPressFired = false;
longPressTimer = window.setTimeout(() => {
longPressFired = true;
openHistory();
}, 520);
});
backBtn.addEventListener('pointerup', () => {
if (longPressTimer) window.clearTimeout(longPressTimer);
if (!longPressFired) onBackShortPress();
}); localStorageで履歴を永続化
JSON形式で計算履歴を保存し、ページを開くたびに読み込みます。
const CALC_HISTORY_KEY = 'pt_calc_history_v1';
const CALC_HISTORY_LIMIT = 30;
function readCalcHistory() {
try {
const raw = localStorage.getItem(CALC_HISTORY_KEY);
return raw ? JSON.parse(raw) : [];
} catch (_) {
return [];
}
}
function writeCalcHistory(arr) {
try {
localStorage.setItem(CALC_HISTORY_KEY, JSON.stringify(arr));
} catch (_) {}
}
function addCalcHistoryItem(item) {
const list = readCalcHistory();
list.unshift(item);
writeCalcHistory(list.slice(0, CALC_HISTORY_LIMIT));
} RPN(逆ポーランド記法)で評価
中置記法の式をトークン分解し、RPNに変換してから評価することで、演算子の優先度を正確に処理します。
function toRpn(tokens) {
const output = [];
const stack = [];
const precedence = { '+': 1, '-': 1, '*': 2, '/': 2 };
for (const token of tokens) {
if (!isNaN(token)) {
output.push(token);
} else if (token in precedence) {
while (stack.length && precedence[stack[stack.length - 1]] >= precedence[token]) {
output.push(stack.pop());
}
stack.push(token);
}
}
while (stack.length) output.push(stack.pop());
return output;
}
function evalRpn(rpn) {
const stack = [];
for (const token of rpn) {
if (!isNaN(token)) {
stack.push(Number.parseFloat(token));
} else {
const b = stack.pop();
const a = stack.pop();
if (token === '+') stack.push(a + b);
else if (token === '-') stack.push(a - b);
else if (token === '*') stack.push(a * b);
else if (token === '/') stack.push(a / b);
}
}
return stack[0];
} 履歴インデックスで順次閲覧
historyBrowseIndexで現在表示中の履歴位置を管理し、短いクリックで順次閲覧します。
function onBackShortPress() {
const list = readCalcHistory();
if (!list.length) {
flashMessage('履歴がありません', 800);
return;
}
const nextIndex = Math.min(historyBrowseIndex + 1, list.length - 1);
showHistoryItemAt(nextIndex);
}
function showHistoryItemAt(index) {
const list = readCalcHistory();
const item = list[index];
if (!item) return false;
historyBrowseIndex = index;
expression = String(item.result);
lastWasResult = true;
exprEl.textContent = formatExpression(item.expression);
valueEl.textContent = item.result;
return true;
} まとめ
履歴機能付き電卓のポイント
- 直感的な履歴UI ブラウザの戻るボタンを模した短いクリックと長押しの2種類の操作
- 永続化データ localStorageで計算履歴を保存し、実用的な体験を提供
- 履歴から再開 過去の計算結果を起点に、新たな計算を続けられる
- RPN評価 逆ポーランド記法で正確な計算順序を実現
- アクセシビリティ キーボード操作対応とARIA属性で全ユーザーが利用可能
電卓というシンプルなツールですが、履歴機能を加えることで実用性が大きく向上します。ブラウザの戻るボタンという馴染みのあるインターフェースを参考にすることで、ユーザーにとって直感的で使いやすいツールを実現できます。