@vespera-ui/angular 0.2.0 → 0.4.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,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,160 @@
|
|
|
1
|
+
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
@Component({
|
|
4
|
+
selector: 'vsp-number-stepper',
|
|
5
|
+
template: `<div class="ui-stepper">
|
|
6
|
+
<button
|
|
7
|
+
type="button"
|
|
8
|
+
aria-label="Decrease"
|
|
9
|
+
[disabled]="min != null && value <= min"
|
|
10
|
+
(click)="set(value - step)"
|
|
11
|
+
>
|
|
12
|
+
−
|
|
13
|
+
</button>
|
|
14
|
+
<span class="val"
|
|
15
|
+
>{{ value }}
|
|
16
|
+
@if (unit) {
|
|
17
|
+
<i>{{ unit }}</i>
|
|
18
|
+
}
|
|
19
|
+
</span>
|
|
20
|
+
<button
|
|
21
|
+
type="button"
|
|
22
|
+
aria-label="Increase"
|
|
23
|
+
[disabled]="max != null && value >= max"
|
|
24
|
+
(click)="set(value + step)"
|
|
25
|
+
>
|
|
26
|
+
+
|
|
27
|
+
</button>
|
|
28
|
+
</div>`,
|
|
29
|
+
})
|
|
30
|
+
export class VspNumberStepper {
|
|
31
|
+
@Input() value = 0;
|
|
32
|
+
@Output() valueChange = new EventEmitter<number>();
|
|
33
|
+
@Input() min?: number;
|
|
34
|
+
@Input() max?: number;
|
|
35
|
+
@Input() step = 1;
|
|
36
|
+
@Input() unit?: string;
|
|
37
|
+
set(v: number): void {
|
|
38
|
+
let n = v;
|
|
39
|
+
if (this.min != null && n < this.min) n = this.min;
|
|
40
|
+
if (this.max != null && n > this.max) n = this.max;
|
|
41
|
+
this.value = n;
|
|
42
|
+
this.valueChange.emit(n);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@Component({
|
|
47
|
+
selector: 'vsp-copy-button',
|
|
48
|
+
template: `<button type="button" [class]="cls" (click)="copy()">
|
|
49
|
+
@if (done) {
|
|
50
|
+
<span style="color: var(--success); display: inline-flex">
|
|
51
|
+
<svg
|
|
52
|
+
viewBox="0 0 24 24"
|
|
53
|
+
width="15"
|
|
54
|
+
height="15"
|
|
55
|
+
fill="none"
|
|
56
|
+
stroke="currentColor"
|
|
57
|
+
stroke-width="2"
|
|
58
|
+
stroke-linecap="round"
|
|
59
|
+
stroke-linejoin="round"
|
|
60
|
+
>
|
|
61
|
+
<path d="M20 6L9 17l-5-5" />
|
|
62
|
+
</svg> </span
|
|
63
|
+
>Copied
|
|
64
|
+
} @else {
|
|
65
|
+
<svg
|
|
66
|
+
viewBox="0 0 24 24"
|
|
67
|
+
width="15"
|
|
68
|
+
height="15"
|
|
69
|
+
fill="none"
|
|
70
|
+
stroke="currentColor"
|
|
71
|
+
stroke-width="2"
|
|
72
|
+
stroke-linecap="round"
|
|
73
|
+
stroke-linejoin="round"
|
|
74
|
+
>
|
|
75
|
+
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8zM14 2v6h6" />
|
|
76
|
+
</svg>
|
|
77
|
+
{{ label }}
|
|
78
|
+
}
|
|
79
|
+
</button>`,
|
|
80
|
+
})
|
|
81
|
+
export class VspCopyButton {
|
|
82
|
+
@Input() text = '';
|
|
83
|
+
@Input() label = 'Copy';
|
|
84
|
+
@Input() size = 'sm';
|
|
85
|
+
done = false;
|
|
86
|
+
get cls(): string {
|
|
87
|
+
return 'btn btn-ghost' + (this.size === 'sm' ? ' btn-sm' : '');
|
|
88
|
+
}
|
|
89
|
+
async copy(): Promise<void> {
|
|
90
|
+
try {
|
|
91
|
+
await navigator.clipboard?.writeText(this.text);
|
|
92
|
+
} catch {
|
|
93
|
+
/* clipboard unavailable */
|
|
94
|
+
}
|
|
95
|
+
this.done = true;
|
|
96
|
+
setTimeout(() => (this.done = false), 1400);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@Component({
|
|
101
|
+
selector: 'vsp-inline-edit',
|
|
102
|
+
template: `@if (editing) {
|
|
103
|
+
<input
|
|
104
|
+
#inp
|
|
105
|
+
autofocus
|
|
106
|
+
class="ui-input"
|
|
107
|
+
[value]="draft"
|
|
108
|
+
style="height: 32px; max-width: 240px"
|
|
109
|
+
(input)="draft = inp.value"
|
|
110
|
+
(blur)="commit()"
|
|
111
|
+
(keydown.enter)="commit()"
|
|
112
|
+
(keydown.escape)="cancel()"
|
|
113
|
+
/>
|
|
114
|
+
} @else {
|
|
115
|
+
<span
|
|
116
|
+
class="ui-inline"
|
|
117
|
+
role="button"
|
|
118
|
+
tabindex="0"
|
|
119
|
+
(click)="start()"
|
|
120
|
+
(keydown.enter)="start()"
|
|
121
|
+
>
|
|
122
|
+
<span [style.color]="value ? 'var(--text)' : 'var(--text-faint)'">{{
|
|
123
|
+
value || placeholder
|
|
124
|
+
}}</span>
|
|
125
|
+
<span class="pen" style="display: inline-flex">
|
|
126
|
+
<svg
|
|
127
|
+
viewBox="0 0 24 24"
|
|
128
|
+
width="14"
|
|
129
|
+
height="14"
|
|
130
|
+
fill="none"
|
|
131
|
+
stroke="currentColor"
|
|
132
|
+
stroke-width="2"
|
|
133
|
+
stroke-linecap="round"
|
|
134
|
+
stroke-linejoin="round"
|
|
135
|
+
>
|
|
136
|
+
<path d="M12 20h9M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4z" />
|
|
137
|
+
</svg>
|
|
138
|
+
</span>
|
|
139
|
+
</span>
|
|
140
|
+
}`,
|
|
141
|
+
})
|
|
142
|
+
export class VspInlineEdit {
|
|
143
|
+
@Input() value = '';
|
|
144
|
+
@Input() placeholder = 'Empty';
|
|
145
|
+
@Output() save = new EventEmitter<string>();
|
|
146
|
+
editing = false;
|
|
147
|
+
draft = '';
|
|
148
|
+
start(): void {
|
|
149
|
+
this.draft = this.value;
|
|
150
|
+
this.editing = true;
|
|
151
|
+
}
|
|
152
|
+
commit(): void {
|
|
153
|
+
this.editing = false;
|
|
154
|
+
if (this.draft !== this.value) this.save.emit(this.draft);
|
|
155
|
+
}
|
|
156
|
+
cancel(): void {
|
|
157
|
+
this.draft = this.value;
|
|
158
|
+
this.editing = false;
|
|
159
|
+
}
|
|
160
|
+
}
|