Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions apps/docs/src/app/services/i18n.characterization.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 1 addition & 3 deletions apps/docs/src/assets/stackblitz/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,13 @@ 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, {
providers: [
provideAnimations(),
provideHttpClient(),
provideRouter([]),
{ provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService },
KBQ_TOOLTIP_SCROLL_STRATEGY_FACTORY_PROVIDER

@artembelik artembelik Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

в прошлый раз мы это добавили, тк тултипы на стакблитз не отображались DS-5007

UPD. стакблитз запускается, ошибок нет

{ provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService }
]
}).catch((err) => console.error(err));
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 1 addition & 5 deletions packages/components/autocomplete/autocomplete.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -17,7 +14,6 @@ import { KbqAutocomplete, KbqAutocompleteFooter } from './autocomplete.component
KbqAutocompleteOrigin,
KbqAutocompleteFooter
],
providers: [KBQ_AUTOCOMPLETE_SCROLL_STRATEGY_FACTORY_PROVIDER],
exports: [
KbqAutocomplete,
KbqOptionModule,
Expand Down
9 changes: 3 additions & 6 deletions packages/components/button-toggle/button-toggle.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
16 changes: 13 additions & 3 deletions packages/components/core/select/constants.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 {
Expand Down
15 changes: 4 additions & 11 deletions packages/components/datepicker/calendar-header.component.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -43,7 +36,7 @@ export type MonthName = {
exportAs: 'kbqCalendarHeader'
})
export class KbqCalendarHeader<D> implements AfterContentInit {
private readonly adapter = inject<DateAdapter<D>>(DateAdapter);
private readonly adapter = injectRequiredDateAdapter<D>();

/** Accessible names for the icon-only month navigation buttons. */
protected readonly a11yLocaleConfiguration = kbqInjectA11yLocaleConfiguration();
Expand Down
12 changes: 12 additions & 0 deletions packages/components/datepicker/calendar-header.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand Down
9 changes: 2 additions & 7 deletions packages/components/datepicker/calendar.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -41,7 +40,7 @@ import { KbqMonthView } from './month-view.component';
exportAs: 'kbqCalendar'
})
export class KbqCalendar<D> implements AfterContentInit, OnDestroy, OnChanges {
private readonly adapter = inject<DateAdapter<D>>(DateAdapter, { optional: true })!;
private readonly adapter = injectRequiredDateAdapter<D>();
private changeDetectorRef = inject(ChangeDetectorRef);

/** A date representing the period (month or year) to start the calendar in. */
Expand Down Expand Up @@ -167,10 +166,6 @@ export class KbqCalendar<D> 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();
Expand Down
14 changes: 14 additions & 0 deletions packages/components/datepicker/datepicker-errors.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import { inject } from '@angular/core';
import { DateAdapter } from '@koobiq/components/core';

/** @docs-private */
export function createMissingDateImplError(provider: string) {
return Error(
`KbqDatepicker: No provider found for ${provider}. You must import one of the existing ` +
`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<D>(): DateAdapter<D> {
const adapter = inject<DateAdapter<D>>(DateAdapter, { optional: true });

if (!adapter) {
throw createMissingDateImplError('DateAdapter');
}

return adapter;
}
79 changes: 79 additions & 0 deletions packages/components/datepicker/datepicker-in-modal.spec.ts
Original file line number Diff line number Diff line change
@@ -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: `
<kbq-form-field>
<input [kbqDatepicker]="picker" [ngModel]="null" />
<kbq-datepicker-toggle-icon kbqSuffix [for]="picker" />
<kbq-datepicker #picker />
</kbq-form-field>
`
})
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', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Достаточно ли просто добавить в datepicker.spec.ts, чтобы не создавать новый файл?

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<HTMLElement>('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();
}));
});
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,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 {
Expand Down Expand Up @@ -248,7 +248,7 @@ export class KbqDatepickerInput<D>
{
elementRef = inject<ElementRef<HTMLInputElement>>(ElementRef);
private readonly renderer = inject(Renderer2);
readonly adapter = inject<DateAdapter<D>>(DateAdapter, { optional: true })!;
readonly adapter: DateAdapter<D> = injectRequiredDateAdapter<D>();
private readonly dateFormats = inject<KbqDateFormats>(KBQ_DATE_FORMATS, { optional: true });
/** @docs-private */
protected readonly formField = inject(KBQ_FORM_FIELD, { optional: true, host: true });
Expand Down Expand Up @@ -565,10 +565,6 @@ export class KbqDatepickerInput<D>
this.filterValidator
]);

if (!this.adapter) {
throw createMissingDateImplError('DateAdapter');
}

this.errorStateTracker = new KbqErrorStateTracker(
inject(ErrorStateMatcher),
// update ngControl later, so it will be initialized
Expand Down
11 changes: 2 additions & 9 deletions packages/components/datepicker/datepicker-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,
Expand Down
Loading