custom-elements-ts 0.1.0 → 0.2.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 +3 -2
- package/README.md +10 -3
- package/assets/readme-header.png +0 -0
- package/assets/social-preview.jpg +0 -0
- package/demos/site/event-log/event-log.element.ts +3 -14
- package/demos/site/index.html +39 -17
- package/demos/site/index.ts +6 -0
- package/demos/site/llms.txt +53 -0
- package/demos/site/og-image.png +0 -0
- package/demos/site/scroll-restoration.ts +28 -0
- package/demos/site/source-toggle/source-toggle.ts +88 -0
- package/demos/site/source-viewer/source-viewer.element.scss +292 -0
- package/demos/site/source-viewer/source-viewer.element.ts +138 -0
- package/demos/site/source-viewer/sources.generated.ts +11 -0
- package/demos/site/styles/site.css +144 -32
- package/demos/todo-dashboard/todo-dashboard.element.scss +8 -3
- package/demos/todo-dashboard/todo-dashboard.element.ts +1 -0
- package/demos/todo-dashboard/todo-item.element.ts +1 -0
- package/package.json +3 -3
- package/src/index.ts +3 -2
- package/src/signal.ts +66 -0
- package/src/state.ts +20 -8
- package/src/template-runtime.ts +404 -56
- package/tests/map-shallow-state.spec.ts +184 -0
- package/tests/signal.spec.ts +117 -0
- package/tools/build.js +14 -5
- package/tools/generate-sources.js +76 -0
- package/tools/rollup-config.js +2 -2
- package/tools/start.js +13 -7
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { CustomElement, Prop, State, TemplateResult, html } from 'custom-elements-ts';
|
|
2
|
+
|
|
3
|
+
import { SOURCES, SourceFile } from './sources.generated';
|
|
4
|
+
|
|
5
|
+
declare const Prism: {
|
|
6
|
+
highlight(code: string, grammar: unknown): string;
|
|
7
|
+
languages: {
|
|
8
|
+
typescript: unknown;
|
|
9
|
+
javascript: unknown;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Inline source viewer for site playgrounds.
|
|
15
|
+
*/
|
|
16
|
+
@CustomElement({
|
|
17
|
+
tag: 'cts-source-viewer',
|
|
18
|
+
styleUrl: './source-viewer.element.scss',
|
|
19
|
+
})
|
|
20
|
+
export class SourceViewerElement extends HTMLElement {
|
|
21
|
+
/** Key into the `SOURCES` map. e.g. `counter`, `todo-dashboard`. */
|
|
22
|
+
@Prop() slug = '';
|
|
23
|
+
|
|
24
|
+
/** Index of the file shown in the body. Reset whenever `slug` changes. */
|
|
25
|
+
@State() activeIndex = 0;
|
|
26
|
+
|
|
27
|
+
// Avoid re-tokenizing unchanged source.
|
|
28
|
+
private files: SourceFile[] = [];
|
|
29
|
+
private highlighted: string[] = [];
|
|
30
|
+
private cachedFor = '\0';
|
|
31
|
+
|
|
32
|
+
// Raw node insertion avoids lowercasing `.innerHTML` in parsed templates.
|
|
33
|
+
private bodyNode: HTMLPreElement | null = null;
|
|
34
|
+
private codeNode: HTMLElement | null = null;
|
|
35
|
+
private renderedHtml = '';
|
|
36
|
+
|
|
37
|
+
render(): TemplateResult {
|
|
38
|
+
if (this.cachedFor !== this.slug) {
|
|
39
|
+
this.recompute();
|
|
40
|
+
this.cachedFor = this.slug;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!this.files.length) {
|
|
44
|
+
return html`
|
|
45
|
+
<div class="viewer">
|
|
46
|
+
<p class="empty">Source unavailable for <code>${this.slug}</code>.</p>
|
|
47
|
+
</div>
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const showTabs = this.files.length > 1;
|
|
52
|
+
const active = this.highlighted[this.activeIndex] ?? '';
|
|
53
|
+
|
|
54
|
+
if (!this.bodyNode || !this.codeNode) {
|
|
55
|
+
this.codeNode = document.createElement('code');
|
|
56
|
+
this.bodyNode = document.createElement('pre');
|
|
57
|
+
this.bodyNode.className = 'body';
|
|
58
|
+
this.bodyNode.tabIndex = 0;
|
|
59
|
+
this.bodyNode.appendChild(this.codeNode);
|
|
60
|
+
}
|
|
61
|
+
if (this.renderedHtml !== active) {
|
|
62
|
+
this.codeNode.innerHTML = active;
|
|
63
|
+
this.renderedHtml = active;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return html`
|
|
67
|
+
<div class="viewer">
|
|
68
|
+
<div class="bar">
|
|
69
|
+
<span class="kicker">
|
|
70
|
+
<span class="kicker-dot" aria-hidden="true"></span>
|
|
71
|
+
inline source
|
|
72
|
+
</span>
|
|
73
|
+
|
|
74
|
+
${showTabs
|
|
75
|
+
? html`
|
|
76
|
+
<div class="tabs" role="tablist" aria-label="Source files">
|
|
77
|
+
${this.files.map((file, index) => {
|
|
78
|
+
const isActive = index === this.activeIndex;
|
|
79
|
+
return html`
|
|
80
|
+
<button
|
|
81
|
+
class=${isActive ? 'tab is-active' : 'tab'}
|
|
82
|
+
role="tab"
|
|
83
|
+
type="button"
|
|
84
|
+
aria-selected=${isActive ? 'true' : 'false'}
|
|
85
|
+
@click=${() => this.selectTab(index)}
|
|
86
|
+
>
|
|
87
|
+
${file.name}
|
|
88
|
+
</button>
|
|
89
|
+
`;
|
|
90
|
+
})}
|
|
91
|
+
</div>
|
|
92
|
+
`
|
|
93
|
+
: html`<span class="single">${this.files[0]?.name ?? ''}</span>`}
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
${this.bodyNode}
|
|
97
|
+
</div>
|
|
98
|
+
`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private selectTab(index: number) {
|
|
102
|
+
if (index === this.activeIndex) return;
|
|
103
|
+
this.activeIndex = index;
|
|
104
|
+
// Center the active tab when the bar is overflowing on narrow widths.
|
|
105
|
+
requestAnimationFrame(() => {
|
|
106
|
+
const tab = this.shadowRoot?.querySelectorAll<HTMLElement>('.tab')[index];
|
|
107
|
+
tab?.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' });
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private recompute() {
|
|
112
|
+
const files = SOURCES[this.slug] ?? [];
|
|
113
|
+
this.files = files;
|
|
114
|
+
|
|
115
|
+
const grammar =
|
|
116
|
+
typeof Prism !== 'undefined'
|
|
117
|
+
? (Prism.languages.typescript ?? Prism.languages.javascript)
|
|
118
|
+
: null;
|
|
119
|
+
|
|
120
|
+
this.highlighted = files.map((file) =>
|
|
121
|
+
grammar ? Prism.highlight(file.source, grammar) : escapeHtml(file.source)
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
if (this.activeIndex >= files.length) {
|
|
125
|
+
this.activeIndex = 0;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const ENTITY_MAP: Record<string, string> = {
|
|
131
|
+
'&': '&',
|
|
132
|
+
'<': '<',
|
|
133
|
+
'>': '>',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
function escapeHtml(value: string): string {
|
|
137
|
+
return value.replace(/[&<>]/g, (char) => ENTITY_MAP[char] ?? char);
|
|
138
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Stub committed so the IDE / type checker has something to resolve.
|
|
2
|
+
// The real, populated module is emitted by `tools/generate-sources.js`
|
|
3
|
+
// into `.tmp/site/source-viewer/sources.generated.ts` at build/dev time
|
|
4
|
+
// and is what the bundler actually picks up.
|
|
5
|
+
|
|
6
|
+
export interface SourceFile {
|
|
7
|
+
name: string;
|
|
8
|
+
source: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const SOURCES: Record<string, SourceFile[]> = {};
|
|
@@ -167,6 +167,7 @@ a:hover { color: var(--accent); }
|
|
|
167
167
|
font-family: var(--font-mono);
|
|
168
168
|
font-size: 14px;
|
|
169
169
|
color: var(--fg);
|
|
170
|
+
white-space: nowrap;
|
|
170
171
|
}
|
|
171
172
|
.brand__name .dim { color: var(--fg-subtle); }
|
|
172
173
|
|
|
@@ -238,6 +239,7 @@ a:hover { color: var(--accent); }
|
|
|
238
239
|
font-size: 12px;
|
|
239
240
|
color: var(--fg-muted);
|
|
240
241
|
font-variant-numeric: tabular-nums;
|
|
242
|
+
white-space: nowrap;
|
|
241
243
|
}
|
|
242
244
|
.hero__pill .badge {
|
|
243
245
|
display: inline-flex;
|
|
@@ -410,21 +412,6 @@ a:hover { color: var(--accent); }
|
|
|
410
412
|
}
|
|
411
413
|
.window__title .accent { color: var(--accent-fg); }
|
|
412
414
|
|
|
413
|
-
.window__copy {
|
|
414
|
-
margin-left: auto;
|
|
415
|
-
display: inline-flex;
|
|
416
|
-
align-items: center;
|
|
417
|
-
gap: 6px;
|
|
418
|
-
padding: 4px 10px;
|
|
419
|
-
font-family: var(--font-mono);
|
|
420
|
-
font-size: 11px;
|
|
421
|
-
color: var(--fg-muted);
|
|
422
|
-
border: 1px solid var(--line);
|
|
423
|
-
border-radius: var(--radius-xs);
|
|
424
|
-
background: var(--bg-elev-2);
|
|
425
|
-
}
|
|
426
|
-
.window__copy svg { width: 12px; height: 12px; }
|
|
427
|
-
|
|
428
415
|
.window__body {
|
|
429
416
|
background: #0b0e14;
|
|
430
417
|
}
|
|
@@ -576,6 +563,13 @@ a:hover { color: var(--accent); }
|
|
|
576
563
|
color: var(--accent-fg);
|
|
577
564
|
}
|
|
578
565
|
|
|
566
|
+
/* Keep inline code chips like `npm install` or `@State()` atomic so
|
|
567
|
+
prose wraps around them instead of breaking them mid-token. */
|
|
568
|
+
:where(.hero__lede, .live__lede, .side-card__sub, .playground__title,
|
|
569
|
+
.playground__sub, .stage__caption, .api__lede) code {
|
|
570
|
+
white-space: nowrap;
|
|
571
|
+
}
|
|
572
|
+
|
|
579
573
|
.playground {
|
|
580
574
|
position: relative;
|
|
581
575
|
margin-top: 22px;
|
|
@@ -646,7 +640,69 @@ a:hover { color: var(--accent); }
|
|
|
646
640
|
font-size: 12px;
|
|
647
641
|
color: var(--accent-fg);
|
|
648
642
|
}
|
|
649
|
-
.
|
|
643
|
+
/* Playground header actions. */
|
|
644
|
+
.playground__actions {
|
|
645
|
+
flex: 0 0 auto;
|
|
646
|
+
display: inline-flex;
|
|
647
|
+
align-items: center;
|
|
648
|
+
gap: 8px;
|
|
649
|
+
flex-wrap: wrap;
|
|
650
|
+
justify-content: flex-end;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
.playground__btn {
|
|
654
|
+
flex: 0 0 auto;
|
|
655
|
+
display: inline-flex;
|
|
656
|
+
align-items: center;
|
|
657
|
+
gap: 8px;
|
|
658
|
+
padding: 8px 12px;
|
|
659
|
+
border: 1px solid var(--line-strong);
|
|
660
|
+
border-radius: var(--radius-xs);
|
|
661
|
+
background: rgba(255, 255, 255, 0.02);
|
|
662
|
+
font-family: var(--font-mono);
|
|
663
|
+
font-size: 12px;
|
|
664
|
+
color: var(--fg-muted);
|
|
665
|
+
white-space: nowrap;
|
|
666
|
+
cursor: pointer;
|
|
667
|
+
text-decoration: none;
|
|
668
|
+
transition: border-color var(--dur) var(--ease),
|
|
669
|
+
color var(--dur) var(--ease),
|
|
670
|
+
background var(--dur) var(--ease);
|
|
671
|
+
}
|
|
672
|
+
.playground__btn:hover {
|
|
673
|
+
color: var(--fg);
|
|
674
|
+
border-color: var(--line-accent);
|
|
675
|
+
background: var(--accent-soft);
|
|
676
|
+
}
|
|
677
|
+
.playground__btn:active {
|
|
678
|
+
transform: translateY(1px);
|
|
679
|
+
}
|
|
680
|
+
.playground__btn[aria-expanded="true"],
|
|
681
|
+
.playground__btn.is-active {
|
|
682
|
+
color: var(--accent-fg);
|
|
683
|
+
border-color: var(--line-accent);
|
|
684
|
+
background: var(--accent-soft);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
.playground__btn-caret {
|
|
688
|
+
margin-left: 2px;
|
|
689
|
+
opacity: 0.6;
|
|
690
|
+
transition: transform var(--dur) var(--ease), opacity var(--dur) var(--ease);
|
|
691
|
+
}
|
|
692
|
+
.playground__btn[aria-expanded="true"] .playground__btn-caret {
|
|
693
|
+
transform: rotate(180deg);
|
|
694
|
+
opacity: 0.9;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
.playground__btn--ghost {
|
|
698
|
+
background: transparent;
|
|
699
|
+
}
|
|
700
|
+
.playground__btn--ghost:hover {
|
|
701
|
+
background: var(--bg-elev-2);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/* Backwards compatibility for the original source link class. */
|
|
705
|
+
.playground__source {
|
|
650
706
|
flex: 0 0 auto;
|
|
651
707
|
display: inline-flex;
|
|
652
708
|
align-items: center;
|
|
@@ -686,9 +742,19 @@ a:hover { color: var(--accent); }
|
|
|
686
742
|
}
|
|
687
743
|
}
|
|
688
744
|
|
|
689
|
-
/* Todo
|
|
690
|
-
|
|
691
|
-
|
|
745
|
+
/* Todo needs a wider breakpoint than the counter card. */
|
|
746
|
+
.playground--todo .playground__body--split {
|
|
747
|
+
grid-template-columns: 1fr;
|
|
748
|
+
}
|
|
749
|
+
@media (min-width: 1024px) {
|
|
750
|
+
.playground--todo .playground__body--split {
|
|
751
|
+
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
|
752
|
+
align-items: stretch;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/* Legacy stack variant for external embeds. */
|
|
757
|
+
.playground__body--stack {
|
|
692
758
|
grid-template-columns: 1fr;
|
|
693
759
|
}
|
|
694
760
|
|
|
@@ -722,6 +788,11 @@ a:hover { color: var(--accent); }
|
|
|
722
788
|
.stage--todo {
|
|
723
789
|
padding: 32px 18px;
|
|
724
790
|
}
|
|
791
|
+
/* Prevent host min-content overflow on narrow phones. */
|
|
792
|
+
.stage--todo cts-todo-dashboard {
|
|
793
|
+
width: min(540px, 100%);
|
|
794
|
+
min-width: 0;
|
|
795
|
+
}
|
|
725
796
|
|
|
726
797
|
.stage__caption {
|
|
727
798
|
margin: 0;
|
|
@@ -750,9 +821,16 @@ a:hover { color: var(--accent); }
|
|
|
750
821
|
width: 100%;
|
|
751
822
|
}
|
|
752
823
|
|
|
753
|
-
/*
|
|
754
|
-
|
|
755
|
-
|
|
824
|
+
/* Do not set display here; the host grid drives the open/close animation. */
|
|
825
|
+
cts-source-viewer:not(:defined) {
|
|
826
|
+
/* Keep the collapsed state during hydration. */
|
|
827
|
+
display: block;
|
|
828
|
+
height: 0;
|
|
829
|
+
overflow: hidden;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/* Loading skeletons for live demos. */
|
|
833
|
+
cts-counter,
|
|
756
834
|
cts-todo-dashboard,
|
|
757
835
|
cts-event-log {
|
|
758
836
|
display: block;
|
|
@@ -766,8 +844,9 @@ cts-counter:not(:defined) {
|
|
|
766
844
|
background: linear-gradient(180deg, #11141a 0%, #0f131b 100%);
|
|
767
845
|
}
|
|
768
846
|
|
|
769
|
-
cts-todo-dashboard:not(:defined) {
|
|
770
|
-
|
|
847
|
+
cts-todo-dashboard:not(:defined) {
|
|
848
|
+
/* Match the compact hydrated width. */
|
|
849
|
+
width: min(540px, calc(100vw - 80px));
|
|
771
850
|
height: 520px;
|
|
772
851
|
border: 1px solid var(--line-strong);
|
|
773
852
|
border-radius: var(--radius-lg);
|
|
@@ -937,10 +1016,8 @@ cts-message:not(:defined)::after {
|
|
|
937
1016
|
pointer-events: none;
|
|
938
1017
|
}
|
|
939
1018
|
|
|
940
|
-
/* Code
|
|
941
|
-
|
|
942
|
-
so we don't have to add the padding offset to every value. */
|
|
943
|
-
cts-code-example:not(:defined) {
|
|
1019
|
+
/* Code skeleton lines via stacked gradients. */
|
|
1020
|
+
cts-code-example:not(:defined) {
|
|
944
1021
|
--skel: rgba(255, 255, 255, 0.06);
|
|
945
1022
|
min-height: 380px;
|
|
946
1023
|
padding: 28px 32px;
|
|
@@ -997,10 +1074,8 @@ cts-message:not(:defined) {
|
|
|
997
1074
|
100% { background-position: -110% 0; }
|
|
998
1075
|
}
|
|
999
1076
|
|
|
1000
|
-
/*
|
|
1001
|
-
|
|
1002
|
-
completes, which is exactly when the new content renders. */
|
|
1003
|
-
cts-code-example:defined,
|
|
1077
|
+
/* Fade in once custom elements are defined. */
|
|
1078
|
+
cts-code-example:defined,
|
|
1004
1079
|
cts-message:defined {
|
|
1005
1080
|
animation: cts-hydrate 280ms var(--ease) both;
|
|
1006
1081
|
}
|
|
@@ -1010,6 +1085,43 @@ cts-message:defined {
|
|
|
1010
1085
|
to { opacity: 1; }
|
|
1011
1086
|
}
|
|
1012
1087
|
|
|
1088
|
+
/* Mobile / narrow-viewport adjustments */
|
|
1089
|
+
@media (max-width: 720px) {
|
|
1090
|
+
.playground__head {
|
|
1091
|
+
flex-direction: column;
|
|
1092
|
+
align-items: stretch;
|
|
1093
|
+
gap: 14px;
|
|
1094
|
+
padding: 20px 18px 16px;
|
|
1095
|
+
}
|
|
1096
|
+
.playground__actions {
|
|
1097
|
+
justify-content: flex-start;
|
|
1098
|
+
flex-wrap: wrap;
|
|
1099
|
+
}
|
|
1100
|
+
.playground__title {
|
|
1101
|
+
font-size: 19px;
|
|
1102
|
+
}
|
|
1103
|
+
.playground__sub {
|
|
1104
|
+
font-size: 13.5px;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
.playground__body {
|
|
1108
|
+
padding: 18px 16px 22px;
|
|
1109
|
+
gap: 18px;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
.stage {
|
|
1113
|
+
padding: 22px 14px;
|
|
1114
|
+
min-height: 200px;
|
|
1115
|
+
}
|
|
1116
|
+
.stage--todo {
|
|
1117
|
+
padding: 22px 10px;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
cts-code-example {
|
|
1121
|
+
font-size: 12px;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1013
1125
|
/* ─────────────────────────────────────────────────────────────
|
|
1014
1126
|
Reduced motion
|
|
1015
1127
|
───────────────────────────────────────────────────────────── */
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
--dur-slow: 420ms;
|
|
48
48
|
|
|
49
49
|
display: block;
|
|
50
|
-
width: min(720px,
|
|
50
|
+
width: min(720px, 100%);
|
|
51
51
|
color: var(--fg);
|
|
52
52
|
font-family:
|
|
53
53
|
'Geist',
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
.shell.compact {
|
|
89
|
-
width: min(540px,
|
|
89
|
+
width: min(540px, 100%);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
@keyframes shell-in {
|
|
@@ -310,6 +310,7 @@ cts-todo-item {
|
|
|
310
310
|
font-weight: 500;
|
|
311
311
|
letter-spacing: 0.14em;
|
|
312
312
|
text-transform: uppercase;
|
|
313
|
+
white-space: nowrap;
|
|
313
314
|
}
|
|
314
315
|
|
|
315
316
|
.stat-tag {
|
|
@@ -329,6 +330,7 @@ cts-todo-item {
|
|
|
329
330
|
font-weight: 500;
|
|
330
331
|
letter-spacing: 0.12em;
|
|
331
332
|
text-transform: uppercase;
|
|
333
|
+
white-space: nowrap;
|
|
332
334
|
}
|
|
333
335
|
|
|
334
336
|
.stat-tag::before {
|
|
@@ -1020,7 +1022,8 @@ input.task-label:focus {
|
|
|
1020
1022
|
display: flex;
|
|
1021
1023
|
align-items: center;
|
|
1022
1024
|
justify-content: space-between;
|
|
1023
|
-
|
|
1025
|
+
flex-wrap: wrap;
|
|
1026
|
+
gap: 10px 12px;
|
|
1024
1027
|
padding: 14px 18px 14px 24px;
|
|
1025
1028
|
border-top: 1px solid var(--line);
|
|
1026
1029
|
background: rgba(0, 0, 0, 0.18);
|
|
@@ -1069,6 +1072,7 @@ input.task-label:focus {
|
|
|
1069
1072
|
.footer-actions {
|
|
1070
1073
|
display: flex;
|
|
1071
1074
|
gap: 6px;
|
|
1075
|
+
margin-left: auto;
|
|
1072
1076
|
}
|
|
1073
1077
|
|
|
1074
1078
|
.footer-actions button {
|
|
@@ -1078,6 +1082,7 @@ input.task-label:focus {
|
|
|
1078
1082
|
background: transparent;
|
|
1079
1083
|
border-color: transparent;
|
|
1080
1084
|
color: var(--fg-muted);
|
|
1085
|
+
white-space: nowrap;
|
|
1081
1086
|
}
|
|
1082
1087
|
|
|
1083
1088
|
.footer-actions button:hover {
|
|
@@ -120,6 +120,7 @@ export class TodoDashboardElement extends HTMLElement {
|
|
|
120
120
|
<div class="composer-field">
|
|
121
121
|
<span class="composer-icon" aria-hidden="true">${this.iconPlus()}</span>
|
|
122
122
|
<input
|
|
123
|
+
name="new-task"
|
|
123
124
|
aria-label="New task"
|
|
124
125
|
placeholder="Capture a task and press enter"
|
|
125
126
|
.value=${this.draft}
|
|
@@ -37,6 +37,7 @@ export class TodoItemElement extends HTMLElement {
|
|
|
37
37
|
? html`<span class="task-label is-done">${this.label}</span>`
|
|
38
38
|
: html`<input
|
|
39
39
|
class="task-label"
|
|
40
|
+
name=${`task-label-${this.todoId}`}
|
|
40
41
|
aria-label="Task label"
|
|
41
42
|
.value=${this.label}
|
|
42
43
|
@change=${this.changeLabel}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "custom-elements-ts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Create native Custom Elements using Typescript without using any third party libraries",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"module": "custom-element.js",
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"build": "node tools/build.js",
|
|
9
9
|
"start": "node tools/start.js",
|
|
10
10
|
"bundle": "node tools/bundle.js",
|
|
11
|
-
"publish:dist": "npm run bundle && npm publish dist --access public",
|
|
12
|
-
"publish:dry-run": "npm run bundle && npm publish dist --dry-run",
|
|
11
|
+
"publish:dist": "npm run bundle && npm publish ./dist --access public",
|
|
12
|
+
"publish:dry-run": "npm run bundle && npm publish ./dist --dry-run",
|
|
13
13
|
"test": "vitest run",
|
|
14
14
|
"test:watch": "vitest",
|
|
15
15
|
"test:coverage": "vitest run --coverage --coverage.reporter=lcov",
|
package/src/index.ts
CHANGED
|
@@ -3,5 +3,6 @@ export * from './watch';
|
|
|
3
3
|
export * from './prop';
|
|
4
4
|
export * from './toggle';
|
|
5
5
|
export * from './listen';
|
|
6
|
-
export { State } from './state';
|
|
7
|
-
export {
|
|
6
|
+
export { State, StateOptions } from './state';
|
|
7
|
+
export { signal, Signal, isSignal, SignalSubscriber } from './signal';
|
|
8
|
+
export { html, map, TemplateResult, TemplateValue, MappedTemplates } from './template-runtime';
|
package/src/signal.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export type SignalSubscriber<T> = (value: T) => void;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sentinel thrown by a subscriber whose DOM target has been garbage
|
|
5
|
+
* collected. The notifying signal catches it and drops that subscriber, so
|
|
6
|
+
* bindings clean themselves up lazily — no explicit disposal bookkeeping is
|
|
7
|
+
* needed when rendered subtrees are discarded wholesale.
|
|
8
|
+
*/
|
|
9
|
+
export const signalTargetCollected: symbol = Symbol('custom-elements-ts-signal-target-collected');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A writable reactive value. When a signal is embedded in a template
|
|
13
|
+
* (`html`\`<td>${row.label}</td>\`` or `class=${row.selected}`), the render
|
|
14
|
+
* part subscribes directly to it: assigning `.value` updates just that DOM
|
|
15
|
+
* node, bypassing component re-rendering entirely.
|
|
16
|
+
*/
|
|
17
|
+
export class Signal<T> {
|
|
18
|
+
private currentValue: T;
|
|
19
|
+
private observers?: Set<SignalSubscriber<T>>;
|
|
20
|
+
|
|
21
|
+
constructor(initialValue: T) {
|
|
22
|
+
this.currentValue = initialValue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get value(): T {
|
|
26
|
+
return this.currentValue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
set value(newValue: T) {
|
|
30
|
+
if (newValue === this.currentValue) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
this.currentValue = newValue;
|
|
34
|
+
const observers = this.observers;
|
|
35
|
+
if (!observers) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
observers.forEach((observer) => {
|
|
39
|
+
try {
|
|
40
|
+
observer(newValue);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error === signalTargetCollected) {
|
|
43
|
+
observers.delete(observer);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
subscribe(observer: SignalSubscriber<T>): void {
|
|
52
|
+
(this.observers || (this.observers = new Set())).add(observer);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
unsubscribe(observer: SignalSubscriber<T>): void {
|
|
56
|
+
this.observers?.delete(observer);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
valueOf(): T {
|
|
60
|
+
return this.currentValue;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const signal = <T>(initialValue: T): Signal<T> => new Signal(initialValue);
|
|
65
|
+
|
|
66
|
+
export const isSignal = (value: unknown): value is Signal<unknown> => value instanceof Signal;
|
package/src/state.ts
CHANGED
|
@@ -6,7 +6,17 @@ export interface StateMetadata {
|
|
|
6
6
|
|
|
7
7
|
const proxyToRaw = new WeakMap<object, object>();
|
|
8
8
|
|
|
9
|
-
export
|
|
9
|
+
export interface StateOptions {
|
|
10
|
+
/**
|
|
11
|
+
* When false, the value is stored as-is: no deep proxy is created and
|
|
12
|
+
* nested mutations do not schedule renders — reassign the property to
|
|
13
|
+
* re-render. Much cheaper for large arrays of objects. Defaults to true.
|
|
14
|
+
*/
|
|
15
|
+
deep?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const State = (options?: StateOptions): any => {
|
|
19
|
+
const deep = options?.deep !== false;
|
|
10
20
|
return (target: any, propName: string) => {
|
|
11
21
|
if (!target.constructor.stateInit) {
|
|
12
22
|
target.constructor.stateInit = {};
|
|
@@ -27,13 +37,15 @@ export const State = (): any => {
|
|
|
27
37
|
|
|
28
38
|
const oldValue = this.__stateValues[propName];
|
|
29
39
|
const proxyCache = (this.__stateProxyCaches[propName] ||= new WeakMap<object, unknown>());
|
|
30
|
-
const newValue =
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
const newValue = deep
|
|
41
|
+
? createStateProxy(value, proxyCache, () => {
|
|
42
|
+
this.__notifyPropertyChange?.(
|
|
43
|
+
propName,
|
|
44
|
+
this.__stateValues[propName],
|
|
45
|
+
this.__stateValues[propName]
|
|
46
|
+
);
|
|
47
|
+
})
|
|
48
|
+
: value;
|
|
37
49
|
|
|
38
50
|
if (Object.is(oldValue, newValue)) {
|
|
39
51
|
return;
|