diff --git a/.github/pr-screenshots/picklist-940-lazy-load.png b/.github/pr-screenshots/picklist-940-lazy-load.png new file mode 100644 index 0000000000..13435111b8 Binary files /dev/null and b/.github/pr-screenshots/picklist-940-lazy-load.png differ diff --git a/apps/docs/doc/picklist/lazy-doc.ts b/apps/docs/doc/picklist/lazy-doc.ts new file mode 100644 index 0000000000..8ed371df43 --- /dev/null +++ b/apps/docs/doc/picklist/lazy-doc.ts @@ -0,0 +1,68 @@ +import { AppCode } from '@/components/doc/app.code'; +import { AppDocSectionText } from '@/components/doc/app.docsectiontext'; +import { Component, signal } from '@angular/core'; +import { ScrollerLazyLoadEvent } from '@openng/optimus-ui/scroller'; +import { PickListModule } from '@openng/optimus-ui/picklist'; + +@Component({ + selector: 'lazy-doc', + standalone: true, + imports: [PickListModule, AppCode, AppDocSectionText], + template: ` + +

+ When dealing with huge datasets, new chunks of data can be loaded on demand instead of all at once. Enable sourceVirtualScroll along with sourceLazy to render the source list with a Scroller and + fetch data as the user scrolls, using the onSourceLazyLoad event to request the next chunk. +

+
+
+ + + {{ item?.name }} + + +
+ + ` +}) +export class LazyDoc { + totalSourceCount = 10000; + + sourceProducts = signal(Array.from({ length: this.totalSourceCount }, () => ({}) as any)); + + targetProducts = signal([]); + + loadLazyTimeout: any = null; + + onSourceLazyLoad(event: ScrollerLazyLoadEvent) { + if (this.loadLazyTimeout) { + clearTimeout(this.loadLazyTimeout); + } + + // imitate the delay of a backend fetch for the next chunk of source items + this.loadLazyTimeout = setTimeout( + () => { + const { first, last } = event; + const products = [...this.sourceProducts()]; + + for (let i = first; i < (last ?? first); i++) { + products[i] = { id: i, name: `Product #${i}` }; + } + + this.sourceProducts.set(products); + }, + Math.random() * 1000 + 250 + ); + } +} diff --git a/apps/docs/pages/picklist/index.ts b/apps/docs/pages/picklist/index.ts index e6e27231cd..777ebe2362 100644 --- a/apps/docs/pages/picklist/index.ts +++ b/apps/docs/pages/picklist/index.ts @@ -2,6 +2,7 @@ import { AccessibilityDoc } from '@/doc/picklist/accessibility-doc'; import { BasicDoc } from '@/doc/picklist/basic-doc'; import { FilterDoc } from '@/doc/picklist/filter-doc'; import { ImportDoc } from '@/doc/picklist/import-doc'; +import { LazyDoc } from '@/doc/picklist/lazy-doc'; import { PTComponent } from '@/doc/picklist/pt/PTComponent'; import { TemplateDoc } from '@/doc/picklist/template-doc'; import { Component } from '@angular/core'; @@ -35,6 +36,11 @@ export class PickListDemo { label: 'Template', component: TemplateDoc }, + { + id: 'lazy', + label: 'Lazy Load', + component: LazyDoc + }, { id: 'accessibility', label: 'Accessibility', diff --git a/packages/optimus-ui/src/picklist/picklist.spec.ts b/packages/optimus-ui/src/picklist/picklist.spec.ts index abc3490bf6..c056a5a3a3 100644 --- a/packages/optimus-ui/src/picklist/picklist.spec.ts +++ b/packages/optimus-ui/src/picklist/picklist.spec.ts @@ -15,6 +15,7 @@ import { PickListTargetReorderEvent, PickListTargetSelectEvent } from '@openng/optimus-ui/types/picklist'; +import type { ScrollerLazyLoadEvent } from '@openng/optimus-ui/types/scroller'; import { PickList } from './picklist'; @Component({ @@ -36,6 +37,12 @@ import { PickList } from './picklist'; [sourceStyle]="sourceStyle" [targetStyle]="targetStyle" [dataKey]="dataKey" + [sourceLazy]="sourceLazy" + [targetLazy]="targetLazy" + [sourceVirtualScroll]="sourceVirtualScroll" + [targetVirtualScroll]="targetVirtualScroll" + [sourceVirtualScrollItemSize]="sourceVirtualScrollItemSize" + [targetVirtualScrollItemSize]="targetVirtualScrollItemSize" (onMoveToTarget)="onMoveToTarget($event)" (onMoveToSource)="onMoveToSource($event)" (onMoveAllToTarget)="onMoveAllToTarget($event)" @@ -44,6 +51,8 @@ import { PickList } from './picklist'; (onTargetSelect)="onTargetSelect($event)" (onSourceReorder)="onSourceReorder($event)" (onTargetReorder)="onTargetReorder($event)" + (onSourceLazyLoad)="onSourceLazyLoad($event)" + (onTargetLazyLoad)="onTargetLazyLoad($event)" >
{{ item.name }}
@@ -83,6 +92,12 @@ class TestPickListComponent { sourceStyle: any = null as any; targetStyle: any = null as any; dataKey: string | undefined; + sourceLazy: boolean = false; + targetLazy: boolean = false; + sourceVirtualScroll: boolean = false; + targetVirtualScroll: boolean = false; + sourceVirtualScrollItemSize: number | undefined; + targetVirtualScrollItemSize: number | undefined; // Event handlers onMoveToTarget(event: PickListMoveToTargetEvent) { @@ -117,6 +132,14 @@ class TestPickListComponent { this.targetReorderEvent = event; } + onSourceLazyLoad(event: ScrollerLazyLoadEvent) { + this.sourceLazyLoadEvent = event; + } + + onTargetLazyLoad(event: ScrollerLazyLoadEvent) { + this.targetLazyLoadEvent = event; + } + // Event tracking moveToTargetEvent: PickListMoveToTargetEvent | null = null as any; moveToSourceEvent: PickListMoveToSourceEvent | null = null as any; @@ -126,6 +149,8 @@ class TestPickListComponent { targetSelectEvent: PickListTargetSelectEvent | null = null as any; sourceReorderEvent: PickListSourceReorderEvent | null = null as any; targetReorderEvent: PickListTargetReorderEvent | null = null as any; + sourceLazyLoadEvent: ScrollerLazyLoadEvent | null = null as any; + targetLazyLoadEvent: ScrollerLazyLoadEvent | null = null as any; } describe('PickList', () => { @@ -167,6 +192,69 @@ describe('PickList', () => { }); }); + describe('Lazy Loading & VirtualScroll', () => { + it('should default lazy and virtualScroll to false for both lists', () => { + expect(picklistComponent.sourceLazy).toBe(false); + expect(picklistComponent.targetLazy).toBe(false); + expect(picklistComponent.sourceVirtualScroll).toBe(false); + expect(picklistComponent.targetVirtualScroll).toBe(false); + }); + + it('should forward sourceLazy and sourceVirtualScroll to the source listbox', () => { + component.sourceLazy = true; + component.sourceVirtualScroll = true; + component.sourceVirtualScrollItemSize = 32; + fixture.detectChanges(); + + const sourceListbox = fixture.debugElement.queryAll(By.css('p-listbox'))[0].componentInstance; + expect(sourceListbox.lazy).toBe(true); + expect(sourceListbox.virtualScroll).toBe(true); + expect(sourceListbox.virtualScrollItemSize).toBe(32); + }); + + it('should forward targetLazy and targetVirtualScroll to the target listbox', () => { + component.targetLazy = true; + component.targetVirtualScroll = true; + component.targetVirtualScrollItemSize = 48; + fixture.detectChanges(); + + const targetListbox = fixture.debugElement.queryAll(By.css('p-listbox'))[1].componentInstance; + expect(targetListbox.lazy).toBe(true); + expect(targetListbox.virtualScroll).toBe(true); + expect(targetListbox.virtualScrollItemSize).toBe(48); + }); + + it('should keep source and target lazy/virtualScroll configuration independent', () => { + component.sourceLazy = true; + component.sourceVirtualScroll = true; + component.targetLazy = false; + component.targetVirtualScroll = false; + fixture.detectChanges(); + + const listboxes = fixture.debugElement.queryAll(By.css('p-listbox')); + expect(listboxes[0].componentInstance.lazy).toBe(true); + expect(listboxes[0].componentInstance.virtualScroll).toBe(true); + expect(listboxes[1].componentInstance.lazy).toBe(false); + expect(listboxes[1].componentInstance.virtualScroll).toBe(false); + }); + + it('should emit onSourceLazyLoad when the source listbox emits onLazyLoad', () => { + const event: ScrollerLazyLoadEvent = { first: 0, last: 50 } as ScrollerLazyLoadEvent; + + picklistComponent.onSourceLazyLoad.emit(event); + + expect(component.sourceLazyLoadEvent).toBe(event); + }); + + it('should emit onTargetLazyLoad when the target listbox emits onLazyLoad', () => { + const event: ScrollerLazyLoadEvent = { first: 10, last: 60 } as ScrollerLazyLoadEvent; + + picklistComponent.onTargetLazyLoad.emit(event); + + expect(component.targetLazyLoadEvent).toBe(event); + }); + }); + describe('Drag & Drop Functionality', () => { it('should have CDK drag drop enabled when dragdrop is true', () => { // Check that dragdrop is enabled on component diff --git a/packages/optimus-ui/src/picklist/picklist.ts b/packages/optimus-ui/src/picklist/picklist.ts index 5cc5935a90..6c6ea66f03 100755 --- a/packages/optimus-ui/src/picklist/picklist.ts +++ b/packages/optimus-ui/src/picklist/picklist.ts @@ -22,13 +22,14 @@ import { } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { find, findIndexInList, isEmpty, setAttribute, uuid } from '@openng/optimus-ui-utils'; -import { FilterService, PrimeTemplate, SharedModule } from '@openng/optimus-ui/api'; +import { FilterService, PrimeTemplate, ScrollerOptions, SharedModule } from '@openng/optimus-ui/api'; import { BaseComponent, PARENT_INSTANCE } from '@openng/optimus-ui/basecomponent'; import { Bind, BindModule } from '@openng/optimus-ui/bind'; import { ButtonModule, ButtonProps } from '@openng/optimus-ui/button'; import { AngleDoubleDownIcon, AngleDoubleLeftIcon, AngleDoubleRightIcon, AngleDoubleUpIcon, AngleDownIcon, AngleLeftIcon, AngleRightIcon, AngleUpIcon } from '@openng/optimus-ui/icons'; import { Listbox, ListboxChangeEvent } from '@openng/optimus-ui/listbox'; import { Ripple } from '@openng/optimus-ui/ripple'; +import { ScrollerLazyLoadEvent } from '@openng/optimus-ui/scroller'; import { Nullable, VoidListener } from '@openng/optimus-ui/ts-helpers'; import { PickListFilterOptions, @@ -169,6 +170,11 @@ const PICKLIST_INSTANCE = new InjectionToken('PICKLIST_INSTANCE'); [filterPlaceHolder]="sourceFilterPlaceholder" [dragdrop]="dragdrop" [dropListData]="source()" + [lazy]="sourceLazy" + [virtualScroll]="sourceVirtualScroll" + [virtualScrollItemSize]="sourceVirtualScrollItemSize" + [virtualScrollOptions]="sourceVirtualScrollOptions" + (onLazyLoad)="onSourceLazyLoad.emit($event)" (onDrop)="onDrop($event, SOURCE_LIST)" (onFilter)="onFilter($event.originalEvent, SOURCE_LIST)" [pt]="ptm('pcListbox')" @@ -312,6 +318,11 @@ const PICKLIST_INSTANCE = new InjectionToken('PICKLIST_INSTANCE'); [filterPlaceHolder]="targetFilterPlaceholder" [dragdrop]="dragdrop" [dropListData]="target()" + [lazy]="targetLazy" + [virtualScroll]="targetVirtualScroll" + [virtualScrollItemSize]="targetVirtualScrollItemSize" + [virtualScrollOptions]="targetVirtualScrollOptions" + (onLazyLoad)="onTargetLazyLoad.emit($event)" (onDrop)="onDrop($event, TARGET_LIST)" (onFilter)="onFilter($event.originalEvent, TARGET_LIST)" [pt]="ptm('pcListbox')" @@ -611,6 +622,47 @@ export class PickList extends BaseComponent { */ @Input({ transform: booleanAttribute }) disabled: boolean; + /** + * Defines if data of source list is loaded and interacted with in a lazy manner. + * @group Props + */ + @Input({ transform: booleanAttribute }) sourceLazy: boolean = false; + /** + * Defines if data of target list is loaded and interacted with in a lazy manner. + * @group Props + */ + @Input({ transform: booleanAttribute }) targetLazy: boolean = false; + /** + * Whether to use the virtual scroller feature for the source list to render the items lazily. + * @group Props + */ + @Input({ transform: booleanAttribute }) sourceVirtualScroll: boolean | undefined; + /** + * Whether to use the virtual scroller feature for the target list to render the items lazily. + * @group Props + */ + @Input({ transform: booleanAttribute }) targetVirtualScroll: boolean | undefined; + /** + * Height of an item in the source list for VirtualScrolling. + * @group Props + */ + @Input({ transform: numberAttribute }) sourceVirtualScrollItemSize: number | undefined; + /** + * Height of an item in the target list for VirtualScrolling. + * @group Props + */ + @Input({ transform: numberAttribute }) targetVirtualScrollItemSize: number | undefined; + /** + * Whether to use the scroller feature for the source list. The properties of scroller component can be used like an object in it. + * @group Props + */ + @Input() sourceVirtualScrollOptions: ScrollerOptions | undefined; + /** + * Whether to use the scroller feature for the target list. The properties of scroller component can be used like an object in it. + * @group Props + */ + @Input() targetVirtualScrollOptions: ScrollerOptions | undefined; + /** * Name of the disabled field of a target option or function to determine disabled state. * @group Props @@ -781,6 +833,20 @@ export class PickList extends BaseComponent { */ @Output() onTargetFilter: EventEmitter = new EventEmitter(); + /** + * Callback to invoke on lazy load of the source list, requires the virtualScroll to be enabled. + * @param {ScrollerLazyLoadEvent} event - Scroller lazy load event. + * @group Emits + */ + @Output() onSourceLazyLoad: EventEmitter = new EventEmitter(); + + /** + * Callback to invoke on lazy load of the target list, requires the virtualScroll to be enabled. + * @param {ScrollerLazyLoadEvent} event - Scroller lazy load event. + * @group Emits + */ + @Output() onTargetLazyLoad: EventEmitter = new EventEmitter(); + /** * Callback to invoke when the list is focused * @param {Event} event - Browser event.