@sonny-ui/core 0.1.0-alpha.13 → 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.13",
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",
@@ -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
+ });