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.
@@ -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[]> = {};
@@ -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
- .playground__source {
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 playground: dashboard on top, full-width log below.
690
- The dashboard sets its own max width; we just center it. */
691
- .playground__body--stack {
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
- /* Custom element loading skeletons for the live demos.
754
- Match the same hydration pattern used on the install pill above. */
755
- cts-counter,
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
- width: min(720px, calc(100vw - 80px));
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 window: fake "lines of code" via stacked gradients.
941
- --skel is the line color; positions are content-box relative
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
- /* Soft fade-in once the real component swaps in, so the skeleton
1001
- doesn't pop. `:defined` matches the moment customElements.define
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, calc(100vw - 32px));
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, calc(100vw - 32px));
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
- gap: 12px;
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.1.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 { html, TemplateResult, TemplateValue } from './template-runtime';
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 const State = (): any => {
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 = createStateProxy(value, proxyCache, () => {
31
- this.__notifyPropertyChange?.(
32
- propName,
33
- this.__stateValues[propName],
34
- this.__stateValues[propName]
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;