custom-elements-ts 0.0.16 → 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.
Files changed (69) hide show
  1. package/.eslintrc.json +46 -0
  2. package/.github/workflows/ci.yml +49 -0
  3. package/.prettierrc +7 -0
  4. package/LICENSE +20 -0
  5. package/README.md +426 -168
  6. package/demos/counter/counter.element.html +1 -0
  7. package/demos/counter/counter.element.scss +234 -0
  8. package/demos/counter/counter.element.ts +68 -0
  9. package/demos/counter/index.html +205 -0
  10. package/demos/counter/index.ts +1 -0
  11. package/demos/site/code-example/code-example.element.scss +168 -0
  12. package/demos/site/code-example/code-example.element.ts +88 -0
  13. package/demos/site/event-log/event-log.element.scss +179 -0
  14. package/demos/site/event-log/event-log.element.ts +134 -0
  15. package/demos/site/favicon.svg +14 -0
  16. package/demos/site/index.html +346 -0
  17. package/demos/site/index.ts +13 -0
  18. package/demos/site/message/message.element.scss +75 -0
  19. package/demos/site/message/message.element.ts +76 -0
  20. package/demos/site/og-image.png +0 -0
  21. package/demos/site/styles/site.css +1023 -0
  22. package/demos/site/styles/tokens.css +56 -0
  23. package/demos/site/toast/toast.element.scss +110 -0
  24. package/demos/site/toast/toast.element.ts +63 -0
  25. package/demos/todo-dashboard/index.html +141 -0
  26. package/demos/todo-dashboard/index.ts +4 -0
  27. package/demos/todo-dashboard/todo-dashboard.element.scss +1145 -0
  28. package/demos/todo-dashboard/todo-dashboard.element.ts +332 -0
  29. package/demos/todo-dashboard/todo-filters.element.ts +54 -0
  30. package/demos/todo-dashboard/todo-item.element.ts +126 -0
  31. package/demos/todo-dashboard/todo-stats.element.ts +189 -0
  32. package/package.json +73 -29
  33. package/src/custom-element.ts +206 -0
  34. package/{index.d.ts → src/index.ts} +2 -0
  35. package/src/listen.ts +70 -0
  36. package/src/prop.ts +92 -0
  37. package/src/state.ts +129 -0
  38. package/src/template-runtime.ts +435 -0
  39. package/src/toggle.ts +66 -0
  40. package/src/tsconfig.json +24 -0
  41. package/src/util.ts +33 -0
  42. package/src/watch.ts +14 -0
  43. package/tests/basic.spec.ts +70 -0
  44. package/tests/custom-element.spec.ts +77 -0
  45. package/tests/dispatch.spec.ts +52 -0
  46. package/tests/init.spec.ts +94 -0
  47. package/tests/listen.spec.ts +118 -0
  48. package/tests/prop.spec.ts +118 -0
  49. package/tests/templating-runtime.spec.ts +575 -0
  50. package/tests/toggle.spec.ts +92 -0
  51. package/tests/watch.spec.ts +183 -0
  52. package/tools/build.js +119 -0
  53. package/tools/bundle.js +167 -0
  54. package/tools/rollup-config.js +70 -0
  55. package/tools/start.js +188 -0
  56. package/tsconfig.json +38 -0
  57. package/vite.config.mts +30 -0
  58. package/bundles/custom-elements-ts.umd.js +0 -315
  59. package/bundles/custom-elements-ts.umd.js.map +0 -1
  60. package/custom-element.d.ts +0 -12
  61. package/esm2015/custom-elements-ts.js +0 -260
  62. package/esm2015/custom-elements-ts.js.map +0 -1
  63. package/esm5/custom-elements-ts.js +0 -298
  64. package/esm5/custom-elements-ts.js.map +0 -1
  65. package/listen.d.ts +0 -17
  66. package/prop.d.ts +0 -2
  67. package/toggle.d.ts +0 -1
  68. package/util.d.ts +0 -4
  69. package/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
+ }