@sonny-ui/core 0.1.0-alpha.12 → 0.1.0-alpha.14

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.12",
3
+ "version": "0.1.0-alpha.14",
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",
@@ -393,12 +393,12 @@ function generateComponent(options) {
393
393
  // Ensure cn.ts utility exists
394
394
  const cnTargetPath = `${targetDir}/utils/cn.ts`;
395
395
  if (!tree.exists(cnTargetPath)) {
396
- const cnContent = `import { clsx, type ClassValue } from 'clsx';
397
- import { twMerge } from 'tailwind-merge';
398
-
399
- export function cn(...inputs: ClassValue[]): string {
400
- return twMerge(clsx(inputs));
401
- }
396
+ const cnContent = `import { clsx, type ClassValue } from 'clsx';
397
+ import { twMerge } from 'tailwind-merge';
398
+
399
+ export function cn(...inputs: ClassValue[]): string {
400
+ return twMerge(clsx(inputs));
401
+ }
402
402
  `;
403
403
  tree.create(cnTargetPath, cnContent);
404
404
  context.logger.info('Created utils/cn.ts');
@@ -1,32 +1,32 @@
1
- {
2
- "$schema": "http://json-schema.org/schema",
3
- "$id": "SonnyUIGenerateComponentSchema",
4
- "title": "SonnyUI component generator (copy-paste style)",
5
- "type": "object",
6
- "properties": {
7
- "name": {
8
- "type": "string",
9
- "description": "The component to copy (accordion, alert, avatar, badge, breadcrumb, button, button-group, calendar, card, carousel, chat-bubble, checkbox, combobox, diff, divider, dock, drawer, dropdown, fab, fieldset, file-input, indicator, input, kbd, link, list, loader, modal, navbar, pagination, progress, radial-progress, radio, rating, select, sheet, skeleton, slider, stat, status, steps, switch, table, tabs, textarea, timeline, toast, toggle, tooltip, validator).",
10
- "$default": {
11
- "$source": "argv",
12
- "index": 0
13
- }
14
- },
15
- "path": {
16
- "type": "string",
17
- "default": "src/app/ui",
18
- "description": "The path to copy the component into."
19
- },
20
- "prefix": {
21
- "type": "string",
22
- "default": "sny",
23
- "description": "The prefix to use for selectors."
24
- },
25
- "skipTests": {
26
- "type": "boolean",
27
- "default": false,
28
- "description": "Skip copying test files."
29
- }
30
- },
31
- "required": ["name"]
32
- }
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "$id": "SonnyUIGenerateComponentSchema",
4
+ "title": "SonnyUI component generator (copy-paste style)",
5
+ "type": "object",
6
+ "properties": {
7
+ "name": {
8
+ "type": "string",
9
+ "description": "The component to copy (accordion, alert, avatar, badge, breadcrumb, button, button-group, calendar, card, carousel, chat-bubble, checkbox, combobox, diff, divider, dock, drawer, dropdown, fab, fieldset, file-input, indicator, input, kbd, link, list, loader, modal, navbar, pagination, progress, radial-progress, radio, rating, select, sheet, skeleton, slider, stat, status, steps, switch, table, tabs, textarea, timeline, toast, toggle, tooltip, validator).",
10
+ "$default": {
11
+ "$source": "argv",
12
+ "index": 0
13
+ }
14
+ },
15
+ "path": {
16
+ "type": "string",
17
+ "default": "src/app/ui",
18
+ "description": "The path to copy the component into."
19
+ },
20
+ "prefix": {
21
+ "type": "string",
22
+ "default": "sny",
23
+ "description": "The prefix to use for selectors."
24
+ },
25
+ "skipTests": {
26
+ "type": "boolean",
27
+ "default": false,
28
+ "description": "Skip copying test files."
29
+ }
30
+ },
31
+ "required": ["name"]
32
+ }
@@ -22,7 +22,7 @@ export class SnyButtonDirective {
22
22
  protected readonly computedClass = computed(() =>
23
23
  cn(
24
24
  buttonVariants({ variant: this.variant(), size: this.size() }),
25
- this.loading() && 'relative cursor-wait',
25
+ this.loading() && 'cursor-wait opacity-70',
26
26
  this.class()
27
27
  )
28
28
  );
@@ -1,4 +1,4 @@
1
- import { ChangeDetectionStrategy, Component, computed, effect, forwardRef, input, model, signal } from '@angular/core';
1
+ import { ChangeDetectionStrategy, Component, computed, forwardRef, input, model, signal } from '@angular/core';
2
2
  import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
3
3
  import { cn } from '../core/utils/cn';
4
4
 
@@ -74,21 +74,8 @@ export class SnyCalendarComponent implements ControlValueAccessor {
74
74
 
75
75
  private _onChange: (value: Date | null) => void = () => {};
76
76
  protected onTouched: () => void = () => {};
77
- private _writing = false;
78
-
79
- constructor() {
80
- effect(() => {
81
- const val = this.value();
82
- if (this._writing) {
83
- this._writing = false;
84
- return;
85
- }
86
- this._onChange(val);
87
- });
88
- }
89
77
 
90
78
  writeValue(val: Date | null): void {
91
- this._writing = true;
92
79
  this.value.set(val ?? null);
93
80
  if (val) {
94
81
  this.viewDate.set(new Date(val.getFullYear(), val.getMonth(), 1));
@@ -160,6 +147,7 @@ export class SnyCalendarComponent implements ControlValueAccessor {
160
147
 
161
148
  selectDate(date: Date): void {
162
149
  this.value.set(date);
150
+ this._onChange(date);
163
151
  this.onTouched();
164
152
  }
165
153
 
@@ -201,6 +189,7 @@ export class SnyCalendarComponent implements ControlValueAccessor {
201
189
  const next = new Date(current);
202
190
  next.setDate(next.getDate() + offset);
203
191
  this.value.set(next);
192
+ this._onChange(next);
204
193
  this.viewDate.set(new Date(next.getFullYear(), next.getMonth(), 1));
205
194
  }
206
195
 
@@ -2,7 +2,6 @@ import {
2
2
  ChangeDetectionStrategy,
3
3
  Component,
4
4
  computed,
5
- effect,
6
5
  ElementRef,
7
6
  forwardRef,
8
7
  HostListener,
@@ -72,7 +71,7 @@ export interface ComboboxOption {
72
71
 
73
72
  <!-- Options list -->
74
73
  @if (filtered().length > 0) {
75
- <ul role="listbox" class="max-h-60 overflow-auto p-1">
74
+ <ul role="listbox" class="max-h-60 overflow-auto p-1 sny-scrollbar">
76
75
  @for (opt of filtered(); track opt.value; let i = $index) {
77
76
  <li
78
77
  role="option"
@@ -122,21 +121,8 @@ export class SnyComboboxComponent implements ControlValueAccessor, OnDestroy {
122
121
 
123
122
  private _onChange: (value: string) => void = () => {};
124
123
  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
124
 
138
125
  writeValue(val: string): void {
139
- this._writing = true;
140
126
  this.value.set(val ?? '');
141
127
  }
142
128
 
@@ -244,6 +230,7 @@ export class SnyComboboxComponent implements ControlValueAccessor, OnDestroy {
244
230
 
245
231
  select(opt: ComboboxOption): void {
246
232
  this.value.set(opt.value);
233
+ this._onChange(opt.value);
247
234
  this.close();
248
235
  }
249
236
 
@@ -0,0 +1,443 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
3
+ import { SnyDataTableComponent } from './data-table.component';
4
+ import {
5
+ SnyCellDefDirective,
6
+ SnyHeaderCellDefDirective,
7
+ SnyBulkActionsDefDirective,
8
+ SnyRowExpandDefDirective,
9
+ } from './data-table.directives';
10
+ import type { DataTableColumn } from './data-table.types';
11
+
12
+ const COLUMNS: DataTableColumn[] = [
13
+ { key: 'name', label: 'Name', sortable: true },
14
+ { key: 'email', label: 'Email' },
15
+ { key: 'age', label: 'Age', sortable: true },
16
+ ];
17
+
18
+ function generateRows(count: number): Record<string, unknown>[] {
19
+ return Array.from({ length: count }, (_, i) => ({
20
+ id: i + 1,
21
+ name: `User ${String(i + 1).padStart(2, '0')}`,
22
+ email: `user${i + 1}@test.com`,
23
+ age: 20 + (i % 40),
24
+ }));
25
+ }
26
+
27
+ // Basic test host
28
+ @Component({
29
+ standalone: true,
30
+ imports: [SnyDataTableComponent],
31
+ template: `
32
+ <sny-data-table
33
+ [columns]="columns()"
34
+ [data]="data()"
35
+ [selectable]="selectable()"
36
+ [paginated]="paginated()"
37
+ [filterable]="filterable()"
38
+ [paginationConfig]="paginationConfig()"
39
+ [noDataText]="noDataText()"
40
+ [trackBy]="'id'"
41
+ [(selectedRows)]="selectedRows"
42
+ (sortChanged)="lastSort = $event"
43
+ (rowClicked)="lastRow = $event"
44
+ />
45
+ `,
46
+ })
47
+ class TestHostComponent {
48
+ columns = signal(COLUMNS);
49
+ data = signal(generateRows(5));
50
+ selectable = signal(false);
51
+ paginated = signal(false);
52
+ filterable = signal(true);
53
+ paginationConfig = signal({ pageSize: 10, pageSizeOptions: [5, 10, 25] });
54
+ noDataText = signal('No data available');
55
+ selectedRows = signal<Record<string, unknown>[]>([]);
56
+ lastSort: unknown = null;
57
+ lastRow: unknown = null;
58
+ }
59
+
60
+ // Enhanced test host with templates
61
+ @Component({
62
+ standalone: true,
63
+ imports: [
64
+ SnyDataTableComponent,
65
+ SnyCellDefDirective,
66
+ SnyHeaderCellDefDirective,
67
+ SnyBulkActionsDefDirective,
68
+ SnyRowExpandDefDirective,
69
+ ],
70
+ template: `
71
+ <sny-data-table
72
+ [columns]="columns()"
73
+ [data]="data()"
74
+ [selectable]="selectable()"
75
+ [expandable]="expandable()"
76
+ [loading]="loading()"
77
+ [loadingRows]="loadingRows()"
78
+ [showColumnToggle]="showColumnToggle()"
79
+ [paginated]="false"
80
+ [filterable]="false"
81
+ [trackBy]="'id'"
82
+ [(selectedRows)]="selectedRows"
83
+ >
84
+ <ng-template snyCell="name" let-value let-row="row">
85
+ <span class="custom-cell">{{ value }}</span>
86
+ </ng-template>
87
+ <ng-template snyHeaderCell="name" let-col>
88
+ <span class="custom-header">{{ col.label }}</span>
89
+ </ng-template>
90
+ <ng-template snyBulkActions let-selected>
91
+ <button class="bulk-action-btn">Delete ({{ selected.length }})</button>
92
+ </ng-template>
93
+ <ng-template snyRowExpand let-row>
94
+ <div class="expand-content">Details for {{ row['name'] }}</div>
95
+ </ng-template>
96
+ </sny-data-table>
97
+ `,
98
+ })
99
+ class EnhancedTestHostComponent {
100
+ columns = signal(COLUMNS);
101
+ data = signal(generateRows(5));
102
+ selectable = signal(false);
103
+ expandable = signal(false);
104
+ loading = signal(false);
105
+ loadingRows = signal(5);
106
+ showColumnToggle = signal(false);
107
+ selectedRows = signal<Record<string, unknown>[]>([]);
108
+ }
109
+
110
+ describe('SnyDataTableComponent', () => {
111
+ let fixture: ComponentFixture<TestHostComponent>;
112
+ let host: TestHostComponent;
113
+ let el: HTMLElement;
114
+
115
+ beforeEach(async () => {
116
+ await TestBed.configureTestingModule({
117
+ imports: [TestHostComponent],
118
+ }).compileComponents();
119
+ fixture = TestBed.createComponent(TestHostComponent);
120
+ host = fixture.componentInstance;
121
+ fixture.detectChanges();
122
+ el = fixture.nativeElement;
123
+ });
124
+
125
+ it('should render column headers', () => {
126
+ const headers = el.querySelectorAll('th');
127
+ expect(headers.length).toBe(3);
128
+ expect(headers[0].textContent).toContain('Name');
129
+ expect(headers[1].textContent).toContain('Email');
130
+ expect(headers[2].textContent).toContain('Age');
131
+ });
132
+
133
+ it('should render data rows', () => {
134
+ const rows = el.querySelectorAll('tbody tr');
135
+ expect(rows.length).toBe(5);
136
+ expect(rows[0].textContent).toContain('User 01');
137
+ });
138
+
139
+ it('should sort ascending on header click', () => {
140
+ host.data.set([
141
+ { id: 1, name: 'Charlie', email: 'c@t.com', age: 30 },
142
+ { id: 2, name: 'Alice', email: 'a@t.com', age: 25 },
143
+ { id: 3, name: 'Bob', email: 'b@t.com', age: 35 },
144
+ ]);
145
+ fixture.detectChanges();
146
+
147
+ const nameHeader = el.querySelector('th') as HTMLElement;
148
+ nameHeader.click();
149
+ fixture.detectChanges();
150
+
151
+ const firstCell = el.querySelector('tbody tr td');
152
+ expect(firstCell?.textContent).toContain('Alice');
153
+ });
154
+
155
+ it('should sort descending on second click', () => {
156
+ host.data.set([
157
+ { id: 1, name: 'Charlie', email: 'c@t.com', age: 30 },
158
+ { id: 2, name: 'Alice', email: 'a@t.com', age: 25 },
159
+ { id: 3, name: 'Bob', email: 'b@t.com', age: 35 },
160
+ ]);
161
+ fixture.detectChanges();
162
+
163
+ const nameHeader = el.querySelector('th') as HTMLElement;
164
+ nameHeader.click();
165
+ fixture.detectChanges();
166
+ nameHeader.click();
167
+ fixture.detectChanges();
168
+
169
+ const firstCell = el.querySelector('tbody tr td');
170
+ expect(firstCell?.textContent).toContain('Charlie');
171
+ });
172
+
173
+ it('should clear sort on third click', () => {
174
+ host.data.set([
175
+ { id: 1, name: 'Charlie', email: 'c@t.com', age: 30 },
176
+ { id: 2, name: 'Alice', email: 'a@t.com', age: 25 },
177
+ ]);
178
+ fixture.detectChanges();
179
+
180
+ const nameHeader = el.querySelector('th') as HTMLElement;
181
+ nameHeader.click();
182
+ fixture.detectChanges();
183
+ nameHeader.click();
184
+ fixture.detectChanges();
185
+ nameHeader.click();
186
+ fixture.detectChanges();
187
+
188
+ const firstCell = el.querySelector('tbody tr td');
189
+ expect(firstCell?.textContent).toContain('Charlie');
190
+ });
191
+
192
+ it('should filter rows by text', () => {
193
+ fixture.detectChanges();
194
+ const input = el.querySelector('input[snyinput]') as HTMLInputElement;
195
+ input.value = 'User 01';
196
+ input.dispatchEvent(new Event('input'));
197
+ fixture.detectChanges();
198
+
199
+ const rows = el.querySelectorAll('tbody tr');
200
+ expect(rows.length).toBe(1);
201
+ expect(rows[0].textContent).toContain('User 01');
202
+ });
203
+
204
+ it('should show correct pagination page count', () => {
205
+ host.data.set(generateRows(25));
206
+ host.paginated.set(true);
207
+ host.paginationConfig.set({ pageSize: 10, pageSizeOptions: [10, 25] });
208
+ fixture.detectChanges();
209
+
210
+ const rows = el.querySelectorAll('tbody tr');
211
+ expect(rows.length).toBe(10);
212
+ });
213
+
214
+ it('should navigate pagination', () => {
215
+ host.data.set(generateRows(25));
216
+ host.paginated.set(true);
217
+ host.paginationConfig.set({ pageSize: 10, pageSizeOptions: [10, 25] });
218
+ fixture.detectChanges();
219
+
220
+ const nextBtn = el.querySelector('sny-pagination button:last-child') as HTMLButtonElement;
221
+ nextBtn.click();
222
+ fixture.detectChanges();
223
+
224
+ const firstCell = el.querySelector('tbody tr td');
225
+ expect(firstCell?.textContent).toContain('User 11');
226
+ });
227
+
228
+ it('should select row with checkbox', () => {
229
+ host.selectable.set(true);
230
+ fixture.detectChanges();
231
+
232
+ const checkboxes = el.querySelectorAll('tbody input[type="checkbox"]');
233
+ (checkboxes[0] as HTMLInputElement).click();
234
+ fixture.detectChanges();
235
+
236
+ expect(host.selectedRows().length).toBe(1);
237
+ expect(host.selectedRows()[0]['name']).toBe('User 01');
238
+ });
239
+
240
+ it('should select all with header checkbox', () => {
241
+ host.selectable.set(true);
242
+ fixture.detectChanges();
243
+
244
+ const headerCheckbox = el.querySelector('thead input[type="checkbox"]') as HTMLInputElement;
245
+ headerCheckbox.click();
246
+ fixture.detectChanges();
247
+
248
+ expect(host.selectedRows().length).toBe(5);
249
+ });
250
+
251
+ it('should emit rowClicked on row click', () => {
252
+ fixture.detectChanges();
253
+ const firstRow = el.querySelector('tbody tr') as HTMLElement;
254
+ firstRow.click();
255
+ fixture.detectChanges();
256
+
257
+ expect(host.lastRow).toBeTruthy();
258
+ expect((host.lastRow as Record<string, unknown>)['name']).toBe('User 01');
259
+ });
260
+
261
+ it('should show empty state text', () => {
262
+ host.data.set([]);
263
+ host.noDataText.set('Nothing here');
264
+ fixture.detectChanges();
265
+
266
+ const cell = el.querySelector('tbody td');
267
+ expect(cell?.textContent).toContain('Nothing here');
268
+ });
269
+
270
+ it('should reset page to 1 on filter', () => {
271
+ host.data.set(generateRows(25));
272
+ host.paginated.set(true);
273
+ host.paginationConfig.set({ pageSize: 10, pageSizeOptions: [10] });
274
+ fixture.detectChanges();
275
+
276
+ const nextBtn = el.querySelector('sny-pagination button:last-child') as HTMLButtonElement;
277
+ nextBtn.click();
278
+ fixture.detectChanges();
279
+
280
+ const input = el.querySelector('input[snyinput]') as HTMLInputElement;
281
+ input.value = 'User 0';
282
+ input.dispatchEvent(new Event('input'));
283
+ fixture.detectChanges();
284
+
285
+ const firstCell = el.querySelector('tbody tr td');
286
+ expect(firstCell?.textContent).toContain('User 0');
287
+ });
288
+ });
289
+
290
+ describe('SnyDataTableComponent (enhanced)', () => {
291
+ let fixture: ComponentFixture<EnhancedTestHostComponent>;
292
+ let host: EnhancedTestHostComponent;
293
+ let el: HTMLElement;
294
+
295
+ beforeEach(async () => {
296
+ await TestBed.configureTestingModule({
297
+ imports: [EnhancedTestHostComponent],
298
+ }).compileComponents();
299
+ fixture = TestBed.createComponent(EnhancedTestHostComponent);
300
+ host = fixture.componentInstance;
301
+ fixture.detectChanges();
302
+ el = fixture.nativeElement;
303
+ });
304
+
305
+ // Custom Cell Templates
306
+ it('should render custom cell template', () => {
307
+ const customCell = el.querySelector('tbody .custom-cell');
308
+ expect(customCell).toBeTruthy();
309
+ expect(customCell?.textContent).toContain('User 01');
310
+ });
311
+
312
+ it('should fall back to plain text for columns without custom template', () => {
313
+ const cells = el.querySelectorAll('tbody tr:first-child td');
314
+ // email column (index 1) should not have .custom-cell
315
+ expect(cells[1].querySelector('.custom-cell')).toBeNull();
316
+ expect(cells[1].textContent).toContain('user1@test.com');
317
+ });
318
+
319
+ // Custom Header Templates
320
+ it('should render custom header template', () => {
321
+ const customHeader = el.querySelector('thead .custom-header');
322
+ expect(customHeader).toBeTruthy();
323
+ expect(customHeader?.textContent).toContain('Name');
324
+ });
325
+
326
+ it('should fall back to default header for columns without custom template', () => {
327
+ const headers = el.querySelectorAll('th');
328
+ // Email header (index 1) should not have .custom-header
329
+ expect(headers[1].querySelector('.custom-header')).toBeNull();
330
+ expect(headers[1].textContent).toContain('Email');
331
+ });
332
+
333
+ // Loading State
334
+ it('should show skeleton rows when loading', () => {
335
+ host.loading.set(true);
336
+ fixture.detectChanges();
337
+
338
+ const skeletons = el.querySelectorAll('[aria-busy="true"]');
339
+ expect(skeletons.length).toBeGreaterThan(0);
340
+ });
341
+
342
+ it('should show correct number of skeleton rows', () => {
343
+ host.loading.set(true);
344
+ host.loadingRows.set(3);
345
+ fixture.detectChanges();
346
+
347
+ const rows = el.querySelectorAll('tbody tr');
348
+ expect(rows.length).toBe(3);
349
+ });
350
+
351
+ it('should show data when loading is false', () => {
352
+ host.loading.set(false);
353
+ fixture.detectChanges();
354
+
355
+ const rows = el.querySelectorAll('tbody tr');
356
+ expect(rows.length).toBe(5);
357
+ expect(rows[0].textContent).toContain('User 01');
358
+ });
359
+
360
+ // Bulk Actions
361
+ it('should not show bulk actions when no rows selected', () => {
362
+ host.selectable.set(true);
363
+ fixture.detectChanges();
364
+
365
+ const bulkBar = el.querySelector('.bulk-action-btn');
366
+ expect(bulkBar).toBeNull();
367
+ });
368
+
369
+ it('should show bulk actions when rows selected and template provided', () => {
370
+ host.selectable.set(true);
371
+ fixture.detectChanges();
372
+
373
+ const checkbox = el.querySelector('tbody input[type="checkbox"]') as HTMLInputElement;
374
+ checkbox.click();
375
+ fixture.detectChanges();
376
+
377
+ const bulkBtn = el.querySelector('.bulk-action-btn');
378
+ expect(bulkBtn).toBeTruthy();
379
+ expect(bulkBtn?.textContent).toContain('Delete (1)');
380
+ });
381
+
382
+ it('should update bulk actions context when selection changes', () => {
383
+ host.selectable.set(true);
384
+ fixture.detectChanges();
385
+
386
+ const checkboxes = el.querySelectorAll('tbody input[type="checkbox"]');
387
+ (checkboxes[0] as HTMLInputElement).click();
388
+ fixture.detectChanges();
389
+ (checkboxes[1] as HTMLInputElement).click();
390
+ fixture.detectChanges();
391
+
392
+ const bulkBtn = el.querySelector('.bulk-action-btn');
393
+ expect(bulkBtn?.textContent).toContain('Delete (2)');
394
+ });
395
+
396
+ // Row Expansion
397
+ it('should show expand button when expandable', () => {
398
+ host.expandable.set(true);
399
+ fixture.detectChanges();
400
+
401
+ const expandBtns = el.querySelectorAll('tbody tr button');
402
+ expect(expandBtns.length).toBeGreaterThan(0);
403
+ });
404
+
405
+ it('should toggle expanded content on click', () => {
406
+ host.expandable.set(true);
407
+ fixture.detectChanges();
408
+
409
+ let expandContent = el.querySelector('.expand-content');
410
+ expect(expandContent).toBeNull();
411
+
412
+ const expandBtn = el.querySelector('tbody tr button') as HTMLElement;
413
+ expandBtn.click();
414
+ fixture.detectChanges();
415
+
416
+ expandContent = el.querySelector('.expand-content');
417
+ expect(expandContent).toBeTruthy();
418
+ expect(expandContent?.textContent).toContain('Details for User 01');
419
+ });
420
+
421
+ it('should collapse expanded row on second click', () => {
422
+ host.expandable.set(true);
423
+ fixture.detectChanges();
424
+
425
+ const expandBtn = el.querySelector('tbody tr button') as HTMLElement;
426
+ expandBtn.click();
427
+ fixture.detectChanges();
428
+ expandBtn.click();
429
+ fixture.detectChanges();
430
+
431
+ const expandContent = el.querySelector('.expand-content');
432
+ expect(expandContent).toBeNull();
433
+ });
434
+
435
+ // Column Visibility
436
+ it('should show column toggle when enabled', () => {
437
+ host.showColumnToggle.set(true);
438
+ fixture.detectChanges();
439
+
440
+ const toggleBtn = el.querySelector('[snydropdowntrigger]');
441
+ expect(toggleBtn).toBeTruthy();
442
+ });
443
+ });