a post-component library for building user-interfaces on the web.
at main 230 lines 5.4 kB view raw
1// @ts-check 2 3import { html } from 'dhtml' 4import { createRoot, invalidate } from 'dhtml/client' 5 6function transition(cb) { 7 if ('startViewTransition' in document) { 8 document.startViewTransition(cb) 9 } else { 10 cb() 11 } 12} 13 14function classes(...args) { 15 const classes = args.flatMap(a => (a ? a.split(' ') : [])) 16 return node => { 17 node.classList.add(...classes) 18 return () => { 19 node.classList.remove(...classes) 20 } 21 } 22} 23 24const autofocus = node => node.focus() 25const autoselect = node => node.setSelectionRange(0, node.value.length) 26 27class TodoItem { 28 id = crypto.randomUUID() 29 completed = false 30 editing = false 31 constructor(app, title) { 32 this.app = app 33 this.title = title 34 } 35 36 render() { 37 return html` 38 <li 39 ${classes(this.completed && 'completed', this.editing && 'editing')} 40 style=${`view-transition-name: _${this.id}`} 41 > 42 <div class="view"> 43 <input 44 class="toggle" 45 type="checkbox" 46 checked=${this.completed} 47 onchange=${e => { 48 e.preventDefault() 49 transition(async () => { 50 this.completed = e.target.checked 51 await invalidate(this, this.app) 52 }) 53 }} 54 /> 55 <label 56 ondblclick=${() => { 57 transition(async () => { 58 this.editing = true 59 await invalidate(this) 60 }) 61 }} 62 >${this.title}</label 63 > 64 <button 65 class="destroy" 66 onclick=${() => { 67 transition(async () => { 68 this.app.remove(this.id) 69 await invalidate(this.app) 70 }) 71 }} 72 ></button> 73 </div> 74 ${this.editing 75 ? html` 76 <div class="input-container"> 77 <input 78 class="edit" 79 value=${this.title} 80 ${autofocus} 81 ${autoselect} 82 onblur=${e => { 83 const value = e.target.value.trim() 84 if (value) { 85 transition(async () => { 86 this.title = value 87 this.editing = false 88 await invalidate(this) 89 }) 90 } 91 }} 92 onkeydown=${e => { 93 if (e.key === 'Enter') { 94 const value = e.target.value.trim() 95 if (value) { 96 transition(async () => { 97 this.title = value 98 this.editing = false 99 await invalidate(this) 100 }) 101 } 102 } 103 }} 104 /> 105 </div> 106 ` 107 : null} 108 </li> 109 ` 110 } 111} 112 113class App { 114 todos = [] 115 get(id) { 116 return this.todos.find(todo => todo.id === id) 117 } 118 remove(id) { 119 this.todos = this.todos.filter(todo => todo.id !== id) 120 } 121 122 filter = 'All' 123 render() { 124 const completedCount = this.todos.filter(todo => todo.completed).length 125 const activeCount = this.todos.length - completedCount 126 127 return html` 128 <header class="header"> 129 <h1>todos</h1> 130 <input 131 class="new-todo" 132 placeholder="What needs to be done?" 133 autofocus 134 onkeydown=${event => { 135 if (event.key === 'Enter') { 136 const value = event.target.value.trim() 137 if (value) { 138 transition(async () => { 139 this.todos.push(new TodoItem(this, value)) 140 event.target.value = '' 141 await invalidate(this) 142 }) 143 } 144 } 145 }} 146 /> 147 </header> 148 ${this.todos.length > 0 149 ? html` 150 <main class="main"> 151 <div class="toggle-all-container"> 152 <input 153 class="toggle-all" 154 id="toggle-all" 155 type="checkbox" 156 checked=${activeCount === 0} 157 onchange=${e => { 158 transition(async () => { 159 for (const todo of this.todos) todo.completed = e.target.checked 160 await invalidate(this) 161 }) 162 }} 163 /> 164 <label class="toggle-all-label" for="toggle-all">Toggle All Input</label> 165 </div> 166 <ul class="todo-list"> 167 ${this.todos.filter(todo => { 168 switch (this.filter) { 169 case 'Active': 170 return !todo.completed 171 case 'Completed': 172 return todo.completed 173 case 'All': 174 return true 175 } 176 })} 177 </ul> 178 </main> 179 <footer class="footer"> 180 <span class="todo-count">${activeCount} ${activeCount === 1 ? 'item' : 'items'} left</span> 181 <ul class="filters"> 182 ${['All', 'Active', 'Completed'].map( 183 filter => 184 html`<li> 185 <a 186 href="#" 187 ${classes(this.filter === filter && 'selected')} 188 onclick=${() => { 189 transition(async () => { 190 this.filter = filter 191 await invalidate(this) 192 }) 193 }} 194 >${filter}</a 195 > 196 </li>`, 197 )} 198 </ul> 199 ${completedCount > 0 200 ? html`<button 201 class="clear-completed" 202 onclick=${() => { 203 transition(async () => { 204 this.todos = this.todos.filter(todo => !todo.completed) 205 await invalidate(this) 206 }) 207 }} 208 > 209 Clear completed 210 </button>` 211 : null} 212 </footer> 213 ` 214 : null} 215 ` 216 } 217} 218 219const app = new App() 220globalThis.app = app 221document.body.addEventListener('keypress', e => { 222 if (e.ctrlKey && e.key === 'i') invalidate(app) 223}) 224 225app.todos.push(new TodoItem(app, 'hello')) 226app.todos.push(new TodoItem(app, 'world')) 227 228const rootEl = document.getElementById('root') 229if (!rootEl) throw new Error('Root element not found') 230createRoot(rootEl).render(app)