diff --git a/packages/components-dev/notification-center/module.ts b/packages/components-dev/notification-center/module.ts index 37efc9186b..c05358d125 100644 --- a/packages/components-dev/notification-center/module.ts +++ b/packages/components-dev/notification-center/module.ts @@ -23,6 +23,9 @@ import { NotificationCenterExamplesModule } from '../../docs-examples/components

+ +
+
`, changeDetection: ChangeDetectionStrategy.OnPush diff --git a/packages/components/core/locales/en-US.ts b/packages/components/core/locales/en-US.ts index bba08b080c..bad707dba2 100644 --- a/packages/components/core/locales/en-US.ts +++ b/packages/components/core/locales/en-US.ts @@ -218,6 +218,7 @@ export const enUSLocaleData = { showPopUpNotifications: 'Show pop-up notifications', noNotifications: 'No notifications', failedToLoadNotifications: 'Failed to load notifications', - repeat: 'Repeat' + repeat: 'Repeat', + loadingMore: 'Loading more notifications' } }; diff --git a/packages/components/core/locales/es-LA.ts b/packages/components/core/locales/es-LA.ts index f548ac07f5..94471ba698 100644 --- a/packages/components/core/locales/es-LA.ts +++ b/packages/components/core/locales/es-LA.ts @@ -217,6 +217,7 @@ export const esLALocaleData = { showPopUpNotifications: 'Mostrar notificaciones emergentes', noNotifications: 'Sin notificaciones', failedToLoadNotifications: 'Error al cargar las notificaciones', - repeat: 'Repetir' + repeat: 'Repetir', + loadingMore: 'Cargando más notificaciones' } }; diff --git a/packages/components/core/locales/pt-BR.ts b/packages/components/core/locales/pt-BR.ts index 5fe5b0bff9..45368cfcd1 100644 --- a/packages/components/core/locales/pt-BR.ts +++ b/packages/components/core/locales/pt-BR.ts @@ -217,6 +217,7 @@ export const ptBRLocaleData = { showPopUpNotifications: 'Mostrar notificações pop-up', noNotifications: 'Sem notificações', failedToLoadNotifications: 'Falha ao carregar notificações', - repeat: 'Repetir' + repeat: 'Repetir', + loadingMore: 'Carregando mais notificações' } }; diff --git a/packages/components/core/locales/ru-RU.ts b/packages/components/core/locales/ru-RU.ts index 7201eeac87..d9755d3054 100644 --- a/packages/components/core/locales/ru-RU.ts +++ b/packages/components/core/locales/ru-RU.ts @@ -232,6 +232,7 @@ export const ruRULocaleData = { showPopUpNotifications: 'Показывать всплывающие уведомления', noNotifications: 'Нет уведомлений', failedToLoadNotifications: 'Не удалось загрузить уведомления', - repeat: 'Повторить' + repeat: 'Повторить', + loadingMore: 'Загрузка уведомлений' } }; diff --git a/packages/components/core/locales/tk-TM.ts b/packages/components/core/locales/tk-TM.ts index 9081c119cf..e335bfe187 100644 --- a/packages/components/core/locales/tk-TM.ts +++ b/packages/components/core/locales/tk-TM.ts @@ -218,6 +218,7 @@ export const tkTMLocaleData = { showPopUpNotifications: 'Açylýan bildirişleri görkeziň', noNotifications: 'Duýduryş ýok', failedToLoadNotifications: 'Duýduryşlary ýükläp bilmedi', - repeat: 'Gaýtalama' + repeat: 'Gaýtalama', + loadingMore: 'Duýduryşlar ýüklenýär' } }; diff --git a/packages/components/navbar/navbar-item.scss b/packages/components/navbar/navbar-item.scss index 51af952c7d..6af283bdd8 100644 --- a/packages/components/navbar/navbar-item.scss +++ b/packages/components/navbar/navbar-item.scss @@ -20,6 +20,15 @@ min-height: var(--kbq-size-l); } + // Filled fade-contrast only: its background is a semi-transparent tint, so the + // navbar icon shows through. Composite the tint over an opaque bg-tertiary substrate. + // The outline variant is intentionally left untouched — it stays a clean transparent outline. + & .kbq-badge-filled.kbq-badge_fade-contrast { + background: + linear-gradient(var(--kbq-background-contrast-fade), var(--kbq-background-contrast-fade)), + var(--kbq-background-bg-tertiary); + } + & .kbq-button-icon { position: absolute; padding-left: var(--kbq-size-xs); diff --git a/packages/components/notification-center/__screenshots__/01-dark.png b/packages/components/notification-center/__screenshots__/01-dark.png index 304da686f8..95aae02f91 100644 Binary files a/packages/components/notification-center/__screenshots__/01-dark.png and b/packages/components/notification-center/__screenshots__/01-dark.png differ diff --git a/packages/components/notification-center/__screenshots__/01-light.png b/packages/components/notification-center/__screenshots__/01-light.png index 93c8d3eb30..fee9c10824 100644 Binary files a/packages/components/notification-center/__screenshots__/01-light.png and b/packages/components/notification-center/__screenshots__/01-light.png differ diff --git a/packages/components/notification-center/_notification-center-theme.scss b/packages/components/notification-center/_notification-center-theme.scss index 7f8b8b2186..cddfad9f73 100644 --- a/packages/components/notification-center/_notification-center-theme.scss +++ b/packages/components/notification-center/_notification-center-theme.scss @@ -11,7 +11,8 @@ } } - .kbq-notification-center-error-container { + .kbq-notification-center-error-container, + .kbq-notification-center-load-more-error { color: var(--kbq-foreground-error); } } diff --git a/packages/components/notification-center/notification-center.en.md b/packages/components/notification-center/notification-center.en.md index c0436c459b..60a7205d50 100644 --- a/packages/components/notification-center/notification-center.en.md +++ b/packages/components/notification-center/notification-center.en.md @@ -29,10 +29,20 @@ In the empty state, a "No notifications" message is displayed and the "Delete al ### Error -The panel supports scrolling with a sticky header and lazy loading of records. In the empty state, a "No notifications" message is displayed and the "Delete all" button is hidden. +If notifications fail to load, a "Failed to load notifications" message and a "Repeat" button are shown instead of the list. Enable this state with `setErrorMode`, and subscribe to `KbqNotificationCenterService.onReload` to retry loading when the button is pressed. +### Infinite scroll + +The list can load notifications page by page as the user scrolls to the bottom. Subscribe to `KbqNotificationCenterService.onNextPage` to fetch the next page, append the result to `items`, and control the flow with `setLoadingMore`, `setHasMore` and `setLoadMoreErrorMode`. The threshold at which loading starts is configured with the `scrolledToBottomOffset` input. + + + +### Deletion + +Notifications can be removed one by one, by date group, or all at once. Subscribe to `KbqNotificationCenterService.onDelete` to react to a removal — for example, to delete the items on the server. The event fires for all three cases and carries `type` (`'item'`, `'group'` or `'all'`) and `items` — the notifications that were removed. + ### Dropdown window The notification center can be opened in a popover. For example, when placed in a horizontal menu. diff --git a/packages/components/notification-center/notification-center.html b/packages/components/notification-center/notification-center.html index 3b607f62ec..ac88fca79e 100644 --- a/packages/components/notification-center/notification-center.html +++ b/packages/components/notification-center/notification-center.html @@ -6,9 +6,11 @@ #dropdownTrigger="kbqDropdownTrigger" kbq-button class="kbq-notification-center-title__button" + data-testid="kbq-notification-center-silent-mode-toggle" [kbqDropdownTriggerFor]="notificationSwitcherDropdown" [kbqTooltip]="service.silentMode.value ? localeData.doNotDisturb : localeData.showPopUpNotifications" [kbqTooltipArrow]="false" + [kbqPlacement]="'right'" [kbqTooltipOffset]="4" [kbqTooltipDisabled]="dropdownTrigger.opened" [kbqStyle]="'transparent'" @@ -22,6 +24,7 @@ } } @else { - + } } @else { -
+
{{ localeData.failedToLoadNotifications }} -
@@ -96,11 +142,19 @@
- - diff --git a/packages/components/notification-center/notification-center.ru.md b/packages/components/notification-center/notification-center.ru.md index 8af865557f..0fd37c35b9 100644 --- a/packages/components/notification-center/notification-center.ru.md +++ b/packages/components/notification-center/notification-center.ru.md @@ -33,6 +33,16 @@ npm install overlayscrollbars@2.7.3 +### Бесконечная прокрутка + +Список может догружать уведомления постранично по мере прокрутки к концу списка. Подпишитесь на `KbqNotificationCenterService.onNextPage`, чтобы загрузить следующую страницу, добавьте результат в `items` и управляйте процессом через `setLoadingMore`, `setHasMore` и `setLoadMoreErrorMode`. Порог, при котором начинается загрузка, настраивается свойством `scrolledToBottomOffset`. + + + +### Удаление + +Уведомления можно удалять по одному, группой за день или все сразу. Подпишитесь на `KbqNotificationCenterService.onDelete`, чтобы отреагировать на удаление — например, удалить элементы на сервере. Событие срабатывает во всех трёх случаях и содержит `type` (`'item'`, `'group'` или `'all'`) и `items` — удалённые уведомления. + ### Выпадающее окно Центр уведомлений можно открыть в поповере. Например, при размещении в горизонтальном меню. diff --git a/packages/components/notification-center/notification-center.scss b/packages/components/notification-center/notification-center.scss index 311f234355..626664c4ff 100644 --- a/packages/components/notification-center/notification-center.scss +++ b/packages/components/notification-center/notification-center.scss @@ -36,7 +36,7 @@ justify-content: space-between; - padding: var(--kbq-size-xl) var(--kbq-size-xl) var(--kbq-size-xxs) var(--kbq-size-xxl); + padding: var(--kbq-size-m) var(--kbq-size-xl) var(--kbq-size-xxs) var(--kbq-size-xxl); } .kbq-notification-center-title { @@ -67,7 +67,7 @@ display: flex; flex-direction: row; - padding: var(--kbq-size-s) var(--kbq-size-xxl); + padding: var(--kbq-size-s) var(--kbq-size-xl); & .kbq-notification-center-sub-header__button { position: absolute; @@ -92,7 +92,7 @@ border-radius: inherit; - padding-bottom: var(--kbq-size-m); + padding-bottom: var(--kbq-size-xl); & .kbq-loader-overlay_parent { border-radius: inherit; @@ -103,6 +103,31 @@ } } +.kbq-notification-center-load-more { + display: flex; + + align-items: center; + justify-content: center; + + padding: 26px 0; +} + +.kbq-notification-center-load-more-error { + display: flex; + flex-direction: column; + + align-items: center; + justify-content: center; + + padding: var(--kbq-size-xxs) var(--kbq-size-xxl); + + text-align: center; + + & .kbq-button { + margin-top: var(--kbq-size-s); + } +} + .kbq-notification-center-empty-container, .kbq-notification-center-error-container { display: flex; diff --git a/packages/components/notification-center/notification-center.service.ts b/packages/components/notification-center/notification-center.service.ts index 2e0e698b4d..9efd7f1502 100644 --- a/packages/components/notification-center/notification-center.service.ts +++ b/packages/components/notification-center/notification-center.service.ts @@ -8,6 +8,9 @@ import { map } from 'rxjs/operators'; export interface KbqNotificationItem extends Omit { id?: string; + /** Numeric id of the shown toast, set by `push()` and consumed by `hideToast()`. */ + toastId?: number; + title?: string | TemplateRef; style?: string | KbqToastStyle; @@ -28,6 +31,14 @@ type KbqNotificationsGroup = { title: string; items: KbqNotificationItem[] }; type KbqNotificationsGroups = Record; +/** Payload emitted by `KbqNotificationCenterService.onDelete`. */ +export type KbqNotificationDeleteEvent = { + /** What was removed: a single item, a whole date group, or all notifications. */ + type: 'item' | 'group' | 'all'; + /** The notification items that were removed. */ + items: KbqNotificationItem[]; +}; + @Injectable({ providedIn: 'root' }) export class KbqNotificationCenterService { /** @docs-private */ @@ -43,27 +54,68 @@ export class KbqNotificationCenterService { readonly loadingMode = new BehaviorSubject(false); /** @docs-private */ readonly errorMode = new BehaviorSubject(false); + /** + * Whether the bottom "load more" spinner is shown while the next page is being loaded. + * Note: this is the infinite-scroll indicator and is distinct from `loadingMode`, + * which renders the full-screen loader instead of the list. + */ + readonly loadingMore = new BehaviorSubject(false); + /** + * Whether the bottom "load more" error row (with a retry button) is shown. + * Distinct from `errorMode`, which replaces the whole list with the full-screen error state. + */ + readonly loadMoreErrorMode = new BehaviorSubject(false); + /** + * Whether there are more notifications to load. While `true`, scrolling to the bottom + * emits `onNextPage`; set it to `false` to stop further infinite-scroll requests. + */ + readonly hasMore = new BehaviorSubject(true); /** @docs-private */ readonly onRead = new BehaviorSubject(null); /** Triggers an event when the user presses the reload button. */ readonly onReload = new EventEmitter(); + /** Triggers an event when the list is scrolled to the bottom and the next page should be loaded. */ + readonly onNextPage = new EventEmitter(); + + /** Triggers an event when an item, a group, or all notifications are removed. */ + readonly onDelete = new EventEmitter(); + private originalItems = new BehaviorSubject([] as KbqNotificationItem[]); - /** @docs-private */ + /** + * Grouped notifications, always ordered from newest to oldest: day groups are sorted by date + * descending, and notifications within each day are sorted by date descending. + * @docs-private + */ readonly groupedItems = this.originalItems.pipe( map((items) => { const result: KbqNotificationsGroups = {}; - items.map((item) => this.makeGroup(item, result)); + items.forEach((item) => this.makeGroup(item, result)); + + const groups = Object.values(result); - return Object.values(result).reverse(); + // Newest notifications first within each day. + groups.forEach((group) => group.items.sort(this.compareByDateDesc)); + + // Newest day first. + return groups.sort((a, b) => this.compareByDateDesc(a.items[0], b.items[0])); }) ); /** Emits an event whenever the changes. */ - readonly changes = merge(this.silentMode, this.loadingMode, this.errorMode, this.originalItems, this.onRead); + readonly changes = merge( + this.silentMode, + this.loadingMode, + this.errorMode, + this.loadingMore, + this.loadMoreErrorMode, + this.hasMore, + this.originalItems, + this.onRead + ); /** Notification items */ get items() { @@ -120,30 +172,69 @@ export class KbqNotificationCenterService { this.errorMode.next(value); } + /** Set the bottom "load more" spinner visibility. */ + setLoadingMore(value: boolean) { + this.loadingMore.next(value); + } + + /** Set the bottom "load more" error state visibility. */ + setLoadMoreErrorMode(value: boolean) { + this.loadMoreErrorMode.next(value); + } + + /** Set whether there are more notifications to load via infinite scroll. */ + setHasMore(value: boolean) { + this.hasMore.next(value); + } + /** Push new notification item in center */ push(item: KbqNotificationItem) { this.setReadState(this.setIds([item])); if (!this.silentMode.value) { - this.toastService.show(item); + item.toastId = this.toastService.show(item).id; } return this.originalItems.next([...this.originalItems.value, item]); } + /** Hides the toast that corresponds to the given notification item. */ + hideToast(item: KbqNotificationItem): void { + if (item.toastId === undefined) { + return; + } + + this.toastService.hide(item.toastId); + item.toastId = undefined; + } + /** Remove notification item */ remove(removedItem: KbqNotificationItem) { + this.hideToast(removedItem); + this.originalItems.next(this.originalItems.value.filter((item) => removedItem !== item)); + + this.onDelete.emit({ type: 'item', items: [removedItem] }); } /** Remove group of notification items */ removeGroup(group: KbqNotificationsGroup) { + group.items.forEach((item) => this.hideToast(item)); + this.originalItems.next(this.originalItems.value.filter((item) => !group.items.includes(item))); + + this.onDelete.emit({ type: 'group', items: [...group.items] }); } /** Remove all notification items */ removeAll() { + const items = this.originalItems.value; + + items.forEach((item) => this.hideToast(item)); + this.originalItems.next([]); + + this.onDelete.emit({ type: 'all', items }); } private makeGroup = (item: KbqNotificationItem, groups: KbqNotificationsGroups) => { @@ -152,7 +243,7 @@ export class KbqNotificationCenterService { const groupTitle = this.formatter.absoluteLongDate(parsedDate); if (groups[groupId]) { - groups[groupId].items.unshift(item); + groups[groupId].items.push(item); } else { groups[groupId] = { title: groupTitle, @@ -161,6 +252,18 @@ export class KbqNotificationCenterService { } }; + /** Compares two notifications by date so the newest comes first. */ + private compareByDateDesc = (a: KbqNotificationItem, b: KbqNotificationItem): number => { + const parsedA = this.adapter.parse(a.date, ''); + const parsedB = this.adapter.parse(b.date, ''); + + if (!parsedA || !parsedB) { + return 0; + } + + return this.adapter.compareDateTime(parsedB, parsedA); + }; + private setIds(items: KbqNotificationItem[]) { items.forEach((item) => (item.id = item.id ?? new Date().getTime().toString())); diff --git a/packages/components/notification-center/notification-center.spec.ts b/packages/components/notification-center/notification-center.spec.ts index 99772f774d..c2325ffa42 100644 --- a/packages/components/notification-center/notification-center.spec.ts +++ b/packages/components/notification-center/notification-center.spec.ts @@ -1,12 +1,18 @@ import { OverlayContainer } from '@angular/cdk/overlay'; import { Component, DebugElement, Provider, Type, ViewChild } from '@angular/core'; -import { ComponentFixture, TestBed, fakeAsync, inject } from '@angular/core/testing'; +import { ComponentFixture, TestBed, fakeAsync, inject, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { KbqLuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter'; import { KbqFormattersModule } from '@koobiq/components/core'; -import { KbqNotificationCenterModule, KbqNotificationCenterTrigger } from '@koobiq/components/notification-center'; +import { + KbqNotificationCenterModule, + KbqNotificationCenterService, + KbqNotificationCenterTrigger, + KbqNotificationItem +} from '@koobiq/components/notification-center'; import { KbqScrollbarModule } from '@koobiq/components/scrollbar'; +import { KbqToastService } from '@koobiq/components/toast'; import { AsyncScheduler } from 'rxjs/internal/scheduler/AsyncScheduler'; import { TestScheduler } from 'rxjs/testing'; @@ -67,6 +73,424 @@ describe('KbqNotificationCenter', () => { expect(center.nativeElement).toBeDefined(); expect(center.query(By.css('.kbq-notification-center-header')).nativeElement).toBeDefined(); })); + + describe('infinite scroll', () => { + // The rendered center resolves the service from the module provider (re-provided in + // KbqNotificationCenterModule), so drive that exact instance via the trigger — not the root one. + const getService = () => + (componentInstance.trigger as unknown as { service: KbqNotificationCenterService }).service; + + const openCenter = () => { + componentInstance.trigger.show(); + fixture.detectChanges(); + }; + + // Mirrors SCROLLED_TO_BOTTOM_AUDIT_TIME in notification-center.ts (private to that module). + const scrollAuditTime = 100; + + // The scroll-to-bottom detection lives on the rendered overlay component, not the trigger. + const getCenter = () => + ( + componentInstance.trigger as unknown as { + instance: { + scrollContainer: { contentElement: { nativeElement: HTMLElement } }; + onContainerScroll: () => void; + }; + } + ).instance; + + // Fakes the container geometry so the list sits exactly at the bottom (distance 0 <= the + // default scrolledToBottomOffset of 0). + const setAtBottomGeometry = () => { + const element = getCenter().scrollContainer.contentElement.nativeElement; + + Object.defineProperty(element, 'scrollHeight', { configurable: true, value: 1000 }); + Object.defineProperty(element, 'clientHeight', { configurable: true, value: 500 }); + Object.defineProperty(element, 'offsetHeight', { configurable: true, value: 500 }); + Object.defineProperty(element, 'scrollTop', { configurable: true, value: 500 }); + }; + + // Sits the list at the bottom, then fires the container's scroll handler. + const scrollToBottom = () => { + setAtBottomGeometry(); + + getCenter().onContainerScroll(); + }; + + it('shows the bottom "load more" spinner without replacing the list', fakeAsync(() => { + const service = getService(); + + openCenter(); + + service.setLoadingMore(true); + fixture.detectChanges(); + + expect(debugElement.query(By.css('.kbq-notification-center-load-more kbq-progress-spinner'))).not.toBe( + null + ); + // the full-screen loader must NOT replace the list + expect(debugElement.query(By.css('.kbq-loader-overlay'))).toBe(null); + })); + + it('shows the bottom "load more" error row, separate from the full-screen error', fakeAsync(() => { + const service = getService(); + + openCenter(); + + service.setLoadMoreErrorMode(true); + fixture.detectChanges(); + + const errorRow = debugElement.query(By.css('.kbq-notification-center-load-more-error')); + + expect(errorRow).not.toBe(null); + expect(errorRow.query(By.css('button'))).not.toBe(null); + // full-screen error state must NOT be shown + expect(debugElement.query(By.css('.kbq-notification-center-error-container'))).toBe(null); + })); + + it('re-emits onNextPage and clears the error when the bottom retry button is clicked', fakeAsync(() => { + const service = getService(); + const emitSpy = jest.spyOn(service.onNextPage, 'emit'); + + openCenter(); + + service.setLoadMoreErrorMode(true); + fixture.detectChanges(); + + debugElement + .query(By.css('.kbq-notification-center-load-more-error button')) + .triggerEventHandler('click', {}); + + expect(emitSpy).toHaveBeenCalled(); + // retry must reset the error state itself so the spinner and the error row can never coexist + expect(service.loadMoreErrorMode.value).toBe(false); + })); + + it('keeps paging when a completed load leaves the list still at the bottom', fakeAsync(() => { + const service = getService(); + const emitSpy = jest.spyOn(service.onNextPage, 'emit'); + + openCenter(); + + // The just-loaded page was too short to overflow: the list is still at the bottom and + // no further scroll event will fire — completing the load must re-trigger paging. + setAtBottomGeometry(); + + service.setLoadingMore(true); + service.setLoadingMore(false); + tick(scrollAuditTime); + + expect(emitSpy).toHaveBeenCalled(); + })); + + it('keeps the full-screen error path emitting onReload', fakeAsync(() => { + const service = getService(); + const reloadSpy = jest.spyOn(service.onReload, 'emit'); + + openCenter(); + + service.setErrorMode(true); + fixture.detectChanges(); + + const errorContainer = debugElement.query(By.css('.kbq-notification-center-error-container')); + + expect(errorContainer).not.toBe(null); + // the bottom load-more rows must NOT render while the full-screen error is shown + expect(debugElement.query(By.css('.kbq-notification-center-load-more'))).toBe(null); + + errorContainer.query(By.css('button')).triggerEventHandler('click', {}); + + expect(reloadSpy).toHaveBeenCalled(); + })); + + it('reports loadingMore / loadMoreErrorMode updates through the changes stream', () => { + const service = getService(); + + let emissions = 0; + const subscription = service.changes.subscribe(() => emissions++); + + const afterSubscribe = emissions; + + service.setLoadingMore(true); + expect(emissions).toBe(afterSubscribe + 1); + + const afterLoadingMore = emissions; + + service.setLoadMoreErrorMode(true); + expect(emissions).toBe(afterLoadingMore + 1); + + subscription.unsubscribe(); + }); + + it('updates the corresponding subjects through the setters', () => { + const service = getService(); + + service.setLoadingMore(true); + expect(service.loadingMore.value).toBe(true); + + service.setLoadMoreErrorMode(true); + expect(service.loadMoreErrorMode.value).toBe(true); + + service.setHasMore(false); + expect(service.hasMore.value).toBe(false); + }); + + it('emits onNextPage when scrolled to the bottom with more to load', fakeAsync(() => { + const service = getService(); + const emitSpy = jest.spyOn(service.onNextPage, 'emit'); + + openCenter(); + + scrollToBottom(); + tick(scrollAuditTime); + + expect(emitSpy).toHaveBeenCalled(); + })); + + it('does not emit onNextPage when there is nothing more to load', fakeAsync(() => { + const service = getService(); + const emitSpy = jest.spyOn(service.onNextPage, 'emit'); + + openCenter(); + + service.setHasMore(false); + + scrollToBottom(); + tick(scrollAuditTime); + + expect(emitSpy).not.toHaveBeenCalled(); + })); + + it('does not emit onNextPage while a page is already loading', fakeAsync(() => { + const service = getService(); + const emitSpy = jest.spyOn(service.onNextPage, 'emit'); + + openCenter(); + + service.setLoadingMore(true); + + scrollToBottom(); + tick(scrollAuditTime); + + expect(emitSpy).not.toHaveBeenCalled(); + })); + + it('does not emit onNextPage while the load-more error is shown', fakeAsync(() => { + const service = getService(); + const emitSpy = jest.spyOn(service.onNextPage, 'emit'); + + openCenter(); + + service.setLoadMoreErrorMode(true); + + scrollToBottom(); + tick(scrollAuditTime); + + expect(emitSpy).not.toHaveBeenCalled(); + })); + }); + + describe('onDelete', () => { + const getService = () => + (componentInstance.trigger as unknown as { service: KbqNotificationCenterService }).service; + + const createItem = (title: string): KbqNotificationItem => ({ title, date: new Date().toISOString() }); + + it('emits an "item" event with the removed item on remove()', () => { + const service = getService(); + const item = createItem('a'); + + service.items = [item]; + + const emitSpy = jest.spyOn(service.onDelete, 'emit'); + + service.remove(item); + + expect(emitSpy).toHaveBeenCalledWith({ type: 'item', items: [item] }); + expect(service.isEmpty).toBe(true); + }); + + it('emits a "group" event with the group items on removeGroup()', () => { + const service = getService(); + const item = createItem('a'); + + service.items = [item]; + + const emitSpy = jest.spyOn(service.onDelete, 'emit'); + + service.removeGroup({ title: 'group', items: [item] }); + + expect(emitSpy).toHaveBeenCalledWith({ type: 'group', items: [item] }); + expect(service.isEmpty).toBe(true); + }); + + it('emits an "all" event with a snapshot of all items on removeAll()', () => { + const service = getService(); + const items = [createItem('a'), createItem('b')]; + + service.items = items; + + const emitSpy = jest.spyOn(service.onDelete, 'emit'); + + service.removeAll(); + + expect(emitSpy).toHaveBeenCalledWith({ type: 'all', items }); + expect(service.isEmpty).toBe(true); + }); + }); + + describe('hideToast', () => { + const getService = () => + (componentInstance.trigger as unknown as { service: KbqNotificationCenterService }).service; + + const createItem = (title: string): KbqNotificationItem => ({ title, date: new Date().toISOString() }); + + it('push() stores the returned toast id on the item', () => { + const service = getService(); + const toastService = TestBed.inject(KbqToastService); + + jest.spyOn(toastService, 'show').mockReturnValue({ id: 42, ref: {} as any }); + + const item = createItem('a'); + + service.push(item); + + expect(item.toastId).toBe(42); + }); + + it('hides the toast by the stored toastId and clears it', () => { + const service = getService(); + const toastService = TestBed.inject(KbqToastService); + const hideSpy = jest.spyOn(toastService, 'hide').mockImplementation(); + + const item: KbqNotificationItem = { ...createItem('a'), toastId: 42 }; + + service.hideToast(item); + + expect(hideSpy).toHaveBeenCalledWith(42); + expect(item.toastId).toBeUndefined(); + }); + + it('does nothing when the item has no toastId', () => { + const service = getService(); + const toastService = TestBed.inject(KbqToastService); + const hideSpy = jest.spyOn(toastService, 'hide').mockImplementation(); + + service.hideToast(createItem('a')); + + expect(hideSpy).not.toHaveBeenCalled(); + }); + + it('remove() hides the toast of the removed item', () => { + const service = getService(); + const toastService = TestBed.inject(KbqToastService); + + jest.spyOn(toastService, 'show').mockReturnValue({ id: 7, ref: {} as any }); + const hideSpy = jest.spyOn(toastService, 'hide').mockImplementation(); + + const item = createItem('a'); + + service.push(item); + service.remove(item); + + expect(hideSpy).toHaveBeenCalledWith(7); + }); + + it('removeAll() hides the toasts of all items shown via push()', () => { + const service = getService(); + const toastService = TestBed.inject(KbqToastService); + + jest.spyOn(toastService, 'show') + .mockReturnValueOnce({ id: 1, ref: {} as any }) + .mockReturnValueOnce({ id: 2, ref: {} as any }); + const hideSpy = jest.spyOn(toastService, 'hide').mockImplementation(); + + service.push(createItem('a')); + service.push(createItem('b')); + service.removeAll(); + + expect(hideSpy).toHaveBeenCalledWith(1); + expect(hideSpy).toHaveBeenCalledWith(2); + }); + }); + + describe('ordering', () => { + const getService = () => + (componentInstance.trigger as unknown as { service: KbqNotificationCenterService }).service; + + const createItem = (title: string, date: string): KbqNotificationItem => ({ title, date }); + + // groupedItems is built from a BehaviorSubject, so it emits synchronously on subscribe. + const readTitles = (service: KbqNotificationCenterService): string[][] => { + let titles: string[][] = []; + + service.groupedItems + .subscribe( + (groups) => (titles = groups.map((group) => group.items.map((item) => String(item.title)))) + ) + .unsubscribe(); + + return titles; + }; + + it('always orders groups and items from newest to oldest, regardless of input order', () => { + const service = getService(); + + // Two days × two times, provided deliberately scrambled. Midday UTC times keep each + // pair in the same day-group regardless of the test machine's timezone. + service.items = [ + createItem('1a', '2025-10-01T12:00:00.000Z'), + createItem('2b', '2025-10-02T15:00:00.000Z'), + createItem('1b', '2025-10-01T15:00:00.000Z'), + createItem('2a', '2025-10-02T12:00:00.000Z') + ]; + + // Newest day first; within each day the newest notification first. + expect(readTitles(service)).toEqual([ + ['2b', '2a'], + ['1b', '1a'] + ]); + }); + }); + + describe('onRead', () => { + const getService = () => + (componentInstance.trigger as unknown as { service: KbqNotificationCenterService }).service; + + const createItem = (title: string): KbqNotificationItem => ({ title, date: new Date().toISOString() }); + + const openCenter = () => { + componentInstance.trigger.show(); + fixture.detectChanges(); + }; + + // The rendered notification item hosts KbqReadStateDirective, whose (click) handler emits + // read=true on every click. onRead must still fire only on the unread -> read transition. + it('emits onRead only once per item across repeated read events', () => { + const service = getService(); + const item = createItem('a'); + + service.items = [item]; + + openCenter(); + + const itemElement = overlayContainer + .getContainerElement() + .querySelector('kbq-notification-item'); + + expect(itemElement).not.toBeNull(); + + const onReadSpy = jest.spyOn(service.onRead, 'next'); + + itemElement!.click(); + itemElement!.click(); + itemElement!.click(); + + expect(onReadSpy).toHaveBeenCalledTimes(1); + expect(onReadSpy).toHaveBeenCalledWith(item); + expect(item.read).toBe(true); + }); + }); }); }); diff --git a/packages/components/notification-center/notification-center.ts b/packages/components/notification-center/notification-center.ts index d6ea1aee9e..fdd8f118b1 100644 --- a/packages/components/notification-center/notification-center.ts +++ b/packages/components/notification-center/notification-center.ts @@ -42,15 +42,20 @@ import { KbqDividerModule } from '@koobiq/components/divider'; import { KbqDropdownModule } from '@koobiq/components/dropdown'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqLoaderOverlayModule } from '@koobiq/components/loader-overlay'; +import { KbqProgressSpinnerModule } from '@koobiq/components/progress-spinner'; import { KbqScrollbar, KbqScrollbarModule } from '@koobiq/components/scrollbar'; import { KbqToolTipModule } from '@koobiq/components/tooltip'; -import { Subscription, merge } from 'rxjs'; +import { Subject, Subscription, merge } from 'rxjs'; +import { auditTime, distinctUntilChanged, filter, map, pairwise } from 'rxjs/operators'; import { KbqNotificationCenterAnimations } from './notification-center-animations'; import { KbqNotificationCenterService } from './notification-center.service'; import { KbqNotificationItemComponent } from './notification-item'; const defaultOffsetX = 8; +/** Rate-limit window (ms) for the scroll-to-bottom check that drives infinite scroll. */ +const SCROLLED_TO_BOTTOM_AUDIT_TIME = 100; + /**default configuration of notification-center */ export const KBQ_NOTIFICATION_CENTER_DEFAULT_CONFIGURATION = ruRULocaleData.notificationCenter; @@ -87,7 +92,8 @@ export const KBQ_NOTIFICATION_CENTER_SCROLL_STRATEGY_FACTORY_PROVIDER = { KbqToolTipModule, AsyncPipe, KbqNotificationItemComponent, - KbqLoaderOverlayModule + KbqLoaderOverlayModule, + KbqProgressSpinnerModule ], templateUrl: './notification-center.html', styleUrls: ['./notification-center.scss'], @@ -125,6 +131,13 @@ export class KbqNotificationCenterComponent extends KbqPopUp implements AfterVie /** @docs-private */ protected isBottomOverflow: boolean = false; + /** Distance in pixels from the bottom of the list at which the next page is requested. + * @docs-private */ + protected scrolledToBottomOffset: number = 0; + + /** Emits on every scroll of the list container; drives the scroll-to-bottom check. */ + private readonly scroll$ = new Subject(); + /** localized data * @docs-private */ get localeData() { @@ -163,7 +176,7 @@ export class KbqNotificationCenterComponent extends KbqPopUp implements AfterVie } ngAfterViewInit() { - this.visibleChange.subscribe((state) => { + this.visibleChange.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((state) => { if (this.offset !== null && state) { applyPopupMargins( this.renderer, @@ -176,11 +189,88 @@ export class KbqNotificationCenterComponent extends KbqPopUp implements AfterVie this.setStickPosition(); }); - this.service.changes.subscribe(() => this.changeDetectorRef.markForCheck()); + this.service.changes + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.changeDetectorRef.markForCheck()); this.switcher.focus(); setTimeout(this.checkOverflow); + + this.subscribeToScrolledToBottom(); + } + + /** Handles the list container scroll: updates overflow shadows and feeds the scroll-to-bottom check. + * @docs-private */ + protected onContainerScroll(): void { + this.checkOverflow(); + this.scroll$.next(); + } + + /** Retries loading the next page from the bottom error row. + * @docs-private */ + protected retryLoadMore(): void { + // The retry button is about to unmount; keep keyboard focus inside the panel instead of + // letting it fall back to . + this.focusScrollContainer(); + + // Clear the bottom error state here so the spinner and the error row can never be shown at + // the same time, regardless of what the consumer's `onNextPage` handler does. + this.service.setLoadMoreErrorMode(false); + + this.service.onNextPage.emit(); + } + + /** + * Requests the next page (via `service.onNextPage`) once the list is scrolled to within + * `scrolledToBottomOffset` pixels of the bottom. Two triggers feed it: the user scrolling, and a + * page finishing loading. The latter keeps paging when a freshly loaded page is too short to + * overflow the viewport — otherwise no further scroll event would fire and pagination would + * stall. Suppressed while a load is in flight, errored, or when there is nothing more to load. + */ + private subscribeToScrolledToBottom(): void { + const scrolledToBottom$ = this.scroll$.pipe( + auditTime(SCROLLED_TO_BOTTOM_AUDIT_TIME), + map(() => this.isScrolledToBottom()), + distinctUntilChanged(), + filter(Boolean) + ); + + // Re-measure once a load completes (and the appended items have rendered): if the list still + // sits at the bottom, continue paging instead of waiting for a scroll event that never comes. + const loadCompleted$ = this.service.loadingMore.pipe( + distinctUntilChanged(), + pairwise(), + filter(([wasLoading, isLoading]) => wasLoading && !isLoading), + auditTime(SCROLLED_TO_BOTTOM_AUDIT_TIME), + filter(() => this.isScrolledToBottom()) + ); + + merge(scrolledToBottom$, loadCompleted$) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.requestNextPage()); + } + + /** Whether the list is scrolled to within `scrolledToBottomOffset` pixels of the bottom. */ + private isScrolledToBottom(): boolean { + const { scrollTop, clientHeight, scrollHeight } = this.scrollContainer.contentElement.nativeElement; + + return scrollHeight - scrollTop - clientHeight <= this.scrolledToBottomOffset; + } + + /** Emits `onNextPage` unless a load is already in flight, errored, or there is nothing more to load. */ + private requestNextPage(): void { + if (this.service.hasMore.value && !this.service.loadingMore.value && !this.service.loadMoreErrorMode.value) { + this.service.onNextPage.emit(); + } + } + + private focusScrollContainer(): void { + const element = this.scrollContainer.contentElement.nativeElement; + + // tabindex -1 keeps the container out of the Tab order while allowing programmatic focus. + element.setAttribute('tabindex', '-1'); + element.focus({ preventScroll: true }); } /** @docs-private */ @@ -271,6 +361,9 @@ export class KbqNotificationCenterTrigger /** Offset of popUp */ @Input({ transform: numberAttribute }) offset: number | null = defaultOffsetX; + /** Distance in pixels from the bottom of the list at which the next page is requested via `onNextPage`. */ + @Input({ transform: numberAttribute }) scrolledToBottomOffset: number = 0; + /** Use popover or not */ @Input({ transform: booleanAttribute }) get popoverMode(): boolean { @@ -375,7 +468,7 @@ export class KbqNotificationCenterTrigger } }); } else { - this.preventClosingByInnerScrollSubscription.unsubscribe(); + this.preventClosingByInnerScrollSubscription?.unsubscribe(); this.focus(); } }); @@ -392,6 +485,7 @@ export class KbqNotificationCenterTrigger this.instance.footer = this.footer; this.instance.popoverMode = this.popoverMode; this.instance.popoverHeight = this.popoverHeight; + this.instance.scrolledToBottomOffset = this.scrolledToBottomOffset; this.instance.updateTrapFocus(this.trigger !== PopUpTriggers.Focus); diff --git a/packages/components/notification-center/notification-item.html b/packages/components/notification-center/notification-item.html index 5ba3e96e91..5aae823b9f 100644 --- a/packages/components/notification-center/notification-item.html +++ b/packages/components/notification-center/notification-item.html @@ -103,6 +103,7 @@ + } + + + + +
+ Empty state +
+
No items
+
No objects have been added here yet
+
+ + diff --git a/packages/docs-examples/components/notification-center/notification-center-infinite-scroll/notification-center-infinite-scroll-example.ts b/packages/docs-examples/components/notification-center/notification-center-infinite-scroll/notification-center-infinite-scroll-example.ts new file mode 100644 index 0000000000..6340cd0141 --- /dev/null +++ b/packages/docs-examples/components/notification-center/notification-center-infinite-scroll/notification-center-infinite-scroll-example.ts @@ -0,0 +1,177 @@ +import { BreakpointObserver } from '@angular/cdk/layout'; +import { AsyncPipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter'; +import { KbqBadgeModule } from '@koobiq/components/badge'; +import { KbqButtonModule, KbqButtonStyles } from '@koobiq/components/button'; +import { KbqComponentColors, KbqFormattersModule, ThemeService } from '@koobiq/components/core'; +import { KbqDropdownModule } from '@koobiq/components/dropdown'; +import { KbqEmptyStateModule } from '@koobiq/components/empty-state'; +import { KbqIconModule } from '@koobiq/components/icon'; +import { KbqLink } from '@koobiq/components/link'; +import { KbqNavbarModule } from '@koobiq/components/navbar'; +import { + KbqNotificationCenterModule, + KbqNotificationCenterService, + KbqNotificationItem +} from '@koobiq/components/notification-center'; +import { KbqToastStyle } from '@koobiq/components/toast'; +import { KbqTopBarModule } from '@koobiq/components/top-bar'; +import { of, timer } from 'rxjs'; +import { map } from 'rxjs/operators'; + +/** Items per loaded page. */ +const PAGE_SIZE = 20; +/** Total number of pages the fake backend can serve. */ +const TOTAL_PAGES = 5; +/** Simulated network latency, ms. */ +const LOAD_DELAY = 800; +/** Fixed base date so generated notifications group by day deterministically. */ +const BASE_DATE = Date.parse('2025-10-08T12:00:00Z'); + +const STYLES = [KbqToastStyle.Success, KbqToastStyle.Warning, KbqToastStyle.Error, KbqToastStyle.Contrast]; + +type ExampleAction = { + id: string; + icon?: string; + text?: string; + action?: () => void; + style: KbqButtonStyles | string; + color: KbqComponentColors; +}; + +enum NavbarIcItems { + Assets, + Issues, + Incidents, + Policies, + Security +} + +/** + * @title notification-center-infinite-scroll + */ +@Component({ + selector: 'notification-center-infinite-scroll-example', + imports: [ + KbqNotificationCenterModule, + KbqButtonModule, + KbqIconModule, + KbqBadgeModule, + AsyncPipe, + LuxonDateModule, + KbqFormattersModule, + KbqDropdownModule, + KbqEmptyStateModule, + KbqLink, + KbqNavbarModule, + KbqTopBarModule + ], + templateUrl: 'notification-center-infinite-scroll-example.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [{ provide: KbqNotificationCenterService, useClass: KbqNotificationCenterService }] +}) +export class NotificationCenterInfiniteScrollExample { + protected readonly notificationService = inject(KbqNotificationCenterService); + + private readonly destroyRef = inject(DestroyRef); + + /** Index of the last loaded page. */ + private currentPage = 0; + /** Used to demonstrate the bottom error state once, then succeed on retry. */ + private hasFailedOnce = false; + + protected readonly actions: ExampleAction[] = [ + { + id: '1', + color: KbqComponentColors.Contrast, + style: KbqButtonStyles.Filled, + text: 'Primary Action' + }, + { + id: '2', + color: KbqComponentColors.Contrast, + style: KbqButtonStyles.Transparent, + icon: 'kbq-ellipsis-horizontal_16' + } + ]; + + protected items = NavbarIcItems; + protected selected: NavbarIcItems = NavbarIcItems.Assets; + + constructor() { + // Initial page loads immediately; subsequent pages are appended on scroll. + this.appendPage(this.currentPage + 1); + + this.notificationService.onNextPage + .pipe(takeUntilDestroyed()) + .subscribe(() => this.appendPage(this.currentPage + 1)); + } + + private appendPage(page: number): void { + if (this.notificationService.loadingMore.value || !this.notificationService.hasMore.value) { + return; + } + + this.notificationService.setLoadMoreErrorMode(false); + this.notificationService.setLoadingMore(true); + + // Replace with a real paginated request in production. + timer(LOAD_DELAY) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + // Fail once on page 3 to showcase the bottom "load more" error + retry. + if (page === 3 && !this.hasFailedOnce) { + this.hasFailedOnce = true; + + this.notificationService.setLoadingMore(false); + this.notificationService.setLoadMoreErrorMode(true); + + return; + } + + this.currentPage = page; + this.notificationService.items = [...this.notificationService.items, ...this.createPage(page)]; + this.notificationService.setHasMore(page < TOTAL_PAGES); + this.notificationService.setLoadingMore(false); + }); + } + + private createPage(page: number): KbqNotificationItem[] { + return Array.from({ length: PAGE_SIZE }, (_, index) => { + const globalIndex = (page - 1) * PAGE_SIZE + index; + + return { + title: `Notification #${globalIndex + 1}`, + caption: `Loaded with page ${page}`, + icon: true, + style: STYLES[globalIndex % STYLES.length], + date: new Date(BASE_DATE - globalIndex * 3600_000).toISOString() + }; + }); + } + + protected readonly srcSet = computed(() => { + const currentTheme = this.currentTheme(); + + return `https://koobiq.io/assets/images/${currentTheme}/empty_192.png 1x, assets/images/${currentTheme}/empty_192@2x.png 2x`; + }); + + protected readonly currentTheme = toSignal( + inject(ThemeService, { optional: true })?.current.pipe( + map((theme) => theme && theme.className.replace('kbq-', '')) + ) || of('light'), + { initialValue: 'light' } + ); + + readonly isDesktop = toSignal( + inject(BreakpointObserver) + .observe('(min-width: 900px)') + .pipe( + takeUntilDestroyed(), + map(({ matches }) => matches) + ), + { initialValue: true } + ); +} diff --git a/packages/docs-examples/components/notification-center/notification-center-overview/notification-center-overview-example.html b/packages/docs-examples/components/notification-center/notification-center-overview/notification-center-overview-example.html index 71cbd00c50..e99840add3 100644 --- a/packages/docs-examples/components/notification-center/notification-center-overview/notification-center-overview-example.html +++ b/packages/docs-examples/components/notification-center/notification-center-overview/notification-center-overview-example.html @@ -27,7 +27,7 @@ - + @@ -45,7 +45,7 @@ [class.kbq-active]="selected === items.Assets" (click)="selected = items.Assets" > - +
Assets
@@ -54,7 +54,7 @@ [class.kbq-active]="selected === items.Issues" [kbqDropdownTriggerFor]="issuesDropdown" > - +
Issues
@@ -64,7 +64,7 @@ [class.kbq-active]="selected === items.Incidents" (click)="selected = items.Incidents" > - +
Incidents
@@ -74,7 +74,7 @@ [class.kbq-active]="selected === items.Policies" (click)="selected = items.Policies" > - +
Policies
@@ -83,7 +83,7 @@ [class.kbq-active]="selected === items.Security" [kbqDropdownTriggerFor]="securityDropdown" > - +
Security
@@ -96,20 +96,28 @@ [popoverHeight]="'500px'" [kbqNotificationCenterPanelClass]="'example-notification-center-panel'" > - +
Notifications
@if (trigger.unreadItemsCounter | async; as unreadItems) { - {{ unreadItems }} + + {{ unreadItems }} + } - +
System Monitor
- + Administrator diff --git a/packages/docs-examples/components/notification-center/notification-center-popover/notification-center-popover-example.html b/packages/docs-examples/components/notification-center/notification-center-popover/notification-center-popover-example.html index 06caa3897d..cb8e3fb23a 100644 --- a/packages/docs-examples/components/notification-center/notification-center-popover/notification-center-popover-example.html +++ b/packages/docs-examples/components/notification-center/notification-center-popover/notification-center-popover-example.html @@ -6,7 +6,7 @@ - + @@ -18,7 +18,7 @@ @@ -37,18 +37,26 @@ [popoverMode]="true" [popoverHeight]="'430px'" > - + @if (trigger.unreadItemsCounter | async; as unreadItems) { - {{ unreadItems }} + + {{ unreadItems }} + } - + - + diff --git a/packages/docs-examples/components/notification-center/notification-center-push/notification-center-push-example.html b/packages/docs-examples/components/notification-center/notification-center-push/notification-center-push-example.html index 0a1d809252..4ee90cc58b 100644 --- a/packages/docs-examples/components/notification-center/notification-center-push/notification-center-push-example.html +++ b/packages/docs-examples/components/notification-center/notification-center-push/notification-center-push-example.html @@ -27,7 +27,7 @@ - + @@ -45,7 +45,7 @@ [class.kbq-active]="selected === items.Assets" (click)="selected = items.Assets" > - +
Assets
@@ -54,7 +54,7 @@ [class.kbq-active]="selected === items.Issues" [kbqDropdownTriggerFor]="issuesDropdown" > - +
Issues
@@ -64,7 +64,7 @@ [class.kbq-active]="selected === items.Incidents" (click)="selected = items.Incidents" > - +
Incidents
@@ -74,7 +74,7 @@ [class.kbq-active]="selected === items.Policies" (click)="selected = items.Policies" > - +
Policies
@@ -83,7 +83,7 @@ [class.kbq-active]="selected === items.Security" [kbqDropdownTriggerFor]="securityDropdown" > - +
Security
@@ -96,20 +96,28 @@ [popoverHeight]="'500px'" [kbqNotificationCenterPanelClass]="'example-notification-center-panel'" > - +
Notifications
@if (trigger.unreadItemsCounter | async; as unreadItems) { - {{ unreadItems }} + + {{ unreadItems }} + } - +
System Monitor
- + Administrator diff --git a/packages/docs-examples/example-module.ts b/packages/docs-examples/example-module.ts index 5e70572ba2..59d755eb49 100644 --- a/packages/docs-examples/example-module.ts +++ b/packages/docs-examples/example-module.ts @@ -3544,6 +3544,19 @@ export const EXAMPLE_COMPONENTS: {[id: string]: LiveExample} = { "primaryFile": "notification-center-error-example.ts", "importPath": "components/notification-center" }, + "notification-center-infinite-scroll": { + "packagePath": "components/notification-center/notification-center-infinite-scroll", + "title": "notification-center-infinite-scroll", + "componentName": "NotificationCenterInfiniteScrollExample", + "files": [ + "notification-center-infinite-scroll-example.ts", + "notification-center-infinite-scroll-example.html" + ], + "selector": "notification-center-infinite-scroll-example", + "additionalComponents": [], + "primaryFile": "notification-center-infinite-scroll-example.ts", + "importPath": "components/notification-center" + }, "notification-center-overview": { "packagePath": "components/notification-center/notification-center-overview", "title": "notification-center", @@ -6970,6 +6983,8 @@ return import('@koobiq/docs-examples/components/navbar'); case 'notification-center-empty': return import('@koobiq/docs-examples/components/notification-center'); case 'notification-center-error': +return import('@koobiq/docs-examples/components/notification-center'); + case 'notification-center-infinite-scroll': return import('@koobiq/docs-examples/components/notification-center'); case 'notification-center-overview': return import('@koobiq/docs-examples/components/notification-center'); diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 1f3956934e..56f0152394 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -458,6 +458,7 @@ export const enUSLocaleData: { noNotifications: string; failedToLoadNotifications: string; repeat: string; + loadingMore: string; }; }; @@ -689,6 +690,7 @@ export const esLALocaleData: { noNotifications: string; failedToLoadNotifications: string; repeat: string; + loadingMore: string; }; }; @@ -1008,6 +1010,7 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): { noNotifications: string; failedToLoadNotifications: string; repeat: string; + loadingMore: string; }; }; 'es-LA': { @@ -1220,6 +1223,7 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): { noNotifications: string; failedToLoadNotifications: string; repeat: string; + loadingMore: string; }; }; 'pt-BR': { @@ -1428,6 +1432,7 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): { noNotifications: string; failedToLoadNotifications: string; repeat: string; + loadingMore: string; }; }; 'ru-RU': { @@ -1642,6 +1647,7 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): { noNotifications: string; failedToLoadNotifications: string; repeat: string; + loadingMore: string; }; }; 'tk-TM': { @@ -1852,6 +1858,7 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): { noNotifications: string; failedToLoadNotifications: string; repeat: string; + loadingMore: string; }; }; }; @@ -3449,6 +3456,7 @@ export const ptBRLocaleData: { noNotifications: string; failedToLoadNotifications: string; repeat: string; + loadingMore: string; }; }; @@ -3861,6 +3869,7 @@ export const ruRULocaleData: { noNotifications: string; failedToLoadNotifications: string; repeat: string; + loadingMore: string; }; }; @@ -4177,6 +4186,7 @@ export const tkTMLocaleData: { noNotifications: string; failedToLoadNotifications: string; repeat: string; + loadingMore: string; }; }; diff --git a/tools/public_api_guard/components/notification-center.api.md b/tools/public_api_guard/components/notification-center.api.md index e254e7abef..33f6a32773 100644 --- a/tools/public_api_guard/components/notification-center.api.md +++ b/tools/public_api_guard/components/notification-center.api.md @@ -43,6 +43,7 @@ export const KBQ_NOTIFICATION_CENTER_DEFAULT_CONFIGURATION: { noNotifications: string; failedToLoadNotifications: string; repeat: string; + loadingMore: string; }; // @public @@ -79,11 +80,14 @@ export class KbqNotificationCenterComponent extends KbqPopUp implements AfterVie protected readonly localeService: KbqLocaleService | null; // (undocumented) ngAfterViewInit(): void; + protected onContainerScroll(): void; // (undocumented) get popoverHeight(): string; set popoverHeight(value: string); protected popoverMode: boolean; prefix: string; + protected retryLoadMore(): void; + protected scrolledToBottomOffset: number; protected readonly service: KbqNotificationCenterService; // (undocumented) switcher: KbqButton; @@ -118,10 +122,16 @@ export class KbqNotificationCenterService { readonly errorMode: BehaviorSubject; // Warning: (ae-forgotten-export) The symbol "KbqNotificationsGroup" needs to be exported by the entry point index.d.ts readonly groupedItems: Observable; + readonly hasMore: BehaviorSubject; + hideToast(item: KbqNotificationItem): void; get isEmpty(): boolean; get items(): KbqNotificationItem[]; set items(values: KbqNotificationItem[]); readonly loadingMode: BehaviorSubject; + readonly loadingMore: BehaviorSubject; + readonly loadMoreErrorMode: BehaviorSubject; + readonly onDelete: EventEmitter; + readonly onNextPage: EventEmitter; readonly onRead: BehaviorSubject; readonly onReload: EventEmitter; push(item: KbqNotificationItem): void; @@ -129,7 +139,10 @@ export class KbqNotificationCenterService { removeAll(): void; removeGroup(group: KbqNotificationsGroup): void; setErrorMode(value: boolean): void; + setHasMore(value: boolean): void; setLoadingMode(value: boolean): void; + setLoadingMore(value: boolean): void; + setLoadMoreErrorMode(value: boolean): void; setSilentMode(value: boolean): void; readonly silentMode: BehaviorSubject; get unreadItemsCounter(): Observable; @@ -161,6 +174,8 @@ export class KbqNotificationCenterTrigger extends KbqPopUpTrigger ScrollStrategy; protected readonly service: KbqNotificationCenterService; stickToWindow: KbqStickToWindowPlacementValues; @@ -183,11 +199,17 @@ export class KbqNotificationCenterTrigger extends KbqPopUpTrigger; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export type KbqNotificationDeleteEvent = { + type: 'item' | 'group' | 'all'; + items: KbqNotificationItem[]; +}; + // @public (undocumented) export interface KbqNotificationItem extends Omit { // (undocumented) @@ -210,6 +232,7 @@ export interface KbqNotificationItem extends Omit { style?: string | KbqToastStyle; // (undocumented) title?: string | TemplateRef; + toastId?: number; } // @public (undocumented)