@sonny-ui/core 0.1.0-alpha.1 → 0.1.0-alpha.11
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/README.md +101 -32
- package/fesm2022/sonny-ui-core.mjs +3031 -42
- package/fesm2022/sonny-ui-core.mjs.map +1 -1
- package/package.json +8 -5
- package/schematics/ng-add/schema.json +1 -1
- package/schematics/ng-generate/component/index.js +182 -1
- package/schematics/ng-generate/component/schema.json +2 -2
- package/src/lib/accordion/accordion.directives.spec.ts +173 -0
- package/src/lib/accordion/accordion.directives.ts +147 -0
- package/src/lib/accordion/index.ts +8 -0
- package/src/lib/alert/alert.directives.spec.ts +154 -0
- package/src/lib/alert/alert.directives.ts +70 -0
- package/src/lib/alert/alert.variants.ts +25 -0
- package/src/lib/alert/index.ts +6 -0
- package/src/lib/avatar/avatar.component.spec.ts +75 -0
- package/src/lib/avatar/avatar.component.ts +44 -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/calendar/calendar.component.spec.ts +105 -0
- package/src/lib/calendar/calendar.component.ts +231 -0
- package/src/lib/calendar/index.ts +1 -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/carousel/carousel.directives.spec.ts +85 -0
- package/src/lib/carousel/carousel.directives.ts +164 -0
- package/src/lib/carousel/index.ts +8 -0
- package/src/lib/chat-bubble/chat-bubble.directives.spec.ts +52 -0
- package/src/lib/chat-bubble/chat-bubble.directives.ts +102 -0
- package/src/lib/chat-bubble/index.ts +11 -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 +151 -0
- package/src/lib/combobox/combobox.component.ts +279 -0
- package/src/lib/combobox/combobox.variants.ts +19 -0
- package/src/lib/combobox/index.ts +2 -0
- package/src/lib/diff/diff.component.spec.ts +47 -0
- package/src/lib/diff/diff.component.ts +83 -0
- package/src/lib/diff/index.ts +1 -0
- package/src/lib/divider/divider.component.spec.ts +48 -0
- package/src/lib/divider/divider.component.ts +52 -0
- package/src/lib/divider/divider.variants.ts +22 -0
- package/src/lib/divider/index.ts +2 -0
- package/src/lib/dock/dock.directives.spec.ts +85 -0
- package/src/lib/dock/dock.directives.ts +83 -0
- package/src/lib/dock/index.ts +1 -0
- package/src/lib/drawer/drawer.directives.spec.ts +62 -0
- package/src/lib/drawer/drawer.directives.ts +83 -0
- package/src/lib/drawer/index.ts +8 -0
- package/src/lib/dropdown/dropdown.directives.spec.ts +106 -0
- package/src/lib/dropdown/dropdown.directives.ts +143 -0
- package/src/lib/dropdown/dropdown.variants.ts +27 -0
- package/src/lib/dropdown/index.ts +15 -0
- package/src/lib/fab/fab.directives.spec.ts +60 -0
- package/src/lib/fab/fab.directives.ts +80 -0
- package/src/lib/fab/index.ts +8 -0
- package/src/lib/fieldset/fieldset.directives.spec.ts +74 -0
- package/src/lib/fieldset/fieldset.directives.ts +52 -0
- package/src/lib/fieldset/fieldset.variants.ts +15 -0
- package/src/lib/fieldset/index.ts +6 -0
- package/src/lib/file-input/file-input.component.spec.ts +114 -0
- package/src/lib/file-input/file-input.component.ts +168 -0
- package/src/lib/file-input/file-input.variants.ts +25 -0
- package/src/lib/file-input/index.ts +6 -0
- package/src/lib/indicator/index.ts +6 -0
- package/src/lib/indicator/indicator.directives.spec.ts +64 -0
- package/src/lib/indicator/indicator.directives.ts +61 -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/kbd/index.ts +2 -0
- package/src/lib/kbd/kbd.directive.spec.ts +42 -0
- package/src/lib/kbd/kbd.directive.ts +19 -0
- package/src/lib/kbd/kbd.variants.ts +19 -0
- package/src/lib/link/index.ts +2 -0
- package/src/lib/link/link.directive.spec.ts +41 -0
- package/src/lib/link/link.directive.ts +19 -0
- package/src/lib/link/link.variants.ts +20 -0
- package/src/lib/list/index.ts +8 -0
- package/src/lib/list/list.directives.spec.ts +65 -0
- package/src/lib/list/list.directives.ts +86 -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 +48 -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/navbar/index.ts +7 -0
- package/src/lib/navbar/navbar.directives.spec.ts +59 -0
- package/src/lib/navbar/navbar.directives.ts +61 -0
- package/src/lib/pagination/index.ts +6 -0
- package/src/lib/pagination/pagination.component.spec.ts +59 -0
- package/src/lib/pagination/pagination.component.ts +144 -0
- package/src/lib/pagination/pagination.variants.ts +31 -0
- package/src/lib/progress/index.ts +7 -0
- package/src/lib/progress/progress.component.spec.ts +117 -0
- package/src/lib/progress/progress.component.ts +65 -0
- package/src/lib/progress/progress.variants.ts +43 -0
- package/src/lib/radial-progress/index.ts +5 -0
- package/src/lib/radial-progress/radial-progress.component.spec.ts +41 -0
- package/src/lib/radial-progress/radial-progress.component.ts +71 -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/rating/index.ts +2 -0
- package/src/lib/rating/rating.component.spec.ts +157 -0
- package/src/lib/rating/rating.component.ts +171 -0
- package/src/lib/rating/rating.variants.ts +20 -0
- package/src/lib/select/index.ts +2 -0
- package/src/lib/select/select.component.spec.ts +112 -0
- package/src/lib/select/select.component.ts +250 -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 +63 -0
- package/src/lib/skeleton/skeleton.directive.ts +22 -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 +104 -0
- package/src/lib/slider/slider.component.ts +188 -0
- package/src/lib/slider/slider.variants.ts +25 -0
- package/src/lib/stat/index.ts +8 -0
- package/src/lib/stat/stat.directives.spec.ts +60 -0
- package/src/lib/stat/stat.directives.ts +84 -0
- package/src/lib/status/index.ts +2 -0
- package/src/lib/status/status.directive.spec.ts +43 -0
- package/src/lib/status/status.directive.ts +38 -0
- package/src/lib/status/status.variants.ts +26 -0
- package/src/lib/steps/index.ts +8 -0
- package/src/lib/steps/steps.directives.spec.ts +52 -0
- package/src/lib/steps/steps.directives.ts +80 -0
- package/src/lib/switch/index.ts +2 -0
- package/src/lib/switch/switch.component.spec.ts +98 -0
- package/src/lib/switch/switch.component.ts +84 -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 +136 -0
- package/src/lib/tabs/tabs.directives.ts +130 -0
- package/src/lib/tabs/tabs.variants.ts +17 -0
- package/src/lib/textarea/index.ts +7 -0
- package/src/lib/textarea/textarea.directive.spec.ts +84 -0
- package/src/lib/textarea/textarea.directive.ts +72 -0
- package/src/lib/textarea/textarea.variants.ts +34 -0
- package/src/lib/timeline/index.ts +11 -0
- package/src/lib/timeline/timeline.directives.spec.ts +55 -0
- package/src/lib/timeline/timeline.directives.ts +90 -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.spec.ts +38 -0
- package/src/lib/toast/toaster.component.ts +82 -0
- package/src/lib/toggle/index.ts +2 -0
- package/src/lib/toggle/toggle.directive.spec.ts +100 -0
- package/src/lib/toggle/toggle.directive.ts +73 -0
- package/src/lib/toggle/toggle.variants.ts +25 -0
- package/src/lib/tooltip/index.ts +2 -0
- package/src/lib/tooltip/tooltip.directive.spec.ts +113 -0
- package/src/lib/tooltip/tooltip.directive.ts +131 -0
- package/src/lib/tooltip/tooltip.variants.ts +20 -0
- package/src/lib/validator/index.ts +5 -0
- package/src/lib/validator/validator.directives.spec.ts +47 -0
- package/src/lib/validator/validator.directives.ts +52 -0
- package/types/sonny-ui-core.d.ts +878 -11
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Component, signal, viewChild } from '@angular/core';
|
|
2
|
+
import { TestBed, ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import {
|
|
4
|
+
SnyTabsDirective,
|
|
5
|
+
SnyTabsListDirective,
|
|
6
|
+
SnyTabsTriggerDirective,
|
|
7
|
+
SnyTabsContentDirective,
|
|
8
|
+
} from './tabs.directives';
|
|
9
|
+
|
|
10
|
+
@Component({
|
|
11
|
+
standalone: true,
|
|
12
|
+
imports: [SnyTabsDirective, SnyTabsListDirective, SnyTabsTriggerDirective, SnyTabsContentDirective],
|
|
13
|
+
template: `
|
|
14
|
+
<div snyTabs [(value)]="activeTab">
|
|
15
|
+
<div snyTabsList>
|
|
16
|
+
<button snyTabsTrigger value="tab1">Tab 1</button>
|
|
17
|
+
<button snyTabsTrigger value="tab2">Tab 2</button>
|
|
18
|
+
</div>
|
|
19
|
+
<div snyTabsContent value="tab1">Content 1</div>
|
|
20
|
+
<div snyTabsContent value="tab2">Content 2</div>
|
|
21
|
+
</div>
|
|
22
|
+
`,
|
|
23
|
+
})
|
|
24
|
+
class TestHostComponent {
|
|
25
|
+
activeTab = signal('tab1');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('Tabs Directives', () => {
|
|
29
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
await TestBed.configureTestingModule({
|
|
33
|
+
imports: [TestHostComponent],
|
|
34
|
+
}).compileComponents();
|
|
35
|
+
|
|
36
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
37
|
+
fixture.detectChanges();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should show active tab content', () => {
|
|
41
|
+
const contents = fixture.nativeElement.querySelectorAll('[role="tabpanel"]');
|
|
42
|
+
expect(contents[0].style.display).not.toBe('none');
|
|
43
|
+
expect(contents[1].style.display).toBe('none');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should set aria-selected on active trigger', () => {
|
|
47
|
+
const triggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
|
|
48
|
+
expect(triggers[0].getAttribute('aria-selected')).toBe('true');
|
|
49
|
+
expect(triggers[1].getAttribute('aria-selected')).toBe('false');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should switch tabs on click', () => {
|
|
53
|
+
const triggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
|
|
54
|
+
triggers[1].click();
|
|
55
|
+
fixture.detectChanges();
|
|
56
|
+
const contents = fixture.nativeElement.querySelectorAll('[role="tabpanel"]');
|
|
57
|
+
expect(contents[0].style.display).toBe('none');
|
|
58
|
+
expect(contents[1].style.display).not.toBe('none');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should have tablist role', () => {
|
|
62
|
+
const list = fixture.nativeElement.querySelector('[role="tablist"]');
|
|
63
|
+
expect(list).toBeTruthy();
|
|
64
|
+
expect(list.className).toContain('bg-muted');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should implement roving tabindex on triggers', () => {
|
|
68
|
+
const triggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
|
|
69
|
+
expect(triggers[0].getAttribute('tabindex')).toBe('0');
|
|
70
|
+
expect(triggers[1].getAttribute('tabindex')).toBe('-1');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should move focus with ArrowRight', () => {
|
|
74
|
+
const triggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
|
|
75
|
+
(triggers[0] as HTMLElement).focus();
|
|
76
|
+
triggers[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
|
77
|
+
fixture.detectChanges();
|
|
78
|
+
const updatedTriggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
|
|
79
|
+
expect(document.activeElement).toBe(updatedTriggers[1]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should move focus with ArrowLeft', () => {
|
|
83
|
+
const triggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
|
|
84
|
+
(triggers[1] as HTMLElement).focus();
|
|
85
|
+
triggers[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
|
86
|
+
fixture.detectChanges();
|
|
87
|
+
const updatedTriggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
|
|
88
|
+
expect(document.activeElement).toBe(updatedTriggers[0]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should move focus to first with Home', () => {
|
|
92
|
+
const triggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
|
|
93
|
+
(triggers[1] as HTMLElement).focus();
|
|
94
|
+
triggers[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
|
|
95
|
+
fixture.detectChanges();
|
|
96
|
+
const updatedTriggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
|
|
97
|
+
expect(document.activeElement).toBe(updatedTriggers[0]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should move focus to last with End', () => {
|
|
101
|
+
const triggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
|
|
102
|
+
(triggers[0] as HTMLElement).focus();
|
|
103
|
+
triggers[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
|
|
104
|
+
fixture.detectChanges();
|
|
105
|
+
const updatedTriggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
|
|
106
|
+
expect(document.activeElement).toBe(updatedTriggers[1]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
@Component({
|
|
111
|
+
standalone: true,
|
|
112
|
+
imports: [SnyTabsDirective, SnyTabsListDirective, SnyTabsTriggerDirective, SnyTabsContentDirective],
|
|
113
|
+
template: `
|
|
114
|
+
<div snyTabs #t="snyTabs" [(value)]="activeTab">
|
|
115
|
+
<div snyTabsList>
|
|
116
|
+
<button snyTabsTrigger value="tab1">Tab 1</button>
|
|
117
|
+
</div>
|
|
118
|
+
<div snyTabsContent value="tab1">Content 1</div>
|
|
119
|
+
</div>
|
|
120
|
+
`,
|
|
121
|
+
})
|
|
122
|
+
class ExportAsHost {
|
|
123
|
+
activeTab = signal('tab1');
|
|
124
|
+
tabs = viewChild<SnyTabsDirective>('t');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
describe('Tabs exportAs', () => {
|
|
128
|
+
it('should expose snyTabs via template ref', async () => {
|
|
129
|
+
await TestBed.configureTestingModule({ imports: [ExportAsHost] }).compileComponents();
|
|
130
|
+
const fixture = TestBed.createComponent(ExportAsHost);
|
|
131
|
+
fixture.detectChanges();
|
|
132
|
+
const ref = fixture.componentInstance.tabs();
|
|
133
|
+
expect(ref).toBeTruthy();
|
|
134
|
+
expect(ref!.value()).toBe('tab1');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Directive, ElementRef, computed, inject, input, model, InjectionToken } from '@angular/core';
|
|
2
|
+
import { cn } from '../core/utils/cn';
|
|
3
|
+
|
|
4
|
+
export const SNY_TABS = new InjectionToken<SnyTabsDirective>('SnyTabs');
|
|
5
|
+
|
|
6
|
+
@Directive({
|
|
7
|
+
selector: '[snyTabs]',
|
|
8
|
+
standalone: true,
|
|
9
|
+
exportAs: 'snyTabs',
|
|
10
|
+
providers: [{ provide: SNY_TABS, useExisting: SnyTabsDirective }],
|
|
11
|
+
host: { '[class]': 'computedClass()' },
|
|
12
|
+
})
|
|
13
|
+
export class SnyTabsDirective {
|
|
14
|
+
readonly value = model<string>('');
|
|
15
|
+
readonly class = input<string>('');
|
|
16
|
+
|
|
17
|
+
protected readonly computedClass = computed(() =>
|
|
18
|
+
cn('', this.class())
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
select(value: string): void {
|
|
22
|
+
this.value.set(value);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@Directive({
|
|
27
|
+
selector: '[snyTabsList]',
|
|
28
|
+
standalone: true,
|
|
29
|
+
host: {
|
|
30
|
+
role: 'tablist',
|
|
31
|
+
'[class]': 'computedClass()',
|
|
32
|
+
'(keydown)': 'onKeydown($event)',
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
export class SnyTabsListDirective {
|
|
36
|
+
readonly class = input<string>('');
|
|
37
|
+
private readonly elRef = inject(ElementRef);
|
|
38
|
+
|
|
39
|
+
protected readonly computedClass = computed(() =>
|
|
40
|
+
cn(
|
|
41
|
+
'inline-flex h-10 items-center justify-center rounded-sm bg-muted p-1 text-muted-foreground',
|
|
42
|
+
this.class()
|
|
43
|
+
)
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
onKeydown(event: KeyboardEvent): void {
|
|
47
|
+
const triggers = Array.from(
|
|
48
|
+
(this.elRef.nativeElement as HTMLElement).querySelectorAll<HTMLElement>('[role="tab"]')
|
|
49
|
+
);
|
|
50
|
+
if (triggers.length === 0) return;
|
|
51
|
+
|
|
52
|
+
const currentIndex = triggers.indexOf(document.activeElement as HTMLElement);
|
|
53
|
+
if (currentIndex === -1) return;
|
|
54
|
+
|
|
55
|
+
let nextIndex: number | null = null;
|
|
56
|
+
switch (event.key) {
|
|
57
|
+
case 'ArrowRight':
|
|
58
|
+
case 'ArrowDown':
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
nextIndex = (currentIndex + 1) % triggers.length;
|
|
61
|
+
break;
|
|
62
|
+
case 'ArrowLeft':
|
|
63
|
+
case 'ArrowUp':
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
nextIndex = (currentIndex - 1 + triggers.length) % triggers.length;
|
|
66
|
+
break;
|
|
67
|
+
case 'Home':
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
nextIndex = 0;
|
|
70
|
+
break;
|
|
71
|
+
case 'End':
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
nextIndex = triggers.length - 1;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
if (nextIndex !== null) {
|
|
77
|
+
triggers[nextIndex].focus();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@Directive({
|
|
83
|
+
selector: '[snyTabsTrigger]',
|
|
84
|
+
standalone: true,
|
|
85
|
+
host: {
|
|
86
|
+
role: 'tab',
|
|
87
|
+
'[class]': 'computedClass()',
|
|
88
|
+
'[attr.aria-selected]': 'isActive()',
|
|
89
|
+
'[attr.tabindex]': 'isActive() ? 0 : -1',
|
|
90
|
+
'(click)': 'tabs.select(value())',
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
export class SnyTabsTriggerDirective {
|
|
94
|
+
readonly value = input.required<string>();
|
|
95
|
+
readonly class = input<string>('');
|
|
96
|
+
readonly tabs = inject(SNY_TABS);
|
|
97
|
+
|
|
98
|
+
readonly isActive = computed(() => this.tabs.value() === this.value());
|
|
99
|
+
|
|
100
|
+
protected readonly computedClass = computed(() =>
|
|
101
|
+
cn(
|
|
102
|
+
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer',
|
|
103
|
+
this.isActive()
|
|
104
|
+
? 'bg-background text-foreground shadow-sm'
|
|
105
|
+
: 'hover:bg-background/50',
|
|
106
|
+
this.class()
|
|
107
|
+
)
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@Directive({
|
|
112
|
+
selector: '[snyTabsContent]',
|
|
113
|
+
standalone: true,
|
|
114
|
+
host: {
|
|
115
|
+
role: 'tabpanel',
|
|
116
|
+
'[class]': 'computedClass()',
|
|
117
|
+
'[style.display]': 'isActive() ? null : "none"',
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
export class SnyTabsContentDirective {
|
|
121
|
+
readonly value = input.required<string>();
|
|
122
|
+
readonly class = input<string>('');
|
|
123
|
+
private readonly tabs = inject(SNY_TABS);
|
|
124
|
+
|
|
125
|
+
readonly isActive = computed(() => this.tabs.value() === this.value());
|
|
126
|
+
|
|
127
|
+
protected readonly computedClass = computed(() =>
|
|
128
|
+
cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', this.class())
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
export const tabsListVariants = cva(
|
|
4
|
+
'inline-flex h-10 items-center justify-center rounded-sm bg-muted p-1 text-muted-foreground',
|
|
5
|
+
{
|
|
6
|
+
variants: {},
|
|
7
|
+
defaultVariants: {},
|
|
8
|
+
}
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
export const tabsTriggerVariants = cva(
|
|
12
|
+
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
|
13
|
+
{
|
|
14
|
+
variants: {},
|
|
15
|
+
defaultVariants: {},
|
|
16
|
+
}
|
|
17
|
+
);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import { SnyTextareaDirective } from './textarea.directive';
|
|
4
|
+
import type { TextareaVariant, TextareaSize, TextareaResize } from './textarea.variants';
|
|
5
|
+
|
|
6
|
+
@Component({
|
|
7
|
+
standalone: true,
|
|
8
|
+
imports: [SnyTextareaDirective],
|
|
9
|
+
template: `
|
|
10
|
+
<textarea
|
|
11
|
+
snyTextarea
|
|
12
|
+
[variant]="variant()"
|
|
13
|
+
[textareaSize]="textareaSize()"
|
|
14
|
+
[resize]="resize()"
|
|
15
|
+
[autoResize]="autoResize()"
|
|
16
|
+
></textarea>
|
|
17
|
+
`,
|
|
18
|
+
})
|
|
19
|
+
class TestHostComponent {
|
|
20
|
+
variant = signal<TextareaVariant>('default');
|
|
21
|
+
textareaSize = signal<TextareaSize>('md');
|
|
22
|
+
resize = signal<TextareaResize>('vertical');
|
|
23
|
+
autoResize = signal(false);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('SnyTextareaDirective', () => {
|
|
27
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
28
|
+
let el: HTMLTextAreaElement;
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
await TestBed.configureTestingModule({
|
|
32
|
+
imports: [TestHostComponent],
|
|
33
|
+
}).compileComponents();
|
|
34
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
35
|
+
fixture.detectChanges();
|
|
36
|
+
el = fixture.nativeElement.querySelector('textarea');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should apply default variant classes', () => {
|
|
40
|
+
expect(el.className).toContain('border-input');
|
|
41
|
+
expect(el.className).toContain('rounded-md');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should apply error variant classes', () => {
|
|
45
|
+
fixture.componentInstance.variant.set('error');
|
|
46
|
+
fixture.detectChanges();
|
|
47
|
+
expect(el.className).toContain('border-destructive');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should set aria-invalid for error variant', () => {
|
|
51
|
+
expect(el.getAttribute('aria-invalid')).toBeNull();
|
|
52
|
+
fixture.componentInstance.variant.set('error');
|
|
53
|
+
fixture.detectChanges();
|
|
54
|
+
expect(el.getAttribute('aria-invalid')).toBe('true');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should apply small size', () => {
|
|
58
|
+
fixture.componentInstance.textareaSize.set('sm');
|
|
59
|
+
fixture.detectChanges();
|
|
60
|
+
expect(el.className).toContain('text-xs');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should apply large size', () => {
|
|
64
|
+
fixture.componentInstance.textareaSize.set('lg');
|
|
65
|
+
fixture.detectChanges();
|
|
66
|
+
expect(el.className).toContain('text-base');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should apply resize-none when resize is "none"', () => {
|
|
70
|
+
fixture.componentInstance.resize.set('none');
|
|
71
|
+
fixture.detectChanges();
|
|
72
|
+
expect(el.className).toContain('resize-none');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should apply resize-y by default', () => {
|
|
76
|
+
expect(el.className).toContain('resize-y');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should force resize-none when autoResize is true', () => {
|
|
80
|
+
fixture.componentInstance.autoResize.set(true);
|
|
81
|
+
fixture.detectChanges();
|
|
82
|
+
expect(el.className).toContain('resize-none');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Directive,
|
|
3
|
+
computed,
|
|
4
|
+
input,
|
|
5
|
+
effect,
|
|
6
|
+
inject,
|
|
7
|
+
ElementRef,
|
|
8
|
+
afterNextRender,
|
|
9
|
+
} from '@angular/core';
|
|
10
|
+
import { cn } from '../core/utils/cn';
|
|
11
|
+
import {
|
|
12
|
+
textareaVariants,
|
|
13
|
+
type TextareaVariant,
|
|
14
|
+
type TextareaSize,
|
|
15
|
+
type TextareaResize,
|
|
16
|
+
} from './textarea.variants';
|
|
17
|
+
|
|
18
|
+
@Directive({
|
|
19
|
+
selector: 'textarea[snyTextarea]',
|
|
20
|
+
standalone: true,
|
|
21
|
+
host: {
|
|
22
|
+
'[class]': 'computedClass()',
|
|
23
|
+
'[attr.aria-invalid]': 'variant() === "error" || null',
|
|
24
|
+
'(input)': 'onInput()',
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
export class SnyTextareaDirective {
|
|
28
|
+
readonly variant = input<TextareaVariant>('default');
|
|
29
|
+
readonly textareaSize = input<TextareaSize>('md');
|
|
30
|
+
readonly resize = input<TextareaResize>('vertical');
|
|
31
|
+
readonly autoResize = input(false);
|
|
32
|
+
readonly class = input<string>('');
|
|
33
|
+
|
|
34
|
+
private readonly el = inject(ElementRef<HTMLTextAreaElement>);
|
|
35
|
+
|
|
36
|
+
protected readonly computedClass = computed(() =>
|
|
37
|
+
cn(
|
|
38
|
+
textareaVariants({
|
|
39
|
+
variant: this.variant(),
|
|
40
|
+
textareaSize: this.textareaSize(),
|
|
41
|
+
resize: this.autoResize() ? 'none' : this.resize(),
|
|
42
|
+
}),
|
|
43
|
+
this.class()
|
|
44
|
+
)
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
constructor() {
|
|
48
|
+
afterNextRender(() => {
|
|
49
|
+
if (this.autoResize()) {
|
|
50
|
+
this.adjustHeight();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
effect(() => {
|
|
55
|
+
if (this.autoResize()) {
|
|
56
|
+
this.adjustHeight();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
protected onInput(): void {
|
|
62
|
+
if (this.autoResize()) {
|
|
63
|
+
this.adjustHeight();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private adjustHeight(): void {
|
|
68
|
+
const textarea = this.el.nativeElement;
|
|
69
|
+
textarea.style.height = 'auto';
|
|
70
|
+
textarea.style.height = `${textarea.scrollHeight}px`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
export const textareaVariants = cva(
|
|
4
|
+
'flex w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background 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:
|
|
10
|
+
'border-destructive focus-visible:ring-destructive text-destructive placeholder:text-destructive/60',
|
|
11
|
+
},
|
|
12
|
+
textareaSize: {
|
|
13
|
+
sm: 'min-h-[60px] text-xs',
|
|
14
|
+
md: 'min-h-[80px] text-sm',
|
|
15
|
+
lg: 'min-h-[120px] text-base',
|
|
16
|
+
},
|
|
17
|
+
resize: {
|
|
18
|
+
none: 'resize-none',
|
|
19
|
+
vertical: 'resize-y',
|
|
20
|
+
horizontal: 'resize-x',
|
|
21
|
+
both: 'resize',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
defaultVariants: {
|
|
25
|
+
variant: 'default',
|
|
26
|
+
textareaSize: 'md',
|
|
27
|
+
resize: 'vertical',
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
export type TextareaVariant = 'default' | 'error';
|
|
33
|
+
export type TextareaSize = 'sm' | 'md' | 'lg';
|
|
34
|
+
export type TextareaResize = 'none' | 'vertical' | 'horizontal' | 'both';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export {
|
|
2
|
+
SnyTimelineDirective,
|
|
3
|
+
SnyTimelineItemDirective,
|
|
4
|
+
SnyTimelineStartDirective,
|
|
5
|
+
SnyTimelineMiddleDirective,
|
|
6
|
+
SnyTimelineEndDirective,
|
|
7
|
+
SNY_TIMELINE,
|
|
8
|
+
type TimelineOrientation,
|
|
9
|
+
type TimelineConnect,
|
|
10
|
+
type TimelineMiddleVariant,
|
|
11
|
+
} from './timeline.directives';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Component } from '@angular/core';
|
|
2
|
+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import {
|
|
4
|
+
SnyTimelineDirective,
|
|
5
|
+
SnyTimelineItemDirective,
|
|
6
|
+
SnyTimelineStartDirective,
|
|
7
|
+
SnyTimelineMiddleDirective,
|
|
8
|
+
SnyTimelineEndDirective,
|
|
9
|
+
} from './timeline.directives';
|
|
10
|
+
|
|
11
|
+
@Component({
|
|
12
|
+
standalone: true,
|
|
13
|
+
imports: [SnyTimelineDirective, SnyTimelineItemDirective, SnyTimelineStartDirective, SnyTimelineMiddleDirective, SnyTimelineEndDirective],
|
|
14
|
+
template: `
|
|
15
|
+
<div snyTimeline>
|
|
16
|
+
<div snyTimelineItem>
|
|
17
|
+
<div snyTimelineStart>2024</div>
|
|
18
|
+
<div snyTimelineMiddle variant="primary"></div>
|
|
19
|
+
<div snyTimelineEnd>Event 1</div>
|
|
20
|
+
</div>
|
|
21
|
+
<div snyTimelineItem>
|
|
22
|
+
<div snyTimelineStart>2025</div>
|
|
23
|
+
<div snyTimelineMiddle></div>
|
|
24
|
+
<div snyTimelineEnd>Event 2</div>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
`,
|
|
28
|
+
})
|
|
29
|
+
class TestHostComponent {}
|
|
30
|
+
|
|
31
|
+
describe('SnyTimelineDirective', () => {
|
|
32
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
33
|
+
|
|
34
|
+
beforeEach(async () => {
|
|
35
|
+
await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
|
|
36
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
37
|
+
fixture.detectChanges();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should render with list role', () => {
|
|
41
|
+
const timeline = fixture.nativeElement.querySelector('[snyTimeline]');
|
|
42
|
+
expect(timeline.getAttribute('role')).toBe('list');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should render items with listitem role', () => {
|
|
46
|
+
const items = fixture.nativeElement.querySelectorAll('[snyTimelineItem]');
|
|
47
|
+
expect(items.length).toBe(2);
|
|
48
|
+
expect(items[0].getAttribute('role')).toBe('listitem');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should mark middle as aria-hidden', () => {
|
|
52
|
+
const middles = fixture.nativeElement.querySelectorAll('[snyTimelineMiddle]');
|
|
53
|
+
expect(middles[0].getAttribute('aria-hidden')).toBe('true');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Directive, InjectionToken, computed, contentChildren, input } from '@angular/core';
|
|
2
|
+
import { cn } from '../core/utils/cn';
|
|
3
|
+
|
|
4
|
+
export const SNY_TIMELINE = new InjectionToken<SnyTimelineDirective>('SnyTimeline');
|
|
5
|
+
export type TimelineOrientation = 'vertical' | 'horizontal';
|
|
6
|
+
export type TimelineConnect = 'start' | 'end' | 'both' | 'none';
|
|
7
|
+
export type TimelineMiddleVariant = 'default' | 'primary' | 'success' | 'error';
|
|
8
|
+
|
|
9
|
+
@Directive({
|
|
10
|
+
selector: '[snyTimelineItem]',
|
|
11
|
+
standalone: true,
|
|
12
|
+
host: { 'role': 'listitem', '[class]': 'computedClass()' },
|
|
13
|
+
})
|
|
14
|
+
export class SnyTimelineItemDirective {
|
|
15
|
+
readonly connect = input<TimelineConnect>('both');
|
|
16
|
+
readonly class = input<string>('');
|
|
17
|
+
protected readonly computedClass = computed(() =>
|
|
18
|
+
cn('relative flex gap-4', this.class())
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@Directive({
|
|
23
|
+
selector: '[snyTimelineStart]',
|
|
24
|
+
standalone: true,
|
|
25
|
+
host: { '[class]': 'computedClass()' },
|
|
26
|
+
})
|
|
27
|
+
export class SnyTimelineStartDirective {
|
|
28
|
+
readonly class = input<string>('');
|
|
29
|
+
protected readonly computedClass = computed(() =>
|
|
30
|
+
cn('text-sm text-muted-foreground min-w-[80px] text-right', this.class())
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Directive({
|
|
35
|
+
selector: '[snyTimelineMiddle]',
|
|
36
|
+
standalone: true,
|
|
37
|
+
host: { '[class]': 'computedClass()', 'aria-hidden': 'true' },
|
|
38
|
+
})
|
|
39
|
+
export class SnyTimelineMiddleDirective {
|
|
40
|
+
readonly variant = input<TimelineMiddleVariant>('default');
|
|
41
|
+
readonly class = input<string>('');
|
|
42
|
+
protected readonly computedClass = computed(() => {
|
|
43
|
+
const v = this.variant();
|
|
44
|
+
const variantClass =
|
|
45
|
+
v === 'primary' ? 'bg-primary' :
|
|
46
|
+
v === 'success' ? 'bg-green-600 dark:bg-green-500' :
|
|
47
|
+
v === 'error' ? 'bg-destructive' :
|
|
48
|
+
'bg-border';
|
|
49
|
+
return cn('flex flex-col items-center', variantClass, this.class());
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@Directive({
|
|
54
|
+
selector: '[snyTimelineEnd]',
|
|
55
|
+
standalone: true,
|
|
56
|
+
host: { '[class]': 'computedClass()' },
|
|
57
|
+
})
|
|
58
|
+
export class SnyTimelineEndDirective {
|
|
59
|
+
readonly class = input<string>('');
|
|
60
|
+
protected readonly computedClass = computed(() =>
|
|
61
|
+
cn('flex-1 pb-8', this.class())
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@Directive({
|
|
66
|
+
selector: '[snyTimeline]',
|
|
67
|
+
standalone: true,
|
|
68
|
+
exportAs: 'snyTimeline',
|
|
69
|
+
providers: [{ provide: SNY_TIMELINE, useExisting: SnyTimelineDirective }],
|
|
70
|
+
host: {
|
|
71
|
+
'role': 'list',
|
|
72
|
+
'aria-label': 'Timeline',
|
|
73
|
+
'[class]': 'computedClass()',
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
export class SnyTimelineDirective {
|
|
77
|
+
readonly orientation = input<TimelineOrientation>('vertical');
|
|
78
|
+
readonly class = input<string>('');
|
|
79
|
+
|
|
80
|
+
readonly items = contentChildren(SnyTimelineItemDirective);
|
|
81
|
+
|
|
82
|
+
protected readonly computedClass = computed(() => {
|
|
83
|
+
const o = this.orientation();
|
|
84
|
+
return cn(
|
|
85
|
+
'relative',
|
|
86
|
+
o === 'vertical' ? 'flex flex-col' : 'flex flex-row',
|
|
87
|
+
this.class()
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
}
|