From 8f9379ff37488cbb7565bef2638fb08d0e107433 Mon Sep 17 00:00:00 2001 From: Ivan Minchev Date: Mon, 9 Nov 2026 16:08:03 +0200 Subject: [PATCH 1/5] feat(combo): update samples content --- .../combo-features.component.html | 28 +- .../combo-features.component.scss | 44 ++- .../combo-features.component.ts | 17 +- .../lists/combo/combo-features/local-data.ts | 6 +- .../combo-overlay.component.html | 20 +- .../combo-overlay.component.scss | 14 +- .../combo-overlay/combo-overlay.component.ts | 31 +- .../combo-remote/combo-remote.component.html | 104 +++++- .../combo-remote/combo-remote.component.scss | 194 ++++++++++- .../combo-remote/combo-remote.component.ts | 314 +++++++++++++----- src/app/services/remoteNwind.service.ts | 18 +- 11 files changed, 631 insertions(+), 159 deletions(-) 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 92c25d630e..d84765eb46 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,18 @@ +
+
+ + + +
- - - -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 64282cc1ea..d95f1a9114 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,42 @@ -.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; +} + +.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 79bf967fa5..eeb26f4a90 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,17 @@ 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 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 481a17c9dc..ef17f8b1f2 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 a78f097040..95574ae6ba 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,13 @@ - - +
+ + + +
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 698803b942..39293be3a7 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,12 @@ -.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; } 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 ede80c3d91..787579b908 100644 --- a/src/app/lists/combo/combo-overlay/combo-overlay.component.ts +++ b/src/app/lists/combo/combo-overlay/combo-overlay.component.ts @@ -1,21 +1,38 @@ import { Component } from '@angular/core'; -import { GlobalPositionStrategy, OverlaySettings } from 'igniteui-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' }]; + 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 }; 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 d333189486..52f5cd212f 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 b0c5820c94..5104f8fbd8 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,192 @@ -.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; +} + +.remote-binding { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: center; + gap: 2.5rem 3.75rem; +} + +.combo-column { + width: 22.5rem; +} + +/* 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: 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-info-500); +} + +.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-info-500); + 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-success-900); + font-size: 0.6875rem; + font-weight: 500; + text-align: end; +} + +.log-result[data-status='pending'], +.log-result[data-status='cancelled'] { + color: var(--ig-warn-700); +} + +.log-result[data-status='error'] { + color: var(--ig-error-500); +} + +.log-detail { + grid-column: 1 / -1; + color: var(--ig-gray-700); + font-family: inherit; + font-size: 0.625rem; + word-break: break-all; } 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 febc5e093a..9d4ae6bd10 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 9d4cf039d7..f196d006b1 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) { From 2d34765d6d50bfb897910f751af146b5e3ffb891 Mon Sep 17 00:00:00 2001 From: Ivan Minchev Date: Tue, 10 Nov 2026 13:16:13 +0200 Subject: [PATCH 2/5] feat(combo): add the selection panel to the features and overlay samples --- .../combo-features.component.html | 15 ++++++- .../combo-features.component.scss | 44 +++++++++++++++++++ .../combo-features.component.ts | 1 + .../combo-overlay.component.html | 14 ++++++ .../combo-overlay.component.scss | 44 +++++++++++++++++++ .../combo-overlay/combo-overlay.component.ts | 12 ++++- 6 files changed, 127 insertions(+), 3 deletions(-) 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 d84765eb46..896d3581a3 100644 --- a/src/app/lists/combo/combo-features/combo-features.component.html +++ b/src/app/lists/combo/combo-features/combo-features.component.html @@ -3,9 +3,22 @@ + [groupKey]="grouping ? 'region' : ''" [disabled]="disabled" [(ngModel)]="selection"> + +
+ Selection + @if (selection.length) { +
    + @for (item of selection; track item) { +
  • {{ item }}
  • + } +
+ } @else { + No items selected + } +
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 d95f1a9114..bfc6be8bed 100644 --- a/src/app/lists/combo/combo-features/combo-features.component.scss +++ b/src/app/lists/combo/combo-features/combo-features.component.scss @@ -21,6 +21,50 @@ 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; 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 eeb26f4a90..3738fb766a 100644 --- a/src/app/lists/combo/combo-features/combo-features.component.ts +++ b/src/app/lists/combo/combo-features/combo-features.component.ts @@ -18,6 +18,7 @@ export class ComboFeaturesComponent implements OnInit{ public customValues = false; public grouping = false; public disabled = false; + public selection: string[] = []; public ngOnInit() { this.lData = localData; 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 95574ae6ba..1397833f15 100644 --- a/src/app/lists/combo/combo-overlay/combo-overlay.component.html +++ b/src/app/lists/combo/combo-overlay/combo-overlay.component.html @@ -1,5 +1,6 @@
+ +
+ 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 39293be3a7..36a77d6ea7 100644 --- a/src/app/lists/combo/combo-overlay/combo-overlay.component.scss +++ b/src/app/lists/combo/combo-overlay/combo-overlay.component.scss @@ -10,3 +10,47 @@ 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 787579b908..3c6244f350 100644 --- a/src/app/lists/combo/combo-overlay/combo-overlay.component.ts +++ b/src/app/lists/combo/combo-overlay/combo-overlay.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@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'; @@ -10,7 +10,10 @@ import { IgxLabelDirective } from 'igniteui-angular/input-group'; styleUrls: ['combo-overlay.component.scss'], imports: [IgxComboComponent, IgxLabelDirective, FormsModule] }) -export class ComboOverlayComponent { +export class ComboOverlayComponent implements AfterViewInit { + @ViewChild('combo', { static: true }) + public combo: IgxComboComponent; + public people = [ { name: 'Ana Petrova', department: 'Engineering' }, { name: 'Marc Lee', department: 'Engineering' }, @@ -36,4 +39,9 @@ export class ComboOverlayComponent { modal: true, closeOnOutsideClick: true }; + + // Opens the list on load, so the sample shows the overlay right away. + public ngAfterViewInit() { + this.combo.open(); + } } From 928e6399961da867bdad222b9f1a8ef97430acfa Mon Sep 17 00:00:00 2001 From: Ivan Minchev Date: Thu, 12 Nov 2026 11:48:52 +0200 Subject: [PATCH 3/5] fix(combo): use gray 900 for the remote sample's monitor text --- src/app/lists/combo/combo-remote/combo-remote.component.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 5104f8fbd8..d01a9889a0 100644 --- a/src/app/lists/combo/combo-remote/combo-remote.component.scss +++ b/src/app/lists/combo/combo-remote/combo-remote.component.scss @@ -108,7 +108,7 @@ /* What the last update changed. */ .state dd.changed, .selection-values { - color: var(--ig-info-500); + color: var(--ig-gray-900); } .selection-values { @@ -168,7 +168,7 @@ } .log-result { - color: var(--ig-success-900); + color: var(--ig-gray-900); font-size: 0.6875rem; font-weight: 500; text-align: end; From 47552f338367788357438c24582f41ec3943f377 Mon Sep 17 00:00:00 2001 From: Ivan Minchev Date: Thu, 12 Nov 2026 14:29:37 +0200 Subject: [PATCH 4/5] fix(combo): use gray 900 for the remote sample's status text and link --- .../lists/combo/combo-remote/combo-remote.component.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 d01a9889a0..e290b4997e 100644 --- a/src/app/lists/combo/combo-remote/combo-remote.component.scss +++ b/src/app/lists/combo/combo-remote/combo-remote.component.scss @@ -132,7 +132,7 @@ .selection-more-link { padding: 0; border: 0; - color: var(--ig-info-500); + color: var(--ig-gray-900); background: none; font: inherit; font-weight: 500; @@ -176,11 +176,11 @@ .log-result[data-status='pending'], .log-result[data-status='cancelled'] { - color: var(--ig-warn-700); + color: var(--ig-gray-900); } .log-result[data-status='error'] { - color: var(--ig-error-500); + color: var(--ig-gray-900); } .log-detail { From 2197ed72ff88f21f46bc74e0e28d03a48564ff58 Mon Sep 17 00:00:00 2001 From: Ivan Minchev Date: Thu, 12 Nov 2026 15:49:51 +0200 Subject: [PATCH 5/5] fix(combo): center the combo in the remote sample with the log beside it --- .../combo-remote/combo-remote.component.scss | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) 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 e290b4997e..2e3145f2a5 100644 --- a/src/app/lists/combo/combo-remote/combo-remote.component.scss +++ b/src/app/lists/combo/combo-remote/combo-remote.component.scss @@ -10,16 +10,17 @@ padding: 4.875rem 1rem 1.5rem; } +/* The combo sits in the middle, and the monitor in the space on its right. */ .remote-binding { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - justify-content: center; - gap: 2.5rem 3.75rem; + display: grid; + flex: 1; + grid-template-columns: 1fr minmax(0, 22.5rem) 1fr; + align-items: start; + column-gap: 3rem; } .combo-column { - width: 22.5rem; + grid-column: 2; } /* The dropdown footer: the chunk being loaded, and how much of the data is loaded. */ @@ -64,7 +65,8 @@ display: flex; flex-direction: column; gap: 1rem; - width: 20rem; + width: 100%; + max-width: 20rem; font-family: Inter, sans-serif; } @@ -188,5 +190,22 @@ color: var(--ig-gray-700); font-family: inherit; font-size: 0.625rem; - word-break: break-all; + 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; + } }