@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.
@@ -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
+ }
@@ -0,0 +1,199 @@
1
+ import { Component, EventEmitter, Input, Output } from '@angular/core';
2
+
3
+ export type TabItem = string | { value: string; label: string; count?: number };
4
+
5
+ @Component({
6
+ selector: 'vsp-tabs',
7
+ template: `<div class="ui-tabs" style="align-items: center">
8
+ @for (t of tabs; track id(t)) {
9
+ <button type="button" [class]="cls(t)" (click)="pick(id(t))">
10
+ {{ lbl(t) }}
11
+ @if (cnt(t) != null) {
12
+ <span class="badge badge-muted" style="margin-left: 7px">{{ cnt(t) }}</span>
13
+ }
14
+ </button>
15
+ }
16
+ <div style="flex: 1"></div>
17
+ <ng-content select="[slot=right]" />
18
+ </div>`,
19
+ })
20
+ export class VspTabs {
21
+ @Input() tabs: TabItem[] = [];
22
+ @Input() value?: string;
23
+ @Output() valueChange = new EventEmitter<string>();
24
+ id(t: TabItem): string {
25
+ return typeof t === 'string' ? t : t.value;
26
+ }
27
+ lbl(t: TabItem): string {
28
+ return typeof t === 'string' ? t : t.label;
29
+ }
30
+ cnt(t: TabItem): number | undefined {
31
+ return typeof t === 'object' ? t.count : undefined;
32
+ }
33
+ cls(t: TabItem): string {
34
+ return 'ui-tab' + (this.value === this.id(t) ? ' on' : '');
35
+ }
36
+ pick(v: string): void {
37
+ this.value = v;
38
+ this.valueChange.emit(v);
39
+ }
40
+ }
41
+
42
+ @Component({
43
+ selector: 'vsp-breadcrumb',
44
+ template: `<nav style="display: flex; align-items: center; gap: 7px; font-size: 12.5px">
45
+ @for (it of items; track $index; let i = $index, last = $last) {
46
+ @if (i > 0) {
47
+ <svg
48
+ viewBox="0 0 24 24"
49
+ width="13"
50
+ height="13"
51
+ fill="none"
52
+ stroke="currentColor"
53
+ stroke-width="2"
54
+ stroke-linecap="round"
55
+ stroke-linejoin="round"
56
+ style="color: var(--text-faint)"
57
+ >
58
+ <path d="M9 18l6-6-6-6" />
59
+ </svg>
60
+ }
61
+ <span
62
+ [style.color]="last ? 'var(--text)' : 'var(--text-dim)'"
63
+ [style.fontWeight]="last ? 600 : 500"
64
+ >{{ it }}</span
65
+ >
66
+ }
67
+ </nav>`,
68
+ })
69
+ export class VspBreadcrumb {
70
+ @Input() items: string[] = [];
71
+ }
72
+
73
+ interface PageItem {
74
+ gap: boolean;
75
+ n: number;
76
+ }
77
+
78
+ @Component({
79
+ selector: 'vsp-pagination',
80
+ template: `<div style="display: flex; gap: 4px; align-items: center">
81
+ <button
82
+ type="button"
83
+ class="btn btn-ghost btn-sm"
84
+ [disabled]="page === 0"
85
+ aria-label="Previous page"
86
+ (click)="go(page - 1)"
87
+ >
88
+ <svg
89
+ viewBox="0 0 24 24"
90
+ width="14"
91
+ height="14"
92
+ fill="none"
93
+ stroke="currentColor"
94
+ stroke-width="2"
95
+ stroke-linecap="round"
96
+ stroke-linejoin="round"
97
+ >
98
+ <path d="M15 18l-6-6 6-6" />
99
+ </svg>
100
+ </button>
101
+ @for (item of nums; track $index) {
102
+ @if (item.gap) {
103
+ <span class="mono" style="padding: 0 6px; color: var(--text-faint)">…</span>
104
+ } @else {
105
+ <button
106
+ type="button"
107
+ [class]="numCls(item.n)"
108
+ style="min-width: 32px; padding: 0"
109
+ (click)="go(item.n)"
110
+ >
111
+ {{ item.n + 1 }}
112
+ </button>
113
+ }
114
+ }
115
+ <button
116
+ type="button"
117
+ class="btn btn-ghost btn-sm"
118
+ [disabled]="page >= pages - 1"
119
+ aria-label="Next page"
120
+ (click)="go(page + 1)"
121
+ >
122
+ <svg
123
+ viewBox="0 0 24 24"
124
+ width="14"
125
+ height="14"
126
+ fill="none"
127
+ stroke="currentColor"
128
+ stroke-width="2"
129
+ stroke-linecap="round"
130
+ stroke-linejoin="round"
131
+ >
132
+ <path d="M9 18l6-6-6-6" />
133
+ </svg>
134
+ </button>
135
+ </div>`,
136
+ })
137
+ export class VspPagination {
138
+ @Input() page = 0;
139
+ @Output() pageChange = new EventEmitter<number>();
140
+ @Input() pages = 1;
141
+ get nums(): PageItem[] {
142
+ const r: PageItem[] = [];
143
+ for (let i = 0; i < this.pages; i++) {
144
+ if (i === 0 || i === this.pages - 1 || Math.abs(i - this.page) <= 1)
145
+ r.push({ gap: false, n: i });
146
+ else if (!r[r.length - 1]?.gap) r.push({ gap: true, n: -1 });
147
+ }
148
+ return r;
149
+ }
150
+ numCls(n: number): string {
151
+ return 'btn btn-sm ' + (n === this.page ? 'btn-primary' : 'btn-subtle');
152
+ }
153
+ go(p: number): void {
154
+ this.page = p;
155
+ this.pageChange.emit(p);
156
+ }
157
+ }
158
+
159
+ @Component({
160
+ selector: 'vsp-stepper',
161
+ template: `<div class="ui-steps">
162
+ @for (s of steps; track $index; let i = $index) {
163
+ @if (i > 0) {
164
+ <div [class]="barCls(i)"></div>
165
+ }
166
+ <div [class]="stepCls(i)">
167
+ <span class="ui-step-dot">
168
+ @if (i < current) {
169
+ <svg
170
+ viewBox="0 0 24 24"
171
+ width="14"
172
+ height="14"
173
+ fill="none"
174
+ stroke="currentColor"
175
+ stroke-width="2"
176
+ stroke-linecap="round"
177
+ stroke-linejoin="round"
178
+ >
179
+ <path d="M20 6L9 17l-5-5" />
180
+ </svg>
181
+ } @else {
182
+ {{ i + 1 }}
183
+ }
184
+ </span>
185
+ <span class="ui-step-label">{{ s }}</span>
186
+ </div>
187
+ }
188
+ </div>`,
189
+ })
190
+ export class VspStepper {
191
+ @Input() steps: string[] = [];
192
+ @Input() current = 0;
193
+ barCls(i: number): string {
194
+ return 'ui-step-bar' + (i <= this.current ? ' done' : '');
195
+ }
196
+ stepCls(i: number): string {
197
+ return 'ui-step ' + (i < this.current ? 'done' : i === this.current ? 'active' : 'pending');
198
+ }
199
+ }
@@ -0,0 +1,14 @@
1
+ export * from './button.component';
2
+ export * from './badge.component';
3
+ export * from './display.component';
4
+ export * from './media.component';
5
+ export * from './feedback.component';
6
+ export * from './card.component';
7
+ export * from './alert.component';
8
+ export * from './field.component';
9
+ export * from './toggle.component';
10
+ export * from './forms.component';
11
+ export * from './nav.component';
12
+ export * from './data.component';
13
+ export * from './structure.component';
14
+ export * from './charts.component';
@@ -0,0 +1,162 @@
1
+ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
2
+
3
+ export type BannerTone = 'info' | 'warn' | 'accent';
4
+ const BANNER_ICON: Record<BannerTone, string> = {
5
+ info: 'M12 3l1.6 5L19 9.6l-5 1.6L12 16l-1.6-4.8L5 9.6l5.4-1.6z',
6
+ warn: 'M18 8a6 6 0 00-12 0c0 7-3 9-3 9h18s-3-2-3-9',
7
+ accent: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z',
8
+ };
9
+
10
+ @Component({
11
+ selector: 'vsp-banner',
12
+ template: `<div [class]="cls">
13
+ <span #ic style="display: contents"><ng-content select="[slot=icon]" /></span>
14
+ @if (!ic.childElementCount) {
15
+ <svg
16
+ viewBox="0 0 24 24"
17
+ width="18"
18
+ height="18"
19
+ fill="none"
20
+ stroke="currentColor"
21
+ stroke-width="2"
22
+ stroke-linecap="round"
23
+ stroke-linejoin="round"
24
+ >
25
+ <path [attr.d]="iconPath" />
26
+ </svg>
27
+ }
28
+ <div style="flex: 1; font-size: 13px; font-weight: 500"><ng-content /></div>
29
+ <ng-content select="[slot=action]" />
30
+ @if (dismissible) {
31
+ <button type="button" class="ui-banner-x" aria-label="Dismiss" (click)="dismiss.emit()">
32
+ <svg
33
+ viewBox="0 0 24 24"
34
+ width="15"
35
+ height="15"
36
+ fill="none"
37
+ stroke="currentColor"
38
+ stroke-width="2"
39
+ stroke-linecap="round"
40
+ stroke-linejoin="round"
41
+ >
42
+ <path d="M18 6L6 18M6 6l12 12" />
43
+ </svg>
44
+ </button>
45
+ }
46
+ </div>`,
47
+ })
48
+ export class VspBanner {
49
+ @Input() tone: BannerTone = 'info';
50
+ @Input() dismissible = false;
51
+ @Output() dismiss = new EventEmitter<void>();
52
+ get cls(): string {
53
+ return 'ui-banner ' + this.tone;
54
+ }
55
+ get iconPath(): string {
56
+ return BANNER_ICON[this.tone];
57
+ }
58
+ }
59
+
60
+ @Component({
61
+ selector: 'vsp-empty-state',
62
+ template: `<div
63
+ style="display: grid; place-items: center; text-align: center"
64
+ [style.padding]="compact ? '32px 20px' : '56px 24px'"
65
+ >
66
+ <div style="max-width: 340px">
67
+ <span
68
+ style="width: 56px; height: 56px; border-radius: 16px; display: grid; place-items: center; margin: 0 auto 18px; background: color-mix(in oklab, var(--accent) 12%, transparent); color: var(--accent); border: 1px solid color-mix(in oklab, var(--accent) 22%, transparent)"
69
+ >
70
+ <span #ic style="display: contents"><ng-content select="[slot=icon]" /></span>
71
+ @if (!ic.childElementCount) {
72
+ <svg
73
+ viewBox="0 0 24 24"
74
+ width="26"
75
+ height="26"
76
+ fill="none"
77
+ stroke="currentColor"
78
+ stroke-width="2"
79
+ stroke-linecap="round"
80
+ stroke-linejoin="round"
81
+ >
82
+ <path
83
+ d="M22 12h-6l-2 3h-4l-2-3H2M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"
84
+ />
85
+ </svg>
86
+ }
87
+ </span>
88
+ <div style="font-size: 17px; font-weight: 700">{{ title }}</div>
89
+ @if (desc) {
90
+ <p style="margin: 7px 0 0; color: var(--text-dim); font-size: 13.5px; line-height: 1.6">
91
+ {{ desc }}
92
+ </p>
93
+ }
94
+ <div
95
+ #ac
96
+ [style.display]="ac.childElementCount ? 'flex' : 'none'"
97
+ style="margin-top: 20px; gap: 8px; justify-content: center"
98
+ >
99
+ <ng-content select="[slot=action]" />
100
+ </div>
101
+ </div>
102
+ </div>`,
103
+ })
104
+ export class VspEmptyState {
105
+ @Input() title?: string;
106
+ @Input() desc?: string;
107
+ @Input() compact = false;
108
+ }
109
+
110
+ export interface AccordionItem {
111
+ title: string;
112
+ body: string;
113
+ }
114
+
115
+ @Component({
116
+ selector: 'vsp-accordion',
117
+ template: `<div class="ui-acc">
118
+ @for (it of items; track $index; let i = $index) {
119
+ <div [class]="itemCls(i)">
120
+ <button type="button" class="ui-acc-head" (click)="toggle(i)">
121
+ {{ it.title }}
122
+ <svg
123
+ class="chev"
124
+ viewBox="0 0 24 24"
125
+ width="17"
126
+ height="17"
127
+ fill="none"
128
+ stroke="currentColor"
129
+ stroke-width="2"
130
+ stroke-linecap="round"
131
+ stroke-linejoin="round"
132
+ >
133
+ <path d="M9 18l6-6-6-6" />
134
+ </svg>
135
+ </button>
136
+ <div class="ui-acc-bodywrap">
137
+ <div>
138
+ <div class="ui-acc-body">{{ it.body }}</div>
139
+ </div>
140
+ </div>
141
+ </div>
142
+ }
143
+ </div>`,
144
+ })
145
+ export class VspAccordion implements OnInit {
146
+ @Input() items: AccordionItem[] = [];
147
+ @Input() multiple = false;
148
+ @Input() defaultOpen: number[] = [];
149
+ private open = new Set<number>();
150
+ ngOnInit(): void {
151
+ this.open = new Set(this.defaultOpen);
152
+ }
153
+ toggle(i: number): void {
154
+ const n = new Set<number>(this.multiple ? this.open : []);
155
+ if (this.open.has(i)) n.delete(i);
156
+ else n.add(i);
157
+ this.open = n;
158
+ }
159
+ itemCls(i: number): string {
160
+ return 'ui-acc-item' + (this.open.has(i) ? ' open' : '');
161
+ }
162
+ }
@@ -0,0 +1,62 @@
1
+ import { Component, EventEmitter, Input, Output } from '@angular/core';
2
+
3
+ @Component({
4
+ selector: 'vsp-switch',
5
+ template: `<button
6
+ type="button"
7
+ [disabled]="disabled"
8
+ [class]="cls"
9
+ [attr.aria-pressed]="checked"
10
+ aria-label="Toggle"
11
+ (click)="toggle()"
12
+ ></button>`,
13
+ })
14
+ export class VspSwitch {
15
+ @Input() checked = false;
16
+ @Output() checkedChange = new EventEmitter<boolean>();
17
+ @Input() size?: 'sm';
18
+ @Input() disabled = false;
19
+ get cls(): string {
20
+ return ['ui-switch', this.size === 'sm' && 'sm', this.checked && 'on']
21
+ .filter(Boolean)
22
+ .join(' ');
23
+ }
24
+ toggle(): void {
25
+ this.checked = !this.checked;
26
+ this.checkedChange.emit(this.checked);
27
+ }
28
+ }
29
+
30
+ @Component({
31
+ selector: 'vsp-checkbox',
32
+ template: `<label class="ui-opt" [style.opacity]="disabled ? 0.5 : 1">
33
+ <input
34
+ type="checkbox"
35
+ [checked]="checked"
36
+ [disabled]="disabled"
37
+ (change)="toggle()"
38
+ style="position: absolute; width: 1px; height: 1px; opacity: 0; margin: 0"
39
+ />
40
+ <span [class]="checkCls"></span>
41
+ <span>
42
+ <span>{{ label }}</span>
43
+ @if (sub) {
44
+ <span class="ui-opt-sub">{{ sub }}</span>
45
+ }
46
+ </span>
47
+ </label>`,
48
+ })
49
+ export class VspCheckbox {
50
+ @Input() checked = false;
51
+ @Output() checkedChange = new EventEmitter<boolean>();
52
+ @Input() label?: string;
53
+ @Input() sub?: string;
54
+ @Input() disabled = false;
55
+ get checkCls(): string {
56
+ return this.checked ? 'ui-check on' : 'ui-check';
57
+ }
58
+ toggle(): void {
59
+ this.checked = !this.checked;
60
+ this.checkedChange.emit(this.checked);
61
+ }
62
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "outDir": "./dist/out-tsc",
4
+ "declaration": true,
5
+ "declarationMap": true,
6
+ "inlineSources": true,
7
+ "types": [],
8
+ "lib": ["ES2022", "dom"],
9
+ "target": "ES2022",
10
+ "module": "ES2022",
11
+ "moduleResolution": "bundler",
12
+ "experimentalDecorators": false,
13
+ "strict": true,
14
+ "skipLibCheck": true
15
+ },
16
+ "angularCompilerOptions": {
17
+ "strictTemplates": true,
18
+ "compilationMode": "partial"
19
+ },
20
+ "include": ["src/**/*.ts"]
21
+ }