@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
+ }
package/src/public-api.ts CHANGED
@@ -11,3 +11,5 @@ export * from './forms.component';
11
11
  export * from './nav.component';
12
12
  export * from './data.component';
13
13
  export * from './structure.component';
14
+ export * from './charts.component';
15
+ export * from './extras.component';