custom-elements-ts 0.0.17 → 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.
Files changed (82) hide show
  1. package/.eslintrc.json +47 -0
  2. package/.github/workflows/ci.yml +49 -0
  3. package/.prettierrc +7 -0
  4. package/LICENSE +20 -0
  5. package/README.md +279 -37
  6. package/assets/readme-header.png +0 -0
  7. package/assets/social-preview.jpg +0 -0
  8. package/demos/counter/counter.element.html +1 -0
  9. package/demos/counter/counter.element.scss +234 -0
  10. package/demos/counter/counter.element.ts +68 -0
  11. package/demos/counter/index.html +205 -0
  12. package/demos/counter/index.ts +1 -0
  13. package/demos/site/code-example/code-example.element.scss +168 -0
  14. package/demos/site/code-example/code-example.element.ts +88 -0
  15. package/demos/site/event-log/event-log.element.scss +179 -0
  16. package/demos/site/event-log/event-log.element.ts +123 -0
  17. package/demos/site/favicon.svg +14 -0
  18. package/demos/site/index.html +368 -0
  19. package/demos/site/index.ts +19 -0
  20. package/demos/site/llms.txt +53 -0
  21. package/demos/site/message/message.element.scss +75 -0
  22. package/demos/site/message/message.element.ts +76 -0
  23. package/demos/site/og-image.png +0 -0
  24. package/demos/site/scroll-restoration.ts +28 -0
  25. package/demos/site/source-toggle/source-toggle.ts +88 -0
  26. package/demos/site/source-viewer/source-viewer.element.scss +292 -0
  27. package/demos/site/source-viewer/source-viewer.element.ts +138 -0
  28. package/demos/site/source-viewer/sources.generated.ts +11 -0
  29. package/demos/site/styles/site.css +1135 -0
  30. package/demos/site/styles/tokens.css +56 -0
  31. package/demos/site/toast/toast.element.scss +110 -0
  32. package/demos/site/toast/toast.element.ts +63 -0
  33. package/demos/todo-dashboard/index.html +141 -0
  34. package/demos/todo-dashboard/index.ts +4 -0
  35. package/demos/todo-dashboard/todo-dashboard.element.scss +1150 -0
  36. package/demos/todo-dashboard/todo-dashboard.element.ts +333 -0
  37. package/demos/todo-dashboard/todo-filters.element.ts +54 -0
  38. package/demos/todo-dashboard/todo-item.element.ts +127 -0
  39. package/demos/todo-dashboard/todo-stats.element.ts +189 -0
  40. package/package.json +73 -24
  41. package/src/custom-element.ts +206 -0
  42. package/src/index.ts +8 -0
  43. package/src/listen.ts +70 -0
  44. package/src/prop.ts +92 -0
  45. package/src/signal.ts +66 -0
  46. package/src/state.ts +141 -0
  47. package/src/template-runtime.ts +783 -0
  48. package/src/toggle.ts +66 -0
  49. package/src/tsconfig.json +24 -0
  50. package/src/util.ts +33 -0
  51. package/src/watch.ts +14 -0
  52. package/tests/basic.spec.ts +70 -0
  53. package/tests/custom-element.spec.ts +77 -0
  54. package/tests/dispatch.spec.ts +52 -0
  55. package/tests/init.spec.ts +94 -0
  56. package/tests/listen.spec.ts +118 -0
  57. package/tests/map-shallow-state.spec.ts +184 -0
  58. package/tests/prop.spec.ts +118 -0
  59. package/tests/signal.spec.ts +117 -0
  60. package/tests/templating-runtime.spec.ts +575 -0
  61. package/tests/toggle.spec.ts +92 -0
  62. package/tests/watch.spec.ts +183 -0
  63. package/tools/build.js +128 -0
  64. package/tools/bundle.js +167 -0
  65. package/tools/generate-sources.js +76 -0
  66. package/tools/rollup-config.js +70 -0
  67. package/tools/start.js +194 -0
  68. package/tsconfig.json +38 -0
  69. package/vite.config.mts +30 -0
  70. package/bundles/custom-elements-ts.umd.js +0 -359
  71. package/bundles/custom-elements-ts.umd.js.map +0 -1
  72. package/esm2015/custom-elements-ts.js +0 -283
  73. package/esm2015/custom-elements-ts.js.map +0 -1
  74. package/esm5/custom-element.d.ts +0 -12
  75. package/esm5/custom-elements-ts.js +0 -344
  76. package/esm5/custom-elements-ts.js.map +0 -1
  77. package/esm5/index.d.ts +0 -5
  78. package/esm5/listen.d.ts +0 -17
  79. package/esm5/prop.d.ts +0 -2
  80. package/esm5/toggle.d.ts +0 -1
  81. package/esm5/util.d.ts +0 -4
  82. package/esm5/watch.d.ts +0 -1
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Delegates [Source] button clicks to matching <cts-source-viewer> panels.
3
+ */
4
+
5
+ const TOGGLE_ATTR = 'data-source-toggle';
6
+ const TARGET_ATTR = 'data-source-target';
7
+ const OPEN_ATTR = 'open';
8
+ const ACTIVE_CLASS = 'is-active';
9
+
10
+ // Sticky-nav height plus breathing room.
11
+ const SCROLL_OFFSET = 88;
12
+
13
+ function prefersReducedMotion(): boolean {
14
+ return (
15
+ typeof window !== 'undefined' &&
16
+ typeof window.matchMedia === 'function' &&
17
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
18
+ );
19
+ }
20
+
21
+ function scrollPanelIntoView(panel: HTMLElement) {
22
+ // Wait for [open] to land before measuring the panel.
23
+ requestAnimationFrame(() => {
24
+ const top = panel.getBoundingClientRect().top + window.scrollY - SCROLL_OFFSET;
25
+ if (top <= window.scrollY) return;
26
+ window.scrollTo({
27
+ top,
28
+ behavior: prefersReducedMotion() ? 'auto' : 'smooth',
29
+ });
30
+ });
31
+ }
32
+
33
+ function findToggle(target: EventTarget | null): HTMLElement | null {
34
+ if (!(target instanceof Element)) return null;
35
+ return target.closest(`[${TOGGLE_ATTR}]`) as HTMLElement | null;
36
+ }
37
+
38
+ function syncTrigger(trigger: HTMLElement, isOpen: boolean) {
39
+ trigger.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
40
+ trigger.classList.toggle(ACTIVE_CLASS, isOpen);
41
+ }
42
+
43
+ function handleClick(event: MouseEvent) {
44
+ const trigger = findToggle(event.target);
45
+ if (!trigger) return;
46
+
47
+ const targetId = trigger.getAttribute(TARGET_ATTR);
48
+ if (!targetId) return;
49
+
50
+ const panel = document.getElementById(targetId);
51
+ if (!panel) return;
52
+
53
+ event.preventDefault();
54
+
55
+ const willOpen = !panel.hasAttribute(OPEN_ATTR);
56
+ if (willOpen) {
57
+ panel.setAttribute(OPEN_ATTR, '');
58
+ } else {
59
+ panel.removeAttribute(OPEN_ATTR);
60
+ }
61
+ syncTrigger(trigger, willOpen);
62
+
63
+ // Closing should leave the user where they are.
64
+ if (willOpen) {
65
+ scrollPanelIntoView(panel);
66
+ }
67
+ }
68
+
69
+ function init() {
70
+ // Reflect any initially open panels.
71
+ document.querySelectorAll<HTMLElement>(`[${TOGGLE_ATTR}]`).forEach((trigger) => {
72
+ const targetId = trigger.getAttribute(TARGET_ATTR);
73
+ if (!targetId) return;
74
+ const panel = document.getElementById(targetId);
75
+ if (!panel) return;
76
+ syncTrigger(trigger, panel.hasAttribute(OPEN_ATTR));
77
+ });
78
+
79
+ document.addEventListener('click', handleClick);
80
+ }
81
+
82
+ if (typeof document !== 'undefined') {
83
+ if (document.readyState === 'loading') {
84
+ document.addEventListener('DOMContentLoaded', init, { once: true });
85
+ } else {
86
+ init();
87
+ }
88
+ }
@@ -0,0 +1,292 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ cts-source-viewer
3
+ Collapsible inline source panel for playground examples.
4
+ ───────────────────────────────────────────────────────────── */
5
+
6
+ :host {
7
+ display: grid;
8
+ grid-template-rows: 0fr;
9
+ transition: grid-template-rows 360ms cubic-bezier(0.22, 1, 0.36, 1);
10
+ font-family: "Geist Mono", "JetBrains Mono", ui-monospace, SFMono-Regular,
11
+ Menlo, Consolas, "Courier New", monospace;
12
+ }
13
+
14
+ :host([open]) {
15
+ grid-template-rows: 1fr;
16
+ }
17
+
18
+ * {
19
+ box-sizing: border-box;
20
+ }
21
+
22
+ .viewer {
23
+ overflow: hidden;
24
+ min-height: 0;
25
+ visibility: hidden;
26
+ pointer-events: none;
27
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
28
+ background:
29
+ radial-gradient(420px 200px at 0% 0%,
30
+ rgba(49, 120, 198, 0.06) 0%,
31
+ transparent 70%),
32
+ linear-gradient(180deg, #0d1118 0%, #0a0d13 100%);
33
+ transition: visibility 0s linear 360ms;
34
+ }
35
+
36
+ :host([open]) .viewer {
37
+ visibility: visible;
38
+ pointer-events: auto;
39
+ transition-delay: 0s;
40
+ }
41
+
42
+ /* ── Top bar: kicker + file tabs ─────────────────────────── */
43
+
44
+ .bar {
45
+ display: flex;
46
+ align-items: stretch;
47
+ gap: 4px;
48
+ padding: 0 14px;
49
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
50
+ background: linear-gradient(180deg,
51
+ rgba(255, 255, 255, 0.02) 0%,
52
+ rgba(255, 255, 255, 0) 100%);
53
+ overflow-x: auto;
54
+ scrollbar-width: none;
55
+
56
+ &::-webkit-scrollbar { display: none; }
57
+ }
58
+
59
+ .kicker {
60
+ display: inline-flex;
61
+ align-items: center;
62
+ gap: 8px;
63
+ padding: 0 14px 0 4px;
64
+ margin-right: 4px;
65
+ border-right: 1px solid rgba(255, 255, 255, 0.05);
66
+ font-size: 11px;
67
+ letter-spacing: 0.06em;
68
+ text-transform: uppercase;
69
+ color: rgba(255, 255, 255, 0.4);
70
+ white-space: nowrap;
71
+ align-self: center;
72
+ height: 30px;
73
+ }
74
+ .kicker-dot {
75
+ width: 6px;
76
+ height: 6px;
77
+ border-radius: 1px;
78
+ background: #4189d6;
79
+ box-shadow: 0 0 8px rgba(65, 137, 214, 0.6);
80
+ }
81
+
82
+ .tabs {
83
+ display: inline-flex;
84
+ align-items: stretch;
85
+ gap: 0;
86
+ }
87
+
88
+ .tab {
89
+ position: relative;
90
+ display: inline-flex;
91
+ align-items: center;
92
+ padding: 10px 12px;
93
+ background: transparent;
94
+ border: 0;
95
+ border-bottom: 2px solid transparent;
96
+ font-family: inherit;
97
+ font-size: 12px;
98
+ font-weight: 500;
99
+ color: rgba(255, 255, 255, 0.45);
100
+ cursor: pointer;
101
+ white-space: nowrap;
102
+ transition: color 150ms ease, border-color 150ms ease, background 150ms ease;
103
+
104
+ &:hover {
105
+ color: rgba(255, 255, 255, 0.82);
106
+ background: rgba(255, 255, 255, 0.02);
107
+ }
108
+
109
+ &:focus-visible {
110
+ outline: 2px solid #4189d6;
111
+ outline-offset: -2px;
112
+ border-radius: 2px;
113
+ }
114
+
115
+ &.is-active {
116
+ color: #9cc0eb;
117
+ border-bottom-color: #4189d6;
118
+ background: rgba(65, 137, 214, 0.04);
119
+ }
120
+ }
121
+
122
+ .single {
123
+ display: inline-flex;
124
+ align-items: center;
125
+ padding: 10px 12px;
126
+ font-size: 12px;
127
+ font-weight: 500;
128
+ color: #9cc0eb;
129
+ white-space: nowrap;
130
+ }
131
+
132
+ .empty {
133
+ margin: 0;
134
+ padding: 16px 22px;
135
+ font-size: 12.5px;
136
+ color: rgba(255, 255, 255, 0.5);
137
+ }
138
+ .empty code {
139
+ padding: 1px 6px;
140
+ border: 1px solid rgba(255, 255, 255, 0.06);
141
+ border-radius: 2px;
142
+ background: rgba(255, 255, 255, 0.025);
143
+ color: #9cc0eb;
144
+ }
145
+
146
+ /* ── Code body ───────────────────────────────────────────── */
147
+
148
+ .body {
149
+ margin: 0;
150
+ padding: 22px 26px 26px;
151
+ background: transparent;
152
+ color: #d6dce5;
153
+ font-family: inherit;
154
+ font-size: 13px;
155
+ line-height: 1.65;
156
+ overflow: auto;
157
+ max-height: 460px;
158
+ white-space: pre;
159
+ -webkit-font-smoothing: antialiased;
160
+
161
+ scrollbar-width: thin;
162
+ scrollbar-color: rgba(255, 255, 255, 0.10) transparent;
163
+
164
+ &::-webkit-scrollbar { width: 8px; height: 8px; }
165
+ &::-webkit-scrollbar-thumb {
166
+ background: rgba(255, 255, 255, 0.10);
167
+ border-radius: 2px;
168
+ }
169
+ &::-webkit-scrollbar-track { background: transparent; }
170
+
171
+ &:focus { outline: none; }
172
+ &:focus-visible {
173
+ outline: 2px solid #4189d6;
174
+ outline-offset: -2px;
175
+ }
176
+ }
177
+
178
+ .body code {
179
+ background: transparent;
180
+ font-family: inherit;
181
+ color: inherit;
182
+ font-size: inherit;
183
+ line-height: inherit;
184
+ }
185
+
186
+ @media (min-width: 980px) {
187
+ .body {
188
+ font-size: 13.5px;
189
+ padding: 26px 32px 30px;
190
+ max-height: 540px;
191
+ }
192
+ }
193
+
194
+ /* Narrow viewports: preserve tab space and hint at overflow. */
195
+ @media (max-width: 640px) {
196
+ .bar {
197
+ padding: 0 10px;
198
+ mask-image: linear-gradient(to right,
199
+ black calc(100% - 22px),
200
+ transparent 100%);
201
+ -webkit-mask-image: linear-gradient(to right,
202
+ black calc(100% - 22px),
203
+ transparent 100%);
204
+ }
205
+ .kicker {
206
+ padding: 0 10px 0 4px;
207
+ margin-right: 2px;
208
+ font-size: 0; /* collapse text, keep the dot */
209
+ }
210
+ .tab,
211
+ .single {
212
+ padding: 10px 10px;
213
+ font-size: 11.5px;
214
+ }
215
+ .body {
216
+ padding: 18px 18px 22px;
217
+ font-size: 12.5px;
218
+ max-height: 380px;
219
+ }
220
+ }
221
+
222
+ /* ── Prism token theme — kept in sync with cts-code-example ── */
223
+
224
+ .token {
225
+ &.comment,
226
+ &.prolog,
227
+ &.doctype,
228
+ &.cdata {
229
+ color: #6e7681;
230
+ font-style: italic;
231
+ }
232
+
233
+ &.punctuation { color: #c9d1d9; }
234
+
235
+ &.property,
236
+ &.tag,
237
+ &.attr-name { color: #9cdcfe; }
238
+
239
+ &.keyword,
240
+ &.boolean { color: #4189d6; }
241
+
242
+ &.class-name { color: #4ec9b0; }
243
+
244
+ &.constant,
245
+ &.symbol { color: #4189d6; }
246
+
247
+ &.number { color: #b5cea8; }
248
+
249
+ &.string,
250
+ &.char,
251
+ &.attr-value,
252
+ &.builtin,
253
+ &.inserted { color: #ce9178; }
254
+
255
+ &.selector { color: #d7ba7d; }
256
+
257
+ &.variable { color: #d6dce5; }
258
+
259
+ &.operator,
260
+ &.entity { color: #d6dce5; }
261
+
262
+ &.url {
263
+ color: #4189d6;
264
+ text-decoration: underline;
265
+ }
266
+
267
+ &.atrule { color: #c586c0; }
268
+
269
+ &.function { color: #dcdcaa; }
270
+
271
+ &.regex { color: #d16969; }
272
+
273
+ &.important {
274
+ color: #f47067;
275
+ font-weight: 600;
276
+ }
277
+
278
+ &.bold { font-weight: 600; }
279
+ &.italic { font-style: italic; }
280
+ &.deleted { color: #f47067; }
281
+ }
282
+
283
+ .namespace { opacity: .65; }
284
+
285
+ .language-css .token.string,
286
+ .style .token.string {
287
+ color: #ce9178;
288
+ }
289
+
290
+ @media (prefers-reduced-motion: reduce) {
291
+ :host { transition: none; }
292
+ }
@@ -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
+ '&': '&amp;',
132
+ '<': '&lt;',
133
+ '>': '&gt;',
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[]> = {};