@sonny-ui/core 0.1.0-alpha.1 → 0.1.0-alpha.10
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 +1 -1
- package/schematics/ng-generate/component/schema.json +1 -1
- 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,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';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import { SnyDividerComponent } from './divider.component';
|
|
4
|
+
import type { DividerOrientation } from './divider.variants';
|
|
5
|
+
|
|
6
|
+
@Component({
|
|
7
|
+
standalone: true,
|
|
8
|
+
imports: [SnyDividerComponent],
|
|
9
|
+
template: `<sny-divider [orientation]="orientation()" [label]="label()" />`,
|
|
10
|
+
})
|
|
11
|
+
class TestHostComponent {
|
|
12
|
+
orientation = signal<DividerOrientation>('horizontal');
|
|
13
|
+
label = signal('');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('SnyDividerComponent', () => {
|
|
17
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
18
|
+
let host: HTMLElement;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
|
|
22
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
23
|
+
fixture.detectChanges();
|
|
24
|
+
host = fixture.nativeElement.querySelector('sny-divider');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should have separator role', () => {
|
|
28
|
+
expect(host.getAttribute('role')).toBe('separator');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should set aria-orientation', () => {
|
|
32
|
+
expect(host.getAttribute('aria-orientation')).toBe('horizontal');
|
|
33
|
+
fixture.componentInstance.orientation.set('vertical');
|
|
34
|
+
fixture.detectChanges();
|
|
35
|
+
expect(host.getAttribute('aria-orientation')).toBe('vertical');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should render simple divider without label', () => {
|
|
39
|
+
const div = host.querySelector('div');
|
|
40
|
+
expect(div!.className).toContain('bg-border');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should render label when provided', () => {
|
|
44
|
+
fixture.componentInstance.label.set('OR');
|
|
45
|
+
fixture.detectChanges();
|
|
46
|
+
expect(host.textContent).toContain('OR');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
|
2
|
+
import { cn } from '../core/utils/cn';
|
|
3
|
+
import {
|
|
4
|
+
dividerVariants,
|
|
5
|
+
type DividerOrientation,
|
|
6
|
+
type DividerVariant,
|
|
7
|
+
} from './divider.variants';
|
|
8
|
+
|
|
9
|
+
@Component({
|
|
10
|
+
selector: 'sny-divider',
|
|
11
|
+
standalone: true,
|
|
12
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
13
|
+
host: {
|
|
14
|
+
'role': 'separator',
|
|
15
|
+
'[attr.aria-orientation]': 'orientation()',
|
|
16
|
+
},
|
|
17
|
+
template: `
|
|
18
|
+
@if (hasLabel()) {
|
|
19
|
+
<div [class]="labelContainerClass()">
|
|
20
|
+
<div [class]="lineClass()"></div>
|
|
21
|
+
<span class="px-2 text-xs text-muted-foreground">{{ label() }}</span>
|
|
22
|
+
<div [class]="lineClass()"></div>
|
|
23
|
+
</div>
|
|
24
|
+
} @else {
|
|
25
|
+
<div [class]="dividerClass()"></div>
|
|
26
|
+
}
|
|
27
|
+
`,
|
|
28
|
+
})
|
|
29
|
+
export class SnyDividerComponent {
|
|
30
|
+
readonly orientation = input<DividerOrientation>('horizontal');
|
|
31
|
+
readonly variant = input<DividerVariant>('solid');
|
|
32
|
+
readonly label = input<string>('');
|
|
33
|
+
readonly class = input<string>('');
|
|
34
|
+
|
|
35
|
+
readonly hasLabel = computed(() => !!this.label());
|
|
36
|
+
|
|
37
|
+
protected readonly dividerClass = computed(() =>
|
|
38
|
+
cn(dividerVariants({ orientation: this.orientation(), variant: this.variant() }), this.class())
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
protected readonly lineClass = computed(() =>
|
|
42
|
+
cn('flex-1 bg-border', this.orientation() === 'horizontal' ? 'h-[1px]' : 'w-[1px]')
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
protected readonly labelContainerClass = computed(() =>
|
|
46
|
+
cn(
|
|
47
|
+
'flex items-center',
|
|
48
|
+
this.orientation() === 'horizontal' ? 'flex-row' : 'flex-col',
|
|
49
|
+
this.class()
|
|
50
|
+
)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
export const dividerVariants = cva('shrink-0 bg-border', {
|
|
4
|
+
variants: {
|
|
5
|
+
orientation: {
|
|
6
|
+
horizontal: 'h-[1px] w-full',
|
|
7
|
+
vertical: 'h-full w-[1px]',
|
|
8
|
+
},
|
|
9
|
+
variant: {
|
|
10
|
+
solid: '',
|
|
11
|
+
dashed: 'border-dashed',
|
|
12
|
+
dotted: 'border-dotted',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
defaultVariants: {
|
|
16
|
+
orientation: 'horizontal',
|
|
17
|
+
variant: 'solid',
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type DividerOrientation = 'horizontal' | 'vertical';
|
|
22
|
+
export type DividerVariant = 'solid' | 'dashed' | 'dotted';
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import { SnyDockDirective, SnyDockItemDirective, type DockPosition } from './dock.directives';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
standalone: true,
|
|
7
|
+
imports: [SnyDockDirective, SnyDockItemDirective],
|
|
8
|
+
template: `
|
|
9
|
+
<div snyDock [position]="position()">
|
|
10
|
+
<button snyDockItem [active]="true">Home</button>
|
|
11
|
+
<button snyDockItem>Settings</button>
|
|
12
|
+
</div>
|
|
13
|
+
`,
|
|
14
|
+
})
|
|
15
|
+
class TestHostComponent {
|
|
16
|
+
position = signal<DockPosition>('bottom');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('SnyDockDirective', () => {
|
|
20
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
|
|
24
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
25
|
+
fixture.detectChanges();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should render with toolbar role', () => {
|
|
29
|
+
const dock = fixture.nativeElement.querySelector('[snyDock]');
|
|
30
|
+
expect(dock.getAttribute('role')).toBe('toolbar');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should position at bottom by default', () => {
|
|
34
|
+
const dock = fixture.nativeElement.querySelector('[snyDock]');
|
|
35
|
+
expect(dock.className).toContain('bottom-4');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should position at top', () => {
|
|
39
|
+
fixture.componentInstance.position.set('top');
|
|
40
|
+
fixture.detectChanges();
|
|
41
|
+
const dock = fixture.nativeElement.querySelector('[snyDock]');
|
|
42
|
+
expect(dock.className).toContain('top-4');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should apply active state to dock item', () => {
|
|
46
|
+
const items = fixture.nativeElement.querySelectorAll('[snyDockItem]');
|
|
47
|
+
expect(items[0].className).toContain('bg-primary');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should move focus with ArrowRight', () => {
|
|
51
|
+
const items = fixture.nativeElement.querySelectorAll('[snyDockItem]');
|
|
52
|
+
(items[0] as HTMLElement).focus();
|
|
53
|
+
items[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
|
54
|
+
fixture.detectChanges();
|
|
55
|
+
const updated = fixture.nativeElement.querySelectorAll('[snyDockItem]');
|
|
56
|
+
expect(document.activeElement).toBe(updated[1]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should move focus with ArrowLeft', () => {
|
|
60
|
+
const items = fixture.nativeElement.querySelectorAll('[snyDockItem]');
|
|
61
|
+
(items[1] as HTMLElement).focus();
|
|
62
|
+
items[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
|
63
|
+
fixture.detectChanges();
|
|
64
|
+
const updated = fixture.nativeElement.querySelectorAll('[snyDockItem]');
|
|
65
|
+
expect(document.activeElement).toBe(updated[0]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should move focus to first with Home', () => {
|
|
69
|
+
const items = fixture.nativeElement.querySelectorAll('[snyDockItem]');
|
|
70
|
+
(items[1] as HTMLElement).focus();
|
|
71
|
+
items[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
|
|
72
|
+
fixture.detectChanges();
|
|
73
|
+
const updated = fixture.nativeElement.querySelectorAll('[snyDockItem]');
|
|
74
|
+
expect(document.activeElement).toBe(updated[0]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should move focus to last with End', () => {
|
|
78
|
+
const items = fixture.nativeElement.querySelectorAll('[snyDockItem]');
|
|
79
|
+
(items[0] as HTMLElement).focus();
|
|
80
|
+
items[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
|
|
81
|
+
fixture.detectChanges();
|
|
82
|
+
const updated = fixture.nativeElement.querySelectorAll('[snyDockItem]');
|
|
83
|
+
expect(document.activeElement).toBe(updated[1]);
|
|
84
|
+
});
|
|
85
|
+
});
|