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.
- package/.eslintrc.json +47 -0
- package/.github/workflows/ci.yml +49 -0
- package/.prettierrc +7 -0
- package/LICENSE +20 -0
- package/README.md +279 -37
- package/assets/readme-header.png +0 -0
- package/assets/social-preview.jpg +0 -0
- 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 +123 -0
- package/demos/site/favicon.svg +14 -0
- package/demos/site/index.html +368 -0
- package/demos/site/index.ts +19 -0
- package/demos/site/llms.txt +53 -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/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 +1135 -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 +1150 -0
- package/demos/todo-dashboard/todo-dashboard.element.ts +333 -0
- package/demos/todo-dashboard/todo-filters.element.ts +54 -0
- package/demos/todo-dashboard/todo-item.element.ts +127 -0
- package/demos/todo-dashboard/todo-stats.element.ts +189 -0
- package/package.json +73 -24
- package/src/custom-element.ts +206 -0
- package/src/index.ts +8 -0
- package/src/listen.ts +70 -0
- package/src/prop.ts +92 -0
- package/src/signal.ts +66 -0
- package/src/state.ts +141 -0
- package/src/template-runtime.ts +783 -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/map-shallow-state.spec.ts +184 -0
- package/tests/prop.spec.ts +118 -0
- package/tests/signal.spec.ts +117 -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 +128 -0
- package/tools/bundle.js +167 -0
- package/tools/generate-sources.js +76 -0
- package/tools/rollup-config.js +70 -0
- package/tools/start.js +194 -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/index.d.ts +0 -5
- 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,88 @@
|
|
|
1
|
+
import { CustomElement } from 'custom-elements-ts';
|
|
2
|
+
|
|
3
|
+
declare const Prism: {
|
|
4
|
+
highlight(code: string, grammar: unknown): string;
|
|
5
|
+
languages: {
|
|
6
|
+
typescript: unknown;
|
|
7
|
+
javascript: unknown;
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
@CustomElement({
|
|
12
|
+
tag: 'cts-code-example',
|
|
13
|
+
template: '<div id="code"></div>',
|
|
14
|
+
styleUrl: './code-example.element.scss',
|
|
15
|
+
})
|
|
16
|
+
export class CodeExampleElement extends HTMLElement {
|
|
17
|
+
// Mirrors the real cts-message element rendered in the install card on this
|
|
18
|
+
// page. Keeping these in sync is intentional: the source you see is the
|
|
19
|
+
// source that runs the toast you triggered.
|
|
20
|
+
private readonly source = `import {
|
|
21
|
+
CustomElement,
|
|
22
|
+
Prop,
|
|
23
|
+
Listen,
|
|
24
|
+
Dispatch,
|
|
25
|
+
DispatchEmitter,
|
|
26
|
+
} from 'custom-elements-ts';
|
|
27
|
+
|
|
28
|
+
@CustomElement({
|
|
29
|
+
tag: 'cts-message',
|
|
30
|
+
template: \`
|
|
31
|
+
<div class="row" role="button" tabindex="0">
|
|
32
|
+
<span class="prompt" aria-hidden="true">$</span>
|
|
33
|
+
<code class="cmd"></code>
|
|
34
|
+
</div>
|
|
35
|
+
\`,
|
|
36
|
+
styleUrl: './message.element.scss',
|
|
37
|
+
})
|
|
38
|
+
export class MessageElement extends HTMLElement {
|
|
39
|
+
@Prop() message!: string;
|
|
40
|
+
|
|
41
|
+
// Bubbling, composed CustomEvent — the host page can listen on
|
|
42
|
+
// any ancestor (including <cts-toast>) without piercing shadow DOM.
|
|
43
|
+
@Dispatch('cts:toast') toast!: DispatchEmitter;
|
|
44
|
+
|
|
45
|
+
@Listen('click')
|
|
46
|
+
async handleClick() {
|
|
47
|
+
const ok = await this.copy(this.message);
|
|
48
|
+
this.toast.emit({
|
|
49
|
+
bubbles: true,
|
|
50
|
+
composed: true,
|
|
51
|
+
detail: ok
|
|
52
|
+
? { title: 'Copied to clipboard', message: this.message, kind: 'success' }
|
|
53
|
+
: { title: "Couldn't copy", message: 'Copy manually.', kind: 'error' },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
connectedCallback() {
|
|
58
|
+
this.shadowRoot!.querySelector('.cmd')!.textContent = this.message;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private async copy(text: string): Promise<boolean> {
|
|
62
|
+
try {
|
|
63
|
+
await navigator.clipboard.writeText(text);
|
|
64
|
+
return true;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Drop it anywhere — React, Vue, Svelte, plain HTML — it just works:
|
|
72
|
+
<cts-message message="npm install custom-elements-ts"></cts-message>`;
|
|
73
|
+
|
|
74
|
+
private code = '';
|
|
75
|
+
|
|
76
|
+
constructor() {
|
|
77
|
+
super();
|
|
78
|
+
const grammar = Prism.languages.typescript ?? Prism.languages.javascript;
|
|
79
|
+
this.code = Prism.highlight(this.source, grammar);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
connectedCallback() {
|
|
83
|
+
const code = this.shadowRoot?.querySelector('#code');
|
|
84
|
+
if (code) {
|
|
85
|
+
code.innerHTML = `<pre><code>${this.code}</code></pre>`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/* ─────────────────────────────────────────────────────────────
|
|
2
|
+
<cts-event-log>
|
|
3
|
+
Sits beside live demos on the showcase page; renders the most
|
|
4
|
+
recent CustomEvents the demo has dispatched. Tokens mirror the
|
|
5
|
+
site's design system (TS-blue accent, sharp 2–4px radii).
|
|
6
|
+
───────────────────────────────────────────────────────────── */
|
|
7
|
+
|
|
8
|
+
:host {
|
|
9
|
+
/* Mirror the site tokens so we don't rely on inheritance through
|
|
10
|
+
the shadow boundary. */
|
|
11
|
+
--bg: #0a0c10;
|
|
12
|
+
--bg-elev-1: #11141a;
|
|
13
|
+
--bg-elev-2: #161a22;
|
|
14
|
+
--line: rgba(255, 255, 255, 0.06);
|
|
15
|
+
--line-strong: rgba(255, 255, 255, 0.10);
|
|
16
|
+
--fg: #e6ecf3;
|
|
17
|
+
--fg-muted: #97a1ad;
|
|
18
|
+
--fg-subtle: #5b6370;
|
|
19
|
+
--accent: #3178c6;
|
|
20
|
+
--accent-bright: #4189d6;
|
|
21
|
+
--accent-fg: #9cc0eb;
|
|
22
|
+
--accent-soft: rgba(49, 120, 198, 0.14);
|
|
23
|
+
--accent-line: rgba(49, 120, 198, 0.42);
|
|
24
|
+
|
|
25
|
+
--radius-xs: 2px;
|
|
26
|
+
--radius-sm: 2px;
|
|
27
|
+
--radius-md: 3px;
|
|
28
|
+
|
|
29
|
+
--ease: cubic-bezier(0.22, 1, 0.36, 1);
|
|
30
|
+
--dur-fast: 140ms;
|
|
31
|
+
--dur: 220ms;
|
|
32
|
+
|
|
33
|
+
display: flex;
|
|
34
|
+
flex-direction: column;
|
|
35
|
+
min-height: 240px;
|
|
36
|
+
padding: 14px 16px 16px;
|
|
37
|
+
border: 1px solid var(--line-strong);
|
|
38
|
+
border-radius: var(--radius-md);
|
|
39
|
+
background:
|
|
40
|
+
radial-gradient(140% 100% at 0% 0%, rgba(49, 120, 198, 0.05) 0%, transparent 60%),
|
|
41
|
+
linear-gradient(180deg, var(--bg-elev-1) 0%, #0f131b 100%);
|
|
42
|
+
color: var(--fg);
|
|
43
|
+
font-family: "Geist", "Inter", ui-sans-serif, system-ui, -apple-system,
|
|
44
|
+
"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
45
|
+
-webkit-font-smoothing: antialiased;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
* { box-sizing: border-box; }
|
|
49
|
+
|
|
50
|
+
/* ── Header ─────────────────────────────────────────────── */
|
|
51
|
+
|
|
52
|
+
.head {
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
gap: 8px;
|
|
56
|
+
margin-bottom: 10px;
|
|
57
|
+
padding-bottom: 10px;
|
|
58
|
+
border-bottom: 1px solid var(--line);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.dot {
|
|
62
|
+
width: 6px;
|
|
63
|
+
height: 6px;
|
|
64
|
+
background: var(--accent);
|
|
65
|
+
box-shadow: 0 0 10px var(--accent-line);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.label {
|
|
69
|
+
flex: 1 1 auto;
|
|
70
|
+
font-family: "Geist Mono", "JetBrains Mono", ui-monospace, SFMono-Regular,
|
|
71
|
+
Menlo, Consolas, "Courier New", monospace;
|
|
72
|
+
font-size: 11px;
|
|
73
|
+
font-weight: 500;
|
|
74
|
+
letter-spacing: 0.14em;
|
|
75
|
+
text-transform: uppercase;
|
|
76
|
+
color: var(--fg-muted);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.count {
|
|
80
|
+
display: inline-flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
justify-content: center;
|
|
83
|
+
min-width: 22px;
|
|
84
|
+
height: 18px;
|
|
85
|
+
padding: 0 6px;
|
|
86
|
+
border: 1px solid var(--line-strong);
|
|
87
|
+
border-radius: var(--radius-xs);
|
|
88
|
+
background: rgba(255, 255, 255, 0.02);
|
|
89
|
+
color: var(--accent-fg);
|
|
90
|
+
font-family: "Geist Mono", "JetBrains Mono", ui-monospace, SFMono-Regular,
|
|
91
|
+
Menlo, Consolas, "Courier New", monospace;
|
|
92
|
+
font-size: 10px;
|
|
93
|
+
font-weight: 600;
|
|
94
|
+
font-variant-numeric: tabular-nums;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* ── Empty ───────────────────────────────────────────── */
|
|
98
|
+
|
|
99
|
+
.empty {
|
|
100
|
+
margin: auto 0;
|
|
101
|
+
padding: 12px 4px;
|
|
102
|
+
color: var(--fg-subtle);
|
|
103
|
+
font-size: 12.5px;
|
|
104
|
+
line-height: 1.55;
|
|
105
|
+
text-wrap: balance;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* ── List ───────────────────────────────────────────── */
|
|
109
|
+
|
|
110
|
+
.list {
|
|
111
|
+
list-style: none;
|
|
112
|
+
margin: 0;
|
|
113
|
+
padding: 0;
|
|
114
|
+
display: flex;
|
|
115
|
+
flex-direction: column;
|
|
116
|
+
gap: 4px;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.entry {
|
|
120
|
+
position: relative;
|
|
121
|
+
display: grid;
|
|
122
|
+
grid-template-columns: auto auto 1fr;
|
|
123
|
+
align-items: center;
|
|
124
|
+
gap: 10px;
|
|
125
|
+
padding: 7px 8px;
|
|
126
|
+
border: 1px solid var(--line);
|
|
127
|
+
border-radius: var(--radius-xs);
|
|
128
|
+
background: rgba(255, 255, 255, 0.015);
|
|
129
|
+
font-family: "Geist Mono", "JetBrains Mono", ui-monospace, SFMono-Regular,
|
|
130
|
+
Menlo, Consolas, "Courier New", monospace;
|
|
131
|
+
font-size: 11.5px;
|
|
132
|
+
font-variant-numeric: tabular-nums;
|
|
133
|
+
animation: entry-in 220ms var(--ease) both;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.entry::before {
|
|
137
|
+
content: "";
|
|
138
|
+
position: absolute;
|
|
139
|
+
left: -1px;
|
|
140
|
+
top: -1px;
|
|
141
|
+
bottom: -1px;
|
|
142
|
+
width: 2px;
|
|
143
|
+
background: var(--accent);
|
|
144
|
+
opacity: 0.85;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.entry-time {
|
|
148
|
+
color: var(--fg-subtle);
|
|
149
|
+
letter-spacing: 0.02em;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.entry-name {
|
|
153
|
+
color: var(--accent-fg);
|
|
154
|
+
font-weight: 500;
|
|
155
|
+
white-space: nowrap;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.entry-detail {
|
|
159
|
+
min-width: 0;
|
|
160
|
+
color: var(--fg-muted);
|
|
161
|
+
white-space: nowrap;
|
|
162
|
+
overflow: hidden;
|
|
163
|
+
text-overflow: ellipsis;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
@keyframes entry-in {
|
|
167
|
+
from {
|
|
168
|
+
opacity: 0;
|
|
169
|
+
transform: translateY(-3px);
|
|
170
|
+
}
|
|
171
|
+
to {
|
|
172
|
+
opacity: 1;
|
|
173
|
+
transform: translateY(0);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
@media (prefers-reduced-motion: reduce) {
|
|
178
|
+
.entry { animation: none; }
|
|
179
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { CustomElement, Prop, State, TemplateResult, Watch, html } from 'custom-elements-ts';
|
|
2
|
+
|
|
3
|
+
interface LogEntry {
|
|
4
|
+
id: number;
|
|
5
|
+
name: string;
|
|
6
|
+
detail: string;
|
|
7
|
+
time: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const MAX_ENTRIES = 6;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Live event log for bubbling CustomEvents on the showcase page.
|
|
14
|
+
*/
|
|
15
|
+
@CustomElement({
|
|
16
|
+
tag: 'cts-event-log',
|
|
17
|
+
styleUrl: './event-log.element.scss',
|
|
18
|
+
})
|
|
19
|
+
export class EventLogElement extends HTMLElement {
|
|
20
|
+
/** Comma-separated list of event names to subscribe to on `document`. */
|
|
21
|
+
@Prop() events = '';
|
|
22
|
+
/** Optional human label rendered above the list. */
|
|
23
|
+
@Prop() label = 'Event log';
|
|
24
|
+
/** Optional empty-state copy. */
|
|
25
|
+
@Prop() empty = 'Interact with the component above to see events stream in.';
|
|
26
|
+
|
|
27
|
+
@State() entries: LogEntry[] = [];
|
|
28
|
+
|
|
29
|
+
private subscriptions: Array<{ name: string; handler: EventListener }> = [];
|
|
30
|
+
private nextId = 1;
|
|
31
|
+
|
|
32
|
+
connectedCallback() {
|
|
33
|
+
this.bind();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
disconnectedCallback() {
|
|
37
|
+
this.unbind();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@Watch('events')
|
|
41
|
+
rebind() {
|
|
42
|
+
this.unbind();
|
|
43
|
+
this.bind();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
render(): TemplateResult {
|
|
47
|
+
const entries = this.entries;
|
|
48
|
+
return html`
|
|
49
|
+
<header class="head">
|
|
50
|
+
<span class="dot" aria-hidden="true"></span>
|
|
51
|
+
<span class="label">${this.label}</span>
|
|
52
|
+
<span class="count" aria-hidden="true">${entries.length}</span>
|
|
53
|
+
</header>
|
|
54
|
+
|
|
55
|
+
${entries.length === 0
|
|
56
|
+
? html`<p class="empty">${this.empty}</p>`
|
|
57
|
+
: html`<ol class="list" aria-live="polite">
|
|
58
|
+
${entries.map(
|
|
59
|
+
(entry) => html`
|
|
60
|
+
<li class="entry" data-id=${entry.id}>
|
|
61
|
+
<span class="entry-time">${entry.time}</span>
|
|
62
|
+
<span class="entry-name">${entry.name}</span>
|
|
63
|
+
<span class="entry-detail">${entry.detail}</span>
|
|
64
|
+
</li>
|
|
65
|
+
`
|
|
66
|
+
)}
|
|
67
|
+
</ol>`}
|
|
68
|
+
`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private bind() {
|
|
72
|
+
const names = this.events
|
|
73
|
+
.split(',')
|
|
74
|
+
.map((name) => name.trim())
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
|
|
77
|
+
for (const name of names) {
|
|
78
|
+
const handler: EventListener = (event) => this.record(name, event as CustomEvent);
|
|
79
|
+
document.addEventListener(name, handler);
|
|
80
|
+
this.subscriptions.push({ name, handler });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private unbind() {
|
|
85
|
+
for (const { name, handler } of this.subscriptions) {
|
|
86
|
+
document.removeEventListener(name, handler);
|
|
87
|
+
}
|
|
88
|
+
this.subscriptions = [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private record(name: string, event: CustomEvent) {
|
|
92
|
+
const entry: LogEntry = {
|
|
93
|
+
id: this.nextId++,
|
|
94
|
+
name,
|
|
95
|
+
detail: this.formatDetail(event.detail),
|
|
96
|
+
time: this.timestamp(),
|
|
97
|
+
};
|
|
98
|
+
// Newest first, capped to MAX_ENTRIES so the panel never grows unbounded.
|
|
99
|
+
this.entries = [entry, ...this.entries].slice(0, MAX_ENTRIES);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private formatDetail(detail: unknown): string {
|
|
103
|
+
if (detail === null || detail === undefined) return '—';
|
|
104
|
+
if (typeof detail === 'string') return detail;
|
|
105
|
+
if (typeof detail === 'number' || typeof detail === 'boolean') {
|
|
106
|
+
return String(detail);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
return JSON.stringify(detail);
|
|
110
|
+
} catch {
|
|
111
|
+
return String(detail);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private timestamp(): string {
|
|
116
|
+
const now = new Date();
|
|
117
|
+
const pad = (value: number, size = 2) => String(value).padStart(size, '0');
|
|
118
|
+
return `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(
|
|
119
|
+
now.getMilliseconds(),
|
|
120
|
+
3
|
|
121
|
+
)}`;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="32" gradientUnits="userSpaceOnUse">
|
|
4
|
+
<stop offset="0" stop-color="#1c2230"/>
|
|
5
|
+
<stop offset="1" stop-color="#0c0f15"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect width="32" height="32" rx="5" fill="url(#bg)"/>
|
|
9
|
+
<g fill="none" stroke="#9cc0eb" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
10
|
+
<path d="M12 11l-5 5 5 5"/>
|
|
11
|
+
<path d="M20 11l5 5-5 5"/>
|
|
12
|
+
<path d="M19 8l-6 16"/>
|
|
13
|
+
</g>
|
|
14
|
+
</svg>
|