@sonny-ui/core 0.1.0-alpha.16 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sonny-ui/core",
3
- "version": "0.1.0-alpha.16",
3
+ "version": "0.1.0-alpha.17",
4
4
  "description": "Angular UI component library inspired by shadcn/ui — signals, zoneless, Tailwind CSS v4",
5
5
  "peerDependencies": {
6
6
  "@angular/common": "^21.0.0",
@@ -0,0 +1,74 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import { SnyAvatarGroupComponent, type AvatarGroupItem } from './avatar-group.component';
4
+
5
+ @Component({
6
+ standalone: true,
7
+ imports: [SnyAvatarGroupComponent],
8
+ template: `<sny-avatar-group [items]="items()" [max]="max()" [size]="size()" />`,
9
+ })
10
+ class TestHost {
11
+ items = signal<AvatarGroupItem[]>([
12
+ { src: 'a.jpg', alt: 'Alice' },
13
+ { src: 'b.jpg', alt: 'Bob' },
14
+ { src: 'c.jpg', alt: 'Carol' },
15
+ { src: 'd.jpg', alt: 'David' },
16
+ { src: 'e.jpg', alt: 'Eve' },
17
+ ]);
18
+ max = signal(3);
19
+ size = signal<'sm' | 'md' | 'lg'>('md');
20
+ }
21
+
22
+ describe('SnyAvatarGroupComponent', () => {
23
+ let fixture: ComponentFixture<TestHost>;
24
+ let el: HTMLElement;
25
+
26
+ beforeEach(async () => {
27
+ await TestBed.configureTestingModule({ imports: [TestHost] }).compileComponents();
28
+ fixture = TestBed.createComponent(TestHost);
29
+ fixture.detectChanges();
30
+ el = fixture.nativeElement;
31
+ });
32
+
33
+ it('should render max avatars', () => {
34
+ const imgs = el.querySelectorAll('img');
35
+ expect(imgs.length).toBe(3);
36
+ });
37
+
38
+ it('should show overflow counter', () => {
39
+ const counter = el.querySelector('[title="2 more"]');
40
+ expect(counter).not.toBeNull();
41
+ expect(counter?.textContent).toContain('+2');
42
+ });
43
+
44
+ it('should not show counter when no overflow', () => {
45
+ fixture.componentInstance.max.set(5);
46
+ fixture.detectChanges();
47
+ const counter = el.querySelector('[title]');
48
+ expect(counter).toBeNull();
49
+ });
50
+
51
+ it('should render all when max >= items', () => {
52
+ fixture.componentInstance.max.set(10);
53
+ fixture.detectChanges();
54
+ const imgs = el.querySelectorAll('img');
55
+ expect(imgs.length).toBe(5);
56
+ });
57
+
58
+ it('should render fallback initials when no src', () => {
59
+ fixture.componentInstance.items.set([
60
+ { fallback: 'AB' },
61
+ { fallback: 'CD' },
62
+ ]);
63
+ fixture.componentInstance.max.set(2);
64
+ fixture.detectChanges();
65
+ const fallbacks = el.querySelectorAll('.bg-muted');
66
+ expect(fallbacks.length).toBe(2);
67
+ expect(fallbacks[0].textContent).toContain('AB');
68
+ });
69
+
70
+ it('should have aria-label on group', () => {
71
+ const group = el.querySelector('[role="group"]');
72
+ expect(group?.getAttribute('aria-label')).toBe('Group of 5 users');
73
+ });
74
+ });
@@ -0,0 +1,89 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ computed,
5
+ input,
6
+ } from '@angular/core';
7
+ import { cn } from '../core/utils/cn';
8
+
9
+ export interface AvatarGroupItem {
10
+ src?: string;
11
+ alt?: string;
12
+ fallback?: string;
13
+ }
14
+
15
+ const sizeMap = {
16
+ sm: { avatar: 'h-7 w-7 text-xs', counter: 'h-7 w-7 text-[10px]' },
17
+ md: { avatar: 'h-9 w-9 text-sm', counter: 'h-9 w-9 text-xs' },
18
+ lg: { avatar: 'h-11 w-11 text-base', counter: 'h-11 w-11 text-sm' },
19
+ };
20
+
21
+ const spacingMap = {
22
+ tight: '-space-x-3',
23
+ normal: '-space-x-2',
24
+ };
25
+
26
+ export type AvatarGroupSize = 'sm' | 'md' | 'lg';
27
+ export type AvatarGroupSpacing = 'tight' | 'normal';
28
+
29
+ @Component({
30
+ selector: 'sny-avatar-group',
31
+ standalone: true,
32
+ changeDetection: ChangeDetectionStrategy.OnPush,
33
+ template: `
34
+ <div [class]="containerClass()" role="group" [attr.aria-label]="'Group of ' + items().length + ' users'">
35
+ @for (item of visibleItems(); track $index) {
36
+ @if (item.src) {
37
+ <img
38
+ [src]="item.src"
39
+ [alt]="item.alt ?? ''"
40
+ [class]="avatarClass()"
41
+ />
42
+ } @else {
43
+ <div [class]="fallbackClass()">
44
+ {{ item.fallback ?? '?' }}
45
+ </div>
46
+ }
47
+ }
48
+ @if (overflowCount() > 0) {
49
+ <div [class]="counterClass()" [title]="overflowCount() + ' more'">
50
+ +{{ overflowCount() }}
51
+ </div>
52
+ }
53
+ </div>
54
+ `,
55
+ })
56
+ export class SnyAvatarGroupComponent {
57
+ readonly items = input.required<AvatarGroupItem[]>();
58
+ readonly max = input(3);
59
+ readonly size = input<AvatarGroupSize>('md');
60
+ readonly spacing = input<AvatarGroupSpacing>('normal');
61
+
62
+ readonly visibleItems = computed(() => this.items().slice(0, this.max()));
63
+ readonly overflowCount = computed(() => Math.max(0, this.items().length - this.max()));
64
+
65
+ readonly containerClass = computed(() =>
66
+ cn('flex items-center', spacingMap[this.spacing()])
67
+ );
68
+
69
+ readonly avatarClass = computed(() =>
70
+ cn(
71
+ 'inline-block rounded-full object-cover ring-2 ring-background',
72
+ sizeMap[this.size()].avatar
73
+ )
74
+ );
75
+
76
+ readonly fallbackClass = computed(() =>
77
+ cn(
78
+ 'inline-flex items-center justify-center rounded-full bg-muted text-muted-foreground font-medium ring-2 ring-background',
79
+ sizeMap[this.size()].avatar
80
+ )
81
+ );
82
+
83
+ readonly counterClass = computed(() =>
84
+ cn(
85
+ 'inline-flex items-center justify-center rounded-full bg-muted text-muted-foreground font-semibold ring-2 ring-background',
86
+ sizeMap[this.size()].counter
87
+ )
88
+ );
89
+ }
@@ -0,0 +1 @@
1
+ export { SnyAvatarGroupComponent, type AvatarGroupItem, type AvatarGroupSize, type AvatarGroupSpacing } from './avatar-group.component';
@@ -0,0 +1,2 @@
1
+ export { SnyNumberInputComponent } from './number-input.component';
2
+ export { numberInputVariants, type NumberInputSize } from './number-input.variants';
@@ -0,0 +1,151 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { FormControl, ReactiveFormsModule } from '@angular/forms';
3
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
4
+ import { SnyNumberInputComponent } from './number-input.component';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyNumberInputComponent],
9
+ template: `
10
+ <sny-number-input
11
+ [(value)]="num"
12
+ [min]="min()"
13
+ [max]="max()"
14
+ [step]="step()"
15
+ [disabled]="disabled()"
16
+ />
17
+ `,
18
+ })
19
+ class TestHost {
20
+ num = signal(5);
21
+ min = signal<number | null>(null);
22
+ max = signal<number | null>(null);
23
+ step = signal(1);
24
+ disabled = signal(false);
25
+ }
26
+
27
+ describe('SnyNumberInputComponent', () => {
28
+ let fixture: ComponentFixture<TestHost>;
29
+ let el: HTMLElement;
30
+
31
+ beforeEach(async () => {
32
+ await TestBed.configureTestingModule({ imports: [TestHost] }).compileComponents();
33
+ fixture = TestBed.createComponent(TestHost);
34
+ fixture.detectChanges();
35
+ el = fixture.nativeElement;
36
+ });
37
+
38
+ function getInput(): HTMLInputElement {
39
+ return el.querySelector('input') as HTMLInputElement;
40
+ }
41
+ function getButtons(): HTMLButtonElement[] {
42
+ return Array.from(el.querySelectorAll('button'));
43
+ }
44
+
45
+ it('should render with initial value', () => {
46
+ expect(getInput().value).toBe('5');
47
+ });
48
+
49
+ it('should increment on + click', () => {
50
+ getButtons()[1].click();
51
+ fixture.detectChanges();
52
+ expect(fixture.componentInstance.num()).toBe(6);
53
+ });
54
+
55
+ it('should decrement on - click', () => {
56
+ getButtons()[0].click();
57
+ fixture.detectChanges();
58
+ expect(fixture.componentInstance.num()).toBe(4);
59
+ });
60
+
61
+ it('should respect min', () => {
62
+ fixture.componentInstance.min.set(5);
63
+ fixture.detectChanges();
64
+ getButtons()[0].click();
65
+ fixture.detectChanges();
66
+ expect(fixture.componentInstance.num()).toBe(5);
67
+ });
68
+
69
+ it('should respect max', () => {
70
+ fixture.componentInstance.max.set(5);
71
+ fixture.detectChanges();
72
+ getButtons()[1].click();
73
+ fixture.detectChanges();
74
+ expect(fixture.componentInstance.num()).toBe(5);
75
+ });
76
+
77
+ it('should use step', () => {
78
+ fixture.componentInstance.step.set(10);
79
+ fixture.detectChanges();
80
+ getButtons()[1].click();
81
+ fixture.detectChanges();
82
+ expect(fixture.componentInstance.num()).toBe(15); // 5 + 10
83
+ });
84
+
85
+ it('should handle manual input on blur', () => {
86
+ const input = getInput();
87
+ input.value = '42';
88
+ input.dispatchEvent(new Event('input'));
89
+ input.dispatchEvent(new Event('blur'));
90
+ fixture.detectChanges();
91
+ expect(fixture.componentInstance.num()).toBe(42);
92
+ });
93
+
94
+ it('should revert invalid input on blur', () => {
95
+ const input = getInput();
96
+ input.value = 'abc';
97
+ input.dispatchEvent(new Event('input', { bubbles: true }));
98
+ input.dispatchEvent(new Event('blur', { bubbles: true }));
99
+ fixture.detectChanges();
100
+ // Value should remain unchanged at 5
101
+ expect(fixture.componentInstance.num()).toBe(5);
102
+ });
103
+
104
+ it('should handle ArrowUp/Down', () => {
105
+ const input = getInput();
106
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
107
+ fixture.detectChanges();
108
+ expect(fixture.componentInstance.num()).toBe(6);
109
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
110
+ fixture.detectChanges();
111
+ expect(fixture.componentInstance.num()).toBe(5);
112
+ });
113
+
114
+ it('should disable when disabled', () => {
115
+ fixture.componentInstance.disabled.set(true);
116
+ fixture.detectChanges();
117
+ expect(getInput().disabled).toBe(true);
118
+ expect(getButtons().every(b => b.disabled)).toBe(true);
119
+ });
120
+ });
121
+
122
+ @Component({
123
+ standalone: true,
124
+ imports: [ReactiveFormsModule, SnyNumberInputComponent],
125
+ template: `<sny-number-input [formControl]="ctrl" />`,
126
+ })
127
+ class ReactiveHost {
128
+ ctrl = new FormControl(10);
129
+ }
130
+
131
+ describe('SnyNumberInputComponent — Reactive Forms', () => {
132
+ let fixture: ComponentFixture<ReactiveHost>;
133
+
134
+ beforeEach(async () => {
135
+ await TestBed.configureTestingModule({ imports: [ReactiveHost] }).compileComponents();
136
+ fixture = TestBed.createComponent(ReactiveHost);
137
+ fixture.detectChanges();
138
+ });
139
+
140
+ it('should display FormControl value', () => {
141
+ const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
142
+ expect(input.value).toBe('10');
143
+ });
144
+
145
+ it('should update FormControl on increment', () => {
146
+ const buttons = fixture.nativeElement.querySelectorAll('button');
147
+ buttons[1].click();
148
+ fixture.detectChanges();
149
+ expect(fixture.componentInstance.ctrl.value).toBe(11);
150
+ });
151
+ });
@@ -0,0 +1,153 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ computed,
5
+ effect,
6
+ forwardRef,
7
+ input,
8
+ model,
9
+ signal,
10
+ untracked,
11
+ } from '@angular/core';
12
+ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
13
+ import { cn } from '../core/utils/cn';
14
+ import { numberInputVariants, type NumberInputSize } from './number-input.variants';
15
+
16
+ @Component({
17
+ selector: 'sny-number-input',
18
+ standalone: true,
19
+ changeDetection: ChangeDetectionStrategy.OnPush,
20
+ providers: [
21
+ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyNumberInputComponent), multi: true },
22
+ ],
23
+ template: `
24
+ <div [class]="containerClass()">
25
+ <button
26
+ type="button"
27
+ class="px-2.5 hover:bg-accent transition-colors border-r border-border disabled:opacity-40 disabled:cursor-not-allowed"
28
+ [disabled]="isDisabled() || atMin()"
29
+ (click)="decrement()"
30
+ aria-label="Decrease"
31
+ >
32
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/></svg>
33
+ </button>
34
+ <input
35
+ #inputEl
36
+ type="text"
37
+ inputmode="decimal"
38
+ class="flex-1 w-14 text-center outline-none bg-transparent font-medium"
39
+ [value]="inputValue()"
40
+ [disabled]="isDisabled()"
41
+ [placeholder]="placeholder()"
42
+ [attr.aria-label]="'Number input'"
43
+ [attr.aria-valuemin]="min() ?? null"
44
+ [attr.aria-valuemax]="max() ?? null"
45
+ [attr.aria-valuenow]="value()"
46
+ role="spinbutton"
47
+ (input)="onInput($event)"
48
+ (blur)="commitValue()"
49
+ (keydown)="onKeydown($event)"
50
+ />
51
+ <button
52
+ type="button"
53
+ class="px-2.5 hover:bg-accent transition-colors border-l border-border disabled:opacity-40 disabled:cursor-not-allowed"
54
+ [disabled]="isDisabled() || atMax()"
55
+ (click)="increment()"
56
+ aria-label="Increase"
57
+ >
58
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
59
+ </button>
60
+ </div>
61
+ `,
62
+ })
63
+ export class SnyNumberInputComponent implements ControlValueAccessor {
64
+ readonly value = model(0);
65
+ readonly min = input<number | null>(null);
66
+ readonly max = input<number | null>(null);
67
+ readonly step = input(1);
68
+ readonly disabled = input(false);
69
+ readonly size = input<NumberInputSize>('md');
70
+ readonly placeholder = input('');
71
+ readonly class = input<string>('');
72
+
73
+ readonly inputValue = signal('0');
74
+ private readonly _disabledByCva = signal(false);
75
+ readonly isDisabled = computed(() => this.disabled() || this._disabledByCva());
76
+
77
+ readonly atMin = computed(() => this.min() !== null && this.value() <= this.min()!);
78
+ readonly atMax = computed(() => this.max() !== null && this.value() >= this.max()!);
79
+
80
+ readonly containerClass = computed(() =>
81
+ cn(numberInputVariants({ size: this.size() }), this.isDisabled() && 'opacity-50', this.class())
82
+ );
83
+
84
+ private _onChange: (value: number) => void = () => {};
85
+ private _onTouched: () => void = () => {};
86
+
87
+ constructor() {
88
+ effect(() => {
89
+ const val = this.value();
90
+ untracked(() => this.inputValue.set(String(val)));
91
+ });
92
+ }
93
+
94
+ writeValue(val: number): void {
95
+ this.value.set(val ?? 0);
96
+ }
97
+
98
+ registerOnChange(fn: (value: number) => void): void {
99
+ this._onChange = fn;
100
+ }
101
+
102
+ registerOnTouched(fn: () => void): void {
103
+ this._onTouched = fn;
104
+ }
105
+
106
+ setDisabledState(isDisabled: boolean): void {
107
+ this._disabledByCva.set(isDisabled);
108
+ }
109
+
110
+ increment(): void {
111
+ this.setValue(this.value() + this.step());
112
+ }
113
+
114
+ decrement(): void {
115
+ this.setValue(this.value() - this.step());
116
+ }
117
+
118
+ onInput(event: Event): void {
119
+ this.inputValue.set((event.target as HTMLInputElement).value);
120
+ }
121
+
122
+ commitValue(): void {
123
+ const parsed = parseFloat(this.inputValue());
124
+ if (isNaN(parsed)) {
125
+ this.inputValue.set(String(this.value()));
126
+ } else {
127
+ this.setValue(parsed);
128
+ }
129
+ this._onTouched();
130
+ }
131
+
132
+ onKeydown(event: KeyboardEvent): void {
133
+ switch (event.key) {
134
+ case 'ArrowUp':
135
+ event.preventDefault();
136
+ this.increment();
137
+ break;
138
+ case 'ArrowDown':
139
+ event.preventDefault();
140
+ this.decrement();
141
+ break;
142
+ }
143
+ }
144
+
145
+ private setValue(raw: number): void {
146
+ let clamped = raw;
147
+ if (this.min() !== null) clamped = Math.max(this.min()!, clamped);
148
+ if (this.max() !== null) clamped = Math.min(this.max()!, clamped);
149
+ const rounded = parseFloat(clamped.toFixed(10));
150
+ this.value.set(rounded);
151
+ this._onChange(rounded);
152
+ }
153
+ }
@@ -0,0 +1,17 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const numberInputVariants = cva(
4
+ 'inline-flex items-center border border-border rounded-md bg-background transition-colors focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
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: { size: 'md' },
14
+ }
15
+ );
16
+
17
+ export type NumberInputSize = 'sm' | 'md' | 'lg';
@@ -0,0 +1,6 @@
1
+ export {
2
+ SnyPopoverDirective,
3
+ SnyPopoverTriggerDirective,
4
+ SnyPopoverContentDirective,
5
+ SNY_POPOVER,
6
+ } from './popover.directives';
@@ -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
+ });