Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 12 additions & 2 deletions packages/components/autocomplete/autocomplete.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';

/**
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -111,6 +112,9 @@ export class KbqAutocomplete implements AfterContentInit {

readonly panel = viewChild.required<ElementRef>('panel');

/** The panel's custom scrollbar viewport, flashed when the panel opens. */
private readonly scrollbarViewport = viewChild(KbqScrollbarViewport);

@ContentChildren(KbqOption, { descendants: true }) options: QueryList<KbqOption>;

readonly optionGroups = contentChildren(KbqOptgroup);
Expand Down Expand Up @@ -219,6 +223,12 @@ export class KbqAutocomplete implements AfterContentInit {
const defaults = inject<KbqAutocompleteDefaultOptions>(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() {
Expand Down
2 changes: 1 addition & 1 deletion packages/components/autocomplete/autocomplete.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<ng-template>
<div class="kbq-autocomplete-panel" [id]="id" [class]="classList" [style.max-width.px]="panelMaxWidth()">
<div #panel role="listbox" class="kbq-autocomplete-panel__content kbq-scrollbar">
<div #panel kbqScrollbarViewport role="listbox" class="kbq-autocomplete-panel__content">
<ng-content />
</div>
<ng-content select="[kbqAutocompleteFooter], kbq-autocomplete-footer" />
Expand Down
6 changes: 5 additions & 1 deletion packages/components/autocomplete/autocomplete.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1076,7 +1076,11 @@ describe('KbqAutocomplete', () => {
const fixture = createComponent(SimpleAutocomplete, [
{
provide: ScrollDispatcher,
useValue: { scrolled: () => scrolledSubject.asObservable() }
useValue: {
scrolled: () => scrolledSubject.asObservable(),
register: () => {},
deregister: () => {}
}
}
]);

Expand Down
53 changes: 53 additions & 0 deletions packages/components/autocomplete/e2e.playwright-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
35 changes: 35 additions & 0 deletions packages/components/autocomplete/e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `
<kbq-form-field>
<input
data-testid="e2eAutocompleteInput"
kbqInput
placeholder="Placeholder"
[kbqAutocomplete]="autocomplete"
/>

<kbq-autocomplete #autocomplete="kbqAutocomplete">
@for (option of options; track $index) {
<kbq-option [value]="option">{{ option }}</kbq-option>
}
</kbq-autocomplete>
</kbq-form-field>
`,
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}`);
}
3 changes: 2 additions & 1 deletion packages/components/code-block/code-block.html
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
Expand Down
2 changes: 2 additions & 0 deletions packages/components/code-block/code-block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -109,6 +110,7 @@ export class KbqCodeBlockTabLinkContent {}
CdkScrollableModule,
KbqToolTipModule,
KbqIconModule,
KbqNativeScrollbar,
NgTemplateOutlet,
KbqOverflowShadowContainer,
KbqOverflowShadowTop
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 27 additions & 2 deletions packages/components/dropdown/dropdown.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ import {
contentChild,
inject,
input,
numberAttribute
numberAttribute,
viewChild
} from '@angular/core';
import {
ESCAPE,
Expand All @@ -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';
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -274,6 +276,9 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit,
/** @docs-private */
@ViewChild(TemplateRef, { static: false }) templateRef: TemplateRef<any>;

/** 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.
*/
Expand Down Expand Up @@ -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;
}
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/components/dropdown/dropdown.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<ng-template>
<div
kbqScrollbarViewport
tabindex="-1"
class="kbq-dropdown__panel"
[class.kbq-dropdown__panel_nested]="parent"
Expand All @@ -9,7 +10,7 @@
[@transformDropdown]="panelAnimationState"
(@transformDropdown.done)="onAnimationDone($event)"
(@transformDropdown.start)="onAnimationStart($event)"
(click)="close()"
(click)="onPanelClick($event)"
(keydown)="handleKeydown($event)"
>
<div class="kbq-dropdown__content">
Expand Down
12 changes: 9 additions & 3 deletions packages/components/dropdown/dropdown.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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],
Expand Down
56 changes: 56 additions & 0 deletions packages/components/dropdown/e2e.playwright-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
Loading
Loading