Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 1 addition & 7 deletions angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions apps/docs/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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'
Expand Down Expand Up @@ -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'))
Expand Down
13 changes: 3 additions & 10 deletions apps/docs/src/app/components/anchors/_anchors-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
@for (anchor of anchors; track anchor) {
<div
class="docs-anchors__element docs-anchors__element_{{ anchor.level }}"
[ngClass]="{ 'docs-anchors__element_active': anchor.active }"
[class.docs-anchors__element_active]="anchor.active"
>
<a
kbq-title
Expand Down
45 changes: 45 additions & 0 deletions apps/docs/src/app/components/anchors/anchors.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { DOCS_MARKDOWN_HEADING_CLASSES } from '../live-example/markdown-content';
import { DocsAnchorsComponent } from './anchors.component';

describe('DocsAnchorsComponent', () => {
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 <docs-component-viewer> 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);
});
});
});
});
69 changes: 43 additions & 26 deletions apps/docs/src/app/components/anchors/anchors.component.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -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'
Expand All @@ -61,8 +63,11 @@ export class DocsAnchorsComponent implements OnDestroy, OnInit {
private fragmentScrollSubscription: Subscription | undefined;
private fragmentScrollTimer: ReturnType<typeof setTimeout> | 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 {
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
Expand All @@ -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' });
Expand All @@ -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[] {
Expand All @@ -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<string>(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 = () => {
Expand Down
18 changes: 4 additions & 14 deletions apps/docs/src/app/components/code-snippet/code-snippet.ts
Original file line number Diff line number Diff line change
@@ -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]',
Expand All @@ -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<HTMLElement>>(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 || '');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

<div class="kbq-callout kbq-callout_contrast">
<div class="kbq-callout__header">
{{ isRuLocale() ? 'Предложения по улучшению' : 'Suggestions for improvement' }}
{{ t('improvementSuggestions') }}
</div>

<!-- prettier-ignore -->
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -19,7 +19,7 @@ import { DocsOverviewComponentBase } from './component-viewer.component';

<div class="kbq-callout kbq-callout_contrast">
<div class="kbq-callout__header">
{{ isRuLocale() ? 'Предложения по улучшению' : 'Suggestions for improvement' }}
{{ t('improvementSuggestions') }}
</div>

<!-- prettier-ignore -->
Expand All @@ -39,6 +39,7 @@ import { DocsOverviewComponentBase } from './component-viewer.component';
<docs-anchors [headerSelectors]="'.docs-header-link'" />
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'docs-component-overview'
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
});

Expand All @@ -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;

Expand Down Expand Up @@ -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();
}
}
Expand Down
Loading