feat(calc-02): add shared popup menus for mode and constants

Refactor the calc-02 demo to use a single popup menu component for angle mode and constants, align the menus to their trigger buttons, and update the README/project notes to reflect the portrait-first demo layout and constant API.
This commit is contained in:
2026-05-16 04:04:01 +02:00
parent e5f50aee0a
commit 003d4fde1b
4 changed files with 69 additions and 91 deletions
+2 -2
View File
@@ -1,9 +1,9 @@
# State # State
- Core engine: `src/rpn-calculator.js` - Core engine: `src/rpn-calculator.js`
- Active demo: `samples/calc-02/` portrait-only HP48GX layout; display-adjacent button row stays in 4 columns and now shares the same base button styling as the function keys - Active demo: `samples/calc-02/` portrait-only HP48GX layout; display-adjacent button row stays in 4 columns and uses compact popup menus for mode/constants
- Mode button shows the current angle mode only; selecting a mode uses a popup menu - Mode button shows the current angle mode only; selecting a mode uses a popup menu
- Public API: `push`, `pop`, `clear`, `swap`, `remove`, `edit`, `isValidIndex`, `input`, `command`, `getOperationsByCategory`, `getConstants`, `listConstants`, `setConstant`, `removeConstant`, `hasConstant` - Public API: `push`, `pop`, `clear`, `swap`, `remove`, `edit`, `isValidIndex`, `input`, `command`, `getOperationsByCategory`, `getConstants`, `listConstants`, `setConstant`, `removeConstant`, `hasConstant`
- Config: `maxSize`, `base`, `angleMode`, `enabledCommands` - Config: `maxSize`, `base`, `angleMode`, `enabledCommands`
- Commands: arithmetic, stack, trigonometry, constants `pi` and `e`; arithmetic now includes `root` for y-th roots; constants can now be added or removed dynamically through the core API and the calc-02 constant menu reads from the engine - Commands: arithmetic, stack, trigonometry, constants `pi` and `e`; arithmetic now includes `root` for y-th roots; constants can now be added or removed dynamically through the core API and the calc-02 constant menu reads from the engine
- Demo actions: keyboard focus is kept on the hidden input on desktop so typing keeps working; the keypad layout places Enter in the bottom-left, ± in the former Enter position, and Esc before Clear for safety; paste parses clipboard text as a number before pushing it to the stack; Ctrl+V is supported via the hidden input paste event; backspace is ignored when the stack is empty; operation errors are shown as an overlay bar on top of the calculator with a shorter timeout and darker red; the display button row uses a 4-column grid with a spacer cell to preserve alignment; the portrait layout reference now lives in `samples/calc-02/visual-portrait.md` - Demo actions: keyboard focus is kept on the hidden input on desktop so typing keeps working; the keypad layout places Enter in the bottom-left, ± in the former Enter position, and Esc before Clear for safety; paste parses clipboard text as a number before pushing it to the stack; Ctrl+V is supported via the hidden input paste event; backspace is ignored when the stack is empty; operation errors are shown as an overlay bar on top of the calculator with a shorter timeout and darker red; the display button row uses a 4-column grid with a spacer cell to preserve alignment; mode and constant popups are aligned to the left edge of their trigger buttons; the portrait layout reference now lives in `samples/calc-02/visual-portrait.md`
- Exports: browser `window.RpnCalculator`, CommonJS `module.exports` - Exports: browser `window.RpnCalculator`, CommonJS `module.exports`
+14 -9
View File
@@ -16,15 +16,15 @@ The main class is `RpnCalculator`.
## Project structure ## Project structure
- `src/rpn-calculator.js`: calculator engine - `src/rpn-calculator.js`: calculator engine
- `samples/dev/index.html`: active browser demo entry point - `samples/dev/index.html`: browser demo entry point
- `samples/dev/index.css`: demo styles - `samples/dev/index.css`: demo styles
- `samples/dev/index.js`: demo UI and keyboard logic - `samples/dev/index.js`: demo UI and keyboard logic
- `samples/calc-01/index.html`: active browser demo entry point - `samples/calc-01/index.html`: active browser demo entry point
- `samples/calc-01/index.css`: demo styles - `samples/calc-01/index.css`: demo styles
- `samples/calc-01/index.js`: demo UI and keyboard logic - `samples/calc-01/index.js`: demo UI and keyboard logic
- `samples/calc-02/index.html`: new responsive HP48GX-style demo entry point - `samples/calc-02/index.html`: portrait-first HP48GX-style demo entry point
- `samples/calc-02/index.css`: new responsive demo styles - `samples/calc-02/index.css`: portrait-first demo styles
- `samples/calc-02/index.js`: responsive HP48GX-style demo UI and keyboard logic - `samples/calc-02/index.js`: portrait-first HP48GX-style demo UI and keyboard logic
- `samples/calc-XX/`: placeholder name for future demo variants - `samples/calc-XX/`: placeholder name for future demo variants
## Public API ## Public API
@@ -45,6 +45,10 @@ Instance helpers also available:
- `getOperationsByCategory()` - `getOperationsByCategory()`
- `getConstants()` - `getConstants()`
- `listConstants()`
- `setConstant(name, value)`
- `removeConstant(name)`
- `hasConstant(name)`
Static helpers also available: Static helpers also available:
@@ -84,6 +88,7 @@ Available constants:
- `pi` - `pi`
- `e` - `e`
- plus any user-defined constants added through the engine API
They can be used through `command(...)`: They can be used through `command(...)`:
@@ -246,6 +251,11 @@ Main UI features:
- grouped panels for `Stack`, `Arithmetic`, `Trigonometry`, and `Constants` - grouped panels for `Stack`, `Arithmetic`, `Trigonometry`, and `Constants`
- error display area - error display area
## Calc 02 demo
`samples/calc-02/` is a portrait-first HP48GX-inspired demo.
It keeps the display-adjacent button row aligned in four columns, uses compact popup menus for mode and constants, and supports clipboard paste plus the `y√x` root operation.
The demo loads the engine from: The demo loads the engine from:
```html README.md ```html README.md
@@ -270,11 +280,6 @@ The current demo supports:
The demo also implements stack selection and stack-item move mode in its UI layer using the public calculator methods. The demo also implements stack selection and stack-item move mode in its UI layer using the public calculator methods.
It keeps the calculator screen focused and updates the visible stack window as the selection moves. It keeps the calculator screen focused and updates the visible stack window as the selection moves.
## Calc 02 demo
`samples/calc-02/` is a responsive HP48GX-inspired demo.
It adapts its layout to the browser window, supports portrait and landscape arrangements, and includes the `y√x` root operation.
## Exports ## Exports
`RpnCalculator` is exposed in both environments: `RpnCalculator` is exposed in both environments:
+3 -21
View File
@@ -1,5 +1,4 @@
:root { :root {
--bg0: #10151e; --bg0: #10151e;
--bg1: #1b2432; --bg1: #1b2432;
@@ -42,7 +41,6 @@ body {
background: var(--bg0); background: var(--bg0);
} }
.app-shell { .app-shell {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
@@ -113,9 +111,6 @@ body {
max-height: 138px; max-height: 138px;
margin-bottom: 0; margin-bottom: 0;
} }
.display-grid { .display-grid {
height: 100%; height: 100%;
display: grid; display: grid;
@@ -125,7 +120,6 @@ body {
gap: 2px; gap: 2px;
} }
.stack-cell { .stack-cell {
display: grid; display: grid;
grid-template-columns: 2.2ch auto minmax(0, 1fr); grid-template-columns: 2.2ch auto minmax(0, 1fr);
@@ -137,7 +131,6 @@ body {
padding-block: 0; padding-block: 0;
} }
.stack-label { .stack-label {
text-align: right; text-align: right;
opacity: 0.78; opacity: 0.78;
@@ -152,8 +145,6 @@ body {
justify-self: end; justify-self: end;
font-size: 20px; font-size: 20px;
} }
.display-buttons-panel { .display-buttons-panel {
grid-area: display-buttons; grid-area: display-buttons;
padding: 8px; padding: 8px;
@@ -183,7 +174,6 @@ body {
box-shadow: none; box-shadow: none;
} }
.display-button-symbol { .display-button-symbol {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -203,8 +193,7 @@ body {
padding: 6px 8px; padding: 6px 8px;
} }
.menu-popup {
.mode-menu {
position: fixed; position: fixed;
z-index: 20; z-index: 20;
display: flex; display: flex;
@@ -218,7 +207,7 @@ body {
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
} }
.mode-menu-item { .menu-popup-item {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
padding: 6px 10px; padding: 6px 10px;
@@ -228,7 +217,7 @@ body {
font-weight: 700; font-weight: 700;
} }
.mode-menu-item.is-active { .menu-popup-item.is-active {
outline: 1px solid rgba(207, 224, 174, 0.7); outline: 1px solid rgba(207, 224, 174, 0.7);
outline-offset: 0; outline-offset: 0;
} }
@@ -295,10 +284,6 @@ body {
.functions-grid, .functions-grid,
.trigo-grid { .trigo-grid {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
}
.functions-grid,
.trigo-grid {
grid-template-rows: repeat(2, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr));
} }
@@ -321,7 +306,6 @@ button:hover {
filter: brightness(1.06); filter: brightness(1.06);
} }
button:active { button:active {
transform: none; transform: none;
box-shadow: none; box-shadow: none;
@@ -342,8 +326,6 @@ button:active {
opacity: 0.7; opacity: 0.7;
} }
} }
.stack-copy-button { .stack-copy-button {
padding: 4px; padding: 4px;
min-width: 24px; min-width: 24px;
+41 -50
View File
@@ -338,40 +338,49 @@ function handleKeyboard(event) {
} }
const modeOptions = ['deg', 'rad', 'grad']; const modeOptions = ['deg', 'rad', 'grad'];
let modeMenuEl = null; let activeMenuEl = null;
function closeModeMenu() { function closeModeMenu() {
if (modeMenuEl) { if (activeMenuEl) {
modeMenuEl.remove(); activeMenuEl.remove();
modeMenuEl = null; activeMenuEl = null;
} }
} }
function openMenu(anchorButton, items, onSelect) {
const rect = anchorButton.getBoundingClientRect();
const menu = document.createElement('div');
menu.className = 'menu-popup';
menu.style.top = `${rect.bottom + 6 + window.scrollY}px`;
menu.style.left = `${rect.left + window.scrollX}px`;
menu.style.minWidth = `${rect.width}px`;
for (const item of items) {
const button = document.createElement('button');
button.type = 'button';
button.className = `menu-popup-item${item.active ? ' is-active' : ''}`;
button.textContent = item.label;
button.addEventListener('click', () => onSelect(item.value));
menu.appendChild(button);
}
document.body.appendChild(menu);
return menu;
}
function toggleModeMenu() { function toggleModeMenu() {
if (modeMenuEl) { if (activeMenuEl) {
closeModeMenu(); closeModeMenu();
return; return;
} }
closeConstMenu(); closeConstMenu();
const rect = modeButton.getBoundingClientRect(); activeMenuEl = openMenu(modeButton, modeOptions.map((mode) => ({
modeMenuEl = document.createElement('div'); label: mode,
modeMenuEl.className = 'mode-menu'; value: mode,
modeMenuEl.style.top = `${rect.bottom + 6 + window.scrollY}px`; active: mode === calc.angleMode,
modeMenuEl.style.minWidth = `${rect.width}px`; })), (mode) => {
modeOptions.forEach((mode) => {
const button = document.createElement('button');
button.type = 'button';
button.className = `mode-menu-item${mode === calc.angleMode ? ' is-active' : ''}`;
button.textContent = mode;
button.addEventListener('click', () => {
calc.angleMode = mode; calc.angleMode = mode;
render(); render();
closeModeMenu(); closeModeMenu();
}); });
modeMenuEl.appendChild(button);
});
document.body.appendChild(modeMenuEl);
modeMenuEl.style.left = `${rect.left + window.scrollX}px`;
} }
modeButton.addEventListener('click', (event) => { modeButton.addEventListener('click', (event) => {
@@ -396,10 +405,8 @@ window.addEventListener('click', (event) => {
if (label) copyStackValue(label); if (label) copyStackValue(label);
return; return;
} }
if (modeMenuEl && !event.target.closest('.mode-menu') && event.target !== modeButton) { if (activeMenuEl && !event.target.closest('.menu-popup') && event.target !== modeButton && event.target !== constButton) {
closeModeMenu(); closeModeMenu();
}
if (constMenuEl && !event.target.closest('.mode-menu') && event.target !== constButton) {
closeConstMenu(); closeConstMenu();
} }
}); });
@@ -431,37 +438,27 @@ const constantLabels = {
}; };
const constantOrder = ['pi', 'e', 'phi', 'g', 'c']; const constantOrder = ['pi', 'e', 'phi', 'g', 'c'];
let constMenuEl = null;
function closeConstMenu() { function closeConstMenu() {
if (constMenuEl) { if (activeMenuEl) {
constMenuEl.remove(); activeMenuEl.remove();
constMenuEl = null; activeMenuEl = null;
} }
} }
function toggleConstMenu() { function toggleConstMenu() {
if (constMenuEl) { if (activeMenuEl) {
closeConstMenu(); closeConstMenu();
return; return;
} }
closeModeMenu(); closeModeMenu();
const rect = constButton.getBoundingClientRect();
constMenuEl = document.createElement('div');
constMenuEl.className = 'mode-menu';
constMenuEl.style.top = `${rect.bottom + 6 + window.scrollY}px`;
constMenuEl.style.minWidth = `${rect.width}px`;
const availableConstants = calc.listConstants(); const availableConstants = calc.listConstants();
const keys = [...constantOrder, ...Object.keys(availableConstants).filter((name) => !constantOrder.includes(name))]; const keys = [...constantOrder, ...Object.keys(availableConstants).filter((name) => !constantOrder.includes(name))]
keys.forEach((name) => { .filter((name) => Object.prototype.hasOwnProperty.call(availableConstants, name));
const button = document.createElement('button'); activeMenuEl = openMenu(constButton, keys.map((name) => ({
button.type = 'button'; label: constantLabels[name] ?? name,
button.className = 'mode-menu-item'; value: name,
if (!Object.prototype.hasOwnProperty.call(availableConstants, name)) { })), (name) => {
return;
}
button.textContent = constantLabels[name] ?? name;
button.addEventListener('click', () => {
pushEditingValueIfNeeded(); pushEditingValueIfNeeded();
calc.push(availableConstants[name]); calc.push(availableConstants[name]);
render(); render();
@@ -469,10 +466,6 @@ function toggleConstMenu() {
closeConstMenu(); closeConstMenu();
focusInput(); focusInput();
}); });
constMenuEl.appendChild(button);
});
document.body.appendChild(constMenuEl);
constMenuEl.style.left = `${rect.left + window.scrollX}px`;
} }
constButton.addEventListener('click', (event) => { constButton.addEventListener('click', (event) => {
@@ -481,7 +474,6 @@ constButton.addEventListener('click', (event) => {
}); });
leftButton.addEventListener('click', () => {}); leftButton.addEventListener('click', () => {});
downButton.addEventListener('click', () => { downButton.addEventListener('click', () => {
if (!calc.isEditing && calc.isValidIndex(0)) { if (!calc.isEditing && calc.isValidIndex(0)) {
const value = calc.stack[0]; const value = calc.stack[0];
@@ -503,7 +495,6 @@ window.addEventListener('pageshow', focusInput);
window.addEventListener('focus', focusInput); window.addEventListener('focus', focusInput);
window.addEventListener('pointerdown', focusInput, true); window.addEventListener('pointerdown', focusInput, true);
window.addEventListener('mousedown', focusInput, true); window.addEventListener('mousedown', focusInput, true);
window.addEventListener('click', focusInput, true);
hiddenInput.setAttribute('inputmode', 'none'); hiddenInput.setAttribute('inputmode', 'none');
hiddenInput.setAttribute('readonly', 'readonly'); hiddenInput.setAttribute('readonly', 'readonly');
@@ -518,7 +509,7 @@ hiddenInput.addEventListener('focus', () => {
}); });
document.addEventListener('click', (event) => { document.addEventListener('click', (event) => {
if (!isTouchDevice && !event.target.closest('.mode-menu')) { if (!isTouchDevice && !event.target.closest('.menu-popup')) {
focusInput(); focusInput();
} }
}); });