@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/README.md +133 -133
- package/fesm2022/sonny-ui-core.mjs +1430 -695
- package/fesm2022/sonny-ui-core.mjs.map +1 -1
- package/package.json +1 -1
- package/schematics/ng-generate/component/index.js +6 -6
- package/schematics/ng-generate/component/schema.json +32 -32
- package/src/lib/button/button.directive.ts +1 -1
- package/src/lib/calendar/calendar.component.ts +3 -14
- package/src/lib/combobox/combobox.component.ts +2 -15
- package/src/lib/data-table/data-table.component.spec.ts +443 -0
- package/src/lib/data-table/data-table.component.ts +603 -0
- package/src/lib/data-table/data-table.directives.ts +35 -0
- package/src/lib/data-table/data-table.types.ts +20 -0
- package/src/lib/data-table/index.ts +13 -0
- package/src/lib/file-input/file-input.component.ts +2 -14
- package/src/lib/rating/rating.component.ts +11 -18
- package/src/lib/select/select.component.ts +2 -15
- package/src/lib/slider/slider.component.ts +13 -19
- package/src/lib/switch/switch.component.ts +8 -15
- package/src/lib/toggle/toggle.directive.ts +4 -15
- package/src/styles/sonny-theme.css +33 -0
- package/types/sonny-ui-core.d.ts +110 -19
package/package.json
CHANGED
|
@@ -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() && '
|
|
25
|
+
this.loading() && 'cursor-wait opacity-70',
|
|
26
26
|
this.class()
|
|
27
27
|
)
|
|
28
28
|
);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component, computed,
|
|
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
|
+
});
|