@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,57 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { TestBed, ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import { SnyCheckboxDirective } from './checkbox.directive';
|
|
4
|
+
import type { CheckboxSize } from './checkbox.variants';
|
|
5
|
+
|
|
6
|
+
@Component({
|
|
7
|
+
standalone: true,
|
|
8
|
+
imports: [SnyCheckboxDirective],
|
|
9
|
+
template: `<input type="checkbox" snyCheckbox [size]="size()" />`,
|
|
10
|
+
})
|
|
11
|
+
class TestHostComponent {
|
|
12
|
+
size = signal<CheckboxSize>('md');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('SnyCheckboxDirective', () => {
|
|
16
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
17
|
+
let el: HTMLInputElement;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
await TestBed.configureTestingModule({
|
|
21
|
+
imports: [TestHostComponent],
|
|
22
|
+
}).compileComponents();
|
|
23
|
+
|
|
24
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
25
|
+
fixture.detectChanges();
|
|
26
|
+
el = fixture.nativeElement.querySelector('input[type="checkbox"]');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should apply default classes', () => {
|
|
30
|
+
expect(el.className).toContain('appearance-none');
|
|
31
|
+
expect(el.className).toContain('rounded-sm');
|
|
32
|
+
expect(el.className).toContain('border');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should apply default md size', () => {
|
|
36
|
+
expect(el.className).toContain('h-4');
|
|
37
|
+
expect(el.className).toContain('w-4');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should apply sm size', () => {
|
|
41
|
+
fixture.componentInstance.size.set('sm');
|
|
42
|
+
fixture.detectChanges();
|
|
43
|
+
expect(el.className).toContain('h-3.5');
|
|
44
|
+
expect(el.className).toContain('w-3.5');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should apply lg size', () => {
|
|
48
|
+
fixture.componentInstance.size.set('lg');
|
|
49
|
+
fixture.detectChanges();
|
|
50
|
+
expect(el.className).toContain('h-5');
|
|
51
|
+
expect(el.className).toContain('w-5');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should have checked styles in class list', () => {
|
|
55
|
+
expect(el.className).toContain('checked:bg-primary');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Directive, computed, input } from '@angular/core';
|
|
2
|
+
import { cn } from '../core/utils/cn';
|
|
3
|
+
import { checkboxVariants, type CheckboxSize } from './checkbox.variants';
|
|
4
|
+
|
|
5
|
+
@Directive({
|
|
6
|
+
selector: 'input[type="checkbox"][snyCheckbox]',
|
|
7
|
+
standalone: true,
|
|
8
|
+
host: { '[class]': 'computedClass()' },
|
|
9
|
+
})
|
|
10
|
+
export class SnyCheckboxDirective {
|
|
11
|
+
readonly size = input<CheckboxSize>('md');
|
|
12
|
+
readonly class = input<string>('');
|
|
13
|
+
|
|
14
|
+
protected readonly computedClass = computed(() =>
|
|
15
|
+
cn(checkboxVariants({ size: this.size() }), this.class())
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
export const checkboxVariants = cva(
|
|
4
|
+
'peer appearance-none shrink-0 rounded-sm border border-border bg-background transition-colors checked:bg-primary checked:border-primary 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
|
+
size: {
|
|
8
|
+
sm: 'h-3.5 w-3.5',
|
|
9
|
+
md: 'h-4 w-4',
|
|
10
|
+
lg: 'h-5 w-5',
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
defaultVariants: {
|
|
14
|
+
size: 'md',
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export type CheckboxSize = 'sm' | 'md' | 'lg';
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
|
3
|
+
import { TestBed, ComponentFixture } from '@angular/core/testing';
|
|
4
|
+
import { SnyComboboxComponent, type ComboboxOption } from './combobox.component';
|
|
5
|
+
|
|
6
|
+
@Component({
|
|
7
|
+
standalone: true,
|
|
8
|
+
imports: [SnyComboboxComponent],
|
|
9
|
+
template: `<sny-combobox [options]="options" [(value)]="value" placeholder="Select..." searchPlaceholder="Search..." />`,
|
|
10
|
+
})
|
|
11
|
+
class TestHostComponent {
|
|
12
|
+
value = signal('');
|
|
13
|
+
options: ComboboxOption[] = [
|
|
14
|
+
{ value: 'us', label: 'United States' },
|
|
15
|
+
{ value: 'uk', label: 'United Kingdom' },
|
|
16
|
+
{ value: 'ca', label: 'Canada' },
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('SnyComboboxComponent', () => {
|
|
21
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
22
|
+
let el: HTMLElement;
|
|
23
|
+
let trigger: HTMLButtonElement;
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
await TestBed.configureTestingModule({
|
|
27
|
+
imports: [TestHostComponent],
|
|
28
|
+
}).compileComponents();
|
|
29
|
+
|
|
30
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
31
|
+
fixture.detectChanges();
|
|
32
|
+
el = fixture.nativeElement.querySelector('sny-combobox');
|
|
33
|
+
trigger = el.querySelector('button')!;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should render the trigger button', () => {
|
|
37
|
+
expect(trigger).toBeTruthy();
|
|
38
|
+
expect(trigger.getAttribute('role')).toBe('combobox');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should show placeholder when no value selected', () => {
|
|
42
|
+
expect(trigger.textContent).toContain('Select...');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should set aria-expanded=false initially', () => {
|
|
46
|
+
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should open dropdown on trigger click', () => {
|
|
50
|
+
trigger.click();
|
|
51
|
+
fixture.detectChanges();
|
|
52
|
+
expect(trigger.getAttribute('aria-expanded')).toBe('true');
|
|
53
|
+
const listbox = el.querySelector('[role="listbox"]');
|
|
54
|
+
expect(listbox).toBeTruthy();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should show search input in dropdown', () => {
|
|
58
|
+
trigger.click();
|
|
59
|
+
fixture.detectChanges();
|
|
60
|
+
const searchInput = el.querySelector('input[type="text"]') as HTMLInputElement;
|
|
61
|
+
expect(searchInput).toBeTruthy();
|
|
62
|
+
expect(searchInput.getAttribute('placeholder')).toBe('Search...');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should show all options when no query', () => {
|
|
66
|
+
trigger.click();
|
|
67
|
+
fixture.detectChanges();
|
|
68
|
+
const options = el.querySelectorAll('[role="option"]');
|
|
69
|
+
expect(options.length).toBe(3);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should select option on click and show label', () => {
|
|
73
|
+
trigger.click();
|
|
74
|
+
fixture.detectChanges();
|
|
75
|
+
|
|
76
|
+
const option = el.querySelector('[role="option"]') as HTMLElement;
|
|
77
|
+
option.dispatchEvent(new Event('mousedown'));
|
|
78
|
+
fixture.detectChanges();
|
|
79
|
+
|
|
80
|
+
expect(fixture.componentInstance.value()).toBe('us');
|
|
81
|
+
expect(trigger.textContent).toContain('United States');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should close dropdown after selecting', () => {
|
|
85
|
+
trigger.click();
|
|
86
|
+
fixture.detectChanges();
|
|
87
|
+
|
|
88
|
+
const option = el.querySelector('[role="option"]') as HTMLElement;
|
|
89
|
+
option.dispatchEvent(new Event('mousedown'));
|
|
90
|
+
fixture.detectChanges();
|
|
91
|
+
|
|
92
|
+
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
@Component({
|
|
97
|
+
standalone: true,
|
|
98
|
+
imports: [ReactiveFormsModule, SnyComboboxComponent],
|
|
99
|
+
template: `<sny-combobox [options]="options" [formControl]="ctrl" placeholder="Select..." />`,
|
|
100
|
+
})
|
|
101
|
+
class ReactiveFormHost {
|
|
102
|
+
options: ComboboxOption[] = [
|
|
103
|
+
{ value: 'us', label: 'United States' },
|
|
104
|
+
{ value: 'uk', label: 'United Kingdom' },
|
|
105
|
+
{ value: 'ca', label: 'Canada' },
|
|
106
|
+
];
|
|
107
|
+
ctrl = new FormControl('');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe('SnyComboboxComponent — Reactive Forms', () => {
|
|
111
|
+
let fixture: ComponentFixture<ReactiveFormHost>;
|
|
112
|
+
let el: HTMLElement;
|
|
113
|
+
let trigger: HTMLButtonElement;
|
|
114
|
+
|
|
115
|
+
beforeEach(async () => {
|
|
116
|
+
await TestBed.configureTestingModule({
|
|
117
|
+
imports: [ReactiveFormHost],
|
|
118
|
+
}).compileComponents();
|
|
119
|
+
fixture = TestBed.createComponent(ReactiveFormHost);
|
|
120
|
+
fixture.detectChanges();
|
|
121
|
+
el = fixture.nativeElement.querySelector('sny-combobox');
|
|
122
|
+
trigger = el.querySelector('button')!;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should update view when FormControl value changes (writeValue)', () => {
|
|
126
|
+
fixture.componentInstance.ctrl.setValue('uk');
|
|
127
|
+
fixture.detectChanges();
|
|
128
|
+
expect(trigger.textContent).toContain('United Kingdom');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should update FormControl when user interacts (onChange)', () => {
|
|
132
|
+
trigger.click();
|
|
133
|
+
fixture.detectChanges();
|
|
134
|
+
const option = el.querySelector('[role="option"]') as HTMLElement;
|
|
135
|
+
option.dispatchEvent(new Event('mousedown'));
|
|
136
|
+
fixture.detectChanges();
|
|
137
|
+
expect(fixture.componentInstance.ctrl.value).toBe('us');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should handle FormControl.disable() (setDisabledState)', () => {
|
|
141
|
+
fixture.componentInstance.ctrl.disable();
|
|
142
|
+
fixture.detectChanges();
|
|
143
|
+
expect(fixture.componentInstance.ctrl.disabled).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should mark as touched on blur (onTouched)', () => {
|
|
147
|
+
expect(fixture.componentInstance.ctrl.touched).toBe(false);
|
|
148
|
+
trigger.dispatchEvent(new Event('blur'));
|
|
149
|
+
expect(fixture.componentInstance.ctrl.touched).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
computed,
|
|
5
|
+
effect,
|
|
6
|
+
ElementRef,
|
|
7
|
+
forwardRef,
|
|
8
|
+
HostListener,
|
|
9
|
+
inject,
|
|
10
|
+
input,
|
|
11
|
+
model,
|
|
12
|
+
OnDestroy,
|
|
13
|
+
signal,
|
|
14
|
+
viewChild,
|
|
15
|
+
} from '@angular/core';
|
|
16
|
+
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
|
17
|
+
import { cn } from '../core/utils/cn';
|
|
18
|
+
import { comboboxTriggerVariants, type ComboboxSize } from './combobox.variants';
|
|
19
|
+
|
|
20
|
+
export interface ComboboxOption {
|
|
21
|
+
value: string;
|
|
22
|
+
label: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@Component({
|
|
26
|
+
selector: 'sny-combobox',
|
|
27
|
+
standalone: true,
|
|
28
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
29
|
+
host: {
|
|
30
|
+
class: 'relative inline-block w-full',
|
|
31
|
+
},
|
|
32
|
+
providers: [
|
|
33
|
+
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyComboboxComponent), multi: true },
|
|
34
|
+
],
|
|
35
|
+
template: `
|
|
36
|
+
<!-- Trigger button -->
|
|
37
|
+
<button
|
|
38
|
+
#triggerEl
|
|
39
|
+
type="button"
|
|
40
|
+
role="combobox"
|
|
41
|
+
[attr.aria-expanded]="open()"
|
|
42
|
+
aria-haspopup="listbox"
|
|
43
|
+
[class]="triggerClass()"
|
|
44
|
+
(click)="toggle()"
|
|
45
|
+
(blur)="onTouched()"
|
|
46
|
+
>
|
|
47
|
+
<span [class]="selectedLabel() ? '' : 'text-muted-foreground'">
|
|
48
|
+
{{ selectedLabel() || placeholder() }}
|
|
49
|
+
</span>
|
|
50
|
+
<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>
|
|
51
|
+
</button>
|
|
52
|
+
|
|
53
|
+
<!-- Dropdown popover -->
|
|
54
|
+
@if (open()) {
|
|
55
|
+
<div
|
|
56
|
+
#dropdownEl
|
|
57
|
+
class="fixed z-50 rounded-sm border border-border bg-popover text-popover-foreground shadow-md"
|
|
58
|
+
>
|
|
59
|
+
<!-- Search input -->
|
|
60
|
+
<div class="flex items-center border-b border-border px-3">
|
|
61
|
+
<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>
|
|
62
|
+
<input
|
|
63
|
+
#searchEl
|
|
64
|
+
type="text"
|
|
65
|
+
class="flex h-10 w-full bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
|
|
66
|
+
[placeholder]="searchPlaceholder()"
|
|
67
|
+
[value]="query()"
|
|
68
|
+
(input)="onSearchInput($event)"
|
|
69
|
+
(keydown)="onKeydown($event)"
|
|
70
|
+
/>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<!-- Options list -->
|
|
74
|
+
@if (filtered().length > 0) {
|
|
75
|
+
<ul role="listbox" class="max-h-60 overflow-auto p-1">
|
|
76
|
+
@for (opt of filtered(); track opt.value; let i = $index) {
|
|
77
|
+
<li
|
|
78
|
+
role="option"
|
|
79
|
+
[id]="'sny-cb-opt-' + opt.value"
|
|
80
|
+
[attr.aria-selected]="value() === opt.value"
|
|
81
|
+
[class]="optionClass(i)"
|
|
82
|
+
(mousedown)="select(opt); $event.preventDefault()"
|
|
83
|
+
(mouseenter)="activeIndex.set(i)"
|
|
84
|
+
>
|
|
85
|
+
<svg
|
|
86
|
+
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
|
87
|
+
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
88
|
+
[class]="value() === opt.value ? 'mr-2 shrink-0 opacity-100' : 'mr-2 shrink-0 opacity-0'"
|
|
89
|
+
><path d="M20 6 9 17l-5-5"/></svg>
|
|
90
|
+
{{ opt.label }}
|
|
91
|
+
</li>
|
|
92
|
+
}
|
|
93
|
+
</ul>
|
|
94
|
+
} @else {
|
|
95
|
+
<div class="py-6 text-center text-sm text-muted-foreground">No results found.</div>
|
|
96
|
+
}
|
|
97
|
+
</div>
|
|
98
|
+
}
|
|
99
|
+
`,
|
|
100
|
+
})
|
|
101
|
+
export class SnyComboboxComponent implements ControlValueAccessor, OnDestroy {
|
|
102
|
+
readonly options = input<ComboboxOption[]>([]);
|
|
103
|
+
readonly placeholder = input('Select...');
|
|
104
|
+
readonly searchPlaceholder = input('Search...');
|
|
105
|
+
readonly size = input<ComboboxSize>('md');
|
|
106
|
+
readonly class = input<string>('');
|
|
107
|
+
readonly value = model<string>('');
|
|
108
|
+
|
|
109
|
+
readonly open = signal(false);
|
|
110
|
+
readonly query = signal('');
|
|
111
|
+
readonly activeIndex = signal(0);
|
|
112
|
+
|
|
113
|
+
private readonly _disabledByCva = signal(false);
|
|
114
|
+
|
|
115
|
+
private readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('triggerEl');
|
|
116
|
+
private readonly searchRef = viewChild<ElementRef<HTMLInputElement>>('searchEl');
|
|
117
|
+
private readonly dropdownRef = viewChild<ElementRef<HTMLDivElement>>('dropdownEl');
|
|
118
|
+
private readonly elRef = inject(ElementRef);
|
|
119
|
+
|
|
120
|
+
private scrollHandler: (() => void) | null = null;
|
|
121
|
+
private resizeHandler: (() => void) | null = null;
|
|
122
|
+
|
|
123
|
+
private _onChange: (value: string) => void = () => {};
|
|
124
|
+
protected onTouched: () => void = () => {};
|
|
125
|
+
private _writing = false;
|
|
126
|
+
|
|
127
|
+
constructor() {
|
|
128
|
+
effect(() => {
|
|
129
|
+
const val = this.value();
|
|
130
|
+
if (this._writing) {
|
|
131
|
+
this._writing = false;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
this._onChange(val);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
writeValue(val: string): void {
|
|
139
|
+
this._writing = true;
|
|
140
|
+
this.value.set(val ?? '');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
registerOnChange(fn: (value: string) => void): void {
|
|
144
|
+
this._onChange = fn;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
registerOnTouched(fn: () => void): void {
|
|
148
|
+
this.onTouched = fn;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
setDisabledState(_isDisabled: boolean): void {
|
|
152
|
+
this._disabledByCva.set(_isDisabled);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
readonly selectedLabel = computed(() => {
|
|
156
|
+
const v = this.value();
|
|
157
|
+
if (!v) return '';
|
|
158
|
+
const opt = this.options().find(o => o.value === v);
|
|
159
|
+
return opt?.label ?? '';
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
readonly filtered = computed(() => {
|
|
163
|
+
const q = this.query().toLowerCase();
|
|
164
|
+
if (!q) return this.options();
|
|
165
|
+
return this.options().filter(o => o.label.toLowerCase().includes(q));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
protected readonly triggerClass = computed(() =>
|
|
169
|
+
cn(comboboxTriggerVariants({ size: this.size() }), this.class())
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
optionClass(index: number): string {
|
|
173
|
+
const base = 'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors';
|
|
174
|
+
const active = index === this.activeIndex() ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50';
|
|
175
|
+
return cn(base, active);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private updateDropdownPosition(): void {
|
|
179
|
+
const trigger = this.triggerRef()?.nativeElement;
|
|
180
|
+
if (!trigger) return;
|
|
181
|
+
const rect = trigger.getBoundingClientRect();
|
|
182
|
+
const dropdown = this.dropdownRef()?.nativeElement;
|
|
183
|
+
if (dropdown) {
|
|
184
|
+
dropdown.style.top = `${rect.bottom + 4}px`;
|
|
185
|
+
dropdown.style.left = `${rect.left}px`;
|
|
186
|
+
dropdown.style.width = `${rect.width}px`;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private addGlobalListeners(): void {
|
|
191
|
+
this.removeGlobalListeners();
|
|
192
|
+
this.scrollHandler = () => {
|
|
193
|
+
requestAnimationFrame(() => this.updateDropdownPosition());
|
|
194
|
+
};
|
|
195
|
+
this.resizeHandler = () => {
|
|
196
|
+
requestAnimationFrame(() => this.updateDropdownPosition());
|
|
197
|
+
};
|
|
198
|
+
document.addEventListener('scroll', this.scrollHandler, { capture: true, passive: true });
|
|
199
|
+
window.addEventListener('resize', this.resizeHandler, { passive: true });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private removeGlobalListeners(): void {
|
|
203
|
+
if (this.scrollHandler) {
|
|
204
|
+
document.removeEventListener('scroll', this.scrollHandler, { capture: true } as EventListenerOptions);
|
|
205
|
+
this.scrollHandler = null;
|
|
206
|
+
}
|
|
207
|
+
if (this.resizeHandler) {
|
|
208
|
+
window.removeEventListener('resize', this.resizeHandler);
|
|
209
|
+
this.resizeHandler = null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
ngOnDestroy(): void {
|
|
214
|
+
this.removeGlobalListeners();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
toggle(): void {
|
|
218
|
+
if (this.open()) {
|
|
219
|
+
this.close();
|
|
220
|
+
} else {
|
|
221
|
+
this.updateDropdownPosition();
|
|
222
|
+
this.open.set(true);
|
|
223
|
+
this.query.set('');
|
|
224
|
+
this.activeIndex.set(0);
|
|
225
|
+
this.addGlobalListeners();
|
|
226
|
+
setTimeout(() => {
|
|
227
|
+
this.updateDropdownPosition();
|
|
228
|
+
this.searchRef()?.nativeElement.focus();
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
close(): void {
|
|
234
|
+
this.open.set(false);
|
|
235
|
+
this.query.set('');
|
|
236
|
+
this.removeGlobalListeners();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
onSearchInput(event: Event): void {
|
|
240
|
+
const val = (event.target as HTMLInputElement).value;
|
|
241
|
+
this.query.set(val);
|
|
242
|
+
this.activeIndex.set(0);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
select(opt: ComboboxOption): void {
|
|
246
|
+
this.value.set(opt.value);
|
|
247
|
+
this.close();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
onKeydown(event: KeyboardEvent): void {
|
|
251
|
+
const items = this.filtered();
|
|
252
|
+
switch (event.key) {
|
|
253
|
+
case 'ArrowDown':
|
|
254
|
+
event.preventDefault();
|
|
255
|
+
this.activeIndex.update(i => Math.min(i + 1, items.length - 1));
|
|
256
|
+
break;
|
|
257
|
+
case 'ArrowUp':
|
|
258
|
+
event.preventDefault();
|
|
259
|
+
this.activeIndex.update(i => Math.max(i - 1, 0));
|
|
260
|
+
break;
|
|
261
|
+
case 'Enter':
|
|
262
|
+
event.preventDefault();
|
|
263
|
+
if (items[this.activeIndex()]) {
|
|
264
|
+
this.select(items[this.activeIndex()]);
|
|
265
|
+
}
|
|
266
|
+
break;
|
|
267
|
+
case 'Escape':
|
|
268
|
+
this.close();
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
@HostListener('document:click', ['$event'])
|
|
274
|
+
onDocumentClick(event: MouseEvent): void {
|
|
275
|
+
if (!this.elRef.nativeElement.contains(event.target)) {
|
|
276
|
+
this.close();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -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,47 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import { SnyDiffComponent } from './diff.component';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
standalone: true,
|
|
7
|
+
imports: [SnyDiffComponent],
|
|
8
|
+
template: `
|
|
9
|
+
<sny-diff [(value)]="value">
|
|
10
|
+
<div snyDiffBefore>Before</div>
|
|
11
|
+
<div snyDiffAfter>After</div>
|
|
12
|
+
</sny-diff>
|
|
13
|
+
`,
|
|
14
|
+
})
|
|
15
|
+
class TestHostComponent {
|
|
16
|
+
value = signal(50);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('SnyDiffComponent', () => {
|
|
20
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
21
|
+
let host: HTMLElement;
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
|
|
25
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
26
|
+
fixture.detectChanges();
|
|
27
|
+
host = fixture.nativeElement.querySelector('sny-diff');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should render slider handle', () => {
|
|
31
|
+
const slider = host.querySelector('[role="slider"]');
|
|
32
|
+
expect(slider).not.toBeNull();
|
|
33
|
+
expect(slider!.getAttribute('aria-valuenow')).toBe('50');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should handle keyboard ArrowLeft', () => {
|
|
37
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
|
|
38
|
+
fixture.detectChanges();
|
|
39
|
+
expect(fixture.componentInstance.value()).toBe(45);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should handle keyboard ArrowRight', () => {
|
|
43
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
|
|
44
|
+
fixture.detectChanges();
|
|
45
|
+
expect(fixture.componentInstance.value()).toBe(55);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, input, model, signal } from '@angular/core';
|
|
2
|
+
import { cn } from '../core/utils/cn';
|
|
3
|
+
|
|
4
|
+
@Component({
|
|
5
|
+
selector: 'sny-diff',
|
|
6
|
+
standalone: true,
|
|
7
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
8
|
+
host: {
|
|
9
|
+
'[class]': '"relative overflow-hidden select-none w-full"',
|
|
10
|
+
'(pointerdown)': 'onPointerDown($event)',
|
|
11
|
+
'(pointermove)': 'onPointerMove($event)',
|
|
12
|
+
'(pointerup)': 'onPointerUp()',
|
|
13
|
+
'(keydown)': 'onKeydown($event)',
|
|
14
|
+
},
|
|
15
|
+
template: `
|
|
16
|
+
<div class="relative w-full" [style.aspect-ratio]="'16/9'">
|
|
17
|
+
<div class="absolute inset-0">
|
|
18
|
+
<ng-content select="[snyDiffAfter]" />
|
|
19
|
+
</div>
|
|
20
|
+
<div class="absolute inset-0 overflow-hidden" [style.width.%]="value()">
|
|
21
|
+
<ng-content select="[snyDiffBefore]" />
|
|
22
|
+
</div>
|
|
23
|
+
<div
|
|
24
|
+
class="absolute top-0 bottom-0 w-1 bg-foreground cursor-col-resize z-10"
|
|
25
|
+
[style.left.%]="value()"
|
|
26
|
+
role="slider"
|
|
27
|
+
tabindex="0"
|
|
28
|
+
[attr.aria-valuenow]="value()"
|
|
29
|
+
aria-valuemin="0"
|
|
30
|
+
aria-valuemax="100"
|
|
31
|
+
aria-label="Comparison slider"
|
|
32
|
+
>
|
|
33
|
+
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-foreground/80 flex items-center justify-center text-background text-xs">
|
|
34
|
+
⇔
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
`,
|
|
39
|
+
})
|
|
40
|
+
export class SnyDiffComponent {
|
|
41
|
+
readonly value = model(50);
|
|
42
|
+
readonly orientation = input<'horizontal' | 'vertical'>('horizontal');
|
|
43
|
+
readonly class = input<string>('');
|
|
44
|
+
|
|
45
|
+
readonly isDragging = signal(false);
|
|
46
|
+
|
|
47
|
+
onPointerDown(event: PointerEvent): void {
|
|
48
|
+
this.isDragging.set(true);
|
|
49
|
+
(event.target as HTMLElement).setPointerCapture?.(event.pointerId);
|
|
50
|
+
this.updateValue(event);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
onPointerMove(event: PointerEvent): void {
|
|
54
|
+
if (!this.isDragging()) return;
|
|
55
|
+
this.updateValue(event);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
onPointerUp(): void {
|
|
59
|
+
this.isDragging.set(false);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
onKeydown(event: KeyboardEvent): void {
|
|
63
|
+
switch (event.key) {
|
|
64
|
+
case 'ArrowLeft':
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
this.value.update((v) => Math.max(0, v - 5));
|
|
67
|
+
break;
|
|
68
|
+
case 'ArrowRight':
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
this.value.update((v) => Math.min(100, v + 5));
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private updateValue(event: PointerEvent): void {
|
|
76
|
+
const target = (event.currentTarget as HTMLElement).closest('sny-diff');
|
|
77
|
+
if (!target) return;
|
|
78
|
+
const rect = target.getBoundingClientRect();
|
|
79
|
+
const x = event.clientX - rect.left;
|
|
80
|
+
const pct = Math.max(0, Math.min(100, (x / rect.width) * 100));
|
|
81
|
+
this.value.set(Math.round(pct));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { SnyDiffComponent } from './diff.component';
|