a post-component library for building user-interfaces on the web.
1import { html } from 'dhtml'
2import { createRoot, invalidate } from 'dhtml/client'
3import { css } from './css.js'
4
5css`
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
47const operators = {
48 '+': { display: '+', canonical: '+' },
49 '-': { display: '-', canonical: '-' },
50 '*': { display: '×', canonical: '*' },
51 '/': { display: '÷', canonical: '/' },
52}
53
54// Simple calculator app
55const 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
200function 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
258function 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
270function 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
278function round(n) {
279 if (typeof n === 'string') return n
280 return Math.round((n + Number.EPSILON) * 1e12) / 1e12
281}
282
283createRoot(document.body).render(app)