diff --git a/packages/components/autocomplete/__screenshots__/01-dark.png b/packages/components/autocomplete/__screenshots__/01-dark.png index dc15d0059b..e5ff739d04 100644 Binary files a/packages/components/autocomplete/__screenshots__/01-dark.png and b/packages/components/autocomplete/__screenshots__/01-dark.png differ diff --git a/packages/components/autocomplete/__screenshots__/01-light.png b/packages/components/autocomplete/__screenshots__/01-light.png index 30dbc0e665..811ecfe616 100644 Binary files a/packages/components/autocomplete/__screenshots__/01-light.png and b/packages/components/autocomplete/__screenshots__/01-light.png differ diff --git a/packages/components/autocomplete/__screenshots__/02-light.png b/packages/components/autocomplete/__screenshots__/02-light.png new file mode 100644 index 0000000000..9f06e018c3 Binary files /dev/null and b/packages/components/autocomplete/__screenshots__/02-light.png differ diff --git a/packages/components/autocomplete/autocomplete.component.ts b/packages/components/autocomplete/autocomplete.component.ts index 0c4124d070..b0765b5973 100644 --- a/packages/components/autocomplete/autocomplete.component.ts +++ b/packages/components/autocomplete/autocomplete.component.ts @@ -20,7 +20,7 @@ import { viewChild, ViewEncapsulation } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { outputToObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActiveDescendantKeyManager, KBQ_OPTION_PARENT_COMPONENT, @@ -31,6 +31,7 @@ import { KbqPanelWidth } from '@koobiq/components/core'; import { KBQ_FORM_FIELD } from '@koobiq/components/form-field'; +import { KbqScrollbarViewport } from '@koobiq/components/scrollbar'; import { delay, filter } from 'rxjs/operators'; /** @@ -74,7 +75,7 @@ export function KBQ_AUTOCOMPLETE_DEFAULT_OPTIONS_FACTORY(): KbqAutocompleteDefau @Component({ selector: 'kbq-autocomplete', - imports: [], + imports: [KbqScrollbarViewport], templateUrl: 'autocomplete.html', styleUrls: ['autocomplete.scss', 'autocomplete-tokens.scss'], providers: [ @@ -111,6 +112,9 @@ export class KbqAutocomplete implements AfterContentInit { readonly panel = viewChild.required('panel'); + /** The panel's custom scrollbar viewport, flashed when the panel opens. */ + private readonly scrollbarViewport = viewChild(KbqScrollbarViewport); + @ContentChildren(KbqOption, { descendants: true }) options: QueryList; readonly optionGroups = contentChildren(KbqOptgroup); @@ -219,6 +223,12 @@ export class KbqAutocomplete implements AfterContentInit { const defaults = inject(KBQ_AUTOCOMPLETE_DEFAULT_OPTIONS); this._autoActiveFirstOption = !!defaults.autoActiveFirstOption; + + // Briefly reveal the scrollbar on open to hint that the list is scrollable — the panel may open + // without scrolling, so the hover track would otherwise stay hidden. + outputToObservable(this.opened) + .pipe(takeUntilDestroyed()) + .subscribe(() => this.scrollbarViewport()?.flashScrollIndicators()); } ngAfterContentInit() { diff --git a/packages/components/autocomplete/autocomplete.html b/packages/components/autocomplete/autocomplete.html index 078e846180..4025606d18 100644 --- a/packages/components/autocomplete/autocomplete.html +++ b/packages/components/autocomplete/autocomplete.html @@ -1,6 +1,6 @@
-
+
diff --git a/packages/components/autocomplete/autocomplete.spec.ts b/packages/components/autocomplete/autocomplete.spec.ts index 49acb1acf3..c4805b5bcf 100644 --- a/packages/components/autocomplete/autocomplete.spec.ts +++ b/packages/components/autocomplete/autocomplete.spec.ts @@ -1076,7 +1076,11 @@ describe('KbqAutocomplete', () => { const fixture = createComponent(SimpleAutocomplete, [ { provide: ScrollDispatcher, - useValue: { scrolled: () => scrolledSubject.asObservable() } + useValue: { + scrolled: () => scrolledSubject.asObservable(), + register: () => {}, + deregister: () => {} + } } ]); diff --git a/packages/components/autocomplete/e2e.playwright-spec.ts b/packages/components/autocomplete/e2e.playwright-spec.ts index 63fb742caa..8afca24655 100644 --- a/packages/components/autocomplete/e2e.playwright-spec.ts +++ b/packages/components/autocomplete/e2e.playwright-spec.ts @@ -161,6 +161,59 @@ test.describe('KbqAutocompleteModule', () => { }); }); + test.describe('E2eAutocompleteScrollbar', () => { + const getInput = (page: Page) => page.getByTestId('e2eAutocompleteInput'); + const getContent = (page: Page) => page.locator('.kbq-autocomplete-panel__content'); + const getTrack = (page: Page) => getContent(page).locator('kbq-scrollbar-track'); + const getVerticalThumb = (page: Page) => + getContent(page).locator('.kbq-scrollbar-track__bar_vertical .kbq-scrollbar-track__thumb'); + + test.beforeEach(async ({ page }) => { + await page.goto('/E2eAutocompleteScrollbar'); + await getInput(page).focus(); + await expect(getContent(page)).toBeVisible(); + }); + + test('flashes the track on open, then fades it', async ({ page }) => { + // The panel opens without scrolling, so the open-flash is the only thing that reveals the + // track here — no hover, no scroll. + const track = getTrack(page); + + await expect(track).toHaveCSS('opacity', '1'); + // ...and it fades back out again after the hide delay. + await expect(track).toHaveCSS('opacity', '0'); + }); + + test('hides the native scrollbar and reveals the custom track on hover', async ({ page }) => { + await expect(getContent(page)).toHaveClass(/kbq-scrollbar-viewport_native-scrollbar-hidden/); + + const track = getTrack(page); + + await expect(track).toBeAttached(); + // Wait out the open-flash so hover is tested in isolation. + await expect(track).toHaveCSS('opacity', '0'); + + await getContent(page).hover(); + await expect(track).toHaveCSS('opacity', '1'); + }); + + test('clicking the scrollbar thumb keeps the panel open', async ({ page }) => { + await getContent(page).hover(); + await getVerticalThumb(page).click(); + + await expect(getContent(page)).toBeVisible(); + }); + + test('renders the custom scrollbar', async ({ page }) => { + const track = getTrack(page); + + // Hover keeps the hover track revealed (opacity 1) deterministically for the screenshot. + await getContent(page).hover(); + await expect(track).toHaveCSS('opacity', '1'); + await expect(getContent(page)).toHaveScreenshot('02-light.png'); + }); + }); + test.describe('Scroll strategy: close', () => { const getInput = (page: Page) => page.getByTestId('e2eAutocompleteInput'); const getPanel = (page: Page) => page.locator('.kbq-autocomplete-panel'); diff --git a/packages/components/autocomplete/e2e.ts b/packages/components/autocomplete/e2e.ts index 8d11036b5b..7368b19128 100644 --- a/packages/components/autocomplete/e2e.ts +++ b/packages/components/autocomplete/e2e.ts @@ -178,3 +178,38 @@ export class E2eAutocompleteExpandOnResults { export class E2eAutocompleteScrollClose { protected readonly options = Array.from({ length: 8 }).map((_, i) => `Option ${i + 1}`); } + +@Component({ + selector: 'e2e-autocomplete-scrollbar', + imports: [KbqInputModule, KbqAutocompleteModule], + template: ` + + + + + @for (option of options; track $index) { + {{ option }} + } + + + `, + styles: ` + :host { + display: inline-flex; + padding: var(--kbq-size-l); + height: 400px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2eAutocompleteScrollbar' + } +}) +export class E2eAutocompleteScrollbar { + protected readonly options = Array.from({ length: 40 }).map((_, i) => `Option ${i}`); +} diff --git a/packages/components/code-block/code-block.html b/packages/components/code-block/code-block.html index dc0a28b13d..0ed1697627 100644 --- a/packages/components/code-block/code-block.html +++ b/packages/components/code-block/code-block.html @@ -97,9 +97,10 @@ #codeBlockContent="kbqOverflowShadowContainer" cdkMonitorElementFocus cdkScrollable + kbqNativeScrollbar kbqOverflowShadowContainer kbqTabNavPanel - class="kbq-code-block__main kbq-scrollbar" + class="kbq-code-block__main" [style.max-height.px]="calculatedMaxHeight" [tabIndex]="codeContentTabIndex" > diff --git a/packages/components/code-block/code-block.ts b/packages/components/code-block/code-block.ts index 4a08e217bf..8f40ed4e82 100644 --- a/packages/components/code-block/code-block.ts +++ b/packages/components/code-block/code-block.ts @@ -43,6 +43,7 @@ import { ruRULocaleData } from '@koobiq/components/core'; import { KbqIconModule } from '@koobiq/components/icon'; +import { KbqNativeScrollbar } from '@koobiq/components/scrollbar'; import { KbqTabsModule } from '@koobiq/components/tabs'; import { KbqToolTipModule, KbqTooltipTrigger } from '@koobiq/components/tooltip'; import { debounceTime, filter, fromEvent, map, merge, take } from 'rxjs'; @@ -109,6 +110,7 @@ export class KbqCodeBlockTabLinkContent {} CdkScrollableModule, KbqToolTipModule, KbqIconModule, + KbqNativeScrollbar, NgTemplateOutlet, KbqOverflowShadowContainer, KbqOverflowShadowTop diff --git a/packages/components/content-panel/__screenshots__/01-dark.png b/packages/components/content-panel/__screenshots__/01-dark.png index 37766c905b..4ae88daf1a 100644 Binary files a/packages/components/content-panel/__screenshots__/01-dark.png and b/packages/components/content-panel/__screenshots__/01-dark.png differ diff --git a/packages/components/content-panel/__screenshots__/01-light.png b/packages/components/content-panel/__screenshots__/01-light.png index 8165ec531f..f010e57041 100644 Binary files a/packages/components/content-panel/__screenshots__/01-light.png and b/packages/components/content-panel/__screenshots__/01-light.png differ diff --git a/packages/components/dropdown/__screenshots__/02-light.png b/packages/components/dropdown/__screenshots__/02-light.png new file mode 100644 index 0000000000..432e154b60 Binary files /dev/null and b/packages/components/dropdown/__screenshots__/02-light.png differ diff --git a/packages/components/dropdown/dropdown.component.ts b/packages/components/dropdown/dropdown.component.ts index 6c9d36ead9..23be84a83d 100644 --- a/packages/components/dropdown/dropdown.component.ts +++ b/packages/components/dropdown/dropdown.component.ts @@ -25,7 +25,8 @@ import { contentChild, inject, input, - numberAttribute + numberAttribute, + viewChild } from '@angular/core'; import { ESCAPE, @@ -38,6 +39,7 @@ import { RIGHT_ARROW } from '@koobiq/components/core'; import { KbqFormField } from '@koobiq/components/form-field'; +import { KbqScrollbarViewport } from '@koobiq/components/scrollbar'; import { Observable, Subject, Subscription, merge } from 'rxjs'; import { startWith, switchMap, take } from 'rxjs/operators'; import { kbqDropdownAnimations } from './dropdown-animations'; @@ -67,7 +69,7 @@ export class KbqDropdownFooter {} @Component({ selector: 'kbq-dropdown', - imports: [], + imports: [KbqScrollbarViewport], templateUrl: 'dropdown.html', /* Component inherits styles from `list`, so `list` variables are imported as the single source of truth. */ styleUrls: ['dropdown.scss', 'dropdown-tokens.scss'], @@ -274,6 +276,9 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit, /** @docs-private */ @ViewChild(TemplateRef, { static: false }) templateRef: TemplateRef; + /** The panel's custom scrollbar viewport, flashed when the panel finishes opening. */ + private readonly scrollbarViewport = viewChild(KbqScrollbarViewport); + /** * List of the items inside of a dropdown. */ @@ -457,6 +462,12 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit, /** Callback that is invoked when the panel animation completes. */ onAnimationDone(event: AnimationEvent) { + // On open, briefly reveal the scrollbar to hint that the content is scrollable — the panel may + // open without scrolling, so the hover track would otherwise stay hidden. + if (event.toState === 'enter') { + this.scrollbarViewport()?.flashScrollIndicators(); + } + this.animationDone.next(event); this.isAnimating = false; } @@ -481,6 +492,20 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit, this.closed.emit(focusOrigin); } + /** + * Closes the dropdown on a panel click. The panel is the custom scrollbar's viewport, so its + * track and thumb are real DOM children whose clicks bubble here — interacting with the scrollbar + * must not close the dropdown, unlike a click on an item. + * @docs-private + */ + protected onPanelClick(event: MouseEvent): void { + if ((event.target as HTMLElement).closest('.kbq-scrollbar-track')) { + return; + } + + this.close(); + } + /** * Sets up a stream that will keep track of any newly-added menu items and will update the list * of direct descendants. We collect the descendants this way, because `items` can include diff --git a/packages/components/dropdown/dropdown.html b/packages/components/dropdown/dropdown.html index ac94e355c8..d0ec4c0e7a 100644 --- a/packages/components/dropdown/dropdown.html +++ b/packages/components/dropdown/dropdown.html @@ -1,5 +1,6 @@
diff --git a/packages/components/dropdown/dropdown.spec.ts b/packages/components/dropdown/dropdown.spec.ts index d6807e9255..c7cf28f9d8 100644 --- a/packages/components/dropdown/dropdown.spec.ts +++ b/packages/components/dropdown/dropdown.spec.ts @@ -18,7 +18,7 @@ import { viewChild, viewChildren } from '@angular/core'; -import { ComponentFixture, TestBed, fakeAsync, flush, inject, tick } from '@angular/core/testing'; +import { ComponentFixture, TestBed, discardPeriodicTasks, fakeAsync, flush, inject, tick } from '@angular/core/testing'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -481,13 +481,19 @@ describe('KbqDropdown', () => { dispatchMouseEvent(triggerEl, 'touchstart'); triggerEl.click(); fixture.detectChanges(); - flush(); + // Not `flush()`: the panel now hosts `kbqScrollbarViewport`, whose track drives a self-rescheduling + // `requestAnimationFrame` loop that `flush()` can never drain (it hits the 20-task limit). `tick` + // advances a fixed span instead — enough for the dropdown's own open timers — and + // `discardPeriodicTasks()` clears the still-pending scrollbar tasks so the test ends cleanly. + tick(500); const items: HTMLElement[] = Array.from(overlayContainerElement.querySelectorAll(ITEM_SELECTOR)); expect(focusSpyFn).not.toHaveBeenCalled(); expect(document.activeElement).toBe(overlayContainerElement.querySelector(PANEL_SELECTOR)); expect(items.some((item) => item.classList.contains('cdk-focused'))).toBe(false); + + discardPeriodicTasks(); })); it('should focus the first item when opening by keyboard', fakeAsync(() => { @@ -515,7 +521,7 @@ describe('KbqDropdown', () => { imports: [KbqDropdownModule, NoopAnimationsModule, SimpleDropdown] }); TestBed.overrideProvider(ScrollDispatcher, { - useFactory: () => ({ scrolled: () => scrolledSubject }) + useFactory: () => ({ scrolled: () => scrolledSubject, register: () => {}, deregister: () => {} }) }); TestBed.overrideProvider(KBQ_DROPDOWN_SCROLL_STRATEGY, { deps: [Overlay], diff --git a/packages/components/dropdown/e2e.playwright-spec.ts b/packages/components/dropdown/e2e.playwright-spec.ts index cdd238bf81..e90332d786 100644 --- a/packages/components/dropdown/e2e.playwright-spec.ts +++ b/packages/components/dropdown/e2e.playwright-spec.ts @@ -117,4 +117,60 @@ test.describe('KbqDropdownModule', () => { await expect(page.locator('.kbq-tooltip')).toBeVisible(); }); }); + + test.describe('E2eDropdownScrollbar', () => { + const getTrigger = (page: Page) => page.getByTestId('e2eDropdownScrollbarTrigger'); + const getPanel = (page: Page) => page.locator('.kbq-dropdown__panel'); + const getTrack = (page: Page) => getPanel(page).locator('kbq-scrollbar-track'); + const getVerticalThumb = (page: Page) => + getPanel(page).locator('.kbq-scrollbar-track__bar_vertical .kbq-scrollbar-track__thumb'); + + test.beforeEach(async ({ page }) => { + await page.goto('/E2eDropdownScrollbar'); + // Cap the viewport height so the overlay panel (bounded by the viewport) stays short — the + // screenshot captures the whole panel, and the default 720px viewport makes it needlessly tall. + await page.setViewportSize({ width: page.viewportSize()!.width, height: 500 }); + await getTrigger(page).click(); + await expect(getPanel(page)).toBeVisible(); + }); + + test('flashes the track on open, then fades it', async ({ page }) => { + // The panel opens without scrolling, so the open-flash is the only thing that reveals the + // track here — no hover, no scroll. + const track = getTrack(page); + + await expect(track).toHaveCSS('opacity', '1'); + // ...and it fades back out again after the hide delay. + await expect(track).toHaveCSS('opacity', '0'); + }); + + test('hides the native scrollbar and reveals the custom track on hover', async ({ page }) => { + await expect(getPanel(page)).toHaveClass(/kbq-scrollbar-viewport_native-scrollbar-hidden/); + + const track = getTrack(page); + + await expect(track).toBeAttached(); + // Wait out the open-flash so hover is tested in isolation. + await expect(track).toHaveCSS('opacity', '0'); + + await getPanel(page).hover(); + await expect(track).toHaveCSS('opacity', '1'); + }); + + test('clicking the scrollbar thumb does not close the dropdown', async ({ page }) => { + await getPanel(page).hover(); + await getVerticalThumb(page).click(); + + await expect(getPanel(page)).toBeVisible(); + }); + + test('renders the custom scrollbar', async ({ page }) => { + const track = getTrack(page); + + // Hover keeps the hover track revealed (opacity 1) deterministically for the screenshot. + await getPanel(page).hover(); + await expect(track).toHaveCSS('opacity', '1'); + await expect(getPanel(page)).toHaveScreenshot('02-light.png'); + }); + }); }); diff --git a/packages/components/dropdown/e2e.ts b/packages/components/dropdown/e2e.ts index 4f75b40aa1..2c62b5e799 100644 --- a/packages/components/dropdown/e2e.ts +++ b/packages/components/dropdown/e2e.ts @@ -306,3 +306,34 @@ export class E2eDropdownTitleOverflow { protected readonly longValue = 'Just a text and a long text and a long text and a long text and a long text and a long text and a long text'; } + +@Component({ + selector: 'e2e-dropdown-scrollbar', + imports: [KbqDropdownModule, KbqButtonModule], + template: ` + + + + @for (item of items; track item) { + + } + + `, + styles: ` + :host { + display: flex; + height: 400px; + width: 400px; + padding: var(--kbq-size-s); + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2eDropdownScrollbar' + } +}) +export class E2eDropdownScrollbar { + protected readonly items = Array.from({ length: 40 }).map((_, i) => `Item #${i}`); +} diff --git a/packages/components/file-upload/multiple-file-upload.component.html b/packages/components/file-upload/multiple-file-upload.component.html index db6181e3f5..a2af162c57 100644 --- a/packages/components/file-upload/multiple-file-upload.component.html +++ b/packages/components/file-upload/multiple-file-upload.component.html @@ -23,7 +23,7 @@ } } @else {
- + @for (file of files; track file) { { }); }); + test.describe('E2eModalScrollbar', () => { + const getBody = (page: Page) => page.locator('.kbq-modal-body'); + const getTrack = (page: Page) => getBody(page).locator('kbq-scrollbar-track'); + + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 500, height: 500 }); + await page.goto('/E2eModalScrollbar'); + await page.getByTestId('e2eOpenModal').click(); + await getBody(page).waitFor({ state: 'visible' }); + // The modal opens centered under the pointer left by the click, which would keep the body hovered + // and the track permanently revealed; park the pointer in a corner so hover is only what the test asks for. + await page.mouse.move(0, 0); + }); + + test('flashes the track on open, then fades it', async ({ page }) => { + // The modal opens scrolled to the top with no interaction, so the open-flash is the only thing + // that reveals the track here — no hover, no scroll. + const track = getTrack(page); + + await expect(track).toHaveCSS('opacity', '1'); + // ...and it fades back out again after the hide delay. + await expect(track).toHaveCSS('opacity', '0'); + }); + + test('hides the native scrollbar and reveals the custom track on hover', async ({ page }) => { + await expect(getBody(page)).toHaveClass(/kbq-scrollbar-viewport_native-scrollbar-hidden/); + + const track = getTrack(page); + + await expect(track).toBeAttached(); + // Wait out the open-flash so hover is tested in isolation. + await expect(track).toHaveCSS('opacity', '0'); + + await getBody(page).hover(); + await expect(track).toHaveCSS('opacity', '1'); + }); + + test('renders the custom scrollbar', async ({ page }) => { + const track = getTrack(page); + + // Hover keeps the hover track revealed (opacity 1) deterministically for the screenshot. Only the + // light theme is captured — the scrollbar's own suite covers dark, so it's redundant here. + await getBody(page).hover(); + await expect(track).toHaveCSS('opacity', '1'); + await expect(getBody(page)).toHaveScreenshot('03-light.png'); + }); + }); + test.describe('overflow shadow', () => { test('should show footer shadow on init when body content overflows', async ({ page }) => { await page.setViewportSize({ width: 400, height: 350 }); diff --git a/packages/components/modal/e2e.ts b/packages/components/modal/e2e.ts index a6383aedc7..53f19e9a24 100644 --- a/packages/components/modal/e2e.ts +++ b/packages/components/modal/e2e.ts @@ -110,3 +110,41 @@ export class E2eModalFullCustom { }); } } + +@Component({ + selector: 'e2e-modal-scrollbar', + template: ` + + `, + styles: ` + :host { + display: flex; + justify-content: center; + align-items: center; + + width: 400px; + height: 400px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2eModalScrollbar' + } +}) +export class E2eModalScrollbar { + private readonly modal = inject(KbqModalService); + + // A long wrapping paragraph so the fixed-height body overflows and scrolls. + protected readonly content = Array.from({ length: 40 }, (_, i) => `Scrollable modal line ${i}`).join(' '); + + protected open(): void { + this.modal.create({ + kbqWidth: '360px', + kbqBodyStyle: { height: '200px' }, + kbqTitle: 'Scrollable modal', + kbqContent: this.content, + kbqOkText: 'Ok', + kbqCancelText: 'Cancel' + }); + } +} diff --git a/packages/components/modal/modal.component.html b/packages/components/modal/modal.component.html index b614899705..65e398f6b0 100644 --- a/packages/components/modal/modal.component.html +++ b/packages/components/modal/modal.component.html @@ -95,7 +95,8 @@
diff --git a/packages/components/modal/modal.component.ts b/packages/components/modal/modal.component.ts index 89b4efa3c5..7d7ecc274b 100644 --- a/packages/components/modal/modal.component.ts +++ b/packages/components/modal/modal.component.ts @@ -41,6 +41,7 @@ import { KbqOverflowShadowTop } from '@koobiq/components/core'; import { KbqIconModule } from '@koobiq/components/icon'; +import { KbqScrollbarViewport } from '@koobiq/components/scrollbar'; import { KbqTitleModule } from '@koobiq/components/title'; import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; @@ -67,6 +68,7 @@ type AnimationState = 'enter' | 'leave' | null; KbqIconModule, CssUnitPipe, NgTemplateOutlet, + KbqScrollbarViewport, KbqOverflowShadowContainer, KbqOverflowShadowTop, KbqOverflowShadowBottom, @@ -275,6 +277,8 @@ export class KbqModalComponent readonly modalContainer = viewChild.required('modalContainer'); readonly bodyContainer = viewChild.required('bodyContainer', { read: ViewContainerRef }); + /** The built-in body's custom scrollbar viewport, flashed once the open animation finishes. */ + private readonly scrollbarViewport = viewChild(KbqScrollbarViewport); // Only aim to focus the ok button that needs to be auto focused readonly autoFocusedButtons = viewChildren('autoFocusedButton', { read: ElementRef }); @@ -617,6 +621,9 @@ export class KbqModalComponent // Emit open/close event after animations over .then(() => { if (visible) { + // Briefly reveal the scrollbar to hint the body is scrollable — a modal may open with + // no scroll, so its hover track would otherwise stay hidden until the pointer enters. + this.scrollbarViewport()?.flashScrollIndicators(); this.kbqAfterOpen.emit(); } else { this.kbqAfterClose.emit(closeResult); diff --git a/packages/components/modal/modal.directive.ts b/packages/components/modal/modal.directive.ts index 11bf8156a4..115140f485 100644 --- a/packages/components/modal/modal.directive.ts +++ b/packages/components/modal/modal.directive.ts @@ -1,7 +1,9 @@ import { Component, Directive, effect, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { KbqButtonModule } from '@koobiq/components/button'; import { kbqInjectA11yLocaleConfiguration, KbqOverflowShadowContainer } from '@koobiq/components/core'; import { KbqIconModule } from '@koobiq/components/icon'; +import { KbqScrollbarViewport } from '@koobiq/components/scrollbar'; import { KbqTitleDirective } from '@koobiq/components/title'; import { KbqModalComponent } from './modal.component'; @@ -51,16 +53,21 @@ export class KbqModalTitle { @Directive({ selector: `[kbq-modal-body], kbq-modal-body, [kbqModalBody]`, host: { - class: 'kbq-modal-body kbq-scrollbar' + class: 'kbq-modal-body' }, - hostDirectives: [KbqOverflowShadowContainer] + hostDirectives: [KbqOverflowShadowContainer, KbqScrollbarViewport] }) export class KbqModalBody { private readonly modal = inject(KbqModalComponent); private readonly overflowContainer = inject(KbqOverflowShadowContainer); + private readonly scrollbarViewport = inject(KbqScrollbarViewport); constructor() { effect(() => this.modal.bodyOverflow.set(this.overflowContainer.overflow())); + + // Briefly reveal the scrollbar once the modal has opened to hint the body is scrollable — it may + // open with no scroll, so its hover track would otherwise stay hidden until the pointer enters. + this.modal.afterOpen.pipe(takeUntilDestroyed()).subscribe(() => this.scrollbarViewport.flashScrollIndicators()); } } diff --git a/packages/components/modal/modal.scss b/packages/components/modal/modal.scss index 1e81f7b8ab..8b98485c38 100644 --- a/packages/components/modal/modal.scss +++ b/packages/components/modal/modal.scss @@ -127,10 +127,6 @@ var(--kbq-modal-size-content-padding-bottom) var(--kbq-modal-size-content-padding-horizontal); overflow-wrap: break-word; - - & > * { - position: relative; - } } .kbq-modal-footer, diff --git a/packages/components/modal/modal.spec.ts b/packages/components/modal/modal.spec.ts index e8160f5d62..ef356d02c9 100644 --- a/packages/components/modal/modal.spec.ts +++ b/packages/components/modal/modal.spec.ts @@ -1,7 +1,15 @@ import { FocusOrigin } from '@angular/cdk/a11y'; import { OverlayContainer } from '@angular/cdk/overlay'; import { Component, EventEmitter, inject, Injectable, Injector, NgModule, Provider, Type } from '@angular/core'; -import { ComponentFixture, fakeAsync, flush, TestBed, inject as testingInject, tick } from '@angular/core/testing'; +import { + ComponentFixture, + discardPeriodicTasks, + fakeAsync, + flush, + TestBed, + inject as testingInject, + tick +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { KbqButtonModule } from '@koobiq/components/button'; @@ -80,6 +88,22 @@ describe('KbqModal', () => { expect(modalService.openModals.length).toBe(1); })); + it('renders the custom scrollbar on the body', fakeAsync(() => { + modalService.create({ kbqContent: 'Test content' }); + + fixture.detectChanges(); + tick(ANIMATION_DURATION); + + const body = overlayContainerElement.querySelector('.kbq-modal-body')!; + + // `KbqScrollbarViewport` applies these host classes, so their presence proves the custom + // scrollbar replaced the deprecated `.kbq-scrollbar` native styling. + expect(body.classList).toContain('kbq-scrollbar-viewport'); + expect(body.classList).toContain('kbq-scrollbar-viewport_native-scrollbar-hidden'); + + discardPeriodicTasks(); + })); + it('should fire onClick events', fakeAsync(() => { const spy = jest.fn(); const onClickEmitter = new EventEmitter(); @@ -392,7 +416,11 @@ describe('KbqModal', () => { const secondModal = modalService.create(); fixture.detectChanges(); - flush(); + // Not `flush()`: the modal body now hosts `kbqScrollbarViewport`, whose track drives a + // self-rescheduling `requestAnimationFrame` loop that `flush()` can never drain (it hits the + // 20-task limit). `tick` advances a fixed span covering the modal's own open/close timers, and + // `discardPeriodicTasks()` clears the still-pending scrollbar tasks so the test ends cleanly. + tick(ANIMATION_DURATION); fixture.detectChanges(); expect(document.querySelectorAll('.kbq-modal-mask').length).toEqual(1); @@ -400,10 +428,12 @@ describe('KbqModal', () => { secondModal.close(); fixture.detectChanges(); - flush(); + tick(ANIMATION_DURATION); fixture.detectChanges(); expect(document.querySelectorAll('.kbq-modal-mask').length).toEqual(1); + + discardPeriodicTasks(); })); it('should process kbqPreventFocusRestoring flag set to true', fakeAsync(() => { @@ -516,7 +546,10 @@ describe('KbqModal', () => { modalRef.close(); fixture.detectChanges(); - flush(); + // Not `flush()`: the open modal's `kbqScrollbarViewport` track drives a self-rescheduling + // `requestAnimationFrame` loop that `flush()` can never drain. `tick` advances the modal's + // finite close timers instead; the leftover scrollbar tasks are cleared at the end. + tick(ANIMATION_DURATION); expect(document.activeElement).toBe(buttonElement); expect(document.activeElement?.classList).toContain(`cdk-${origin}-focused`); @@ -536,6 +569,8 @@ describe('KbqModal', () => { dispatchKeyboardEvent(document, 'keydown', TAB); buttonElement.focus(); testFocusRestoreFor('keyboard'); + + discardPeriodicTasks(); })); }); diff --git a/packages/components/popover/__screenshots__/02-light.png b/packages/components/popover/__screenshots__/02-light.png new file mode 100644 index 0000000000..b99e76aace Binary files /dev/null and b/packages/components/popover/__screenshots__/02-light.png differ diff --git a/packages/components/popover/e2e.playwright-spec.ts b/packages/components/popover/e2e.playwright-spec.ts index 6bc33c16d2..2ddd5f8e1c 100644 --- a/packages/components/popover/e2e.playwright-spec.ts +++ b/packages/components/popover/e2e.playwright-spec.ts @@ -89,6 +89,50 @@ test.describe('KbqPopoverModule', () => { }); }); + test.describe('E2ePopoverScrollbar', () => { + const getContent = (page: Page) => page.locator('.kbq-popover__content'); + const getTrack = (page: Page) => getContent(page).locator('kbq-scrollbar-track'); + + test.beforeEach(async ({ page }) => { + await page.goto('/E2ePopoverScrollbar'); + await page.getByTestId('e2ePopoverTrigger').click(); + await expect(getContent(page)).toBeVisible(); + }); + + test('flashes the track on open, then fades it', async ({ page }) => { + // The popover opens scrolled to the top with no interaction, so the open-flash is the only thing + // that reveals the track here — no hover, no scroll. + const track = getTrack(page); + + await expect(track).toHaveCSS('opacity', '1'); + // ...and it fades back out again after the hide delay. + await expect(track).toHaveCSS('opacity', '0'); + }); + + test('hides the native scrollbar and reveals the custom track on hover', async ({ page }) => { + await expect(getContent(page)).toHaveClass(/kbq-scrollbar-viewport_native-scrollbar-hidden/); + + const track = getTrack(page); + + await expect(track).toBeAttached(); + // Wait out the open-flash so hover is tested in isolation. + await expect(track).toHaveCSS('opacity', '0'); + + await getContent(page).hover(); + await expect(track).toHaveCSS('opacity', '1'); + }); + + test('renders the custom scrollbar', async ({ page }) => { + const track = getTrack(page); + + // Hover keeps the hover track revealed (opacity 1) deterministically for the screenshot. Only the + // light theme is captured — the scrollbar's own suite covers dark, so it's redundant here. + await getContent(page).hover(); + await expect(track).toHaveCSS('opacity', '1'); + await expect(getContent(page)).toHaveScreenshot('02-light.png'); + }); + }); + test.describe('overflow shadow', () => { test('should show footer shadow on init when content overflows', async ({ page }) => { await page.goto('/E2ePopoverStates'); diff --git a/packages/components/popover/e2e.ts b/packages/components/popover/e2e.ts index 84ddd9d285..9d0adbd074 100644 --- a/packages/components/popover/e2e.ts +++ b/packages/components/popover/e2e.ts @@ -213,3 +213,40 @@ export class E2ePopoverPositioning {} } }) export class E2ePopoverWithTooltip {} + +@Component({ + selector: 'e2e-popover-scrollbar', + imports: [KbqPopoverModule, KbqButton, KbqButtonCssStyler], + template: ` + + `, + styles: ` + :host { + display: flex; + justify-content: center; + align-items: flex-start; + + width: 400px; + height: 400px; + padding-top: 40px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2ePopoverScrollbar' + } +}) +export class E2ePopoverScrollbar { + // A long wrapping paragraph so the small popover's content exceeds the container's max-height and scrolls. + protected readonly content = Array.from({ length: 80 }, (_, i) => `Scrollable popover line ${i}`).join(' '); +} diff --git a/packages/components/popover/popover.component.html b/packages/components/popover/popover.component.html index 3de4b38603..c002c3707a 100644 --- a/packages/components/popover/popover.component.html +++ b/packages/components/popover/popover.component.html @@ -32,7 +32,8 @@
{ if (this.offset !== null && state && this.elementRef) { @@ -129,6 +136,16 @@ export class KbqPopoverComponent extends KbqPopUp implements AfterViewInit { this.trigger.focus(); } + override animationDone(event: AnimationEvent): void { + super.animationDone(event); + + // Once the panel has finished opening, briefly reveal the scrollbar to hint the content is + // scrollable — the popover may open with no scroll, so its hover track would otherwise stay hidden. + if (event.toState === PopUpVisibility.Visible) { + this.scrollbarViewport()?.flashScrollIndicators(); + } + } + protected readonly componentColors = KbqComponentColors; } diff --git a/packages/components/popover/popover.spec.ts b/packages/components/popover/popover.spec.ts index 46a74ac6ba..da594816bf 100644 --- a/packages/components/popover/popover.spec.ts +++ b/packages/components/popover/popover.spec.ts @@ -165,6 +165,21 @@ describe('KbqPopover', () => { expect(content.nativeElement.textContent).toEqual(expectedValue); })); + it('renders the custom scrollbar on the content', fakeAsync(() => { + const triggerElement = componentInstance.test5().nativeElement; + + dispatchMouseEvent(triggerElement, 'mouseenter'); + tick(); + fixture.detectChanges(); + + const content = debugElement.query(By.css('.kbq-popover__content')); + + // `KbqScrollbarViewport` applies these host classes, so their presence proves the custom + // scrollbar replaced the deprecated `.kbq-scrollbar` native styling. + expect(content.nativeElement.classList).toContain('kbq-scrollbar-viewport'); + expect(content.nativeElement.classList).toContain('kbq-scrollbar-viewport_native-scrollbar-hidden'); + })); + it('Can set kbqPopoverFooter', fakeAsync(() => { const expectedValue = '_TEST6'; const triggerElement = componentInstance.test6().nativeElement; diff --git a/packages/components/scrollbar/e2e.playwright-spec.ts b/packages/components/scrollbar/e2e.playwright-spec.ts index bab4e9f8c2..e9d081d709 100644 --- a/packages/components/scrollbar/e2e.playwright-spec.ts +++ b/packages/components/scrollbar/e2e.playwright-spec.ts @@ -85,6 +85,24 @@ test.describe('KbqScrollbar', () => { await page.mouse.up(); }); + + test('reveals the track while scrolling and hides it again after scrolling stops', async ({ page }) => { + const scrollbar = getScrollbar(page); + const track = getComponent(page).locator('kbq-scrollbar-track'); + + // Keep the pointer away so `:hover` doesn't reveal the track on its own. + await page.mouse.move(0, 0); + await expect(track).toHaveCSS('opacity', '0'); + + // Scrolling reveals it — matches native/overlayscrollbars. + await scrollbar.evaluate((el) => { + el.scrollTop = 100; + }); + await expect(track).toHaveCSS('opacity', '1'); + + // ...and it fades out again a short while after scrolling stops. + await expect(track).toHaveCSS('opacity', '0'); + }); }); test.describe('E2eScrollbarTrack', () => { @@ -230,38 +248,21 @@ test.describe('KbqScrollbar', () => { await expect(track).toHaveCSS('opacity', '1'); }); - test('hover mode: clicking a focusable descendant with the mouse does not keep the track visible after the pointer leaves', async ({ - page - }) => { - // Regression test: the track used to stay visible relying on `:focus-within`, which also - // matches DOM focus left behind by a mouse click (e.g. a dropdown item), not just keyboard - // navigation. Fixed by keying off `.cdk-keyboard-focused` instead. + test('hover mode: focusing a descendant does not reveal the track', async ({ page }) => { + // Focus never reveals the hover track — only pointer hover and scrolling do. This guards that a + // mouse click on a focusable descendant (which leaves DOM focus behind) doesn't keep the track + // visible once the pointer leaves. await setMode(page, 'hover'); const track = getTrack(page); // `force: true`: the horizontal thumb overlaps the button (the shared fixture content - // overflows both axes), which is irrelevant here — only the resulting focus origin matters. + // overflows both axes), which is irrelevant here — only the resulting focus matters. await page.getByTestId('e2eScrollbarModeFocusable').click({ force: true }); await page.mouse.move(0, 0); await expect(track).toHaveCSS('opacity', '0'); }); - test('hover mode: keyboard-focusing a plain descendant keeps the track visible', async ({ page }) => { - // The button isn't individually wired up to `FocusMonitor` — `KbqScrollbarViewport` monitors - // its whole subtree, so this works for any projected content, not just components that opt in. - await setMode(page, 'hover'); - const track = getTrack(page); - - // `Locator.focus()` is program-origin, not keyboard-origin — real Tab navigation is required - // so `FocusMonitor` classifies the resulting focus as `cdk-keyboard-focused`. Nothing between - // the last mode button and the target is tabbable, so a single Tab reaches it. - await page.getByTestId('mode-hidden').focus(); - await page.keyboard.press('Tab'); - - await expect(track).toHaveCSS('opacity', '1'); - }); - test('hover mode: keyboard-scrolling via a focused descendant shows the track', async ({ page }) => { // macOS-style expectation: the track should appear while the content is actively being // scrolled from the keyboard. Tabbing to (and, belt-and-suspenders, arrow-key-scrolling past) @@ -462,4 +463,50 @@ test.describe('KbqScrollbar', () => { await expect(viewport).not.toHaveClass(/kbq-scrollbar-viewport_native-scrollbar-hidden/); }); }); + + test.describe('E2eScrollbarPadding', () => { + const getViewport = (page: Page) => page.getByTestId('e2eScrollbarPaddingTarget'); + const getVerticalBar = (page: Page) => getViewport(page).locator('.kbq-scrollbar-track__bar_vertical'); + + test('the track spans the scrollport flush at every scroll position when the viewport is padded', async ({ + page + }) => { + await page.goto('/E2eScrollbarPadding'); + + const viewport = getViewport(page); + const bar = getVerticalBar(page); + + await expect(bar).toBeVisible(); + + const assertFlush = async () => { + const vpBox = (await viewport.boundingBox())!; + const barBox = (await bar.boundingBox())!; + + // The bar must actually span the scrollport's height, not collapse — a track sized with + // unitless (invalid) lengths would still be attached but zero-sized, silently passing the + // edge checks below while rendering nothing. + expect(barBox.height).toBeGreaterThanOrEqual(vpBox.height - 2); + + // box-sizing: border-box with no border, so the viewport's bounding box is its padding box + // (the scrollport). The vertical bar must span it flush on both axes, rather than being + // pushed off the edges by the padding: + // - block axis: top aligned to the top edge, no overhang past the bottom edge; + // - inline axis: the bar's end (right) edge aligned to the scrollport's end edge. + // (all within 1px — the track is intentionally sized one pixel short.) + expect(Math.abs(barBox.y - vpBox.y)).toBeLessThanOrEqual(1); + expect(barBox.y + barBox.height).toBeLessThanOrEqual(vpBox.y + vpBox.height + 1); + expect(Math.abs(vpBox.x + vpBox.width - (barBox.x + barBox.width))).toBeLessThanOrEqual(1); + }; + + // At the initial position (scroll top) — where the block-start padding still pushed the track + // down after the first, incomplete fix. + await assertFlush(); + + // And still flush once scrolled to the bottom. + await viewport.evaluate((el) => { + el.scrollTop = el.scrollHeight; + }); + await assertFlush(); + }); + }); }); diff --git a/packages/components/scrollbar/e2e.ts b/packages/components/scrollbar/e2e.ts index dc86b9808c..43e1f93731 100644 --- a/packages/components/scrollbar/e2e.ts +++ b/packages/components/scrollbar/e2e.ts @@ -134,8 +134,8 @@ export class E2eScrollbarTrack {}

content

- +
`, @@ -403,3 +403,48 @@ export class E2eScrollbarNested {} } }) export class E2eNativeScrollbar {} + +@Component({ + selector: 'e2e-scrollbar-padding', + imports: [KbqScrollbarViewport], + template: ` +
+
+
+ `, + styles: ` + :host { + display: block; + padding: var(--kbq-size-xs); + } + + .e2e-scrollbar { + --kbq-scrollbar-track-background: cyan; + --kbq-scrollbar-thumb-default-background: orange; + + box-sizing: border-box; + width: 200px; + height: 200px; + /* Padding on the scroll container itself — reproduces the dropdown panels, where the track + must still align to the scrollport edges instead of overhanging by the padding. */ + padding: 16px; + overflow: auto; + background-color: var(--kbq-background-bg-secondary); + } + + .e2e-content { + width: 100%; + height: 800px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2eScrollbarPadding' + } +}) +export class E2eScrollbarPadding {} diff --git a/packages/components/scrollbar/scrollbar-tokens.scss b/packages/components/scrollbar/scrollbar-tokens.scss index f5c325c69e..0bbeb33f17 100644 --- a/packages/components/scrollbar/scrollbar-tokens.scss +++ b/packages/components/scrollbar/scrollbar-tokens.scss @@ -15,3 +15,12 @@ --kbq-scrollbar-thumb-hover-background: var(--kbq-semantic-dark-contrast-a10, var(--kbq-palette-grey-50-a60)); --kbq-scrollbar-thumb-active-background: var(--kbq-semantic-dark-contrast-a11, var(--kbq-palette-grey-50-a80)); } + +// Every scrollbar transition/animation (hover fade, scroll-reveal fade, thumb colour, bar enter/leave) +// runs off this one duration, so zeroing it here makes them all instant under reduced-motion — the +// scrollbar still appears and hides, just without the fade. +@media (prefers-reduced-motion: reduce) { + :where(.kbq-scrollbar-viewport, .kbq-native-scrollbar) { + --kbq-scrollbar-transition-duration: 0s; + } +} diff --git a/packages/components/scrollbar/scrollbar-track.scss b/packages/components/scrollbar/scrollbar-track.scss index 9a0fed3643..ff375d02f5 100644 --- a/packages/components/scrollbar/scrollbar-track.scss +++ b/packages/components/scrollbar/scrollbar-track.scss @@ -8,11 +8,6 @@ // negative `margin-inline-end`/`margin-block-end` already cancels its layout contribution, so // pinning shrink to 0 is layout-neutral (and ignored entirely in a block container). flex-shrink: 0; - inset-block-start: 0; - inset-inline-start: 0; - min-inline-size: calc(100% - 1px); - max-inline-size: calc(100% - 1px); - margin-inline-end: calc(-100% + 1px); pointer-events: none; overflow: hidden; // Lets a consumer theme one scrollbar instance without a class hook to target — the track may be diff --git a/packages/components/scrollbar/scrollbar-viewport.scss b/packages/components/scrollbar/scrollbar-viewport.scss index 06c76eafa9..98d88ac542 100644 --- a/packages/components/scrollbar/scrollbar-viewport.scss +++ b/packages/components/scrollbar/scrollbar-viewport.scss @@ -13,13 +13,14 @@ opacity: 0; } -// `:focus-within` also matches DOM focus a mouse click leaves behind on a descendant (e.g. a dropdown -// item) after the pointer leaves, keeping the track visible indefinitely. `cdk-keyboard-focused` is -// scoped to keyboard-originated focus (`KbqScrollbarViewport` monitors its subtree via -// `cdkMonitorSubtreeFocus`), so the track stays hidden for mouse focus but visible while, e.g., arrow -// keys scroll the content. +// The hover track is revealed while: +// - the pointer is over the viewport (`:hover`); +// - the track is transiently revealed (`kbq-scrollbar-track_revealed`, toggled by KbqScrollbarTrack on a +// scroll or `flashScrollIndicators()` and auto-cleared shortly after) — matches native/overlayscrollbars, +// and covers reveal on wheel/keyboard scrolling, the programmatic scroll-into-view when a dropdown opens +// by mouse, and an explicit flash. .kbq-scrollbar-viewport:hover > .kbq-scrollbar-track_hover, -.kbq-scrollbar-viewport.cdk-keyboard-focused > .kbq-scrollbar-track_hover { +.kbq-scrollbar-track_hover.kbq-scrollbar-track_revealed { transition: opacity var(--kbq-scrollbar-transition-duration) ease; opacity: 1; } diff --git a/packages/components/scrollbar/scrollbar.en.md b/packages/components/scrollbar/scrollbar.en.md index f8b2f6b0fd..1f24a20033 100644 --- a/packages/components/scrollbar/scrollbar.en.md +++ b/packages/components/scrollbar/scrollbar.en.md @@ -4,10 +4,12 @@ The `kbqScrollbarMode` input controls how the scrollbar is displayed: -- `hover` — shows the scrollbar on pointer hover or keyboard focus. This is the default mode. -- `always` — always shows the scrollbar when the content overflows its container. -- `native` — shows the browser's native scrollbar. -- `hidden` — hides the scrollbar while keeping the content scrollable. +| Mode | Description | +| -------- | ------------------------------------------------------------------------- | +| `hover` | Shows the scrollbar on pointer hover or scroll. This is the default mode. | +| `always` | Always shows the scrollbar when the content overflows its container. | +| `native` | Shows the browser's native scrollbar. | +| `hidden` | Hides the scrollbar while keeping the content scrollable. | Use `kbqScrollbarOptionsProvider` to change the default mode for the application or a specific dependency injection scope. @@ -23,18 +25,78 @@ Apply the `kbqScrollbarViewport` directive to `cdk-virtual-scroll-viewport` to a Access the component through its `kbqScrollbar` export and use its public methods: -- `scrollTo` — scrolls to specified coordinates; -- `scrollToTop` and `scrollToBottom` — scroll to the start or end of the vertical axis; -- `scrollStart` and `scrollEnd` — scroll to the logical start or end of the horizontal axis, respecting RTL; -- `scrollToElement` — scrolls to an element or CSS selector with optional offsets; -- `scrollIntoView` — centers an element within the viewport. +| Method | Description | +| ---------------------------------- | -------------------------------------------------------------------------- | +| `scrollTo` | Scrolls to specified coordinates. | +| `scrollToTop` and `scrollToBottom` | Scroll to the start or end of the vertical axis. | +| `scrollStart` and `scrollEnd` | Scroll to the logical start or end of the horizontal axis, respecting RTL. | +| `scrollToElement` | Scrolls to an element or CSS selector with optional offsets. | +| `scrollIntoView` | Centers an element within the viewport. | -Methods that accept a `behavior` parameter support the native `auto` and `smooth` scrolling behaviors. Scroll events are available through `scrollChanges`. +Methods that accept a `behavior` parameter support the native `auto` and `smooth` scrolling behaviors. +## Scroll indicators + +Call `flashScrollIndicators` to briefly reveal the scrollbar and hint that the content is scrollable: + +```ts +import { afterNextRender, ChangeDetectionStrategy, Component, viewChild } from '@angular/core'; +import { KbqScrollbar } from '@koobiq/components/scrollbar'; + +@Component({ + imports: [KbqScrollbar], + template: ` + ... + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class FlashScrollIndicatorsExample { + private readonly scrollbar = viewChild.required(KbqScrollbar); + + constructor() { + afterNextRender(() => { + this.scrollbar().flashScrollIndicators(); + }); + } +} +``` + ## Browser scrollbar -Use `kbqNativeScrollbar` to customize only the native scrollbar. Add `kbqNativeScrollbarDescendants` to apply the customization to descendant elements. +Use `kbqNativeScrollbar` to customize an element's browser-rendered scrollbar without replacing native scrolling. Add `kbqNativeScrollbarDescendants` to apply the same customization to the native scrollbars of all its descendant elements. + +## Scroll events + +Subscribe to `scrollChanges` to track the viewport's native scroll events: + +```ts +import { afterNextRender, ChangeDetectionStrategy, Component, DestroyRef, inject, viewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { KbqScrollbar } from '@koobiq/components/scrollbar'; + +@Component({ + imports: [KbqScrollbar], + template: ` + ... + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ScrollEventsExample { + private readonly scrollbar = viewChild.required(KbqScrollbar); + private readonly destroyRef = inject(DestroyRef); + + constructor() { + afterNextRender(() => { + this.scrollbar() + .scrollChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + // Handle the scroll event. + }); + }); + } +} +``` diff --git a/packages/components/scrollbar/scrollbar.ru.md b/packages/components/scrollbar/scrollbar.ru.md index cc447f873e..2bfb890480 100644 --- a/packages/components/scrollbar/scrollbar.ru.md +++ b/packages/components/scrollbar/scrollbar.ru.md @@ -4,10 +4,12 @@ Режим задаётся входным параметром `kbqScrollbarMode`: -- `hover` — скроллбар появляется при наведении указателя или клавиатурном фокусе. Используется по умолчанию. -- `always` — скроллбар отображается постоянно, если содержимое выходит за границы области. -- `native` — отображается системный скроллбар браузера. -- `hidden` — скроллбар скрыт, но содержимое можно прокручивать. +| Режим | Описание | +| -------- | -------------------------------------------------------------------------------------- | +| `hover` | Скроллбар появляется при наведении указателя или прокрутке. Используется по умолчанию. | +| `always` | Скроллбар отображается постоянно, если содержимое выходит за границы области. | +| `native` | Отображается системный скроллбар браузера. | +| `hidden` | Скроллбар скрыт, но содержимое можно прокручивать. | Режим по умолчанию для приложения или отдельной области DI можно изменить с помощью `kbqScrollbarOptionsProvider`. @@ -23,18 +25,78 @@ Получите компонент через экспорт `kbqScrollbar` и используйте его публичные методы: -- `scrollTo` — прокрутить до заданных координат; -- `scrollToTop` и `scrollToBottom` — прокрутить к началу или концу вертикальной оси; -- `scrollStart` и `scrollEnd` — прокрутить к логическому началу или концу горизонтальной оси с учётом RTL; -- `scrollToElement` — прокрутить до элемента или CSS-селектора с необязательными отступами; -- `scrollIntoView` — расположить элемент в центре области просмотра. +| Метод | Описание | +| -------------------------------- | ---------------------------------------------------------------------------- | +| `scrollTo` | Прокручивает до заданных координат. | +| `scrollToTop` и `scrollToBottom` | Прокручивают к началу или концу вертикальной оси. | +| `scrollStart` и `scrollEnd` | Прокручивают к логическому началу или концу горизонтальной оси с учётом RTL. | +| `scrollToElement` | Прокручивает до элемента или CSS-селектора с необязательными отступами. | +| `scrollIntoView` | Располагает элемент в центре области просмотра. | -В методах с параметром `behavior` можно выбрать нативное поведение прокрутки `auto` или `smooth`. События прокрутки доступны через `scrollChanges`. +В методах с параметром `behavior` можно выбрать нативное поведение прокрутки `auto` или `smooth`. +## Индикаторы прокрутки + +Вызовите `flashScrollIndicators`, чтобы кратко показать скроллбар и подсказать, что содержимое можно прокручивать: + +```ts +import { afterNextRender, ChangeDetectionStrategy, Component, viewChild } from '@angular/core'; +import { KbqScrollbar } from '@koobiq/components/scrollbar'; + +@Component({ + imports: [KbqScrollbar], + template: ` + ... + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class FlashScrollIndicatorsExample { + private readonly scrollbar = viewChild.required(KbqScrollbar); + + constructor() { + afterNextRender(() => { + this.scrollbar().flashScrollIndicators(); + }); + } +} +``` + ## Браузерный скроллбар -Используйте `kbqNativeScrollbar`, чтобы настроить только нативный скроллбар. Добавьте `kbqNativeScrollbarDescendants`, чтобы применить кастомизацию к дочерним элементам. +Используйте `kbqNativeScrollbar` для кастомизации нативного скроллбара элемента без замены браузерного механизма прокрутки. Добавьте `kbqNativeScrollbarDescendants`, чтобы применить ту же кастомизацию к нативным скроллбарам всех его дочерних элементов на любом уровне вложенности. + +## События прокрутки + +Подпишитесь на `scrollChanges`, чтобы отслеживать нативные события прокрутки области: + +```ts +import { afterNextRender, ChangeDetectionStrategy, Component, DestroyRef, inject, viewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { KbqScrollbar } from '@koobiq/components/scrollbar'; + +@Component({ + imports: [KbqScrollbar], + template: ` + ... + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ScrollEventsExample { + private readonly scrollbar = viewChild.required(KbqScrollbar); + private readonly destroyRef = inject(DestroyRef); + + constructor() { + afterNextRender(() => { + this.scrollbar() + .scrollChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + // Обработайте событие прокрутки. + }); + }); + } +} +``` diff --git a/packages/components/scrollbar/scrollbar.spec.ts b/packages/components/scrollbar/scrollbar.spec.ts index f1d2df7c46..8ad520a6e7 100644 --- a/packages/components/scrollbar/scrollbar.spec.ts +++ b/packages/components/scrollbar/scrollbar.spec.ts @@ -1,8 +1,11 @@ +import { Dir } from '@angular/cdk/bidi'; +import { SharedResizeObserver } from '@angular/cdk/observers/private'; import { CdkScrollable } from '@angular/cdk/scrolling'; import { Component, ElementRef, Provider, Type, viewChild } from '@angular/core'; import { ComponentFixture, discardPeriodicTasks, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { dispatchMouseEvent, KbqOverflowShadowContainer } from '@koobiq/components/core'; +import { Subject } from 'rxjs'; import { KbqNativeScrollbar, KbqScrollbar, @@ -44,6 +47,21 @@ const setMetrics = (el: HTMLElement, metrics: ElementMetrics): void => { const getScrollable = (fixture: ComponentFixture): CdkScrollable => fixture.debugElement.query(By.directive(KbqScrollbar)).injector.get(CdkScrollable); +/** + * Drives `KbqScrollbarTrack`'s geometry pipe under test. It reads the viewport box on each + * `SharedResizeObserver` emission, but the real observer never fires against jsdom's no-op + * `ResizeObserver`, so swap in a controllable stream and call `triggerResize()` after setting the + * viewport metrics to apply the geometry. + */ +const createResizeTrigger = (): { provider: Provider; triggerResize: () => void } => { + const resizes = new Subject(); + + return { + provider: { provide: SharedResizeObserver, useValue: { observe: () => resizes } }, + triggerResize: () => resizes.next([]) + }; +}; + const setRect = (el: HTMLElement, rect: Partial): void => { jest.spyOn(el, 'getBoundingClientRect').mockReturnValue({ top: 0, @@ -354,12 +372,13 @@ describe(KbqScrollbar.name, () => { })); it('mirrors the viewport clientHeight into block-size/margin-block-end, one pixel short', fakeAsync(() => { - const fixture = createComponent(TestScrollbarTrackVisibility); + const { provider, triggerResize } = createResizeTrigger(); + const fixture = createComponent(TestScrollbarTrackVisibility, [provider]); const trackEl: HTMLElement = fixture.nativeElement.querySelector('kbq-scrollbar-track'); setMetrics(getViewportEl(fixture), { clientHeight: 50 }); - tick(300); + triggerResize(); fixture.detectChanges(); expect(trackEl.style.blockSize).toBe('49px'); @@ -368,6 +387,78 @@ describe(KbqScrollbar.name, () => { discardPeriodicTasks(); })); + it('lifts the track over the viewport start padding on both axes so it spans the padding box, flush and without shifting content', fakeAsync(() => { + const { provider, triggerResize } = createResizeTrigger(); + const fixture = createComponent(TestScrollbarTrackVisibility, [provider]); + const viewportEl = getViewportEl(fixture); + const trackEl: HTMLElement = fixture.nativeElement.querySelector('kbq-scrollbar-track'); + + setMetrics(viewportEl, { clientHeight: 50, clientWidth: 30 }); + const realGetComputedStyle = window.getComputedStyle.bind(window); + + jest.spyOn(window, 'getComputedStyle').mockImplementation((el) => + el === viewportEl + ? ({ paddingBlockStart: '8px', paddingInlineStart: '6px' } as CSSStyleDeclaration) + : realGetComputedStyle(el) + ); + + triggerResize(); + fixture.detectChanges(); + + // Block axis: `margin-block-start`/`inset-block-start` lift the track by the 8px block-start + // padding; `margin-block-end` both cancels the 49px block-size and compensates that lift + // (8 - 49 = -41), so the net layout contribution stays zero and content is not shifted. + expect(trackEl.style.blockSize).toBe('49px'); + expect(trackEl.style.marginBlockStart).toBe('-8px'); + expect(trackEl.style.insetBlockStart).toBe('-8px'); + expect(trackEl.style.marginBlockEnd).toBe('-41px'); + + // Inline axis: the exact mirror with the 6px inline-start padding (6 - 29 = -23). + expect(trackEl.style.minInlineSize).toBe('29px'); + expect(trackEl.style.maxInlineSize).toBe('29px'); + expect(trackEl.style.marginInlineStart).toBe('-6px'); + expect(trackEl.style.insetInlineStart).toBe('-6px'); + expect(trackEl.style.marginInlineEnd).toBe('-23px'); + + discardPeriodicTasks(); + })); + + it('toggles kbq-scrollbar-track_revealed while scrolling and clears it after scrolling stops', fakeAsync(() => { + const fixture = createComponent(TestScrollbarTrackVisibility); + const trackEl: HTMLElement = fixture.nativeElement.querySelector('kbq-scrollbar-track'); + + expect(trackEl.classList).not.toContain('kbq-scrollbar-track_revealed'); + + getViewportEl(fixture).dispatchEvent(new Event('scroll')); + fixture.detectChanges(); + expect(trackEl.classList).toContain('kbq-scrollbar-track_revealed'); + + // Cleared hideDelay (1000ms default) after the last scroll event. + tick(1000); + fixture.detectChanges(); + expect(trackEl.classList).not.toContain('kbq-scrollbar-track_revealed'); + + discardPeriodicTasks(); + })); + + it('flashScrollIndicators() reveals the track and clears it after hideDelay, without any scroll', fakeAsync(() => { + const fixture = createComponent(TestScrollbarTrackVisibility); + const trackEl: HTMLElement = fixture.nativeElement.querySelector('kbq-scrollbar-track'); + const scrollbar: KbqScrollbar = fixture.debugElement.query(By.directive(KbqScrollbar)).componentInstance; + + expect(trackEl.classList).not.toContain('kbq-scrollbar-track_revealed'); + + scrollbar.flashScrollIndicators(); + fixture.detectChanges(); + expect(trackEl.classList).toContain('kbq-scrollbar-track_revealed'); + + tick(1000); + fixture.detectChanges(); + expect(trackEl.classList).not.toContain('kbq-scrollbar-track_revealed'); + + discardPeriodicTasks(); + })); + it('is inserted as the first child of the scrollable element', () => { const fixture = createComponent(TestScrollbarTrackVisibility); @@ -505,7 +596,7 @@ describe(KbqScrollbar.name, () => { it('negates the horizontal offset in RTL when clicking the track', fakeAsync(() => { @Component({ selector: 'test-scrollbar-thumb-rtl', - imports: [KbqScrollbarViewport], + imports: [Dir, KbqScrollbarViewport], template: `
` @@ -515,12 +606,6 @@ describe(KbqScrollbar.name, () => { const fixture = createComponent(TestScrollbarThumbRtl); const { viewport, bar, thumb } = getThumbElements(fixture, 'horizontal'); - // jsdom's `.matches()` doesn't support `:scope` combined with a descendant combinator - // (confirmed: `el.matches('[dir="rtl"] :scope')` returns false even with a real dir="rtl" - // ancestor, while `el.closest('[dir="rtl"]')` correctly finds it) — so the `dir="rtl"` - // wrapper above only documents intent; the RTL branch itself has to be forced here. - jest.spyOn(thumb, 'matches').mockReturnValue(true); - setMetrics(thumb, { offsetHeight: 0, offsetWidth: 0 }); setRect(bar, { top: 0, left: 0, height: 100, width: 100, right: 100, bottom: 100 }); diff --git a/packages/components/scrollbar/scrollbar.ts b/packages/components/scrollbar/scrollbar.ts index 71df53e2ae..4d3dd612da 100644 --- a/packages/components/scrollbar/scrollbar.ts +++ b/packages/components/scrollbar/scrollbar.ts @@ -1,21 +1,27 @@ -import { _IdGenerator, CdkMonitorFocus } from '@angular/cdk/a11y'; +import { _IdGenerator } from '@angular/cdk/a11y'; +import { Directionality } from '@angular/cdk/bidi'; +import { coerceCssPixelValue } from '@angular/cdk/coercion'; +import { SharedResizeObserver } from '@angular/cdk/observers/private'; import { _CdkPrivateStyleLoader } from '@angular/cdk/private'; import { CdkScrollable, type ExtendedScrollToOptions } from '@angular/cdk/scrolling'; -import { DOCUMENT } from '@angular/common'; import { afterNextRender, + ApplicationRef, booleanAttribute, ChangeDetectionStrategy, Component, + createComponent, + DestroyRef, Directive, effect, - ElementRef, + EnvironmentInjector, inject, InjectionToken, Injector, input, NgZone, - ViewContainerRef, + numberAttribute, + Renderer2, ViewEncapsulation, type ComponentRef, type Provider @@ -24,16 +30,20 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { KBQ_WINDOW, kbqInjectNativeElement } from '@koobiq/components/core'; import { asyncScheduler, + concat, distinctUntilChanged, filter, fromEvent, map, merge, Observable, + of, startWith, + Subject, switchMap, takeUntil, throttleTime, + timer, type MonoTypeOperatorFunction, type SchedulerAction, type SchedulerLike, @@ -119,18 +129,9 @@ function getElementOffset(ancestor: HTMLElement, element: HTMLElement): { offset return { offsetTop, offsetLeft }; } -/** - * A DI token pointing to the element whose scroll state the scrollbar tracks and controls. - * By default resolves to {@link KbqScrollbar}'s own host element; place `[kbqScrollbarViewport]` on a - * nested element to delegate to it instead. - */ -export const KBQ_SCROLLBAR_VIEWPORT = new InjectionToken>('KBQ_SCROLLBAR_VIEWPORT', { - factory: () => new ElementRef(inject(DOCUMENT).documentElement) -}); - /** * How the scrollbar is presented: - * - `hover` — track appears on pointer hover or keyboard focus (default); + * - `hover` — track appears on pointer hover or while scrolling (default); * - `always` — track is always visible while the content overflows; * - `native` — the browser's native scrollbar is used; * - `hidden` — no scrollbar is shown, but the content stays scrollable. @@ -140,10 +141,13 @@ export type KbqScrollbarMode = 'always' | 'hidden' | 'hover' | 'native'; /** Configuration for {@link KbqScrollbar}. */ export type KbqScrollbarOptions = { mode: KbqScrollbarMode; + /** How long the scroll-revealed track stays visible after scrolling stops, in milliseconds. */ + hideDelay: number; }; const KBQ_SCROLLBAR_DEFAULT_OPTIONS: KbqScrollbarOptions = { - mode: 'hover' + mode: 'hover', + hideDelay: 1000 }; /** Injection token holding the current {@link KbqScrollbarOptions}. */ @@ -162,6 +166,9 @@ export function kbqScrollbarOptionsProvider(options: Partial(inject(KBQ_SCROLLBAR_OPTIONS).mode, { alias: 'kbqScrollbarMode' }); + readonly mode = input(this.options.mode, { alias: 'kbqScrollbarMode' }); + + /** + * How long the scroll-revealed `hover`-mode track stays visible after scrolling stops, in milliseconds. + * Defaults to the app-wide {@link KBQ_SCROLLBAR_OPTIONS}. + */ + readonly hideDelay = input(this.options.hideDelay, { + alias: 'kbqScrollbarHideDelay', + transform: numberAttribute + }); // Reference to the dynamically created track, used to update its mode and destroy it when custom // scrollbars are disabled. private trackRef: ComponentRef | null = null; + // Fires each `flashScrollIndicators()` call; the track reveals itself on it just like on a scroll event. + private readonly flashSubject = new Subject(); + + /** + * Emits each time {@link KbqScrollbarViewport.flashScrollIndicators} is called, so the track can reveal + * itself. A read-only view over `flashSubject`, so consumers can't cast it back to a writable Subject. + */ + readonly flashes = this.flashSubject.asObservable(); + + /** Emits on every native `scroll` event of the viewport. Emits outside Angular's zone — see `CdkScrollable.elementScrolled`. */ + readonly scrollChanges = this.scrollable.elementScrolled(); + constructor() { this.styleLoader.load(ScrollbarStyleLoader); effect(() => { const mode = this.mode(); + const hideDelay = this.hideDelay(); const showTrack = mode !== 'native' && mode !== 'hidden'; if (!showTrack) { - this.trackRef?.destroy(); - this.trackRef = null; + this.destroyTrack(); return; } @@ -283,18 +316,24 @@ export class KbqScrollbarViewport { this.trackRef = this.createTrack(); } - this.trackRef.setInput('kbqScrollbarMode', mode); + this.trackRef.setInput('mode', mode); + this.trackRef.setInput('hideDelay', hideDelay); }); - } - /** The viewport's native scrollable element — the host this directive is applied to. */ - getNativeElement(): HTMLElement { - return this.scrollable.getElementRef().nativeElement; + // The track view is attached to `ApplicationRef` (see `createTrack`), so destroying the host that + // owns this viewport does not tear it down — `ApplicationRef` keeps change-detecting the orphaned + // view and throws `NG0911` once the surrounding view is gone. Tear it down explicitly on destroy. + this.destroyRef.onDestroy(() => this.destroyTrack()); } - /** Emits on every native `scroll` event of the viewport. Emits outside Angular's zone — see `CdkScrollable.elementScrolled`. */ - get scrollChanges(): Observable { - return this.scrollable.elementScrolled(); + /** + * Briefly reveals the scrollbar track, then fades it out after `hideDelay` — the same transient reveal + * as scrolling, without any actual scroll. Mirrors iOS `UIScrollView.flashScrollIndicators()`: call it + * to hint that content is scrollable when nothing has scrolled yet (e.g. right after a dropdown panel + * opens on an already-visible item). Only visible in `hover` mode with overflowing content; a no-op otherwise. + */ + flashScrollIndicators(): void { + this.flashSubject.next(); } /** Scrolls to the specified offsets. RTL-normalized — see `CdkScrollable.scrollTo`. */ @@ -351,14 +390,23 @@ export class KbqScrollbarViewport { } private createTrack(): ComponentRef { - const track = this.viewContainerRef.createComponent(KbqScrollbarTrack, { injector: this.injector }); - - // The track needs to be a direct child of the scrollable element itself (for the sticky - // positioning in scrollbar-track.scss to work) regardless of where `createComponent` happens to - // insert its view, so move it there explicitly. Captures `track` itself, not `this.trackRef` — - // by the time this fires the viewport may already have destroyed/replaced it (e.g. mode flipping - // through native/hidden and back before the next render), and relocating a stale, already-detached - // node is harmless, but dereferencing a by-then-cleared `this.trackRef` would throw. + // Created standalone and attached to the ApplicationRef rather than through a `ViewContainerRef`: + // the track must live as a direct child of the scrollable element (for the sticky positioning in + // scrollbar-track.scss), but a `cdk-virtual-scroll-viewport` re-renders its own DOM subtree, and a + // VCR-owned view manually moved into it is snapped back to its logical anchor on the next change + // detection — leaving the virtual viewport with no working scrollbar. An ApplicationRef-attached + // view has no such anchor, so it stays where we put it. + const track = createComponent(KbqScrollbarTrack, { + environmentInjector: this.environmentInjector, + elementInjector: this.injector + }); + + this.appRef.attachView(track.hostView); + + // Captures `track` itself, not `this.trackRef` — by the time this fires the viewport may already + // have destroyed/replaced it (e.g. mode flipping through native/hidden and back before the next + // render), and inserting a stale, already-destroyed node is harmless, but dereferencing a by-then- + // cleared `this.trackRef` would throw. afterNextRender( () => { this.getNativeElement().insertBefore(track.location.nativeElement, this.getNativeElement().firstChild); @@ -368,31 +416,49 @@ export class KbqScrollbarViewport { return track; } + + /** Detaches the track view from `ApplicationRef` and destroys it. Safe to call when no track exists. */ + private destroyTrack(): void { + if (!this.trackRef) { + return; + } + + this.appRef.detachView(this.trackRef.hostView); + this.trackRef.destroy(); + this.trackRef = null; + } + + /** The viewport's native scrollable element — the host this directive is applied to. */ + getNativeElement(): HTMLElement { + return this.scrollable.getElementRef().nativeElement; + } } /** - * Draggable thumb element: turns drags/track clicks into scroll positions of - * {@link KBQ_SCROLLBAR_VIEWPORT}, and mirrors its scroll position/size back onto its own CSS position. + * Draggable thumb element: turns drags/track clicks into scroll positions of the + * {@link KbqScrollbarViewport}, and mirrors its scroll position/size back onto its own CSS position. */ @Directive({ selector: '[kbqScrollbarThumb]', host: { role: 'scrollbar', '[attr.aria-orientation]': 'orientation()', - '[attr.aria-controls]': 'viewport.nativeElement.id', + '[attr.aria-controls]': 'viewportElement.id', '[attr.aria-valuemin]': '0', '[attr.aria-valuemax]': '100' }, exportAs: 'kbqScrollbarThumb' }) class KbqScrollbarThumb { - /** @docs-private */ - protected readonly viewport = inject(KBQ_SCROLLBAR_VIEWPORT); + private readonly viewport = inject(KbqScrollbarViewport); + private readonly directionality = inject(Directionality); + /** The scroll viewport's element, whose scroll state this thumb reflects and controls. @docs-private */ + protected readonly viewportElement = this.viewport.getNativeElement(); private readonly nativeElement = kbqInjectNativeElement(); private readonly style = this.nativeElement.style; /** Axis the thumb scrolls along — `'vertical'` (default) or `'horizontal'`. */ - readonly orientation = input.required({ alias: 'kbqScrollbarThumbOrientation' }); + readonly orientation = input.required(); constructor() { merge( @@ -417,21 +483,18 @@ class KbqScrollbarThumb { ) .pipe(takeUntilDestroyed()) .subscribe(([top, left]) => { - this.viewport.nativeElement.style.scrollBehavior = 'auto'; + this.viewportElement.style.scrollBehavior = 'auto'; if (this.orientation() === 'horizontal') { - this.viewport.nativeElement.scrollLeft = left; + this.viewportElement.scrollLeft = left; } else { - this.viewport.nativeElement.scrollTop = top; + this.viewportElement.scrollTop = top; } - this.viewport.nativeElement.style.scrollBehavior = ''; + this.viewportElement.style.scrollBehavior = ''; }); - merge( - animationFrame().pipe(throttleTime(100, zoneFreeScheduler())), - fromEvent(this.viewport.nativeElement, 'scroll').pipe(zoneFree()) - ) + merge(animationFrame().pipe(throttleTime(100, zoneFreeScheduler())), this.viewport.scrollChanges) .pipe( zoneFree(), map(() => this.getDimension()), @@ -459,11 +522,11 @@ class KbqScrollbarThumb { private getScrolled({ clientY, clientX }: MouseEvent, offsetY: number, offsetX: number): ScrollPosition { const { offsetHeight, offsetWidth } = this.nativeElement; const { top, left, right, width, height } = this.nativeElement.parentElement!.getBoundingClientRect(); - const rtl = this.nativeElement.matches('[dir="rtl"] :scope'); + const rtl = this.directionality.value === 'rtl'; const inline = rtl ? right : left; const multiplier = rtl ? -1 : 1; - const maxTop = this.viewport.nativeElement.scrollHeight - height; - const maxLeft = this.viewport.nativeElement.scrollWidth - width; + const maxTop = this.viewportElement.scrollHeight - height; + const maxLeft = this.viewportElement.scrollWidth - width; const scrolledTop = (clientY - top - offsetHeight * offsetY) / (height - offsetHeight); const scrolledLeft = (clientX - inline - offsetWidth * offsetX * multiplier) / (width - offsetWidth); @@ -479,8 +542,7 @@ class KbqScrollbarThumb { } private getDimension(): Dimension { - const { scrollTop, scrollHeight, clientHeight, scrollLeft, scrollWidth, clientWidth } = - this.viewport.nativeElement; + const { scrollTop, scrollHeight, clientHeight, scrollLeft, scrollWidth, clientWidth } = this.viewportElement; return { scrollTop, scrollHeight, clientHeight, scrollLeft, scrollWidth, clientWidth }; } @@ -539,7 +601,7 @@ class KbqScrollbarThumb { } /** - * Renders the visual scroll bars/thumbs for {@link KBQ_SCROLLBAR_VIEWPORT}. + * Renders the visual scroll bars/thumbs for the {@link KbqScrollbarViewport}. * * Created and positioned exclusively by `KbqScrollbarViewport` — not exported, never place this * directly in a template. It only ever exists for `kbqScrollbarMode="hover"`/`"always"` (`KbqScrollbarViewport` @@ -557,7 +619,7 @@ class KbqScrollbarThumb { [class.kbq-scrollbar-track__bar_has-horizontal]="visibility()[1]" (mousedown)="$event.preventDefault()" > -
+
} @if (visibility()[1]) { @@ -568,11 +630,7 @@ class KbqScrollbarThumb { [class.kbq-scrollbar-track__bar_has-vertical]="visibility()[0]" (mousedown)="$event.preventDefault()" > -
+
} `, @@ -581,15 +639,19 @@ class KbqScrollbarThumb { host: { class: 'kbq-scrollbar-track', '[class.kbq-scrollbar-track_hover]': "mode() === 'hover'", - '[style.block-size.px]': 'viewportBlockSize() - 1', - '[style.margin-block-end.px]': '-(viewportBlockSize() - 1)' + '[class.kbq-scrollbar-track_revealed]': 'revealed()' } }) class KbqScrollbarTrack { - private readonly viewport = inject(KBQ_SCROLLBAR_VIEWPORT); + private readonly viewport = inject(KbqScrollbarViewport); + private readonly viewportElement = this.viewport.getNativeElement(); + private readonly window = inject(KBQ_WINDOW); + private readonly renderer = inject(Renderer2); + private readonly resizeObserver = inject(SharedResizeObserver); + private readonly nativeElement = kbqInjectNativeElement(); protected readonly visibility = toSignal( animationFrame().pipe( - throttleTime(300, zoneFreeScheduler()), + throttleTime(TRACK_THROTTLE_TIME, zoneFreeScheduler()), map(() => this.scrollbars), startWith([false, false] as const), distinctUntilChanged((a, b) => a[0] === b[0] && a[1] === b[1]), @@ -597,17 +659,21 @@ class KbqScrollbarTrack { ), { requireSync: true } ); + /** - * The scroll viewport's pixel height. The sticky track remains in normal flow, so an equal negative - * `margin-block-end` cancels its height without shifting content or increasing the scrollable area. - * Both values must use pixels because percentage block margins resolve against the viewport's inline - * size rather than its block size. + * Whether the hover-mode track is transiently revealed — `true` on each scroll event or + * {@link KbqScrollbarViewport.flashScrollIndicators} call, cleared `hideDelay` ms after the last one + * (`switchMap` restarts the hide timer on every trigger, so continuous scrolling keeps it `true`). + * Shows the track on wheel/keyboard scrolling, on the programmatic scroll-into-view when a dropdown + * opens by mouse, and on an explicit flash — matching native/overlayscrollbars. */ - protected readonly viewportBlockSize = toSignal( - animationFrame().pipe( - throttleTime(300, zoneFreeScheduler()), - map(() => this.viewport.nativeElement.clientHeight), - startWith(0), + protected readonly revealed = toSignal( + // `scrollChanges` (CdkScrollable.elementScrolled) already emits outside Angular's zone. + merge(this.viewport.scrollChanges, this.viewport.flashes).pipe( + // No `zoneFreeScheduler()` on `timer`: it would `inject()` inside this per-scroll `switchMap` + // callback — outside an injection context — and throw. The chain is already zone-free. + switchMap(() => concat(of(true), timer(this.hideDelay()).pipe(map(() => false)))), + startWith(false), distinctUntilChanged(), zoneOptimized() ), @@ -615,16 +681,83 @@ class KbqScrollbarTrack { ); /** Visibility mode, forwarded from the owning {@link KbqScrollbarViewport}; only `hover`/`always` reach the track. */ - readonly mode = input.required({ alias: 'kbqScrollbarMode' }); + readonly mode = input.required(); + + /** Scroll-reveal hide delay (ms), forwarded from the owning {@link KbqScrollbarViewport}. */ + readonly hideDelay = input.required(); + + constructor() { + // Reapply the geometry whenever the viewport's box changes. `SharedResizeObserver` fires only on an + // actual size/padding change (a padding change shifts the content box too), not every frame, so + // `getComputedStyle` runs only when something changed — and its `shareReplay` delivers the current + // size on subscribe, applying the initial geometry right away. Writes styles directly, no change + // detection. In SSR (no `ResizeObserver`) the stream simply never emits. + this.resizeObserver + .observe(this.viewportElement) + .pipe( + map(() => this.getViewportMetrics()), + distinctUntilChanged( + (a, b) => + a.blockSize === b.blockSize && + a.inlineSize === b.inlineSize && + a.paddingBlockStart === b.paddingBlockStart && + a.paddingInlineStart === b.paddingInlineStart + ), + takeUntilDestroyed() + ) + .subscribe((metrics) => this.applyGeometry(metrics)); + } private get scrollbars(): ScrollbarVisibility { - const { clientHeight, scrollHeight, clientWidth, scrollWidth } = this.viewport.nativeElement; + const { clientHeight, scrollHeight, clientWidth, scrollWidth } = this.viewportElement; return [ Math.ceil((clientHeight / scrollHeight) * 100) < 100, Math.ceil((clientWidth / scrollWidth) * 100) < 100 ]; } + + /** + * The scroll viewport's inner size and start padding on both axes: `blockSize`/`inlineSize` (its + * `clientHeight`/`clientWidth`, i.e. the padding box) and `paddingBlockStart`/`paddingInlineStart` + * (logical, so RTL flips the inline start to the right edge). + */ + private getViewportMetrics(): ViewportMetrics { + const element = this.viewportElement; + const style = this.window.getComputedStyle(element); + + return { + blockSize: element.clientHeight, + inlineSize: element.clientWidth, + paddingBlockStart: parseFloat(style.paddingBlockStart) || 0, + paddingInlineStart: parseFloat(style.paddingInlineStart) || 0 + }; + } + + /** + * Aligns the sticky track to the scrollport (the padding box) on both axes, flush at every scroll + * position. Without it the viewport's own padding pushes the track onto the content box, so it overhangs + * the scrollport end (block axis) and the bar sits `padding-inline-end` inside the scrollport end (inline + * axis). Each axis uses the same three-part trick: + * - `margin-*-start` lifts the track's box over the start padding to the scrollport start edge; + * - `inset-*-start` keeps it pinned there (not at the content-box start) once scrolled; + * - `margin-*-end` cancels the track's size in flow AND compensates the lifting `margin-*-start`, so the + * net layout contribution stays zero and content isn't shifted. + */ + private applyGeometry({ blockSize, inlineSize, paddingBlockStart, paddingInlineStart }: ViewportMetrics): void { + const setStyle = (property: string, value: number) => + this.renderer.setStyle(this.nativeElement, property, coerceCssPixelValue(value)); + + setStyle('blockSize', blockSize - 1); + setStyle('marginBlockStart', -paddingBlockStart); + setStyle('marginBlockEnd', paddingBlockStart - (blockSize - 1)); + setStyle('insetBlockStart', -paddingBlockStart); + setStyle('minInlineSize', inlineSize - 1); + setStyle('maxInlineSize', inlineSize - 1); + setStyle('marginInlineStart', -paddingInlineStart); + setStyle('marginInlineEnd', paddingInlineStart - (inlineSize - 1)); + setStyle('insetInlineStart', -paddingInlineStart); + } } /** Custom scrollbar wrapper: projects content and overlays a scrollbar track over it (created by its {@link KbqScrollbarViewport} host directive). */ @@ -637,7 +770,9 @@ class KbqScrollbarTrack { `, styleUrl: './scrollbar.scss', changeDetection: ChangeDetectionStrategy.OnPush, - hostDirectives: [{ directive: KbqScrollbarViewport, inputs: ['kbqScrollbarMode'] }], + hostDirectives: [ + { directive: KbqScrollbarViewport, inputs: ['kbqScrollbarMode', 'kbqScrollbarHideDelay'] } + ], exportAs: 'kbqScrollbar' }) export class KbqScrollbar { @@ -647,6 +782,18 @@ export class KbqScrollbar { /** Visibility mode for the scrollbar. Defaults to the app-wide {@link KBQ_SCROLLBAR_OPTIONS}. */ readonly mode = input(this.options.mode, { alias: 'kbqScrollbarMode' }); + /** + * How long the scroll-revealed `hover`-mode track stays visible after scrolling stops, in milliseconds. + * Defaults to the app-wide {@link KBQ_SCROLLBAR_OPTIONS}. + */ + readonly hideDelay = input(this.options.hideDelay, { + alias: 'kbqScrollbarHideDelay', + transform: numberAttribute + }); + + /** Emits on every native `scroll` event of the viewport. Emits outside Angular's zone — see `CdkScrollable.elementScrolled`. */ + readonly scrollChanges = this.viewport.scrollChanges; + /** The scrollbar's native scrollable element. */ getNativeElement(): HTMLElement { return this.viewport.getNativeElement(); @@ -687,8 +834,8 @@ export class KbqScrollbar { this.viewport.scrollIntoView(target, behavior); } - /** Emits on every native `scroll` event of the viewport. Emits outside Angular's zone — see `CdkScrollable.elementScrolled`. */ - get scrollChanges(): Observable { - return this.viewport.scrollChanges; + /** Briefly reveals the scrollbar track — see {@link KbqScrollbarViewport.flashScrollIndicators}. */ + flashScrollIndicators(): void { + this.viewport.flashScrollIndicators(); } } diff --git a/packages/components/select/__screenshots__/02-dark.png b/packages/components/select/__screenshots__/02-dark.png index 322b6227e8..e40e795eca 100644 Binary files a/packages/components/select/__screenshots__/02-dark.png and b/packages/components/select/__screenshots__/02-dark.png differ diff --git a/packages/components/select/__screenshots__/02-light.png b/packages/components/select/__screenshots__/02-light.png index f04007fd47..3e4bc79799 100644 Binary files a/packages/components/select/__screenshots__/02-light.png and b/packages/components/select/__screenshots__/02-light.png differ diff --git a/packages/components/select/__screenshots__/07-light.png b/packages/components/select/__screenshots__/07-light.png new file mode 100644 index 0000000000..49466249ce Binary files /dev/null and b/packages/components/select/__screenshots__/07-light.png differ diff --git a/packages/components/select/e2e.playwright-spec.ts b/packages/components/select/e2e.playwright-spec.ts index d1d155dd06..409cf2e81b 100644 --- a/packages/components/select/e2e.playwright-spec.ts +++ b/packages/components/select/e2e.playwright-spec.ts @@ -84,6 +84,121 @@ async function panelScrollTop(page: Page): Promise { } test.describe('KbqSelectModule', () => { + test.describe('E2eSelectScrollbar', () => { + const getSelect = (page: Page) => page.getByTestId('e2eSelect'); + const getContent = (page: Page) => page.locator('.kbq-select__content'); + const getTrack = (page: Page) => getContent(page).locator('kbq-scrollbar-track'); + const getVerticalThumb = (page: Page) => + getContent(page).locator('.kbq-scrollbar-track__bar_vertical .kbq-scrollbar-track__thumb'); + + test.beforeEach(async ({ page }) => { + await page.goto('/E2eSelectScrollbar'); + await getSelect(page).click(); + await expect(getContent(page)).toBeVisible(); + }); + + test('flashes the track on open, then fades it', async ({ page }) => { + // The panel opens on the first (already-visible) option with no scroll, so the open-flash is + // the only thing that reveals the track here — no hover, no scroll. + const track = getTrack(page); + + await expect(track).toHaveCSS('opacity', '1'); + // ...and it fades back out again after the hide delay. + await expect(track).toHaveCSS('opacity', '0'); + }); + + test('hides the native scrollbar and reveals the custom track on hover', async ({ page }) => { + await expect(getContent(page)).toHaveClass(/kbq-scrollbar-viewport_native-scrollbar-hidden/); + + const track = getTrack(page); + + await expect(track).toBeAttached(); + // Wait out the open-flash so hover is tested in isolation. + await expect(track).toHaveCSS('opacity', '0'); + + await getContent(page).hover(); + await expect(track).toHaveCSS('opacity', '1'); + }); + + test('clicking the scrollbar thumb keeps the panel open', async ({ page }) => { + await getContent(page).hover(); + await getVerticalThumb(page).click(); + + await expect(getContent(page)).toBeVisible(); + }); + + test('renders the custom scrollbar', async ({ page }) => { + const track = getTrack(page); + + // Hover keeps the hover track revealed (opacity 1) deterministically for the screenshot. + await getContent(page).hover(); + await expect(track).toHaveCSS('opacity', '1'); + await expect(getContent(page)).toHaveScreenshot('07-light.png'); + }); + }); + + test.describe('E2eVirtualScrollSelectScrollbar', () => { + const getSelect = (page: Page) => page.getByTestId('e2eSelect'); + // The consumer puts kbqScrollbarViewport on the cdk-virtual-scroll-viewport, so the custom track + // that matters lives inside that viewport — not inside the outer .kbq-select__content viewport. + const getViewport = (page: Page) => page.locator('.cdk-overlay-pane .cdk-virtual-scroll-viewport'); + const getTrack = (page: Page) => getViewport(page).locator('kbq-scrollbar-track'); + const getVerticalThumb = (page: Page) => + getViewport(page).locator('.kbq-scrollbar-track__bar_vertical .kbq-scrollbar-track__thumb'); + + test.beforeEach(async ({ page }) => { + await page.goto('/E2eVirtualScrollSelectScrollbar'); + await getSelect(page).click(); + await expect(getViewport(page)).toBeVisible(); + }); + + test('flashes the track on open, then fades it', async ({ page }) => { + // The selected option (index 0) is already visible, so scrollToIndex(0) does not scroll — the + // open-flash is the only thing that reveals the projected virtual-scroll viewport's track. + const track = getTrack(page); + + await expect(track).toHaveCSS('opacity', '1'); + // ...and it fades back out again after the hide delay. + await expect(track).toHaveCSS('opacity', '0'); + }); + + test('hides the native scrollbar and reveals the custom track on hover', async ({ page }) => { + await expect(getViewport(page)).toHaveClass(/kbq-scrollbar-viewport_native-scrollbar-hidden/); + + const track = getTrack(page); + + await expect(track).toBeAttached(); + // Wait out the open-flash so hover is tested in isolation. + await expect(track).toHaveCSS('opacity', '0'); + + await getViewport(page).hover(); + await expect(track).toHaveCSS('opacity', '1'); + }); + + test('thumb tracks the virtual viewport scroll position', async ({ page }) => { + await getViewport(page).hover(); + + const thumb = getVerticalThumb(page); + + await expect(thumb).toBeVisible(); + const before = await box(thumb); + + // Scroll the virtual viewport far down through the 10k-item list. + await page.evaluate(() => { + document.querySelector('.cdk-overlay-pane .cdk-virtual-scroll-viewport')!.scrollTop = 4000; + }); + + await expect.poll(async () => (await box(thumb)).y).toBeGreaterThan(before.y); + }); + + test('clicking the scrollbar thumb keeps the panel open', async ({ page }) => { + await getViewport(page).hover(); + await getVerticalThumb(page).click(); + + await expect(getViewport(page)).toBeVisible(); + }); + }); + test.describe('E2eSelectStates', () => { const getComponent = (page: Page) => page.getByTestId('e2eSelectStates'); const getSelect = (locator: Locator) => locator.getByTestId('e2eSelect'); diff --git a/packages/components/select/e2e.ts b/packages/components/select/e2e.ts index de156d38be..01ef10b9c5 100644 --- a/packages/components/select/e2e.ts +++ b/packages/components/select/e2e.ts @@ -5,6 +5,7 @@ import { FormsModule, ReactiveFormsModule, UntypedFormControl } from '@angular/f import { KbqFormFieldModule } from '@koobiq/components/form-field'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqInputModule } from '@koobiq/components/input'; +import { KbqScrollbarViewport } from '@koobiq/components/scrollbar'; import { KbqTagsModule } from '@koobiq/components/tags'; import { EMPTY } from 'rxjs'; import { KbqSelect } from './select.component'; @@ -753,3 +754,80 @@ export class E2eVirtualScrollSelectPanelMaxHeight { } }) export class E2eSelectSelectAllStates {} + +@Component({ + selector: 'e2e-select-scrollbar', + imports: [ + KbqSelectModule, + FormsModule + ], + template: ` + + + @for (option of options; track option) { + {{ option }} + } + + + `, + styles: ` + :host { + display: flex; + justify-content: center; + + width: 350px; + height: 500px; + padding: 8px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2eSelectScrollbar' + } +}) +export class E2eSelectScrollbar { + protected readonly options = Array.from({ length: 40 }).map((_, i) => `Option ${i}`); +} + +@Component({ + selector: 'e2e-virtual-scroll-select-scrollbar', + imports: [KbqSelectModule, ScrollingModule, KbqScrollbarViewport], + template: ` + + + + {{ option }} + + + + `, + styles: ` + :host { + display: flex; + justify-content: center; + + width: 350px; + height: 500px; + padding: 8px; + } + + .kbq-form-field { + width: 320px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2eVirtualScrollSelectScrollbar' + } +}) +export class E2eVirtualScrollSelectScrollbar { + protected readonly options = Array.from({ length: 10000 }).map((_, i) => `Option ${i}`); + protected readonly viewport = viewChild.required(CdkVirtualScrollViewport); + + protected selected = this.options[0]; + + protected openedChange(isOpened: boolean): void { + if (!isOpened) return; + this.viewport().scrollToIndex(this.options.indexOf(this.selected)); + } +} diff --git a/packages/components/select/select.component.spec.ts b/packages/components/select/select.component.spec.ts index 76171cce05..f5f07e2ece 100644 --- a/packages/components/select/select.component.spec.ts +++ b/packages/components/select/select.component.spec.ts @@ -2351,7 +2351,9 @@ describe('KbqSelect', () => { provide: ScrollDispatcher, useFactory: () => ({ scrolled: () => scrolledSubject.asObservable(), - getAncestorScrollContainers: () => [] + getAncestorScrollContainers: () => [], + register: () => {}, + deregister: () => {} }) } ] diff --git a/packages/components/select/select.component.ts b/packages/components/select/select.component.ts index 0280e69d8c..49ae6c38a3 100644 --- a/packages/components/select/select.component.ts +++ b/packages/components/select/select.component.ts @@ -114,6 +114,7 @@ import { kbqCleanerFactoryProvider } from '@koobiq/components/form-field'; import { KbqIconModule } from '@koobiq/components/icon'; +import { KBQ_SCROLLBAR_OPTIONS, KbqScrollbarViewport, type KbqScrollbarMode } from '@koobiq/components/scrollbar'; import { KbqTag } from '@koobiq/components/tags'; import { SizeXxs as SelectSizeMultipleContentGap } from '@koobiq/design-tokens'; import { BehaviorSubject, EMPTY, Observable, Subject, Subscription, defer, fromEvent, merge } from 'rxjs'; @@ -205,7 +206,8 @@ export const minimumTimeToDisplayLoading = 300; CdkConnectedOverlay, KbqIconModule, KbqOption, - KbqPseudoCheckbox + KbqPseudoCheckbox, + KbqScrollbarViewport ], templateUrl: 'select.html', styleUrls: ['./select.scss', './select-tokens.scss'], @@ -285,6 +287,7 @@ export class KbqSelect protected readonly defaultOptions = inject(KBQ_SELECT_OPTIONS, { optional: true }); private readonly window = inject(KBQ_WINDOW); + private readonly scrollbarOptions = inject(KBQ_SCROLLBAR_OPTIONS); /** Whether the component is in an error state. */ errorState: boolean = false; @@ -382,6 +385,17 @@ export class KbqSelect /** Reference to the container element that holds the options. */ readonly optionsContainer = viewChild.required('optionsContainer'); + /** The options container's custom scrollbar viewport, flashed when the panel opens. */ + private readonly scrollbarViewport = viewChild(KbqScrollbarViewport); + + /** + * A custom scrollbar a consumer projects onto the panel's own scroller — under virtual scroll the + * options container is `native` (never overflows), so the real scroller is the projected + * `cdk-virtual-scroll-viewport` carrying `kbqScrollbarViewport`. Flashed on open alongside the + * container's; whichever is inert (`native`) no-ops. + */ + private readonly projectedScrollbarViewport = contentChild(KbqScrollbarViewport, { descendants: true }); + /** Reference to the built-in "select all" row, rendered only while `selectAll` is on. */ readonly selectAllOption = viewChild(KbqOption); @@ -895,6 +909,17 @@ export class KbqSelect /** Whether virtual scrolling is enabled for the options panel. */ withVirtualScroll: boolean; + /** + * Scrollbar mode for the options container. Falls back to `native` under virtual scroll: there the + * `cdk-virtual-scroll-viewport` is the real scroller, so this container never overflows and its custom + * track would only ever be an inert element — a consumer opts the viewport itself into a custom + * scrollbar with `kbqScrollbarViewport` instead. + * @docs-private + */ + protected get scrollbarMode(): KbqScrollbarMode { + return this.withVirtualScroll ? 'native' : this.scrollbarOptions.mode; + } + private _focused = false; /** Whether the search returned no results. */ @@ -1113,6 +1138,11 @@ export class KbqSelect search.focus(); } + // Briefly reveal the scrollbar to hint that the list is scrollable — the panel may open + // on an already-visible option with no scroll, so the hover track would otherwise stay hidden. + this.scrollbarViewport()?.flashScrollIndicators(); + this.projectedScrollbarViewport()?.flashScrollIndicators(); + this.openedChange.emit(true); } else { this.openedChange.emit(false); diff --git a/packages/components/select/select.html b/packages/components/select/select.html index 0e8f2d5f34..6a4d8f5583 100644 --- a/packages/components/select/select.html +++ b/packages/components/select/select.html @@ -119,8 +119,9 @@
diff --git a/packages/components/sidepanel/__screenshots__/01-light.png b/packages/components/sidepanel/__screenshots__/01-light.png index c92c7de414..8b09269172 100644 Binary files a/packages/components/sidepanel/__screenshots__/01-light.png and b/packages/components/sidepanel/__screenshots__/01-light.png differ diff --git a/packages/components/sidepanel/__screenshots__/02-light.png b/packages/components/sidepanel/__screenshots__/02-light.png index 74c1c75f02..b6be60b8f1 100644 Binary files a/packages/components/sidepanel/__screenshots__/02-light.png and b/packages/components/sidepanel/__screenshots__/02-light.png differ diff --git a/packages/components/sidepanel/__screenshots__/03-light.png b/packages/components/sidepanel/__screenshots__/03-light.png index a990587206..d20f33b830 100644 Binary files a/packages/components/sidepanel/__screenshots__/03-light.png and b/packages/components/sidepanel/__screenshots__/03-light.png differ diff --git a/packages/components/sidepanel/__screenshots__/04-dark.png b/packages/components/sidepanel/__screenshots__/04-dark.png index 855778f196..4aa6627c7f 100644 Binary files a/packages/components/sidepanel/__screenshots__/04-dark.png and b/packages/components/sidepanel/__screenshots__/04-dark.png differ diff --git a/packages/components/sidepanel/__screenshots__/04-light.png b/packages/components/sidepanel/__screenshots__/04-light.png index d82783aec5..a12877f05f 100644 Binary files a/packages/components/sidepanel/__screenshots__/04-light.png and b/packages/components/sidepanel/__screenshots__/04-light.png differ diff --git a/packages/components/sidepanel/__screenshots__/05-light.png b/packages/components/sidepanel/__screenshots__/05-light.png new file mode 100644 index 0000000000..fe3514e107 Binary files /dev/null and b/packages/components/sidepanel/__screenshots__/05-light.png differ diff --git a/packages/components/sidepanel/e2e.playwright-spec.ts b/packages/components/sidepanel/e2e.playwright-spec.ts index eaf8b1c63b..bffd379eb9 100644 --- a/packages/components/sidepanel/e2e.playwright-spec.ts +++ b/packages/components/sidepanel/e2e.playwright-spec.ts @@ -92,4 +92,53 @@ test.describe('KbqSidepanel', () => { await expect.poll(() => e2eHasOverflowShadow(page.locator('.kbq-sidepanel-footer'))).toBeTruthy(); }); }); + + test.describe('E2eSidepanelScrollbar', () => { + const getBody = (page: Page) => page.locator('.kbq-sidepanel-body'); + const getTrack = (page: Page) => getBody(page).locator('kbq-scrollbar-track'); + + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 640, height: 300 }); + await page.goto('/E2eSidepanelStateAndStyle'); + await page.getByTestId('e2eSidepanelMedium').click(); + await getBody(page).waitFor({ state: 'visible' }); + }); + + test('flashes the track on open, then fades it', async ({ page }) => { + // The sidepanel opens scrolled to the top with no interaction, so the open-flash is the only + // thing that reveals the track here — no hover, no scroll. + const track = getTrack(page); + + await expect(track).toHaveCSS('opacity', '1'); + // ...and it fades back out again after the hide delay. + await expect(track).toHaveCSS('opacity', '0'); + }); + + test('hides the native scrollbar and reveals the custom track on hover', async ({ page }) => { + await expect(getBody(page)).toHaveClass(/kbq-scrollbar-viewport_native-scrollbar-hidden/); + + const track = getTrack(page); + + await expect(track).toBeAttached(); + // Wait out the open-flash so hover is tested in isolation. + await expect(track).toHaveCSS('opacity', '0'); + + // `force: true` skips the actionability "stable" wait: the track's own scroll-position + // `requestAnimationFrame` loop repaints every frame, which webkit intermittently reports as the + // body never settling. The reveal only needs the pointer over the viewport, so force is safe here. + await getBody(page).hover({ force: true }); + await expect(track).toHaveCSS('opacity', '1'); + }); + + test('renders the custom scrollbar', async ({ page }) => { + const track = getTrack(page); + + // Hover keeps the hover track revealed (opacity 1) deterministically for the screenshot; `force` + // skips the actionability "stable" wait that webkit flakes on under the track's rAF repaints. Only + // the light theme is captured — the scrollbar's own suite covers dark, so it's redundant here. + await getBody(page).hover({ force: true }); + await expect(track).toHaveCSS('opacity', '1'); + await expect(getBody(page)).toHaveScreenshot('05-light.png'); + }); + }); }); diff --git a/packages/components/sidepanel/sidepanel-directives.ts b/packages/components/sidepanel/sidepanel-directives.ts index f384230f42..d05366aba2 100644 --- a/packages/components/sidepanel/sidepanel-directives.ts +++ b/packages/components/sidepanel/sidepanel-directives.ts @@ -11,9 +11,11 @@ import { OnInit, SimpleChanges } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { KbqButtonModule } from '@koobiq/components/button'; import { kbqInjectA11yLocaleConfiguration, KbqOverflowShadowContainer } from '@koobiq/components/core'; import { KbqIconModule } from '@koobiq/components/icon'; +import { KbqScrollbarViewport } from '@koobiq/components/scrollbar'; import { KbqTitleDirective } from '@koobiq/components/title'; import { KbqSidepanelRef } from './sidepanel-ref'; import { KbqSidepanelService } from './sidepanel.service'; @@ -117,16 +119,24 @@ export class KbqSidepanelHeader { @Directive({ selector: 'kbq-sidepanel-body, [kbq-sidepanel-body], kbqSidepanelBody', host: { - class: 'kbq-sidepanel-body kbq-scrollbar' + class: 'kbq-sidepanel-body' }, - hostDirectives: [KbqOverflowShadowContainer] + hostDirectives: [KbqOverflowShadowContainer, KbqScrollbarViewport] }) export class KbqSidepanelBody { private readonly sidepanelRef = inject(KbqSidepanelRef); private readonly overflowContainer = inject(KbqOverflowShadowContainer); + private readonly scrollbarViewport = inject(KbqScrollbarViewport); constructor() { effect(() => this.sidepanelRef.bodyOverflow.set(this.overflowContainer.overflow())); + + // Briefly reveal the scrollbar once the sidepanel has opened to hint the body is scrollable — it + // may open with no scroll, so its hover track would otherwise stay hidden until the pointer enters. + this.sidepanelRef + .afterOpened() + .pipe(takeUntilDestroyed()) + .subscribe(() => this.scrollbarViewport.flashScrollIndicators()); } } diff --git a/packages/components/sidepanel/sidepanel.spec.ts b/packages/components/sidepanel/sidepanel.spec.ts index 3c328fe01e..b4be8a8179 100644 --- a/packages/components/sidepanel/sidepanel.spec.ts +++ b/packages/components/sidepanel/sidepanel.spec.ts @@ -374,6 +374,20 @@ describe('KbqSidepanelService', () => { expect(submitSpy).not.toHaveBeenCalled(); })); + it('renders the custom scrollbar on the body', fakeAsync(() => { + sidepanelService.open(SidepanelWithFormComponent); + + rootComponentFixture.detectChanges(); + flush(); + + const body = overlayContainerElement.querySelector('.kbq-sidepanel-body')!; + + // `KbqScrollbarViewport` applies these host classes, so their presence proves the custom + // scrollbar replaced the deprecated `.kbq-scrollbar` native styling. + expect(body.classList).toContain('kbq-scrollbar-viewport'); + expect(body.classList).toContain('kbq-scrollbar-viewport_native-scrollbar-hidden'); + })); + it('should set focus inside modal when opened by dropdown', fakeAsync(() => { const activeElement: HTMLElement | null = document.activeElement as HTMLElement; const fixtureComponent = TestBed.createComponent(SidepanelFromDropdownComponent); diff --git a/packages/components/tabs/tab-body.component.ts b/packages/components/tabs/tab-body.component.ts index 4ad4d93874..3752dd854d 100644 --- a/packages/components/tabs/tab-body.component.ts +++ b/packages/components/tabs/tab-body.component.ts @@ -22,6 +22,7 @@ import { output, viewChild } from '@angular/core'; +import { KbqNativeScrollbar } from '@koobiq/components/scrollbar'; import { Subscription } from 'rxjs'; import { startWith } from 'rxjs/operators'; import { kbqTabsAnimations } from './tabs-animations'; @@ -52,7 +53,7 @@ export type KbqTabBodyOriginState = 'left' | 'right'; */ @Component({ selector: 'kbq-tab-body', - imports: [CdkScrollable, forwardRef(() => KbqTabBodyPortal)], + imports: [CdkScrollable, KbqNativeScrollbar, forwardRef(() => KbqTabBodyPortal)], templateUrl: './tab-body.html', styleUrl: './tab-body.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/packages/components/tabs/tab-body.html b/packages/components/tabs/tab-body.html index f4ee5a2a47..a4a7325af2 100644 --- a/packages/components/tabs/tab-body.html +++ b/packages/components/tabs/tab-body.html @@ -1,7 +1,8 @@
+ @for (group of data; track group) { + + @for (timezone of group.zones; track timezone) { + + } + + } + + + `, + styles: ` + :host { + display: flex; + justify-content: center; + padding: var(--kbq-size-l); + width: 320px; + height: 500px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2eTimezoneScrollbar' + } +}) +export class E2eTimezoneScrollbar extends BaseTimezoneStates {} diff --git a/packages/components/timezone/timezone-select.component.html b/packages/components/timezone/timezone-select.component.html index 90d387d128..a5c2df462d 100644 --- a/packages/components/timezone/timezone-select.component.html +++ b/packages/components/timezone/timezone-select.component.html @@ -71,7 +71,9 @@
diff --git a/packages/components/timezone/timezone-select.component.spec.ts b/packages/components/timezone/timezone-select.component.spec.ts index a0402acd56..d57edcf399 100644 --- a/packages/components/timezone/timezone-select.component.spec.ts +++ b/packages/components/timezone/timezone-select.component.spec.ts @@ -259,7 +259,9 @@ describe('KbqTimezoneSelect', () => { provide: ScrollDispatcher, useFactory: () => ({ scrolled: () => scrolledSubject.asObservable(), - getAncestorScrollContainers: () => [] + getAncestorScrollContainers: () => [], + register: () => {}, + deregister: () => {} }) } ] @@ -928,7 +930,11 @@ describe('KbqTimezoneSelect', () => { it('should display tooltip when option text wraps beyond the visible rows count', fakeAsync(() => { trigger.click(); fixture.detectChanges(); - flush(); + // Not `flush()`: the panel now hosts `kbqScrollbarViewport`, whose track drives a + // self-rescheduling `requestAnimationFrame` loop that `flush()` can never drain (it hits the + // 20-task limit). `tick` advances a fixed span and `discardPeriodicTasks()` clears the still + // -pending scrollbar tasks at the end. + tick(500); const optionInstances = fixture.componentInstance.options(); const tooltipContentEl = optionInstances[2].tooltipContent().nativeElement; @@ -944,7 +950,7 @@ describe('KbqTimezoneSelect', () => { dispatchMouseEvent(optionEls[2], 'mouseenter'); fixture.detectChanges(); - flush(); + tick(500); discardPeriodicTasks(); const tooltips = document.querySelectorAll('.kbq-tooltip__content'); diff --git a/packages/components/timezone/timezone-select.component.ts b/packages/components/timezone/timezone-select.component.ts index b4e070704f..b37a4850e8 100644 --- a/packages/components/timezone/timezone-select.component.ts +++ b/packages/components/timezone/timezone-select.component.ts @@ -13,6 +13,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { KBQ_OPTION_PARENT_COMPONENT, kbqSiblingPopupProvider, ruRULocaleData } from '@koobiq/components/core'; import { kbqCleanerFactoryProvider, KbqFormFieldControl } from '@koobiq/components/form-field'; import { KbqIconModule } from '@koobiq/components/icon'; +import { KbqScrollbarViewport } from '@koobiq/components/scrollbar'; import { KbqSelect } from '@koobiq/components/select'; @Directive({ @@ -28,7 +29,8 @@ const defaultSearchPlaceholder = ruRULocaleData.timezone.searchPlaceholder; CdkOverlayOrigin, CdkConnectedOverlay, CdkMonitorFocus, - KbqIconModule + KbqIconModule, + KbqScrollbarViewport ], templateUrl: 'timezone-select.component.html', styleUrls: [ diff --git a/packages/components/tree-select/__screenshots__/04-light.png b/packages/components/tree-select/__screenshots__/04-light.png new file mode 100644 index 0000000000..c6902fad7b Binary files /dev/null and b/packages/components/tree-select/__screenshots__/04-light.png differ diff --git a/packages/components/tree-select/e2e.playwright-spec.ts b/packages/components/tree-select/e2e.playwright-spec.ts index e6a256a936..6a5ee55e10 100644 --- a/packages/components/tree-select/e2e.playwright-spec.ts +++ b/packages/components/tree-select/e2e.playwright-spec.ts @@ -76,6 +76,58 @@ async function probeTriggerPanelGap( } test.describe('KbqTreeSelectModule', () => { + test.describe('E2eTreeSelectScrollbar', () => { + const getContent = (page: Page) => page.locator('.kbq-tree-select__content'); + const getTrack = (page: Page) => getContent(page).locator('kbq-scrollbar-track'); + const getVerticalThumb = (page: Page) => + getContent(page).locator('.kbq-scrollbar-track__bar_vertical .kbq-scrollbar-track__thumb'); + + test.beforeEach(async ({ page }) => { + await page.goto('/E2eTreeSelectScrollbar'); + await page.getByTestId('e2eTreeSelect').click(); + await expect(getContent(page)).toBeVisible(); + }); + + test('flashes the track on open, then fades it', async ({ page }) => { + // The panel opens without scrolling, so the open-flash is the only thing that reveals the + // track here — no hover, no scroll. + const track = getTrack(page); + + await expect(track).toHaveCSS('opacity', '1'); + // ...and it fades back out again after the hide delay. + await expect(track).toHaveCSS('opacity', '0'); + }); + + test('hides the native scrollbar and reveals the custom track on hover', async ({ page }) => { + await expect(getContent(page)).toHaveClass(/kbq-scrollbar-viewport_native-scrollbar-hidden/); + + const track = getTrack(page); + + await expect(track).toBeAttached(); + // Wait out the open-flash so hover is tested in isolation. + await expect(track).toHaveCSS('opacity', '0'); + + await getContent(page).hover(); + await expect(track).toHaveCSS('opacity', '1'); + }); + + test('clicking the scrollbar thumb keeps the panel open', async ({ page }) => { + await getContent(page).hover(); + await getVerticalThumb(page).click(); + + await expect(getContent(page)).toBeVisible(); + }); + + test('renders the custom scrollbar', async ({ page }) => { + const track = getTrack(page); + + // Hover keeps the hover track revealed (opacity 1) deterministically for the screenshot. + await getContent(page).hover(); + await expect(track).toHaveCSS('opacity', '1'); + await expect(getContent(page)).toHaveScreenshot('04-light.png'); + }); + }); + test.describe('E2eTreeSelectStates', () => { const getComponent = (page: Page) => page.getByTestId('e2eTreeSelectStates'); const getTreeSelect = (locator: Locator) => locator.getByTestId('e2eTreeSelect'); diff --git a/packages/components/tree-select/e2e.ts b/packages/components/tree-select/e2e.ts index 1df2cb76b8..950ba4e45d 100644 --- a/packages/components/tree-select/e2e.ts +++ b/packages/components/tree-select/e2e.ts @@ -759,3 +759,52 @@ export class E2eTreeSelectPanelMaxHeight extends BaseTreeSelectStates {} } }) export class E2eMultiTreeSelectSelectAllStates extends BaseTreeSelectStates {} + +/** Flat list of many leaf nodes so the panel overflows and the custom scrollbar renders a thumb. */ +const SCROLLBAR_TREE_DATA = Array.from({ length: 40 }).reduce>((acc, _, i) => { + acc[`Item ${i}`] = 'ts'; + + return acc; +}, {}); + +@Component({ + selector: 'e2e-tree-select-scrollbar', + imports: [ + FormsModule, + KbqIconModule, + KbqInputModule, + KbqTreeModule, + KbqTreeSelectModule + ], + template: ` + + + + + + + + + + `, + styles: ` + :host { + display: flex; + + width: 320px; + height: 500px; + padding: 8px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2eTreeSelectScrollbar' + } +}) +export class E2eTreeSelectScrollbar extends BaseTreeSelectStates { + constructor() { + super(); + + this.dataSource.data = buildFileTree(SCROLLBAR_TREE_DATA, 0); + } +} diff --git a/packages/components/tree-select/tree-select.component.spec.ts b/packages/components/tree-select/tree-select.component.spec.ts index 0c98bf44a7..7aa6dd46d5 100644 --- a/packages/components/tree-select/tree-select.component.spec.ts +++ b/packages/components/tree-select/tree-select.component.spec.ts @@ -1866,7 +1866,9 @@ describe('KbqTreeSelect', () => { { provide: ScrollDispatcher, useFactory: () => ({ - scrolled: () => scrolledSubject.asObservable() + scrolled: () => scrolledSubject.asObservable(), + register: () => {}, + deregister: () => {} }) }, ...providers diff --git a/packages/components/tree-select/tree-select.component.ts b/packages/components/tree-select/tree-select.component.ts index 00017fda97..d5902428e2 100644 --- a/packages/components/tree-select/tree-select.component.ts +++ b/packages/components/tree-select/tree-select.component.ts @@ -95,6 +95,7 @@ import { kbqCleanerFactoryProvider } from '@koobiq/components/form-field'; import { KbqIconModule } from '@koobiq/components/icon'; +import { KbqScrollbarViewport } from '@koobiq/components/scrollbar'; import { KbqTag, KbqTagRemove } from '@koobiq/components/tags'; import { KbqTree, KbqTreeOption, KbqTreeSelection } from '@koobiq/components/tree'; import { SizeXxs as SelectSizeMultipleContentGap } from '@koobiq/design-tokens'; @@ -177,6 +178,7 @@ export class KbqTreeSelectChange { CdkConnectedOverlay, CdkMonitorFocus, KbqTag, + KbqScrollbarViewport, NgTemplateOutlet ], templateUrl: 'tree-select.html', @@ -332,6 +334,9 @@ export class KbqTreeSelect /** Reference to the overlay panel element. */ readonly panel = viewChild('panel'); + /** The options container's custom scrollbar viewport, flashed when the panel opens. */ + private readonly scrollbarViewport = viewChild(KbqScrollbarViewport); + @ViewChild(CdkConnectedOverlay, { static: false }) overlayDir: CdkConnectedOverlay; @ViewChildren(KbqTag) tags: QueryList; @@ -834,6 +839,10 @@ export class KbqTreeSelect } }); + // Briefly reveal the scrollbar to hint that the list is scrollable — the panel may open + // on an already-visible option with no scroll, so the hover track would otherwise stay hidden. + this.scrollbarViewport()?.flashScrollIndicators(); + this.openedChange.emit(true); } else { this.openedChange.emit(false); diff --git a/packages/components/tree-select/tree-select.html b/packages/components/tree-select/tree-select.html index ad725a2491..2f4c24ddd8 100644 --- a/packages/components/tree-select/tree-select.html +++ b/packages/components/tree-select/tree-select.html @@ -116,7 +116,8 @@
diff --git a/packages/docs-examples/components/list/list-virtual-scroll/list-virtual-scroll-example.ts b/packages/docs-examples/components/list/list-virtual-scroll/list-virtual-scroll-example.ts index 5fb678dc22..05542f534f 100644 --- a/packages/docs-examples/components/list/list-virtual-scroll/list-virtual-scroll-example.ts +++ b/packages/docs-examples/components/list/list-virtual-scroll/list-virtual-scroll-example.ts @@ -3,18 +3,25 @@ import { JsonPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { KbqListModule } from '@koobiq/components/list'; +import { KbqScrollbarViewport } from '@koobiq/components/scrollbar'; /** * @title List with virtual-scroll */ @Component({ selector: 'list-virtual-scroll-example', - imports: [KbqListModule, FormsModule, ScrollingModule, JsonPipe], + imports: [KbqListModule, FormsModule, ScrollingModule, JsonPipe, KbqScrollbarViewport], template: `
Selected: {{ selected | json }}

- + {{ option.label }} diff --git a/packages/docs-examples/components/scrollbar/scrollbar-overview/scrollbar-overview-example.ts b/packages/docs-examples/components/scrollbar/scrollbar-overview/scrollbar-overview-example.ts index 8fb19d36eb..2f5937d9c8 100644 --- a/packages/docs-examples/components/scrollbar/scrollbar-overview/scrollbar-overview-example.ts +++ b/packages/docs-examples/components/scrollbar/scrollbar-overview/scrollbar-overview-example.ts @@ -18,8 +18,8 @@ import { KbqSelectModule } from '@koobiq/components/select'; -
- +
+

In cryptography, a brute-force attack or exhaustive key search is a cryptanalytic attack that consists of an attacker submitting many possible keys or passwords with the hope of eventually @@ -27,6 +27,11 @@ import { KbqSelectModule } from '@koobiq/components/select'; not information-theoretically secure.[1] However, in a properly designed cryptosystem the chance of successfully guessing the key is negligible.

+

+ Brute-force attacks are an application of brute-force search, the general problem-solving technique + of enumerating all candidates and checking each one. The word 'hammering' is sometimes used to + describe a brute-force attack,[4] with 'anti-hammering' for countermeasures.[5] +

When cracking passwords, this method is very fast when used to check all short passwords, but for longer passwords other methods such as the dictionary attack are used because a brute-force search @@ -39,6 +44,12 @@ import { KbqSelectModule } from '@koobiq/components/select'; do more work to test each guess. One of the measures of the strength of an encryption system is how long it would theoretically take an attacker to mount a successful brute-force attack against it.[3]

+

+ When cracking passwords, this method is very fast when used to check all short passwords, but for + longer passwords other methods such as the dictionary attack are used because a brute-force search + takes too long. Longer passwords, passphrases and keys have more possible values, making them + exponentially more difficult to crack than shorter ones due to the diversity of characters.[2] +

Brute-force attacks are an application of brute-force search, the general problem-solving technique of enumerating all candidates and checking each one. The word 'hammering' is sometimes used to @@ -60,17 +71,23 @@ import { KbqSelectModule } from '@koobiq/components/select'; width: 200px; } - .example-scrollbar { - overflow: auto; + .example-scrollbar-wrapper { + overflow: hidden; resize: both; + width: 100%; + max-width: 100%; + min-width: 200px; height: 200px; - min-height: 200px; max-height: 400px; + min-height: 200px; + } + + .example-scrollbar { + height: 100%; width: 100%; - min-width: 200px; - max-width: 100%; border-radius: var(--kbq-size-border-radius); background-color: var(--kbq-background-bg-secondary); + outline-offset: -1px; } p { diff --git a/packages/docs-examples/components/select/select-auto-hide-scroll-strategy/select-auto-hide-scroll-strategy-example.ts b/packages/docs-examples/components/select/select-auto-hide-scroll-strategy/select-auto-hide-scroll-strategy-example.ts index 0bf2aec4bc..62d3e1a3fb 100644 --- a/packages/docs-examples/components/select/select-auto-hide-scroll-strategy/select-auto-hide-scroll-strategy-example.ts +++ b/packages/docs-examples/components/select/select-auto-hide-scroll-strategy/select-auto-hide-scroll-strategy-example.ts @@ -14,6 +14,7 @@ import { KbqAutoHideScrollStrategy, kbqAutoHideScrollStrategyFactory } from '@koobiq/components/core'; +import { KbqNativeScrollbar } from '@koobiq/components/scrollbar'; import { KbqSelect, KbqSelectModule } from '@koobiq/components/select'; /** @@ -21,9 +22,9 @@ import { KbqSelect, KbqSelectModule } from '@koobiq/components/select'; */ @Component({ selector: 'select-auto-hide-scroll-strategy-example', - imports: [CdkScrollableModule, KbqSelectModule], + imports: [CdkScrollableModule, KbqSelectModule, KbqNativeScrollbar], template: ` -

+
Scroll down
diff --git a/packages/docs-examples/components/select/select-virtual-scroll/select-virtual-scroll-example.ts b/packages/docs-examples/components/select/select-virtual-scroll/select-virtual-scroll-example.ts index 34c3bd1555..ad79d398f4 100644 --- a/packages/docs-examples/components/select/select-virtual-scroll/select-virtual-scroll-example.ts +++ b/packages/docs-examples/components/select/select-virtual-scroll/select-virtual-scroll-example.ts @@ -1,6 +1,7 @@ import { CdkVirtualScrollViewport, ScrollingModule } from '@angular/cdk/scrolling'; import { ChangeDetectionStrategy, Component, viewChild } from '@angular/core'; import { KbqVirtualOption } from '@koobiq/components/core'; +import { KbqScrollbarViewport } from '@koobiq/components/scrollbar'; import { KbqSelectModule } from '@koobiq/components/select'; type OptionItem = { id: number; label: string }; @@ -10,7 +11,7 @@ type OptionItem = { id: number; label: string }; */ @Component({ selector: 'select-virtual-scroll-example', - imports: [KbqSelectModule, ScrollingModule], + imports: [KbqSelectModule, ScrollingModule, KbqScrollbarViewport], template: ` - + {{ option.label }} diff --git a/packages/docs-examples/components/tabs/tabs-add-tab-vertical/tabs-add-tab-vertical-example.ts b/packages/docs-examples/components/tabs/tabs-add-tab-vertical/tabs-add-tab-vertical-example.ts index 3151211c0b..3108c390a2 100644 --- a/packages/docs-examples/components/tabs/tabs-add-tab-vertical/tabs-add-tab-vertical-example.ts +++ b/packages/docs-examples/components/tabs/tabs-add-tab-vertical/tabs-add-tab-vertical-example.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, viewChildren } from '@angular/core'; import { KbqButtonModule } from '@koobiq/components/button'; import { KbqIconModule } from '@koobiq/components/icon'; +import { KbqNativeScrollbar } from '@koobiq/components/scrollbar'; import { KbqTabLink, KbqTabsModule } from '@koobiq/components/tabs'; import { KbqToolTipModule } from '@koobiq/components/tooltip'; @@ -57,9 +58,7 @@ import { KbqToolTipModule } from '@koobiq/components/tooltip'; `, styleUrls: ['./tabs-add-tab-vertical-example.css'], changeDetection: ChangeDetectionStrategy.OnPush, - host: { - class: 'kbq-scrollbar' - } + hostDirectives: [KbqNativeScrollbar] }) export class TabsAddTabVerticalExample { private readonly tabLinks = viewChildren(KbqTabLink); diff --git a/packages/docs-examples/components/top-bar/top-bar-overflow/top-bar-overflow-example.ts b/packages/docs-examples/components/top-bar/top-bar-overflow/top-bar-overflow-example.ts index dc6a09c887..4621a9d8ba 100644 --- a/packages/docs-examples/components/top-bar/top-bar-overflow/top-bar-overflow-example.ts +++ b/packages/docs-examples/components/top-bar/top-bar-overflow/top-bar-overflow-example.ts @@ -17,6 +17,7 @@ import { KbqDlModule } from '@koobiq/components/dl'; import { KbqDropdownModule } from '@koobiq/components/dropdown'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqOverflowItemsModule } from '@koobiq/components/overflow-items'; +import { KbqNativeScrollbar } from '@koobiq/components/scrollbar'; import { KbqToolTipModule } from '@koobiq/components/tooltip'; import { KbqTopBarModule } from '@koobiq/components/top-bar'; import { auditTime, map } from 'rxjs/operators'; @@ -46,6 +47,7 @@ type ExampleAction = { KbqBadgeModule, KbqDropdownModule, KbqOverflowItemsModule, + KbqNativeScrollbar, KbqFormattersModule ], template: ` @@ -105,7 +107,7 @@ type ExampleAction = {
-
+
Description diff --git a/packages/e2e/routes.ts b/packages/e2e/routes.ts index 09d8602ce7..b5f7448aac 100644 --- a/packages/e2e/routes.ts +++ b/packages/e2e/routes.ts @@ -6,6 +6,7 @@ import { E2eScrollbarHover, E2eScrollbarMode, E2eScrollbarNested, + E2eScrollbarPadding, E2eScrollbarScrollTo, E2eScrollbarStateAndStyle, E2eScrollbarTrack, @@ -18,6 +19,7 @@ import { E2eAppSwitcherStates, E2eAppSwitcherWithSitesStates } from '../componen import { E2eAutocompleteExpandOnResults, E2eAutocompleteFallbackPosition, + E2eAutocompleteScrollbar, E2eAutocompleteScrollClose, E2eAutocompleteStates } from '../components/autocomplete/e2e'; @@ -39,6 +41,7 @@ import { E2eDlStates } from '../components/dl/e2e'; import { E2eDropdownNestedLtr, E2eDropdownNestedRtl, + E2eDropdownScrollbar, E2eDropdownStates, E2eDropdownTitleOverflow } from '../components/dropdown/e2e'; @@ -65,7 +68,7 @@ import { E2eLinkStates, E2eLinkWithCaption } from '../components/link/e2e'; import { E2eListOptionActionVisibility, E2eListSelectionState, E2eListStates } from '../components/list/e2e'; import { E2eLoaderOverlayCard, E2eLoaderOverlayStates } from '../components/loader-overlay/e2e'; import { E2eMarkdownStates } from '../components/markdown/e2e'; -import { E2eModalFullCustom, E2eModalStates } from '../components/modal/e2e'; +import { E2eModalFullCustom, E2eModalScrollbar, E2eModalStates } from '../components/modal/e2e'; import { E2eHorizontalNavbarStates, E2eVerticalNavbarBrandAutoLongTitle, @@ -80,7 +83,12 @@ import { E2eOverflowItemsOrdered, E2eOverflowItemsVertical } from '../components/overflow-items/e2e'; -import { E2ePopoverPositioning, E2ePopoverStates, E2ePopoverWithTooltip } from '../components/popover/e2e'; +import { + E2ePopoverPositioning, + E2ePopoverScrollbar, + E2ePopoverStates, + E2ePopoverWithTooltip +} from '../components/popover/e2e'; import { E2eProgressBarStateAndStyle } from '../components/progress-bar/e2e'; import { E2eProgressSpinnerStates } from '../components/progress-spinner/e2e'; import { E2eRadioStateAndStyle } from '../components/radio/e2e'; @@ -96,6 +104,7 @@ import { E2eSelectPanelMaxHeight, E2eSelectPositioning, E2eSelectRtlPositioning, + E2eSelectScrollbar, E2eSelectSelectAllStates, E2eSelectSelectionState, E2eSelectStates, @@ -103,7 +112,8 @@ import { E2eSelectWithGroupsRtlPositioning, E2eSelectWithSearchAndFooter, E2eVirtualScrollMultiSelectNarrow, - E2eVirtualScrollSelectPanelMaxHeight + E2eVirtualScrollSelectPanelMaxHeight, + E2eVirtualScrollSelectScrollbar } from '../components/select/e2e'; import { E2eSidepanelStateAndStyle } from '../components/sidepanel/e2e'; import { E2eSplitButtonStateAndStyle, E2eSplitButtonTruncation } from '../components/split-button/e2e'; @@ -125,7 +135,12 @@ import { E2eTextareaStates } from '../components/textarea/e2e'; import { E2eTimepickerStates } from '../components/timepicker/e2e'; -import { E2eTimezonePanelStates, E2eTimezoneStates, E2eTimezoneWithSearch } from '../components/timezone/e2e'; +import { + E2eTimezonePanelStates, + E2eTimezoneScrollbar, + E2eTimezoneStates, + E2eTimezoneWithSearch +} from '../components/timezone/e2e'; import { E2eToastStates } from '../components/toast/e2e'; import { E2eToggleStateAndStyle, E2eToggleWithTextAndCaption } from '../components/toggle/e2e'; import { E2eTooltipArrowOffset, E2eTooltipStates } from '../components/tooltip/e2e'; @@ -144,6 +159,7 @@ import { E2eTreeSelectPositioning, E2eTreeSelectPropertyDisabled, E2eTreeSelectRtlPositioning, + E2eTreeSelectScrollbar, E2eTreeSelectStates } from '../components/tree-select/e2e'; import { E2eTreeOptionActionVisibility, E2eTreeStates, E2eTreeTwoLineNode } from '../components/tree/e2e'; @@ -210,6 +226,7 @@ const components = [ E2eTagInputSeparators, E2eModalStates, E2eModalFullCustom, + E2eModalScrollbar, E2eListStates, E2eListSelectionState, E2eListOptionActionVisibility, @@ -219,11 +236,13 @@ const components = [ E2eAutocompleteFallbackPosition, E2eAutocompleteExpandOnResults, E2eAutocompleteScrollClose, + E2eAutocompleteScrollbar, E2eCheckboxStateAndStyle, E2eDropdownStates, E2eDropdownNestedLtr, E2eDropdownNestedRtl, E2eDropdownTitleOverflow, + E2eDropdownScrollbar, E2eCheckboxWithTextAndCaption, E2eMarkdownStates, E2eSearchExpandableStates, @@ -234,6 +253,7 @@ const components = [ E2eScrollbarScrollTo, E2eScrollbarVirtualScroll, E2eScrollbarNested, + E2eScrollbarPadding, E2eNativeScrollbar, E2eRadioStateAndStyle, E2eProgressBarStateAndStyle, @@ -249,6 +269,7 @@ const components = [ E2ePopoverStates, E2ePopoverPositioning, E2ePopoverWithTooltip, + E2ePopoverScrollbar, E2eTooltipStates, E2eTooltipArrowOffset, E2eTagListStates, @@ -256,13 +277,16 @@ const components = [ E2eTimezoneStates, E2eTimezonePanelStates, E2eTimezoneWithSearch, + E2eTimezoneScrollbar, E2eSelectStates, E2eMultiSelectStates, E2eMultilineSelectStates, E2eSelectSelectionState, E2eSelectSelectAllStates, + E2eSelectScrollbar, E2eTreeStates, E2eTreeSelectStates, + E2eTreeSelectScrollbar, E2eMultiTreeSelectStates, E2eMultiTreeSelectSelectAllStates, E2eMultilineTreeSelectStates, @@ -287,6 +311,7 @@ const components = [ E2eVirtualScrollMultiSelectNarrow, E2eSelectPanelMaxHeight, E2eVirtualScrollSelectPanelMaxHeight, + E2eVirtualScrollSelectScrollbar, E2eSelectLongOptionText, E2eInlineEditStates, E2eInlineEditMenuButton, diff --git a/tools/cspell-locales/ru.json b/tools/cspell-locales/ru.json index e6b17b1257..9b53e4ae7d 100644 --- a/tools/cspell-locales/ru.json +++ b/tools/cspell-locales/ru.json @@ -194,6 +194,7 @@ "скролла", "скроллбар", "скроллбара", + "скроллбарам", "скроллбаров", "скроллбары", "скролле", diff --git a/tools/public_api_guard/components/dropdown.api.md b/tools/public_api_guard/components/dropdown.api.md index 1f59cae8bd..ca32f00de1 100644 --- a/tools/public_api_guard/components/dropdown.api.md +++ b/tools/public_api_guard/components/dropdown.api.md @@ -100,6 +100,7 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit, onAnimationDone(event: AnimationEvent_2): void; // (undocumented) onAnimationStart(event: AnimationEvent_2): void; + protected onPanelClick(event: MouseEvent): void; get overlapTriggerX(): boolean; set overlapTriggerX(value: boolean); get overlapTriggerY(): boolean; diff --git a/tools/public_api_guard/components/modal.api.md b/tools/public_api_guard/components/modal.api.md index 8eface1681..b904979186 100644 --- a/tools/public_api_guard/components/modal.api.md +++ b/tools/public_api_guard/components/modal.api.md @@ -10,7 +10,8 @@ import { ElementRef } from '@angular/core'; import { EventEmitter } from '@angular/core'; import * as i0 from '@angular/core'; import * as i1 from '@angular/cdk/overlay'; -import * as i2 from '@angular/cdk/a11y'; +import * as i2$1 from '@angular/cdk/a11y'; +import * as i2 from '@koobiq/components/scrollbar'; import * as i3 from '@koobiq/components/button'; import * as i4 from '@koobiq/components/icon'; import * as i5 from '@koobiq/components/title'; @@ -80,7 +81,7 @@ export const KBQ_MODAL_DATA: InjectionToken; export class KbqModalBody { constructor(); // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -272,7 +273,7 @@ export class KbqModalModule { // Warning: (ae-forgotten-export) The symbol "CssUnitPipe" needs to be exported by the entry point index.d.ts // // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public diff --git a/tools/public_api_guard/components/popover.api.md b/tools/public_api_guard/components/popover.api.md index cf88cfe0ff..0c4467b962 100644 --- a/tools/public_api_guard/components/popover.api.md +++ b/tools/public_api_guard/components/popover.api.md @@ -6,6 +6,7 @@ import { AfterContentInit } from '@angular/core'; import { AfterViewInit } from '@angular/core'; +import { AnimationEvent as AnimationEvent_2 } from '@angular/animations'; import { AnimationTriggerMetadata } from '@angular/animations'; import { CdkScrollable } from '@angular/cdk/overlay'; import { CdkTrapFocus } from '@angular/cdk/a11y'; @@ -68,6 +69,8 @@ export const kbqPopoverAnimations: { export class KbqPopoverComponent extends KbqPopUp implements AfterViewInit { protected readonly a11yLocaleConfiguration: i0.Signal<_koobiq_components_core.KbqA11yLocaleConfiguration>; // (undocumented) + animationDone(event: AnimationEvent_2): void; + // (undocumented) readonly cdkTrapFocus: i0.Signal; // (undocumented) protected readonly componentColors: typeof KbqComponentColors; diff --git a/tools/public_api_guard/components/scrollbar.api.md b/tools/public_api_guard/components/scrollbar.api.md index c2ef0a5206..b7bfb7c73c 100644 --- a/tools/public_api_guard/components/scrollbar.api.md +++ b/tools/public_api_guard/components/scrollbar.api.md @@ -4,11 +4,9 @@ ```ts -import { ElementRef } from '@angular/core'; import { ExtendedScrollToOptions } from '@angular/cdk/scrolling'; import * as i0 from '@angular/core'; import * as i1 from '@angular/cdk/scrolling'; -import * as i2 from '@angular/cdk/a11y'; import { InjectionToken } from '@angular/core'; import { Observable } from 'rxjs'; import { Provider } from '@angular/core'; @@ -16,9 +14,6 @@ import { Provider } from '@angular/core'; // @public export const KBQ_SCROLLBAR_OPTIONS: InjectionToken; -// @public -export const KBQ_SCROLLBAR_VIEWPORT: InjectionToken>; - // @public export class KbqNativeScrollbar { constructor(); @@ -31,9 +26,11 @@ export class KbqNativeScrollbar { // @public export class KbqScrollbar { + flashScrollIndicators(): void; getNativeElement(): HTMLElement; + readonly hideDelay: i0.InputSignalWithTransform; readonly mode: i0.InputSignal; - get scrollChanges(): Observable; + readonly scrollChanges: Observable; scrollEnd(behavior?: ScrollBehavior): void; scrollIntoView(target: HTMLElement, behavior?: ScrollBehavior): void; scrollStart(behavior?: ScrollBehavior): void; @@ -42,7 +39,7 @@ export class KbqScrollbar { scrollToElement(target: HTMLElement | string, options?: KbqScrollbarScrollToElementOptions): void; scrollToTop(behavior?: ScrollBehavior): void; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -53,6 +50,7 @@ export type KbqScrollbarMode = 'always' | 'hidden' | 'hover' | 'native'; // @public export type KbqScrollbarOptions = { mode: KbqScrollbarMode; + hideDelay: number; }; // @public @@ -71,10 +69,13 @@ export type KbqScrollbarScrollToOptions = ExtendedScrollToOptions; // @public export class KbqScrollbarViewport { constructor(); + readonly flashes: Observable; + flashScrollIndicators(): void; getNativeElement(): HTMLElement; + readonly hideDelay: i0.InputSignalWithTransform; protected readonly id: string; readonly mode: i0.InputSignal; - get scrollChanges(): Observable; + readonly scrollChanges: Observable; scrollEnd(behavior?: ScrollBehavior): void; scrollIntoView(target: HTMLElement, behavior?: ScrollBehavior): void; scrollStart(behavior?: ScrollBehavior): void; @@ -83,7 +84,7 @@ export class KbqScrollbarViewport { scrollToElement(target: HTMLElement | string, options?: KbqScrollbarScrollToElementOptions): void; scrollToTop(behavior?: ScrollBehavior): void; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } diff --git a/tools/public_api_guard/components/select.api.md b/tools/public_api_guard/components/select.api.md index 4c40e58232..6040017ae8 100644 --- a/tools/public_api_guard/components/select.api.md +++ b/tools/public_api_guard/components/select.api.md @@ -45,6 +45,7 @@ import { KbqPanelMaxWidth } from '@koobiq/components/core'; import { KbqPanelMinWidth } from '@koobiq/components/core'; import { KbqPanelWidth } from '@koobiq/components/core'; import { KbqPseudoCheckboxState } from '@koobiq/components/core'; +import { KbqScrollbarMode } from '@koobiq/components/scrollbar'; import { KbqSelectAllEvent } from '@koobiq/components/core'; import { KbqSelectMatcher } from '@koobiq/components/core'; import { KbqSelectSearch } from '@koobiq/components/core'; @@ -227,6 +228,7 @@ export class KbqSelect extends KbqAbstractSelect implements AfterContentInit, On get required(): boolean; set required(value: boolean); resetSearch(): void; + protected get scrollbarMode(): KbqScrollbarMode; readonly scrolledToBottom: _angular_core.OutputEmitterRef; readonly scrolledToBottomOffset: _angular_core.InputSignalWithTransform; scrollStrategy: _angular_cdk_overlay_module_d.ScrollStrategy; @@ -274,7 +276,7 @@ export class KbqSelect extends KbqAbstractSelect implements AfterContentInit, On withVirtualScroll: boolean; writeValue(value: any): void; // (undocumented) - static ɵcmp: _angular_core.ɵɵComponentDeclaration; + static ɵcmp: _angular_core.ɵɵComponentDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } diff --git a/tools/public_api_guard/components/sidepanel.api.md b/tools/public_api_guard/components/sidepanel.api.md index 3347c51acf..1639cf06c0 100644 --- a/tools/public_api_guard/components/sidepanel.api.md +++ b/tools/public_api_guard/components/sidepanel.api.md @@ -14,7 +14,8 @@ import { EmbeddedViewRef } from '@angular/core'; import { EventEmitter } from '@angular/core'; import * as i0 from '@angular/core'; import * as i1 from '@angular/cdk/overlay'; -import * as i2 from '@angular/cdk/portal'; +import * as i2$1 from '@angular/cdk/portal'; +import * as i2 from '@koobiq/components/scrollbar'; import * as i3 from '@koobiq/components/button'; import * as i4 from '@koobiq/components/icon'; import * as i5 from '@koobiq/components/title'; @@ -54,7 +55,7 @@ export class KbqSidepanelActions { export class KbqSidepanelBody { constructor(); // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -160,7 +161,7 @@ export class KbqSidepanelModule { // (undocumented) static ɵinj: i0.ɵɵInjectorDeclaration; // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public (undocumented) diff --git a/tools/public_api_guard/components/textarea.api.md b/tools/public_api_guard/components/textarea.api.md index 1aae1a0452..44899ca071 100644 --- a/tools/public_api_guard/components/textarea.api.md +++ b/tools/public_api_guard/components/textarea.api.md @@ -10,7 +10,8 @@ import { ElementRef } from '@angular/core'; import { ErrorStateMatcher } from '@koobiq/components/core'; import { FormGroupDirective } from '@angular/forms'; import * as i0 from '@angular/core'; -import * as i1 from '@angular/cdk/a11y'; +import * as i1$1 from '@angular/cdk/a11y'; +import * as i1 from '@koobiq/components/scrollbar'; import * as i2 from '@angular/forms'; import * as i4 from '@koobiq/components/form-field'; import { InjectionToken } from '@angular/core'; @@ -92,7 +93,7 @@ export class KbqTextarea implements KbqFormFieldControl, OnInit, OnChanges, get value(): string; set value(value: string); // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -104,7 +105,7 @@ export class KbqTextareaModule { // (undocumented) static ɵinj: i0.ɵɵInjectorDeclaration; // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // (No @packageDocumentation comment for this package)