@vespera-ui/angular 0.1.0 → 0.3.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/LICENSE +201 -0
- package/README.md +8 -3
- package/dist/README.md +58 -0
- package/dist/fesm2022/vespera-ui-angular.mjs +2154 -0
- package/dist/fesm2022/vespera-ui-angular.mjs.map +1 -0
- package/dist/index.d.ts +391 -0
- package/ng-package.json +7 -0
- package/package.json +13 -15
- package/src/alert.component.ts +24 -0
- package/src/badge.component.ts +21 -0
- package/src/button.component.ts +25 -0
- package/src/card.component.ts +30 -0
- package/src/charts.component.ts +202 -0
- package/src/data.component.ts +181 -0
- package/src/display.component.ts +29 -0
- package/src/feedback.component.ts +55 -0
- package/src/field.component.ts +61 -0
- package/src/forms.component.ts +113 -0
- package/src/media.component.ts +88 -0
- package/src/nav.component.ts +199 -0
- package/src/public-api.ts +14 -0
- package/src/structure.component.ts +162 -0
- package/src/toggle.component.ts +62 -0
- package/tsconfig.lib.json +21 -0
- package/fesm2022/vespera-ui-angular.mjs +0 -404
- package/fesm2022/vespera-ui-angular.mjs.map +0 -1
- package/index.d.ts +0 -101
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Component, Input } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
export type AlertTone = 'info' | 'pos' | 'warn' | 'neg';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
selector: 'vsp-alert',
|
|
7
|
+
template: `<div [class]="cls">
|
|
8
|
+
<ng-content select="[slot=icon]" />
|
|
9
|
+
<div style="flex: 1">
|
|
10
|
+
@if (title) {
|
|
11
|
+
<div class="ui-alert-title">{{ title }}</div>
|
|
12
|
+
}
|
|
13
|
+
<div class="ui-alert-body"><ng-content /></div>
|
|
14
|
+
</div>
|
|
15
|
+
<ng-content select="[slot=action]" />
|
|
16
|
+
</div>`,
|
|
17
|
+
})
|
|
18
|
+
export class VspAlert {
|
|
19
|
+
@Input() tone: AlertTone = 'info';
|
|
20
|
+
@Input() title?: string;
|
|
21
|
+
get cls(): string {
|
|
22
|
+
return `ui-alert ${this.tone}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Component, Input } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
export type BadgeTone = 'pos' | 'neg' | 'warn' | 'info' | 'muted';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
selector: 'vsp-badge',
|
|
7
|
+
template: `<span [class]="cls">
|
|
8
|
+
@if (dot) {
|
|
9
|
+
<i></i>
|
|
10
|
+
}
|
|
11
|
+
<ng-content />
|
|
12
|
+
</span>`,
|
|
13
|
+
})
|
|
14
|
+
export class VspBadge {
|
|
15
|
+
@Input() tone: BadgeTone = 'muted';
|
|
16
|
+
@Input() dot = false;
|
|
17
|
+
|
|
18
|
+
get cls(): string {
|
|
19
|
+
return `badge badge-${this.tone}`;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Component, Input } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
export type ButtonVariant = 'primary' | 'ghost' | 'subtle' | 'outline' | 'danger';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
selector: 'vsp-button',
|
|
7
|
+
template: `<button [class]="cls" [disabled]="disabled || loading">
|
|
8
|
+
@if (loading) {
|
|
9
|
+
<span class="ui-spinner" aria-hidden="true"></span>
|
|
10
|
+
}
|
|
11
|
+
<ng-content />
|
|
12
|
+
</button>`,
|
|
13
|
+
})
|
|
14
|
+
export class VspButton {
|
|
15
|
+
@Input() variant: ButtonVariant = 'ghost';
|
|
16
|
+
@Input() size?: 'sm';
|
|
17
|
+
@Input() loading = false;
|
|
18
|
+
@Input() disabled = false;
|
|
19
|
+
|
|
20
|
+
get cls(): string {
|
|
21
|
+
return ['btn', `btn-${this.variant}`, this.size === 'sm' ? 'btn-sm' : '']
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.join(' ');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Component, Input } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
@Component({
|
|
4
|
+
selector: 'vsp-card',
|
|
5
|
+
template: `<div [class]="cls"><ng-content /></div>`,
|
|
6
|
+
})
|
|
7
|
+
export class VspCard {
|
|
8
|
+
@Input() pad = false;
|
|
9
|
+
get cls(): string {
|
|
10
|
+
return this.pad ? 'card card-pad' : 'card';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@Component({
|
|
15
|
+
selector: 'vsp-card-head',
|
|
16
|
+
template: `<div class="card-head">
|
|
17
|
+
<div style="min-width: 0">
|
|
18
|
+
<div class="ttl">{{ title }}</div>
|
|
19
|
+
@if (desc) {
|
|
20
|
+
<div class="eyebrow" style="margin-top: 3px">{{ desc }}</div>
|
|
21
|
+
}
|
|
22
|
+
</div>
|
|
23
|
+
<div class="vsp-top-spacer"></div>
|
|
24
|
+
<ng-content select="[slot=right]" />
|
|
25
|
+
</div>`,
|
|
26
|
+
})
|
|
27
|
+
export class VspCardHead {
|
|
28
|
+
@Input() title?: string;
|
|
29
|
+
@Input() desc?: string;
|
|
30
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { Component, Input } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
type Pt = [number, number];
|
|
4
|
+
function smoothPath(pts: Pt[]): string {
|
|
5
|
+
if (pts.length < 2) return '';
|
|
6
|
+
let d = `M ${pts[0]![0]} ${pts[0]![1]}`;
|
|
7
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
8
|
+
const [x0, y0] = pts[i]!;
|
|
9
|
+
const [x1, y1] = pts[i + 1]!;
|
|
10
|
+
const cx = (x0 + x1) / 2;
|
|
11
|
+
d += ` C ${cx} ${y0} ${cx} ${y1} ${x1} ${y1}`;
|
|
12
|
+
}
|
|
13
|
+
return d;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let sparkUid = 0;
|
|
17
|
+
|
|
18
|
+
@Component({
|
|
19
|
+
selector: 'vsp-sparkline',
|
|
20
|
+
template: `<svg
|
|
21
|
+
[attr.width]="w"
|
|
22
|
+
[attr.height]="h"
|
|
23
|
+
[attr.viewBox]="'0 0 ' + w + ' ' + h"
|
|
24
|
+
style="display: block; overflow: visible"
|
|
25
|
+
>
|
|
26
|
+
@if (fill) {
|
|
27
|
+
<defs>
|
|
28
|
+
<linearGradient [id]="gid" x1="0" x2="0" y1="0" y2="1">
|
|
29
|
+
<stop offset="0" [attr.stop-color]="color" stop-opacity="0.28" />
|
|
30
|
+
<stop offset="1" [attr.stop-color]="color" stop-opacity="0" />
|
|
31
|
+
</linearGradient>
|
|
32
|
+
</defs>
|
|
33
|
+
<path [attr.d]="areaD" [attr.fill]="'url(#' + gid + ')'" />
|
|
34
|
+
}
|
|
35
|
+
<path [attr.d]="d" fill="none" [attr.stroke]="color" stroke-width="2" stroke-linecap="round" />
|
|
36
|
+
<circle [attr.cx]="last[0]" [attr.cy]="last[1]" r="2.6" [attr.fill]="color" />
|
|
37
|
+
</svg>`,
|
|
38
|
+
})
|
|
39
|
+
export class VspSparkline {
|
|
40
|
+
@Input() data: number[] = [];
|
|
41
|
+
@Input() color = 'var(--accent)';
|
|
42
|
+
@Input() w = 110;
|
|
43
|
+
@Input() h = 34;
|
|
44
|
+
@Input() fill = true;
|
|
45
|
+
gid = 'spk' + ++sparkUid;
|
|
46
|
+
get pts(): Pt[] {
|
|
47
|
+
const min = Math.min(...this.data);
|
|
48
|
+
const max = Math.max(...this.data);
|
|
49
|
+
const rng = max - min || 1;
|
|
50
|
+
return this.data.map((v, i) => [
|
|
51
|
+
(i / (this.data.length - 1)) * this.w,
|
|
52
|
+
this.h - 3 - ((v - min) / rng) * (this.h - 6),
|
|
53
|
+
]);
|
|
54
|
+
}
|
|
55
|
+
get d(): string {
|
|
56
|
+
return smoothPath(this.pts);
|
|
57
|
+
}
|
|
58
|
+
get areaD(): string {
|
|
59
|
+
return `${this.d} L ${this.w} ${this.h} L 0 ${this.h} Z`;
|
|
60
|
+
}
|
|
61
|
+
get last(): Pt {
|
|
62
|
+
return this.pts[this.pts.length - 1] ?? [0, 0];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface DonutDatum {
|
|
67
|
+
label: string;
|
|
68
|
+
value: number;
|
|
69
|
+
color: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@Component({
|
|
73
|
+
selector: 'vsp-donut',
|
|
74
|
+
template: `<div style="display: flex; align-items: center; gap: 22px">
|
|
75
|
+
<svg [attr.width]="size" [attr.height]="size" style="transform: rotate(-90deg); flex-shrink: 0">
|
|
76
|
+
<circle
|
|
77
|
+
[attr.cx]="c"
|
|
78
|
+
[attr.cy]="c"
|
|
79
|
+
[attr.r]="r"
|
|
80
|
+
fill="none"
|
|
81
|
+
stroke="var(--surface-3)"
|
|
82
|
+
[attr.stroke-width]="thickness"
|
|
83
|
+
/>
|
|
84
|
+
@for (s of segs; track $index) {
|
|
85
|
+
<circle
|
|
86
|
+
[attr.cx]="c"
|
|
87
|
+
[attr.cy]="c"
|
|
88
|
+
[attr.r]="r"
|
|
89
|
+
fill="none"
|
|
90
|
+
[attr.stroke]="s.color"
|
|
91
|
+
[attr.stroke-width]="thickness"
|
|
92
|
+
[attr.stroke-dasharray]="s.dash"
|
|
93
|
+
[attr.stroke-dashoffset]="s.offset"
|
|
94
|
+
stroke-linecap="round"
|
|
95
|
+
/>
|
|
96
|
+
}
|
|
97
|
+
</svg>
|
|
98
|
+
<div style="display: flex; flex-direction: column; gap: 9px; flex: 1">
|
|
99
|
+
@for (d of data; track $index) {
|
|
100
|
+
<div style="display: flex; align-items: center; gap: 9px; font-size: 12.5px">
|
|
101
|
+
<i
|
|
102
|
+
[style.background]="d.color"
|
|
103
|
+
style="width: 9px; height: 9px; border-radius: 3px; flex-shrink: 0"
|
|
104
|
+
></i>
|
|
105
|
+
<span style="color: var(--text-dim); flex: 1">{{ d.label }}</span>
|
|
106
|
+
<span class="mono tnum" style="font-weight: 600">{{ pct(d.value) }}%</span>
|
|
107
|
+
</div>
|
|
108
|
+
}
|
|
109
|
+
</div>
|
|
110
|
+
</div>`,
|
|
111
|
+
})
|
|
112
|
+
export class VspDonut {
|
|
113
|
+
@Input() data: DonutDatum[] = [];
|
|
114
|
+
@Input() size = 168;
|
|
115
|
+
@Input() thickness = 22;
|
|
116
|
+
get total(): number {
|
|
117
|
+
return this.data.reduce((s, d) => s + d.value, 0) || 1;
|
|
118
|
+
}
|
|
119
|
+
get r(): number {
|
|
120
|
+
return (this.size - this.thickness) / 2;
|
|
121
|
+
}
|
|
122
|
+
get c(): number {
|
|
123
|
+
return this.size / 2;
|
|
124
|
+
}
|
|
125
|
+
get circ(): number {
|
|
126
|
+
return 2 * Math.PI * this.r;
|
|
127
|
+
}
|
|
128
|
+
get segs(): { color: string; dash: string; offset: number }[] {
|
|
129
|
+
let acc = 0;
|
|
130
|
+
return this.data.map((d) => {
|
|
131
|
+
const len = (d.value / this.total) * this.circ;
|
|
132
|
+
const seg = { color: d.color, dash: `${len - 2.5} ${this.circ - len + 2.5}`, offset: -acc };
|
|
133
|
+
acc += len;
|
|
134
|
+
return seg;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
pct(v: number): number {
|
|
138
|
+
return Math.round((v / this.total) * 100);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
@Component({
|
|
143
|
+
selector: 'vsp-stat-card',
|
|
144
|
+
imports: [VspSparkline],
|
|
145
|
+
template: `<div
|
|
146
|
+
class="card card-pad vsp-rise"
|
|
147
|
+
style="display: flex; flex-direction: column; gap: 14px"
|
|
148
|
+
>
|
|
149
|
+
<div style="display: flex; align-items: center; justify-content: space-between">
|
|
150
|
+
<div style="display: flex; align-items: center; gap: 10px">
|
|
151
|
+
<span
|
|
152
|
+
style="width: 34px; height: 34px; border-radius: var(--r-sm); display: grid; place-items: center; background: color-mix(in oklab, var(--accent) 13%, transparent); color: var(--accent)"
|
|
153
|
+
>
|
|
154
|
+
<ng-content select="[slot=icon]" />
|
|
155
|
+
</span>
|
|
156
|
+
<span class="eyebrow">{{ label }}</span>
|
|
157
|
+
</div>
|
|
158
|
+
@if (delta != null) {
|
|
159
|
+
<span [class]="deltaCls">
|
|
160
|
+
<svg
|
|
161
|
+
viewBox="0 0 24 24"
|
|
162
|
+
width="11"
|
|
163
|
+
height="11"
|
|
164
|
+
fill="none"
|
|
165
|
+
stroke="currentColor"
|
|
166
|
+
stroke-width="2"
|
|
167
|
+
stroke-linecap="round"
|
|
168
|
+
stroke-linejoin="round"
|
|
169
|
+
>
|
|
170
|
+
<path [attr.d]="arrow" />
|
|
171
|
+
</svg>
|
|
172
|
+
{{ delta }}
|
|
173
|
+
</span>
|
|
174
|
+
}
|
|
175
|
+
</div>
|
|
176
|
+
<div style="display: flex; align-items: flex-end; justify-content: space-between; gap: 12px">
|
|
177
|
+
<div
|
|
178
|
+
class="tnum"
|
|
179
|
+
style="font-size: 30px; font-weight: 800; letter-spacing: -0.02em; line-height: 1"
|
|
180
|
+
>
|
|
181
|
+
{{ value }}
|
|
182
|
+
</div>
|
|
183
|
+
@if (spark) {
|
|
184
|
+
<vsp-sparkline [data]="spark" [color]="sparkColor" />
|
|
185
|
+
}
|
|
186
|
+
</div>
|
|
187
|
+
</div>`,
|
|
188
|
+
})
|
|
189
|
+
export class VspStatCard {
|
|
190
|
+
@Input() label?: string;
|
|
191
|
+
@Input() value?: string;
|
|
192
|
+
@Input() delta?: string;
|
|
193
|
+
@Input() deltaDir: 'up' | 'down' = 'up';
|
|
194
|
+
@Input() spark?: number[];
|
|
195
|
+
@Input() sparkColor = 'var(--accent)';
|
|
196
|
+
get deltaCls(): string {
|
|
197
|
+
return 'badge ' + (this.deltaDir === 'up' ? 'badge-pos' : 'badge-neg');
|
|
198
|
+
}
|
|
199
|
+
get arrow(): string {
|
|
200
|
+
return this.deltaDir === 'up' ? 'M12 19V5M5 12l7-7 7 7' : 'M12 5v14M5 12l7 7 7-7';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { Component, Input } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
@Component({
|
|
4
|
+
selector: 'vsp-circular-progress',
|
|
5
|
+
template: `<div style="position: relative" [style.width.px]="size" [style.height.px]="size">
|
|
6
|
+
<svg [attr.width]="size" [attr.height]="size" style="transform: rotate(-90deg)">
|
|
7
|
+
<circle
|
|
8
|
+
[attr.cx]="size / 2"
|
|
9
|
+
[attr.cy]="size / 2"
|
|
10
|
+
[attr.r]="r"
|
|
11
|
+
fill="none"
|
|
12
|
+
stroke="var(--surface-3)"
|
|
13
|
+
[attr.stroke-width]="thickness"
|
|
14
|
+
/>
|
|
15
|
+
<circle
|
|
16
|
+
[attr.cx]="size / 2"
|
|
17
|
+
[attr.cy]="size / 2"
|
|
18
|
+
[attr.r]="r"
|
|
19
|
+
fill="none"
|
|
20
|
+
[attr.stroke]="color"
|
|
21
|
+
[attr.stroke-width]="thickness"
|
|
22
|
+
stroke-linecap="round"
|
|
23
|
+
[attr.stroke-dasharray]="circ"
|
|
24
|
+
[attr.stroke-dashoffset]="offset"
|
|
25
|
+
style="transition: stroke-dashoffset 0.5s cubic-bezier(0.3, 0.7, 0.3, 1)"
|
|
26
|
+
/>
|
|
27
|
+
</svg>
|
|
28
|
+
<div
|
|
29
|
+
class="tnum"
|
|
30
|
+
[style.fontSize.px]="size * 0.24"
|
|
31
|
+
style="position: absolute; inset: 0; display: grid; place-items: center; font-weight: 800"
|
|
32
|
+
>
|
|
33
|
+
{{ display }}
|
|
34
|
+
</div>
|
|
35
|
+
</div>`,
|
|
36
|
+
})
|
|
37
|
+
export class VspCircularProgress {
|
|
38
|
+
@Input() value = 0;
|
|
39
|
+
@Input() size = 76;
|
|
40
|
+
@Input() thickness = 7;
|
|
41
|
+
@Input() color = 'var(--accent)';
|
|
42
|
+
@Input() label?: string;
|
|
43
|
+
get r(): number {
|
|
44
|
+
return (this.size - this.thickness) / 2;
|
|
45
|
+
}
|
|
46
|
+
get circ(): number {
|
|
47
|
+
return 2 * Math.PI * this.r;
|
|
48
|
+
}
|
|
49
|
+
get offset(): number {
|
|
50
|
+
return this.circ * (1 - Math.min(100, this.value) / 100);
|
|
51
|
+
}
|
|
52
|
+
get display(): string {
|
|
53
|
+
return this.label ?? `${Math.round(this.value)}%`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@Component({
|
|
58
|
+
selector: 'vsp-stat',
|
|
59
|
+
template: `<div class="card card-pad" style="display: flex; align-items: center; gap: 13px">
|
|
60
|
+
<span
|
|
61
|
+
#ic
|
|
62
|
+
[style.display]="ic.childElementCount ? '' : 'none'"
|
|
63
|
+
[style.background]="'color-mix(in oklab, ' + tone + ' 14%, transparent)'"
|
|
64
|
+
[style.color]="tone"
|
|
65
|
+
style="width: 38px; height: 38px; border-radius: var(--r-sm); flex-shrink: 0; display: grid; place-items: center"
|
|
66
|
+
>
|
|
67
|
+
<ng-content select="[slot=icon]" />
|
|
68
|
+
</span>
|
|
69
|
+
<div style="min-width: 0">
|
|
70
|
+
<div class="eyebrow">{{ label }}</div>
|
|
71
|
+
<div style="display: flex; align-items: baseline; gap: 8px; margin-top: 3px">
|
|
72
|
+
<span class="tnum" style="font-size: 22px; font-weight: 800; letter-spacing: -0.02em">{{
|
|
73
|
+
value
|
|
74
|
+
}}</span>
|
|
75
|
+
@if (delta != null) {
|
|
76
|
+
<span [class]="deltaCls" style="padding: 1px 6px">
|
|
77
|
+
<svg
|
|
78
|
+
viewBox="0 0 24 24"
|
|
79
|
+
width="10"
|
|
80
|
+
height="10"
|
|
81
|
+
fill="none"
|
|
82
|
+
stroke="currentColor"
|
|
83
|
+
stroke-width="2"
|
|
84
|
+
stroke-linecap="round"
|
|
85
|
+
stroke-linejoin="round"
|
|
86
|
+
>
|
|
87
|
+
<path [attr.d]="arrow" />
|
|
88
|
+
</svg>
|
|
89
|
+
{{ delta }}
|
|
90
|
+
</span>
|
|
91
|
+
}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>`,
|
|
95
|
+
})
|
|
96
|
+
export class VspStat {
|
|
97
|
+
@Input() label?: string;
|
|
98
|
+
@Input() value?: string;
|
|
99
|
+
@Input() delta?: string;
|
|
100
|
+
@Input() deltaDir: 'up' | 'down' = 'up';
|
|
101
|
+
@Input() tone = 'var(--accent)';
|
|
102
|
+
get deltaCls(): string {
|
|
103
|
+
return 'badge ' + (this.deltaDir === 'up' ? 'badge-pos' : 'badge-neg');
|
|
104
|
+
}
|
|
105
|
+
get arrow(): string {
|
|
106
|
+
return this.deltaDir === 'up' ? 'M12 19V5M5 12l7-7 7 7' : 'M12 5v14M5 12l7 7 7-7';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type TimelineTone = 'pos' | 'neg' | 'warn' | 'info';
|
|
111
|
+
export interface TimelineItem {
|
|
112
|
+
title: string;
|
|
113
|
+
time?: string;
|
|
114
|
+
body?: string;
|
|
115
|
+
tone?: TimelineTone;
|
|
116
|
+
}
|
|
117
|
+
const TL_TONE: Record<TimelineTone, string> = {
|
|
118
|
+
pos: 'var(--success)',
|
|
119
|
+
neg: 'var(--danger)',
|
|
120
|
+
warn: 'var(--warning)',
|
|
121
|
+
info: 'var(--accent)',
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
@Component({
|
|
125
|
+
selector: 'vsp-timeline',
|
|
126
|
+
template: `<div class="ui-tl">
|
|
127
|
+
@for (it of items; track $index) {
|
|
128
|
+
<div class="ui-tl-item">
|
|
129
|
+
<span class="ui-tl-dot" [style]="dotStyle(it.tone)">
|
|
130
|
+
<svg
|
|
131
|
+
viewBox="0 0 24 24"
|
|
132
|
+
width="14"
|
|
133
|
+
height="14"
|
|
134
|
+
fill="none"
|
|
135
|
+
stroke="currentColor"
|
|
136
|
+
stroke-width="2"
|
|
137
|
+
stroke-linecap="round"
|
|
138
|
+
stroke-linejoin="round"
|
|
139
|
+
>
|
|
140
|
+
<circle cx="12" cy="12" r="9" />
|
|
141
|
+
<path d="M12 7v5l3 2" />
|
|
142
|
+
</svg>
|
|
143
|
+
</span>
|
|
144
|
+
<div class="ui-tl-body">
|
|
145
|
+
<div style="display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap">
|
|
146
|
+
<span style="font-weight: 600; font-size: 13.5px">{{ it.title }}</span>
|
|
147
|
+
@if (it.time) {
|
|
148
|
+
<span class="eyebrow" style="margin-left: auto">{{ it.time }}</span>
|
|
149
|
+
}
|
|
150
|
+
</div>
|
|
151
|
+
@if (it.body) {
|
|
152
|
+
<div style="font-size: 12.5px; color: var(--text-dim); margin-top: 3px">
|
|
153
|
+
{{ it.body }}
|
|
154
|
+
</div>
|
|
155
|
+
}
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
}
|
|
159
|
+
</div>`,
|
|
160
|
+
})
|
|
161
|
+
export class VspTimeline {
|
|
162
|
+
@Input() items: TimelineItem[] = [];
|
|
163
|
+
dotStyle(tone?: TimelineTone): string {
|
|
164
|
+
if (!tone) return '';
|
|
165
|
+
const c = TL_TONE[tone];
|
|
166
|
+
return `background:color-mix(in oklab, ${c} 14%, transparent);color:${c};border-color:color-mix(in oklab, ${c} 30%, transparent)`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
@Component({
|
|
171
|
+
selector: 'vsp-description-list',
|
|
172
|
+
template: `<dl class="ui-dl">
|
|
173
|
+
@for (item of items; track $index; let last = $last) {
|
|
174
|
+
<dt [class.last]="last">{{ item[0] }}</dt>
|
|
175
|
+
<dd [class.last]="last">{{ item[1] }}</dd>
|
|
176
|
+
}
|
|
177
|
+
</dl>`,
|
|
178
|
+
})
|
|
179
|
+
export class VspDescriptionList {
|
|
180
|
+
@Input() items: [string, string][] = [];
|
|
181
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
@Component({
|
|
4
|
+
selector: 'vsp-tag',
|
|
5
|
+
template: `<span class="ui-tag">
|
|
6
|
+
<ng-content />
|
|
7
|
+
<button type="button" aria-label="Remove" (click)="remove.emit()">×</button>
|
|
8
|
+
</span>`,
|
|
9
|
+
})
|
|
10
|
+
export class VspTag {
|
|
11
|
+
@Output() remove = new EventEmitter<void>();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@Component({
|
|
15
|
+
selector: 'vsp-kbd',
|
|
16
|
+
template: `<kbd class="ui-kbd"><ng-content /></kbd>`,
|
|
17
|
+
})
|
|
18
|
+
export class VspKbd {}
|
|
19
|
+
|
|
20
|
+
@Component({
|
|
21
|
+
selector: 'vsp-divider',
|
|
22
|
+
template: `<hr [class]="cls" />`,
|
|
23
|
+
})
|
|
24
|
+
export class VspDivider {
|
|
25
|
+
@Input() vertical = false;
|
|
26
|
+
get cls(): string {
|
|
27
|
+
return this.vertical ? 'ui-divider v' : 'ui-divider';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Component, Input } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
@Component({
|
|
4
|
+
selector: 'vsp-spinner',
|
|
5
|
+
template: `<span [class]="cls" aria-hidden="true"></span>`,
|
|
6
|
+
})
|
|
7
|
+
export class VspSpinner {
|
|
8
|
+
@Input() size?: 'lg';
|
|
9
|
+
get cls(): string {
|
|
10
|
+
return this.size === 'lg' ? 'ui-spinner lg' : 'ui-spinner';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@Component({
|
|
15
|
+
selector: 'vsp-icon-button',
|
|
16
|
+
template: `<button type="button" class="vsp-icon-btn" [attr.aria-label]="label">
|
|
17
|
+
<ng-content />
|
|
18
|
+
</button>`,
|
|
19
|
+
})
|
|
20
|
+
export class VspIconButton {
|
|
21
|
+
@Input() label?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Component({
|
|
25
|
+
selector: 'vsp-progress',
|
|
26
|
+
template: `<div class="meter" [style.height.px]="height">
|
|
27
|
+
<i [style.width.%]="clamped" [style.background]="tone" style="transition: width 0.3s"></i>
|
|
28
|
+
</div>`,
|
|
29
|
+
})
|
|
30
|
+
export class VspProgress {
|
|
31
|
+
@Input() value = 0;
|
|
32
|
+
@Input() tone?: string;
|
|
33
|
+
@Input() height = 6;
|
|
34
|
+
get clamped(): number {
|
|
35
|
+
return Math.min(100, this.value);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@Component({
|
|
40
|
+
selector: 'vsp-skeleton',
|
|
41
|
+
template: `<div
|
|
42
|
+
class="skel"
|
|
43
|
+
[style.width]="px(w)"
|
|
44
|
+
[style.height]="px(h)"
|
|
45
|
+
[style.borderRadius]="px(r)"
|
|
46
|
+
></div>`,
|
|
47
|
+
})
|
|
48
|
+
export class VspSkeleton {
|
|
49
|
+
@Input() w: string | number = '100%';
|
|
50
|
+
@Input() h: string | number = 12;
|
|
51
|
+
@Input() r: number = 7;
|
|
52
|
+
px(v: string | number): string {
|
|
53
|
+
return typeof v === 'number' ? `${v}px` : v;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
@Component({
|
|
4
|
+
selector: 'vsp-field',
|
|
5
|
+
template: `<div class="ui-field">
|
|
6
|
+
@if (label) {
|
|
7
|
+
<label class="ui-label" [attr.for]="htmlFor">
|
|
8
|
+
<span
|
|
9
|
+
>{{ label }}
|
|
10
|
+
@if (required) {
|
|
11
|
+
<span class="req"> *</span>
|
|
12
|
+
}
|
|
13
|
+
</span>
|
|
14
|
+
</label>
|
|
15
|
+
}
|
|
16
|
+
<ng-content />
|
|
17
|
+
@if (error || hint) {
|
|
18
|
+
<span [class]="hintCls">{{ error || hint }}</span>
|
|
19
|
+
}
|
|
20
|
+
</div>`,
|
|
21
|
+
})
|
|
22
|
+
export class VspField {
|
|
23
|
+
@Input() label?: string;
|
|
24
|
+
@Input() required = false;
|
|
25
|
+
@Input() hint?: string;
|
|
26
|
+
@Input() error?: string;
|
|
27
|
+
@Input() htmlFor?: string;
|
|
28
|
+
get hintCls(): string {
|
|
29
|
+
return this.error ? 'ui-hint err' : 'ui-hint';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@Component({
|
|
34
|
+
selector: 'vsp-input',
|
|
35
|
+
template: `<input [class]="cls" [value]="value" (input)="onInput($event)" />`,
|
|
36
|
+
})
|
|
37
|
+
export class VspInput {
|
|
38
|
+
@Input() value = '';
|
|
39
|
+
@Output() valueChange = new EventEmitter<string>();
|
|
40
|
+
@Input() invalid = false;
|
|
41
|
+
get cls(): string {
|
|
42
|
+
return this.invalid ? 'ui-input invalid' : 'ui-input';
|
|
43
|
+
}
|
|
44
|
+
onInput(e: Event): void {
|
|
45
|
+
this.value = (e.target as HTMLInputElement).value;
|
|
46
|
+
this.valueChange.emit(this.value);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@Component({
|
|
51
|
+
selector: 'vsp-textarea',
|
|
52
|
+
template: `<textarea class="ui-textarea" [value]="value" (input)="onInput($event)"></textarea>`,
|
|
53
|
+
})
|
|
54
|
+
export class VspTextarea {
|
|
55
|
+
@Input() value = '';
|
|
56
|
+
@Output() valueChange = new EventEmitter<string>();
|
|
57
|
+
onInput(e: Event): void {
|
|
58
|
+
this.value = (e.target as HTMLTextAreaElement).value;
|
|
59
|
+
this.valueChange.emit(this.value);
|
|
60
|
+
}
|
|
61
|
+
}
|