Skip to content

Commit b00dcdc

Browse files
authored
add calculator example (#177)
mostly to iterate on a `css` directive. adds caching to the directive function calls.
1 parent 63f051d commit b00dcdc

File tree

8 files changed

+439
-1
lines changed

8 files changed

+439
-1
lines changed

examples/calculator/css.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
const class_names = new WeakMap()
2+
const adopted = new WeakSet()
3+
const stylesheet = new CSSStyleSheet()
4+
let next_id = 0
5+
const cache = new Map()
6+
7+
/**
8+
* @param {TemplateStringsArray} strings
9+
* @param {unknown[]} dynamics
10+
* @returns {import('dhtml/client').Directive}
11+
*/
12+
export function css(strings, ...dynamics) {
13+
let class_name = class_names.get(strings)
14+
if (!class_name) {
15+
class_names.set(strings, (class_name = `gen-${next_id++}`))
16+
stylesheet.insertRule(
17+
`.${class_name}{${strings.reduce((acc, value, index) => acc + `var(--${class_name}-${index - 1})` + value)}}`,
18+
)
19+
}
20+
21+
const cache_key = `${class_name}\0${dynamics.map(v => String(v)).join('\0')}`
22+
const cached = cache.get(cache_key)
23+
if (cached) return cached
24+
25+
/** @type {import('dhtml/client').Directive} */
26+
const directive = element => {
27+
const root = /** @type {Document | ShadowRoot} */ (element.getRootNode())
28+
if (!adopted.has(root)) {
29+
root.adoptedStyleSheets.push(stylesheet)
30+
adopted.add(root)
31+
}
32+
33+
const { style, classList } = /** @type {HTMLElement} */ (element)
34+
35+
classList.add(class_name)
36+
for (let i = 0; i < dynamics.length; i++) {
37+
style.setProperty(`--${class_name}-${i}`, String(dynamics[i]))
38+
}
39+
40+
return () => {
41+
classList.remove(class_name)
42+
for (let i = 0; i < dynamics.length; i++) {
43+
style.setProperty(`--${class_name}-${i}`, null)
44+
}
45+
}
46+
}
47+
48+
cache.set(cache_key, directive)
49+
return directive
50+
}

examples/calculator/index.html

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<link rel="icon" type="image/svg+xml" href="data:" />
7+
<title>dhtml calculator</title>
8+
<script type="importmap">
9+
{
10+
"imports": {
11+
"dhtml": "./node_modules/dhtml/index.js",
12+
"dhtml/client": "./node_modules/dhtml/client.js"
13+
}
14+
}
15+
</script>
16+
<script type="module" src="main.js"></script>
17+
</head>
18+
<body></body>
19+
</html>

examples/calculator/main.js

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import { html } from 'dhtml'
2+
import { createRoot, invalidate } from 'dhtml/client'
3+
import { css } from './css.js'
4+
5+
css`
6+
/* Theme colors */
7+
--bg-body: #f8f9fa;
8+
--bg-container: #fff;
9+
--bg-display: #f7f7f9;
10+
--bg-buttons: #fff;
11+
--bg-button-default: #f5f5f5;
12+
--bg-button-function: #f3f4f6;
13+
--bg-button-operator: #e2e8f0;
14+
--bg-button-equals: #334155;
15+
--bg-button-operator-active: #64748b;
16+
--text-primary: #000;
17+
--text-secondary: #999;
18+
--text-button: #111;
19+
--text-button-operator: #1e293b;
20+
--text-button-light: #fff;
21+
--border-color: #ddd;
22+
--shadow: rgba(0, 0, 0, 0.08);
23+
24+
@media (prefers-color-scheme: dark) {
25+
--bg-body: #0a0a0a;
26+
--bg-container: #0d0d0d;
27+
--bg-display: #1f1f1f;
28+
--bg-buttons: #0d0d0d;
29+
--bg-button-default: #2d2d2d;
30+
--bg-button-function: #404040;
31+
--bg-button-operator: #475569;
32+
--bg-button-equals: #64748b;
33+
--bg-button-operator-active: #64748b;
34+
--text-primary: #fff;
35+
--text-secondary: #666;
36+
--text-button: #fff;
37+
--text-button-operator: #e2e8f0;
38+
--text-button-light: #fff;
39+
--border-color: #333;
40+
--shadow: rgba(0, 0, 0, 0.3);
41+
}
42+
43+
background-color: var(--bg-body);
44+
`(document.body)
45+
46+
// Operator mappings
47+
const operators = {
48+
'+': { display: '+', canonical: '+' },
49+
'-': { display: '-', canonical: '-' },
50+
'*': { display: '×', canonical: '*' },
51+
'/': { display: '÷', canonical: '/' },
52+
}
53+
54+
// Simple calculator app
55+
const app = {
56+
display: '0',
57+
waitingForOperand: false,
58+
operator: null,
59+
value: null,
60+
inputDigit(digit) {
61+
if (this.waitingForOperand) {
62+
this.display = digit === '.' ? '0.' : String(digit)
63+
this.waitingForOperand = false
64+
} else {
65+
if (this.display === '0' && digit !== '.') this.display = String(digit)
66+
else if (digit === '.' && this.display.includes('.')) return
67+
else this.display = this.display + digit
68+
}
69+
},
70+
inputDot() {
71+
if (this.waitingForOperand) {
72+
this.display = '0.'
73+
this.waitingForOperand = false
74+
return
75+
}
76+
if (!this.display.includes('.')) this.display = this.display + '.'
77+
},
78+
clear() {
79+
this.display = '0'
80+
this.value = null
81+
this.operator = null
82+
this.waitingForOperand = false
83+
},
84+
negate() {
85+
if (this.display === '0') return
86+
if (this.display.startsWith('-')) this.display = this.display.slice(1)
87+
else this.display = '-' + this.display
88+
},
89+
percent() {
90+
const num = parseFloat(this.display) || 0
91+
this.display = String(num / 100)
92+
},
93+
op(nextOp) {
94+
// If clicking the same operator that's already active, deactivate it
95+
if (this.operator === nextOp) {
96+
this.operator = null
97+
this.waitingForOperand = false
98+
return
99+
}
100+
101+
const inputValue = parseFloat(this.display)
102+
if (this.value == null) {
103+
this.value = inputValue
104+
} else if (this.operator) {
105+
const result = performOperation(this.value, inputValue, this.operator)
106+
this.value = result
107+
this.display = String(result)
108+
}
109+
this.operator = nextOp
110+
this.waitingForOperand = true
111+
},
112+
equals() {
113+
const inputValue = parseFloat(this.display)
114+
if (this.operator && this.value != null) {
115+
const result = performOperation(this.value, inputValue, this.operator)
116+
this.display = String(result)
117+
this.value = null
118+
this.operator = null
119+
this.waitingForOperand = true
120+
}
121+
},
122+
render() {
123+
return html`
124+
<div
125+
${css`
126+
font-family:
127+
system-ui,
128+
-apple-system,
129+
'Segoe UI',
130+
Roboto,
131+
'Helvetica Neue',
132+
Arial;
133+
max-width: 360px;
134+
margin: 48px auto;
135+
border: 1px solid var(--border-color);
136+
border-radius: 12px;
137+
box-shadow: 0 6px 24px var(--shadow);
138+
overflow: hidden;
139+
`}
140+
>
141+
<div
142+
${css`
143+
background: var(--bg-display);
144+
padding: 20px;
145+
text-align: right;
146+
`}
147+
>
148+
<div
149+
${css`
150+
color: var(--text-secondary);
151+
font-size: 14px;
152+
margin-bottom: 6px;
153+
height: 18px; /* reserve space to avoid layout shift */
154+
line-height: 18px;
155+
overflow: hidden;
156+
`}
157+
>
158+
${this.value != null
159+
? `${this.value} ${this.operator ? operators[this.operator]?.display || this.operator : ''}`
160+
: ''}
161+
</div>
162+
<div
163+
${css`
164+
font-size: 36px;
165+
font-weight: 600;
166+
margin-top: 0px;
167+
color: var(--text-primary);
168+
`}
169+
>
170+
${this.display}
171+
</div>
172+
</div>
173+
<div
174+
${css`
175+
padding: 12px;
176+
background: var(--bg-buttons);
177+
display: grid;
178+
grid-template-columns: repeat(4, 1fr);
179+
gap: 8px;
180+
`}
181+
>
182+
${button('C', () => this.clear(), { type: 'function' }, this)}
183+
${button('+/-', () => this.negate(), { type: 'function' }, this)}
184+
${button('%', () => this.percent(), { type: 'function' }, this)}
185+
${button('÷', () => this.op('/'), { type: 'operator' }, this)} ${digitButton('7', this)}
186+
${digitButton('8', this)} ${digitButton('9', this)}
187+
${button('×', () => this.op('*'), { type: 'operator' }, this)} ${digitButton('4', this)}
188+
${digitButton('5', this)} ${digitButton('6', this)}
189+
${button('-', () => this.op('-'), { type: 'operator' }, this)} ${digitButton('1', this)}
190+
${digitButton('2', this)} ${digitButton('3', this)}
191+
${button('+', () => this.op('+'), { type: 'operator' }, this)}
192+
${button('0', () => this.inputDigit('0'), { span: 2 }, this)} ${digitButton('.', this)}
193+
${button('=', () => this.equals(), { type: 'equals' }, this)}
194+
</div>
195+
</div>
196+
`
197+
},
198+
}
199+
200+
function button(label, onClick, opts = {}, app) {
201+
const isOperator = Object.values(operators).some(op => op.display === label)
202+
const operatorData = Object.values(operators).find(op => op.display === label)
203+
const active = isOperator && app?.operator === operatorData?.canonical
204+
205+
const getButtonColor = () => {
206+
if (active) return 'var(--bg-button-operator-active)'
207+
208+
const typeMap = {
209+
function: 'var(--bg-button-function)',
210+
operator: 'var(--bg-button-operator)',
211+
equals: 'var(--bg-button-equals)',
212+
}
213+
return typeMap[opts.type] || 'var(--bg-button-default)'
214+
}
215+
216+
const getTextColor = () => {
217+
const typeMap = {
218+
operator: 'var(--text-button-operator)',
219+
equals: 'var(--text-button-light)',
220+
}
221+
return typeMap[opts.type] || 'var(--text-button)'
222+
}
223+
224+
const styles = css`
225+
padding: 14px 12px;
226+
background: ${getButtonColor()};
227+
color: ${getTextColor()};
228+
border-radius: 8px;
229+
font-size: 18px;
230+
font-weight: 600;
231+
display: inline-flex;
232+
align-items: center;
233+
justify-content: center;
234+
cursor: pointer;
235+
user-select: none;
236+
&:active {
237+
transform: translateY(1px);
238+
}
239+
`
240+
const spanStyles = opts.span
241+
? css`
242+
grid-column: span ${opts.span};
243+
`
244+
: null
245+
246+
return html`<div
247+
${styles}
248+
${spanStyles}
249+
onclick=${() => {
250+
onClick()
251+
invalidate(app)
252+
}}
253+
>
254+
${label}
255+
</div>`
256+
}
257+
258+
function digitButton(d, app) {
259+
return button(
260+
d,
261+
() => {
262+
if (d === '.') app.inputDot()
263+
else app.inputDigit(d)
264+
},
265+
{},
266+
app,
267+
)
268+
}
269+
270+
function performOperation(a, b, op) {
271+
if (op === '+') return round(a + b)
272+
if (op === '-') return round(a - b)
273+
if (op === '*') return round(a * b)
274+
if (op === '/') return b === 0 ? 'Error' : round(a / b)
275+
return b
276+
}
277+
278+
function round(n) {
279+
if (typeof n === 'string') return n
280+
return Math.round((n + Number.EPSILON) * 1e12) / 1e12
281+
}
282+
283+
createRoot(document.body).render(app)

examples/calculator/package.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "@dhtml-examples/calculator",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"check": "tsc"
7+
},
8+
"devDependencies": {
9+
"typescript": "~5.8.3"
10+
},
11+
"dependencies": {
12+
"dhtml": "file:../../dist"
13+
}
14+
}

examples/calculator/tsconfig.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"compilerOptions": {
3+
"checkJs": true,
4+
"noEmit": true,
5+
"allowImportingTsExtensions": true,
6+
"verbatimModuleSyntax": true,
7+
"moduleResolution": "bundler",
8+
"module": "preserve",
9+
"target": "es2020"
10+
}
11+
}

0 commit comments

Comments
 (0)