From b4b2859f0d34a477343f131ba99a4d88be2a1ec3 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Thu, 13 Aug 2026 09:29:16 +0300 Subject: [PATCH 1/3] fix(components): scroll strategy tokens throw NullInjectorError outside their NgModule (#DS-3522) --- .../services/i18n.characterization.spec.ts | 3 +- apps/docs/src/assets/stackblitz/src/main.ts | 4 +- .../autocomplete-trigger.directive.ts | 12 ++- .../autocomplete/autocomplete.module.ts | 6 +- .../button-toggle/button-toggle.module.ts | 9 +-- packages/components/core/select/constants.ts | 16 +++- .../datepicker/calendar-header.component.ts | 15 +--- .../datepicker/calendar-header.spec.ts | 12 +++ .../datepicker/calendar.component.ts | 9 +-- .../datepicker/datepicker-errors.ts | 14 ++++ .../datepicker/datepicker-in-modal.spec.ts | 79 +++++++++++++++++++ .../datepicker/datepicker-input.directive.ts | 9 +-- .../datepicker/datepicker-module.ts | 11 +-- .../datepicker/datepicker.component.ts | 29 +++---- .../datepicker/month-view.component.ts | 9 +-- .../dropdown/dropdown-trigger.directive.ts | 10 ++- .../components/dropdown/dropdown.module.ts | 3 +- .../components/modal/examples.modal.en.md | 8 +- .../components/modal/examples.modal.ru.md | 7 +- .../notification-center.module.ts | 7 +- .../notification-center.ts | 6 +- .../components/popover/popover.component.ts | 5 +- packages/components/popover/popover.module.ts | 7 +- .../select/select.component.spec.ts | 52 +++++++++++- packages/components/select/select.module.ts | 2 - .../time-range/time-range.service.ts | 10 ++- .../timezone-select.component.spec.ts | 31 +++++++- .../components/title/title.directive.spec.ts | 3 +- .../components/tooltip/tooltip.component.ts | 5 +- packages/components/tooltip/tooltip.module.ts | 12 +-- .../tree-select/tree-select.module.ts | 2 - 31 files changed, 285 insertions(+), 122 deletions(-) create mode 100644 packages/components/datepicker/datepicker-in-modal.spec.ts diff --git a/apps/docs/src/app/services/i18n.characterization.spec.ts b/apps/docs/src/app/services/i18n.characterization.spec.ts index cc8ee0db0b..38105c817d 100644 --- a/apps/docs/src/app/services/i18n.characterization.spec.ts +++ b/apps/docs/src/app/services/i18n.characterization.spec.ts @@ -3,7 +3,6 @@ import { Type } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { KbqToastService } from '@koobiq/components/toast'; -import { KBQ_TOOLTIP_SCROLL_STRATEGY_FACTORY_PROVIDER } from '@koobiq/components/tooltip'; import { BehaviorSubject, map } from 'rxjs'; import { DocsCopyButtonComponent } from '../components/copy-button/copy-button'; import { DocsTokensTable } from '../components/design-tokens-viewers/tokens-overview'; @@ -86,7 +85,7 @@ describe('docs i18n strings (characterization)', () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ imports: [DocsTokensTable], - providers: [provideDocsLocale(locale), provideRouter([]), KBQ_TOOLTIP_SCROLL_STRATEGY_FACTORY_PROVIDER] + providers: [provideDocsLocale(locale), provideRouter([])] }); const fixture = TestBed.createComponent(DocsTokensTable); diff --git a/apps/docs/src/assets/stackblitz/src/main.ts b/apps/docs/src/assets/stackblitz/src/main.ts index 0d5d8c84e0..0dc47bb5d5 100644 --- a/apps/docs/src/assets/stackblitz/src/main.ts +++ b/apps/docs/src/assets/stackblitz/src/main.ts @@ -5,7 +5,6 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { provideAnimations } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; import { KBQ_LOCALE_SERVICE, KbqLocaleService } from '@koobiq/components/core'; -import { KBQ_TOOLTIP_SCROLL_STRATEGY_FACTORY_PROVIDER } from '@koobiq/components/tooltip'; import { KoobiqDocsExample } from './example/koobiq-docs-example'; bootstrapApplication(KoobiqDocsExample, { @@ -13,7 +12,6 @@ bootstrapApplication(KoobiqDocsExample, { provideAnimations(), provideHttpClient(), provideRouter([]), - { provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService }, - KBQ_TOOLTIP_SCROLL_STRATEGY_FACTORY_PROVIDER + { provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService } ] }).catch((err) => console.error(err)); diff --git a/packages/components/autocomplete/autocomplete-trigger.directive.ts b/packages/components/autocomplete/autocomplete-trigger.directive.ts index cdd6b509b1..6692b71ba9 100644 --- a/packages/components/autocomplete/autocomplete-trigger.directive.ts +++ b/packages/components/autocomplete/autocomplete-trigger.directive.ts @@ -65,9 +65,17 @@ import { KbqAutocomplete } from './autocomplete.component'; /** The total height of the autocomplete panel. */ export const AUTOCOMPLETE_PANEL_HEIGHT = 256; -/** Injection token that determines the scroll handling while the autocomplete panel is open. */ +/** + * Injection token that determines the scroll handling while the autocomplete panel is open. The root default + * keeps the trigger usable outside `KbqAutocompleteModule`'s injector; providing the token anywhere still wins + * over it. + */ export const KBQ_AUTOCOMPLETE_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>( - 'kbq-autocomplete-scroll-strategy' + 'kbq-autocomplete-scroll-strategy', + { + providedIn: 'root', + factory: () => KBQ_AUTOCOMPLETE_SCROLL_STRATEGY_FACTORY(inject(Overlay)) + } ); export function KBQ_AUTOCOMPLETE_SCROLL_STRATEGY_FACTORY(overlay: Overlay): () => ScrollStrategy { diff --git a/packages/components/autocomplete/autocomplete.module.ts b/packages/components/autocomplete/autocomplete.module.ts index c0e2da11b4..6ae999e452 100644 --- a/packages/components/autocomplete/autocomplete.module.ts +++ b/packages/components/autocomplete/autocomplete.module.ts @@ -2,10 +2,7 @@ import { OverlayModule } from '@angular/cdk/overlay'; import { NgModule } from '@angular/core'; import { KbqOptionModule } from '@koobiq/components/core'; import { KbqAutocompleteOrigin } from './autocomplete-origin.directive'; -import { - KBQ_AUTOCOMPLETE_SCROLL_STRATEGY_FACTORY_PROVIDER, - KbqAutocompleteTrigger -} from './autocomplete-trigger.directive'; +import { KbqAutocompleteTrigger } from './autocomplete-trigger.directive'; import { KbqAutocomplete, KbqAutocompleteFooter } from './autocomplete.component'; @NgModule({ @@ -17,7 +14,6 @@ import { KbqAutocomplete, KbqAutocompleteFooter } from './autocomplete.component KbqAutocompleteOrigin, KbqAutocompleteFooter ], - providers: [KBQ_AUTOCOMPLETE_SCROLL_STRATEGY_FACTORY_PROVIDER], exports: [ KbqAutocomplete, KbqOptionModule, diff --git a/packages/components/button-toggle/button-toggle.module.ts b/packages/components/button-toggle/button-toggle.module.ts index 57ded33c90..8a93a5e94e 100644 --- a/packages/components/button-toggle/button-toggle.module.ts +++ b/packages/components/button-toggle/button-toggle.module.ts @@ -4,12 +4,9 @@ import { KbqTitleModule } from '@koobiq/components/title'; import { KbqButtonToggle, KbqButtonToggleGroup } from './button-toggle.component'; /** - * Entry point for `KbqButtonToggleGroup`/`KbqButtonToggle`. - * - * Both are standalone, but importing them directly is not enough: a toggle renders `kbq-title` in its - * own template, and the tooltip behind it resolves `KBQ_TOOLTIP_SCROLL_STRATEGY` and - * `KBQ_TOOLTIP_OPEN_TIME` from the module injector, where only `KbqTitleModule` puts them. Import - * this module — a bare `imports: [KbqButtonToggle]` throws NG0201 the moment a label is truncated. + * Entry point for `KbqButtonToggleGroup`/`KbqButtonToggle`. Both are standalone, so a bare + * `imports: [KbqButtonToggle]` works; this module is the convenience bundle that also pulls in + * `KbqButtonModule`/`KbqTitleModule` and re-exports the slot markers. */ @NgModule({ imports: [KbqButtonModule, KbqTitleModule, KbqButtonToggleGroup, KbqButtonToggle], diff --git a/packages/components/core/select/constants.ts b/packages/components/core/select/constants.ts index 3012cb2fab..6c7f0a6f6a 100644 --- a/packages/components/core/select/constants.ts +++ b/packages/components/core/select/constants.ts @@ -1,5 +1,5 @@ import { Overlay, RepositionScrollStrategy, ScrollStrategy } from '@angular/cdk/overlay'; -import { InjectionToken } from '@angular/core'; +import { inject, InjectionToken } from '@angular/core'; /** * Minimum option count threshold for displaying select search. @@ -13,8 +13,18 @@ export const KBQ_SELECT_SEARCH_MIN_OPTIONS_THRESHOLD = 10; */ export const SELECT_PANEL_VIEWPORT_PADDING = 8; -/** Injection token that determines the scroll handling while a select is open. */ -export const KBQ_SELECT_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>('kbq-select-scroll-strategy'); +/** + * Injection token that determines the scroll handling while a select is open. + * + * The root default keeps the select usable when it is reached outside `KbqSelectModule`'s injector - imported + * as a bare standalone component, pulled in through another standalone component (`KbqTimezoneSelect`, + * `KbqCalendarHeader`), or rendered in a component built from the root injector, as `KbqModalService` does. + * Providing the token anywhere still wins over this default. + */ +export const KBQ_SELECT_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>('kbq-select-scroll-strategy', { + providedIn: 'root', + factory: () => kbqSelectScrollStrategyProviderFactory(inject(Overlay)) +}); /** @docs-private */ export function kbqSelectScrollStrategyProviderFactory(overlay: Overlay): () => RepositionScrollStrategy { diff --git a/packages/components/datepicker/calendar-header.component.ts b/packages/components/datepicker/calendar-header.component.ts index 6cd3a933eb..d65efb2a80 100644 --- a/packages/components/datepicker/calendar-header.component.ts +++ b/packages/components/datepicker/calendar-header.component.ts @@ -1,17 +1,10 @@ import { TitleCasePipe } from '@angular/common'; -import { - AfterContentInit, - ChangeDetectionStrategy, - Component, - inject, - Input, - output, - ViewEncapsulation -} from '@angular/core'; +import { AfterContentInit, ChangeDetectionStrategy, Component, Input, output, ViewEncapsulation } from '@angular/core'; import { KbqButtonModule } from '@koobiq/components/button'; -import { DateAdapter, kbqInjectA11yLocaleConfiguration, KbqOptionModule } from '@koobiq/components/core'; +import { kbqInjectA11yLocaleConfiguration, KbqOptionModule } from '@koobiq/components/core'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqSelectModule } from '@koobiq/components/select'; +import { injectRequiredDateAdapter } from './datepicker-errors'; const defaultMinYear = 1900; const defaultMaxYear = 2099; @@ -43,7 +36,7 @@ export type MonthName = { exportAs: 'kbqCalendarHeader' }) export class KbqCalendarHeader implements AfterContentInit { - private readonly adapter = inject>(DateAdapter); + private readonly adapter = injectRequiredDateAdapter(); /** Accessible names for the icon-only month navigation buttons. */ protected readonly a11yLocaleConfiguration = kbqInjectA11yLocaleConfiguration(); diff --git a/packages/components/datepicker/calendar-header.spec.ts b/packages/components/datepicker/calendar-header.spec.ts index 707bf3b8e6..9cb9aa7506 100644 --- a/packages/components/datepicker/calendar-header.spec.ts +++ b/packages/components/datepicker/calendar-header.spec.ts @@ -4,6 +4,7 @@ import { By } from '@angular/platform-browser'; import { KbqLuxonDateModule, LuxonDateAdapter } from '@koobiq/angular-luxon-adapter/adapter'; import { DateAdapter } from '@koobiq/components/core'; import { DateTime } from 'luxon'; +import { KbqCalendarHeader } from './calendar-header.component'; import { KbqCalendar } from './calendar.component'; import { KbqDatepickerIntl } from './datepicker-intl'; import { KbqDatepickerModule } from './datepicker-module'; @@ -67,6 +68,17 @@ describe('KbqCalendarHeader', () => { expect(adapter.format(calendarInstance.activeDate, 'yyyyMMdd')).toEqual('20170128'); }); }); + + describe('without a DateAdapter', () => { + it('should say which provider is missing', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ imports: [KbqCalendarHeader] }); + + expect(() => TestBed.createComponent(KbqCalendarHeader)).toThrow( + /KbqDatepicker: No provider found for DateAdapter/ + ); + }); + }); }); @Component({ diff --git a/packages/components/datepicker/calendar.component.ts b/packages/components/datepicker/calendar.component.ts index 589ece41d6..dc18a34b40 100644 --- a/packages/components/datepicker/calendar.component.ts +++ b/packages/components/datepicker/calendar.component.ts @@ -13,11 +13,10 @@ import { output, viewChild } from '@angular/core'; -import { DateAdapter } from '@koobiq/components/core'; import { Subject, Subscription } from 'rxjs'; import { KbqCalendarCellCssClasses } from './calendar-body.component'; import { KbqCalendarHeader } from './calendar-header.component'; -import { createMissingDateImplError } from './datepicker-errors'; +import { injectRequiredDateAdapter } from './datepicker-errors'; import { KbqDatepickerIntl } from './datepicker-intl'; import { KbqMonthView } from './month-view.component'; @@ -41,7 +40,7 @@ import { KbqMonthView } from './month-view.component'; exportAs: 'kbqCalendar' }) export class KbqCalendar implements AfterContentInit, OnDestroy, OnChanges { - private readonly adapter = inject>(DateAdapter, { optional: true })!; + private readonly adapter = injectRequiredDateAdapter(); private changeDetectorRef = inject(ChangeDetectorRef); /** A date representing the period (month or year) to start the calendar in. */ @@ -167,10 +166,6 @@ export class KbqCalendar implements AfterContentInit, OnDestroy, OnChanges { const intl = inject(KbqDatepickerIntl); const changeDetectorRef = this.changeDetectorRef; - if (!this.adapter) { - throw createMissingDateImplError('DateAdapter'); - } - this.intlChanges = intl.changes.subscribe(() => { changeDetectorRef.markForCheck(); this.stateChanges.next(); diff --git a/packages/components/datepicker/datepicker-errors.ts b/packages/components/datepicker/datepicker-errors.ts index 61b8b20a9d..2ca490f386 100644 --- a/packages/components/datepicker/datepicker-errors.ts +++ b/packages/components/datepicker/datepicker-errors.ts @@ -1,3 +1,6 @@ +import { inject } from '@angular/core'; +import { DateAdapter } from '@koobiq/components/core'; + /** @docs-private */ export function createMissingDateImplError(provider: string) { return Error( @@ -5,3 +8,14 @@ export function createMissingDateImplError(provider: string) { `modules at your application root or provide a custom implementation or use exists ones.` ); } + +/** Injects `DateAdapter`, naming the missing provider instead of letting DI throw a bare `NullInjectorError`. */ +export function injectRequiredDateAdapter(): DateAdapter { + const adapter = inject>(DateAdapter, { optional: true }); + + if (!adapter) { + throw createMissingDateImplError('DateAdapter'); + } + + return adapter; +} diff --git a/packages/components/datepicker/datepicker-in-modal.spec.ts b/packages/components/datepicker/datepicker-in-modal.spec.ts new file mode 100644 index 0000000000..00250c8d30 --- /dev/null +++ b/packages/components/datepicker/datepicker-in-modal.spec.ts @@ -0,0 +1,79 @@ +import { OverlayContainer } from '@angular/cdk/overlay'; +import { Component, inject } from '@angular/core'; +import { fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { KBQ_LUXON_DATE_FORMATS, KbqLuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter'; +import { KBQ_DATE_FORMATS } from '@koobiq/components/core'; +import { KbqFormFieldModule } from '@koobiq/components/form-field'; +import { KbqModalModule, KbqModalService } from '@koobiq/components/modal'; +import { KbqDatepickerModule } from './index'; + +/** Reproduces the reported scenario: a datepicker living inside a `KbqModalService`-created component. */ +@Component({ + selector: 'modal-content-with-datepicker', + imports: [FormsModule, KbqDatepickerModule, KbqFormFieldModule], + template: ` + + + + + + ` +}) +class ModalContentWithDatepicker {} + +@Component({ + selector: 'modal-host', + imports: [KbqModalModule], + template: '' +}) +class ModalHost { + readonly modalService = inject(KbqModalService); + + open() { + return this.modalService.open({ kbqComponent: ModalContentWithDatepicker }); + } +} + +describe('datepicker inside a modal', () => { + let overlayContainer: OverlayContainer; + + beforeEach(() => { + TestBed.configureTestingModule({ + // Only the app-level date wiring is registered here. `KbqDatepickerModule` deliberately stays a + // standalone import of the modal content component, which is where the reported app had it. + imports: [KbqLuxonDateModule, NoopAnimationsModule, ModalHost], + providers: [{ provide: KBQ_DATE_FORMATS, useValue: KBQ_LUXON_DATE_FORMATS }] + }); + + overlayContainer = TestBed.inject(OverlayContainer); + }); + + afterEach(() => overlayContainer.ngOnDestroy()); + + it('should open the calendar', fakeAsync(() => { + const fixture = TestBed.createComponent(ModalHost); + + fixture.detectChanges(); + + expect(() => { + fixture.componentInstance.open(); + fixture.detectChanges(); + flush(); + }).not.toThrow(); + + const toggle = overlayContainer.getContainerElement().querySelector('kbq-datepicker-toggle-icon'); + + expect(toggle).not.toBeNull(); + + expect(() => { + toggle!.click(); + fixture.detectChanges(); + tick(500); + flush(); + }).not.toThrow(); + + expect(overlayContainer.getContainerElement().querySelector('kbq-datepicker__content')).not.toBeNull(); + })); +}); diff --git a/packages/components/datepicker/datepicker-input.directive.ts b/packages/components/datepicker/datepicker-input.directive.ts index 4c30c76925..1fc96329ce 100644 --- a/packages/components/datepicker/datepicker-input.directive.ts +++ b/packages/components/datepicker/datepicker-input.directive.ts @@ -29,7 +29,6 @@ import { } from '@angular/forms'; import { BACKSPACE, - DateAdapter, DELETE, DOWN_ARROW, END, @@ -59,7 +58,7 @@ import { KBQ_FORM_FIELD, KbqFormFieldControl } from '@koobiq/components/form-fie import type { KbqTooltipTrigger } from '@koobiq/components/tooltip'; import { Subject, Subscription } from 'rxjs'; import { KbqCalendar } from './calendar.component'; -import { createMissingDateImplError } from './datepicker-errors'; +import { injectRequiredDateAdapter } from './datepicker-errors'; import { KbqDatepicker } from './datepicker.component'; enum DateParts { @@ -248,7 +247,7 @@ export class KbqDatepickerInput { elementRef = inject>(ElementRef); private readonly renderer = inject(Renderer2); - readonly adapter = inject>(DateAdapter, { optional: true })!; + readonly adapter = injectRequiredDateAdapter(); private readonly dateFormats = inject(KBQ_DATE_FORMATS, { optional: true }); /** @docs-private */ protected readonly formField = inject(KBQ_FORM_FIELD, { optional: true, host: true }); @@ -565,10 +564,6 @@ export class KbqDatepickerInput this.filterValidator ]); - if (!this.adapter) { - throw createMissingDateImplError('DateAdapter'); - } - this.errorStateTracker = new KbqErrorStateTracker( inject(ErrorStateMatcher), // update ngControl later, so it will be initialized diff --git a/packages/components/datepicker/datepicker-module.ts b/packages/components/datepicker/datepicker-module.ts index 3b719e79ef..146955ef18 100644 --- a/packages/components/datepicker/datepicker-module.ts +++ b/packages/components/datepicker/datepicker-module.ts @@ -13,11 +13,7 @@ import { KbqCalendar } from './calendar.component'; import { KbqDatepickerInput } from './datepicker-input.directive'; import { KbqDatepickerIntl } from './datepicker-intl'; import { KbqDatepickerToggleIcon, KbqDatepickerToggleIconComponent } from './datepicker-toggle.component'; -import { - KBQ_DATEPICKER_SCROLL_STRATEGY_FACTORY_PROVIDER, - KbqDatepicker, - KbqDatepickerContent -} from './datepicker.component'; +import { KbqDatepicker, KbqDatepickerContent } from './datepicker.component'; import { KbqMonthView } from './month-view.component'; @NgModule({ @@ -39,10 +35,7 @@ import { KbqMonthView } from './month-view.component'; KbqMonthView, KbqCalendarHeader ], - providers: [ - KbqDatepickerIntl, - KBQ_DATEPICKER_SCROLL_STRATEGY_FACTORY_PROVIDER - ], + providers: [KbqDatepickerIntl], exports: [ KbqCalendar, KbqCalendarBody, diff --git a/packages/components/datepicker/datepicker.component.ts b/packages/components/datepicker/datepicker.component.ts index 7c4665340a..12a9619cab 100644 --- a/packages/components/datepicker/datepicker.component.ts +++ b/packages/components/datepicker/datepicker.component.ts @@ -21,26 +21,29 @@ import { ViewContainerRef, ViewEncapsulation } from '@angular/core'; -import { - DateAdapter, - KBQ_CONNECTED_OVERLAY_ABOVE_CLASS, - KBQ_CONNECTED_OVERLAY_BELOW_CLASS -} from '@koobiq/components/core'; +import { KBQ_CONNECTED_OVERLAY_ABOVE_CLASS, KBQ_CONNECTED_OVERLAY_BELOW_CLASS } from '@koobiq/components/core'; import { KbqFormFieldControl } from '@koobiq/components/form-field'; import { merge, Subject, Subscription } from 'rxjs'; import { take } from 'rxjs/operators'; import { KbqCalendarCellCssClasses } from './calendar-body.component'; import { KbqCalendar } from './calendar.component'; import { kbqDatepickerAnimations } from './datepicker-animations'; -import { createMissingDateImplError } from './datepicker-errors'; +import { injectRequiredDateAdapter } from './datepicker-errors'; import { KbqDatepickerInput } from './datepicker-input.directive'; /** Used to generate a unique ID for each datepicker instance. */ let datepickerUid = 0; -/** Injection token that determines the scroll handling while the calendar is open. */ +/** + * Injection token that determines the scroll handling while the calendar is open. The root default keeps the + * datepicker usable outside `KbqDatepickerModule`'s injector; providing the token anywhere still wins over it. + */ export const KBQ_DATEPICKER_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>( - 'kbq-datepicker-scroll-strategy' + 'kbq-datepicker-scroll-strategy', + { + providedIn: 'root', + factory: () => KBQ_DATEPICKER_SCROLL_STRATEGY_FACTORY(inject(Overlay)) + } ); /** @docs-private */ @@ -134,7 +137,7 @@ export class KbqDatepicker implements OnDestroy { private overlay = inject(Overlay); private ngZone = inject(NgZone); private viewContainerRef = inject(ViewContainerRef); - private readonly dateAdapter = inject>(DateAdapter, { optional: true })!; + private readonly dateAdapter = injectRequiredDateAdapter(); private dir = inject(Directionality, { optional: true })!; protected readonly document = inject(DOCUMENT); @@ -295,13 +298,7 @@ export class KbqDatepicker implements OnDestroy { private closeSubscription = Subscription.EMPTY; constructor() { - const scrollStrategy = inject(KBQ_DATEPICKER_SCROLL_STRATEGY); - - if (!this.dateAdapter) { - throw createMissingDateImplError('DateAdapter'); - } - - this.scrollStrategy = scrollStrategy; + this.scrollStrategy = inject(KBQ_DATEPICKER_SCROLL_STRATEGY); } ngOnDestroy() { diff --git a/packages/components/datepicker/month-view.component.ts b/packages/components/datepicker/month-view.component.ts index 9e87739494..42152d018d 100644 --- a/packages/components/datepicker/month-view.component.ts +++ b/packages/components/datepicker/month-view.component.ts @@ -10,9 +10,8 @@ import { output, viewChild } from '@angular/core'; -import { DateAdapter } from '@koobiq/components/core'; import { KbqCalendarBody, KbqCalendarCell, KbqCalendarCellCssClasses } from './calendar-body.component'; -import { createMissingDateImplError } from './datepicker-errors'; +import { injectRequiredDateAdapter } from './datepicker-errors'; const DAYS_PER_WEEK = 7; @@ -32,7 +31,7 @@ const DAYS_PER_WEEK = 7; }) export class KbqMonthView implements AfterContentInit { private changeDetectorRef = inject(ChangeDetectorRef); - adapter = inject>(DateAdapter, { optional: true })!; + adapter = injectRequiredDateAdapter(); /** * The date to display in this month view (everything other than the month and year is ignored). @@ -114,10 +113,6 @@ export class KbqMonthView implements AfterContentInit { weekdays: { long: string; narrow: string }[]; constructor() { - if (!this.adapter) { - throw createMissingDateImplError('DateAdapter'); - } - const firstDayOfWeek = this.adapter.getFirstDayOfWeek(); const narrowWeekdays = this.adapter.getDayOfWeekNames('short'); const longWeekdays = this.adapter.getDayOfWeekNames('long'); diff --git a/packages/components/dropdown/dropdown-trigger.directive.ts b/packages/components/dropdown/dropdown-trigger.directive.ts index ad24ff1487..29ca59fb34 100644 --- a/packages/components/dropdown/dropdown-trigger.directive.ts +++ b/packages/components/dropdown/dropdown-trigger.directive.ts @@ -49,8 +49,14 @@ import { KbqDropdownItem } from './dropdown-item.component'; import { KbqDropdown } from './dropdown.component'; import { DropdownCloseReason, KbqDropdownPanel, KbqDropdownPositionX, KbqDropdownPositionY } from './dropdown.types'; -/** Injection token that determines the scroll handling while the dropdown is open. */ -export const KBQ_DROPDOWN_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>('kbq-dropdown-scroll-strategy'); +/** + * Injection token that determines the scroll handling while the dropdown is open. The root default keeps the + * trigger usable outside `KbqDropdownModule`'s injector; providing the token anywhere still wins over it. + */ +export const KBQ_DROPDOWN_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>('kbq-dropdown-scroll-strategy', { + providedIn: 'root', + factory: () => KBQ_DROPDOWN_SCROLL_STRATEGY_FACTORY(inject(Overlay)) +}); /** @docs-private */ export function KBQ_DROPDOWN_SCROLL_STRATEGY_FACTORY(overlay: Overlay): () => ScrollStrategy { diff --git a/packages/components/dropdown/dropdown.module.ts b/packages/components/dropdown/dropdown.module.ts index 485b824a50..dfe8e55ef3 100644 --- a/packages/components/dropdown/dropdown.module.ts +++ b/packages/components/dropdown/dropdown.module.ts @@ -3,7 +3,7 @@ import { NgModule } from '@angular/core'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqDropdownContent } from './dropdown-content.directive'; import { KbqDropdownItem } from './dropdown-item.component'; -import { KBQ_DROPDOWN_SCROLL_STRATEGY_FACTORY_PROVIDER, KbqDropdownTrigger } from './dropdown-trigger.directive'; +import { KbqDropdownTrigger } from './dropdown-trigger.directive'; import { KbqDropdown, KbqDropdownFooter, KbqDropdownStaticContent } from './dropdown.component'; @NgModule({ @@ -17,7 +17,6 @@ import { KbqDropdown, KbqDropdownFooter, KbqDropdownStaticContent } from './drop KbqDropdownContent, KbqDropdownFooter ], - providers: [KBQ_DROPDOWN_SCROLL_STRATEGY_FACTORY_PROVIDER], exports: [ KbqDropdown, KbqDropdownItem, diff --git a/packages/components/modal/examples.modal.en.md b/packages/components/modal/examples.modal.en.md index 188e37aed1..5d932f02b4 100644 --- a/packages/components/modal/examples.modal.en.md +++ b/packages/components/modal/examples.modal.en.md @@ -1,7 +1,9 @@ ### Custom Injector for Modal Component -When you need to use component-level services within dynamically created modal components, include information about the current `injector` instance. -To achieve this, pass the `injector` instance from the parent component as an argument to the `modalService.open` method. -This ensures proper behavior and access to the required dependencies. +A component passed as `kbqComponent` is created from the **root** environment injector, not from the component that called `KbqModalService`. Anything the caller provides itself is therefore invisible inside the modal: component-level services, tokens from a route or a lazily loaded module, and providers that reach the caller only through NgModules listed in its standalone `imports` — `DateAdapter`, `KBQ_DATE_FORMATS` and `DateFormatter` among them. + +To make them available, pass the caller's `injector` to `modalService.open`; it becomes the parent of the modal content's injection hierarchy. Content passed as a `TemplateRef` is unaffected — it keeps the injector of the component that declared it. + +A missing dependency surfaces as `NullInjectorError: No provider for …`, thrown while the modal content is being created. diff --git a/packages/components/modal/examples.modal.ru.md b/packages/components/modal/examples.modal.ru.md index 67473d5dd3..a57b715c9e 100644 --- a/packages/components/modal/examples.modal.ru.md +++ b/packages/components/modal/examples.modal.ru.md @@ -1,6 +1,9 @@ ### Пользовательский инжектор для модального компонента -Когда нужно использовать сервисы уровня компонентов внутри динамически создаваемых модальных компонентов, добавьте информацию о текущем экземпляре `injector`. -Для этого следует передать экземпляр `injector` из родительского компонента в метод `modalService.open` как аргумент. Это обеспечит корректное поведение и доступ к нужным зависимостям. +Компонент, переданный в `kbqComponent`, создается из **корневого** environment-инжектора, а не из компонента, который вызвал `KbqModalService`. Поэтому внутри модального окна не видно ничего, что объявил у себя вызывающий компонент: сервисы уровня компонента, токены из маршрута или лениво загружаемого модуля, а также провайдеры, которые попадают к вызывающему компоненту только через NgModule в его standalone-`imports` — в том числе `DateAdapter`, `KBQ_DATE_FORMATS` и `DateFormatter`. + +Чтобы они стали доступны, передайте `injector` вызывающего компонента в метод `modalService.open` — он станет родителем иерархии внедрения зависимостей содержимого модального окна. Содержимое, переданное как `TemplateRef`, этой особенности не подвержено: оно сохраняет инжектор компонента, в котором объявлено. + +Отсутствующая зависимость проявляется как `NullInjectorError: No provider for …` и выбрасывается в момент создания содержимого модального окна. diff --git a/packages/components/notification-center/notification-center.module.ts b/packages/components/notification-center/notification-center.module.ts index 880c66255e..3e458df0ad 100644 --- a/packages/components/notification-center/notification-center.module.ts +++ b/packages/components/notification-center/notification-center.module.ts @@ -1,11 +1,7 @@ import { ConfigurableFocusTrapFactory, FOCUS_TRAP_INERT_STRATEGY, FocusTrapFactory } from '@angular/cdk/a11y'; import { NgModule } from '@angular/core'; import { EmptyFocusTrapStrategy } from '@koobiq/components/core'; -import { - KBQ_NOTIFICATION_CENTER_SCROLL_STRATEGY_FACTORY_PROVIDER, - KbqNotificationCenterComponent, - KbqNotificationCenterTrigger -} from './notification-center'; +import { KbqNotificationCenterComponent, KbqNotificationCenterTrigger } from './notification-center'; import { KbqNotificationCenterService } from './notification-center.service'; @NgModule({ @@ -14,7 +10,6 @@ import { KbqNotificationCenterService } from './notification-center.service'; KbqNotificationCenterTrigger ], providers: [ - KBQ_NOTIFICATION_CENTER_SCROLL_STRATEGY_FACTORY_PROVIDER, { provide: FocusTrapFactory, useClass: ConfigurableFocusTrapFactory }, { provide: FOCUS_TRAP_INERT_STRATEGY, useClass: EmptyFocusTrapStrategy }, KbqNotificationCenterService diff --git a/packages/components/notification-center/notification-center.ts b/packages/components/notification-center/notification-center.ts index 4c590b7617..9d33c0a25a 100644 --- a/packages/components/notification-center/notification-center.ts +++ b/packages/components/notification-center/notification-center.ts @@ -83,7 +83,11 @@ export const KBQ_NOTIFICATION_CENTER_CONFIGURATION = new InjectionToken('KbqNoti /** @docs-private */ export const KBQ_NOTIFICATION_CENTER_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>( - 'kbq-notification-center-scroll-strategy' + 'kbq-notification-center-scroll-strategy', + { + providedIn: 'root', + factory: () => kbqNotificationCenterScrollStrategyFactory(inject(Overlay)) + } ); /** @docs-private */ diff --git a/packages/components/popover/popover.component.ts b/packages/components/popover/popover.component.ts index affde29cce..545f1f791b 100644 --- a/packages/components/popover/popover.component.ts +++ b/packages/components/popover/popover.component.ts @@ -132,7 +132,10 @@ export class KbqPopoverComponent extends KbqPopUp implements AfterViewInit { protected readonly componentColors = KbqComponentColors; } -export const KBQ_POPOVER_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>('kbq-popover-scroll-strategy'); +export const KBQ_POPOVER_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>('kbq-popover-scroll-strategy', { + providedIn: 'root', + factory: () => kbqPopoverScrollStrategyFactory(inject(Overlay)) +}); /** @docs-private */ export function kbqPopoverScrollStrategyFactory(overlay: Overlay): () => ScrollStrategy { diff --git a/packages/components/popover/popover.module.ts b/packages/components/popover/popover.module.ts index 2348d17ef1..37441cfaf9 100644 --- a/packages/components/popover/popover.module.ts +++ b/packages/components/popover/popover.module.ts @@ -12,11 +12,7 @@ import { KbqButtonModule } from '@koobiq/components/button'; import { EmptyFocusTrapStrategy } from '@koobiq/components/core'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqPopoverConfirmComponent, KbqPopoverConfirmTrigger } from './popover-confirm.component'; -import { - KBQ_POPOVER_SCROLL_STRATEGY_FACTORY_PROVIDER, - KbqPopoverComponent, - KbqPopoverTrigger -} from './popover.component'; +import { KbqPopoverComponent, KbqPopoverTrigger } from './popover.component'; @NgModule({ imports: [ @@ -32,7 +28,6 @@ import { KbqPopoverConfirmTrigger ], providers: [ - KBQ_POPOVER_SCROLL_STRATEGY_FACTORY_PROVIDER, { provide: FocusTrapFactory, useClass: ConfigurableFocusTrapFactory }, { provide: FOCUS_TRAP_INERT_STRATEGY, useClass: EmptyFocusTrapStrategy } ], diff --git a/packages/components/select/select.component.spec.ts b/packages/components/select/select.component.spec.ts index 8a95502db6..89e65cbd82 100644 --- a/packages/components/select/select.component.spec.ts +++ b/packages/components/select/select.component.spec.ts @@ -1,5 +1,11 @@ import { Directionality } from '@angular/cdk/bidi'; -import { OverlayContainer, ScrollDispatcher } from '@angular/cdk/overlay'; +import { + CloseScrollStrategy, + Overlay, + OverlayContainer, + RepositionScrollStrategy, + ScrollDispatcher +} from '@angular/cdk/overlay'; import { CdkVirtualScrollViewport, ScrollingModule } from '@angular/cdk/scrolling'; import { AsyncPipe } from '@angular/common'; import { @@ -41,6 +47,7 @@ import { ErrorStateMatcher, HOME, KBQ_LOCALE_SERVICE, + KBQ_SELECT_SCROLL_STRATEGY, KbqLocaleService, KbqLocaleServiceModule, KbqOption, @@ -2189,6 +2196,19 @@ class SelectWithCleanerWithoutTemplateRef { readonly control = new UntypedFormControl('pizza'); } +@Component({ + selector: 'standalone-select', + imports: [KbqSelect, KbqOption], + template: ` + + Option + + ` +}) +class StandaloneSelect { + readonly select = viewChild.required(KbqSelect); +} + describe('KbqSelect', () => { let overlayContainer: OverlayContainer; let overlayContainerElement: HTMLElement; @@ -8271,4 +8291,34 @@ describe('KbqSelect', () => { })); }); }); + + // `KbqSelect` is exported standalone, so `imports: [KbqSelect, KbqOption]` is a legitimate way to consume + // it. Nothing in the select's own `imports` provides `KBQ_SELECT_SCROLL_STRATEGY` — only `KbqSelectModule` + // does — so the token has to carry its own default. + describe('without KbqSelectModule', () => { + it('should render when imported as a bare standalone component', () => { + TestBed.configureTestingModule({ imports: [StandaloneSelect, NoopAnimationsModule] }); + + const fixture = TestBed.createComponent(StandaloneSelect); + + fixture.detectChanges(); + + expect(fixture.componentInstance.select().scrollStrategy).toBeInstanceOf(RepositionScrollStrategy); + }); + + it('should let an explicitly provided scroll strategy win over the default', () => { + const closeStrategy = () => TestBed.inject(Overlay).scrollStrategies.close(); + + TestBed.configureTestingModule({ + imports: [StandaloneSelect, NoopAnimationsModule], + providers: [{ provide: KBQ_SELECT_SCROLL_STRATEGY, useValue: closeStrategy }] + }); + + const fixture = TestBed.createComponent(StandaloneSelect); + + fixture.detectChanges(); + + expect(fixture.componentInstance.select().scrollStrategy).toBeInstanceOf(CloseScrollStrategy); + }); + }); }); diff --git a/packages/components/select/select.module.ts b/packages/components/select/select.module.ts index 842558db2c..b71b975024 100644 --- a/packages/components/select/select.module.ts +++ b/packages/components/select/select.module.ts @@ -3,7 +3,6 @@ import { OverlayModule } from '@angular/cdk/overlay'; import { NgTemplateOutlet } from '@angular/common'; import { NgModule } from '@angular/core'; import { - KBQ_SELECT_SCROLL_STRATEGY_PROVIDER, KbqOptionModule, KbqSelectFooter, KbqSelectMatcher, @@ -40,7 +39,6 @@ import { KbqSelect } from './select.component'; KbqSelectNoOptions, KbqSelectErrorText ], - providers: [KBQ_SELECT_SCROLL_STRATEGY_PROVIDER], exports: [ KbqSelect, KbqOptionTooltip, diff --git a/packages/components/time-range/time-range.service.ts b/packages/components/time-range/time-range.service.ts index a48f75590d..af8b775137 100644 --- a/packages/components/time-range/time-range.service.ts +++ b/packages/components/time-range/time-range.service.ts @@ -18,8 +18,10 @@ import { @Injectable() export class KbqTimeRangeService { - readonly dateAdapter = inject>(DateAdapter); - readonly dateFormatter = inject>(DateFormatter); + // Optional so the constructor can name the missing provider instead of letting DI throw a bare + // `NullInjectorError`. Neither is optional in practice - the guards below reject a missing one. + readonly dateAdapter = inject>(DateAdapter, { optional: true })!; + readonly dateFormatter = inject>(DateFormatter, { optional: true })!; readonly providedDefaultTimeRangeTypes = inject(KBQ_DEFAULT_TIME_RANGE_TYPES, { optional: true }) || defaultTimeRangeTypes; @@ -56,6 +58,10 @@ export class KbqTimeRangeService { throw createMissingDateImplError('KbqTimeRange', 'DateAdapter'); } + if (!this.dateFormatter) { + throw createMissingDateImplError('KbqTimeRange', 'DateFormatter'); + } + this.customTimeRangeTypes ?.filter(({ type }) => { return !this.timeRangeConfig[type]; diff --git a/packages/components/timezone/timezone-select.component.spec.ts b/packages/components/timezone/timezone-select.component.spec.ts index e88166a2ac..a0402acd56 100644 --- a/packages/components/timezone/timezone-select.component.spec.ts +++ b/packages/components/timezone/timezone-select.component.spec.ts @@ -1,4 +1,4 @@ -import { OverlayContainer, ScrollDispatcher } from '@angular/cdk/overlay'; +import { OverlayContainer, RepositionScrollStrategy, ScrollDispatcher } from '@angular/cdk/overlay'; import { AsyncPipe } from '@angular/common'; import { Component, OnInit, Type, getDebugNode, viewChild, viewChildren } from '@angular/core'; import { ComponentFixture, TestBed, discardPeriodicTasks, fakeAsync, flush, inject, tick } from '@angular/core/testing'; @@ -228,6 +228,15 @@ class TimezoneSelectWithSearch implements OnInit { } } +@Component({ + selector: 'standalone-timezone-select', + imports: [KbqTimezoneSelect], + template: '' +}) +class StandaloneTimezoneSelect { + readonly select = viewChild.required(KbqTimezoneSelect); +} + describe('KbqTimezoneSelect', () => { let overlayContainer: OverlayContainer; let overlayContainerElement: HTMLElement; @@ -968,4 +977,24 @@ describe('KbqTimezoneSelect', () => { flush(); })); }); + + // `KbqTimezoneSelect` is exported standalone, so `imports: [KbqTimezoneSelect]` is a legitimate way to + // consume it — but it extends `KbqSelect` and inherits its `KBQ_SELECT_SCROLL_STRATEGY` injection, while + // its own `imports` carry no module that provides that token. Every other suite here pulls in + // `KbqSelectModule`, which hides the gap. + describe('without KbqSelectModule', () => { + beforeEach(() => { + TestBed.configureTestingModule({ imports: [StandaloneTimezoneSelect, NoopAnimationsModule] }); + + overlayContainer = TestBed.inject(OverlayContainer); + }); + + it('should render when imported as a bare standalone component', () => { + const fixture = TestBed.createComponent(StandaloneTimezoneSelect); + + fixture.detectChanges(); + + expect(fixture.componentInstance.select().scrollStrategy).toBeInstanceOf(RepositionScrollStrategy); + }); + }); }); diff --git a/packages/components/title/title.directive.spec.ts b/packages/components/title/title.directive.spec.ts index 16dffa6308..9c90eb8760 100644 --- a/packages/components/title/title.directive.spec.ts +++ b/packages/components/title/title.directive.spec.ts @@ -6,14 +6,13 @@ import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { dispatchMouseEvent, KBQ_TITLE_TEXT_REF, KbqTitleTextRef, PopUpTriggers } from '@koobiq/components/core'; -import { KBQ_TOOLTIP_SCROLL_STRATEGY_FACTORY_PROVIDER } from '@koobiq/components/tooltip'; import { Subject } from 'rxjs'; import { KbqTitleDirective } from './title.directive'; const createComponent = (component: Type, providers: any[] = []): ComponentFixture => { TestBed.configureTestingModule({ imports: [component, NoopAnimationsModule], - providers: [KBQ_TOOLTIP_SCROLL_STRATEGY_FACTORY_PROVIDER, ...providers] + providers }).compileComponents(); const fixture = TestBed.createComponent(component); diff --git a/packages/components/tooltip/tooltip.component.ts b/packages/components/tooltip/tooltip.component.ts index 6b13af3f7a..45470ccaa7 100644 --- a/packages/components/tooltip/tooltip.component.ts +++ b/packages/components/tooltip/tooltip.component.ts @@ -131,7 +131,10 @@ const INTERACTIVE_TRIGGERS = [ */ const RELEASE_TRIGGERS = ['mouseleave', 'blur']; -export const KBQ_TOOLTIP_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>('kbq-tooltip-scroll-strategy'); +export const KBQ_TOOLTIP_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>('kbq-tooltip-scroll-strategy', { + providedIn: 'root', + factory: () => kbqTooltipScrollStrategyFactory(inject(Overlay)) +}); /** @docs-private */ export function kbqTooltipScrollStrategyFactory(overlay: Overlay): () => ScrollStrategy { diff --git a/packages/components/tooltip/tooltip.module.ts b/packages/components/tooltip/tooltip.module.ts index 39a73dc532..80cf2390db 100644 --- a/packages/components/tooltip/tooltip.module.ts +++ b/packages/components/tooltip/tooltip.module.ts @@ -1,10 +1,5 @@ import { NgModule } from '@angular/core'; -import { - KBQ_TOOLTIP_OPEN_TIME_PROVIDER, - KBQ_TOOLTIP_SCROLL_STRATEGY_FACTORY_PROVIDER, - KbqTooltipComponent, - KbqTooltipTrigger -} from './tooltip.component'; +import { KBQ_TOOLTIP_OPEN_TIME_PROVIDER, KbqTooltipComponent, KbqTooltipTrigger } from './tooltip.component'; const COMPONENTS = [ KbqTooltipComponent, @@ -13,10 +8,7 @@ const COMPONENTS = [ @NgModule({ imports: COMPONENTS, - providers: [ - KBQ_TOOLTIP_SCROLL_STRATEGY_FACTORY_PROVIDER, - KBQ_TOOLTIP_OPEN_TIME_PROVIDER - ], + providers: [KBQ_TOOLTIP_OPEN_TIME_PROVIDER], exports: COMPONENTS }) export class KbqToolTipModule {} diff --git a/packages/components/tree-select/tree-select.module.ts b/packages/components/tree-select/tree-select.module.ts index b0e5e02d37..806d81baec 100644 --- a/packages/components/tree-select/tree-select.module.ts +++ b/packages/components/tree-select/tree-select.module.ts @@ -3,7 +3,6 @@ import { OverlayModule } from '@angular/cdk/overlay'; import { NgTemplateOutlet } from '@angular/common'; import { NgModule } from '@angular/core'; import { - KBQ_SELECT_SCROLL_STRATEGY_PROVIDER, KbqPseudoCheckboxModule, KbqSelectFooter, KbqSelectMatcher, @@ -33,7 +32,6 @@ import { KbqTreeSelect } from './tree-select.component'; A11yModule, KbqTreeSelect ], - providers: [KBQ_SELECT_SCROLL_STRATEGY_PROVIDER], exports: [ KbqTreeSelect, KbqSelectSearch, From c8969fd090cbe81725080ccae663ace59b673588 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Thu, 13 Aug 2026 10:09:22 +0300 Subject: [PATCH 2/3] fix(datepicker): keep DateAdapter import so the API report stays stable (#DS-3522) Extracting injectRequiredDateAdapter() dropped the explicit DateAdapter import from datepicker-input and month-view. The inferred type is the same, but api-extractor then renders the two public adapter fields as _koobiq_components_core.DateAdapter instead of DateAdapter, which fails check-api on an otherwise unchanged public surface. Annotate both fields explicitly so the report is byte-identical again. --- packages/components/datepicker/datepicker-input.directive.ts | 3 ++- packages/components/datepicker/month-view.component.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/components/datepicker/datepicker-input.directive.ts b/packages/components/datepicker/datepicker-input.directive.ts index 1fc96329ce..c1edd7daa5 100644 --- a/packages/components/datepicker/datepicker-input.directive.ts +++ b/packages/components/datepicker/datepicker-input.directive.ts @@ -29,6 +29,7 @@ import { } from '@angular/forms'; import { BACKSPACE, + DateAdapter, DELETE, DOWN_ARROW, END, @@ -247,7 +248,7 @@ export class KbqDatepickerInput { elementRef = inject>(ElementRef); private readonly renderer = inject(Renderer2); - readonly adapter = injectRequiredDateAdapter(); + readonly adapter: DateAdapter = injectRequiredDateAdapter(); private readonly dateFormats = inject(KBQ_DATE_FORMATS, { optional: true }); /** @docs-private */ protected readonly formField = inject(KBQ_FORM_FIELD, { optional: true, host: true }); diff --git a/packages/components/datepicker/month-view.component.ts b/packages/components/datepicker/month-view.component.ts index 42152d018d..ba7d6d9078 100644 --- a/packages/components/datepicker/month-view.component.ts +++ b/packages/components/datepicker/month-view.component.ts @@ -10,6 +10,7 @@ import { output, viewChild } from '@angular/core'; +import { DateAdapter } from '@koobiq/components/core'; import { KbqCalendarBody, KbqCalendarCell, KbqCalendarCellCssClasses } from './calendar-body.component'; import { injectRequiredDateAdapter } from './datepicker-errors'; @@ -31,7 +32,7 @@ const DAYS_PER_WEEK = 7; }) export class KbqMonthView implements AfterContentInit { private changeDetectorRef = inject(ChangeDetectorRef); - adapter = injectRequiredDateAdapter(); + adapter: DateAdapter = injectRequiredDateAdapter(); /** * The date to display in this month view (everything other than the month and year is ignored). From 26dccc00e1f2d301222737dce31c00ae10ea59fb Mon Sep 17 00:00:00 2001 From: lskramarov Date: Fri, 14 Aug 2026 14:22:02 +0300 Subject: [PATCH 3/3] fix: after review --- .../datepicker/datepicker-in-modal.spec.ts | 79 ------------------- .../components/datepicker/datepicker.spec.ts | 73 +++++++++++++++++ 2 files changed, 73 insertions(+), 79 deletions(-) delete mode 100644 packages/components/datepicker/datepicker-in-modal.spec.ts diff --git a/packages/components/datepicker/datepicker-in-modal.spec.ts b/packages/components/datepicker/datepicker-in-modal.spec.ts deleted file mode 100644 index 00250c8d30..0000000000 --- a/packages/components/datepicker/datepicker-in-modal.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { OverlayContainer } from '@angular/cdk/overlay'; -import { Component, inject } from '@angular/core'; -import { fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { KBQ_LUXON_DATE_FORMATS, KbqLuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter'; -import { KBQ_DATE_FORMATS } from '@koobiq/components/core'; -import { KbqFormFieldModule } from '@koobiq/components/form-field'; -import { KbqModalModule, KbqModalService } from '@koobiq/components/modal'; -import { KbqDatepickerModule } from './index'; - -/** Reproduces the reported scenario: a datepicker living inside a `KbqModalService`-created component. */ -@Component({ - selector: 'modal-content-with-datepicker', - imports: [FormsModule, KbqDatepickerModule, KbqFormFieldModule], - template: ` - - - - - - ` -}) -class ModalContentWithDatepicker {} - -@Component({ - selector: 'modal-host', - imports: [KbqModalModule], - template: '' -}) -class ModalHost { - readonly modalService = inject(KbqModalService); - - open() { - return this.modalService.open({ kbqComponent: ModalContentWithDatepicker }); - } -} - -describe('datepicker inside a modal', () => { - let overlayContainer: OverlayContainer; - - beforeEach(() => { - TestBed.configureTestingModule({ - // Only the app-level date wiring is registered here. `KbqDatepickerModule` deliberately stays a - // standalone import of the modal content component, which is where the reported app had it. - imports: [KbqLuxonDateModule, NoopAnimationsModule, ModalHost], - providers: [{ provide: KBQ_DATE_FORMATS, useValue: KBQ_LUXON_DATE_FORMATS }] - }); - - overlayContainer = TestBed.inject(OverlayContainer); - }); - - afterEach(() => overlayContainer.ngOnDestroy()); - - it('should open the calendar', fakeAsync(() => { - const fixture = TestBed.createComponent(ModalHost); - - fixture.detectChanges(); - - expect(() => { - fixture.componentInstance.open(); - fixture.detectChanges(); - flush(); - }).not.toThrow(); - - const toggle = overlayContainer.getContainerElement().querySelector('kbq-datepicker-toggle-icon'); - - expect(toggle).not.toBeNull(); - - expect(() => { - toggle!.click(); - fixture.detectChanges(); - tick(500); - flush(); - }).not.toThrow(); - - expect(overlayContainer.getContainerElement().querySelector('kbq-datepicker__content')).not.toBeNull(); - })); -}); diff --git a/packages/components/datepicker/datepicker.spec.ts b/packages/components/datepicker/datepicker.spec.ts index 46a2ca5d6c..5a8ab4b85c 100644 --- a/packages/components/datepicker/datepicker.spec.ts +++ b/packages/components/datepicker/datepicker.spec.ts @@ -38,6 +38,7 @@ import { UP_ARROW } from '@koobiq/components/core'; import { KbqFormFieldModule } from '@koobiq/components/form-field'; +import { KbqModalModule, KbqModalService } from '@koobiq/components/modal'; import { DateTime } from 'luxon'; import { map, Observable, timer } from 'rxjs'; import { KbqInputModule } from '../input/index'; @@ -1488,6 +1489,48 @@ describe('KbqDatepicker', () => { }); }); + // `KbqModalService` builds the modal content from the root injector, so `KbqDatepickerModule`'s own + // providers are out of reach there — `KBQ_DATEPICKER_SCROLL_STRATEGY` has to carry a root default for the + // calendar to open at all. Every suite above pulls the module into the TestBed root, which hides the gap. + describe('inside a modal', () => { + beforeEach(() => { + // Only the app-level date wiring is registered here. `KbqDatepickerModule` deliberately stays a + // standalone import of the modal content component, which is where the reported app had it. + TestBed.configureTestingModule({ + imports: [KbqLuxonDateModule, NoopAnimationsModule, DatepickerInModalHost], + providers: [{ provide: KBQ_DATE_FORMATS, useValue: KBQ_LUXON_DATE_FORMATS }] + }); + }); + + it('should open the calendar', fakeAsync(() => { + const overlayContainer = TestBed.inject(OverlayContainer); + const fixture = TestBed.createComponent(DatepickerInModalHost); + + fixture.detectChanges(); + + expect(() => { + fixture.componentInstance.open(); + fixture.detectChanges(); + flush(); + }).not.toThrow(); + + const toggle = overlayContainer + .getContainerElement() + .querySelector('kbq-datepicker-toggle-icon'); + + expect(toggle).not.toBeNull(); + + expect(() => { + toggle!.click(); + fixture.detectChanges(); + tick(500); + flush(); + }).not.toThrow(); + + expect(overlayContainer.getContainerElement().querySelector('kbq-datepicker__content')).not.toBeNull(); + })); + }); + // TODO Fix it with (use Moment) xdescribe('internationalization', () => { let fixture: ComponentFixture; @@ -1786,3 +1829,33 @@ class DelayedDatepicker { date: DateTime | null; assignedDatepicker: KbqDatepicker; } + +@Component({ + imports: [ + KbqDatepickerModule, + KbqFormFieldModule, + FormsModule + ], + template: ` + + + + + + ` +}) +class DatepickerInModalContent {} + +@Component({ + imports: [ + KbqModalModule + ], + template: '' +}) +class DatepickerInModalHost { + readonly modalService = inject_1(KbqModalService); + + open() { + return this.modalService.open({ kbqComponent: DatepickerInModalContent }); + } +}