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:
+2
-2
@@ -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`
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user