@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.
@@ -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,13 @@
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';
@@ -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
+ }