diff --git a/src/app/lists/combo/combo-features/combo-features.component.html b/src/app/lists/combo/combo-features/combo-features.component.html
index 92c25d630..896d3581a 100644
--- a/src/app/lists/combo/combo-features/combo-features.component.html
+++ b/src/app/lists/combo/combo-features/combo-features.component.html
@@ -1,12 +1,31 @@
+
+
+
+
+
-
-
+
+
Selection
+ @if (selection.length) {
+
+ @for (item of selection; track item) {
+ - {{ item }}
+ }
+
+ } @else {
+
No items selected
+ }
+
+
-
Disable Filtering
-
Show Case-sensitive Icon
-
Allow Custom Values
-
Enable Grouping
-
Disable Combo
+
+ Disable Filtering
+ Show Case-sensitive Icon
+ Allow Custom Values
+ Enable Grouping
+ Disable Combo
+
+
diff --git a/src/app/lists/combo/combo-features/combo-features.component.scss b/src/app/lists/combo/combo-features/combo-features.component.scss
index 64282cc1e..bfc6be8be 100644
--- a/src/app/lists/combo/combo-features/combo-features.component.scss
+++ b/src/app/lists/combo/combo-features/combo-features.component.scss
@@ -1,4 +1,86 @@
-.combo, igx-switch {
- margin: 8px;
- width: 430px;
+:host {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+}
+
+/* The combo sits in the middle, and the switches in the space on its right. */
+.combo-features {
+ display: grid;
+ flex: 1;
+ grid-template-columns: 1fr minmax(0, 22.5rem) 1fr;
+ align-items: center;
+ column-gap: 6rem;
+ padding: 1rem;
+}
+
+/* Keeps room below the combo for its open dropdown, so the two stay centered together. */
+.combo-column {
+ grid-column: 2;
+ min-height: 17.5rem;
+}
+
+/* The selected values of the combo, under it. */
+.selection-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ margin-top: 1.25rem;
+ padding: 0.75rem 1rem;
+}
+
+.selection-title {
+ color: var(--ig-gray-900);
+ font-family: var(--ig-subtitle-2-font-family, var(--ig-font-family));
+ font-size: var(--ig-subtitle-2-font-size);
+ font-weight: var(--ig-subtitle-2-font-weight);
+ line-height: var(--ig-subtitle-2-line-height);
+ letter-spacing: var(--ig-subtitle-2-letter-spacing);
+}
+
+.selection-list,
+.selection-empty {
+ font-family: var(--ig-body-2-font-family, var(--ig-font-family));
+ font-size: var(--ig-body-2-font-size);
+ font-weight: var(--ig-body-2-font-weight);
+ line-height: var(--ig-body-2-line-height);
+ letter-spacing: var(--ig-body-2-letter-spacing);
+}
+
+/* A long selection scrolls, so the column keeps its height and the combo stays in place. */
+.selection-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ max-height: 8rem;
+ margin: 0;
+ padding: 0;
+ overflow-y: auto;
+ list-style: none;
+ color: var(--ig-gray-700);
+}
+
+.selection-empty {
+ color: var(--ig-gray-500);
+}
+
+.controls {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1.625rem;
+}
+
+@media (max-width: 50rem) {
+ .combo-features {
+ grid-template-columns: minmax(0, 22.5rem);
+ justify-content: center;
+ row-gap: 2rem;
+ }
+
+ .combo-column {
+ grid-column: 1;
+ min-height: 0;
+ }
}
diff --git a/src/app/lists/combo/combo-features/combo-features.component.ts b/src/app/lists/combo/combo-features/combo-features.component.ts
index 79bf967fa..3738fb766 100644
--- a/src/app/lists/combo/combo-features/combo-features.component.ts
+++ b/src/app/lists/combo/combo-features/combo-features.component.ts
@@ -1,5 +1,6 @@
-import { Component, OnInit, ViewChild } from '@angular/core';
+import { Component, OnInit } from '@angular/core';
import { IgxComboComponent } from 'igniteui-angular/combo';
+import { IgxLabelDirective } from 'igniteui-angular/input-group';
import { IgxSwitchComponent } from 'igniteui-angular/switch';
import { localData } from './local-data';
import { FormsModule } from '@angular/forms';
@@ -8,25 +9,18 @@ import { FormsModule } from '@angular/forms';
selector: 'app-combo',
styleUrls: ['./combo-features.component.scss'],
templateUrl: './combo-features.component.html',
- imports: [IgxComboComponent, IgxSwitchComponent, FormsModule]
+ imports: [IgxComboComponent, IgxLabelDirective, IgxSwitchComponent, FormsModule]
})
export class ComboFeaturesComponent implements OnInit{
- @ViewChild('combo', { read: IgxComboComponent, static: true }) public combo: IgxComboComponent;
- // eslint-disable-next-line @typescript-eslint/naming-convention
- public GROUP_KEY = 'region';
public lData: any[];
public disableFiltering = false;
public showSearchCaseIcon = false;
- public customValues = true;
+ public customValues = false;
+ public grouping = false;
public disabled = false;
+ public selection: string[] = [];
public ngOnInit() {
this.lData = localData;
}
-
- public handleAddition(event) { }
-
- public enableGroups(event) {
- this.combo.groupKey = event.checked ? this.GROUP_KEY : '';
- }
}
diff --git a/src/app/lists/combo/combo-features/local-data.ts b/src/app/lists/combo/combo-features/local-data.ts
index 481a17c9d..ef17f8b1f 100644
--- a/src/app/lists/combo/combo-features/local-data.ts
+++ b/src/app/lists/combo/combo-features/local-data.ts
@@ -12,10 +12,9 @@ const division = {
'South Atlantic 01': ['Delaware', 'Florida', 'Georgia', 'Maryland'],
'South Atlantic 02': ['North Carolina', 'South Carolina', 'Virginia',
'District of Columbia', 'West Virginia'],
- 'South Atlantic 03': ['District of Columbia', 'West Virginia'],
'East South Central 01': ['Alabama', 'Kentucky'],
'East South Central 02': ['Mississippi', 'Tennessee'],
- 'West South Central': ['Arkansas', 'Louisiana', 'Oklahome', 'Texas'],
+ 'West South Central': ['Arkansas', 'Louisiana', 'Oklahoma', 'Texas'],
'Mountain': ['Arizona', 'Colorado', 'Idaho', 'Montana', 'Nevada', 'New Mexico', 'Utah', 'Wyoming'],
'Pacific 01': ['Alaska', 'California'],
'Pacific 02': ['Hawaii', 'Oregon', 'Washington']
@@ -27,7 +26,8 @@ for (const key of keys) {
division[key].map((e) => {
localData.push({
field: e,
- region: key.substring(0, key.length - 3)
+ // The number only splits a region into shorter lists: "Pacific 01" is "Pacific".
+ region: key.replace(/ \d+$/, '')
});
});
}
diff --git a/src/app/lists/combo/combo-overlay/combo-overlay.component.html b/src/app/lists/combo/combo-overlay/combo-overlay.component.html
index a78f09704..1397833f1 100644
--- a/src/app/lists/combo/combo-overlay/combo-overlay.component.html
+++ b/src/app/lists/combo/combo-overlay/combo-overlay.component.html
@@ -1,7 +1,27 @@
-
-
+
+
+
+
+
+
+
Selection
+ @if (selectedPeople.length) {
+
+ @for (person of selectedPeople; track person) {
+ - {{ person }}
+ }
+
+ } @else {
+
No items selected
+ }
+
+
diff --git a/src/app/lists/combo/combo-overlay/combo-overlay.component.scss b/src/app/lists/combo/combo-overlay/combo-overlay.component.scss
index 698803b94..36a77d6ea 100644
--- a/src/app/lists/combo/combo-overlay/combo-overlay.component.scss
+++ b/src/app/lists/combo/combo-overlay/combo-overlay.component.scss
@@ -1,4 +1,56 @@
-.igx-combo {
- width: 430px;
- margin: 8px;
+:host {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+}
+
+/* Keeps room below the combo for its open list, so the two stay centered together. */
+.combo-column {
+ width: 22.5rem;
+ min-height: 18.5rem;
+}
+
+/* The selected values of the combo, under it. */
+.selection-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ margin-top: 1.25rem;
+ padding: 0.75rem 1rem;
+}
+
+.selection-title {
+ color: var(--ig-gray-900);
+ font-family: var(--ig-subtitle-2-font-family, var(--ig-font-family));
+ font-size: var(--ig-subtitle-2-font-size);
+ font-weight: var(--ig-subtitle-2-font-weight);
+ line-height: var(--ig-subtitle-2-line-height);
+ letter-spacing: var(--ig-subtitle-2-letter-spacing);
+}
+
+.selection-list,
+.selection-empty {
+ font-family: var(--ig-body-2-font-family, var(--ig-font-family));
+ font-size: var(--ig-body-2-font-size);
+ font-weight: var(--ig-body-2-font-weight);
+ line-height: var(--ig-body-2-line-height);
+ letter-spacing: var(--ig-body-2-letter-spacing);
+}
+
+/* A long selection scrolls, so the column keeps its height and the combo stays in place. */
+.selection-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ max-height: 8rem;
+ margin: 0;
+ padding: 0;
+ overflow-y: auto;
+ list-style: none;
+ color: var(--ig-gray-700);
+}
+
+.selection-empty {
+ color: var(--ig-gray-500);
}
diff --git a/src/app/lists/combo/combo-overlay/combo-overlay.component.ts b/src/app/lists/combo/combo-overlay/combo-overlay.component.ts
index ede80c3d9..3c6244f35 100644
--- a/src/app/lists/combo/combo-overlay/combo-overlay.component.ts
+++ b/src/app/lists/combo/combo-overlay/combo-overlay.component.ts
@@ -1,22 +1,47 @@
-import { Component } from '@angular/core';
-import { GlobalPositionStrategy, OverlaySettings } from 'igniteui-angular/core';
+import { AfterViewInit, Component, ViewChild } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { OverlaySettings } from 'igniteui-angular/core';
import { IgxComboComponent } from 'igniteui-angular/combo';
-import { scaleInCenter, scaleOutCenter } from 'igniteui-angular/animations';
+import { IgxLabelDirective } from 'igniteui-angular/input-group';
@Component({
selector: 'app-combo-overlay',
templateUrl: 'combo-overlay.component.html',
styleUrls: ['combo-overlay.component.scss'],
- imports: [IgxComboComponent]
+ imports: [IgxComboComponent, IgxLabelDirective, FormsModule]
})
-export class ComboOverlayComponent {
- public items = [{ name: 'Option 1' }, { name: 'Option 2' }, { name: 'Option 3' },
- { name: 'Option 4' }, { name: 'Option 5' }, { name: 'Option 6' },
- { name: 'Option 7' }, { name: 'Option 8' }, { name: 'Option 9' }];
+export class ComboOverlayComponent implements AfterViewInit {
+ @ViewChild('combo', { static: true })
+ public combo: IgxComboComponent;
+ public people = [
+ { name: 'Ana Petrova', department: 'Engineering' },
+ { name: 'Marc Lee', department: 'Engineering' },
+ { name: 'Priya Nair', department: 'Engineering' },
+ { name: 'Tom Becker', department: 'Engineering' },
+ { name: 'Yuki Sato', department: 'Engineering' },
+ { name: 'Daniel Kim', department: 'Finance' },
+ { name: 'Elena Popescu', department: 'Finance' },
+ { name: 'Grace Okafor', department: 'Finance' },
+ { name: 'Hugo Martin', department: 'Marketing' },
+ { name: 'Isabel Costa', department: 'Marketing' },
+ { name: 'Lena Fischer', department: 'Marketing' },
+ { name: 'Jonas Berg', department: 'Sales' },
+ { name: 'Omar Haddad', department: 'Sales' },
+ { name: 'Sofia Rossi', department: 'Sales' }
+ ];
+
+ public selectedPeople = ['Ana Petrova', 'Marc Lee'];
+
+ /* Merged over the combo's own settings, so the list still opens below the combo.
+ A modal overlay dims the page behind the list, and a click outside closes it. */
public overlaySettings: OverlaySettings = {
- positionStrategy: new GlobalPositionStrategy({ openAnimation: scaleInCenter, closeAnimation: scaleOutCenter }),
modal: true,
closeOnOutsideClick: true
};
+
+ // Opens the list on load, so the sample shows the overlay right away.
+ public ngAfterViewInit() {
+ this.combo.open();
+ }
}
diff --git a/src/app/lists/combo/combo-remote/combo-remote.component.html b/src/app/lists/combo/combo-remote/combo-remote.component.html
index d33318948..52f5cd212 100644
--- a/src/app/lists/combo/combo-remote/combo-remote.component.html
+++ b/src/app/lists/combo/combo-remote/combo-remote.component.html
@@ -1,14 +1,90 @@
-
-
-
+
+
+
+
+ @if (selection.length) {
+ {{ selection.length }} selected · kept by ProductID while chunks reload
+ }
+
+
+ @if (loadingRange) {
+
+ Loading items {{ loadingRange.from }}–{{ loadingRange.to }}…
+
+
+ }
+
+ {{ footerText }}
+
+
+
+
+
+
+
+
diff --git a/src/app/lists/combo/combo-remote/combo-remote.component.scss b/src/app/lists/combo/combo-remote/combo-remote.component.scss
index b0c5820c9..2e3145f2a 100644
--- a/src/app/lists/combo/combo-remote/combo-remote.component.scss
+++ b/src/app/lists/combo/combo-remote/combo-remote.component.scss
@@ -1,4 +1,211 @@
-.combo {
- margin: 8px;
- width: 430px;
+@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap");
+
+/* Anchored at the top, so the combo stays put while the panel beside it grows. */
+:host {
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ box-sizing: border-box;
+ min-height: 100%;
+ padding: 4.875rem 1rem 1.5rem;
+}
+
+/* The combo sits in the middle, and the monitor in the space on its right. */
+.remote-binding {
+ display: grid;
+ flex: 1;
+ grid-template-columns: 1fr minmax(0, 22.5rem) 1fr;
+ align-items: start;
+ column-gap: 3rem;
+}
+
+.combo-column {
+ grid-column: 2;
+}
+
+/* The dropdown footer: the chunk being loaded, and how much of the data is loaded. */
+.loading-chunk {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.625rem 1rem;
+ color: var(--ig-gray-600);
+}
+
+.loading-chunk igx-linear-bar {
+ width: 100%;
+}
+
+.combo-summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 0.375rem 1rem;
+ color: var(--ig-gray-700);
+ background: var(--ig-gray-100);
+}
+
+.loading-chunk,
+.combo-summary {
+ font-family: var(--ig-body-2-font-family, var(--ig-font-family));
+ font-size: var(--ig-body-2-font-size);
+ font-weight: var(--ig-body-2-font-weight);
+ line-height: var(--ig-body-2-line-height);
+ letter-spacing: var(--ig-body-2-letter-spacing);
+}
+
+.combo-summary igx-badge {
+ flex-shrink: 0;
+}
+
+/* The panel beside the combo: its state, and the requests it makes. */
+.monitor {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ width: 100%;
+ max-width: 20rem;
+ font-family: Inter, sans-serif;
+}
+
+.monitor-title {
+ margin: 0;
+ padding: 0 1rem;
+ color: var(--ig-gray-900);
+ font-family: var(--ig-subtitle-2-font-family, var(--ig-font-family));
+ font-size: var(--ig-subtitle-2-font-size);
+ font-weight: var(--ig-subtitle-2-font-weight);
+ line-height: var(--ig-subtitle-2-line-height);
+ letter-spacing: var(--ig-subtitle-2-letter-spacing);
+}
+
+.state {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ margin: 0.5rem 0 0;
+ padding: 0 1rem;
+ font-size: 0.75rem;
+}
+
+.state-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.state dt {
+ color: var(--ig-gray-700);
+}
+
+.state dd {
+ margin: 0;
+ color: var(--ig-gray-900);
+ font-weight: 500;
+ text-align: end;
+}
+
+/* What the last update changed. */
+.state dd.changed,
+.selection-values {
+ color: var(--ig-gray-900);
+}
+
+.selection-values {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ margin: 0.5rem 1rem 0;
+ padding: 0.5rem 0.625rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ line-height: 1.125rem;
+}
+
+.selection-more {
+ display: flex;
+ gap: 0.375rem;
+ color: var(--ig-gray-700);
+ font-weight: 400;
+}
+
+.selection-more-link {
+ padding: 0;
+ border: 0;
+ color: var(--ig-gray-900);
+ background: none;
+ font: inherit;
+ font-weight: 500;
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+.selection-tooltip {
+ max-width: 20rem;
+}
+
+.log-title {
+ padding-block: 0.5rem;
+}
+
+.log {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.log-entry {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 0.1875rem 0.5rem;
+ padding: 0.5rem 1rem;
+}
+
+.log-name {
+ color: var(--ig-gray-900);
+ font-size: 0.75rem;
+ font-weight: 600;
+}
+
+.log-result {
+ color: var(--ig-gray-900);
+ font-size: 0.6875rem;
+ font-weight: 500;
+ text-align: end;
+}
+
+.log-result[data-status='pending'],
+.log-result[data-status='cancelled'] {
+ color: var(--ig-gray-900);
+}
+
+.log-result[data-status='error'] {
+ color: var(--ig-gray-900);
+}
+
+.log-detail {
+ grid-column: 1 / -1;
+ color: var(--ig-gray-700);
+ font-family: inherit;
+ font-size: 0.625rem;
+ overflow-wrap: anywhere;
+}
+
+/* Too narrow for a side column: the monitor moves under the combo. */
+@media (max-width: 60rem) {
+ .remote-binding {
+ grid-template-columns: minmax(0, 22.5rem);
+ justify-content: center;
+ row-gap: 2.5rem;
+ }
+
+ .combo-column {
+ grid-column: 1;
+ }
+
+ .monitor {
+ max-width: none;
+ }
}
diff --git a/src/app/lists/combo/combo-remote/combo-remote.component.ts b/src/app/lists/combo/combo-remote/combo-remote.component.ts
index febc5e093..9d4ae6bd1 100644
--- a/src/app/lists/combo/combo-remote/combo-remote.component.ts
+++ b/src/app/lists/combo/combo-remote/combo-remote.component.ts
@@ -1,130 +1,266 @@
-import { AfterViewInit, ChangeDetectorRef, Component, OnInit, ViewChild, inject } from '@angular/core';
-import { IComboSearchInputEventArgs, IComboSelectionChangingEventArgs, IgxComboComponent } from 'igniteui-angular/combo';
-import { IForOfState } from 'igniteui-angular/directives';
-import { IgxToastComponent } from 'igniteui-angular/toast';
-import { VerticalAlignment } from 'igniteui-angular/core';
-import { RemoteNWindService } from '../../../services/remoteNwind.service';
+import { ChangeDetectorRef, Component, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
+import { Subscription } from 'rxjs';
+import { IComboSearchInputEventArgs, IComboSelectionChangingEventArgs, IgxComboComponent, IgxComboFooterDirective } from 'igniteui-angular/combo';
+import { IForOfState, IgxTooltipDirective, IgxTooltipTargetDirective } from 'igniteui-angular/directives';
+import { IgxHintDirective, IgxLabelDirective } from 'igniteui-angular/input-group';
+import { IgxBadgeComponent } from 'igniteui-angular/badge';
+import { IgxLinearProgressBarComponent } from 'igniteui-angular/progressbar';
+import { RemoteNWindService } from '../../../services/remoteNwind.service';
+
+type RequestStatus = 'pending' | 'done' | 'event' | 'cancelled' | 'error';
+
+interface LogEntry {
+ id: number;
+ title: string;
+ status: RequestStatus;
+ /** The short result on the right: "200 · 6 items", "pending…" or "event". */
+ result: string;
+ /** The request URL, or what the event changed. */
+ detail: string;
+}
+
+interface ComboState {
+ startIndex: number;
+ chunkSize: number;
+ totalItemCount: number;
+ searchText: string;
+ selection: string;
+}
+
+const ITEM_HEIGHT = 32;
+const VISIBLE_ITEMS = 6;
+const SEARCH_DEBOUNCE_MS = 300;
+const LOG_SIZE = 4;
+const UPDATE_BURST_MS = 1000;
+/** Up to this many IDs fit on the selection row of the state panel. */
+const INLINE_SELECTION = 5;
+/** About three lines of IDs in the selection box of the state panel. */
+const SELECTION_CHARS = 130;
@Component({
providers: [RemoteNWindService],
selector: 'app-combo-remote',
styleUrls: ['./combo-remote.component.scss'],
templateUrl: './combo-remote.component.html',
- imports: [IgxComboComponent, IgxToastComponent, AsyncPipe]
+ imports: [
+ IgxComboComponent,
+ IgxComboFooterDirective,
+ IgxLabelDirective,
+ IgxHintDirective,
+ IgxBadgeComponent,
+ IgxLinearProgressBarComponent,
+ IgxTooltipDirective,
+ IgxTooltipTargetDirective,
+ AsyncPipe
+ ]
})
-export class ComboRemoteComponent implements OnInit, AfterViewInit {
+export class ComboRemoteComponent implements OnInit, OnDestroy {
private remoteService = inject(RemoteNWindService);
- cdr = inject(ChangeDetectorRef);
-
- @ViewChild('loadingToast')
- public loadingToast: IgxToastComponent;
+ private cdr = inject(ChangeDetectorRef);
- @ViewChild('remoteCombo')
+ @ViewChild('remoteCombo', { static: true })
public remoteCombo: IgxComboComponent;
- public prevRequest: any;
- public rData: any;
+ public readonly itemHeight = ITEM_HEIGHT;
+ public readonly itemsMaxHeight = ITEM_HEIGHT * VISIBLE_ITEMS;
+ public readonly inlineSelection = INLINE_SELECTION;
+ public rData = this.remoteService.remoteData;
- private searchText: string = null;
- private defaultVirtState: IForOfState = { chunkSize: 6, startIndex: 0 };
- private currentVirtState: IForOfState = { chunkSize: 6, startIndex: 0 };
- private itemID: number = 1;
- private itemCount: number = 0;
- private hasSelection: boolean;
- private additionalScroll: number = 0;
+ /** What the state panel shows, and which of it changed with the last update. */
+ public state: ComboState = { startIndex: 0, chunkSize: VISIBLE_ITEMS, totalItemCount: 0, searchText: '—', selection: '[]' };
+ public changed = new Set();
+ public selection: number[] = [];
+ public selectionList = '';
+ public selectionFull = '';
+ public hiddenSelection = 0;
+
+ public log: LogEntry[] = [];
+ /** The items the bound data holds, and the chunk being loaded, for the dropdown footer. */
+ public loadedRange = { from: 0, to: 0 };
+ public loadingRange: { from: number; to: number } | null = null;
+ public searchText = '';
+
+ private request: Subscription;
+ private requestEntry: LogEntry;
+ private searchTimer: ReturnType;
+ private nextLogId = 0;
+ private lastUpdate = 0;
+ /** All products, unfiltered: the ProductIDs run from 1 to this count. */
+ private productCount = 0;
+ /** The item to scroll to on opening: the last selection, or the first item. */
+ private itemID = 1;
+ private additionalScroll = 0;
public ngOnInit() {
- this.rData = this.remoteService.remoteData;
+ this.load('init · getData()', { startIndex: 0, chunkSize: VISIBLE_ITEMS }, '', { withCount: true });
}
- public ngAfterViewInit() {
- const initSize = {
- startIndex: 0,
- chunkSize: Math.ceil(250 / this.remoteCombo.itemHeight)
- };
- this.remoteService.getData(initSize, null, (data) => {
- this.remoteCombo.totalItemCount = data['@odata.count'];
- this.itemCount = this.remoteCombo.totalItemCount;
- });
+ public ngOnDestroy() {
+ clearTimeout(this.searchTimer);
+ this.request?.unsubscribe();
}
- public dataLoading() {
- if (this.prevRequest) {
- this.prevRequest.unsubscribe();
+ public get footerText(): string {
+ const { totalItemCount } = this.state;
+ const shown = `${this.loadedRange.from}–${this.loadedRange.to}`;
+
+ if (!totalItemCount) {
+ return this.searchText ? `No products match "${this.searchText}"` : 'No products';
}
- this.loadingToast.positionSettings.verticalDirection = VerticalAlignment.Middle;
- this.loadingToast.autoHide = false;
- this.loadingToast.open('Loading Remote Data...');
- this.cdr.detectChanges();
-
- this.prevRequest = this.remoteService.getData(
- this.remoteCombo.virtualizationState,
- this.searchText,
- (data) => {
- this.remoteCombo.totalItemCount = data['@odata.count'];
- this.loadingToast.close();
- this.cdr.detectChanges();
- }
- );
+ if (this.searchText) {
+ return `Showing ${shown} of ${totalItemCount} matches`;
+ }
+ return this.loadingRange
+ ? `Showing ${shown} of ${totalItemCount} · loading ${this.loadingRange.from}–${this.loadingRange.to}`
+ : `Showing ${shown} of ${totalItemCount} · loaded on demand`;
}
- public handleSearchInputUpdate(searchData: IComboSearchInputEventArgs) {
- this.currentVirtState.startIndex = 0;
- this.currentVirtState.chunkSize = Math.ceil(this.remoteCombo.itemsMaxHeight / this.remoteCombo.itemHeight);
- this.searchText = searchData?.searchText || '';
- this.remoteService.getData(
- this.searchText ? this.currentVirtState : this.defaultVirtState,
- this.searchText,
- (data) => {
- this.remoteCombo.totalItemCount = data['@odata.count'];
- }
- );
+ public onDataPreLoad() {
+ const state = this.remoteCombo.virtualizationState;
+ this.loadingRange = {
+ from: state.startIndex + 1,
+ to: Math.min(state.startIndex + state.chunkSize, this.state.totalItemCount)
+ };
+ this.load('dataPreLoad · getData()', { ...state }, this.searchText);
+ }
+
+ public onSearchInputUpdate(event: IComboSearchInputEventArgs) {
+ const text = event?.searchText || '';
+
+ // One request when typing pauses, rather than one per key.
+ clearTimeout(this.searchTimer);
+ this.searchTimer = setTimeout(() => {
+ this.searchText = text;
+ this.load(`searchInputUpdate · getData(${text ? `"${text}"` : ''})`,
+ { startIndex: 0, chunkSize: VISIBLE_ITEMS }, text, { withCount: true, search: true });
+ }, SEARCH_DEBOUNCE_MS);
+ }
+
+ public onSelectionChanging(event: IComboSelectionChangingEventArgs) {
+ const ids = event.newValue as number[];
+
+ if (!ids.length) {
+ this.itemID = 1;
+ this.additionalScroll = 0;
+ } else {
+ // On the next opening, the list scrolls back to the last selected product, as close
+ // to the top as the end of the list allows.
+ const last = ids[ids.length - 1];
+ this.additionalScroll = last === this.productCount ? 1 : 0;
+ this.itemID = this.productCount - last >= VISIBLE_ITEMS - 1
+ ? last
+ : this.productCount - (VISIBLE_ITEMS - 1);
+ }
+
+ this.selection = [...ids].sort((a, b) => a - b);
+ this.describeSelection();
+ const newSelection = ids.length > INLINE_SELECTION ? `${ids.length} items` : `[${ids.join(', ')}]`;
+ this.addLog('selectionChanging', 'event', 'event', `newSelection = ${newSelection} → itemID = ${this.itemID}`);
+ this.updateState({ selection: this.selectionRow() });
}
public onOpened() {
- // additionalScroll is one row, set when the selection is the last item. Landing a
- // row further down puts that item at the bottom of the viewport.
- void this.remoteCombo.virtualScrollContainer.scrollToIndex(this.itemID - 1 + (this.additionalScroll ? 1 : 0), { block: 'start' });
- this.cdr.detectChanges();
+ const index = this.itemID - 1 + this.additionalScroll;
+ void this.remoteCombo.virtualScrollContainer.scrollToIndex(index, { block: 'start' });
+ this.addLog('opened', 'event', 'event', this.selection.length
+ ? `scrollToIndex(${index}): back at the last selection`
+ : 'scrollToIndex(0): the top of the list');
}
public onClosing() {
+ clearTimeout(this.searchTimer);
+ const hadSearch = !!this.searchText;
this.searchText = '';
+ this.addLog('closing', 'event', 'event', 'searchText = ""');
+ this.updateState({ searchText: hadSearch ? '— (cleared on closing)' : '—' });
}
public onClosed() {
- this.currentVirtState.startIndex = (this.itemID || 1) - 1;
- this.remoteService.getData(
- this.currentVirtState,
- this.searchText,
- (data) => {
- this.remoteCombo.totalItemCount = data['@odata.count'];
- this.cdr.detectChanges();
- }
- );
+ // Load the chunk around the last selection, so that the next opening starts there.
+ this.load('closed · getData()', { startIndex: this.itemID - 1, chunkSize: VISIBLE_ITEMS }, '', { withCount: true });
}
- public handleSelectionChanging(evt: IComboSelectionChangingEventArgs) {
- this.hasSelection = !!evt?.newValue.length;
-
- if (!this.hasSelection) {
- this.itemID = 1;
- this.currentVirtState = this.defaultVirtState;
- return;
+ private load(title: string, state: IForOfState, searchText: string, options: { withCount?: boolean; search?: boolean } = {}) {
+ // A newer request makes the one in flight stale.
+ if (this.request && this.requestEntry?.status === 'pending') {
+ this.request.unsubscribe();
+ this.requestEntry.status = 'cancelled';
+ this.requestEntry.result = 'cancelled';
}
- const currentSelection = evt.newValue[evt.newValue.length - 1]
- this.currentVirtState.chunkSize = Math.ceil(this.remoteCombo.itemsMaxHeight / this.remoteCombo.itemHeight);
+ const url = this.remoteService.buildUrl(state, searchText);
+ const entry = this.addLog(title, 'pending', 'pending…', `GET ${url.replace(/^.*\/Northwind\.svc/, '')}`);
+ this.requestEntry = entry;
+ this.cdr.markForCheck();
- this.itemCount === currentSelection ?
- this.additionalScroll = this.remoteCombo.itemHeight :
- this.additionalScroll = 0;
+ this.request = this.remoteService.getData(state, searchText, (data) => {
+ const count: number = data['@odata.count'];
+ const items: number = data.value.length;
- if (this.itemCount - currentSelection >= this.currentVirtState.chunkSize - 1) {
- this.itemID = this.currentVirtState.startIndex = currentSelection;
- } else {
- this.itemID = this.currentVirtState.startIndex = this.itemCount - (this.currentVirtState.chunkSize - 1);
+ this.remoteCombo.totalItemCount = count;
+ if (!searchText) {
+ this.productCount = count;
+ }
+
+ entry.status = 'done';
+ entry.result = `200 · ${items} items${options.withCount ? ` · count ${count}` : ''}`;
+ this.loadedRange = { from: items ? state.startIndex + 1 : 0, to: state.startIndex + items };
+ this.loadingRange = null;
+ this.updateState({
+ startIndex: state.startIndex,
+ chunkSize: state.chunkSize,
+ totalItemCount: count,
+ ...(options.search && { searchText: searchText ? `"${searchText}"` : '—' })
+ });
+ this.cdr.markForCheck();
+ }, (error) => {
+ entry.status = 'error';
+ entry.result = error?.status ? `${error.status} · failed` : 'failed';
+ this.loadingRange = null;
+ this.cdr.markForCheck();
+ });
+ }
+
+ private addLog(title: string, status: RequestStatus, result: string, detail: string): LogEntry {
+ const entry: LogEntry = { id: this.nextLogId++, title, status, result, detail };
+ this.log = [...this.log, entry].slice(-LOG_SIZE);
+ return entry;
+ }
+
+ /**
+ * Applies the next state and marks what changed, so that the panel can highlight it. Updates
+ * that arrive together, like a search and the chunk the combo loads after it, share one mark.
+ */
+ private updateState(next: Partial) {
+ const changed = (Object.keys(next) as (keyof ComboState)[]).filter(key => next[key] !== this.state[key]);
+ const now = Date.now();
+ this.changed = now - this.lastUpdate < UPDATE_BURST_MS ? new Set([...this.changed, ...changed]) : new Set(changed);
+ this.lastUpdate = now;
+ this.state = { ...this.state, ...next };
+ }
+
+ private selectionRow(): string {
+ return this.selection.length > INLINE_SELECTION
+ ? `${this.selection.length} items`
+ : `[${this.selection.join(', ')}]`;
+ }
+
+ /** A selection too long for its row is listed below it, up to about three lines. */
+ private describeSelection() {
+ let list = '[';
+ let shown = 0;
+ for (const id of this.selection) {
+ const next = `${shown ? ', ' : ''}${id}`;
+ if (list.length + next.length > SELECTION_CHARS) {
+ break;
+ }
+ list += next;
+ shown++;
}
+
+ this.hiddenSelection = this.selection.length - shown;
+ this.selectionList = this.hiddenSelection ? `${list}, …` : `${list}]`;
+ this.selectionFull = `[${this.selection.join(', ')}]`;
}
}
diff --git a/src/app/services/remoteNwind.service.ts b/src/app/services/remoteNwind.service.ts
index 9d4cf039d..f196d006b 100644
--- a/src/app/services/remoteNwind.service.ts
+++ b/src/app/services/remoteNwind.service.ts
@@ -15,19 +15,23 @@ export class RemoteNWindService {
this.remoteData = new BehaviorSubject([]);
}
- public getData(data?: IForOfState, searchText?: string, cb?: (any) => void): any {
+ public getData(data?: IForOfState, searchText?: string, cb?: (any) => void, errorCb?: (error: any) => void): any {
const dataState = data;
return this.http
.get(this.buildUrl(dataState, searchText))
- .subscribe((d: any) => {
- this.remoteData.next(d.value);
- if (cb) {
- cb(d);
- }
+ .subscribe({
+ next: (d: any) => {
+ this.remoteData.next(d.value);
+ if (cb) {
+ cb(d);
+ }
+ },
+ // Without an errorCb, rxjs reports the error as before.
+ error: errorCb
});
}
- private buildUrl(dataState: any, searchText?: string): string {
+ public buildUrl(dataState: any, searchText?: string): string {
let qS = '?';
let requiredChunkSize: number;
if (dataState) {