@alaarab/ogrid-angular-radix 2.0.4

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.
Files changed (33) hide show
  1. package/README.md +76 -0
  2. package/dist/esm/column-chooser/column-chooser.component.js +199 -0
  3. package/dist/esm/column-header-filter/column-header-filter.component.js +497 -0
  4. package/dist/esm/datagrid-table/datagrid-table.component.js +573 -0
  5. package/dist/esm/index.js +14 -0
  6. package/dist/esm/ogrid/ogrid.component.js +77 -0
  7. package/dist/esm/pagination-controls/pagination-controls.component.js +189 -0
  8. package/dist/types/column-chooser/column-chooser.component.d.ts +26 -0
  9. package/dist/types/column-header-filter/column-header-filter.component.d.ts +67 -0
  10. package/dist/types/datagrid-table/datagrid-table.component.d.ts +131 -0
  11. package/dist/types/index.d.ts +12 -0
  12. package/dist/types/ogrid/ogrid.component.d.ts +14 -0
  13. package/dist/types/pagination-controls/pagination-controls.component.d.ts +15 -0
  14. package/jest-mocks/angular-cdk-overlay.cjs.js +38 -0
  15. package/jest-mocks/style-mock.js +1 -0
  16. package/jest.config.js +43 -0
  17. package/package.json +37 -0
  18. package/scripts/compile-styles.js +53 -0
  19. package/src/__tests__/column-chooser.component.spec.ts.skip +195 -0
  20. package/src/__tests__/column-header-filter.component.spec.ts.skip +401 -0
  21. package/src/__tests__/datagrid-table.component.spec.ts.skip +417 -0
  22. package/src/__tests__/exports.test.ts +54 -0
  23. package/src/__tests__/ogrid.component.spec.ts.skip +236 -0
  24. package/src/__tests__/pagination-controls.component.spec.ts.skip +190 -0
  25. package/src/column-chooser/column-chooser.component.ts +204 -0
  26. package/src/column-header-filter/column-header-filter.component.ts +528 -0
  27. package/src/datagrid-table/datagrid-table.component.scss +289 -0
  28. package/src/datagrid-table/datagrid-table.component.ts +636 -0
  29. package/src/index.ts +16 -0
  30. package/src/ogrid/ogrid.component.ts +78 -0
  31. package/src/pagination-controls/pagination-controls.component.ts +187 -0
  32. package/tsconfig.build.json +9 -0
  33. package/tsconfig.json +21 -0
@@ -0,0 +1,528 @@
1
+ import {
2
+ Component, input, signal, computed,
3
+ ChangeDetectionStrategy, ElementRef, viewChild,
4
+ } from '@angular/core';
5
+ import type { ColumnFilterType, IDateFilterValue, UserLike } from '@alaarab/ogrid-angular';
6
+
7
+ export interface IColumnHeaderFilterProps {
8
+ columnKey: string;
9
+ columnName: string;
10
+ filterType: ColumnFilterType;
11
+ isSorted?: boolean;
12
+ isSortedDescending?: boolean;
13
+ onSort?: () => void;
14
+ selectedValues?: string[];
15
+ onFilterChange?: (values: string[]) => void;
16
+ options?: string[];
17
+ isLoadingOptions?: boolean;
18
+ textValue?: string;
19
+ onTextChange?: (value: string) => void;
20
+ selectedUser?: UserLike;
21
+ onUserChange?: (user: UserLike | undefined) => void;
22
+ peopleSearch?: (query: string) => Promise<UserLike[]>;
23
+ dateValue?: IDateFilterValue;
24
+ onDateChange?: (value: IDateFilterValue | undefined) => void;
25
+ }
26
+
27
+ /**
28
+ * Column header filter component for Angular Radix (lightweight styling).
29
+ * Standalone component with inline template and positioned popovers.
30
+ */
31
+ @Component({
32
+ selector: 'column-header-filter',
33
+ standalone: true,
34
+ changeDetection: ChangeDetectionStrategy.OnPush,
35
+ template: `
36
+ <div class="ogrid-header-filter" #headerEl>
37
+ <div class="ogrid-header-filter__label">
38
+ <span class="ogrid-header-filter__name" [title]="columnName()" data-header-label>
39
+ {{ columnName() }}
40
+ </span>
41
+ </div>
42
+
43
+ <div class="ogrid-header-filter__actions">
44
+ @if (onSort()) {
45
+ <button
46
+ class="ogrid-header-filter__btn"
47
+ [class.ogrid-header-filter__btn--active]="isSorted()"
48
+ (click)="onSort()!()"
49
+ [attr.aria-label]="'Sort by ' + columnName()"
50
+ [title]="isSorted() ? (isSortedDescending() ? 'Sorted descending' : 'Sorted ascending') : 'Sort'"
51
+ >
52
+ @if (isSorted() && isSortedDescending()) {
53
+
54
+ } @else if (isSorted()) {
55
+
56
+ } @else {
57
+
58
+ }
59
+ </button>
60
+ }
61
+
62
+ @if (filterType() !== 'none') {
63
+ <button
64
+ class="ogrid-header-filter__btn"
65
+ [class.ogrid-header-filter__btn--active]="hasActiveFilter() || isFilterOpen()"
66
+ (click)="toggleFilter($event)"
67
+ [attr.aria-label]="'Filter ' + columnName()"
68
+ [title]="'Filter ' + columnName()"
69
+ >
70
+
71
+ @if (hasActiveFilter()) {
72
+ <span class="ogrid-header-filter__dot"></span>
73
+ }
74
+ </button>
75
+ }
76
+ </div>
77
+ </div>
78
+
79
+ @if (isFilterOpen() && filterType() !== 'none') {
80
+ <div
81
+ class="ogrid-header-filter__popover"
82
+ [style.top.px]="popoverTop()"
83
+ [style.left.px]="popoverLeft()"
84
+ (click)="$event.stopPropagation()"
85
+ >
86
+ <div class="ogrid-header-filter__popover-header">
87
+ Filter: {{ columnName() }}
88
+ </div>
89
+
90
+ @switch (filterType()) {
91
+ @case ('text') {
92
+ <div class="ogrid-header-filter__popover-body" style="width: 260px;">
93
+ <div style="padding: 12px;">
94
+ <input
95
+ type="text"
96
+ class="ogrid-header-filter__input"
97
+ placeholder="Enter search term..."
98
+ [value]="tempTextValue()"
99
+ (input)="tempTextValue.set(asInputValue($event))"
100
+ (keydown)="onTextKeydown($event)"
101
+ autocomplete="off"
102
+ />
103
+ </div>
104
+ <div class="ogrid-header-filter__popover-actions">
105
+ <button class="ogrid-header-filter__action-btn" [disabled]="!tempTextValue()" (click)="handleTextClear()">Clear</button>
106
+ <button class="ogrid-header-filter__action-btn ogrid-header-filter__action-btn--primary" (click)="handleTextApply()">Apply</button>
107
+ </div>
108
+ </div>
109
+ }
110
+ @case ('multiSelect') {
111
+ <div class="ogrid-header-filter__popover-body" style="width: 280px;">
112
+ <div style="padding: 12px 12px 4px;">
113
+ <input
114
+ type="text"
115
+ class="ogrid-header-filter__input"
116
+ placeholder="Search..."
117
+ [value]="searchText()"
118
+ (input)="searchText.set(asInputValue($event))"
119
+ (keydown)="$event.stopPropagation()"
120
+ autocomplete="off"
121
+ />
122
+ <div class="ogrid-header-filter__options-info">
123
+ {{ filteredOptions().length }} of {{ (options() ?? []).length }} options
124
+ </div>
125
+ </div>
126
+ <div class="ogrid-header-filter__select-actions">
127
+ <button class="ogrid-header-filter__action-btn" (click)="handleSelectAllFiltered()">
128
+ Select All ({{ filteredOptions().length }})
129
+ </button>
130
+ <button class="ogrid-header-filter__action-btn" (click)="handleClearSelection()">Clear</button>
131
+ </div>
132
+ <div class="ogrid-header-filter__options-list">
133
+ @if (isLoadingOptions()) {
134
+ <div class="ogrid-header-filter__loading">Loading...</div>
135
+ } @else if (filteredOptions().length === 0) {
136
+ <div class="ogrid-header-filter__empty">No options found</div>
137
+ } @else {
138
+ @for (option of filteredOptions(); track option) {
139
+ <label class="ogrid-header-filter__option">
140
+ <input
141
+ type="checkbox"
142
+ [checked]="tempSelected().has(option)"
143
+ (change)="handleCheckboxChange(option, $event)"
144
+ />
145
+ <span>{{ option }}</span>
146
+ </label>
147
+ }
148
+ }
149
+ </div>
150
+ <div class="ogrid-header-filter__popover-actions" style="border-top: 1px solid var(--ogrid-border, #e0e0e0);">
151
+ <button class="ogrid-header-filter__action-btn" [disabled]="tempSelected().size === 0" (click)="handleMultiSelectClear()">Clear</button>
152
+ <button class="ogrid-header-filter__action-btn ogrid-header-filter__action-btn--primary" (click)="handleMultiSelectApply()">Apply</button>
153
+ </div>
154
+ </div>
155
+ }
156
+ @case ('date') {
157
+ <div class="ogrid-header-filter__popover-body" style="width: 280px;">
158
+ <div style="padding: 12px;">
159
+ <div style="margin-bottom: 8px;">
160
+ <label style="display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600;">From</label>
161
+ <input
162
+ type="date"
163
+ class="ogrid-header-filter__input"
164
+ [value]="tempDateFrom()"
165
+ (change)="tempDateFrom.set(asInputValue($event))"
166
+ />
167
+ </div>
168
+ <div>
169
+ <label style="display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600;">To</label>
170
+ <input
171
+ type="date"
172
+ class="ogrid-header-filter__input"
173
+ [value]="tempDateTo()"
174
+ (change)="tempDateTo.set(asInputValue($event))"
175
+ />
176
+ </div>
177
+ </div>
178
+ <div class="ogrid-header-filter__popover-actions">
179
+ <button class="ogrid-header-filter__action-btn" [disabled]="!tempDateFrom() && !tempDateTo()" (click)="handleDateClear()">Clear</button>
180
+ <button class="ogrid-header-filter__action-btn ogrid-header-filter__action-btn--primary" (click)="handleDateApply()">Apply</button>
181
+ </div>
182
+ </div>
183
+ }
184
+ }
185
+ </div>
186
+ }
187
+ `,
188
+ styles: [`
189
+ :host {
190
+ display: flex;
191
+ flex-direction: column;
192
+ height: 100%;
193
+ }
194
+ .ogrid-header-filter {
195
+ display: flex;
196
+ align-items: center;
197
+ justify-content: space-between;
198
+ gap: 4px;
199
+ height: 100%;
200
+ flex: 1;
201
+ }
202
+ .ogrid-header-filter__label {
203
+ flex: 1;
204
+ min-width: 0;
205
+ }
206
+ .ogrid-header-filter__name {
207
+ display: block;
208
+ overflow: hidden;
209
+ text-overflow: ellipsis;
210
+ white-space: nowrap;
211
+ }
212
+ .ogrid-header-filter__actions {
213
+ display: flex;
214
+ gap: 2px;
215
+ flex-shrink: 0;
216
+ }
217
+ .ogrid-header-filter__btn {
218
+ position: relative;
219
+ min-width: 20px;
220
+ height: 20px;
221
+ padding: 0;
222
+ border: none;
223
+ border-radius: 2px;
224
+ background: transparent;
225
+ color: var(--ogrid-fg, #242424);
226
+ cursor: pointer;
227
+ font-size: 12px;
228
+ display: flex;
229
+ align-items: center;
230
+ justify-content: center;
231
+ opacity: 0.6;
232
+ transition: all 0.15s ease;
233
+ }
234
+ .ogrid-header-filter__btn:hover {
235
+ opacity: 1;
236
+ background: var(--ogrid-hover-bg, #f0f0f0);
237
+ }
238
+ .ogrid-header-filter__btn--active {
239
+ opacity: 1;
240
+ color: var(--ogrid-active-border, #0078d4);
241
+ font-weight: 700;
242
+ }
243
+ .ogrid-header-filter__dot {
244
+ position: absolute;
245
+ top: 2px;
246
+ right: 2px;
247
+ width: 4px;
248
+ height: 4px;
249
+ border-radius: 50%;
250
+ background: var(--ogrid-active-border, #0078d4);
251
+ }
252
+ .ogrid-header-filter__popover {
253
+ position: fixed;
254
+ z-index: 1000;
255
+ background: var(--ogrid-bg, #ffffff);
256
+ border: 1px solid var(--ogrid-border, #e0e0e0);
257
+ border-radius: 4px;
258
+ box-shadow: 0 4px 16px rgba(0,0,0,0.15);
259
+ min-width: 200px;
260
+ }
261
+ .ogrid-header-filter__popover-header {
262
+ padding: 8px 12px;
263
+ font-size: 14px;
264
+ font-weight: 600;
265
+ color: var(--ogrid-fg, #242424);
266
+ border-bottom: 1px solid var(--ogrid-border, #e0e0e0);
267
+ background: var(--ogrid-header-bg, #f5f5f5);
268
+ }
269
+ .ogrid-header-filter__popover-body {
270
+ display: flex;
271
+ flex-direction: column;
272
+ }
273
+ .ogrid-header-filter__input {
274
+ width: 100%;
275
+ padding: 6px 8px;
276
+ border: 1px solid var(--ogrid-border, #e0e0e0);
277
+ border-radius: 4px;
278
+ font-size: 14px;
279
+ background: var(--ogrid-bg, #ffffff);
280
+ color: var(--ogrid-fg, #242424);
281
+ }
282
+ .ogrid-header-filter__input:focus {
283
+ outline: 2px solid var(--ogrid-active-border, #0078d4);
284
+ outline-offset: 1px;
285
+ }
286
+ .ogrid-header-filter__options-info {
287
+ margin-top: 6px;
288
+ font-size: 12px;
289
+ color: var(--ogrid-fg, #242424);
290
+ opacity: 0.7;
291
+ }
292
+ .ogrid-header-filter__select-actions {
293
+ display: flex;
294
+ gap: 8px;
295
+ padding: 8px 12px;
296
+ border-bottom: 1px solid var(--ogrid-border, #e0e0e0);
297
+ }
298
+ .ogrid-header-filter__options-list {
299
+ max-height: 240px;
300
+ overflow-y: auto;
301
+ padding: 4px 0;
302
+ }
303
+ .ogrid-header-filter__option {
304
+ display: flex;
305
+ align-items: center;
306
+ gap: 8px;
307
+ padding: 6px 12px;
308
+ cursor: pointer;
309
+ font-size: 14px;
310
+ color: var(--ogrid-fg, #242424);
311
+ transition: background 0.15s ease;
312
+ }
313
+ .ogrid-header-filter__option:hover {
314
+ background: var(--ogrid-hover-bg, #f0f0f0);
315
+ }
316
+ .ogrid-header-filter__loading,
317
+ .ogrid-header-filter__empty {
318
+ padding: 16px;
319
+ text-align: center;
320
+ font-size: 14px;
321
+ color: var(--ogrid-fg, #242424);
322
+ opacity: 0.7;
323
+ }
324
+ .ogrid-header-filter__popover-actions {
325
+ display: flex;
326
+ justify-content: flex-end;
327
+ gap: 8px;
328
+ padding: 8px 12px;
329
+ background: var(--ogrid-header-bg, #f5f5f5);
330
+ }
331
+ .ogrid-header-filter__action-btn {
332
+ padding: 6px 12px;
333
+ border: 1px solid var(--ogrid-border, #e0e0e0);
334
+ border-radius: 4px;
335
+ background: var(--ogrid-bg, #ffffff);
336
+ color: var(--ogrid-fg, #242424);
337
+ cursor: pointer;
338
+ font-size: 13px;
339
+ transition: all 0.15s ease;
340
+ }
341
+ .ogrid-header-filter__action-btn:hover:not(:disabled) {
342
+ background: var(--ogrid-hover-bg, #f0f0f0);
343
+ }
344
+ .ogrid-header-filter__action-btn:disabled {
345
+ opacity: 0.4;
346
+ cursor: not-allowed;
347
+ }
348
+ .ogrid-header-filter__action-btn--primary {
349
+ background: var(--ogrid-active-border, #0078d4);
350
+ color: #ffffff;
351
+ border-color: var(--ogrid-active-border, #0078d4);
352
+ }
353
+ .ogrid-header-filter__action-btn--primary:hover:not(:disabled) {
354
+ opacity: 0.9;
355
+ }
356
+ `],
357
+ host: {
358
+ '(document:click)': 'onDocumentClick($event)',
359
+ },
360
+ })
361
+ export class ColumnHeaderFilterComponent {
362
+ readonly columnKey = input.required<string>();
363
+ readonly columnName = input.required<string>();
364
+ readonly filterType = input.required<ColumnFilterType>();
365
+ readonly isSorted = input<boolean>(false);
366
+ readonly isSortedDescending = input<boolean>(false);
367
+ readonly onSort = input<(() => void) | undefined>(undefined);
368
+ readonly selectedValues = input<string[]>([]);
369
+ readonly onFilterChange = input<((values: string[]) => void) | undefined>(undefined);
370
+ readonly options = input<string[] | undefined>(undefined);
371
+ readonly isLoadingOptions = input<boolean>(false);
372
+ readonly textValue = input<string>('');
373
+ readonly onTextChange = input<((value: string) => void) | undefined>(undefined);
374
+ readonly selectedUser = input<UserLike | undefined>(undefined);
375
+ readonly onUserChange = input<((user: UserLike | undefined) => void) | undefined>(undefined);
376
+ readonly peopleSearch = input<((query: string) => Promise<UserLike[]>) | undefined>(undefined);
377
+ readonly dateValue = input<IDateFilterValue | undefined>(undefined);
378
+ readonly onDateChange = input<((value: IDateFilterValue | undefined) => void) | undefined>(undefined);
379
+
380
+ private readonly headerRef = viewChild<ElementRef<HTMLElement>>('headerEl');
381
+
382
+ readonly isFilterOpen = signal(false);
383
+ readonly popoverTop = signal(0);
384
+ readonly popoverLeft = signal(0);
385
+
386
+ // Text filter
387
+ readonly tempTextValue = signal('');
388
+
389
+ // MultiSelect filter
390
+ readonly searchText = signal('');
391
+ readonly tempSelected = signal(new Set<string>());
392
+
393
+ // Date filter
394
+ readonly tempDateFrom = signal('');
395
+ readonly tempDateTo = signal('');
396
+
397
+ readonly hasActiveFilter = computed(() => {
398
+ const ft = this.filterType();
399
+ if (ft === 'text') return !!this.textValue();
400
+ if (ft === 'multiSelect') return (this.selectedValues() ?? []).length > 0;
401
+ if (ft === 'date') {
402
+ const dv = this.dateValue();
403
+ return !!dv && (!!dv.from || !!dv.to);
404
+ }
405
+ return false;
406
+ });
407
+
408
+ readonly filteredOptions = computed(() => {
409
+ const search = this.searchText().toLowerCase();
410
+ const opts = this.options() ?? [];
411
+ if (!search) return opts;
412
+ return opts.filter(o => o.toLowerCase().includes(search));
413
+ });
414
+
415
+ toggleFilter(event: MouseEvent): void {
416
+ event.stopPropagation();
417
+
418
+ if (this.isFilterOpen()) {
419
+ this.isFilterOpen.set(false);
420
+ return;
421
+ }
422
+
423
+ // Initialize temp values
424
+ if (this.filterType() === 'text') {
425
+ this.tempTextValue.set(this.textValue() ?? '');
426
+ } else if (this.filterType() === 'multiSelect') {
427
+ this.tempSelected.set(new Set(this.selectedValues() ?? []));
428
+ this.searchText.set('');
429
+ } else if (this.filterType() === 'date') {
430
+ const dv = this.dateValue();
431
+ this.tempDateFrom.set(dv?.from ?? '');
432
+ this.tempDateTo.set(dv?.to ?? '');
433
+ }
434
+
435
+ // Calculate popover position
436
+ const headerEl = this.headerRef()?.nativeElement;
437
+ if (headerEl) {
438
+ const rect = headerEl.getBoundingClientRect();
439
+ this.popoverTop.set(rect.bottom + 4);
440
+ this.popoverLeft.set(rect.left);
441
+ }
442
+
443
+ this.isFilterOpen.set(true);
444
+ }
445
+
446
+ onDocumentClick(event: MouseEvent): void {
447
+ const el = event.target as HTMLElement;
448
+ if (!el.closest('column-header-filter')) {
449
+ this.isFilterOpen.set(false);
450
+ }
451
+ }
452
+
453
+ asInputValue(event: Event): string {
454
+ return (event.target as HTMLInputElement).value;
455
+ }
456
+
457
+ // Text filter handlers
458
+ onTextKeydown(event: KeyboardEvent): void {
459
+ if (event.key === 'Enter') {
460
+ this.handleTextApply();
461
+ } else if (event.key === 'Escape') {
462
+ this.isFilterOpen.set(false);
463
+ }
464
+ }
465
+
466
+ handleTextApply(): void {
467
+ this.onTextChange()?.(this.tempTextValue());
468
+ this.isFilterOpen.set(false);
469
+ }
470
+
471
+ handleTextClear(): void {
472
+ this.tempTextValue.set('');
473
+ this.onTextChange()?.('');
474
+ this.isFilterOpen.set(false);
475
+ }
476
+
477
+ // MultiSelect filter handlers
478
+ handleCheckboxChange(option: string, event: Event): void {
479
+ const checked = (event.target as HTMLInputElement).checked;
480
+ const newSet = new Set(this.tempSelected());
481
+ if (checked) {
482
+ newSet.add(option);
483
+ } else {
484
+ newSet.delete(option);
485
+ }
486
+ this.tempSelected.set(newSet);
487
+ }
488
+
489
+ handleSelectAllFiltered(): void {
490
+ const newSet = new Set(this.tempSelected());
491
+ for (const opt of this.filteredOptions()) {
492
+ newSet.add(opt);
493
+ }
494
+ this.tempSelected.set(newSet);
495
+ }
496
+
497
+ handleClearSelection(): void {
498
+ this.tempSelected.set(new Set());
499
+ }
500
+
501
+ handleMultiSelectApply(): void {
502
+ this.onFilterChange()?.([...this.tempSelected()]);
503
+ this.isFilterOpen.set(false);
504
+ }
505
+
506
+ handleMultiSelectClear(): void {
507
+ this.tempSelected.set(new Set());
508
+ this.onFilterChange()?.([]);
509
+ this.isFilterOpen.set(false);
510
+ }
511
+
512
+ // Date filter handlers
513
+ handleDateApply(): void {
514
+ const from = this.tempDateFrom();
515
+ const to = this.tempDateTo();
516
+ if (from || to) {
517
+ this.onDateChange()?.({ from, to });
518
+ }
519
+ this.isFilterOpen.set(false);
520
+ }
521
+
522
+ handleDateClear(): void {
523
+ this.tempDateFrom.set('');
524
+ this.tempDateTo.set('');
525
+ this.onDateChange()?.({ from: '', to: '' });
526
+ this.isFilterOpen.set(false);
527
+ }
528
+ }