@sonny-ui/core 0.1.0-alpha.15 → 0.1.0-alpha.17
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 +109 -55
- package/fesm2022/sonny-ui-core.mjs +1987 -4
- package/fesm2022/sonny-ui-core.mjs.map +1 -1
- package/package.json +1 -1
- package/src/lib/avatar-group/avatar-group.component.spec.ts +74 -0
- package/src/lib/avatar-group/avatar-group.component.ts +89 -0
- package/src/lib/avatar-group/index.ts +1 -0
- package/src/lib/color-picker/color-picker.component.spec.ts +328 -0
- package/src/lib/color-picker/color-picker.component.ts +537 -0
- package/src/lib/color-picker/color-picker.types.ts +24 -0
- package/src/lib/color-picker/color-picker.utils.ts +183 -0
- package/src/lib/color-picker/color-picker.variants.ts +17 -0
- package/src/lib/color-picker/index.ts +20 -0
- package/src/lib/command-palette/command-palette.component.spec.ts +178 -0
- package/src/lib/command-palette/command-palette.component.ts +195 -0
- package/src/lib/command-palette/command-palette.service.ts +36 -0
- package/src/lib/command-palette/command-palette.types.ts +23 -0
- package/src/lib/command-palette/index.ts +7 -0
- package/src/lib/number-input/index.ts +2 -0
- package/src/lib/number-input/number-input.component.spec.ts +151 -0
- package/src/lib/number-input/number-input.component.ts +153 -0
- package/src/lib/number-input/number-input.variants.ts +17 -0
- package/src/lib/otp-input/index.ts +2 -0
- package/src/lib/otp-input/otp-input.component.spec.ts +252 -0
- package/src/lib/otp-input/otp-input.component.ts +275 -0
- package/src/lib/otp-input/otp-input.variants.ts +18 -0
- package/src/lib/popover/index.ts +6 -0
- package/src/lib/popover/popover.directives.spec.ts +147 -0
- package/src/lib/popover/popover.directives.ts +155 -0
- package/src/lib/tag-input/index.ts +2 -0
- package/src/lib/tag-input/tag-input.component.spec.ts +190 -0
- package/src/lib/tag-input/tag-input.component.ts +173 -0
- package/src/lib/tag-input/tag-input.variants.ts +31 -0
- package/types/sonny-ui-core.d.ts +351 -3
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterNextRender,
|
|
3
|
+
ChangeDetectionStrategy,
|
|
4
|
+
Component,
|
|
5
|
+
computed,
|
|
6
|
+
effect,
|
|
7
|
+
ElementRef,
|
|
8
|
+
forwardRef,
|
|
9
|
+
input,
|
|
10
|
+
linkedSignal,
|
|
11
|
+
model,
|
|
12
|
+
output,
|
|
13
|
+
signal,
|
|
14
|
+
untracked,
|
|
15
|
+
viewChildren,
|
|
16
|
+
} from '@angular/core';
|
|
17
|
+
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
|
18
|
+
import { cn } from '../core/utils/cn';
|
|
19
|
+
import { otpCellVariants, type OtpInputSize, type OtpInputType } from './otp-input.variants';
|
|
20
|
+
|
|
21
|
+
@Component({
|
|
22
|
+
selector: 'sny-otp-input',
|
|
23
|
+
standalone: true,
|
|
24
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
25
|
+
providers: [
|
|
26
|
+
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyOtpInputComponent), multi: true },
|
|
27
|
+
],
|
|
28
|
+
template: `
|
|
29
|
+
<div
|
|
30
|
+
role="group"
|
|
31
|
+
[attr.aria-label]="'OTP input, ' + length() + ' digits'"
|
|
32
|
+
class="flex items-center gap-2"
|
|
33
|
+
>
|
|
34
|
+
@for (digit of digits(); track $index; let i = $index) {
|
|
35
|
+
@if (separator() !== null && i === separator() && i > 0) {
|
|
36
|
+
<span class="text-muted-foreground text-lg select-none" aria-hidden="true">—</span>
|
|
37
|
+
}
|
|
38
|
+
<input
|
|
39
|
+
#inputEl
|
|
40
|
+
[type]="mask() ? 'password' : 'text'"
|
|
41
|
+
[inputMode]="type() === 'number' ? 'numeric' : 'text'"
|
|
42
|
+
[attr.pattern]="type() === 'number' ? '[0-9]' : '[a-zA-Z0-9]'"
|
|
43
|
+
maxlength="1"
|
|
44
|
+
autocomplete="one-time-code"
|
|
45
|
+
[value]="digit"
|
|
46
|
+
[placeholder]="placeholder()"
|
|
47
|
+
[disabled]="isDisabled()"
|
|
48
|
+
[class]="cellClass(i)"
|
|
49
|
+
[attr.aria-label]="'Digit ' + (i + 1) + ' of ' + length()"
|
|
50
|
+
(input)="onInput($event, i)"
|
|
51
|
+
(keydown)="onKeydown($event, i)"
|
|
52
|
+
(paste)="onPaste($event, i)"
|
|
53
|
+
(focus)="focusedIndex.set(i)"
|
|
54
|
+
(blur)="onBlur()"
|
|
55
|
+
/>
|
|
56
|
+
}
|
|
57
|
+
</div>
|
|
58
|
+
`,
|
|
59
|
+
})
|
|
60
|
+
export class SnyOtpInputComponent implements ControlValueAccessor {
|
|
61
|
+
// Public API
|
|
62
|
+
readonly length = input(6);
|
|
63
|
+
readonly type = input<OtpInputType>('number');
|
|
64
|
+
readonly size = input<OtpInputSize>('md');
|
|
65
|
+
readonly disabled = input(false);
|
|
66
|
+
readonly mask = input(false);
|
|
67
|
+
readonly autoFocus = input(true);
|
|
68
|
+
readonly placeholder = input('');
|
|
69
|
+
readonly separator = input<number | null>(null);
|
|
70
|
+
readonly status = input<'idle' | 'loading' | 'success' | 'error'>('idle');
|
|
71
|
+
readonly value = model('');
|
|
72
|
+
|
|
73
|
+
readonly completed = output<string>();
|
|
74
|
+
|
|
75
|
+
// Internal state
|
|
76
|
+
readonly digits = linkedSignal<string[]>(() => Array(this.length()).fill(''));
|
|
77
|
+
readonly focusedIndex = signal(-1);
|
|
78
|
+
readonly inputRefs = viewChildren<ElementRef<HTMLInputElement>>('inputEl');
|
|
79
|
+
|
|
80
|
+
private readonly _disabledByCva = signal(false);
|
|
81
|
+
readonly isDisabled = computed(() => this.disabled() || this._disabledByCva() || this.status() === 'loading');
|
|
82
|
+
|
|
83
|
+
private _onChange: (value: string) => void = () => {};
|
|
84
|
+
private _onTouched: () => void = () => {};
|
|
85
|
+
|
|
86
|
+
// Computed
|
|
87
|
+
readonly fullValue = computed(() => this.digits().join(''));
|
|
88
|
+
readonly isComplete = computed(() => {
|
|
89
|
+
const d = this.digits();
|
|
90
|
+
return d.length === this.length() && d.every((c) => c !== '');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
constructor() {
|
|
94
|
+
// Sync value → digits when value changes externally (e.g. reset)
|
|
95
|
+
effect(() => {
|
|
96
|
+
const val = this.value();
|
|
97
|
+
untracked(() => {
|
|
98
|
+
const chars = val.split('').slice(0, this.length());
|
|
99
|
+
const padded = [...chars, ...Array(this.length() - chars.length).fill('')];
|
|
100
|
+
const current = this.digits();
|
|
101
|
+
if (padded.join('') !== current.join('')) {
|
|
102
|
+
this.digits.set(padded);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterNextRender(() => {
|
|
108
|
+
if (this.autoFocus()) {
|
|
109
|
+
this.focusInput(0);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// CVA
|
|
115
|
+
writeValue(val: string): void {
|
|
116
|
+
const str = val ?? '';
|
|
117
|
+
this.value.set(str);
|
|
118
|
+
const chars = str.split('').slice(0, this.length());
|
|
119
|
+
const padded = [...chars, ...Array(this.length() - chars.length).fill('')];
|
|
120
|
+
this.digits.set(padded);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
registerOnChange(fn: (value: string) => void): void {
|
|
124
|
+
this._onChange = fn;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
registerOnTouched(fn: () => void): void {
|
|
128
|
+
this._onTouched = fn;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
setDisabledState(isDisabled: boolean): void {
|
|
132
|
+
this._disabledByCva.set(isDisabled);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Cell class
|
|
136
|
+
cellClass(index: number): string {
|
|
137
|
+
const isFocused = this.focusedIndex() === index;
|
|
138
|
+
const hasValue = this.digits()[index] !== '';
|
|
139
|
+
const st = this.status();
|
|
140
|
+
return cn(
|
|
141
|
+
otpCellVariants({ size: this.size() }),
|
|
142
|
+
st === 'idle' && isFocused && 'border-primary ring-2 ring-ring',
|
|
143
|
+
st === 'idle' && hasValue && !isFocused && 'border-primary/50',
|
|
144
|
+
st === 'loading' && 'border-muted-foreground/30 opacity-70',
|
|
145
|
+
st === 'success' && 'border-green-500 bg-green-500/5',
|
|
146
|
+
st === 'error' && 'border-destructive bg-destructive/5 animate-shake',
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Input handler
|
|
151
|
+
onInput(event: Event, index: number): void {
|
|
152
|
+
const input = event.target as HTMLInputElement;
|
|
153
|
+
const char = input.value.slice(-1);
|
|
154
|
+
|
|
155
|
+
if (!this.isValidChar(char)) {
|
|
156
|
+
input.value = this.digits()[index];
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
this.setDigit(index, char);
|
|
161
|
+
|
|
162
|
+
if (index < this.length() - 1) {
|
|
163
|
+
this.focusInput(index + 1);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
this.emitValue();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Keyboard handler
|
|
170
|
+
onKeydown(event: KeyboardEvent, index: number): void {
|
|
171
|
+
switch (event.key) {
|
|
172
|
+
case 'Backspace':
|
|
173
|
+
event.preventDefault();
|
|
174
|
+
if (this.digits()[index] !== '') {
|
|
175
|
+
this.setDigit(index, '');
|
|
176
|
+
this.emitValue();
|
|
177
|
+
} else if (index > 0) {
|
|
178
|
+
this.setDigit(index - 1, '');
|
|
179
|
+
this.focusInput(index - 1);
|
|
180
|
+
this.emitValue();
|
|
181
|
+
}
|
|
182
|
+
break;
|
|
183
|
+
|
|
184
|
+
case 'Delete':
|
|
185
|
+
event.preventDefault();
|
|
186
|
+
this.setDigit(index, '');
|
|
187
|
+
this.emitValue();
|
|
188
|
+
break;
|
|
189
|
+
|
|
190
|
+
case 'ArrowLeft':
|
|
191
|
+
event.preventDefault();
|
|
192
|
+
if (index > 0) this.focusInput(index - 1);
|
|
193
|
+
break;
|
|
194
|
+
|
|
195
|
+
case 'ArrowRight':
|
|
196
|
+
event.preventDefault();
|
|
197
|
+
if (index < this.length() - 1) this.focusInput(index + 1);
|
|
198
|
+
break;
|
|
199
|
+
|
|
200
|
+
case 'Home':
|
|
201
|
+
event.preventDefault();
|
|
202
|
+
this.focusInput(0);
|
|
203
|
+
break;
|
|
204
|
+
|
|
205
|
+
case 'End':
|
|
206
|
+
event.preventDefault();
|
|
207
|
+
this.focusInput(this.length() - 1);
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Paste handler
|
|
213
|
+
onPaste(event: ClipboardEvent, index: number): void {
|
|
214
|
+
event.preventDefault();
|
|
215
|
+
const text = event.clipboardData?.getData('text') ?? '';
|
|
216
|
+
const chars = text.split('').filter((c) => this.isValidChar(c));
|
|
217
|
+
|
|
218
|
+
if (chars.length === 0) return;
|
|
219
|
+
|
|
220
|
+
const newDigits = [...this.digits()];
|
|
221
|
+
let lastFilledIndex = index;
|
|
222
|
+
|
|
223
|
+
for (let i = 0; i < chars.length && index + i < this.length(); i++) {
|
|
224
|
+
newDigits[index + i] = chars[i];
|
|
225
|
+
lastFilledIndex = index + i;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
this.digits.set(newDigits);
|
|
229
|
+
|
|
230
|
+
const nextIndex = Math.min(lastFilledIndex + 1, this.length() - 1);
|
|
231
|
+
this.focusInput(nextIndex);
|
|
232
|
+
this.emitValue();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Blur
|
|
236
|
+
onBlur(): void {
|
|
237
|
+
this.focusedIndex.set(-1);
|
|
238
|
+
this._onTouched();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Helpers
|
|
242
|
+
private setDigit(index: number, char: string): void {
|
|
243
|
+
this.digits.update((d) => {
|
|
244
|
+
const next = [...d];
|
|
245
|
+
next[index] = char;
|
|
246
|
+
return next;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private emitValue(): void {
|
|
251
|
+
const val = this.fullValue();
|
|
252
|
+
this.value.set(val);
|
|
253
|
+
this._onChange(val);
|
|
254
|
+
|
|
255
|
+
if (this.isComplete()) {
|
|
256
|
+
this.completed.emit(val);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private focusInput(index: number): void {
|
|
261
|
+
const refs = this.inputRefs();
|
|
262
|
+
if (refs[index]) {
|
|
263
|
+
const el = refs[index].nativeElement;
|
|
264
|
+
el.focus();
|
|
265
|
+
el.select();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private isValidChar(char: string): boolean {
|
|
270
|
+
if (!char || char.length !== 1) return false;
|
|
271
|
+
if (this.type() === 'number') return /^[0-9]$/.test(char);
|
|
272
|
+
return /^[a-zA-Z0-9]$/.test(char);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
export const otpCellVariants = cva(
|
|
4
|
+
'text-center font-mono font-semibold border border-border bg-background rounded-md transition-all focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed',
|
|
5
|
+
{
|
|
6
|
+
variants: {
|
|
7
|
+
size: {
|
|
8
|
+
sm: 'h-9 w-9 text-sm',
|
|
9
|
+
md: 'h-11 w-11 text-lg',
|
|
10
|
+
lg: 'h-14 w-14 text-2xl',
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
defaultVariants: { size: 'md' },
|
|
14
|
+
}
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export type OtpInputSize = 'sm' | 'md' | 'lg';
|
|
18
|
+
export type OtpInputType = 'number' | 'alphanumeric';
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import {
|
|
4
|
+
SnyPopoverDirective,
|
|
5
|
+
SnyPopoverTriggerDirective,
|
|
6
|
+
SnyPopoverContentDirective,
|
|
7
|
+
} from './popover.directives';
|
|
8
|
+
|
|
9
|
+
@Component({
|
|
10
|
+
standalone: true,
|
|
11
|
+
imports: [SnyPopoverDirective, SnyPopoverTriggerDirective, SnyPopoverContentDirective],
|
|
12
|
+
template: `
|
|
13
|
+
<div snyPopover [matchWidth]="matchWidth()" [closeOnOutside]="closeOnOutside()" [closeOnEscape]="closeOnEscape()" #pop="snyPopover">
|
|
14
|
+
<button snyPopoverTrigger>Open</button>
|
|
15
|
+
<div snyPopoverContent class="p-4">
|
|
16
|
+
<p>Popover content</p>
|
|
17
|
+
<button class="close-btn" (click)="pop.close()">Close</button>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
`,
|
|
21
|
+
})
|
|
22
|
+
class TestHost {
|
|
23
|
+
matchWidth = signal(false);
|
|
24
|
+
closeOnOutside = signal(true);
|
|
25
|
+
closeOnEscape = signal(true);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('SnyPopoverDirective', () => {
|
|
29
|
+
let fixture: ComponentFixture<TestHost>;
|
|
30
|
+
let el: HTMLElement;
|
|
31
|
+
|
|
32
|
+
beforeEach(async () => {
|
|
33
|
+
await TestBed.configureTestingModule({ imports: [TestHost] }).compileComponents();
|
|
34
|
+
fixture = TestBed.createComponent(TestHost);
|
|
35
|
+
fixture.detectChanges();
|
|
36
|
+
el = fixture.nativeElement;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function getTrigger(): HTMLButtonElement {
|
|
40
|
+
return el.querySelector('[snypopovertrigger]') as HTMLButtonElement;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getContent(): HTMLElement | null {
|
|
44
|
+
return el.querySelector('[snyPopoverContent], [snypopovercontent]');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isVisible(): boolean {
|
|
48
|
+
const content = getContent();
|
|
49
|
+
return content ? content.style.display !== 'none' : false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
it('should render trigger and hidden content', () => {
|
|
53
|
+
expect(getTrigger()).not.toBeNull();
|
|
54
|
+
expect(getContent()).not.toBeNull();
|
|
55
|
+
expect(isVisible()).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should open on trigger click', () => {
|
|
59
|
+
getTrigger().click();
|
|
60
|
+
fixture.detectChanges();
|
|
61
|
+
expect(isVisible()).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should close on second trigger click', () => {
|
|
65
|
+
getTrigger().click();
|
|
66
|
+
fixture.detectChanges();
|
|
67
|
+
expect(isVisible()).toBe(true);
|
|
68
|
+
|
|
69
|
+
getTrigger().click();
|
|
70
|
+
fixture.detectChanges();
|
|
71
|
+
expect(isVisible()).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should set aria-expanded on trigger', () => {
|
|
75
|
+
expect(getTrigger().getAttribute('aria-expanded')).toBe('false');
|
|
76
|
+
getTrigger().click();
|
|
77
|
+
fixture.detectChanges();
|
|
78
|
+
expect(getTrigger().getAttribute('aria-expanded')).toBe('true');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should have aria-haspopup on trigger', () => {
|
|
82
|
+
expect(getTrigger().getAttribute('aria-haspopup')).toBe('dialog');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should have role=dialog on content', () => {
|
|
86
|
+
expect(getContent()?.getAttribute('role')).toBe('dialog');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should close on click outside', () => {
|
|
90
|
+
getTrigger().click();
|
|
91
|
+
fixture.detectChanges();
|
|
92
|
+
expect(isVisible()).toBe(true);
|
|
93
|
+
|
|
94
|
+
document.body.click();
|
|
95
|
+
fixture.detectChanges();
|
|
96
|
+
expect(isVisible()).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should not close on click outside when closeOnOutside=false', () => {
|
|
100
|
+
fixture.componentInstance.closeOnOutside.set(false);
|
|
101
|
+
fixture.detectChanges();
|
|
102
|
+
|
|
103
|
+
getTrigger().click();
|
|
104
|
+
fixture.detectChanges();
|
|
105
|
+
expect(isVisible()).toBe(true);
|
|
106
|
+
|
|
107
|
+
document.body.click();
|
|
108
|
+
fixture.detectChanges();
|
|
109
|
+
expect(isVisible()).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should close on escape', () => {
|
|
113
|
+
getTrigger().click();
|
|
114
|
+
fixture.detectChanges();
|
|
115
|
+
expect(isVisible()).toBe(true);
|
|
116
|
+
|
|
117
|
+
const host = el.querySelector('[snyPopover], [snypopover]') as HTMLElement;
|
|
118
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
119
|
+
fixture.detectChanges();
|
|
120
|
+
expect(isVisible()).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should not close on escape when closeOnEscape=false', () => {
|
|
124
|
+
fixture.componentInstance.closeOnEscape.set(false);
|
|
125
|
+
fixture.detectChanges();
|
|
126
|
+
|
|
127
|
+
getTrigger().click();
|
|
128
|
+
fixture.detectChanges();
|
|
129
|
+
expect(isVisible()).toBe(true);
|
|
130
|
+
|
|
131
|
+
const host = el.querySelector('[snyPopover], [snypopover]') as HTMLElement;
|
|
132
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
133
|
+
fixture.detectChanges();
|
|
134
|
+
expect(isVisible()).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should close programmatically via template ref', () => {
|
|
138
|
+
getTrigger().click();
|
|
139
|
+
fixture.detectChanges();
|
|
140
|
+
expect(isVisible()).toBe(true);
|
|
141
|
+
|
|
142
|
+
const closeBtn = el.querySelector('.close-btn') as HTMLButtonElement;
|
|
143
|
+
closeBtn.click();
|
|
144
|
+
fixture.detectChanges();
|
|
145
|
+
expect(isVisible()).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Directive,
|
|
3
|
+
ElementRef,
|
|
4
|
+
HostListener,
|
|
5
|
+
InjectionToken,
|
|
6
|
+
OnDestroy,
|
|
7
|
+
computed,
|
|
8
|
+
inject,
|
|
9
|
+
input,
|
|
10
|
+
signal,
|
|
11
|
+
} from '@angular/core';
|
|
12
|
+
import { cn } from '../core/utils/cn';
|
|
13
|
+
|
|
14
|
+
export const SNY_POPOVER = new InjectionToken<SnyPopoverDirective>('SnyPopover');
|
|
15
|
+
|
|
16
|
+
@Directive({
|
|
17
|
+
selector: '[snyPopover]',
|
|
18
|
+
standalone: true,
|
|
19
|
+
exportAs: 'snyPopover',
|
|
20
|
+
providers: [{ provide: SNY_POPOVER, useExisting: SnyPopoverDirective }],
|
|
21
|
+
host: {
|
|
22
|
+
'[class]': '"relative inline-block"',
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
export class SnyPopoverDirective implements OnDestroy {
|
|
26
|
+
private readonly elRef = inject(ElementRef);
|
|
27
|
+
|
|
28
|
+
readonly matchWidth = input(false);
|
|
29
|
+
readonly offset = input(4);
|
|
30
|
+
readonly closeOnOutside = input(true);
|
|
31
|
+
readonly closeOnEscape = input(true);
|
|
32
|
+
|
|
33
|
+
readonly isOpen = signal(false);
|
|
34
|
+
readonly triggerEl = signal<HTMLElement | null>(null);
|
|
35
|
+
readonly panelEl = signal<HTMLElement | null>(null);
|
|
36
|
+
|
|
37
|
+
private scrollHandler: (() => void) | null = null;
|
|
38
|
+
private resizeHandler: (() => void) | null = null;
|
|
39
|
+
|
|
40
|
+
toggle(): void {
|
|
41
|
+
if (this.isOpen()) {
|
|
42
|
+
this.close();
|
|
43
|
+
} else {
|
|
44
|
+
this.open();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
open(): void {
|
|
49
|
+
this.isOpen.set(true);
|
|
50
|
+
this.addListeners();
|
|
51
|
+
setTimeout(() => this.updatePosition());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
close(): void {
|
|
55
|
+
this.isOpen.set(false);
|
|
56
|
+
this.removeListeners();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
updatePosition(): void {
|
|
60
|
+
const trigger = this.triggerEl();
|
|
61
|
+
const panel = this.panelEl();
|
|
62
|
+
if (!trigger || !panel) return;
|
|
63
|
+
const rect = trigger.getBoundingClientRect();
|
|
64
|
+
panel.style.top = `${rect.bottom + this.offset()}px`;
|
|
65
|
+
panel.style.left = `${rect.left}px`;
|
|
66
|
+
if (this.matchWidth()) {
|
|
67
|
+
panel.style.width = `${rect.width}px`;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private addListeners(): void {
|
|
72
|
+
this.removeListeners();
|
|
73
|
+
this.scrollHandler = () => {
|
|
74
|
+
requestAnimationFrame(() => this.updatePosition());
|
|
75
|
+
};
|
|
76
|
+
this.resizeHandler = () => {
|
|
77
|
+
requestAnimationFrame(() => this.updatePosition());
|
|
78
|
+
};
|
|
79
|
+
document.addEventListener('scroll', this.scrollHandler, { capture: true, passive: true });
|
|
80
|
+
window.addEventListener('resize', this.resizeHandler, { passive: true });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private removeListeners(): void {
|
|
84
|
+
if (this.scrollHandler) {
|
|
85
|
+
document.removeEventListener('scroll', this.scrollHandler, { capture: true } as EventListenerOptions);
|
|
86
|
+
this.scrollHandler = null;
|
|
87
|
+
}
|
|
88
|
+
if (this.resizeHandler) {
|
|
89
|
+
window.removeEventListener('resize', this.resizeHandler);
|
|
90
|
+
this.resizeHandler = null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@HostListener('document:click', ['$event'])
|
|
95
|
+
onDocumentClick(event: MouseEvent): void {
|
|
96
|
+
if (this.closeOnOutside() && this.isOpen() && !this.elRef.nativeElement.contains(event.target)) {
|
|
97
|
+
this.close();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@HostListener('keydown.escape')
|
|
102
|
+
onEscape(): void {
|
|
103
|
+
if (this.closeOnEscape() && this.isOpen()) {
|
|
104
|
+
this.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
ngOnDestroy(): void {
|
|
109
|
+
this.removeListeners();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@Directive({
|
|
114
|
+
selector: '[snyPopoverTrigger]',
|
|
115
|
+
standalone: true,
|
|
116
|
+
host: {
|
|
117
|
+
'(click)': 'popover.toggle()',
|
|
118
|
+
'[attr.aria-expanded]': 'popover.isOpen()',
|
|
119
|
+
'aria-haspopup': 'dialog',
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
export class SnyPopoverTriggerDirective {
|
|
123
|
+
protected readonly popover = inject(SNY_POPOVER);
|
|
124
|
+
private readonly elRef = inject(ElementRef);
|
|
125
|
+
|
|
126
|
+
constructor() {
|
|
127
|
+
this.popover.triggerEl.set(this.elRef.nativeElement);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
@Directive({
|
|
132
|
+
selector: '[snyPopoverContent]',
|
|
133
|
+
standalone: true,
|
|
134
|
+
host: {
|
|
135
|
+
'role': 'dialog',
|
|
136
|
+
'[style.display]': 'popover.isOpen() ? "" : "none"',
|
|
137
|
+
'[class]': 'computedClass()',
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
export class SnyPopoverContentDirective {
|
|
141
|
+
protected readonly popover = inject(SNY_POPOVER);
|
|
142
|
+
private readonly elRef = inject(ElementRef);
|
|
143
|
+
readonly class = input<string>('');
|
|
144
|
+
|
|
145
|
+
protected readonly computedClass = computed(() =>
|
|
146
|
+
cn(
|
|
147
|
+
'fixed z-50 rounded-md border border-border bg-popover text-popover-foreground shadow-lg animate-in fade-in-0 zoom-in-95',
|
|
148
|
+
this.class()
|
|
149
|
+
)
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
constructor() {
|
|
153
|
+
this.popover.panelEl.set(this.elRef.nativeElement);
|
|
154
|
+
}
|
|
155
|
+
}
|