diff --git a/angular.json b/angular.json index b17adcee0f..23cda17e22 100644 --- a/angular.json +++ b/angular.json @@ -113,13 +113,7 @@ "configurations": { "production": { "outputHashing": "all", - "tsConfig": "apps/docs/tsconfig.build.json", - "fileReplacements": [ - { - "replace": "apps/docs/src/environments/environment.ts", - "with": "apps/docs/src/environments/environment.prod.ts" - } - ] + "tsConfig": "apps/docs/tsconfig.build.json" }, "development": { "optimization": false, diff --git a/apps/docs/src/app/app.component.ts b/apps/docs/src/app/app.component.ts index 5467f983bc..d4c1c5a6b9 100644 --- a/apps/docs/src/app/app.component.ts +++ b/apps/docs/src/app/app.component.ts @@ -1,6 +1,6 @@ import { animate, state, style, transition, trigger } from '@angular/animations'; import { AsyncPipe } from '@angular/common'; -import { Component, inject, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, ViewEncapsulation } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { NavigationEnd, Router, RouterOutlet } from '@angular/router'; import { KbqDividerModule } from '@koobiq/components/divider'; @@ -20,6 +20,7 @@ import { DocsDocStates, DocsNavbarState } from './services/doc-states'; ], templateUrl: 'app.component.html', styleUrl: 'app.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { class: 'docs-app' @@ -63,7 +64,7 @@ export class DocsAppComponent { map((state) => state === DocsNavbarState.Opened) ); - isExamplesPage = toSignal( + readonly isExamplesPage = toSignal( this.router.events.pipe( filter((event): event is NavigationEnd => event instanceof NavigationEnd), map((event) => event.urlAfterRedirects.startsWith('/examples')) diff --git a/apps/docs/src/app/components/anchors/_anchors-theme.scss b/apps/docs/src/app/components/anchors/_anchors-theme.scss index 71f2b2bd1b..4b9206118b 100644 --- a/apps/docs/src/app/components/anchors/_anchors-theme.scss +++ b/apps/docs/src/app/components/anchors/_anchors-theme.scss @@ -21,23 +21,16 @@ .docs-anchors__element { &:first-child { - padding: 0 0 0 16px; + padding: 0 0 0 var(--kbq-size-l); margin: 0; & .docs-anchors__link { - padding: 6px 16px 6px 0; + padding: var(--kbq-size-xs) var(--kbq-size-l) var(--kbq-size-xs) 0; } } &.docs-anchors__element_active { - @include box-shadow(inset 3px 0 0 #363949); + box-shadow: inset 3px 0 0 $shadow; } } } - -@mixin box-shadow($shadow) { - -webkit-box-shadow: $shadow; - -moz-box-shadow: $shadow; - -o-box-shadow: $shadow; - box-shadow: $shadow; -} diff --git a/apps/docs/src/app/components/anchors/anchors.component.html b/apps/docs/src/app/components/anchors/anchors.component.html index ae84dea54c..7ebad71bf0 100644 --- a/apps/docs/src/app/components/anchors/anchors.component.html +++ b/apps/docs/src/app/components/anchors/anchors.component.html @@ -1,7 +1,7 @@ @for (anchor of anchors; track anchor) {
{ + const createComponent = () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [DocsAnchorsComponent], + providers: [provideRouter([])] + }); + + return TestBed.createComponent(DocsAnchorsComponent); + }; + + describe('scroll-container guard (BUG-07)', () => { + it('does not throw from setScrollPosition when docs-component-viewer is absent', () => { + const fixture = createComponent(); + + fixture.componentInstance.headerSelectors = '.docs-header-link'; + fixture.componentInstance.anchors = []; + + // No exists in the test DOM, so the scroll-container query + // returns null. Before the guard this threw "Cannot read properties of null". + expect(() => fixture.componentInstance.setScrollPosition()).not.toThrow(); + }); + }); + + describe('heading level mapping', () => { + it('derives the anchor level from the shared markdown heading classes', () => { + const component = createComponent().componentInstance as unknown as { + getLevel(classList: DOMTokenList): number; + }; + + DOCS_MARKDOWN_HEADING_CLASSES.forEach((className, level) => { + const element = document.createElement('div'); + + element.className = className; + + expect(component.getLevel(element.classList)).toBe(level); + }); + }); + }); +}); diff --git a/apps/docs/src/app/components/anchors/anchors.component.ts b/apps/docs/src/app/components/anchors/anchors.component.ts index 8838e61b51..0ad0abbc9d 100644 --- a/apps/docs/src/app/components/anchors/anchors.component.ts +++ b/apps/docs/src/app/components/anchors/anchors.component.ts @@ -1,6 +1,7 @@ import { SharedResizeObserver } from '@angular/cdk/observers/private'; -import { DOCUMENT, NgClass } from '@angular/common'; +import { DOCUMENT } from '@angular/common'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, @@ -16,6 +17,7 @@ import { PopUpPlacements } from '@koobiq/components/core'; import { KbqTitleModule } from '@koobiq/components/title'; import { filter, fromEvent, Subscription, timer } from 'rxjs'; import { debounceTime, takeUntil } from 'rxjs/operators'; +import { DOCS_MARKDOWN_HEADING_CLASSES } from '../live-example/markdown-content'; interface KbqDocsAnchor { href: string; @@ -30,11 +32,11 @@ interface KbqDocsAnchor { selector: 'docs-anchors', imports: [ RouterLink, - NgClass, KbqTitleModule ], templateUrl: './anchors.component.html', styleUrls: ['./anchors.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { class: 'docs-anchors' @@ -61,8 +63,11 @@ export class DocsAnchorsComponent implements OnDestroy, OnInit { private fragmentScrollSubscription: Subscription | undefined; private fragmentScrollTimer: ReturnType | undefined; - private get scrollContainer(): HTMLElement { - return this.document.querySelector('docs-component-viewer')!; + // The scroll container lives outside this component's view, so it can be absent (e.g. on a page + // that renders `docs-anchors` without a `docs-component-viewer` host). Return `null` and let + // callers no-op rather than dereferencing a non-null assertion that can lie. + private get scrollContainer(): HTMLElement | null { + return this.document.querySelector('docs-component-viewer'); } private get firstAnchor(): KbqDocsAnchor { @@ -74,7 +79,7 @@ export class DocsAnchorsComponent implements OnDestroy, OnInit { } private get scrollOffset(): number { - return this.scrollContainer.scrollTop + this.headerHeight; + return (this.scrollContainer?.scrollTop ?? 0) + this.headerHeight; } private readonly destroyRef = inject(DestroyRef); @@ -121,20 +126,28 @@ export class DocsAnchorsComponent implements OnDestroy, OnInit { this.updateActiveAnchor(); + const container = this.scrollContainer; + + if (!container) { + this.ref.detectChanges(); + + return; + } + const target = this.document.getElementById(this.fragment); if (target) { this.scrollToFragment(target); } else { // For SSR compatibility - if (typeof this.scrollContainer.scroll === 'function') this.scrollContainer.scroll(0, 0); + if (typeof container.scroll === 'function') container.scroll(0, 0); } this.scrollSubscription?.unsubscribe(); // For SSR compatibility - if (typeof this.scrollContainer.scroll === 'function') { - this.scrollSubscription = fromEvent(this.scrollContainer, 'scroll') + if (typeof container.scroll === 'function') { + this.scrollSubscription = fromEvent(container, 'scroll') .pipe(debounceTime(this.debounceTime), takeUntilDestroyed(this.destroyRef)) .subscribe(this.onScroll); } @@ -153,14 +166,19 @@ export class DocsAnchorsComponent implements OnDestroy, OnInit { this.fragmentScrollTimer = setTimeout(() => { target.scrollIntoView({ behavior: 'instant' }); + const container = this.scrollContainer; + + if (!container) { + return; + } + // Guard against layout shifts caused by lazily-loaded example components: // re-scroll to target whenever the scroll container grows, for up to 2s. this.fragmentScrollSubscription = this.resizeObserver - .observe(this.scrollContainer) + .observe(container) .pipe(takeUntilDestroyed(this.destroyRef), takeUntil(timer(2000))) .subscribe(() => { - const distanceFromTop = - target.getBoundingClientRect().top - this.scrollContainer.getBoundingClientRect().top; + const distanceFromTop = target.getBoundingClientRect().top - container.getBoundingClientRect().top; if (distanceFromTop > 10) { target.scrollIntoView({ behavior: 'instant' }); @@ -184,10 +202,16 @@ export class DocsAnchorsComponent implements OnDestroy, OnInit { } private isScrolledToEnd(): boolean { - const scrollHeight = Math.ceil(this.scrollContainer.scrollTop + this.scrollContainer.clientHeight); + const container = this.scrollContainer; + + if (!container) { + return false; + } + + const scrollHeight = Math.ceil(container.scrollTop + container.clientHeight); // scrollHeight should be strictly equal to documentHeight, but in Edge it is slightly larger - return scrollHeight >= this.scrollContainer.scrollHeight; + return scrollHeight >= container.scrollHeight; } private createAnchors(): KbqDocsAnchor[] { @@ -204,25 +228,18 @@ export class DocsAnchorsComponent implements OnDestroy, OnInit { /** Returns the element's absolute scroll position within the scroll container. */ private getHeaderTopOffset(header: HTMLElement): number { - // For SSR compatibility - if (typeof this.scrollContainer.getBoundingClientRect !== 'function') return 0; + const container = this.scrollContainer; - return ( - this.scrollContainer.scrollTop + - header.getBoundingClientRect().top - - this.scrollContainer.getBoundingClientRect().top - ); + // For SSR compatibility / missing container + if (!container || typeof container.getBoundingClientRect !== 'function') return 0; + + return container.scrollTop + header.getBoundingClientRect().top - container.getBoundingClientRect().top; } private getLevel(classList: DOMTokenList): number { const className = Array.from(classList).find((name) => name.startsWith('kbq-markdown__')) || ''; - return [ - 'kbq-markdown__h2', - 'kbq-markdown__h3', - 'kbq-markdown__h4', - 'kbq-markdown__h5' - ].indexOf(className); + return DOCS_MARKDOWN_HEADING_CLASSES.indexOf(className); } private onScroll = () => { diff --git a/apps/docs/src/app/components/code-snippet/code-snippet.ts b/apps/docs/src/app/components/code-snippet/code-snippet.ts index 35ad1bbb13..fd90e826e8 100644 --- a/apps/docs/src/app/components/code-snippet/code-snippet.ts +++ b/apps/docs/src/app/components/code-snippet/code-snippet.ts @@ -1,7 +1,5 @@ -import { Clipboard } from '@angular/cdk/clipboard'; import { Directive, ElementRef, inject } from '@angular/core'; -import { KbqToastService } from '@koobiq/components/toast'; -import { DocsLocaleState } from 'src/app/services/locale'; +import { DocsClipboardService } from 'src/app/services/clipboard'; @Directive({ selector: '[docsCodeSnippet]', @@ -10,19 +8,11 @@ import { DocsLocaleState } from 'src/app/services/locale'; '(click)': 'copy()' } }) -export class DocsCodeSnippetDirective extends DocsLocaleState { - private readonly clipboard = inject(Clipboard); - private readonly toastService = inject(KbqToastService); +export class DocsCodeSnippetDirective { + private readonly clipboard = inject(DocsClipboardService); private readonly elementRef = inject>(ElementRef); copy() { - if (!this.clipboard.copy(this.elementRef.nativeElement.textContent || '')) { - return; - } - - this.toastService.show({ - style: 'success', - title: this.isRuLocale() ? 'Скопировано' : 'Copied' - }); + this.clipboard.copyWithToast(this.elementRef.nativeElement.textContent || ''); } } diff --git a/apps/docs/src/app/components/component-viewer/component-overview.template.html b/apps/docs/src/app/components/component-viewer/component-overview.template.html index 39f3e42e1c..bbaf053f85 100644 --- a/apps/docs/src/app/components/component-viewer/component-overview.template.html +++ b/apps/docs/src/app/components/component-viewer/component-overview.template.html @@ -9,7 +9,7 @@
- {{ isRuLocale() ? 'Предложения по улучшению' : 'Suggestions for improvement' }} + {{ t('improvementSuggestions') }}
diff --git a/apps/docs/src/app/components/component-viewer/component-viewer-wrapper.ts b/apps/docs/src/app/components/component-viewer/component-viewer-wrapper.ts index 9759b03d24..5e145b2406 100644 --- a/apps/docs/src/app/components/component-viewer/component-viewer-wrapper.ts +++ b/apps/docs/src/app/components/component-viewer/component-viewer-wrapper.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { KbqDividerModule } from '@koobiq/components/divider'; import { KbqLinkModule } from '@koobiq/components/link'; import { DocsAnchorsComponent } from '../anchors/anchors.component'; @@ -19,7 +19,7 @@ import { DocsOverviewComponentBase } from './component-viewer.component';
- {{ isRuLocale() ? 'Предложения по улучшению' : 'Suggestions for improvement' }} + {{ t('improvementSuggestions') }}
@@ -39,6 +39,7 @@ import { DocsOverviewComponentBase } from './component-viewer.component';
`, + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'docs-component-overview' } diff --git a/apps/docs/src/app/components/component-viewer/component-viewer.component.ts b/apps/docs/src/app/components/component-viewer/component-viewer.component.ts index d44523c98a..511f2eeba7 100644 --- a/apps/docs/src/app/components/component-viewer/component-viewer.component.ts +++ b/apps/docs/src/app/components/component-viewer/component-viewer.component.ts @@ -10,7 +10,6 @@ import { ViewEncapsulation } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { Title } from '@angular/platform-browser'; import { ActivatedRoute, Router, RouterLink, RouterLinkActive, RouterOutlet, UrlSegment } from '@angular/router'; import { KbqDividerModule } from '@koobiq/components/divider'; import { KbqLinkModule } from '@koobiq/components/link'; @@ -84,9 +83,11 @@ export class DocsComponentViewerComponent extends DocsLocaleState { if (!docItem) { this.router.navigate(['/404']); + + return; } - this.structureItem = docItem!; + this.structureItem = docItem; this.structureCategoryId = docsGetCategoryById(this.structureItem.categoryId!)!.id; }); @@ -98,7 +99,6 @@ export class DocsComponentViewerComponent extends DocsLocaleState { export class DocsOverviewComponentBase extends DocsLocaleState { private readonly activatedRoute = inject(ActivatedRoute); private readonly changeDetectorRef = inject(ChangeDetectorRef); - private readonly titleService = inject(Title); componentDocItem: DocsStructureItem | null = null; @@ -136,15 +136,8 @@ export class DocsOverviewComponentBase extends DocsLocaleState { } private showView() { - const documentName = this.componentDocItem?.id; - let title = 'Koobiq'; - - if (documentName) { - title = `${documentName.charAt(0).toUpperCase()}${documentName.slice(1)} \u00B7 ${title}`; - } - - this.titleService.setTitle(title); - + // The page title/meta are owned centrally by `DocsTitleStrategy`; here we only flush the + // view once the document content has rendered. this.changeDetectorRef.detectChanges(); } } diff --git a/apps/docs/src/app/components/component-viewer/component-viewer.template.html b/apps/docs/src/app/components/component-viewer/component-viewer.template.html index 1a2502f086..b6807eccd3 100644 --- a/apps/docs/src/app/components/component-viewer/component-viewer.template.html +++ b/apps/docs/src/app/components/component-viewer/component-viewer.template.html @@ -6,14 +6,14 @@
diff --git a/apps/docs/src/app/components/copy-button/copy-button.ts b/apps/docs/src/app/components/copy-button/copy-button.ts index da73696904..b58f67bc63 100644 --- a/apps/docs/src/app/components/copy-button/copy-button.ts +++ b/apps/docs/src/app/components/copy-button/copy-button.ts @@ -1,9 +1,11 @@ -import { Clipboard } from '@angular/cdk/clipboard'; -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, input, signal, ViewEncapsulation } from '@angular/core'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqLinkModule } from '@koobiq/components/link'; +import { DocsClipboardService } from 'src/app/services/clipboard'; import { DocsLocaleState } from 'src/app/services/locale'; +const SUCCESS_LABEL_DURATION = 1000; + @Component({ selector: 'docs-copy-button', imports: [ @@ -11,16 +13,23 @@ import { DocsLocaleState } from 'src/app/services/locale'; KbqLinkModule ], template: ` - @if (isLabelSuccessVisible) { - - - {{ isRuLocale() ? 'Скопировано' : 'Copied' }} - - } @else { - - - - } + + @if (isLabelSuccessVisible()) { + + {{ t('copied') }} + } @else { + + } + `, styleUrls: ['./copy-button.scss'], changeDetection: ChangeDetectionStrategy.OnPush, @@ -30,23 +39,19 @@ import { DocsLocaleState } from 'src/app/services/locale'; } }) export class DocsCopyButtonComponent extends DocsLocaleState { - @Input() contentToCopy: string; + readonly contentToCopy = input(''); - isLabelSuccessVisible = false; + protected readonly isLabelSuccessVisible = signal(false); - private readonly clipboard = inject(Clipboard); - private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly clipboard = inject(DocsClipboardService); copyContent(): void { - if (!this.contentToCopy || !this.clipboard.copy(this.contentToCopy)) { + if (!this.contentToCopy() || !this.clipboard.copy(this.contentToCopy())) { return; } - this.isLabelSuccessVisible = true; + this.isLabelSuccessVisible.set(true); - setTimeout(() => { - this.isLabelSuccessVisible = false; - this.changeDetectorRef.markForCheck(); - }, 1000); + setTimeout(() => this.isLabelSuccessVisible.set(false), SUCCESS_LABEL_DURATION); } } diff --git a/apps/docs/src/app/components/design-tokens-viewers/design-tokens-viewer.ts b/apps/docs/src/app/components/design-tokens-viewers/design-tokens-viewer.ts index 4952afd066..b6edbf1304 100644 --- a/apps/docs/src/app/components/design-tokens-viewers/design-tokens-viewer.ts +++ b/apps/docs/src/app/components/design-tokens-viewers/design-tokens-viewer.ts @@ -3,6 +3,7 @@ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { KbqSidepanelService } from '@koobiq/components/sidepanel'; import { KbqTabsModule } from '@koobiq/components/tabs'; import { DocsLocale } from 'src/app/constants/locale'; +import { DOCS_TRANSLATIONS } from 'src/app/services/i18n'; import { DocsStructureTokensTab } from '../../structure'; import { DocsComponentViewerComponent } from '../component-viewer/component-viewer.component'; import { DocsRegisterHeaderDirective } from '../register-header/register-header.directive'; @@ -19,7 +20,7 @@ import { DocsRegisterHeaderDirective } from '../register-header/register-header. template: `
- {{ isRuLocale() ? 'Дизайн-токены' : 'Design tokens' }} + {{ t('designTokens') }}