@vespera-ui/angular 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.
- package/LICENSE +201 -0
- package/README.md +8 -3
- package/dist/README.md +58 -0
- package/dist/fesm2022/vespera-ui-angular.mjs +1825 -0
- package/dist/fesm2022/vespera-ui-angular.mjs.map +1 -0
- package/dist/index.d.ts +341 -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/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 +13 -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,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,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
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
export type SelectOption = string | { value: string; label: string; sub?: string };
|
|
4
|
+
|
|
5
|
+
const val = (o: SelectOption): string => (typeof o === 'string' ? o : o.value);
|
|
6
|
+
const lbl = (o: SelectOption): string => (typeof o === 'string' ? o : o.label);
|
|
7
|
+
const subOf = (o: SelectOption): string | undefined => (typeof o === 'object' ? o.sub : undefined);
|
|
8
|
+
|
|
9
|
+
@Component({
|
|
10
|
+
selector: 'vsp-radio',
|
|
11
|
+
template: `<label class="ui-opt">
|
|
12
|
+
<input
|
|
13
|
+
type="radio"
|
|
14
|
+
[name]="name"
|
|
15
|
+
[value]="value"
|
|
16
|
+
[checked]="checked"
|
|
17
|
+
(change)="select.emit()"
|
|
18
|
+
style="position: absolute; width: 1px; height: 1px; opacity: 0; margin: 0"
|
|
19
|
+
/>
|
|
20
|
+
<span [class]="dotCls"></span>
|
|
21
|
+
<span>
|
|
22
|
+
<span>{{ label }}</span>
|
|
23
|
+
@if (sub) {
|
|
24
|
+
<span class="ui-opt-sub">{{ sub }}</span>
|
|
25
|
+
}
|
|
26
|
+
</span>
|
|
27
|
+
</label>`,
|
|
28
|
+
})
|
|
29
|
+
export class VspRadio {
|
|
30
|
+
@Input() checked = false;
|
|
31
|
+
@Input() label?: string;
|
|
32
|
+
@Input() sub?: string;
|
|
33
|
+
@Input() name?: string;
|
|
34
|
+
@Input() value?: string;
|
|
35
|
+
@Output() select = new EventEmitter<void>();
|
|
36
|
+
get dotCls(): string {
|
|
37
|
+
return this.checked ? 'ui-radio-dot on' : 'ui-radio-dot';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@Component({
|
|
42
|
+
selector: 'vsp-radio-group',
|
|
43
|
+
imports: [VspRadio],
|
|
44
|
+
template: `<div style="display: flex; flex-direction: column; gap: 12px">
|
|
45
|
+
@for (o of options; track val(o)) {
|
|
46
|
+
<vsp-radio
|
|
47
|
+
[name]="name"
|
|
48
|
+
[label]="lbl(o)"
|
|
49
|
+
[sub]="subOf(o)"
|
|
50
|
+
[value]="val(o)"
|
|
51
|
+
[checked]="value === val(o)"
|
|
52
|
+
(select)="pick(val(o))"
|
|
53
|
+
/>
|
|
54
|
+
}
|
|
55
|
+
</div>`,
|
|
56
|
+
})
|
|
57
|
+
export class VspRadioGroup {
|
|
58
|
+
@Input() value?: string;
|
|
59
|
+
@Output() valueChange = new EventEmitter<string>();
|
|
60
|
+
@Input() options: SelectOption[] = [];
|
|
61
|
+
@Input() name = 'vsp-radio';
|
|
62
|
+
val = val;
|
|
63
|
+
lbl = lbl;
|
|
64
|
+
subOf = subOf;
|
|
65
|
+
pick(v: string): void {
|
|
66
|
+
this.value = v;
|
|
67
|
+
this.valueChange.emit(v);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@Component({
|
|
72
|
+
selector: 'vsp-slider',
|
|
73
|
+
template: `<input
|
|
74
|
+
type="range"
|
|
75
|
+
class="ui-slider"
|
|
76
|
+
[value]="value"
|
|
77
|
+
[min]="min"
|
|
78
|
+
[max]="max"
|
|
79
|
+
[step]="step"
|
|
80
|
+
(input)="onInput($event)"
|
|
81
|
+
/>`,
|
|
82
|
+
})
|
|
83
|
+
export class VspSlider {
|
|
84
|
+
@Input() value = 0;
|
|
85
|
+
@Output() valueChange = new EventEmitter<number>();
|
|
86
|
+
@Input() min = 0;
|
|
87
|
+
@Input() max = 100;
|
|
88
|
+
@Input() step = 1;
|
|
89
|
+
onInput(e: Event): void {
|
|
90
|
+
this.value = Number((e.target as HTMLInputElement).value);
|
|
91
|
+
this.valueChange.emit(this.value);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@Component({
|
|
96
|
+
selector: 'vsp-native-select',
|
|
97
|
+
template: `<select class="ui-select" [value]="value" (change)="onChange($event)">
|
|
98
|
+
@for (o of options; track val(o)) {
|
|
99
|
+
<option [value]="val(o)">{{ lbl(o) }}</option>
|
|
100
|
+
}
|
|
101
|
+
</select>`,
|
|
102
|
+
})
|
|
103
|
+
export class VspNativeSelect {
|
|
104
|
+
@Input() value?: string;
|
|
105
|
+
@Output() valueChange = new EventEmitter<string>();
|
|
106
|
+
@Input() options: SelectOption[] = [];
|
|
107
|
+
val = val;
|
|
108
|
+
lbl = lbl;
|
|
109
|
+
onChange(e: Event): void {
|
|
110
|
+
this.value = (e.target as HTMLSelectElement).value;
|
|
111
|
+
this.valueChange.emit(this.value);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
@Component({
|
|
4
|
+
selector: 'vsp-avatar',
|
|
5
|
+
template: `<span
|
|
6
|
+
class="vsp-avatar"
|
|
7
|
+
[style.width.px]="size"
|
|
8
|
+
[style.height.px]="size"
|
|
9
|
+
[style.fontSize.px]="size * 0.38"
|
|
10
|
+
[style.background]="bg"
|
|
11
|
+
>{{ initials }}</span
|
|
12
|
+
>`,
|
|
13
|
+
})
|
|
14
|
+
export class VspAvatar {
|
|
15
|
+
@Input() name = '';
|
|
16
|
+
@Input() hue = 0;
|
|
17
|
+
@Input() size = 34;
|
|
18
|
+
get initials(): string {
|
|
19
|
+
return this.name
|
|
20
|
+
.split(' ')
|
|
21
|
+
.map((s) => s.charAt(0))
|
|
22
|
+
.slice(0, 2)
|
|
23
|
+
.join('')
|
|
24
|
+
.toUpperCase();
|
|
25
|
+
}
|
|
26
|
+
get bg(): string {
|
|
27
|
+
return `linear-gradient(140deg, oklch(0.62 0.16 ${this.hue}), oklch(0.55 0.17 ${(this.hue + 50) % 360}))`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Person {
|
|
32
|
+
name: string;
|
|
33
|
+
hue?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@Component({
|
|
37
|
+
selector: 'vsp-avatar-group',
|
|
38
|
+
imports: [VspAvatar],
|
|
39
|
+
template: `<div style="display: flex; align-items: center">
|
|
40
|
+
@for (p of shown; track $index) {
|
|
41
|
+
<span
|
|
42
|
+
[style.marginLeft.px]="$index ? -10 : 0"
|
|
43
|
+
[style.zIndex]="shown.length - $index"
|
|
44
|
+
style="border: 2px solid var(--surface-1); border-radius: 50%; position: relative"
|
|
45
|
+
>
|
|
46
|
+
<vsp-avatar [name]="p.name" [hue]="p.hue ?? 0" [size]="size" />
|
|
47
|
+
</span>
|
|
48
|
+
}
|
|
49
|
+
@if (extra > 0) {
|
|
50
|
+
<span
|
|
51
|
+
[style.width.px]="size"
|
|
52
|
+
[style.height.px]="size"
|
|
53
|
+
[style.fontSize.px]="size * 0.34"
|
|
54
|
+
style="margin-left: -10px; border-radius: 50%; display: grid; place-items: center; background: var(--surface-3); border: 2px solid var(--surface-1); font-weight: 700; color: var(--text-dim)"
|
|
55
|
+
>+{{ extra }}</span
|
|
56
|
+
>
|
|
57
|
+
}
|
|
58
|
+
</div>`,
|
|
59
|
+
})
|
|
60
|
+
export class VspAvatarGroup {
|
|
61
|
+
@Input() people: Person[] = [];
|
|
62
|
+
@Input() max = 4;
|
|
63
|
+
@Input() size = 32;
|
|
64
|
+
get shown(): Person[] {
|
|
65
|
+
return this.people.slice(0, this.max);
|
|
66
|
+
}
|
|
67
|
+
get extra(): number {
|
|
68
|
+
return this.people.length - this.shown.length;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@Component({
|
|
73
|
+
selector: 'vsp-segmented',
|
|
74
|
+
template: `<div class="ui-seg">
|
|
75
|
+
@for (o of options; track o) {
|
|
76
|
+
<button type="button" [class.on]="value === o" (click)="pick(o)">{{ o }}</button>
|
|
77
|
+
}
|
|
78
|
+
</div>`,
|
|
79
|
+
})
|
|
80
|
+
export class VspSegmented {
|
|
81
|
+
@Input() value?: string;
|
|
82
|
+
@Output() valueChange = new EventEmitter<string>();
|
|
83
|
+
@Input() options: string[] = [];
|
|
84
|
+
pick(o: string): void {
|
|
85
|
+
this.value = o;
|
|
86
|
+
this.valueChange.emit(o);
|
|
87
|
+
}
|
|
88
|
+
}
|