Skip to content

Commit 96b8ed7

Browse files
authored
fix(code-block): show viewAll on overflow (#DS-4920) (#1602)
Co-authored-by: artembelik <24925224+artembelik@users.noreply.github.com>
1 parent d00ae40 commit 96b8ed7

7 files changed

Lines changed: 151 additions & 14 deletions

File tree

9.63 KB
Loading
10.1 KB
Loading

packages/components/code-block/code-block.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,11 @@
9999
[tabIndex]="codeContentTabIndex"
100100
>
101101
<pre
102+
#codeBlockPre
102103
class="kbq-code-block__pre"
103104
><code kbqCodeBlockHighlight class="kbq-code-block__code" [file]="file">{{ file.content }}</code></pre>
104105

105-
@if (maxHeight) {
106+
@if (contentExceedsMaxHeight()) {
106107
<div
107108
class="kbq-code-block__view-all"
108109
[class.kbq-code-block__view-all_collapsed]="!viewAll"

packages/components/code-block/code-block.spec.ts

Lines changed: 103 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { SharedResizeObserver } from '@angular/cdk/observers/private';
12
import { Platform } from '@angular/cdk/platform';
23
import { ChangeDetectionStrategy, Component, DebugElement, Provider, Type } from '@angular/core';
34
import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
45
import { By } from '@angular/platform-browser';
56
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
67
import { KbqTabNavBar } from '@koobiq/components/tabs';
78
import { HLJSApi } from 'highlight.js';
9+
import { Observable, Subject } from 'rxjs';
810
import { KBQ_CODE_BLOCK_FALLBACK_FILE_NAME, KbqCodeBlock, kbqCodeBlockLocaleConfigurationProvider } from './code-block';
911
import {
1012
KBQ_CODE_BLOCK_FALLBACK_FILE_LANGUAGE,
@@ -16,6 +18,18 @@ import { KbqCodeBlockFile } from './types';
1618

1719
const HOVER_DEBOUNCE_TIME = 100;
1820

21+
class MockSharedResizeObserver {
22+
private readonly subject = new Subject<ResizeObserverEntry[]>();
23+
24+
observe(_element: Element): Observable<ResizeObserverEntry[]> {
25+
return this.subject.asObservable();
26+
}
27+
28+
triggerResize(): void {
29+
this.subject.next([]);
30+
}
31+
}
32+
1933
const createComponent = <T>(component: Type<T>, providers: Provider[] = []): ComponentFixture<T> => {
2034
TestBed.configureTestingModule({ imports: [component, NoopAnimationsModule], providers });
2135
const fixture = TestBed.createComponent<T>(component);
@@ -61,6 +75,12 @@ const getViewAllButtonElement = (debugElement: DebugElement): HTMLButtonElement
6175
return debugElement.nativeElement.querySelector('.kbq-code-block__view-all__button');
6276
};
6377

78+
const mockPreHeight = (debugElement: DebugElement, height: number): void => {
79+
const pre: HTMLElement = debugElement.nativeElement.querySelector('.kbq-code-block__pre');
80+
81+
Object.defineProperty(pre, 'offsetHeight', { get: () => height, configurable: true });
82+
};
83+
6484
@Component({
6585
imports: [KbqCodeBlockModule],
6686
template: `
@@ -133,6 +153,24 @@ export class TestCodeBlockWithTemplateTabLink {
133153
];
134154
}
135155

156+
@Component({
157+
imports: [KbqCodeBlockModule],
158+
template: `
159+
<kbq-code-block [files]="files" [maxHeight]="maxHeight" />
160+
`,
161+
changeDetection: ChangeDetectionStrategy.Default
162+
})
163+
class MaxHeightCodeBlock {
164+
maxHeight = 200;
165+
files: KbqCodeBlockFile[] = [
166+
{
167+
language: 'html',
168+
filename: 'index.html',
169+
content: `<!DOCTYPE html>\n<html lang="en">\n\t<head>\n\t\t<title>Koobiq</title>\n\t</head>\n\t<body>\n\t\t<app-root>Loading...</app-root>\n\t</body>\n</html>`
170+
}
171+
];
172+
}
173+
136174
describe(KbqCodeBlock.name, () => {
137175
it('should hide lineNumbers', () => {
138176
const fixture = createComponent(BaseCodeBlock);
@@ -498,24 +536,32 @@ describe(KbqCodeBlock.name, () => {
498536
expect(codeBlock.classes['kbq-code-block_show-actionbar']).toBeTruthy();
499537
}));
500538

501-
it('should show viewAll button', () => {
502-
const fixture = createComponent(BaseCodeBlock);
503-
const { debugElement, componentInstance } = fixture;
539+
it('should show viewAll button when content overflows maxHeight', () => {
540+
const mockResizeObserver = new MockSharedResizeObserver();
541+
const fixture = createComponent(MaxHeightCodeBlock, [
542+
{ provide: SharedResizeObserver, useValue: mockResizeObserver }
543+
]);
544+
const { debugElement } = fixture;
504545

505546
expect(getViewAllButtonElement(debugElement)).toBeNull();
506547

507-
componentInstance.maxHeight = 300;
548+
mockPreHeight(debugElement, 500);
549+
mockResizeObserver.triggerResize();
508550
fixture.detectChanges();
509551

510552
expect(getViewAllButtonElement(debugElement)).toBeInstanceOf(HTMLButtonElement);
511553
});
512554

513555
it('should toggle viewAll property by click', () => {
514-
const fixture = createComponent(BaseCodeBlock);
515-
const { debugElement, componentInstance } = fixture;
556+
const mockResizeObserver = new MockSharedResizeObserver();
557+
const fixture = createComponent(MaxHeightCodeBlock, [
558+
{ provide: SharedResizeObserver, useValue: mockResizeObserver }
559+
]);
560+
const { debugElement } = fixture;
516561
const spy = jest.spyOn(geCodeBlockDebugElement(debugElement).componentInstance, 'toggleViewAll');
517562

518-
componentInstance.maxHeight = 300;
563+
mockPreHeight(debugElement, 500);
564+
mockResizeObserver.triggerResize();
519565
fixture.detectChanges();
520566

521567
getViewAllButtonElement(debugElement).click();
@@ -524,18 +570,65 @@ describe(KbqCodeBlock.name, () => {
524570
});
525571

526572
it('should toggle viewAll property by ENTER keydown', () => {
527-
const fixture = createComponent(BaseCodeBlock);
528-
const { debugElement, componentInstance } = fixture;
573+
const mockResizeObserver = new MockSharedResizeObserver();
574+
const fixture = createComponent(MaxHeightCodeBlock, [
575+
{ provide: SharedResizeObserver, useValue: mockResizeObserver }
576+
]);
577+
const { debugElement } = fixture;
529578
const spy = jest.spyOn(geCodeBlockDebugElement(debugElement).componentInstance, 'toggleViewAll');
530579

531-
componentInstance.maxHeight = 300;
580+
mockPreHeight(debugElement, 500);
581+
mockResizeObserver.triggerResize();
532582
fixture.detectChanges();
533583

534584
getViewAllButtonElement(debugElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
535585

536586
expect(spy).toHaveBeenCalledTimes(1);
537587
});
538588

589+
describe('viewAll button visibility', () => {
590+
it('should not show viewAll button when content fits within maxHeight', () => {
591+
const fixture = createComponent(MaxHeightCodeBlock, [
592+
{ provide: SharedResizeObserver, useValue: new MockSharedResizeObserver() }
593+
]);
594+
595+
// jsdom returns offsetHeight = 0 by default, which is less than maxHeight = 200
596+
expect(getViewAllButtonElement(fixture.debugElement)).toBeNull();
597+
});
598+
599+
it('should show viewAll button when content overflows maxHeight', () => {
600+
const mockResizeObserver = new MockSharedResizeObserver();
601+
const fixture = createComponent(MaxHeightCodeBlock, [
602+
{ provide: SharedResizeObserver, useValue: mockResizeObserver }
603+
]);
604+
const { debugElement } = fixture;
605+
606+
mockPreHeight(debugElement, 500);
607+
mockResizeObserver.triggerResize();
608+
fixture.detectChanges();
609+
610+
expect(getViewAllButtonElement(debugElement)).toBeInstanceOf(HTMLButtonElement);
611+
});
612+
613+
it('should hide viewAll button when content shrinks below maxHeight', () => {
614+
const mockResizeObserver = new MockSharedResizeObserver();
615+
const fixture = createComponent(MaxHeightCodeBlock, [
616+
{ provide: SharedResizeObserver, useValue: mockResizeObserver }
617+
]);
618+
const { debugElement } = fixture;
619+
620+
mockPreHeight(debugElement, 500);
621+
mockResizeObserver.triggerResize();
622+
fixture.detectChanges();
623+
expect(getViewAllButtonElement(debugElement)).toBeInstanceOf(HTMLButtonElement);
624+
625+
mockPreHeight(debugElement, 100);
626+
mockResizeObserver.triggerResize();
627+
fixture.detectChanges();
628+
expect(getViewAllButtonElement(debugElement)).toBeNull();
629+
});
630+
});
631+
539632
it('should use template for tabLink when provided', () => {
540633
const fixture = createComponent(TestCodeBlockWithTemplateTabLink);
541634
const { debugElement, componentInstance } = fixture;

packages/components/code-block/code-block.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { A11yModule, FocusMonitor } from '@angular/cdk/a11y';
22
import { Clipboard } from '@angular/cdk/clipboard';
3+
import { SharedResizeObserver } from '@angular/cdk/observers/private';
34
import { Platform } from '@angular/cdk/platform';
45
import { CdkScrollable, CdkScrollableModule, ExtendedScrollToOptions } from '@angular/cdk/scrolling';
56
import { DOCUMENT, NgTemplateOutlet } from '@angular/common';
@@ -23,6 +24,7 @@ import {
2324
Provider,
2425
Renderer2,
2526
SecurityContext,
27+
signal,
2628
TemplateRef,
2729
ViewChild,
2830
ViewEncapsulation
@@ -110,15 +112,19 @@ export class KbqCodeBlock implements AfterViewInit {
110112
/**
111113
* Reference to the scrollable code content.
112114
*
113-
* @deprecated Use `scrollTo` method instead.
115+
* @deprecated Use `scrollTo` method instead, will be removed from public API (mark as private) in the next major release.
114116
*
115117
* @docs-private
116118
*/
117119
@ViewChild(CdkScrollable) readonly scrollableCodeContent: CdkScrollable;
118120

119-
/** @docs-private */
120121
@ViewChild(KbqCodeBlockHighlight) private readonly highlight!: KbqCodeBlockHighlight;
121122

123+
@ViewChild('codeBlockPre') private readonly preElementRef!: ElementRef<HTMLElement>;
124+
125+
/** @docs-private */
126+
protected readonly contentExceedsMaxHeight = signal(false);
127+
122128
/** @docs-private */
123129
@ContentChild(KbqCodeBlockTabLinkContent, { read: TemplateRef })
124130
protected readonly tabLinkTemplate: TemplateRef<KbqTabLinkTemplateContext>;
@@ -298,6 +304,7 @@ export class KbqCodeBlock implements AfterViewInit {
298304
private readonly clipboard = inject(Clipboard);
299305
private readonly domSanitizer = inject(DomSanitizer);
300306
private readonly document = inject<Document>(DOCUMENT);
307+
private readonly sharedResizeObserver = inject(SharedResizeObserver);
301308
/**
302309
* @docs-private
303310
*/
@@ -311,6 +318,7 @@ export class KbqCodeBlock implements AfterViewInit {
311318
ngAfterViewInit(): void {
312319
this.handleScroll();
313320
this.trackHoverState();
321+
this.setupContentOverflowDetection();
314322

315323
// Setup initial actionbar display state
316324
this.setupActionbarDisplay();
@@ -413,6 +421,30 @@ export class KbqCodeBlock implements AfterViewInit {
413421
});
414422
}
415423

424+
private setupContentOverflowDetection(): void {
425+
if (!this.maxHeight) return;
426+
427+
const checkOverflow = () => {
428+
this.contentExceedsMaxHeight.set(this.preElementRef.nativeElement.offsetHeight > this.maxHeight);
429+
};
430+
431+
checkOverflow();
432+
433+
if (this.highlight?.pending()) {
434+
toObservable(this.highlight.pending, { injector: this.injector })
435+
.pipe(
436+
filter((pending) => !pending),
437+
take(1)
438+
)
439+
.subscribe(checkOverflow);
440+
}
441+
442+
this.sharedResizeObserver
443+
.observe(this.preElementRef.nativeElement)
444+
.pipe(takeUntilDestroyed(this.destroyRef))
445+
.subscribe(checkOverflow);
446+
}
447+
416448
/**
417449
* Handles the scroll event on the scrollable code content element and updates the header shadow accordingly.
418450
*/

packages/components/code-block/e2e.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,23 @@ import { KbqCodeBlock, KbqCodeBlockFile } from '@koobiq/components/code-block';
4949
5050
<!-- maxHeight -->
5151
<div>
52-
<kbq-code-block maxHeight="200" [files]="[cssFile, htmlFile, typescriptFile]" />
52+
<kbq-code-block
53+
maxHeight="200"
54+
data-testid="e2eCodeBlockMaxHeight"
55+
[files]="[cssFile, htmlFile, typescriptFile]"
56+
/>
5357
</div>
5458
5559
<!-- maxHeight filled -->
5660
<div>
5761
<kbq-code-block maxHeight="200" filled [files]="[cssFile, htmlFile]" />
5862
</div>
5963
64+
<!-- maxHeight single-line -->
65+
<div>
66+
<kbq-code-block maxHeight="200" data-testid="e2eCodeBlockMaxHeightSingleLine" [files]="[singleLineFile]" />
67+
</div>
68+
6069
<!-- noBorder hideTabs lineNumbers -->
6170
<div>
6271
<kbq-code-block lineNumbers noBorder hideTabs [files]="[typescriptFile]" />

tools/public_api_guard/components/code-block.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { LanguageFn } from 'highlight.js';
1818
import { Provider } from '@angular/core';
1919
import { Signal } from '@angular/core';
2020
import { TemplateRef } from '@angular/core';
21+
import { WritableSignal } from '@angular/core';
2122

2223
// @public
2324
export const KBQ_CODE_BLOCK_FALLBACK_FILE_LANGUAGE: InjectionToken<string>;
@@ -54,6 +55,7 @@ export class KbqCodeBlock implements AfterViewInit {
5455
// @deprecated (undocumented)
5556
set codeFiles(files: KbqCodeBlockFile[]);
5657
protected readonly componentColor: typeof KbqComponentColors;
58+
protected readonly contentExceedsMaxHeight: WritableSignal<boolean>;
5759
protected copyCode(): void;
5860
protected downloadCode(): void;
5961
protected readonly fallbackFileName: string;

0 commit comments

Comments
 (0)