@sonny-ui/core 0.1.0-alpha.7 → 0.1.0-alpha.8
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/package.json +1 -1
- package/src/lib/accordion/accordion.directives.spec.ts +95 -0
- package/src/lib/accordion/accordion.directives.ts +104 -0
- package/src/lib/accordion/index.ts +8 -0
- package/src/lib/avatar/avatar.component.spec.ts +75 -0
- package/src/lib/avatar/avatar.component.ts +43 -0
- package/src/lib/avatar/avatar.variants.ts +26 -0
- package/src/lib/avatar/index.ts +2 -0
- package/src/lib/badge/badge.directive.spec.ts +74 -0
- package/src/lib/badge/badge.directive.ts +18 -0
- package/src/lib/badge/badge.variants.ts +29 -0
- package/src/lib/badge/index.ts +2 -0
- package/src/lib/breadcrumb/breadcrumb.directives.spec.ts +80 -0
- package/src/lib/breadcrumb/breadcrumb.directives.ts +84 -0
- package/src/lib/breadcrumb/index.ts +8 -0
- package/src/lib/button/button.directive.spec.ts +92 -0
- package/src/lib/button/button.directive.ts +29 -0
- package/src/lib/button/button.variants.ts +30 -0
- package/src/lib/button/index.ts +2 -0
- package/src/lib/button-group/button-group.directive.spec.ts +46 -0
- package/src/lib/button-group/button-group.directive.ts +20 -0
- package/src/lib/button-group/button-group.variants.ts +18 -0
- package/src/lib/button-group/index.ts +2 -0
- package/src/lib/card/card.directives.spec.ts +104 -0
- package/src/lib/card/card.directives.ts +78 -0
- package/src/lib/card/card.variants.ts +28 -0
- package/src/lib/card/index.ts +9 -0
- package/src/lib/checkbox/checkbox.directive.spec.ts +57 -0
- package/src/lib/checkbox/checkbox.directive.ts +17 -0
- package/src/lib/checkbox/checkbox.variants.ts +19 -0
- package/src/lib/checkbox/index.ts +2 -0
- package/src/lib/combobox/combobox.component.spec.ts +93 -0
- package/src/lib/combobox/combobox.component.ts +236 -0
- package/src/lib/combobox/combobox.variants.ts +19 -0
- package/src/lib/combobox/index.ts +2 -0
- package/src/lib/input/index.ts +3 -0
- package/src/lib/input/input.directive.spec.ts +103 -0
- package/src/lib/input/input.directive.ts +26 -0
- package/src/lib/input/input.variants.ts +42 -0
- package/src/lib/input/label.directive.ts +17 -0
- package/src/lib/loader/index.ts +2 -0
- package/src/lib/loader/loader.component.spec.ts +58 -0
- package/src/lib/loader/loader.component.ts +47 -0
- package/src/lib/loader/loader.variants.ts +21 -0
- package/src/lib/modal/dialog-ref.ts +19 -0
- package/src/lib/modal/dialog.directives.ts +90 -0
- package/src/lib/modal/dialog.service.spec.ts +52 -0
- package/src/lib/modal/dialog.service.ts +61 -0
- package/src/lib/modal/dialog.types.ts +16 -0
- package/src/lib/modal/index.ts +11 -0
- package/src/lib/radio/index.ts +2 -0
- package/src/lib/radio/radio.directive.spec.ts +46 -0
- package/src/lib/radio/radio.directive.ts +17 -0
- package/src/lib/radio/radio.variants.ts +19 -0
- package/src/lib/select/index.ts +2 -0
- package/src/lib/select/select.component.spec.ts +56 -0
- package/src/lib/select/select.component.ts +206 -0
- package/src/lib/select/select.variants.ts +19 -0
- package/src/lib/sheet/index.ts +10 -0
- package/src/lib/sheet/sheet-ref.ts +18 -0
- package/src/lib/sheet/sheet.component.spec.ts +67 -0
- package/src/lib/sheet/sheet.directives.ts +75 -0
- package/src/lib/sheet/sheet.service.ts +100 -0
- package/src/lib/sheet/sheet.types.ts +23 -0
- package/src/lib/skeleton/index.ts +2 -0
- package/src/lib/skeleton/skeleton.directive.spec.ts +55 -0
- package/src/lib/skeleton/skeleton.directive.ts +18 -0
- package/src/lib/skeleton/skeleton.variants.ts +27 -0
- package/src/lib/slider/index.ts +2 -0
- package/src/lib/slider/slider.component.spec.ts +55 -0
- package/src/lib/slider/slider.component.ts +141 -0
- package/src/lib/slider/slider.variants.ts +25 -0
- package/src/lib/switch/index.ts +2 -0
- package/src/lib/switch/switch.component.spec.ts +50 -0
- package/src/lib/switch/switch.component.ts +43 -0
- package/src/lib/switch/switch.variants.ts +31 -0
- package/src/lib/table/index.ts +12 -0
- package/src/lib/table/table.directives.spec.ts +111 -0
- package/src/lib/table/table.directives.ts +134 -0
- package/src/lib/table/table.variants.ts +36 -0
- package/src/lib/tabs/index.ts +8 -0
- package/src/lib/tabs/tabs.directives.spec.ts +66 -0
- package/src/lib/tabs/tabs.directives.ts +91 -0
- package/src/lib/tabs/tabs.variants.ts +17 -0
- package/src/lib/toast/index.ts +3 -0
- package/src/lib/toast/toast.service.spec.ts +71 -0
- package/src/lib/toast/toast.service.ts +60 -0
- package/src/lib/toast/toast.variants.ts +38 -0
- package/src/lib/toast/toaster.component.ts +80 -0
- package/src/lib/toggle/index.ts +2 -0
- package/src/lib/toggle/toggle.directive.spec.ts +52 -0
- package/src/lib/toggle/toggle.directive.ts +27 -0
- package/src/lib/toggle/toggle.variants.ts +25 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Component,
|
|
3
|
+
computed,
|
|
4
|
+
ElementRef,
|
|
5
|
+
HostListener,
|
|
6
|
+
inject,
|
|
7
|
+
input,
|
|
8
|
+
model,
|
|
9
|
+
OnDestroy,
|
|
10
|
+
signal,
|
|
11
|
+
viewChild,
|
|
12
|
+
} from '@angular/core';
|
|
13
|
+
import { cn } from '../core/utils/cn';
|
|
14
|
+
import { comboboxTriggerVariants, type ComboboxSize } from './combobox.variants';
|
|
15
|
+
|
|
16
|
+
export interface ComboboxOption {
|
|
17
|
+
value: string;
|
|
18
|
+
label: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Component({
|
|
22
|
+
selector: 'sny-combobox',
|
|
23
|
+
standalone: true,
|
|
24
|
+
host: {
|
|
25
|
+
class: 'relative inline-block w-full',
|
|
26
|
+
},
|
|
27
|
+
template: `
|
|
28
|
+
<!-- Trigger button -->
|
|
29
|
+
<button
|
|
30
|
+
#triggerEl
|
|
31
|
+
type="button"
|
|
32
|
+
role="combobox"
|
|
33
|
+
[attr.aria-expanded]="open()"
|
|
34
|
+
aria-haspopup="listbox"
|
|
35
|
+
[class]="triggerClass()"
|
|
36
|
+
(click)="toggle()"
|
|
37
|
+
>
|
|
38
|
+
<span [class]="selectedLabel() ? '' : 'text-muted-foreground'">
|
|
39
|
+
{{ selectedLabel() || placeholder() }}
|
|
40
|
+
</span>
|
|
41
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 opacity-50"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
|
|
42
|
+
</button>
|
|
43
|
+
|
|
44
|
+
<!-- Dropdown popover -->
|
|
45
|
+
@if (open()) {
|
|
46
|
+
<div
|
|
47
|
+
#dropdownEl
|
|
48
|
+
class="fixed z-50 rounded-sm border border-border bg-popover text-popover-foreground shadow-md"
|
|
49
|
+
>
|
|
50
|
+
<!-- Search input -->
|
|
51
|
+
<div class="flex items-center border-b border-border px-3">
|
|
52
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2 shrink-0 opacity-50"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
|
53
|
+
<input
|
|
54
|
+
#searchEl
|
|
55
|
+
type="text"
|
|
56
|
+
class="flex h-10 w-full bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
|
|
57
|
+
[placeholder]="searchPlaceholder()"
|
|
58
|
+
[value]="query()"
|
|
59
|
+
(input)="onSearchInput($event)"
|
|
60
|
+
(keydown)="onKeydown($event)"
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<!-- Options list -->
|
|
65
|
+
@if (filtered().length > 0) {
|
|
66
|
+
<ul role="listbox" class="max-h-60 overflow-auto p-1">
|
|
67
|
+
@for (opt of filtered(); track opt.value; let i = $index) {
|
|
68
|
+
<li
|
|
69
|
+
role="option"
|
|
70
|
+
[id]="'sny-cb-opt-' + opt.value"
|
|
71
|
+
[attr.aria-selected]="value() === opt.value"
|
|
72
|
+
[class]="optionClass(i)"
|
|
73
|
+
(mousedown)="select(opt); $event.preventDefault()"
|
|
74
|
+
(mouseenter)="activeIndex.set(i)"
|
|
75
|
+
>
|
|
76
|
+
<svg
|
|
77
|
+
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
|
78
|
+
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
79
|
+
[class]="value() === opt.value ? 'mr-2 shrink-0 opacity-100' : 'mr-2 shrink-0 opacity-0'"
|
|
80
|
+
><path d="M20 6 9 17l-5-5"/></svg>
|
|
81
|
+
{{ opt.label }}
|
|
82
|
+
</li>
|
|
83
|
+
}
|
|
84
|
+
</ul>
|
|
85
|
+
} @else {
|
|
86
|
+
<div class="py-6 text-center text-sm text-muted-foreground">No results found.</div>
|
|
87
|
+
}
|
|
88
|
+
</div>
|
|
89
|
+
}
|
|
90
|
+
`,
|
|
91
|
+
})
|
|
92
|
+
export class SnyComboboxComponent implements OnDestroy {
|
|
93
|
+
readonly options = input<ComboboxOption[]>([]);
|
|
94
|
+
readonly placeholder = input('Select...');
|
|
95
|
+
readonly searchPlaceholder = input('Search...');
|
|
96
|
+
readonly size = input<ComboboxSize>('md');
|
|
97
|
+
readonly class = input<string>('');
|
|
98
|
+
readonly value = model<string>('');
|
|
99
|
+
|
|
100
|
+
readonly open = signal(false);
|
|
101
|
+
readonly query = signal('');
|
|
102
|
+
readonly activeIndex = signal(0);
|
|
103
|
+
|
|
104
|
+
private readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('triggerEl');
|
|
105
|
+
private readonly searchRef = viewChild<ElementRef<HTMLInputElement>>('searchEl');
|
|
106
|
+
private readonly dropdownRef = viewChild<ElementRef<HTMLDivElement>>('dropdownEl');
|
|
107
|
+
private readonly elRef = inject(ElementRef);
|
|
108
|
+
|
|
109
|
+
private scrollHandler: (() => void) | null = null;
|
|
110
|
+
private resizeHandler: (() => void) | null = null;
|
|
111
|
+
|
|
112
|
+
readonly selectedLabel = computed(() => {
|
|
113
|
+
const v = this.value();
|
|
114
|
+
if (!v) return '';
|
|
115
|
+
const opt = this.options().find(o => o.value === v);
|
|
116
|
+
return opt?.label ?? '';
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
readonly filtered = computed(() => {
|
|
120
|
+
const q = this.query().toLowerCase();
|
|
121
|
+
if (!q) return this.options();
|
|
122
|
+
return this.options().filter(o => o.label.toLowerCase().includes(q));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
protected readonly triggerClass = computed(() =>
|
|
126
|
+
cn(comboboxTriggerVariants({ size: this.size() }), this.class())
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
optionClass(index: number): string {
|
|
130
|
+
const base = 'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors';
|
|
131
|
+
const active = index === this.activeIndex() ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50';
|
|
132
|
+
return cn(base, active);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private updateDropdownPosition(): void {
|
|
136
|
+
const trigger = this.triggerRef()?.nativeElement;
|
|
137
|
+
if (!trigger) return;
|
|
138
|
+
const rect = trigger.getBoundingClientRect();
|
|
139
|
+
const dropdown = this.dropdownRef()?.nativeElement;
|
|
140
|
+
if (dropdown) {
|
|
141
|
+
dropdown.style.top = `${rect.bottom + 4}px`;
|
|
142
|
+
dropdown.style.left = `${rect.left}px`;
|
|
143
|
+
dropdown.style.width = `${rect.width}px`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private addGlobalListeners(): void {
|
|
148
|
+
this.removeGlobalListeners();
|
|
149
|
+
this.scrollHandler = () => {
|
|
150
|
+
requestAnimationFrame(() => this.updateDropdownPosition());
|
|
151
|
+
};
|
|
152
|
+
this.resizeHandler = () => {
|
|
153
|
+
requestAnimationFrame(() => this.updateDropdownPosition());
|
|
154
|
+
};
|
|
155
|
+
document.addEventListener('scroll', this.scrollHandler, { capture: true, passive: true });
|
|
156
|
+
window.addEventListener('resize', this.resizeHandler, { passive: true });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private removeGlobalListeners(): void {
|
|
160
|
+
if (this.scrollHandler) {
|
|
161
|
+
document.removeEventListener('scroll', this.scrollHandler, { capture: true } as EventListenerOptions);
|
|
162
|
+
this.scrollHandler = null;
|
|
163
|
+
}
|
|
164
|
+
if (this.resizeHandler) {
|
|
165
|
+
window.removeEventListener('resize', this.resizeHandler);
|
|
166
|
+
this.resizeHandler = null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
ngOnDestroy(): void {
|
|
171
|
+
this.removeGlobalListeners();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
toggle(): void {
|
|
175
|
+
if (this.open()) {
|
|
176
|
+
this.close();
|
|
177
|
+
} else {
|
|
178
|
+
this.updateDropdownPosition();
|
|
179
|
+
this.open.set(true);
|
|
180
|
+
this.query.set('');
|
|
181
|
+
this.activeIndex.set(0);
|
|
182
|
+
this.addGlobalListeners();
|
|
183
|
+
setTimeout(() => {
|
|
184
|
+
this.updateDropdownPosition();
|
|
185
|
+
this.searchRef()?.nativeElement.focus();
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
close(): void {
|
|
191
|
+
this.open.set(false);
|
|
192
|
+
this.query.set('');
|
|
193
|
+
this.removeGlobalListeners();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
onSearchInput(event: Event): void {
|
|
197
|
+
const val = (event.target as HTMLInputElement).value;
|
|
198
|
+
this.query.set(val);
|
|
199
|
+
this.activeIndex.set(0);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
select(opt: ComboboxOption): void {
|
|
203
|
+
this.value.set(opt.value);
|
|
204
|
+
this.close();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
onKeydown(event: KeyboardEvent): void {
|
|
208
|
+
const items = this.filtered();
|
|
209
|
+
switch (event.key) {
|
|
210
|
+
case 'ArrowDown':
|
|
211
|
+
event.preventDefault();
|
|
212
|
+
this.activeIndex.update(i => Math.min(i + 1, items.length - 1));
|
|
213
|
+
break;
|
|
214
|
+
case 'ArrowUp':
|
|
215
|
+
event.preventDefault();
|
|
216
|
+
this.activeIndex.update(i => Math.max(i - 1, 0));
|
|
217
|
+
break;
|
|
218
|
+
case 'Enter':
|
|
219
|
+
event.preventDefault();
|
|
220
|
+
if (items[this.activeIndex()]) {
|
|
221
|
+
this.select(items[this.activeIndex()]);
|
|
222
|
+
}
|
|
223
|
+
break;
|
|
224
|
+
case 'Escape':
|
|
225
|
+
this.close();
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
@HostListener('document:click', ['$event'])
|
|
231
|
+
onDocumentClick(event: MouseEvent): void {
|
|
232
|
+
if (!this.elRef.nativeElement.contains(event.target)) {
|
|
233
|
+
this.close();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
export const comboboxTriggerVariants = cva(
|
|
4
|
+
'inline-flex w-full items-center justify-between whitespace-nowrap rounded-sm border border-border bg-background px-3 py-2 text-sm ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
|
5
|
+
{
|
|
6
|
+
variants: {
|
|
7
|
+
size: {
|
|
8
|
+
sm: 'h-9 text-xs',
|
|
9
|
+
md: 'h-10 text-sm',
|
|
10
|
+
lg: 'h-11 text-base',
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
defaultVariants: {
|
|
14
|
+
size: 'md',
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export type ComboboxSize = 'sm' | 'md' | 'lg';
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { TestBed, ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import { SnyInputDirective } from './input.directive';
|
|
4
|
+
import { SnyLabelDirective } from './label.directive';
|
|
5
|
+
import type { InputVariant, InputSize } from './input.variants';
|
|
6
|
+
|
|
7
|
+
@Component({
|
|
8
|
+
standalone: true,
|
|
9
|
+
imports: [SnyInputDirective, SnyLabelDirective],
|
|
10
|
+
template: `
|
|
11
|
+
<label snyLabel [variant]="variant()">Name</label>
|
|
12
|
+
<input snyInput [variant]="variant()" [inputSize]="inputSize()" [ariaDescribedBy]="describedBy()" />
|
|
13
|
+
`,
|
|
14
|
+
})
|
|
15
|
+
class TestHostComponent {
|
|
16
|
+
variant = signal<InputVariant>('default');
|
|
17
|
+
inputSize = signal<InputSize>('md');
|
|
18
|
+
describedBy = signal('');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('SnyInputDirective', () => {
|
|
22
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
23
|
+
let input: HTMLInputElement;
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
await TestBed.configureTestingModule({
|
|
27
|
+
imports: [TestHostComponent],
|
|
28
|
+
}).compileComponents();
|
|
29
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
30
|
+
fixture.detectChanges();
|
|
31
|
+
input = fixture.nativeElement.querySelector('input');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should apply default input classes', () => {
|
|
35
|
+
expect(input.className).toContain('border-input');
|
|
36
|
+
expect(input.className).toContain('h-10');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should apply error variant', () => {
|
|
40
|
+
fixture.componentInstance.variant.set('error');
|
|
41
|
+
fixture.detectChanges();
|
|
42
|
+
expect(input.className).toContain('border-destructive');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should set aria-invalid on error', () => {
|
|
46
|
+
fixture.componentInstance.variant.set('error');
|
|
47
|
+
fixture.detectChanges();
|
|
48
|
+
expect(input.getAttribute('aria-invalid')).toBe('true');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should not set aria-invalid on default', () => {
|
|
52
|
+
expect(input.getAttribute('aria-invalid')).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should apply success variant', () => {
|
|
56
|
+
fixture.componentInstance.variant.set('success');
|
|
57
|
+
fixture.detectChanges();
|
|
58
|
+
expect(input.className).toContain('border-green-500');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should apply sm size', () => {
|
|
62
|
+
fixture.componentInstance.inputSize.set('sm');
|
|
63
|
+
fixture.detectChanges();
|
|
64
|
+
expect(input.className).toContain('h-9');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should apply lg size', () => {
|
|
68
|
+
fixture.componentInstance.inputSize.set('lg');
|
|
69
|
+
fixture.detectChanges();
|
|
70
|
+
expect(input.className).toContain('h-11');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should set aria-describedby', () => {
|
|
74
|
+
fixture.componentInstance.describedBy.set('help-text');
|
|
75
|
+
fixture.detectChanges();
|
|
76
|
+
expect(input.getAttribute('aria-describedby')).toBe('help-text');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('SnyLabelDirective', () => {
|
|
81
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
82
|
+
let label: HTMLLabelElement;
|
|
83
|
+
|
|
84
|
+
beforeEach(async () => {
|
|
85
|
+
await TestBed.configureTestingModule({
|
|
86
|
+
imports: [TestHostComponent],
|
|
87
|
+
}).compileComponents();
|
|
88
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
89
|
+
fixture.detectChanges();
|
|
90
|
+
label = fixture.nativeElement.querySelector('label');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should apply label classes', () => {
|
|
94
|
+
expect(label.className).toContain('text-sm');
|
|
95
|
+
expect(label.className).toContain('font-medium');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should apply error variant to label', () => {
|
|
99
|
+
fixture.componentInstance.variant.set('error');
|
|
100
|
+
fixture.detectChanges();
|
|
101
|
+
expect(label.className).toContain('text-destructive');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Directive, computed, input } from '@angular/core';
|
|
2
|
+
import { cn } from '../core/utils/cn';
|
|
3
|
+
import { inputVariants, type InputVariant, type InputSize } from './input.variants';
|
|
4
|
+
|
|
5
|
+
@Directive({
|
|
6
|
+
selector: 'input[snyInput], textarea[snyInput]',
|
|
7
|
+
standalone: true,
|
|
8
|
+
host: {
|
|
9
|
+
'[class]': 'computedClass()',
|
|
10
|
+
'[attr.aria-invalid]': 'variant() === "error" || null',
|
|
11
|
+
'[attr.aria-describedby]': 'ariaDescribedBy() || null',
|
|
12
|
+
},
|
|
13
|
+
})
|
|
14
|
+
export class SnyInputDirective {
|
|
15
|
+
readonly variant = input<InputVariant>('default');
|
|
16
|
+
readonly inputSize = input<InputSize>('md');
|
|
17
|
+
readonly class = input<string>('');
|
|
18
|
+
readonly ariaDescribedBy = input<string>('');
|
|
19
|
+
|
|
20
|
+
protected readonly computedClass = computed(() =>
|
|
21
|
+
cn(
|
|
22
|
+
inputVariants({ variant: this.variant(), inputSize: this.inputSize() }),
|
|
23
|
+
this.class()
|
|
24
|
+
)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
export const inputVariants = cva(
|
|
4
|
+
'flex w-full rounded-sm border bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
5
|
+
{
|
|
6
|
+
variants: {
|
|
7
|
+
variant: {
|
|
8
|
+
default: 'border-input',
|
|
9
|
+
error: 'border-destructive focus-visible:ring-destructive',
|
|
10
|
+
success: 'border-green-500 focus-visible:ring-green-500',
|
|
11
|
+
},
|
|
12
|
+
inputSize: {
|
|
13
|
+
sm: 'h-9 text-xs',
|
|
14
|
+
md: 'h-10',
|
|
15
|
+
lg: 'h-11 text-base',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
defaultVariants: {
|
|
19
|
+
variant: 'default',
|
|
20
|
+
inputSize: 'md',
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
export const labelVariants = cva(
|
|
26
|
+
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
|
27
|
+
{
|
|
28
|
+
variants: {
|
|
29
|
+
variant: {
|
|
30
|
+
default: '',
|
|
31
|
+
error: 'text-destructive',
|
|
32
|
+
success: 'text-green-600',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
defaultVariants: {
|
|
36
|
+
variant: 'default',
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
export type InputVariant = 'default' | 'error' | 'success';
|
|
42
|
+
export type InputSize = 'sm' | 'md' | 'lg';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Directive, computed, input } from '@angular/core';
|
|
2
|
+
import { cn } from '../core/utils/cn';
|
|
3
|
+
import { labelVariants, type InputVariant } from './input.variants';
|
|
4
|
+
|
|
5
|
+
@Directive({
|
|
6
|
+
selector: 'label[snyLabel]',
|
|
7
|
+
standalone: true,
|
|
8
|
+
host: { '[class]': 'computedClass()' },
|
|
9
|
+
})
|
|
10
|
+
export class SnyLabelDirective {
|
|
11
|
+
readonly variant = input<InputVariant>('default');
|
|
12
|
+
readonly class = input<string>('');
|
|
13
|
+
|
|
14
|
+
protected readonly computedClass = computed(() =>
|
|
15
|
+
cn(labelVariants({ variant: this.variant() }), this.class())
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { TestBed, ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import { SnyLoaderComponent } from './loader.component';
|
|
4
|
+
import type { LoaderVariant, LoaderSize } from './loader.variants';
|
|
5
|
+
|
|
6
|
+
@Component({
|
|
7
|
+
standalone: true,
|
|
8
|
+
imports: [SnyLoaderComponent],
|
|
9
|
+
template: `<sny-loader [variant]="variant()" [size]="size()" />`,
|
|
10
|
+
})
|
|
11
|
+
class TestHostComponent {
|
|
12
|
+
variant = signal<LoaderVariant>('spinner');
|
|
13
|
+
size = signal<LoaderSize>('md');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('SnyLoaderComponent', () => {
|
|
17
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
18
|
+
let el: HTMLElement;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
await TestBed.configureTestingModule({
|
|
22
|
+
imports: [TestHostComponent],
|
|
23
|
+
}).compileComponents();
|
|
24
|
+
|
|
25
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
26
|
+
fixture.detectChanges();
|
|
27
|
+
el = fixture.nativeElement.querySelector('sny-loader');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should have status role', () => {
|
|
31
|
+
expect(el.getAttribute('role')).toBe('status');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should render spinner by default', () => {
|
|
35
|
+
expect(el.querySelector('svg')).toBeTruthy();
|
|
36
|
+
expect(el.className).toContain('h-6');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should render dots variant', () => {
|
|
40
|
+
fixture.componentInstance.variant.set('dots');
|
|
41
|
+
fixture.detectChanges();
|
|
42
|
+
const dots = el.querySelectorAll('.rounded-full');
|
|
43
|
+
expect(dots.length).toBe(3);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should render bars variant', () => {
|
|
47
|
+
fixture.componentInstance.variant.set('bars');
|
|
48
|
+
fixture.detectChanges();
|
|
49
|
+
const bars = el.querySelectorAll('.rounded-sm');
|
|
50
|
+
expect(bars.length).toBe(4);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should apply lg size', () => {
|
|
54
|
+
fixture.componentInstance.size.set('lg');
|
|
55
|
+
fixture.detectChanges();
|
|
56
|
+
expect(el.className).toContain('h-8');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Component, computed, input } from '@angular/core';
|
|
2
|
+
import { cn } from '../core/utils/cn';
|
|
3
|
+
import { loaderVariants, type LoaderSize, type LoaderVariant } from './loader.variants';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
selector: 'sny-loader',
|
|
7
|
+
standalone: true,
|
|
8
|
+
host: {
|
|
9
|
+
'[class]': 'computedClass()',
|
|
10
|
+
role: 'status',
|
|
11
|
+
'[attr.aria-label]': '"Loading"',
|
|
12
|
+
},
|
|
13
|
+
template: `
|
|
14
|
+
@switch (variant()) {
|
|
15
|
+
@case ('spinner') {
|
|
16
|
+
<svg class="animate-spin h-full w-full" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
17
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
18
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
19
|
+
</svg>
|
|
20
|
+
}
|
|
21
|
+
@case ('dots') {
|
|
22
|
+
<span class="flex items-center gap-1">
|
|
23
|
+
<span class="h-1.5 w-1.5 rounded-full bg-current animate-bounce [animation-delay:-0.3s]"></span>
|
|
24
|
+
<span class="h-1.5 w-1.5 rounded-full bg-current animate-bounce [animation-delay:-0.15s]"></span>
|
|
25
|
+
<span class="h-1.5 w-1.5 rounded-full bg-current animate-bounce"></span>
|
|
26
|
+
</span>
|
|
27
|
+
}
|
|
28
|
+
@case ('bars') {
|
|
29
|
+
<span class="flex items-end gap-0.5 h-full">
|
|
30
|
+
<span class="w-1 bg-current animate-pulse rounded-sm [animation-delay:-0.3s]" style="height:60%"></span>
|
|
31
|
+
<span class="w-1 bg-current animate-pulse rounded-sm [animation-delay:-0.15s]" style="height:100%"></span>
|
|
32
|
+
<span class="w-1 bg-current animate-pulse rounded-sm" style="height:40%"></span>
|
|
33
|
+
<span class="w-1 bg-current animate-pulse rounded-sm [animation-delay:-0.2s]" style="height:80%"></span>
|
|
34
|
+
</span>
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
`,
|
|
38
|
+
})
|
|
39
|
+
export class SnyLoaderComponent {
|
|
40
|
+
readonly variant = input<LoaderVariant>('spinner');
|
|
41
|
+
readonly size = input<LoaderSize>('md');
|
|
42
|
+
readonly class = input<string>('');
|
|
43
|
+
|
|
44
|
+
protected readonly computedClass = computed(() =>
|
|
45
|
+
cn(loaderVariants({ size: this.size() }), this.class())
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
export const loaderVariants = cva(
|
|
4
|
+
'inline-flex items-center justify-center text-current',
|
|
5
|
+
{
|
|
6
|
+
variants: {
|
|
7
|
+
size: {
|
|
8
|
+
sm: 'h-4 w-4',
|
|
9
|
+
md: 'h-6 w-6',
|
|
10
|
+
lg: 'h-8 w-8',
|
|
11
|
+
xl: 'h-12 w-12',
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
defaultVariants: {
|
|
15
|
+
size: 'md',
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export type LoaderSize = 'sm' | 'md' | 'lg' | 'xl';
|
|
21
|
+
export type LoaderVariant = 'spinner' | 'dots' | 'bars';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Observable } from 'rxjs';
|
|
2
|
+
|
|
3
|
+
/** Structural interface for the subset of CDK DialogRef we consume. */
|
|
4
|
+
interface CdkDialogRefLike<R> {
|
|
5
|
+
close(result?: R): void;
|
|
6
|
+
readonly closed: Observable<R | undefined>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class SnyDialogRef<R = unknown> {
|
|
10
|
+
constructor(private readonly cdkRef: CdkDialogRefLike<R>) {}
|
|
11
|
+
|
|
12
|
+
close(result?: R): void {
|
|
13
|
+
this.cdkRef.close(result);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get closed(): Observable<R | undefined> {
|
|
17
|
+
return this.cdkRef.closed;
|
|
18
|
+
}
|
|
19
|
+
}
|