From 003d4fde1b63fe8eeb805e99634001e0425d8928 Mon Sep 17 00:00:00 2001 From: MatMoul Date: Sat, 16 May 2026 04:04:01 +0200 Subject: [PATCH] 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. --- .memory/project.md | 4 +- README.md | 23 ++++---- samples/calc-02/index.css | 24 ++------- samples/calc-02/index.js | 109 +++++++++++++++++--------------------- 4 files changed, 69 insertions(+), 91 deletions(-) diff --git a/.memory/project.md b/.memory/project.md index 5e9ead2..31bc871 100644 --- a/.memory/project.md +++ b/.memory/project.md @@ -1,9 +1,9 @@ # State - 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 - Public API: `push`, `pop`, `clear`, `swap`, `remove`, `edit`, `isValidIndex`, `input`, `command`, `getOperationsByCategory`, `getConstants`, `listConstants`, `setConstant`, `removeConstant`, `hasConstant` - 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 -- 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` diff --git a/README.md b/README.md index d5d74e5..864fed1 100644 --- a/README.md +++ b/README.md @@ -16,15 +16,15 @@ The main class is `RpnCalculator`. ## Project structure - `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.js`: demo UI and keyboard logic - `samples/calc-01/index.html`: active browser demo entry point - `samples/calc-01/index.css`: demo styles - `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.css`: new responsive demo styles -- `samples/calc-02/index.js`: responsive HP48GX-style demo UI and keyboard logic +- `samples/calc-02/index.html`: portrait-first HP48GX-style demo entry point +- `samples/calc-02/index.css`: portrait-first demo styles +- `samples/calc-02/index.js`: portrait-first HP48GX-style demo UI and keyboard logic - `samples/calc-XX/`: placeholder name for future demo variants ## Public API @@ -45,6 +45,10 @@ Instance helpers also available: - `getOperationsByCategory()` - `getConstants()` +- `listConstants()` +- `setConstant(name, value)` +- `removeConstant(name)` +- `hasConstant(name)` Static helpers also available: @@ -84,6 +88,7 @@ Available constants: - `pi` - `e` +- plus any user-defined constants added through the engine API They can be used through `command(...)`: @@ -246,6 +251,11 @@ Main UI features: - grouped panels for `Stack`, `Arithmetic`, `Trigonometry`, and `Constants` - 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: ```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. 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 `RpnCalculator` is exposed in both environments: diff --git a/samples/calc-02/index.css b/samples/calc-02/index.css index f872a6e..27d3e56 100644 --- a/samples/calc-02/index.css +++ b/samples/calc-02/index.css @@ -1,5 +1,4 @@ - :root { --bg0: #10151e; --bg1: #1b2432; @@ -42,7 +41,6 @@ body { background: var(--bg0); } - .app-shell { min-height: 100vh; display: grid; @@ -113,9 +111,6 @@ body { max-height: 138px; margin-bottom: 0; } - - - .display-grid { height: 100%; display: grid; @@ -125,7 +120,6 @@ body { gap: 2px; } - .stack-cell { display: grid; grid-template-columns: 2.2ch auto minmax(0, 1fr); @@ -137,7 +131,6 @@ body { padding-block: 0; } - .stack-label { text-align: right; opacity: 0.78; @@ -152,8 +145,6 @@ body { justify-self: end; font-size: 20px; } - - .display-buttons-panel { grid-area: display-buttons; padding: 8px; @@ -183,7 +174,6 @@ body { box-shadow: none; } - .display-button-symbol { display: inline-flex; align-items: center; @@ -203,8 +193,7 @@ body { padding: 6px 8px; } - -.mode-menu { +.menu-popup { position: fixed; z-index: 20; display: flex; @@ -218,7 +207,7 @@ body { backdrop-filter: blur(2px); } -.mode-menu-item { +.menu-popup-item { width: 100%; min-width: 0; padding: 6px 10px; @@ -228,7 +217,7 @@ body { font-weight: 700; } -.mode-menu-item.is-active { +.menu-popup-item.is-active { outline: 1px solid rgba(207, 224, 174, 0.7); outline-offset: 0; } @@ -295,10 +284,6 @@ body { .functions-grid, .trigo-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); -} - -.functions-grid, -.trigo-grid { grid-template-rows: repeat(2, minmax(0, 1fr)); } @@ -321,7 +306,6 @@ button:hover { filter: brightness(1.06); } - button:active { transform: none; box-shadow: none; @@ -342,8 +326,6 @@ button:active { opacity: 0.7; } } - - .stack-copy-button { padding: 4px; min-width: 24px; diff --git a/samples/calc-02/index.js b/samples/calc-02/index.js index 02c28dd..8514193 100644 --- a/samples/calc-02/index.js +++ b/samples/calc-02/index.js @@ -338,40 +338,49 @@ function handleKeyboard(event) { } const modeOptions = ['deg', 'rad', 'grad']; -let modeMenuEl = null; +let activeMenuEl = null; function closeModeMenu() { - if (modeMenuEl) { - modeMenuEl.remove(); - modeMenuEl = null; + if (activeMenuEl) { + activeMenuEl.remove(); + 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() { - if (modeMenuEl) { + if (activeMenuEl) { closeModeMenu(); return; } closeConstMenu(); - const rect = modeButton.getBoundingClientRect(); - modeMenuEl = document.createElement('div'); - modeMenuEl.className = 'mode-menu'; - modeMenuEl.style.top = `${rect.bottom + 6 + window.scrollY}px`; - modeMenuEl.style.minWidth = `${rect.width}px`; - 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; - render(); - closeModeMenu(); - }); - modeMenuEl.appendChild(button); + activeMenuEl = openMenu(modeButton, modeOptions.map((mode) => ({ + label: mode, + value: mode, + active: mode === calc.angleMode, + })), (mode) => { + calc.angleMode = mode; + render(); + closeModeMenu(); }); - document.body.appendChild(modeMenuEl); - modeMenuEl.style.left = `${rect.left + window.scrollX}px`; } modeButton.addEventListener('click', (event) => { @@ -396,10 +405,8 @@ window.addEventListener('click', (event) => { if (label) copyStackValue(label); 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(); - } - if (constMenuEl && !event.target.closest('.mode-menu') && event.target !== constButton) { closeConstMenu(); } }); @@ -431,48 +438,34 @@ const constantLabels = { }; const constantOrder = ['pi', 'e', 'phi', 'g', 'c']; -let constMenuEl = null; function closeConstMenu() { - if (constMenuEl) { - constMenuEl.remove(); - constMenuEl = null; + if (activeMenuEl) { + activeMenuEl.remove(); + activeMenuEl = null; } } function toggleConstMenu() { - if (constMenuEl) { + if (activeMenuEl) { closeConstMenu(); return; } 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 keys = [...constantOrder, ...Object.keys(availableConstants).filter((name) => !constantOrder.includes(name))]; - keys.forEach((name) => { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'mode-menu-item'; - if (!Object.prototype.hasOwnProperty.call(availableConstants, name)) { - return; - } - button.textContent = constantLabels[name] ?? name; - button.addEventListener('click', () => { - pushEditingValueIfNeeded(); - calc.push(availableConstants[name]); - render(); - clearStatus(); - closeConstMenu(); - focusInput(); - }); - constMenuEl.appendChild(button); + const keys = [...constantOrder, ...Object.keys(availableConstants).filter((name) => !constantOrder.includes(name))] + .filter((name) => Object.prototype.hasOwnProperty.call(availableConstants, name)); + activeMenuEl = openMenu(constButton, keys.map((name) => ({ + label: constantLabels[name] ?? name, + value: name, + })), (name) => { + pushEditingValueIfNeeded(); + calc.push(availableConstants[name]); + render(); + clearStatus(); + closeConstMenu(); + focusInput(); }); - document.body.appendChild(constMenuEl); - constMenuEl.style.left = `${rect.left + window.scrollX}px`; } constButton.addEventListener('click', (event) => { @@ -481,7 +474,6 @@ constButton.addEventListener('click', (event) => { }); leftButton.addEventListener('click', () => {}); - downButton.addEventListener('click', () => { if (!calc.isEditing && calc.isValidIndex(0)) { const value = calc.stack[0]; @@ -503,7 +495,6 @@ window.addEventListener('pageshow', focusInput); window.addEventListener('focus', focusInput); window.addEventListener('pointerdown', focusInput, true); window.addEventListener('mousedown', focusInput, true); -window.addEventListener('click', focusInput, true); hiddenInput.setAttribute('inputmode', 'none'); hiddenInput.setAttribute('readonly', 'readonly'); @@ -518,7 +509,7 @@ hiddenInput.addEventListener('focus', () => { }); document.addEventListener('click', (event) => { - if (!isTouchDevice && !event.target.closest('.mode-menu')) { + if (!isTouchDevice && !event.target.closest('.menu-popup')) { focusInput(); } });