a post-component library for building user-interfaces on the web.
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)