a post-component library for building user-interfaces on the web.

add calculator example (#177)

mostly to iterate on a `css` directive.
adds caching to the directive function calls.

authored by tombl.dev and committed by

GitHub b00dcdc4 63f051de

+439 -1
+50
examples/calculator/css.js
··· 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 + }
+19
examples/calculator/index.html
··· 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>
+283
examples/calculator/main.js
··· 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)
+14
examples/calculator/package.json
··· 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 + }
+11
examples/calculator/tsconfig.json
··· 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 + }
+31
package-lock.json
··· 20 20 "dist": { 21 21 "name": "dhtml" 22 22 }, 23 + "examples/calculator": { 24 + "name": "@dhtml-examples/calculator", 25 + "dependencies": { 26 + "dhtml": "file:../../dist" 27 + }, 28 + "devDependencies": { 29 + "typescript": "~5.8.3" 30 + } 31 + }, 32 + "examples/calculator/node_modules/dhtml": { 33 + "resolved": "dist", 34 + "link": true 35 + }, 36 + "examples/calculator/node_modules/typescript": { 37 + "version": "5.8.3", 38 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", 39 + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", 40 + "dev": true, 41 + "license": "Apache-2.0", 42 + "bin": { 43 + "tsc": "bin/tsc", 44 + "tsserver": "bin/tsserver" 45 + }, 46 + "engines": { 47 + "node": ">=14.17" 48 + } 49 + }, 23 50 "examples/kanban": { 24 51 "name": "@dhtml-examples/kanban", 25 52 "dependencies": { ··· 217 244 "engines": { 218 245 "node": ">=6.9.0" 219 246 } 247 + }, 248 + "node_modules/@dhtml-examples/calculator": { 249 + "resolved": "examples/calculator", 250 + "link": true 220 251 }, 221 252 "node_modules/@dhtml-examples/kanban": { 222 253 "resolved": "examples/kanban",
+3
src/client/parts.ts
··· 292 292 293 293 export function create_directive_part(node: Node): Part { 294 294 let cleanup: Cleanup 295 + let prev_fn: unknown 295 296 return fn => { 297 + if (prev_fn === fn) return 296 298 assert(typeof fn === 'function' || fn == null) 297 299 cleanup?.() 298 300 cleanup = fn?.(node) 301 + prev_fn = fn 299 302 } 300 303 } 301 304
+28 -1
src/client/tests/directives.test.ts
··· 1 1 import { html } from 'dhtml' 2 2 import { attr, on, type Directive } from 'dhtml/client' 3 - import { assert, assert_eq, test } from '../../../scripts/test/test.ts' 3 + import { assert, assert_deep_eq, assert_eq, test } from '../../../scripts/test/test.ts' 4 4 import { setup } from './setup.ts' 5 5 6 6 test('directive functions work correctly', () => { ··· 216 216 button.click() 217 217 assert_eq(count, 3) 218 218 }) 219 + 220 + test('same directive function is not re-invoked or cleaned up', () => { 221 + const { root } = setup() 222 + 223 + const sequence: string[] = [] 224 + const stable = () => { 225 + sequence.push('stable create') 226 + return () => sequence.push('stable cleanup') 227 + } 228 + const unstable = () => () => { 229 + sequence.push('unstable create') 230 + return () => sequence.push('unstable cleanup') 231 + } 232 + 233 + const template = (d1: Directive | null, d2: Directive | null) => html`<div ${d1} ${d2}></div>` 234 + 235 + root.render(template(stable, unstable())) 236 + assert_deep_eq(sequence, ['stable create', 'unstable create']) 237 + sequence.length = 0 238 + 239 + root.render(template(stable, unstable())) 240 + assert_deep_eq(sequence, ['unstable cleanup', 'unstable create']) 241 + sequence.length = 0 242 + 243 + root.render(template(null, null)) 244 + assert_deep_eq(sequence, ['stable cleanup', 'unstable cleanup']) 245 + })