custom-elements-ts 0.0.17 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +46 -0
- package/.github/workflows/ci.yml +49 -0
- package/.prettierrc +7 -0
- package/LICENSE +20 -0
- package/README.md +270 -35
- package/demos/counter/counter.element.html +1 -0
- package/demos/counter/counter.element.scss +234 -0
- package/demos/counter/counter.element.ts +68 -0
- package/demos/counter/index.html +205 -0
- package/demos/counter/index.ts +1 -0
- package/demos/site/code-example/code-example.element.scss +168 -0
- package/demos/site/code-example/code-example.element.ts +88 -0
- package/demos/site/event-log/event-log.element.scss +179 -0
- package/demos/site/event-log/event-log.element.ts +134 -0
- package/demos/site/favicon.svg +14 -0
- package/demos/site/index.html +346 -0
- package/demos/site/index.ts +13 -0
- package/demos/site/message/message.element.scss +75 -0
- package/demos/site/message/message.element.ts +76 -0
- package/demos/site/og-image.png +0 -0
- package/demos/site/styles/site.css +1023 -0
- package/demos/site/styles/tokens.css +56 -0
- package/demos/site/toast/toast.element.scss +110 -0
- package/demos/site/toast/toast.element.ts +63 -0
- package/demos/todo-dashboard/index.html +141 -0
- package/demos/todo-dashboard/index.ts +4 -0
- package/demos/todo-dashboard/todo-dashboard.element.scss +1145 -0
- package/demos/todo-dashboard/todo-dashboard.element.ts +332 -0
- package/demos/todo-dashboard/todo-filters.element.ts +54 -0
- package/demos/todo-dashboard/todo-item.element.ts +126 -0
- package/demos/todo-dashboard/todo-stats.element.ts +189 -0
- package/package.json +73 -24
- package/src/custom-element.ts +206 -0
- package/{esm5/index.d.ts → src/index.ts} +7 -5
- package/src/listen.ts +70 -0
- package/src/prop.ts +92 -0
- package/src/state.ts +129 -0
- package/src/template-runtime.ts +435 -0
- package/src/toggle.ts +66 -0
- package/src/tsconfig.json +24 -0
- package/src/util.ts +33 -0
- package/src/watch.ts +14 -0
- package/tests/basic.spec.ts +70 -0
- package/tests/custom-element.spec.ts +77 -0
- package/tests/dispatch.spec.ts +52 -0
- package/tests/init.spec.ts +94 -0
- package/tests/listen.spec.ts +118 -0
- package/tests/prop.spec.ts +118 -0
- package/tests/templating-runtime.spec.ts +575 -0
- package/tests/toggle.spec.ts +92 -0
- package/tests/watch.spec.ts +183 -0
- package/tools/build.js +119 -0
- package/tools/bundle.js +167 -0
- package/tools/rollup-config.js +70 -0
- package/tools/start.js +188 -0
- package/tsconfig.json +38 -0
- package/vite.config.mts +30 -0
- package/bundles/custom-elements-ts.umd.js +0 -359
- package/bundles/custom-elements-ts.umd.js.map +0 -1
- package/esm2015/custom-elements-ts.js +0 -283
- package/esm2015/custom-elements-ts.js.map +0 -1
- package/esm5/custom-element.d.ts +0 -12
- package/esm5/custom-elements-ts.js +0 -344
- package/esm5/custom-elements-ts.js.map +0 -1
- package/esm5/listen.d.ts +0 -17
- package/esm5/prop.d.ts +0 -2
- package/esm5/toggle.d.ts +0 -1
- package/esm5/util.d.ts +0 -4
- package/esm5/watch.d.ts +0 -1
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CustomElement,
|
|
3
|
+
Dispatch,
|
|
4
|
+
DispatchEmitter,
|
|
5
|
+
Prop,
|
|
6
|
+
State,
|
|
7
|
+
TemplateResult,
|
|
8
|
+
Toggle,
|
|
9
|
+
Watch,
|
|
10
|
+
html,
|
|
11
|
+
} from 'custom-elements-ts';
|
|
12
|
+
|
|
13
|
+
type Filter = 'all' | 'active' | 'done';
|
|
14
|
+
|
|
15
|
+
interface TodoItem {
|
|
16
|
+
id: number;
|
|
17
|
+
label: string;
|
|
18
|
+
done: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface EmptyMessage {
|
|
22
|
+
title: string;
|
|
23
|
+
sub: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const EMPTY_MESSAGES: Record<Filter, EmptyMessage> = {
|
|
27
|
+
all: {
|
|
28
|
+
title: 'Nothing on the board.',
|
|
29
|
+
sub: 'Capture a task above to start tracking work.',
|
|
30
|
+
},
|
|
31
|
+
active: {
|
|
32
|
+
title: 'All caught up.',
|
|
33
|
+
sub: 'No active tasks remain — bask in the silence.',
|
|
34
|
+
},
|
|
35
|
+
done: {
|
|
36
|
+
title: 'Nothing finished yet.',
|
|
37
|
+
sub: 'Tick a task off the list to see it surface here.',
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
@CustomElement({
|
|
42
|
+
tag: 'cts-todo-dashboard',
|
|
43
|
+
styleUrl: './todo-dashboard.element.scss',
|
|
44
|
+
})
|
|
45
|
+
export class TodoDashboardElement extends HTMLElement {
|
|
46
|
+
@Prop() heading = 'Sprint board';
|
|
47
|
+
@Toggle() compact = false;
|
|
48
|
+
|
|
49
|
+
@State() draft = '';
|
|
50
|
+
@State() filter: Filter = 'all';
|
|
51
|
+
@State() goal = 5;
|
|
52
|
+
@State() revision = 0;
|
|
53
|
+
@State() items: TodoItem[] = [
|
|
54
|
+
{ id: 1, label: 'Wire @CustomElement decorator', done: true },
|
|
55
|
+
{ id: 2, label: 'Diff deeply nested @State() arrays', done: false },
|
|
56
|
+
{ id: 3, label: 'Audit @Listen on shadow root selectors', done: false },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
@Dispatch('todo.change') todoChange!: DispatchEmitter;
|
|
60
|
+
|
|
61
|
+
private nextId = 4;
|
|
62
|
+
|
|
63
|
+
@Watch('items')
|
|
64
|
+
handleItemsChange() {
|
|
65
|
+
this.revision++;
|
|
66
|
+
this.todoChange.emit({
|
|
67
|
+
bubbles: true,
|
|
68
|
+
composed: true,
|
|
69
|
+
detail: {
|
|
70
|
+
total: this.items.length,
|
|
71
|
+
done: this.doneCount,
|
|
72
|
+
active: this.activeCount,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
render(): TemplateResult {
|
|
78
|
+
const visible = this.visibleItems;
|
|
79
|
+
const total = this.items.length;
|
|
80
|
+
const done = this.doneCount;
|
|
81
|
+
const active = total - done;
|
|
82
|
+
const progress = total === 0 ? 0 : Math.round((done / total) * 100);
|
|
83
|
+
|
|
84
|
+
return html`
|
|
85
|
+
<section class=${this.compact ? 'shell compact' : 'shell'}>
|
|
86
|
+
<header class="header">
|
|
87
|
+
<div class="title-block">
|
|
88
|
+
<span class="eyebrow">
|
|
89
|
+
<span class="eyebrow-dot"></span>
|
|
90
|
+
Taskboard
|
|
91
|
+
</span>
|
|
92
|
+
<h1>${this.heading}</h1>
|
|
93
|
+
<p class="meta">
|
|
94
|
+
<span><strong>${active}</strong> active</span>
|
|
95
|
+
<span class="dot-sep">·</span>
|
|
96
|
+
<span><strong>${done}</strong> done</span>
|
|
97
|
+
<span class="dot-sep">·</span>
|
|
98
|
+
<span><strong>${total}</strong> total</span>
|
|
99
|
+
</p>
|
|
100
|
+
</div>
|
|
101
|
+
<span class="revision" title="State revision count">
|
|
102
|
+
<span class="revision-pulse"></span>
|
|
103
|
+
<span class="revision-label">live</span>
|
|
104
|
+
<span class="revision-num">r${this.padRev(this.revision)}</span>
|
|
105
|
+
</span>
|
|
106
|
+
</header>
|
|
107
|
+
|
|
108
|
+
<div class="progress" aria-hidden="true">
|
|
109
|
+
<span class="progress-fill" style=${`--progress:${progress / 100}`}></span>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<cts-todo-stats
|
|
113
|
+
total=${total}
|
|
114
|
+
done=${done}
|
|
115
|
+
goal=${this.goal}
|
|
116
|
+
@goal.change=${this.handleGoalChange}
|
|
117
|
+
></cts-todo-stats>
|
|
118
|
+
|
|
119
|
+
<div class="composer">
|
|
120
|
+
<div class="composer-field">
|
|
121
|
+
<span class="composer-icon" aria-hidden="true">${this.iconPlus()}</span>
|
|
122
|
+
<input
|
|
123
|
+
aria-label="New task"
|
|
124
|
+
placeholder="Capture a task and press enter"
|
|
125
|
+
.value=${this.draft}
|
|
126
|
+
@input=${this.updateDraft}
|
|
127
|
+
@keydown=${this.handleDraftKeydown}
|
|
128
|
+
/>
|
|
129
|
+
<kbd aria-hidden="true">Enter</kbd>
|
|
130
|
+
</div>
|
|
131
|
+
<button class="primary" disabled=${!this.canAdd} @click=${this.addItem}>Add task</button>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<cts-todo-filters
|
|
135
|
+
filter=${this.filter}
|
|
136
|
+
total=${total}
|
|
137
|
+
active=${active}
|
|
138
|
+
done=${done}
|
|
139
|
+
@filter.change=${this.handleFilterChange}
|
|
140
|
+
></cts-todo-filters>
|
|
141
|
+
|
|
142
|
+
${visible.length
|
|
143
|
+
? html`<div
|
|
144
|
+
class="list"
|
|
145
|
+
role="list"
|
|
146
|
+
@todo.toggle=${this.handleTodoToggle}
|
|
147
|
+
@todo.labelchange=${this.handleTodoLabelChange}
|
|
148
|
+
@todo.remove=${this.handleTodoRemove}
|
|
149
|
+
>
|
|
150
|
+
${visible.map(
|
|
151
|
+
(item, index) => html`
|
|
152
|
+
<cts-todo-item
|
|
153
|
+
todoid=${item.id}
|
|
154
|
+
label=${item.label}
|
|
155
|
+
done=${item.done}
|
|
156
|
+
rowindex=${index}
|
|
157
|
+
></cts-todo-item>
|
|
158
|
+
`
|
|
159
|
+
)}
|
|
160
|
+
</div>`
|
|
161
|
+
: this.renderEmpty()}
|
|
162
|
+
|
|
163
|
+
<footer class="footer">
|
|
164
|
+
<span class="footer-stats">
|
|
165
|
+
<strong>${done}</strong>
|
|
166
|
+
<span class="footer-divider">/</span>
|
|
167
|
+
<span>${total}</span>
|
|
168
|
+
<span class="footer-label">completed</span>
|
|
169
|
+
</span>
|
|
170
|
+
<div class="footer-actions">
|
|
171
|
+
<button disabled=${total === 0} @click=${this.completeAll}>Complete all</button>
|
|
172
|
+
<button disabled=${done === 0} @click=${this.clearDone}>Clear done</button>
|
|
173
|
+
</div>
|
|
174
|
+
</footer>
|
|
175
|
+
</section>
|
|
176
|
+
`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private renderEmpty(): TemplateResult {
|
|
180
|
+
const message = EMPTY_MESSAGES[this.filter];
|
|
181
|
+
return html`
|
|
182
|
+
<div class="empty">
|
|
183
|
+
<span class="empty-icon" aria-hidden="true">${this.iconClipboard()}</span>
|
|
184
|
+
<p class="empty-title">${message.title}</p>
|
|
185
|
+
<p class="empty-sub">${message.sub}</p>
|
|
186
|
+
</div>
|
|
187
|
+
`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Inline SVG icons — kept tiny and reused via the template cache.
|
|
191
|
+
|
|
192
|
+
private iconPlus(): TemplateResult {
|
|
193
|
+
return html`<svg
|
|
194
|
+
viewBox="0 0 24 24"
|
|
195
|
+
fill="none"
|
|
196
|
+
stroke="currentColor"
|
|
197
|
+
stroke-width="2"
|
|
198
|
+
stroke-linecap="round"
|
|
199
|
+
stroke-linejoin="round"
|
|
200
|
+
width="14"
|
|
201
|
+
height="14"
|
|
202
|
+
>
|
|
203
|
+
<path d="M12 5v14M5 12h14" />
|
|
204
|
+
</svg>`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private iconClipboard(): TemplateResult {
|
|
208
|
+
return html`<svg
|
|
209
|
+
viewBox="0 0 24 24"
|
|
210
|
+
fill="none"
|
|
211
|
+
stroke="currentColor"
|
|
212
|
+
stroke-width="1.5"
|
|
213
|
+
stroke-linecap="round"
|
|
214
|
+
stroke-linejoin="round"
|
|
215
|
+
width="26"
|
|
216
|
+
height="26"
|
|
217
|
+
>
|
|
218
|
+
<path d="M9 4h6a1 1 0 0 1 1 1v2H8V5a1 1 0 0 1 1-1z" />
|
|
219
|
+
<path d="M16 5h2a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2" />
|
|
220
|
+
<path d="M9 13h6M9 17h4" />
|
|
221
|
+
</svg>`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Event handlers
|
|
225
|
+
|
|
226
|
+
private updateDraft(event: Event) {
|
|
227
|
+
this.draft = (event.target as HTMLInputElement).value;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private handleDraftKeydown(event: KeyboardEvent) {
|
|
231
|
+
if (event.key === 'Enter') {
|
|
232
|
+
this.addItem();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private addItem() {
|
|
237
|
+
const label = this.draft.trim();
|
|
238
|
+
if (!label) return;
|
|
239
|
+
this.items.push({ id: this.nextId++, label, done: false });
|
|
240
|
+
this.draft = '';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private setFilter(filter: Filter) {
|
|
244
|
+
this.filter = filter;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private handleFilterChange(event: Event) {
|
|
248
|
+
this.setFilter((event as CustomEvent<{ filter: Filter }>).detail.filter);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private handleGoalChange(event: Event) {
|
|
252
|
+
this.goal = Math.max(1, (event as CustomEvent<{ goal: number }>).detail.goal);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private handleTodoToggle(event: Event) {
|
|
256
|
+
this.toggleItem((event as CustomEvent<{ id: number }>).detail.id);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private handleTodoLabelChange(event: Event) {
|
|
260
|
+
const { id, label } = (event as CustomEvent<{ id: number; label: string }>).detail;
|
|
261
|
+
this.updateItemLabel(id, label);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private handleTodoRemove(event: Event) {
|
|
265
|
+
this.removeItem((event as CustomEvent<{ id: number }>).detail.id);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private toggleItem(id: number) {
|
|
269
|
+
const item = this.items.find((entry) => entry.id === id);
|
|
270
|
+
if (item) {
|
|
271
|
+
item.done = !item.done;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private updateItemLabel(id: number, value: string) {
|
|
276
|
+
const item = this.items.find((entry) => entry.id === id);
|
|
277
|
+
if (!item) return;
|
|
278
|
+
const label = value.trim();
|
|
279
|
+
if (label) {
|
|
280
|
+
item.label = label;
|
|
281
|
+
} else {
|
|
282
|
+
this.removeItem(id);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private removeItem(id: number) {
|
|
287
|
+
const index = this.items.findIndex((entry) => entry.id === id);
|
|
288
|
+
if (index >= 0) {
|
|
289
|
+
this.items.splice(index, 1);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private completeAll() {
|
|
294
|
+
this.items.forEach((item) => {
|
|
295
|
+
item.done = true;
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private clearDone() {
|
|
300
|
+
for (let index = this.items.length - 1; index >= 0; index--) {
|
|
301
|
+
if (this.items[index].done) {
|
|
302
|
+
this.items.splice(index, 1);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private padRev(value: number): string {
|
|
308
|
+
return value < 10 ? `0${value}` : String(value);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private get canAdd(): boolean {
|
|
312
|
+
return this.draft.trim().length > 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private get visibleItems(): TodoItem[] {
|
|
316
|
+
if (this.filter === 'active') {
|
|
317
|
+
return this.items.filter((item) => !item.done);
|
|
318
|
+
}
|
|
319
|
+
if (this.filter === 'done') {
|
|
320
|
+
return this.items.filter((item) => item.done);
|
|
321
|
+
}
|
|
322
|
+
return this.items;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private get activeCount(): number {
|
|
326
|
+
return this.items.filter((item) => !item.done).length;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private get doneCount(): number {
|
|
330
|
+
return this.items.filter((item) => item.done).length;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CustomElement,
|
|
3
|
+
Dispatch,
|
|
4
|
+
DispatchEmitter,
|
|
5
|
+
Prop,
|
|
6
|
+
TemplateResult,
|
|
7
|
+
html,
|
|
8
|
+
} from 'custom-elements-ts';
|
|
9
|
+
|
|
10
|
+
type Filter = 'all' | 'active' | 'done';
|
|
11
|
+
|
|
12
|
+
@CustomElement({
|
|
13
|
+
tag: 'cts-todo-filters',
|
|
14
|
+
shadow: false,
|
|
15
|
+
})
|
|
16
|
+
export class TodoFiltersElement extends HTMLElement {
|
|
17
|
+
@Prop() filter: Filter = 'all';
|
|
18
|
+
@Prop() total = 0;
|
|
19
|
+
@Prop() active = 0;
|
|
20
|
+
@Prop() done = 0;
|
|
21
|
+
|
|
22
|
+
@Dispatch('filter.change') filterChange!: DispatchEmitter;
|
|
23
|
+
|
|
24
|
+
render(): TemplateResult {
|
|
25
|
+
return html`
|
|
26
|
+
<nav class="filters" aria-label="Task filters">
|
|
27
|
+
${this.renderButton('all', 'All', this.total)}
|
|
28
|
+
${this.renderButton('active', 'Active', this.active)}
|
|
29
|
+
${this.renderButton('done', 'Done', this.done)}
|
|
30
|
+
</nav>
|
|
31
|
+
`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private renderButton(filter: Filter, label: string, count: number): TemplateResult {
|
|
35
|
+
return html`
|
|
36
|
+
<button
|
|
37
|
+
class="filter"
|
|
38
|
+
aria-pressed=${this.filter === filter}
|
|
39
|
+
@click=${() => this.setFilter(filter)}
|
|
40
|
+
>
|
|
41
|
+
<span>${label}</span>
|
|
42
|
+
<span class="filter-count">${count}</span>
|
|
43
|
+
</button>
|
|
44
|
+
`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private setFilter(filter: Filter) {
|
|
48
|
+
this.filterChange.emit({
|
|
49
|
+
bubbles: true,
|
|
50
|
+
composed: true,
|
|
51
|
+
detail: { filter },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CustomElement,
|
|
3
|
+
Dispatch,
|
|
4
|
+
DispatchEmitter,
|
|
5
|
+
Prop,
|
|
6
|
+
TemplateResult,
|
|
7
|
+
html,
|
|
8
|
+
} from 'custom-elements-ts';
|
|
9
|
+
|
|
10
|
+
@CustomElement({
|
|
11
|
+
tag: 'cts-todo-item',
|
|
12
|
+
shadow: false,
|
|
13
|
+
})
|
|
14
|
+
export class TodoItemElement extends HTMLElement {
|
|
15
|
+
@Prop() todoid = 0;
|
|
16
|
+
@Prop() label = '';
|
|
17
|
+
@Prop() done: unknown = false;
|
|
18
|
+
@Prop() rowindex = 0;
|
|
19
|
+
|
|
20
|
+
@Dispatch('todo.toggle') todoToggle!: DispatchEmitter;
|
|
21
|
+
@Dispatch('todo.labelchange') todoLabelChange!: DispatchEmitter;
|
|
22
|
+
@Dispatch('todo.remove') todoRemove!: DispatchEmitter;
|
|
23
|
+
|
|
24
|
+
render(): TemplateResult {
|
|
25
|
+
const done = this.isDone;
|
|
26
|
+
return html`
|
|
27
|
+
<div class="row" role="listitem" data-done=${done} style=${`--index:${this.rowindex}`}>
|
|
28
|
+
<button
|
|
29
|
+
class="check"
|
|
30
|
+
aria-label=${done ? 'Mark as active' : 'Mark as done'}
|
|
31
|
+
aria-pressed=${done}
|
|
32
|
+
@click=${this.toggle}
|
|
33
|
+
>
|
|
34
|
+
<span class="check-tick">${this.iconCheck()}</span>
|
|
35
|
+
</button>
|
|
36
|
+
${done
|
|
37
|
+
? html`<span class="task-label is-done">${this.label}</span>`
|
|
38
|
+
: html`<input
|
|
39
|
+
class="task-label"
|
|
40
|
+
aria-label="Task label"
|
|
41
|
+
.value=${this.label}
|
|
42
|
+
@change=${this.changeLabel}
|
|
43
|
+
@keydown=${this.commitEdit}
|
|
44
|
+
/>`}
|
|
45
|
+
<button class="remove" aria-label="Remove task" @click=${this.removeTodo}>
|
|
46
|
+
${this.iconClose()}
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private toggle() {
|
|
53
|
+
this.todoToggle.emit({
|
|
54
|
+
bubbles: true,
|
|
55
|
+
composed: true,
|
|
56
|
+
detail: { id: this.todoId },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private changeLabel(event: Event) {
|
|
61
|
+
this.todoLabelChange.emit({
|
|
62
|
+
bubbles: true,
|
|
63
|
+
composed: true,
|
|
64
|
+
detail: {
|
|
65
|
+
id: this.todoId,
|
|
66
|
+
label: (event.target as HTMLInputElement).value,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private commitEdit(event: KeyboardEvent) {
|
|
72
|
+
if (event.key === 'Enter') {
|
|
73
|
+
(event.target as HTMLInputElement).blur();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private removeTodo() {
|
|
78
|
+
this.todoRemove.emit({
|
|
79
|
+
bubbles: true,
|
|
80
|
+
composed: true,
|
|
81
|
+
detail: { id: this.todoId },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private iconCheck(): TemplateResult {
|
|
86
|
+
return html`<svg
|
|
87
|
+
viewBox="0 0 24 24"
|
|
88
|
+
fill="none"
|
|
89
|
+
stroke="currentColor"
|
|
90
|
+
stroke-width="3"
|
|
91
|
+
stroke-linecap="round"
|
|
92
|
+
stroke-linejoin="round"
|
|
93
|
+
width="11"
|
|
94
|
+
height="11"
|
|
95
|
+
>
|
|
96
|
+
<path d="M5 12.5l4.2 4.2L19 7" />
|
|
97
|
+
</svg>`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private iconClose(): TemplateResult {
|
|
101
|
+
return html`<svg
|
|
102
|
+
viewBox="0 0 24 24"
|
|
103
|
+
fill="none"
|
|
104
|
+
stroke="currentColor"
|
|
105
|
+
stroke-width="2"
|
|
106
|
+
stroke-linecap="round"
|
|
107
|
+
stroke-linejoin="round"
|
|
108
|
+
width="13"
|
|
109
|
+
height="13"
|
|
110
|
+
>
|
|
111
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
112
|
+
</svg>`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private get todoId(): number {
|
|
116
|
+
return this.asNumber(this.todoid);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private get isDone(): boolean {
|
|
120
|
+
return this.done === true || this.done === 'true';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private asNumber(value: unknown): number {
|
|
124
|
+
return typeof value === 'number' ? value : Number(value) || 0;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CustomElement,
|
|
3
|
+
Dispatch,
|
|
4
|
+
DispatchEmitter,
|
|
5
|
+
Prop,
|
|
6
|
+
TemplateResult,
|
|
7
|
+
html,
|
|
8
|
+
} from 'custom-elements-ts';
|
|
9
|
+
|
|
10
|
+
interface PipTrack {
|
|
11
|
+
filled: number;
|
|
12
|
+
total: number;
|
|
13
|
+
overflow: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const MAX_PIPS = 10;
|
|
17
|
+
|
|
18
|
+
@CustomElement({
|
|
19
|
+
tag: 'cts-todo-stats',
|
|
20
|
+
shadow: false,
|
|
21
|
+
})
|
|
22
|
+
export class TodoStatsElement extends HTMLElement {
|
|
23
|
+
@Prop() total = 0;
|
|
24
|
+
@Prop() done = 0;
|
|
25
|
+
@Prop() goal = 5;
|
|
26
|
+
|
|
27
|
+
@Dispatch('goal.change') goalChange!: DispatchEmitter;
|
|
28
|
+
|
|
29
|
+
render(): TemplateResult {
|
|
30
|
+
const total = this.asNumber(this.total);
|
|
31
|
+
const done = this.asNumber(this.done);
|
|
32
|
+
const goal = Math.max(1, this.asNumber(this.goal));
|
|
33
|
+
const percent = total === 0 ? 0 : Math.round((done / total) * 100);
|
|
34
|
+
const remaining = Math.max(0, goal - done);
|
|
35
|
+
const overGoal = Math.max(0, done - goal);
|
|
36
|
+
const reached = done >= goal;
|
|
37
|
+
const pip = this.buildPipTrack(done, goal);
|
|
38
|
+
|
|
39
|
+
return html`
|
|
40
|
+
<aside class="stats-panel">
|
|
41
|
+
${this.renderProgressCard(total, done, percent)}
|
|
42
|
+
${this.renderGoalCard(done, goal, remaining, overGoal, reached, pip)}
|
|
43
|
+
</aside>
|
|
44
|
+
`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private renderProgressCard(total: number, done: number, percent: number): TemplateResult {
|
|
48
|
+
const empty = total === 0;
|
|
49
|
+
const complete = !empty && percent >= 100;
|
|
50
|
+
const className = complete
|
|
51
|
+
? 'stat-card stat-card--progress is-complete'
|
|
52
|
+
: 'stat-card stat-card--progress';
|
|
53
|
+
|
|
54
|
+
return html`
|
|
55
|
+
<article class=${className}>
|
|
56
|
+
<header class="stat-head">
|
|
57
|
+
<span class="stat-label">Progress</span>
|
|
58
|
+
<span class="stat-tag" data-state=${complete ? 'complete' : empty ? 'empty' : 'live'}>
|
|
59
|
+
${complete ? 'Sprint complete' : empty ? 'No tasks yet' : 'In flight'}
|
|
60
|
+
</span>
|
|
61
|
+
</header>
|
|
62
|
+
|
|
63
|
+
<div class="progress-display" aria-live="polite">
|
|
64
|
+
${empty
|
|
65
|
+
? html`<span class="progress-num is-empty">—</span>`
|
|
66
|
+
: html`<span class="progress-num">${percent}</span
|
|
67
|
+
><span class="progress-suffix">%</span>`}
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div class="progress-track" aria-hidden="true">
|
|
71
|
+
<span class="progress-track-fill" style=${`--p:${empty ? 0 : percent / 100}`}></span>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<p class="stat-caption">
|
|
75
|
+
${empty
|
|
76
|
+
? html`Add a task above to start tracking progress.`
|
|
77
|
+
: html`<strong>${done}</strong> of <strong>${total}</strong> ${total === 1
|
|
78
|
+
? 'task'
|
|
79
|
+
: 'tasks'}
|
|
80
|
+
complete`}
|
|
81
|
+
</p>
|
|
82
|
+
</article>
|
|
83
|
+
`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private renderGoalCard(
|
|
87
|
+
done: number,
|
|
88
|
+
goal: number,
|
|
89
|
+
remaining: number,
|
|
90
|
+
overGoal: number,
|
|
91
|
+
reached: boolean,
|
|
92
|
+
pip: PipTrack
|
|
93
|
+
): TemplateResult {
|
|
94
|
+
const className = reached ? 'stat-card stat-card--goal is-met' : 'stat-card stat-card--goal';
|
|
95
|
+
|
|
96
|
+
return html`
|
|
97
|
+
<article class=${className}>
|
|
98
|
+
<header class="stat-head">
|
|
99
|
+
<span class="stat-label">Sprint goal</span>
|
|
100
|
+
<div class="stepper" aria-label="Adjust completion goal">
|
|
101
|
+
<button
|
|
102
|
+
class="stepper-btn"
|
|
103
|
+
type="button"
|
|
104
|
+
aria-label="Decrease goal"
|
|
105
|
+
disabled=${goal <= 1}
|
|
106
|
+
@click=${this.decreaseGoal}
|
|
107
|
+
>
|
|
108
|
+
−
|
|
109
|
+
</button>
|
|
110
|
+
<span class="stepper-value" aria-hidden="true">${goal}</span>
|
|
111
|
+
<button
|
|
112
|
+
class="stepper-btn"
|
|
113
|
+
type="button"
|
|
114
|
+
aria-label="Increase goal"
|
|
115
|
+
@click=${this.increaseGoal}
|
|
116
|
+
>
|
|
117
|
+
+
|
|
118
|
+
</button>
|
|
119
|
+
</div>
|
|
120
|
+
</header>
|
|
121
|
+
|
|
122
|
+
<div
|
|
123
|
+
class="goal-pips"
|
|
124
|
+
role="img"
|
|
125
|
+
aria-label=${`${Math.min(done, pip.total)} of ${pip.total} goal pips filled`}
|
|
126
|
+
>
|
|
127
|
+
${this.renderPips(pip.filled, pip.total)}
|
|
128
|
+
${pip.overflow > 0
|
|
129
|
+
? html`<span class="pip-more" aria-hidden="true">+${pip.overflow}</span>`
|
|
130
|
+
: null}
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<p class="stat-caption">
|
|
134
|
+
${reached
|
|
135
|
+
? html`<span class="goal-met">Goal met</span>${overGoal > 0
|
|
136
|
+
? html`<span class="goal-over">+${overGoal} bonus</span>`
|
|
137
|
+
: null}`
|
|
138
|
+
: html`<strong>${done}</strong>/<strong>${goal}</strong>
|
|
139
|
+
<span class="goal-sep">·</span>
|
|
140
|
+
<strong>${remaining}</strong> to go`}
|
|
141
|
+
</p>
|
|
142
|
+
</article>
|
|
143
|
+
`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private renderPips(filled: number, total: number): TemplateResult[] {
|
|
147
|
+
const pips: TemplateResult[] = [];
|
|
148
|
+
for (let i = 0; i < total; i++) {
|
|
149
|
+
pips.push(html`<span class=${i < filled ? 'pip is-filled' : 'pip'}></span>`);
|
|
150
|
+
}
|
|
151
|
+
return pips;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private buildPipTrack(done: number, goal: number): PipTrack {
|
|
155
|
+
if (goal <= MAX_PIPS) {
|
|
156
|
+
return {
|
|
157
|
+
filled: Math.min(done, goal),
|
|
158
|
+
total: goal,
|
|
159
|
+
overflow: 0,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const filled = Math.min(MAX_PIPS, Math.round((done / goal) * MAX_PIPS));
|
|
163
|
+
return {
|
|
164
|
+
filled,
|
|
165
|
+
total: MAX_PIPS,
|
|
166
|
+
overflow: goal - MAX_PIPS,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private decreaseGoal() {
|
|
171
|
+
this.emitGoal(Math.max(1, this.asNumber(this.goal) - 1));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private increaseGoal() {
|
|
175
|
+
this.emitGoal(this.asNumber(this.goal) + 1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private emitGoal(goal: number) {
|
|
179
|
+
this.goalChange.emit({
|
|
180
|
+
bubbles: true,
|
|
181
|
+
composed: true,
|
|
182
|
+
detail: { goal },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private asNumber(value: unknown): number {
|
|
187
|
+
return typeof value === 'number' ? value : Number(value) || 0;
|
|
188
|
+
}
|
|
189
|
+
}
|