@rs-x/cli 2.0.0-next.0 → 2.0.0-next.11
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 +10 -4
- package/bin/rsx.cjs +679 -153
- package/package.json +14 -1
- package/{rs-x-vscode-extension-2.0.0-next.0.vsix → rs-x-vscode-extension-2.0.0-next.11.vsix} +0 -0
- package/templates/angular-demo/README.md +115 -0
- package/templates/angular-demo/src/app/app.component.css +97 -0
- package/templates/angular-demo/src/app/app.component.html +58 -0
- package/templates/angular-demo/src/app/app.component.ts +52 -0
- package/templates/angular-demo/src/app/virtual-table/row-data.ts +35 -0
- package/templates/angular-demo/src/app/virtual-table/row-model.ts +45 -0
- package/templates/angular-demo/src/app/virtual-table/virtual-table-data.service.ts +136 -0
- package/templates/angular-demo/src/app/virtual-table/virtual-table-model.ts +224 -0
- package/templates/angular-demo/src/app/virtual-table/virtual-table.component.css +174 -0
- package/templates/angular-demo/src/app/virtual-table/virtual-table.component.html +50 -0
- package/templates/angular-demo/src/app/virtual-table/virtual-table.component.ts +83 -0
- package/templates/angular-demo/src/index.html +11 -0
- package/templates/angular-demo/src/main.ts +16 -0
- package/templates/angular-demo/src/styles.css +261 -0
- package/templates/react-demo/README.md +113 -0
- package/templates/react-demo/index.html +12 -0
- package/templates/react-demo/src/app/app.tsx +87 -0
- package/templates/react-demo/src/app/hooks/use-virtual-table-controller.ts +24 -0
- package/templates/react-demo/src/app/hooks/use-virtual-table-viewport.ts +39 -0
- package/templates/react-demo/src/app/virtual-table/row-data.ts +35 -0
- package/templates/react-demo/src/app/virtual-table/row-model.ts +45 -0
- package/templates/react-demo/src/app/virtual-table/virtual-table-controller.ts +247 -0
- package/templates/react-demo/src/app/virtual-table/virtual-table-data.service.ts +126 -0
- package/templates/react-demo/src/app/virtual-table/virtual-table-row.tsx +38 -0
- package/templates/react-demo/src/app/virtual-table/virtual-table-shell.tsx +83 -0
- package/templates/react-demo/src/main.tsx +23 -0
- package/templates/react-demo/src/rsx-bootstrap.ts +18 -0
- package/templates/react-demo/src/styles.css +422 -0
- package/templates/react-demo/tsconfig.json +17 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { type IExpression, rsx } from '@rs-x/expression-parser';
|
|
2
|
+
|
|
3
|
+
import { type RowData, type SortDirection, type SortKey } from './row-data';
|
|
4
|
+
import { createRowModel, type RowModel, updateRowModel } from './row-model';
|
|
5
|
+
import { VirtualTableDataService } from './virtual-table-data.service';
|
|
6
|
+
|
|
7
|
+
export type RowView = {
|
|
8
|
+
index: number;
|
|
9
|
+
top: number;
|
|
10
|
+
row: RowModel;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const ROW_HEIGHT = 36;
|
|
14
|
+
const PAGE_SIZE = 50;
|
|
15
|
+
const POOL_PAGES = 4;
|
|
16
|
+
const CACHE_PADDING_PAGES = 2;
|
|
17
|
+
const RETAIN_PADDING_PAGES = 4;
|
|
18
|
+
|
|
19
|
+
export class VirtualTableModel {
|
|
20
|
+
public rowHeight = ROW_HEIGHT;
|
|
21
|
+
public readonly pageSize = PAGE_SIZE;
|
|
22
|
+
public readonly poolSize = PAGE_SIZE * POOL_PAGES;
|
|
23
|
+
public readonly totalRows: number;
|
|
24
|
+
|
|
25
|
+
public scrollTop = 0;
|
|
26
|
+
public viewportHeight = 480;
|
|
27
|
+
public sortKey: SortKey = 'id';
|
|
28
|
+
public sortDirection: SortDirection = 'asc';
|
|
29
|
+
public spacerHeight: number;
|
|
30
|
+
public rowsInView = Math.max(
|
|
31
|
+
1,
|
|
32
|
+
Math.ceil(this.viewportHeight / this.rowHeight),
|
|
33
|
+
);
|
|
34
|
+
public visibleRows: RowView[] = [];
|
|
35
|
+
public readonly rowsExpression: IExpression<RowView[]>;
|
|
36
|
+
|
|
37
|
+
private readonly rowsModel = {
|
|
38
|
+
rows: [] as RowView[],
|
|
39
|
+
};
|
|
40
|
+
private readonly pool = Array.from({ length: this.poolSize }, () =>
|
|
41
|
+
createRowModel(),
|
|
42
|
+
);
|
|
43
|
+
private readonly dataByIndex = new Map<number, RowData>();
|
|
44
|
+
private readonly loadedPages = new Set<number>();
|
|
45
|
+
private readonly pageLoading = new Map<number, Promise<void>>();
|
|
46
|
+
private readonly dataService = new VirtualTableDataService();
|
|
47
|
+
|
|
48
|
+
public constructor() {
|
|
49
|
+
this.totalRows = this.dataService.totalRows;
|
|
50
|
+
this.spacerHeight = this.totalRows * this.rowHeight;
|
|
51
|
+
this.rowsExpression = rsx<RowView[]>('rows')(this.rowsModel);
|
|
52
|
+
this.refresh();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public get loadedPageCount(): number {
|
|
56
|
+
return this.loadedPages.size;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
public setViewportHeight(height: number): void {
|
|
60
|
+
this.viewportHeight = height;
|
|
61
|
+
this.rowsInView = Math.max(
|
|
62
|
+
1,
|
|
63
|
+
Math.ceil(this.viewportHeight / this.rowHeight),
|
|
64
|
+
);
|
|
65
|
+
this.refresh();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public setRowHeight(height: number): void {
|
|
69
|
+
if (this.rowHeight === height) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.rowHeight = height;
|
|
74
|
+
this.spacerHeight = this.totalRows * this.rowHeight;
|
|
75
|
+
this.rowsInView = Math.max(
|
|
76
|
+
1,
|
|
77
|
+
Math.ceil(this.viewportHeight / this.rowHeight),
|
|
78
|
+
);
|
|
79
|
+
this.refresh();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
public setScrollTop(value: number): void {
|
|
83
|
+
this.scrollTop = value;
|
|
84
|
+
this.refresh();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
public toggleSort(nextKey: SortKey): void {
|
|
88
|
+
if (this.sortKey === nextKey) {
|
|
89
|
+
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
|
90
|
+
} else {
|
|
91
|
+
this.sortKey = nextKey;
|
|
92
|
+
this.sortDirection = 'asc';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.resetLoadedData();
|
|
96
|
+
this.refresh();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private refresh(): void {
|
|
100
|
+
const scrollIndex = Math.floor(this.scrollTop / this.rowHeight);
|
|
101
|
+
const bufferTop = Math.max(
|
|
102
|
+
0,
|
|
103
|
+
Math.floor((this.poolSize - this.rowsInView) / 2),
|
|
104
|
+
);
|
|
105
|
+
const maxStart = Math.max(0, this.totalRows - this.poolSize);
|
|
106
|
+
const startIndex = Math.min(Math.max(scrollIndex - bufferTop, 0), maxStart);
|
|
107
|
+
const endIndex = Math.min(startIndex + this.poolSize, this.totalRows);
|
|
108
|
+
const startPage = Math.max(
|
|
109
|
+
0,
|
|
110
|
+
Math.floor(startIndex / this.pageSize) - CACHE_PADDING_PAGES,
|
|
111
|
+
);
|
|
112
|
+
const endPage = Math.min(
|
|
113
|
+
Math.floor((endIndex - 1) / this.pageSize) + CACHE_PADDING_PAGES,
|
|
114
|
+
Math.floor((this.totalRows - 1) / this.pageSize),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
this.ensurePages(startPage, endPage);
|
|
118
|
+
this.pruneCachedPages(startPage, endPage);
|
|
119
|
+
|
|
120
|
+
const nextRows: RowView[] = [];
|
|
121
|
+
const length = endIndex - startIndex;
|
|
122
|
+
|
|
123
|
+
for (let offset = 0; offset < length; offset += 1) {
|
|
124
|
+
const index = startIndex + offset;
|
|
125
|
+
const target = this.pool[offset];
|
|
126
|
+
updateRowModel(target, this.getRowData(index));
|
|
127
|
+
|
|
128
|
+
nextRows.push({
|
|
129
|
+
index,
|
|
130
|
+
top: index * this.rowHeight,
|
|
131
|
+
row: target,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this.rowsModel.rows = nextRows;
|
|
136
|
+
this.visibleRows = nextRows;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private ensurePages(startPage: number, endPage: number): void {
|
|
140
|
+
for (let pageIndex = startPage; pageIndex <= endPage; pageIndex += 1) {
|
|
141
|
+
this.ensurePageLoaded(pageIndex);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private ensurePageLoaded(pageIndex: number): void {
|
|
146
|
+
if (this.loadedPages.has(pageIndex) || this.pageLoading.has(pageIndex)) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const task = this.loadPageAsync(pageIndex).finally(() => {
|
|
151
|
+
this.pageLoading.delete(pageIndex);
|
|
152
|
+
this.loadedPages.add(pageIndex);
|
|
153
|
+
this.refresh();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
this.pageLoading.set(pageIndex, task);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private async loadPageAsync(pageIndex: number): Promise<void> {
|
|
160
|
+
const page = await this.dataService.fetchPage(
|
|
161
|
+
pageIndex,
|
|
162
|
+
this.pageSize,
|
|
163
|
+
this.sortKey,
|
|
164
|
+
this.sortDirection,
|
|
165
|
+
);
|
|
166
|
+
const startIndex = pageIndex * this.pageSize;
|
|
167
|
+
|
|
168
|
+
for (let offset = 0; offset < page.items.length; offset += 1) {
|
|
169
|
+
const item = page.items[offset];
|
|
170
|
+
if (!item) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
this.dataByIndex.set(startIndex + offset, item);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private getRowData(index: number): RowData {
|
|
179
|
+
const cached = this.dataByIndex.get(index);
|
|
180
|
+
if (cached) {
|
|
181
|
+
return cached;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return this.buildPlaceholderRow(index);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private buildPlaceholderRow(index: number): RowData {
|
|
188
|
+
return {
|
|
189
|
+
id: index + 1,
|
|
190
|
+
name: 'Loading...',
|
|
191
|
+
price: 0,
|
|
192
|
+
quantity: 0,
|
|
193
|
+
category: 'Pending',
|
|
194
|
+
updatedAt: '--',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private resetLoadedData(): void {
|
|
199
|
+
this.dataByIndex.clear();
|
|
200
|
+
this.loadedPages.clear();
|
|
201
|
+
this.pageLoading.clear();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private pruneCachedPages(startPage: number, endPage: number): void {
|
|
205
|
+
const minPage = Math.max(0, startPage - RETAIN_PADDING_PAGES);
|
|
206
|
+
const maxPage = Math.min(
|
|
207
|
+
Math.floor((this.totalRows - 1) / this.pageSize),
|
|
208
|
+
endPage + RETAIN_PADDING_PAGES,
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
for (const pageIndex of Array.from(this.loadedPages)) {
|
|
212
|
+
if (pageIndex >= minPage && pageIndex <= maxPage) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
this.loadedPages.delete(pageIndex);
|
|
217
|
+
const pageStart = pageIndex * this.pageSize;
|
|
218
|
+
const pageEnd = Math.min(pageStart + this.pageSize, this.totalRows);
|
|
219
|
+
for (let index = pageStart; index < pageEnd; index += 1) {
|
|
220
|
+
this.dataByIndex.delete(index);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: block;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.table-toolbar {
|
|
6
|
+
display: flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
justify-content: space-between;
|
|
9
|
+
gap: 16px;
|
|
10
|
+
padding-bottom: 16px;
|
|
11
|
+
border-bottom: 1px solid var(--border-soft);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.toolbar-left h2 {
|
|
15
|
+
margin: 0;
|
|
16
|
+
font-size: 20px;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.toolbar-left p {
|
|
20
|
+
margin: 4px 0 0;
|
|
21
|
+
color: var(--muted);
|
|
22
|
+
font-size: 13px;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.toolbar-right {
|
|
26
|
+
display: flex;
|
|
27
|
+
flex-wrap: wrap;
|
|
28
|
+
gap: 8px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.toolbar-right button {
|
|
32
|
+
border: 1px solid var(--border);
|
|
33
|
+
background: color-mix(in srgb, var(--surface-solid) 88%, var(--brand) 12%);
|
|
34
|
+
color: var(--text);
|
|
35
|
+
padding: 6px 12px;
|
|
36
|
+
border-radius: 999px;
|
|
37
|
+
font-size: 12px;
|
|
38
|
+
cursor: pointer;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.toolbar-right button:hover {
|
|
42
|
+
border-color: var(--focus);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.table-header {
|
|
46
|
+
display: grid;
|
|
47
|
+
grid-template-columns: 80px 1.4fr 1fr 0.8fr 0.6fr 1fr 0.9fr;
|
|
48
|
+
gap: 8px;
|
|
49
|
+
padding: 12px 8px;
|
|
50
|
+
font-size: 12px;
|
|
51
|
+
font-weight: 600;
|
|
52
|
+
color: var(--muted);
|
|
53
|
+
text-transform: uppercase;
|
|
54
|
+
letter-spacing: 0.06em;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.table-viewport {
|
|
58
|
+
position: relative;
|
|
59
|
+
height: 520px;
|
|
60
|
+
overflow: auto;
|
|
61
|
+
border: 1px solid var(--border-soft);
|
|
62
|
+
border-radius: 12px;
|
|
63
|
+
background: color-mix(in srgb, var(--surface-solid) 94%, transparent);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.table-spacer {
|
|
67
|
+
width: 100%;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.table-row {
|
|
71
|
+
position: absolute;
|
|
72
|
+
top: 0;
|
|
73
|
+
left: 0;
|
|
74
|
+
right: 0;
|
|
75
|
+
height: 36px;
|
|
76
|
+
display: grid;
|
|
77
|
+
grid-template-columns: 80px 1.4fr 1fr 0.8fr 0.6fr 1fr 0.9fr;
|
|
78
|
+
align-items: center;
|
|
79
|
+
gap: 8px;
|
|
80
|
+
padding: 0 8px;
|
|
81
|
+
border-bottom: 1px solid var(--border-soft);
|
|
82
|
+
font-size: 13px;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.table-row:nth-child(odd) {
|
|
86
|
+
background: color-mix(in srgb, var(--surface-solid) 84%, transparent);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.table-row .total {
|
|
90
|
+
font-weight: 600;
|
|
91
|
+
color: var(--brand);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.table-footer {
|
|
95
|
+
display: flex;
|
|
96
|
+
justify-content: space-between;
|
|
97
|
+
margin-top: 12px;
|
|
98
|
+
font-size: 12px;
|
|
99
|
+
color: var(--muted);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
:host-context(.theme-dark) .table-viewport {
|
|
103
|
+
background: rgba(12, 18, 35, 0.88);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
:host-context(.theme-dark) .table-row:nth-child(odd) {
|
|
107
|
+
background: rgba(255, 255, 255, 0.03);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
:host-context(.theme-dark) .toolbar-right button {
|
|
111
|
+
background: rgba(122, 182, 255, 0.12);
|
|
112
|
+
border-color: rgba(237, 242, 255, 0.14);
|
|
113
|
+
color: #edf2ff;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@media (max-width: 900px) {
|
|
117
|
+
.table-header,
|
|
118
|
+
.table-row {
|
|
119
|
+
grid-template-columns: 72px 1.3fr 1fr 0.8fr 0.7fr 1fr;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.table-header span:last-child,
|
|
123
|
+
.table-row span:last-child {
|
|
124
|
+
display: none;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@media (max-width: 720px) {
|
|
129
|
+
.table-toolbar,
|
|
130
|
+
.table-footer {
|
|
131
|
+
flex-direction: column;
|
|
132
|
+
align-items: flex-start;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.table-header {
|
|
136
|
+
display: none;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.table-viewport {
|
|
140
|
+
height: 460px;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.table-row {
|
|
144
|
+
height: 168px;
|
|
145
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
146
|
+
align-content: start;
|
|
147
|
+
gap: 10px 16px;
|
|
148
|
+
padding: 14px 16px;
|
|
149
|
+
border: 1px solid var(--border-soft);
|
|
150
|
+
border-radius: 18px;
|
|
151
|
+
margin: 0 8px;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.table-row span {
|
|
155
|
+
display: flex;
|
|
156
|
+
flex-direction: column;
|
|
157
|
+
gap: 4px;
|
|
158
|
+
min-width: 0;
|
|
159
|
+
font-size: 0.95rem;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.table-row span::before {
|
|
163
|
+
content: attr(data-label);
|
|
164
|
+
color: var(--muted);
|
|
165
|
+
font-size: 0.72rem;
|
|
166
|
+
font-weight: 700;
|
|
167
|
+
letter-spacing: 0.06em;
|
|
168
|
+
text-transform: uppercase;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.table-row .total {
|
|
172
|
+
color: var(--brand);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<section class="table-toolbar">
|
|
2
|
+
<div class="toolbar-left">
|
|
3
|
+
<h2>Inventory Snapshot</h2>
|
|
4
|
+
<p>{{ state.totalRows }} rows • {{ state.poolSize }} pre-wired models</p>
|
|
5
|
+
</div>
|
|
6
|
+
<div class="toolbar-right">
|
|
7
|
+
<button type="button" (click)="toggleSort('price')">Sort by price</button>
|
|
8
|
+
<button type="button" (click)="toggleSort('quantity')">
|
|
9
|
+
Sort by stock
|
|
10
|
+
</button>
|
|
11
|
+
<button type="button" (click)="toggleSort('name')">Sort by name</button>
|
|
12
|
+
</div>
|
|
13
|
+
</section>
|
|
14
|
+
|
|
15
|
+
<div class="table-header">
|
|
16
|
+
<span>ID</span>
|
|
17
|
+
<span>Name</span>
|
|
18
|
+
<span>Category</span>
|
|
19
|
+
<span>Price</span>
|
|
20
|
+
<span>Qty</span>
|
|
21
|
+
<span>Total</span>
|
|
22
|
+
<span>Updated</span>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div #scrollViewport class="table-viewport" (scroll)="onScroll($event)">
|
|
26
|
+
<div class="table-spacer" [style.height.px]="state.spacerHeight"></div>
|
|
27
|
+
<div
|
|
28
|
+
class="table-row"
|
|
29
|
+
*ngFor="let item of state.rowsExpression | rsx; trackBy: trackByIndex"
|
|
30
|
+
[style.transform]="'translateY(' + item.top + 'px)'"
|
|
31
|
+
>
|
|
32
|
+
<span data-label="ID">#{{ item.row.idExpr | rsx }}</span>
|
|
33
|
+
<span data-label="Name">{{ item.row.nameExpr | rsx }}</span>
|
|
34
|
+
<span data-label="Category">{{ item.row.categoryExpr | rsx }}</span>
|
|
35
|
+
<span data-label="Price">€{{ item.row.priceExpr | rsx }}</span>
|
|
36
|
+
<span data-label="Qty">{{ item.row.quantityExpr | rsx }}</span>
|
|
37
|
+
<span data-label="Total" class="total"
|
|
38
|
+
>€{{ item.row.totalExpr | rsx }}</span
|
|
39
|
+
>
|
|
40
|
+
<span data-label="Updated">{{ item.row.updatedAtExpr | rsx }}</span>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div class="table-footer">
|
|
45
|
+
<div>
|
|
46
|
+
Rows in view: {{ state.rowsInView }} • Loaded pages:
|
|
47
|
+
{{ state.loadedPageCount }}
|
|
48
|
+
</div>
|
|
49
|
+
<div>Scroll to stream pages from a 1,000,000-row virtual dataset.</div>
|
|
50
|
+
</div>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { CommonModule } from '@angular/common';
|
|
2
|
+
import {
|
|
3
|
+
AfterViewInit,
|
|
4
|
+
ChangeDetectionStrategy,
|
|
5
|
+
Component,
|
|
6
|
+
ElementRef,
|
|
7
|
+
OnDestroy,
|
|
8
|
+
ViewChild,
|
|
9
|
+
} from '@angular/core';
|
|
10
|
+
|
|
11
|
+
import { RsxPipe } from '@rs-x/angular';
|
|
12
|
+
|
|
13
|
+
import { type RowView, VirtualTableModel } from './virtual-table-model';
|
|
14
|
+
|
|
15
|
+
@Component({
|
|
16
|
+
selector: 'rsx-virtual-table',
|
|
17
|
+
standalone: true,
|
|
18
|
+
imports: [CommonModule, RsxPipe],
|
|
19
|
+
templateUrl: './virtual-table.component.html',
|
|
20
|
+
styleUrls: ['./virtual-table.component.css'],
|
|
21
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
22
|
+
})
|
|
23
|
+
export class VirtualTableComponent implements AfterViewInit, OnDestroy {
|
|
24
|
+
private static readonly COMPACT_BREAKPOINT_PX = 720;
|
|
25
|
+
private static readonly DEFAULT_ROW_HEIGHT = 36;
|
|
26
|
+
private static readonly COMPACT_ROW_HEIGHT = 168;
|
|
27
|
+
|
|
28
|
+
public readonly state = new VirtualTableModel();
|
|
29
|
+
|
|
30
|
+
@ViewChild('scrollViewport', { static: true })
|
|
31
|
+
private readonly scrollViewport?: ElementRef<HTMLDivElement>;
|
|
32
|
+
|
|
33
|
+
private resizeObserver?: ResizeObserver;
|
|
34
|
+
|
|
35
|
+
public ngAfterViewInit(): void {
|
|
36
|
+
const viewport = this.scrollViewport?.nativeElement;
|
|
37
|
+
if (!viewport) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.syncViewportMetrics(viewport);
|
|
42
|
+
this.resizeObserver = new ResizeObserver((entries) => {
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
this.state.setViewportHeight(entry.contentRect.height);
|
|
45
|
+
this.state.setRowHeight(
|
|
46
|
+
entry.contentRect.width <= VirtualTableComponent.COMPACT_BREAKPOINT_PX
|
|
47
|
+
? VirtualTableComponent.COMPACT_ROW_HEIGHT
|
|
48
|
+
: VirtualTableComponent.DEFAULT_ROW_HEIGHT,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
this.resizeObserver.observe(viewport);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public ngOnDestroy(): void {
|
|
56
|
+
this.resizeObserver?.disconnect();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
public onScroll(event: Event): void {
|
|
60
|
+
const target = event.target as HTMLDivElement | null;
|
|
61
|
+
if (!target) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
this.state.setScrollTop(target.scrollTop);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
public toggleSort(key: 'id' | 'name' | 'price' | 'quantity'): void {
|
|
68
|
+
this.state.toggleSort(key);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public trackByIndex(_: number, item: RowView): number {
|
|
72
|
+
return item.index;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private syncViewportMetrics(viewport: HTMLDivElement): void {
|
|
76
|
+
this.state.setViewportHeight(viewport.clientHeight);
|
|
77
|
+
this.state.setRowHeight(
|
|
78
|
+
viewport.clientWidth <= VirtualTableComponent.COMPACT_BREAKPOINT_PX
|
|
79
|
+
? VirtualTableComponent.COMPACT_ROW_HEIGHT
|
|
80
|
+
: VirtualTableComponent.DEFAULT_ROW_HEIGHT,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { enableProdMode, isDevMode } from '@angular/core';
|
|
2
|
+
import { bootstrapApplication } from '@angular/platform-browser';
|
|
3
|
+
|
|
4
|
+
import { providexRsx } from '@rs-x/angular';
|
|
5
|
+
|
|
6
|
+
import { AppComponent } from './app/app.component';
|
|
7
|
+
|
|
8
|
+
if (!isDevMode()) {
|
|
9
|
+
enableProdMode();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
bootstrapApplication(AppComponent, {
|
|
13
|
+
providers: [...providexRsx()],
|
|
14
|
+
}).catch((error) => {
|
|
15
|
+
console.error(error);
|
|
16
|
+
});
|