a post-component library for building user-interfaces on the web.
at main 283 lines 7.2 kB view raw
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)