@gp-grid/angular 0.10.3
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/LICENSE +203 -0
- package/README.md +78 -0
- package/ng-package.json +10 -0
- package/package.json +71 -0
- package/src/lib/components/filter-popup/filter-logic.ts +243 -0
- package/src/lib/components/filter-popup.component.ts +236 -0
- package/src/lib/components/filter-popup.template.ts +182 -0
- package/src/lib/components/grid-body.component.ts +284 -0
- package/src/lib/components/grid-body.template.ts +93 -0
- package/src/lib/components/grid-header.component.ts +206 -0
- package/src/lib/components/grid-overlays.component.ts +140 -0
- package/src/lib/components/index.ts +4 -0
- package/src/lib/createGridData.ts +82 -0
- package/src/lib/gp-grid-bindings.ts +150 -0
- package/src/lib/gp-grid-view-model.ts +148 -0
- package/src/lib/gp-grid.component.ts +279 -0
- package/src/lib/gp-grid.factory.ts +52 -0
- package/src/lib/gp-grid.template.ts +77 -0
- package/src/lib/styles/index.ts +0 -0
- package/src/lib/types.ts +28 -0
- package/src/public-api.ts +57 -0
- package/tsconfig.json +11 -0
- package/tsconfig.lib.json +27 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Component,
|
|
3
|
+
ChangeDetectionStrategy,
|
|
4
|
+
input,
|
|
5
|
+
output,
|
|
6
|
+
signal,
|
|
7
|
+
effect,
|
|
8
|
+
AfterViewInit,
|
|
9
|
+
OnDestroy,
|
|
10
|
+
ViewChild,
|
|
11
|
+
ElementRef,
|
|
12
|
+
HostListener,
|
|
13
|
+
} from '@angular/core';
|
|
14
|
+
import { calculateFilterPopupPosition } from '@gp-grid/core';
|
|
15
|
+
import type {
|
|
16
|
+
ColumnDefinition,
|
|
17
|
+
CellValue,
|
|
18
|
+
ColumnFilterModel,
|
|
19
|
+
} from '@gp-grid/core';
|
|
20
|
+
import { FILTER_POPUP_TEMPLATE } from './filter-popup.template';
|
|
21
|
+
import {
|
|
22
|
+
MAX_CHECKBOX_VALUES,
|
|
23
|
+
NUMBER_OPERATORS,
|
|
24
|
+
TEXT_OPERATORS,
|
|
25
|
+
type FilterMode,
|
|
26
|
+
type NumberConditionState,
|
|
27
|
+
type TextConditionState,
|
|
28
|
+
buildNumberFilter,
|
|
29
|
+
buildTextFilter,
|
|
30
|
+
computeUniqueValues,
|
|
31
|
+
defaultNumberCondition,
|
|
32
|
+
defaultTextCondition,
|
|
33
|
+
initNumberConditions,
|
|
34
|
+
initTextState,
|
|
35
|
+
isNumberColumn as columnIsNumber,
|
|
36
|
+
isValueLessNumberOp,
|
|
37
|
+
isValueLessTextOp,
|
|
38
|
+
resolveColId,
|
|
39
|
+
} from './filter-popup/filter-logic';
|
|
40
|
+
|
|
41
|
+
@Component({
|
|
42
|
+
selector: 'gp-grid-filter-popup',
|
|
43
|
+
standalone: true,
|
|
44
|
+
imports: [],
|
|
45
|
+
changeDetection: ChangeDetectionStrategy.Eager,
|
|
46
|
+
template: FILTER_POPUP_TEMPLATE,
|
|
47
|
+
})
|
|
48
|
+
export class FilterPopupComponent implements AfterViewInit, OnDestroy {
|
|
49
|
+
@ViewChild('popupEl', { static: false }) popupEl!: ElementRef<HTMLDivElement>;
|
|
50
|
+
|
|
51
|
+
column = input.required<ColumnDefinition>();
|
|
52
|
+
colIndex = input.required<number>();
|
|
53
|
+
anchorEl = input.required<HTMLElement>();
|
|
54
|
+
distinctValues = input.required<CellValue[]>();
|
|
55
|
+
currentFilter = input<ColumnFilterModel | undefined>(undefined);
|
|
56
|
+
|
|
57
|
+
apply = output<{ colId: string; filter: ColumnFilterModel | null }>();
|
|
58
|
+
close = output<void>();
|
|
59
|
+
|
|
60
|
+
popupTop = signal(0);
|
|
61
|
+
popupLeft = signal(0);
|
|
62
|
+
popupMinWidth = signal(200);
|
|
63
|
+
positioned = signal(false);
|
|
64
|
+
|
|
65
|
+
filterMode: FilterMode = 'values';
|
|
66
|
+
searchText = '';
|
|
67
|
+
selectedValues = new Set<string>();
|
|
68
|
+
includeBlanks = true;
|
|
69
|
+
textConditions: TextConditionState[] = [defaultTextCondition()];
|
|
70
|
+
numberConditions: NumberConditionState[] = [defaultNumberCondition()];
|
|
71
|
+
|
|
72
|
+
readonly textOperators = TEXT_OPERATORS;
|
|
73
|
+
readonly numberOperators = NUMBER_OPERATORS;
|
|
74
|
+
|
|
75
|
+
protected readonly isValueLessTextOp = isValueLessTextOp;
|
|
76
|
+
protected readonly isValueLessNumberOp = isValueLessNumberOp;
|
|
77
|
+
|
|
78
|
+
constructor() {
|
|
79
|
+
effect(() => {
|
|
80
|
+
this.anchorEl();
|
|
81
|
+
this.currentFilter();
|
|
82
|
+
this.initFromCurrentFilter();
|
|
83
|
+
requestAnimationFrame(() => this.updatePosition());
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
ngAfterViewInit(): void {
|
|
88
|
+
requestAnimationFrame(() => {
|
|
89
|
+
document.addEventListener('pointerdown', this.onDocumentPointerDown, true);
|
|
90
|
+
});
|
|
91
|
+
window.addEventListener('resize', this.onWindowResize);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
ngOnDestroy(): void {
|
|
95
|
+
document.removeEventListener('pointerdown', this.onDocumentPointerDown, true);
|
|
96
|
+
window.removeEventListener('resize', this.onWindowResize);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@HostListener('keydown.escape')
|
|
100
|
+
onEscape(): void {
|
|
101
|
+
this.close.emit();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
isNumberColumn(): boolean {
|
|
105
|
+
return columnIsNumber(this.column());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
showValuesMode(): boolean {
|
|
109
|
+
return this.uniqueValues().length <= MAX_CHECKBOX_VALUES;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
uniqueValues(): string[] {
|
|
113
|
+
return computeUniqueValues(this.distinctValues());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
filteredUniqueValues(): string[] {
|
|
117
|
+
const search = this.searchText.toLowerCase();
|
|
118
|
+
if (!search) return this.uniqueValues();
|
|
119
|
+
return this.uniqueValues().filter(v => v.toLowerCase().includes(search));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
toggleValue(val: string, checked: boolean): void {
|
|
123
|
+
if (checked) {
|
|
124
|
+
this.selectedValues.add(val);
|
|
125
|
+
} else {
|
|
126
|
+
this.selectedValues.delete(val);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
selectAll(): void {
|
|
131
|
+
this.includeBlanks = true;
|
|
132
|
+
for (const v of this.uniqueValues()) this.selectedValues.add(v);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
deselectAll(): void {
|
|
136
|
+
this.includeBlanks = false;
|
|
137
|
+
this.selectedValues.clear();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
onTextOperatorChange(index: number, value: string): void {
|
|
141
|
+
setField(this.textConditions, index, 'operator', value);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
onNumberOperatorChange(index: number, value: string): void {
|
|
145
|
+
setField(this.numberConditions, index, 'operator', value);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
addTextCondition(): void {
|
|
149
|
+
this.textConditions.push(defaultTextCondition());
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
addNumberCondition(): void {
|
|
153
|
+
this.numberConditions.push(defaultNumberCondition());
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
removeTextCondition(index: number): void {
|
|
157
|
+
this.textConditions.splice(index, 1);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
removeNumberCondition(index: number): void {
|
|
161
|
+
this.numberConditions.splice(index, 1);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
setTextNextOp(index: number, value: 'and' | 'or'): void {
|
|
165
|
+
setField(this.textConditions, index, 'nextOperator', value);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
setNumberNextOp(index: number, value: 'and' | 'or'): void {
|
|
169
|
+
setField(this.numberConditions, index, 'nextOperator', value);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
handleApply(): void {
|
|
173
|
+
this.apply.emit({
|
|
174
|
+
colId: resolveColId(this.column()),
|
|
175
|
+
filter: this.buildFilter(),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
handleClear(): void {
|
|
180
|
+
this.apply.emit({ colId: resolveColId(this.column()), filter: null });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private buildFilter(): ColumnFilterModel | null {
|
|
184
|
+
if (this.isNumberColumn()) return buildNumberFilter(this.numberConditions);
|
|
185
|
+
return buildTextFilter({
|
|
186
|
+
filterMode: this.filterMode,
|
|
187
|
+
uniqueValues: this.uniqueValues(),
|
|
188
|
+
selectedValues: this.selectedValues,
|
|
189
|
+
includeBlanks: this.includeBlanks,
|
|
190
|
+
textConditions: this.textConditions,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private initFromCurrentFilter(): void {
|
|
195
|
+
const filter = this.currentFilter();
|
|
196
|
+
if (this.isNumberColumn()) {
|
|
197
|
+
this.numberConditions = initNumberConditions(filter);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const state = initTextState(filter, this.uniqueValues());
|
|
201
|
+
this.filterMode = state.filterMode;
|
|
202
|
+
this.selectedValues = state.selectedValues;
|
|
203
|
+
this.includeBlanks = state.includeBlanks;
|
|
204
|
+
this.textConditions = state.textConditions;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private updatePosition(): void {
|
|
208
|
+
if (!this.popupEl?.nativeElement) return;
|
|
209
|
+
const pos = calculateFilterPopupPosition(this.anchorEl(), this.popupEl.nativeElement);
|
|
210
|
+
this.popupTop.set(pos.top);
|
|
211
|
+
this.popupLeft.set(pos.left);
|
|
212
|
+
this.popupMinWidth.set(pos.minWidth);
|
|
213
|
+
this.positioned.set(true);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private onDocumentPointerDown = (event: Event): void => {
|
|
217
|
+
const target = event.target as HTMLElement;
|
|
218
|
+
if (target.closest('.gp-grid-filter-icon')) return;
|
|
219
|
+
if (this.popupEl?.nativeElement?.contains(target)) return;
|
|
220
|
+
this.close.emit();
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
private onWindowResize = (): void => {
|
|
224
|
+
this.updatePosition();
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const setField = <T, K extends keyof T>(
|
|
229
|
+
arr: T[],
|
|
230
|
+
index: number,
|
|
231
|
+
key: K,
|
|
232
|
+
value: T[K],
|
|
233
|
+
): void => {
|
|
234
|
+
const item = arr[index];
|
|
235
|
+
if (item) item[key] = value;
|
|
236
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
export const FILTER_POPUP_TEMPLATE = `
|
|
2
|
+
<div
|
|
3
|
+
#popupEl
|
|
4
|
+
class="gp-grid-filter-popup"
|
|
5
|
+
[style.position]="'fixed'"
|
|
6
|
+
[style.zIndex]="10000"
|
|
7
|
+
[style.top.px]="popupTop()"
|
|
8
|
+
[style.left.px]="popupLeft()"
|
|
9
|
+
[style.minWidth.px]="popupMinWidth()"
|
|
10
|
+
[style.visibility]="positioned() ? 'visible' : 'hidden'"
|
|
11
|
+
(keydown.escape)="close.emit()"
|
|
12
|
+
(click)="$event.stopPropagation()">
|
|
13
|
+
|
|
14
|
+
<div class="gp-grid-filter-header">
|
|
15
|
+
Filter: {{ column().headerName ?? column().field }}
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div [class]="'gp-grid-filter-content ' + (isNumberColumn() ? 'gp-grid-filter-number' : 'gp-grid-filter-text')">
|
|
19
|
+
@if (isNumberColumn()) {
|
|
20
|
+
@for (cond of numberConditions; track $index; let i = $index) {
|
|
21
|
+
<div class="gp-grid-filter-condition">
|
|
22
|
+
@if (i > 0) {
|
|
23
|
+
<div class="gp-grid-filter-combination">
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
[class.active]="numberConditions[i - 1]?.nextOperator === 'and'"
|
|
27
|
+
(click)="setNumberNextOp(i - 1, 'and')">
|
|
28
|
+
AND
|
|
29
|
+
</button>
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
[class.active]="numberConditions[i - 1]?.nextOperator === 'or'"
|
|
33
|
+
(click)="setNumberNextOp(i - 1, 'or')">
|
|
34
|
+
OR
|
|
35
|
+
</button>
|
|
36
|
+
</div>
|
|
37
|
+
}
|
|
38
|
+
<div class="gp-grid-filter-row">
|
|
39
|
+
<select
|
|
40
|
+
[value]="cond.operator"
|
|
41
|
+
(change)="onNumberOperatorChange(i, $any($event.target).value)">
|
|
42
|
+
@for (op of numberOperators; track op.value) {
|
|
43
|
+
<option [value]="op.value">{{ op.label }}</option>
|
|
44
|
+
}
|
|
45
|
+
</select>
|
|
46
|
+
@if (!isValueLessNumberOp(cond.operator)) {
|
|
47
|
+
<input
|
|
48
|
+
type="number"
|
|
49
|
+
[value]="cond.value"
|
|
50
|
+
(input)="cond.value = $any($event.target).value"
|
|
51
|
+
placeholder="Value" />
|
|
52
|
+
@if (cond.operator === 'between') {
|
|
53
|
+
<span class="gp-grid-filter-to">to</span>
|
|
54
|
+
<input
|
|
55
|
+
type="number"
|
|
56
|
+
[value]="cond.valueTo"
|
|
57
|
+
(input)="cond.valueTo = $any($event.target).value"
|
|
58
|
+
placeholder="Value" />
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
@if (numberConditions.length > 1) {
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
class="gp-grid-filter-remove"
|
|
65
|
+
(click)="removeNumberCondition(i)">×</button>
|
|
66
|
+
}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
}
|
|
70
|
+
<button type="button" class="gp-grid-filter-add" (click)="addNumberCondition()">
|
|
71
|
+
+ Add condition
|
|
72
|
+
</button>
|
|
73
|
+
} @else {
|
|
74
|
+
@if (showValuesMode()) {
|
|
75
|
+
<div class="gp-grid-filter-mode-toggle">
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
[class.active]="filterMode === 'values'"
|
|
79
|
+
(click)="filterMode = 'values'">
|
|
80
|
+
Values
|
|
81
|
+
</button>
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
[class.active]="filterMode === 'condition'"
|
|
85
|
+
(click)="filterMode = 'condition'">
|
|
86
|
+
Condition
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@if (filterMode === 'values' && showValuesMode()) {
|
|
92
|
+
<input
|
|
93
|
+
class="gp-grid-filter-search"
|
|
94
|
+
type="text"
|
|
95
|
+
[value]="searchText"
|
|
96
|
+
(input)="searchText = $any($event.target).value"
|
|
97
|
+
placeholder="Search..." />
|
|
98
|
+
<div class="gp-grid-filter-actions">
|
|
99
|
+
<button type="button" (click)="selectAll()">Select All</button>
|
|
100
|
+
<button type="button" (click)="deselectAll()">Deselect All</button>
|
|
101
|
+
</div>
|
|
102
|
+
<div class="gp-grid-filter-list">
|
|
103
|
+
<label class="gp-grid-filter-option">
|
|
104
|
+
<input
|
|
105
|
+
type="checkbox"
|
|
106
|
+
[checked]="includeBlanks"
|
|
107
|
+
(change)="includeBlanks = $any($event.target).checked" />
|
|
108
|
+
<span class="gp-grid-filter-blank">(Blanks)</span>
|
|
109
|
+
</label>
|
|
110
|
+
@for (val of filteredUniqueValues(); track val) {
|
|
111
|
+
<label class="gp-grid-filter-option">
|
|
112
|
+
<input
|
|
113
|
+
type="checkbox"
|
|
114
|
+
[checked]="selectedValues.has(val)"
|
|
115
|
+
(change)="toggleValue(val, $any($event.target).checked)" />
|
|
116
|
+
<span>{{ val }}</span>
|
|
117
|
+
</label>
|
|
118
|
+
}
|
|
119
|
+
</div>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@if (filterMode === 'condition') {
|
|
123
|
+
@for (cond of textConditions; track $index; let i = $index) {
|
|
124
|
+
<div class="gp-grid-filter-condition">
|
|
125
|
+
@if (i > 0) {
|
|
126
|
+
<div class="gp-grid-filter-combination">
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
[class.active]="textConditions[i - 1]?.nextOperator === 'and'"
|
|
130
|
+
(click)="setTextNextOp(i - 1, 'and')">
|
|
131
|
+
AND
|
|
132
|
+
</button>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
[class.active]="textConditions[i - 1]?.nextOperator === 'or'"
|
|
136
|
+
(click)="setTextNextOp(i - 1, 'or')">
|
|
137
|
+
OR
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
}
|
|
141
|
+
<div class="gp-grid-filter-row">
|
|
142
|
+
<select
|
|
143
|
+
[value]="cond.operator"
|
|
144
|
+
(change)="onTextOperatorChange(i, $any($event.target).value)">
|
|
145
|
+
@for (op of textOperators; track op.value) {
|
|
146
|
+
<option [value]="op.value">{{ op.label }}</option>
|
|
147
|
+
}
|
|
148
|
+
</select>
|
|
149
|
+
@if (!isValueLessTextOp(cond.operator)) {
|
|
150
|
+
<input
|
|
151
|
+
class="gp-grid-filter-text-input"
|
|
152
|
+
type="text"
|
|
153
|
+
[value]="cond.value"
|
|
154
|
+
(input)="cond.value = $any($event.target).value"
|
|
155
|
+
placeholder="Value" />
|
|
156
|
+
}
|
|
157
|
+
@if (textConditions.length > 1) {
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
class="gp-grid-filter-remove"
|
|
161
|
+
(click)="removeTextCondition(i)">×</button>
|
|
162
|
+
}
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
}
|
|
166
|
+
<button type="button" class="gp-grid-filter-add" (click)="addTextCondition()">
|
|
167
|
+
+ Add condition
|
|
168
|
+
</button>
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
<div class="gp-grid-filter-buttons">
|
|
173
|
+
<button type="button" class="gp-grid-filter-btn-clear" (click)="handleClear()">
|
|
174
|
+
Clear
|
|
175
|
+
</button>
|
|
176
|
+
<button type="button" class="gp-grid-filter-btn-apply" (click)="handleApply()">
|
|
177
|
+
Apply
|
|
178
|
+
</button>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
`;
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, ElementRef, input, output, TemplateRef, ViewChild } from "@angular/core";
|
|
2
|
+
import { NgTemplateOutlet } from "@angular/common";
|
|
3
|
+
import {
|
|
4
|
+
formatCellValue,
|
|
5
|
+
getFieldValue,
|
|
6
|
+
buildCellClasses,
|
|
7
|
+
isCellActive,
|
|
8
|
+
isCellEditing,
|
|
9
|
+
isCellInFillPreview,
|
|
10
|
+
isCellSelected,
|
|
11
|
+
SlotData,
|
|
12
|
+
VisibleColumnInfo,
|
|
13
|
+
CellPosition,
|
|
14
|
+
CellRange,
|
|
15
|
+
CellValue,
|
|
16
|
+
ColumnDefinition,
|
|
17
|
+
CellRendererParams,
|
|
18
|
+
EditRendererParams,
|
|
19
|
+
FillHandlePosition,
|
|
20
|
+
DragState,
|
|
21
|
+
} from "@gp-grid/core";
|
|
22
|
+
import { GRID_BODY_TEMPLATE } from "./grid-body.template";
|
|
23
|
+
|
|
24
|
+
export type RowClassFn = (rowIndex: number, rowData: unknown) => string[];
|
|
25
|
+
export type CellClassFn = (
|
|
26
|
+
rowIndex: number,
|
|
27
|
+
colIndex: number,
|
|
28
|
+
column: ColumnDefinition,
|
|
29
|
+
rowData: unknown,
|
|
30
|
+
) => string[];
|
|
31
|
+
|
|
32
|
+
export type CellRendererTemplate = TemplateRef<{ $implicit: CellRendererParams }>;
|
|
33
|
+
export type EditRendererTemplate = TemplateRef<{ $implicit: EditRendererParams }>;
|
|
34
|
+
|
|
35
|
+
export interface FillHandlePointerDownEvent {
|
|
36
|
+
event: PointerEvent;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CellPointerDownEvent {
|
|
40
|
+
rowIndex: number;
|
|
41
|
+
colIndex: number;
|
|
42
|
+
event: PointerEvent;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CellPointerEnterEvent {
|
|
46
|
+
rowIndex: number;
|
|
47
|
+
colIndex: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CellDoubleClickEvent {
|
|
51
|
+
rowIndex: number;
|
|
52
|
+
colIndex: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface EditingCellState {
|
|
56
|
+
row: number;
|
|
57
|
+
col: number;
|
|
58
|
+
initialValue: CellValue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@Component({
|
|
62
|
+
selector: "gp-grid-body",
|
|
63
|
+
standalone: true,
|
|
64
|
+
imports: [NgTemplateOutlet],
|
|
65
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
66
|
+
styles: [`:host { display: flex; flex: 1; min-height: 0; overflow: hidden; }`],
|
|
67
|
+
template: GRID_BODY_TEMPLATE,
|
|
68
|
+
})
|
|
69
|
+
export class GridBodyComponent {
|
|
70
|
+
@ViewChild("scrollContainer") scrollContainer!: ElementRef<HTMLDivElement>;
|
|
71
|
+
rowHeight = input.required<number>();
|
|
72
|
+
totalHeaderHeight = input.required<number>();
|
|
73
|
+
contentWidth = input.required<number>();
|
|
74
|
+
contentHeight = input.required<number>();
|
|
75
|
+
rowsWrapperOffset = input.required<number>();
|
|
76
|
+
slotsArray = input.required<SlotData[]>();
|
|
77
|
+
visibleColumnWithIndices = input.required<VisibleColumnInfo[]>();
|
|
78
|
+
totalWidth = input.required<number>();
|
|
79
|
+
columnPositions = input.required<number[]>();
|
|
80
|
+
columnWidths = input.required<number[]>();
|
|
81
|
+
totalRows = input.required<number>();
|
|
82
|
+
activeCell = input<CellPosition | null>(null);
|
|
83
|
+
selectionRange = input<CellRange | null>(null);
|
|
84
|
+
editingCell = input<EditingCellState | null>(null);
|
|
85
|
+
cellRenderers = input<Record<string, CellRendererTemplate>>({});
|
|
86
|
+
globalCellRenderer = input<CellRendererTemplate | null>(null);
|
|
87
|
+
editRenderers = input<Record<string, EditRendererTemplate>>({});
|
|
88
|
+
globalEditRenderer = input<EditRendererTemplate | null>(null);
|
|
89
|
+
hoverPosition = input<CellPosition | null>(null);
|
|
90
|
+
computeRowClasses = input<RowClassFn | null>(null);
|
|
91
|
+
computeCellClasses = input<CellClassFn | null>(null);
|
|
92
|
+
fillHandlePosition = input<FillHandlePosition | null>(null);
|
|
93
|
+
dragState = input<DragState | null>(null);
|
|
94
|
+
|
|
95
|
+
scrolled = output<number>();
|
|
96
|
+
cellPointerDown = output<CellPointerDownEvent>();
|
|
97
|
+
cellPointerEnter = output<CellPointerEnterEvent>();
|
|
98
|
+
cellPointerLeave = output<void>();
|
|
99
|
+
cellDoubleClick = output<CellDoubleClickEvent>();
|
|
100
|
+
editValueChange = output<string>();
|
|
101
|
+
editCommit = output<void>();
|
|
102
|
+
editCancel = output<void>();
|
|
103
|
+
fillHandlePointerDown = output<FillHandlePointerDownEvent>();
|
|
104
|
+
|
|
105
|
+
protected innerWidth = computed(() =>
|
|
106
|
+
Math.max(this.contentWidth(), this.totalWidth()),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
protected sizerHeight = computed(() =>
|
|
110
|
+
Math.max(this.contentHeight() - this.totalHeaderHeight(), 0),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
protected rowDropIndicator = computed(() => {
|
|
114
|
+
const ds = this.dragState();
|
|
115
|
+
if (ds?.dragType !== 'row-drag') return null;
|
|
116
|
+
if (ds.rowDrag === null || ds.rowDrag.dropTargetIndex === null) return null;
|
|
117
|
+
return ds.rowDrag;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
protected rowDropIndicatorWidth = computed(() =>
|
|
121
|
+
Math.max(this.contentWidth(), this.totalWidth()),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
protected wrapperTransform = computed(() =>
|
|
125
|
+
`translateY(${this.rowsWrapperOffset()}px)`);
|
|
126
|
+
|
|
127
|
+
protected onScroll(): void {
|
|
128
|
+
const el = this.scrollContainer.nativeElement;
|
|
129
|
+
this.scrolled.emit(el.scrollLeft);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
protected cellParams(
|
|
133
|
+
rowData: unknown,
|
|
134
|
+
column: ColumnDefinition,
|
|
135
|
+
rowIndex: number,
|
|
136
|
+
colIndex: number,
|
|
137
|
+
): CellRendererParams {
|
|
138
|
+
return {
|
|
139
|
+
value: getFieldValue(rowData, column.field),
|
|
140
|
+
rowData,
|
|
141
|
+
column,
|
|
142
|
+
rowIndex,
|
|
143
|
+
colIndex,
|
|
144
|
+
isActive: isCellActive(rowIndex, colIndex, this.activeCell()),
|
|
145
|
+
isSelected: isCellSelected(rowIndex, colIndex, this.selectionRange()),
|
|
146
|
+
isEditing: false,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
protected cellTemplate(column: ColumnDefinition): CellRendererTemplate | null {
|
|
151
|
+
const renderer: unknown = column.cellRenderer;
|
|
152
|
+
if (renderer instanceof TemplateRef) {
|
|
153
|
+
return renderer as CellRendererTemplate;
|
|
154
|
+
}
|
|
155
|
+
if (typeof renderer === 'string') {
|
|
156
|
+
const registered = this.cellRenderers()[renderer];
|
|
157
|
+
if (registered) return registered;
|
|
158
|
+
}
|
|
159
|
+
return this.globalCellRenderer();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
protected editTemplate(column: ColumnDefinition): EditRendererTemplate | null {
|
|
163
|
+
const renderer: unknown = (column as { editRenderer?: unknown }).editRenderer;
|
|
164
|
+
if (renderer instanceof TemplateRef) {
|
|
165
|
+
return renderer as EditRendererTemplate;
|
|
166
|
+
}
|
|
167
|
+
if (typeof renderer === 'string') {
|
|
168
|
+
const registered = this.editRenderers()[renderer];
|
|
169
|
+
if (registered) return registered;
|
|
170
|
+
}
|
|
171
|
+
return this.globalEditRenderer();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
protected editParams(
|
|
175
|
+
rowData: unknown,
|
|
176
|
+
column: ColumnDefinition,
|
|
177
|
+
rowIndex: number,
|
|
178
|
+
colIndex: number,
|
|
179
|
+
): EditRendererParams {
|
|
180
|
+
const ec = this.editingCell();
|
|
181
|
+
return {
|
|
182
|
+
value: getFieldValue(rowData, column.field),
|
|
183
|
+
rowData,
|
|
184
|
+
column,
|
|
185
|
+
rowIndex,
|
|
186
|
+
colIndex,
|
|
187
|
+
isActive: true,
|
|
188
|
+
isSelected: true,
|
|
189
|
+
isEditing: true,
|
|
190
|
+
initialValue: ec?.initialValue ?? null,
|
|
191
|
+
onValueChange: (newValue) => {
|
|
192
|
+
const s = newValue === null || newValue === undefined ? '' : String(newValue);
|
|
193
|
+
this.editValueChange.emit(s);
|
|
194
|
+
},
|
|
195
|
+
onCommit: () => this.editCommit.emit(),
|
|
196
|
+
onCancel: () => this.editCancel.emit(),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
protected cellDisplay(
|
|
201
|
+
rowData: unknown,
|
|
202
|
+
column: ColumnDefinition,
|
|
203
|
+
rowIndex: number,
|
|
204
|
+
colIndex: number,
|
|
205
|
+
): string {
|
|
206
|
+
const renderer = column.cellRenderer;
|
|
207
|
+
const value = getFieldValue(rowData, column.field);
|
|
208
|
+
if (typeof renderer === 'function') {
|
|
209
|
+
const params = this.cellParams(rowData, column, rowIndex, colIndex);
|
|
210
|
+
const result = renderer(params);
|
|
211
|
+
return result === null || result === undefined ? '' : String(result);
|
|
212
|
+
}
|
|
213
|
+
return formatCellValue(value, column.valueFormatter);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
protected cellClass(
|
|
217
|
+
rowIndex: number,
|
|
218
|
+
colIndex: number,
|
|
219
|
+
column: ColumnDefinition,
|
|
220
|
+
rowData: unknown,
|
|
221
|
+
): string {
|
|
222
|
+
const editingCell = this.editingCell();
|
|
223
|
+
// Read hoverPosition to register this signal as a dep so Angular re-renders on hover change.
|
|
224
|
+
this.hoverPosition();
|
|
225
|
+
const ds = this.dragState();
|
|
226
|
+
const inFillPreview = isCellInFillPreview(
|
|
227
|
+
rowIndex,
|
|
228
|
+
colIndex,
|
|
229
|
+
ds?.dragType === "fill",
|
|
230
|
+
ds?.fillSourceRange ?? null,
|
|
231
|
+
ds?.fillTarget ?? null,
|
|
232
|
+
);
|
|
233
|
+
const base = buildCellClasses(
|
|
234
|
+
isCellActive(rowIndex, colIndex, this.activeCell()),
|
|
235
|
+
isCellSelected(rowIndex, colIndex, this.selectionRange()),
|
|
236
|
+
isCellEditing(rowIndex, colIndex, editingCell),
|
|
237
|
+
inFillPreview,
|
|
238
|
+
);
|
|
239
|
+
const withHandle = column.rowDrag === true
|
|
240
|
+
? `${base} gp-grid-cell--row-drag-handle`
|
|
241
|
+
: base;
|
|
242
|
+
const fn = this.computeCellClasses();
|
|
243
|
+
if (fn === null) return withHandle;
|
|
244
|
+
const extra = fn(rowIndex, colIndex, column, rowData);
|
|
245
|
+
if (extra.length === 0) return withHandle;
|
|
246
|
+
return `${withHandle} ${extra.join(' ')}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
protected rowClass(rowIndex: number, rowData: unknown): string {
|
|
250
|
+
this.hoverPosition();
|
|
251
|
+
const fn = this.computeRowClasses();
|
|
252
|
+
if (fn === null) return 'gp-grid-row';
|
|
253
|
+
const extra = fn(rowIndex, rowData);
|
|
254
|
+
if (extra.length === 0) return 'gp-grid-row';
|
|
255
|
+
return `gp-grid-row ${extra.join(' ')}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
protected isEditing(rowIndex: number, colIndex: number): boolean {
|
|
259
|
+
return isCellEditing(rowIndex, colIndex, this.editingCell());
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
protected editInitialValue(): string {
|
|
263
|
+
const ec = this.editingCell();
|
|
264
|
+
if (ec === null || ec.initialValue === null || ec.initialValue === undefined) return '';
|
|
265
|
+
return String(ec.initialValue);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
protected asInput(event: Event): HTMLInputElement {
|
|
269
|
+
return event.target as HTMLInputElement;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
protected onEditFocus(event: FocusEvent): void {
|
|
273
|
+
(event.target as HTMLInputElement).select();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
protected onEditKeyDown(event: KeyboardEvent): void {
|
|
277
|
+
event.stopPropagation();
|
|
278
|
+
if (event.key === 'Enter') {
|
|
279
|
+
this.editCommit.emit();
|
|
280
|
+
} else if (event.key === 'Escape') {
|
|
281
|
+
this.editCancel.emit();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|