diff --git a/packages/components/code-block/code-block.en.md b/packages/components/code-block/code-block.en.md index a367a086e6..d9e12bbd4a 100644 --- a/packages/components/code-block/code-block.en.md +++ b/packages/components/code-block/code-block.en.md @@ -86,7 +86,21 @@ When the code block should fill an entire container or screen, it is best to use ### Action panel -The action panel is located in the upper right corner of the block, visible on hover or when one of the buttons is focused, and stays fixed during scrolling. The component configuration determines which actions are available. +The action panel is located in the upper-right corner of the block and remains fixed while scrolling. The component configuration determines which actions are available. + +The panel is always visible when tabs are shown, as well as on iOS and Android devices. When tabs are hidden, it appears on hover on other devices. + +The `alwaysShowActionbar` attribute keeps the panel visible regardless of tabs or hover. It is disabled by default. + +```html + +``` + +To enable the option for all code blocks within an injector scope, use the provider: + +```typescript +providers: [kbqCodeBlockDefaultOptionsProvider({ alwaysShowActionbar: true })]; +``` #### Changing wrap mode diff --git a/packages/components/code-block/code-block.ru.md b/packages/components/code-block/code-block.ru.md index c5ff2f907e..07fe9631cb 100644 --- a/packages/components/code-block/code-block.ru.md +++ b/packages/components/code-block/code-block.ru.md @@ -86,7 +86,21 @@ providers: [ ### Панель действий -Панель действий расположена в правом верхнем углу блока, она видна по ховеру на блок или при фокусе на одну из кнопок, залипает при прокрутке. Конфигурация компонента определяет, какие действия будут доступны. +Панель действий расположена в правом верхнем углу блока и остается на месте при прокрутке. Конфигурация компонента определяет, какие действия будут доступны. + +При видимых вкладках, а также на устройствах iOS и Android панель отображается постоянно. Если вкладки скрыты, на остальных устройствах панель появляется при наведении на блок. + +Атрибут `alwaysShowActionbar` позволяет отображать панель постоянно независимо от наличия вкладок и наведения. По умолчанию он выключен. + +```html + +``` + +Чтобы включить настройку для всех блоков кода, зарегистрируйте провайдер: + +```typescript +providers: [kbqCodeBlockDefaultOptionsProvider({ alwaysShowActionbar: true })]; +``` #### Изменение режима переноса diff --git a/packages/components/code-block/code-block.spec.ts b/packages/components/code-block/code-block.spec.ts index 9a5007c884..25a3da863e 100644 --- a/packages/components/code-block/code-block.spec.ts +++ b/packages/components/code-block/code-block.spec.ts @@ -7,7 +7,12 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { KbqTabNavBar } from '@koobiq/components/tabs'; import { HLJSApi } from 'highlight.js'; import { Observable, Subject } from 'rxjs'; -import { KBQ_CODE_BLOCK_FALLBACK_FILE_NAME, KbqCodeBlock, kbqCodeBlockLocaleConfigurationProvider } from './code-block'; +import { + KBQ_CODE_BLOCK_FALLBACK_FILE_NAME, + KbqCodeBlock, + kbqCodeBlockDefaultOptionsProvider, + kbqCodeBlockLocaleConfigurationProvider +} from './code-block'; import { KBQ_CODE_BLOCK_FALLBACK_FILE_LANGUAGE, KbqCodeBlockHighlight, @@ -92,6 +97,7 @@ const mockPreHeight = (debugElement: DebugElement, height: number): void => { [canDownload]="canDownload" [noBorder]="noBorder" [hideTabs]="hideTabs" + [alwaysShowActionbar]="alwaysShowActionbar" [canCopy]="canCopy" [maxHeight]="maxHeight" [(activeFileIndex)]="activeFileIndex" @@ -126,10 +132,22 @@ class BaseCodeBlock { activeFileIndex: number = 0; noBorder: boolean = false; hideTabs: boolean = false; + alwaysShowActionbar: boolean = false; softWrap: boolean = false; maxHeight: number | undefined = undefined; } +@Component({ + imports: [KbqCodeBlockModule], + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +class CodeBlockWithDefaultOptions { + readonly files: KbqCodeBlockFile[] = [{ language: 'typescript', filename: 'main.ts', content: 'const value = 1;' }]; +} + @Component({ imports: [KbqCodeBlockModule], template: ` @@ -515,6 +533,107 @@ describe(KbqCodeBlock.name, () => { expect(codeBlock.classes['kbq-code-block_show-actionbar']).toBeFalsy(); })); + it('should always show actionbar when alwaysShowActionbar is enabled', fakeAsync(() => { + const fixture = createComponent(BaseCodeBlock); + const { debugElement, componentInstance } = fixture; + const codeBlock = geCodeBlockDebugElement(debugElement); + + componentInstance.hideTabs = true; + componentInstance.alwaysShowActionbar = true; + fixture.detectChanges(); + expect(codeBlock.classes['kbq-code-block_show-actionbar']).toBeTruthy(); + + codeBlock.nativeElement.dispatchEvent(new MouseEvent('mouseleave')); + tick(HOVER_DEBOUNCE_TIME); + expect(codeBlock.classes['kbq-code-block_show-actionbar']).toBeTruthy(); + })); + + it('should use alwaysShowActionbar from default options', () => { + const { debugElement } = createComponent(CodeBlockWithDefaultOptions, [ + kbqCodeBlockDefaultOptionsProvider({ alwaysShowActionbar: true }) + ]); + const codeBlock = geCodeBlockDebugElement(debugElement); + + expect(codeBlock.componentInstance.alwaysShowActionbar()).toBeTruthy(); + expect(codeBlock.classes['kbq-code-block_show-actionbar']).toBeTruthy(); + }); + + it('should override alwaysShowActionbar from default options with input', () => { + const fixture = createComponent(BaseCodeBlock, [ + kbqCodeBlockDefaultOptionsProvider({ alwaysShowActionbar: true }) + ]); + const { debugElement, componentInstance } = fixture; + const codeBlock = geCodeBlockDebugElement(debugElement); + + componentInstance.hideTabs = true; + fixture.detectChanges(); + + expect(codeBlock.componentInstance.alwaysShowActionbar()).toBeFalsy(); + expect(codeBlock.classes['kbq-code-block_show-actionbar']).toBeFalsy(); + }); + + it('should not track hover when alwaysShowActionbar is enabled', () => { + const addEventListenerSpy = jest.spyOn(HTMLElement.prototype, 'addEventListener'); + + try { + TestBed.configureTestingModule({ imports: [BaseCodeBlock, NoopAnimationsModule] }); + const fixture = TestBed.createComponent(BaseCodeBlock); + + fixture.componentInstance.hideTabs = true; + fixture.componentInstance.alwaysShowActionbar = true; + fixture.detectChanges(); + + const codeBlockElement = geCodeBlockDebugElement(fixture.debugElement).nativeElement; + const hostHoverListeners = addEventListenerSpy.mock.calls.filter( + ([eventName], index) => + addEventListenerSpy.mock.contexts[index] === codeBlockElement && + (eventName === 'mouseenter' || eventName === 'mouseleave') + ); + + expect(hostHoverListeners).toHaveLength(0); + } finally { + addEventListenerSpy.mockRestore(); + } + }); + + it('should start tracking hover when alwaysShowActionbar is disabled', fakeAsync(() => { + TestBed.configureTestingModule({ imports: [BaseCodeBlock, NoopAnimationsModule] }); + const fixture = TestBed.createComponent(BaseCodeBlock); + + fixture.componentInstance.hideTabs = true; + fixture.componentInstance.alwaysShowActionbar = true; + fixture.detectChanges(); + + fixture.componentInstance.alwaysShowActionbar = false; + fixture.detectChanges(); + + const codeBlock = geCodeBlockDebugElement(fixture.debugElement); + + codeBlock.nativeElement.dispatchEvent(new MouseEvent('mouseenter')); + tick(HOVER_DEBOUNCE_TIME); + fixture.detectChanges(); + expect(codeBlock.classes['kbq-code-block_show-actionbar']).toBeTruthy(); + })); + + it('should restore hover behavior when alwaysShowActionbar is disabled', fakeAsync(() => { + const fixture = createComponent(BaseCodeBlock); + const { debugElement, componentInstance } = fixture; + const codeBlock = geCodeBlockDebugElement(debugElement); + + componentInstance.hideTabs = true; + componentInstance.alwaysShowActionbar = true; + fixture.detectChanges(); + expect(codeBlock.classes['kbq-code-block_show-actionbar']).toBeTruthy(); + + componentInstance.alwaysShowActionbar = false; + fixture.detectChanges(); + expect(codeBlock.classes['kbq-code-block_show-actionbar']).toBeFalsy(); + + codeBlock.nativeElement.dispatchEvent(new MouseEvent('mouseenter')); + tick(HOVER_DEBOUNCE_TIME); + expect(codeBlock.classes['kbq-code-block_show-actionbar']).toBeTruthy(); + })); + it('should stop tracking hover when hideTabs changes to false', fakeAsync(() => { const fixture = createComponent(BaseCodeBlock); const { debugElement, componentInstance } = fixture; diff --git a/packages/components/code-block/code-block.ts b/packages/components/code-block/code-block.ts index 44f91736e6..4a08e217bf 100644 --- a/packages/components/code-block/code-block.ts +++ b/packages/components/code-block/code-block.ts @@ -10,9 +10,11 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + computed, ContentChild, DestroyRef, Directive, + effect, ElementRef, inject, InjectionToken, @@ -22,14 +24,13 @@ import { numberAttribute, output, Provider, - Renderer2, SecurityContext, signal, TemplateRef, viewChild, ViewEncapsulation } from '@angular/core'; -import { outputToObservable, takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { DomSanitizer } from '@angular/platform-browser'; import { KbqButtonModule, KbqButtonStyles } from '@koobiq/components/button'; import { @@ -44,7 +45,7 @@ import { import { KbqIconModule } from '@koobiq/components/icon'; import { KbqTabsModule } from '@koobiq/components/tabs'; import { KbqToolTipModule, KbqTooltipTrigger } from '@koobiq/components/tooltip'; -import { debounceTime, EMPTY, filter, fromEvent, merge, startWith, switchMap, take } from 'rxjs'; +import { debounceTime, filter, fromEvent, map, merge, take } from 'rxjs'; import { KbqCodeBlockHighlight } from './code-block-highlight'; import { KbqCodeBlockFile, KbqTabLinkTemplateContext } from './types'; @@ -71,6 +72,23 @@ export const kbqCodeBlockFallbackFileNameProvider = (fileName: string): Provider useValue: fileName }); +/** Default options for `kbq-code-block`. */ +export type KbqCodeBlockDefaultOptions = Partial<{ + /** Whether the actionbar should remain visible when tabs are hidden. */ + alwaysShowActionbar: boolean; +}>; + +/** Injection token used to configure the default options for all `kbq-code-block` components. */ +export const KBQ_CODE_BLOCK_DEFAULT_OPTIONS = new InjectionToken( + 'KBQ_CODE_BLOCK_DEFAULT_OPTIONS' +); + +/** Utility provider for `KBQ_CODE_BLOCK_DEFAULT_OPTIONS`. */ +export const kbqCodeBlockDefaultOptionsProvider = (options: KbqCodeBlockDefaultOptions): Provider => ({ + provide: KBQ_CODE_BLOCK_DEFAULT_OPTIONS, + useValue: options +}); + /** Marks a template as a custom tab link. */ @Directive({ selector: 'ng-template[kbqCodeBlockTabLinkContent]', @@ -109,6 +127,7 @@ export class KbqCodeBlockTabLinkContent {} '[class.kbq-code-block_hide-line-numbers]': '!lineNumbers()', '[class.kbq-code-block_hide-tabs]': 'hideTabs', '[class.kbq-code-block_no-border]': 'noBorder() || filled()', + '[class.kbq-code-block_show-actionbar]': 'actionbarVisible()', '[class.kbq-code-block_soft-wrap]': 'softWrap', '[class.kbq-code-block_view-all]': 'viewAll' }, @@ -116,6 +135,7 @@ export class KbqCodeBlockTabLinkContent {} }) export class KbqCodeBlock implements AfterViewInit { private readonly copyButtonTooltip = viewChild('copyButtonTooltip'); + private readonly defaultOptions = inject(KBQ_CODE_BLOCK_DEFAULT_OPTIONS, { optional: true }); /** * Reference to the scrollable code content. * @@ -203,6 +223,11 @@ export class KbqCodeBlock implements AfterViewInit { /** Added copy code button. */ readonly canCopy = input(true, { transform: booleanAttribute }); + /** Whether the actionbar should remain visible when tabs are hidden. */ + readonly alwaysShowActionbar = input(this.defaultOptions?.alwaysShowActionbar ?? false, { + transform: booleanAttribute + }); + /** * @deprecated Will be removed in next major release, use `files` instead. */ @@ -261,16 +286,16 @@ export class KbqCodeBlock implements AfterViewInit { // Accessor inputs cannot be migrated as they are too complex. @Input({ transform: booleanAttribute }) get hideTabs(): boolean { - return this._hideTabs; + return this._hideTabs(); } set hideTabs(value: boolean) { - this._hideTabs = value; + this._hideTabs.set(value); this.hideTabsChange.emit(value); - this.setupActionbarDisplay(); } - private _hideTabs: boolean = false; + private readonly _hideTabs = signal(false); + private readonly actionbarHovered = signal(false); /** * Output to support two-way binding on `[(hideTabs)]` property. @@ -325,7 +350,6 @@ export class KbqCodeBlock implements AfterViewInit { private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly localeService = inject(KBQ_LOCALE_SERVICE, { optional: true }); private readonly destroyRef = inject(DestroyRef); - private readonly renderer = inject(Renderer2); private readonly platform = inject(Platform); private readonly focusMonitor = inject(FocusMonitor); private readonly clipboard = inject(Clipboard); @@ -338,17 +362,24 @@ export class KbqCodeBlock implements AfterViewInit { protected readonly fallbackFileName = inject(KBQ_CODE_BLOCK_FALLBACK_FILE_NAME); private readonly window = inject(KBQ_WINDOW); + /** @docs-private */ + protected readonly actionbarVisible = computed( + () => + this.alwaysShowActionbar() || + this.platform.IOS || + this.platform.ANDROID || + !this._hideTabs() || + this.actionbarHovered() + ); + constructor() { + this.trackHoverState(); this.localeService?.changes.pipe(takeUntilDestroyed()).subscribe(this.updateLocaleParams); } ngAfterViewInit(): void { - this.trackHoverState(); this.setupContentOverflowDetection(); - // Setup initial actionbar display state - this.setupActionbarDisplay(); - this.copyButtonTooltip() ?.visibleChange.pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((isVisible) => { @@ -427,27 +458,30 @@ export class KbqCodeBlock implements AfterViewInit { } } - /** - * Tracks hover events to show/hide the actionbar when `hideTabs` is `true`. - * Reacts to `hideTabs` changes dynamically. - */ + /** Tracks hover when tabs are hidden and `alwaysShowActionbar` is disabled. */ private trackHoverState(): void { - outputToObservable(this.hideTabsChange) - .pipe( - startWith(this._hideTabs), - switchMap((hideTabs) => { - if (!hideTabs) return EMPTY; - - return merge( - fromEvent(this.elementRef.nativeElement, 'mouseenter'), - fromEvent(this.elementRef.nativeElement, 'mouseleave') - ).pipe(debounceTime(100)); - }), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe((event) => { - this.setupActionbarDisplay(event?.type === 'mouseenter'); - }); + effect( + (onCleanup) => { + const hideTabs = this._hideTabs(); + const alwaysShowActionbar = this.alwaysShowActionbar(); + + this.actionbarHovered.set(false); + + if (!hideTabs || alwaysShowActionbar || this.platform.IOS || this.platform.ANDROID) return; + + const subscription = merge( + fromEvent(this.elementRef.nativeElement, 'mouseenter').pipe(map(() => true)), + fromEvent(this.elementRef.nativeElement, 'mouseleave').pipe(map(() => false)) + ) + .pipe(debounceTime(100)) + .subscribe((isHovered) => { + this.actionbarHovered.set(isHovered); + }); + + onCleanup(() => subscription.unsubscribe()); + }, + { injector: this.injector } + ); } private setupContentOverflowDetection(): void { @@ -478,42 +512,6 @@ export class KbqCodeBlock implements AfterViewInit { .subscribe(checkOverflow); } - /** - * Adds or removes the actionbar display class from the code block based on the specified condition. - * - * The actionbar is always visible on mobile devices and when the tabs are visible. - * Otherwise, the actionbar is only visible when the mouse is hovered over the code block. - * - * @param shouldShowActionbar - A boolean indicating whether the actionbar should be visible. - */ - private setupActionbarDisplay(shouldShowActionbar?: boolean): void { - const className = 'kbq-code-block_show-actionbar'; - - // Should always show actionbar on Mobile devices - if (this.platform.IOS || this.platform.ANDROID) { - this.renderer.addClass(this.elementRef.nativeElement, className); - - return; - } - - // Should always show actionbar when tabs are visible - if (!this.hideTabs) { - this.renderer.addClass(this.elementRef.nativeElement, className); - - return; - } - - if (typeof shouldShowActionbar === 'undefined') { - return; - } - - if (shouldShowActionbar) { - this.renderer.addClass(this.elementRef.nativeElement, className); - } else { - this.renderer.removeClass(this.elementRef.nativeElement, className); - } - } - /** Whether the element has scroll. */ private hasScroll({ scrollHeight, scrollWidth, clientHeight, clientWidth }: HTMLElement): boolean { return scrollHeight > clientHeight || scrollWidth > clientWidth; diff --git a/packages/docs-examples/components/code-block/code-block-with-filled/code-block-with-filled-example.ts b/packages/docs-examples/components/code-block/code-block-with-filled/code-block-with-filled-example.ts index 3cb749b5e8..096c814ada 100644 --- a/packages/docs-examples/components/code-block/code-block-with-filled/code-block-with-filled-example.ts +++ b/packages/docs-examples/components/code-block/code-block-with-filled/code-block-with-filled-example.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, model } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { KbqCodeBlockFile, @@ -18,8 +18,10 @@ import { KbqToggleModule } from '@koobiq/components/toggle'; FormsModule ], template: ` - Filled - + Filled + Always show actionbar + + `, providers: [ kbqCodeBlockHighlightJsConfigProvider({ @@ -39,5 +41,6 @@ export class CodeBlockWithFilledExample { } ]; - filled: boolean = true; + readonly filled = model(true); + readonly alwaysShowActionbar = model(false); } diff --git a/tools/public_api_guard/components/code-block.api.md b/tools/public_api_guard/components/code-block.api.md index 130ed5a3f1..05fb0eca39 100644 --- a/tools/public_api_guard/components/code-block.api.md +++ b/tools/public_api_guard/components/code-block.api.md @@ -17,6 +17,11 @@ import { LanguageFn } from 'highlight.js'; import { Provider } from '@angular/core'; import { TemplateRef } from '@angular/core'; +// @public +export const KBQ_CODE_BLOCK_DEFAULT_OPTIONS: InjectionToken>; + // @public export const KBQ_CODE_BLOCK_FALLBACK_FILE_LANGUAGE: InjectionToken; @@ -39,8 +44,10 @@ export const KBQ_CODE_BLOCK_LOCALE_CONFIGURATION: InjectionToken; activeFileIndex: number; readonly activeFileIndexChange: _angular_core.OutputEmitterRef; + readonly alwaysShowActionbar: _angular_core.InputSignalWithTransform; protected readonly buttonStyle: typeof KbqButtonStyles; protected get calculatedMaxHeight(): number | null; readonly canCopy: _angular_core.InputSignalWithTransform; @@ -94,11 +101,19 @@ export class KbqCodeBlock implements AfterViewInit { viewAll: boolean; readonly viewAllChange: _angular_core.OutputEmitterRef; // (undocumented) - static ɵcmp: _angular_core.ɵɵComponentDeclaration; + static ɵcmp: _angular_core.ɵɵComponentDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } +// @public +export type KbqCodeBlockDefaultOptions = Partial<{ + alwaysShowActionbar: boolean; +}>; + +// @public +export const kbqCodeBlockDefaultOptionsProvider: (options: KbqCodeBlockDefaultOptions) => Provider; + // @public export const kbqCodeBlockFallbackFileLanguageProvider: (language: string) => Provider;