Skip to content

Commit 2806a55

Browse files
feat: add calculator example app with switchable modes
Add a modern, dark-themed calculator example at examples/app/calculator/ that showcases WebUI SSR + FAST-HTML hydration with a modular mode system. Architecture: - Plugin-like ModeEngine interface (modes/engine.ts) with a registry pattern — adding a new calculator mode requires only one new file and a registerMode() call, with zero UI component changes - Standard mode: 4-column grid with basic arithmetic, operator chaining, percent, and sign negation - Scientific mode: 5-column grid adding sin/cos/tan (degrees), ln/log, sqrt, x², xʸ, factorial, π, e, parentheses, and memory operations (MC/MR/M+) Components (FAST-HTML hydration): - calc-app: root orchestrator — mode tab switching, keyboard input via document-level keydown listener, delegates to ModeEngine.processInput() - calc-display: glassmorphism display panel showing expression history and current value, with backdrop-filter blur - calc-button: color-coded by type (number/operator/function/action/equal) with scale+shadow press animation and grid-column span support Hydration details: - prepare() reads @attr values from getAttribute() and child element data from shadowRoot.querySelectorAll() — @attr field initializers run after super() with useDefineForClassFields:false, so they are not available during prepare() - Mode tabs are static HTML (not a <for> loop) to avoid a FAST-HTML hydration bug where @observable string arrays in repeat directives cause 'Cannot assign to read only property' errors - Keyboard listener on document (not shadowRoot) so calculator input works regardless of focus, with cleanup in disconnectedCallback() Styling: - Dark theme with CSS custom properties for all colors, spacing, radii, shadows, and transitions - Button types: numbers (#1e2a4a), operators (#e94560), scientific functions (#533483), actions (#0f3460) - Responsive grid switches between 4 and 5 columns via :host([columns]) attribute selector
1 parent bd30230 commit 2806a55

18 files changed

Lines changed: 1415 additions & 0 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"textdirection": "ltr",
3+
"language": "en",
4+
"title": "Calculator",
5+
"mode": "standard",
6+
"displayValue": "0",
7+
"expression": "",
8+
"columns": "4",
9+
"buttons": [
10+
{ "label": "AC", "value": "clear", "type": "action", "span": "1" },
11+
{ "label": "±", "value": "negate", "type": "action", "span": "1" },
12+
{ "label": "%", "value": "percent", "type": "action", "span": "1" },
13+
{ "label": "÷", "value": "/", "type": "operator", "span": "1" },
14+
{ "label": "7", "value": "7", "type": "number", "span": "1" },
15+
{ "label": "8", "value": "8", "type": "number", "span": "1" },
16+
{ "label": "9", "value": "9", "type": "number", "span": "1" },
17+
{ "label": "×", "value": "*", "type": "operator", "span": "1" },
18+
{ "label": "4", "value": "4", "type": "number", "span": "1" },
19+
{ "label": "5", "value": "5", "type": "number", "span": "1" },
20+
{ "label": "6", "value": "6", "type": "number", "span": "1" },
21+
{ "label": "", "value": "-", "type": "operator", "span": "1" },
22+
{ "label": "1", "value": "1", "type": "number", "span": "1" },
23+
{ "label": "2", "value": "2", "type": "number", "span": "1" },
24+
{ "label": "3", "value": "3", "type": "number", "span": "1" },
25+
{ "label": "+", "value": "+", "type": "operator", "span": "1" },
26+
{ "label": "0", "value": "0", "type": "number", "span": "2" },
27+
{ "label": ".", "value": ".", "type": "number", "span": "1" },
28+
{ "label": "=", "value": "=", "type": "equal", "span": "1" }
29+
]
30+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "calculator-example",
3+
"version": "1.0.0",
4+
"type": "module",
5+
"private": true,
6+
"scripts": {
7+
"start:client": "esbuild src/index.ts --bundle --outfile=dist/index.js --format=esm --sourcemap --watch",
8+
"start:server": "cargo run -p webui-cli -- start ./src --state ./data/state.json --plugin=fast --servedir ./dist --port 3001 --watch",
9+
"start": "cargo xtask dev calculator"
10+
},
11+
"devDependencies": {
12+
"@microsoft/fast-element": "catalog:",
13+
"@microsoft/fast-html": "catalog:",
14+
"esbuild": "catalog:",
15+
"tslib": "catalog:",
16+
"typescript": "catalog:"
17+
}
18+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
:host {
2+
display: block;
3+
width: 100%;
4+
max-width: 380px;
5+
}
6+
7+
.calculator {
8+
background: var(--color-surface);
9+
border-radius: var(--border-radius-xl);
10+
padding: var(--spacing-l);
11+
box-shadow: var(--shadow-card);
12+
display: flex;
13+
flex-direction: column;
14+
gap: var(--spacing-m);
15+
}
16+
17+
/* Mode tabs */
18+
.mode-tabs {
19+
display: flex;
20+
gap: var(--spacing-xs);
21+
padding: var(--spacing-xs);
22+
background: rgba(0, 0, 0, 0.15);
23+
border-radius: var(--border-radius-s);
24+
}
25+
26+
.mode-tab {
27+
flex: 1;
28+
padding: var(--spacing-s) var(--spacing-m);
29+
border: none;
30+
border-radius: var(--border-radius-s);
31+
background: var(--color-tab-bg);
32+
color: var(--color-tab-text);
33+
font-family: var(--font-family-base);
34+
font-size: var(--font-size-s);
35+
font-weight: 500;
36+
cursor: pointer;
37+
transition:
38+
background var(--transition-fast),
39+
color var(--transition-fast);
40+
text-transform: capitalize;
41+
}
42+
43+
.mode-tab:hover {
44+
color: var(--color-display-text);
45+
background: rgba(255, 255, 255, 0.05);
46+
}
47+
48+
.mode-tab[data-active] {
49+
background: var(--color-tab-active-bg);
50+
color: var(--color-tab-active-text);
51+
}
52+
53+
/* Button grid */
54+
.button-grid {
55+
display: grid;
56+
grid-template-columns: repeat(4, 1fr);
57+
gap: var(--spacing-s);
58+
}
59+
60+
:host([columns="5"]) .button-grid {
61+
grid-template-columns: repeat(5, 1fr);
62+
}
63+
64+
:host([columns="6"]) .button-grid {
65+
grid-template-columns: repeat(6, 1fr);
66+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<template shadowrootmode="open"
2+
@button-press="{onButtonPress(e)}"
3+
>
4+
<div class="calculator">
5+
<div class="mode-tabs" @click="{onModeSelect(e)}">
6+
<button class="mode-tab" data-mode="standard" data-active>Standard</button>
7+
<button class="mode-tab" data-mode="scientific">Scientific</button>
8+
</div>
9+
10+
<calc-display
11+
expression="{{expression}}"
12+
value="{{displayValue}}"
13+
></calc-display>
14+
15+
<div class="button-grid">
16+
<for each="btn in buttons">
17+
<calc-button
18+
label="{{btn.label}}"
19+
value="{{btn.value}}"
20+
btn-type="{{btn.type}}"
21+
btn-span="{{btn.span}}"
22+
></calc-button>
23+
</for>
24+
</div>
25+
</div>
26+
</template>
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { FASTElement, attr, observable } from '@microsoft/fast-element';
2+
import { RenderableFASTElement } from '@microsoft/fast-html';
3+
import type { CalcState, ButtonDef } from '../modes/engine.js';
4+
import { createInitialState, getMode, getModeKeys } from '../modes/engine.js';
5+
import '../modes/standard.js';
6+
import '../modes/scientific.js';
7+
8+
interface ButtonData {
9+
label: string;
10+
value: string;
11+
type: string;
12+
span: string;
13+
}
14+
15+
export class CalcApp extends RenderableFASTElement(FASTElement) {
16+
@attr mode!: string;
17+
@attr({ attribute: 'display-value' }) displayValue!: string;
18+
@attr expression!: string;
19+
@attr({ attribute: 'columns' }) gridColumns!: string;
20+
21+
@observable buttons!: ButtonData[];
22+
23+
private state!: CalcState;
24+
25+
async prepare(): Promise<void> {
26+
this.mode = this.getAttribute('mode') || 'standard';
27+
this.displayValue = this.getAttribute('display-value') || '0';
28+
this.expression = this.getAttribute('expression') || '';
29+
this.gridColumns = this.getAttribute('columns') || '4';
30+
this.state = createInitialState();
31+
32+
// Read button data from pre-rendered DOM (mirrors todo-fast pattern)
33+
const buttons: ButtonData[] = [];
34+
if (this.shadowRoot) {
35+
for (const el of this.shadowRoot.querySelectorAll('calc-button')) {
36+
buttons.push({
37+
label: el.getAttribute('label') || '',
38+
value: el.getAttribute('value') || '',
39+
type: el.getAttribute('btn-type') || '',
40+
span: el.getAttribute('btn-span') || '1',
41+
});
42+
}
43+
}
44+
45+
if (buttons.length > 0) {
46+
this.buttons = buttons;
47+
} else {
48+
this.loadButtonsFromEngine();
49+
}
50+
}
51+
52+
private boundKeydown = this.onKeydown.bind(this);
53+
54+
connectedCallback(): void {
55+
super.connectedCallback();
56+
document.addEventListener('keydown', this.boundKeydown);
57+
this.updateActiveModeTab();
58+
}
59+
60+
disconnectedCallback(): void {
61+
super.disconnectedCallback();
62+
document.removeEventListener('keydown', this.boundKeydown);
63+
}
64+
65+
onButtonPress(e: CustomEvent<{ value: string }>): void {
66+
this.handleInput(e.detail.value);
67+
}
68+
69+
onModeSelect(e: MouseEvent): void {
70+
const target = e.composedPath()[0] as HTMLElement;
71+
const btn = target.closest('[data-mode]') as HTMLElement | null;
72+
if (!btn) return;
73+
74+
const newMode = btn.getAttribute('data-mode');
75+
if (newMode && newMode !== this.mode) {
76+
this.mode = newMode;
77+
this.state = {
78+
...this.state,
79+
expression: '',
80+
resetOnNext: true,
81+
error: null,
82+
};
83+
this.expression = '';
84+
this.loadButtonsFromEngine();
85+
this.updateActiveModeTab();
86+
}
87+
}
88+
89+
private onKeydown(e: KeyboardEvent): void {
90+
const keyMap: Record<string, string> = {
91+
'0': '0', '1': '1', '2': '2', '3': '3', '4': '4',
92+
'5': '5', '6': '6', '7': '7', '8': '8', '9': '9',
93+
'.': '.', '+': '+', '-': '-', '*': '*', '/': '/',
94+
Enter: '=', '=': '=', Escape: 'clear', Backspace: 'clear',
95+
'%': 'percent',
96+
};
97+
98+
const input = keyMap[e.key];
99+
if (input) {
100+
e.preventDefault();
101+
this.handleInput(input);
102+
}
103+
}
104+
105+
private handleInput(input: string): void {
106+
const engine = getMode(this.mode);
107+
if (!engine) return;
108+
109+
this.state = engine.processInput(input, this.state);
110+
this.displayValue = this.state.display;
111+
this.expression = this.state.expression;
112+
}
113+
114+
private loadButtonsFromEngine(): void {
115+
const engine = getMode(this.mode);
116+
if (!engine) return;
117+
118+
this.gridColumns = String(engine.columns);
119+
this.buttons = engine.buttons.map((b: ButtonDef) => ({
120+
label: b.label,
121+
value: b.value,
122+
type: b.type,
123+
span: String(b.span ?? 1),
124+
}));
125+
}
126+
127+
private updateActiveModeTab(): void {
128+
if (!this.shadowRoot) return;
129+
for (const tab of this.shadowRoot.querySelectorAll('[data-mode]')) {
130+
if (tab.getAttribute('data-mode') === this.mode) {
131+
tab.setAttribute('data-active', '');
132+
} else {
133+
tab.removeAttribute('data-active');
134+
}
135+
}
136+
}
137+
}
138+
139+
CalcApp.defineAsync({
140+
name: 'calc-app',
141+
templateOptions: 'defer-and-hydrate',
142+
});

0 commit comments

Comments
 (0)