diff --git a/packages/components-dev/scrollbar/module.ts b/packages/components-dev/scrollbar/module.ts index 1de4924ec9..9f82fca399 100644 --- a/packages/components-dev/scrollbar/module.ts +++ b/packages/components-dev/scrollbar/module.ts @@ -1,105 +1,57 @@ import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core'; -import { KbqButtonModule } from '@koobiq/components/button'; import { - KBQ_SCROLLBAR_CONFIG, - KbqScrollbarEvents, - KbqScrollbarModule, - KbqScrollbarOptions -} from '@koobiq/components/scrollbar'; + PrivateScrollbarDisableInteractionExample, + PrivateScrollbarNativeExample, + PrivateScrollbarRtlExample, + PrivateScrollbarScrollToExample, + PrivateScrollbarVirtualScrollExample, + PrivateScrollbarVisibilityExample, + ScrollbarOverviewExample, + ScrollbarScrollToTopExample, + ScrollbarWithCustomConfigExample +} from 'packages/docs-examples/components/scrollbar'; import { DevThemeToggle } from '../theme-toggle'; @Component({ - selector: 'dev-scrollbar-with-options', - imports: [KbqScrollbarModule], - template: ` -

ScrollbarWithOptions:

- - @for (item of items; track item) { -
{{ item }}
-
- } -
- `, - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class DevScrollbarWithOptions { - readonly options: KbqScrollbarOptions = { - scrollbars: { - autoHide: 'never' - } - }; - readonly items = Array.from({ length: 1000 }).map((_, i) => `Item #${i}`); -} - -@Component({ - selector: 'dev-scrollbar-with-custom-config', - imports: [KbqScrollbarModule], - template: ` -

ScrollbarWithCustomConfig:

-
- @for (item of items; track item) { -
{{ item }}
-
- } -
- `, - providers: [ - { - provide: KBQ_SCROLLBAR_CONFIG, - useValue: { - scrollbars: { - autoHide: 'never' - } - } satisfies KbqScrollbarOptions - } - ], - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class DevScrollbarWithCustomConfig { - readonly items = Array.from({ length: 1000 }).map((_, i) => `Item #${i}`); -} - -@Component({ - selector: 'dev-scrollbar-scroll-to-top', + selector: 'dev-examples', imports: [ - KbqScrollbarModule, - KbqButtonModule + PrivateScrollbarVisibilityExample, + PrivateScrollbarVirtualScrollExample, + PrivateScrollbarScrollToExample, + PrivateScrollbarRtlExample, + PrivateScrollbarNativeExample, + PrivateScrollbarDisableInteractionExample, + ScrollbarOverviewExample, + ScrollbarScrollToTopExample, + ScrollbarWithCustomConfigExample ], template: ` -

ScrollbarScrollToTop:

- - @for (item of items; track item) { -
{{ item }}
-
- } -
- + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ `, changeDetection: ChangeDetectionStrategy.OnPush }) -export class DevScrollbarScrollToTop { - readonly items = Array.from({ length: 1000 }).map((_, i) => `Item #${i}`); - - onScroll(event): void { - console.log('onScroll', event); - } -} +export class DevDocsExamples {} @Component({ selector: 'dev-app', imports: [ - KbqScrollbarModule, - KbqButtonModule, - // components - DevScrollbarWithOptions, - DevScrollbarWithCustomConfig, - DevScrollbarScrollToTop, + DevDocsExamples, DevThemeToggle ], templateUrl: './template.html', @@ -107,34 +59,4 @@ export class DevScrollbarScrollToTop { changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None }) -export class DevApp { - options: KbqScrollbarOptions; - events: KbqScrollbarEvents = { - initialized: (...args) => this.onInitialize(args) - }; - - onScroll([instance, args]) { - console.log('onScroll', instance, args); - } - - onInitialize($event) { - console.log($event); - } - - longText: string = `Vivamus suscipit tortor eget felis porttitor volutpat. Vivamus magna justo, lacinia eget consectetur sed, convallis at tellus. Quisque velit nisi, pretium ut lacinia in, elementum id enim. Pellentesque in ipsum id orci porta dapibus. Curabitur arcu erat, accumsan id imperdiet et, porttitor at sem. Donec sollicitudin molestie malesuada. Curabitur non nulla sit amet nisl tempus convallis quis ac lectus. Mauris blandit aliquet elit, eget tincidunt nibh pulvinar a. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum ac diam sit amet quam vehicula elementum sed sit amet dui. - Praesent sapien massa, convallis a pellentesque nec, egestas non nisi. Mauris blandit aliquet elit, eget tincidunt nibh pulvinar a. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Donec velit neque, auctor sit amet aliquam vel, ullamcorper sit amet ligula. Nulla porttitor accumsan tincidunt. Nulla quis lorem ut libero malesuada feugiat. Curabitur non nulla sit amet nisl tempus convallis quis ac lectus. Praesent sapien massa, convallis a pellentesque nec, egestas non nisi. Pellentesque in ipsum id orci porta dapibus. Praesent sapien massa, convallis a pellentesque nec, egestas non nisi. Donec rutrum congue leo eget malesuada. - Proin eget tortor risus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum ac diam sit amet quam vehicula elementum sed sit amet dui. Donec sollicitudin molestie malesuada. Pellentesque in ipsum id orci porta dapibus. Curabitur aliquet quam id dui posuere blandit. Vestibulum ac diam sit amet quam vehicula elementum sed sit amet dui. Curabitur arcu erat, accumsan id imperdiet et, porttitor at sem. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed porttitor lectus nibh. - Donec sollicitudin molestie malesuada. Praesent sapien massa, convallis a pellentesque nec, egestas non nisi. Curabitur arcu erat, accumsan id imperdiet et, porttitor at sem. Mauris blandit aliquet elit, eget tincidunt nibh pulvinar a. Vivamus suscipit tortor eget felis porttitor volutpat. Nulla quis lorem ut libero malesuada feugiat. Curabitur aliquet quam id dui posuere blandit. Donec sollicitudin molestie malesuada. Quisque velit nisi, pretium ut lacinia in, elementum id enim. Donec sollicitudin molestie malesuada. - Proin eget tortor risus. Vivamus magna justo, lacinia eget consectetur sed, convallis at tellus. Cras ultricies ligula sed magna dictum porta. Cras ultricies ligula sed magna dictum porta. Cras ultricies ligula sed magna dictum porta. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eget tortor risus. Proin eget tortor risus. Curabitur arcu erat, accumsan id imperdiet et, porttitor at sem. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Donec velit neque, auctor sit amet aliquam vel, ullamcorper sit amet ligula. - Vivamus suscipit tortor eget felis porttitor volutpat. Vivamus magna justo, lacinia eget consectetur sed, convallis at tellus. Quisque velit nisi, pretium ut lacinia in, elementum id enim. Pellentesque in ipsum id orci porta dapibus. Curabitur arcu erat, accumsan id imperdiet et, porttitor at sem. Donec sollicitudin molestie malesuada. Curabitur non nulla sit amet nisl tempus convallis quis ac lectus. Mauris blandit aliquet elit, eget tincidunt nibh pulvinar a. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum ac diam sit amet quam vehicula elementum sed sit amet dui. - Praesent sapien massa, convallis a pellentesque nec, egestas non nisi. Mauris blandit aliquet elit, eget tincidunt nibh pulvinar a. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Donec velit neque, auctor sit amet aliquam vel, ullamcorper sit amet ligula. Nulla porttitor accumsan tincidunt. Nulla quis lorem ut libero malesuada feugiat. Curabitur non nulla sit amet nisl tempus convallis quis ac lectus. Praesent sapien massa, convallis a pellentesque nec, egestas non nisi. Pellentesque in ipsum id orci porta dapibus. Praesent sapien massa, convallis a pellentesque nec, egestas non nisi. Donec rutrum congue leo eget malesuada. - Proin eget tortor risus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum ac diam sit amet quam vehicula elementum sed sit amet dui. Donec sollicitudin molestie malesuada. Pellentesque in ipsum id orci porta dapibus. Curabitur aliquet quam id dui posuere blandit. Vestibulum ac diam sit amet quam vehicula elementum sed sit amet dui. Curabitur arcu erat, accumsan id imperdiet et, porttitor at sem. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed porttitor lectus nibh. - Donec sollicitudin molestie malesuada. Praesent sapien massa, convallis a pellentesque nec, egestas non nisi. Curabitur arcu erat, accumsan id imperdiet et, porttitor at sem. Mauris blandit aliquet elit, eget tincidunt nibh pulvinar a. Vivamus suscipit tortor eget felis porttitor volutpat. Nulla quis lorem ut libero malesuada feugiat. Curabitur aliquet quam id dui posuere blandit. Donec sollicitudin molestie malesuada. Quisque velit nisi, pretium ut lacinia in, elementum id enim. Donec sollicitudin molestie malesuada. - Proin eget tortor risus. Vivamus magna justo, lacinia eget consectetur sed, convallis at tellus. Cras ultricies ligula sed magna dictum porta. Cras ultricies ligula sed magna dictum porta. Cras ultricies ligula sed magna dictum porta. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eget tortor risus. Proin eget tortor risus. Curabitur arcu erat, accumsan id imperdiet et, porttitor at sem. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Donec velit neque, auctor sit amet aliquam vel, ullamcorper sit amet ligula. - Vivamus suscipit tortor eget felis porttitor volutpat. Vivamus magna justo, lacinia eget consectetur sed, convallis at tellus. Quisque velit nisi, pretium ut lacinia in, elementum id enim. Pellentesque in ipsum id orci porta dapibus. Curabitur arcu erat, accumsan id imperdiet et, porttitor at sem. Donec sollicitudin molestie malesuada. Curabitur non nulla sit amet nisl tempus convallis quis ac lectus. Mauris blandit aliquet elit, eget tincidunt nibh pulvinar a. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum ac diam sit amet quam vehicula elementum sed sit amet dui. - Praesent sapien massa, convallis a pellentesque nec, egestas non nisi. Mauris blandit aliquet elit, eget tincidunt nibh pulvinar a. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Donec velit neque, auctor sit amet aliquam vel, ullamcorper sit amet ligula. Nulla porttitor accumsan tincidunt. Nulla quis lorem ut libero malesuada feugiat. Curabitur non nulla sit amet nisl tempus convallis quis ac lectus. Praesent sapien massa, convallis a pellentesque nec, egestas non nisi. Pellentesque in ipsum id orci porta dapibus. Praesent sapien massa, convallis a pellentesque nec, egestas non nisi. Donec rutrum congue leo eget malesuada. - Proin eget tortor risus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum ac diam sit amet quam vehicula elementum sed sit amet dui. Donec sollicitudin molestie malesuada. Pellentesque in ipsum id orci porta dapibus. Curabitur aliquet quam id dui posuere blandit. Vestibulum ac diam sit amet quam vehicula elementum sed sit amet dui. Curabitur arcu erat, accumsan id imperdiet et, porttitor at sem. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed porttitor lectus nibh. - Donec sollicitudin molestie malesuada. Praesent sapien massa, convallis a pellentesque nec, egestas non nisi. Curabitur arcu erat, accumsan id imperdiet et, porttitor at sem. Mauris blandit aliquet elit, eget tincidunt nibh pulvinar a. Vivamus suscipit tortor eget felis porttitor volutpat. Nulla quis lorem ut libero malesuada feugiat. Curabitur aliquet quam id dui posuere blandit. Donec sollicitudin molestie malesuada. Quisque velit nisi, pretium ut lacinia in, elementum id enim. Donec sollicitudin molestie malesuada. - Proin eget tortor risus. Vivamus magna justo, lacinia eget consectetur sed, convallis at tellus. Cras ultricies ligula sed magna dictum porta. Cras ultricies ligula sed magna dictum porta. Cras ultricies ligula sed magna dictum porta. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eget tortor risus. Proin eget tortor risus. Curabitur arcu erat, accumsan id imperdiet et, porttitor at sem. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Donec velit neque, auctor sit amet aliquam vel, ullamcorper sit amet ligula. - `; -} +export class DevApp {} diff --git a/packages/components-dev/scrollbar/styles.scss b/packages/components-dev/scrollbar/styles.scss index 75e6e33886..e7f3e5945c 100644 --- a/packages/components-dev/scrollbar/styles.scss +++ b/packages/components-dev/scrollbar/styles.scss @@ -1,29 +1,4 @@ -.dev-container { - padding: 24px; -} - -textarea { - margin: 10px; +dev-app { + max-width: 608px; display: block; } - -.dev-with-horizontal { - white-space: nowrap; - overflow-x: visible; -} - -.dev-with-buttons { - white-space: nowrap; - overflow: visible; -} - -.dev-nested-scroll { - width: 200px; - height: 200px; - overflow: auto; - margin: 10px; -} - -.dev-nested-scroll__content { - width: 800px; -} diff --git a/packages/components-dev/scrollbar/template.html b/packages/components-dev/scrollbar/template.html index 1236796d25..9eff7f844b 100644 --- a/packages/components-dev/scrollbar/template.html +++ b/packages/components-dev/scrollbar/template.html @@ -1,63 +1,5 @@ -
- -
- - -
- -
-
-
-

Simple Scrollbar Component

- -

Simple example

-
-
{{ longText }}
-
- -
-

Bidi support

-
-
{{ longText }}
-
-
-
- -
-

Scrollbar Via CSS

- -

Horizontal

- - -

Buttons & Resize corner

- - -

Nested scroll — class on wrapper

-
-
-
{{ longText }}
-
-
- -

Nested scroll — class on scroller (reference)

-
-
{{ longText }}
-
-
-
-
+ diff --git a/packages/components/content-panel/content-panel.en.md b/packages/components/content-panel/content-panel.en.md index 4c809ca57b..9900829978 100644 --- a/packages/components/content-panel/content-panel.en.md +++ b/packages/components/content-panel/content-panel.en.md @@ -1,18 +1,5 @@ `KbqContentPanel` - a slide-out side panel that shifts adjacent content. Often used to implement a quick preview mode for entities from a table. -
-
Note
-
- -The component uses [Scrollbar](/en/components/scrollbar), so its dependencies must be installed: - -```bash -npm install overlayscrollbars@2.7.3 -``` - -
-
- ### Grid and content-panel diff --git a/packages/components/content-panel/content-panel.ru.md b/packages/components/content-panel/content-panel.ru.md index 9c23233c8f..1fe730172a 100644 --- a/packages/components/content-panel/content-panel.ru.md +++ b/packages/components/content-panel/content-panel.ru.md @@ -1,18 +1,5 @@ `KbqContentPanel` - выезжающая сбоку панель, которая сдвигает соседний контент. Часто используется, чтобы реализовать режим быстрого просмотра сущности из таблицы. -
-
Обрати внимание
-
- -Компонент использует [Scrollbar](/ru/components/scrollbar), поэтому необходимо установить его зависимости: - -```bash -npm install overlayscrollbars@2.7.3 -``` - -
-
- ### Грид и контент-панель diff --git a/packages/components/content-panel/content-panel.ts b/packages/components/content-panel/content-panel.ts index 93dad914af..63020be93a 100644 --- a/packages/components/content-panel/content-panel.ts +++ b/packages/components/content-panel/content-panel.ts @@ -25,7 +25,7 @@ import { } from '@koobiq/components/core'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqResizable, KbqResizer, KbqResizerSizeChangeEvent } from '@koobiq/components/resizer'; -import { KbqScrollbar, KbqScrollbarModule } from '@koobiq/components/scrollbar'; +import { KbqScrollbar } from '@koobiq/components/scrollbar/private'; import { SizeL } from '@koobiq/design-tokens'; const KBQ_CONTENT_PANEL_CONTAINER_CONTENT_ANIMATION = trigger('contentAnimation', [ @@ -139,15 +139,16 @@ export class KbqContentPanelHeader { @Component({ selector: 'kbq-content-panel-body', - imports: [KbqScrollbarModule, KbqOverflowShadowContainer], + imports: [KbqScrollbar, KbqOverflowShadowContainer], template: ` - - + `, styleUrl: './content-panel-body.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -206,7 +207,10 @@ export class KbqContentPanelFooter { export class KbqContentPanel { private readonly contentPanelBody = contentChild(KbqContentPanelBody); - /** Current body overflow state. Read by the header and footer to render their `box-shadow`. */ + /** + * Current body overflow state. Read by the header and footer to render their `box-shadow`. + * @docs-private + */ readonly bodyOverflow = computed( () => this.contentPanelBody()?.overflowContainer().overflow() ?? { top: false, bottom: false } ); @@ -214,11 +218,11 @@ export class KbqContentPanel { @Component({ selector: 'kbq-content-panel-container', - imports: [KbqResizable, KbqResizer, KbqScrollbarModule], + imports: [KbqResizable, KbqResizer, KbqScrollbar], template: ` - +
- +
@if (openedState()) {
{ test('should show header shadow after scrolling down', async ({ page }) => { await page.goto('/E2eContentPanelScrollOverflow'); - await page.locator('.kbq-content-panel-body [data-overlayscrollbars-contents]').evaluate((el) => { + await page.locator('.kbq-content-panel-body .kbq-private-scrollbar-viewport').evaluate((el) => { el.scrollTop = 50; }); @@ -35,7 +35,7 @@ test.describe('KbqContentPanelModule', () => { test('should show both shadows when scrolled to the middle', async ({ page }) => { await page.goto('/E2eContentPanelScrollOverflow'); - await page.locator('.kbq-content-panel-body [data-overlayscrollbars-contents]').evaluate((el) => { + await page.locator('.kbq-content-panel-body .kbq-private-scrollbar-viewport').evaluate((el) => { el.scrollTop = Math.floor((el.scrollHeight - el.clientHeight) / 2); }); diff --git a/packages/components/scrollbar/examples.scrollbar.en.md b/packages/components/scrollbar/examples.scrollbar.en.md index d6f3d9fc4e..b03207bc14 100644 --- a/packages/components/scrollbar/examples.scrollbar.en.md +++ b/packages/components/scrollbar/examples.scrollbar.en.md @@ -1 +1,35 @@ - +## Scrollbar visibility + +The `kbqScrollbarVisibility` input controls when the custom track/thumb are shown — `hover`, `always`, `scroll`, or `hidden`; scrolling itself always stays functional. + + + +## Virtual scroll + +The `kbqScrollbarVirtualViewport` directive marks a nested `cdk-virtual-scroll-viewport` as the element `kbqScrollbar` should measure, listen to, and scroll — instead of its own host. + + + +## Programmatic scrollbar control + +Via `exportAs="kbqScrollbar"`, the directive exposes `scrollTo`, `scrollToElement`, `scrollToTop`/`scrollToBottom`, `scrollStart`/`scrollEnd`, and the `isTopReached`/`isBottomReached`/`isStartReached`/`isEndReached` signals. + + + +## RTL support + +The scrollbar tracks ancestor direction (e.g. `Directionality`/`dir`) and adjusts drag, `scrollStart`/`scrollEnd`, and edge-reached state accordingly. + + + +## Native fallback + +Setting `native: true` via `kbqScrollbarConfigProvider` makes the directive fall back to the browser's native scrollbar entirely, bypassing the custom track/thumb — the same behavior applied automatically on coarse-pointer (touch) devices. + + + +## Disabling interaction + +The `kbqScrollbarDisableDrag` and `kbqScrollbarDisableClick` inputs keep scrolling functional while independently disabling drag-on-thumb and click-on-track — e.g. keep drag for quick scanning while disabling accidental track clicks, or the other way around. + + diff --git a/packages/components/scrollbar/examples.scrollbar.ru.md b/packages/components/scrollbar/examples.scrollbar.ru.md index d6f3d9fc4e..b40f185ae0 100644 --- a/packages/components/scrollbar/examples.scrollbar.ru.md +++ b/packages/components/scrollbar/examples.scrollbar.ru.md @@ -1 +1,35 @@ - +## Отображение скроллбара + +Входной параметр `kbqScrollbarVisibility` управляет тем, когда показываются кастомные трек и ползунок — `hover`, `always`, `scroll` или `hidden`; скролл при этом всегда остаётся рабочим. + + + +## Виртуальный скролл + +Директива `kbqScrollbarVirtualViewport` помечает вложенный `cdk-virtual-scroll-viewport` как элемент, который `kbqScrollbar` должен измерять, слушать и скроллить вместо собственного хоста. + + + +## Программное управление скроллом + +Через `exportAs="kbqScrollbar"` директива открывает методы `scrollTo`, `scrollToElement`, `scrollToTop`/`scrollToBottom`, `scrollStart`/`scrollEnd`, а также сигналы `isTopReached`/`isBottomReached`/`isStartReached`/`isEndReached`. + + + +## Поддержка RTL + +Скроллбар учитывает направление, заданное родительским элементом (например, через `Directionality`/`Dir`), и соответствующим образом изменяет перетаскивание, `scrollStart`/`scrollEnd` и состояние достижения краёв. + + + +## Нативный скроллбар + +Значение `native: true`, переданное через `kbqScrollbarConfigProvider`, полностью переключает директиву на нативный скроллбар браузера вместо пользовательского трека/ползунка — так же, как это происходит автоматически на устройствах с сенсорным вводом. + + + +## Отключение взаимодействия + +Входные параметры `kbqScrollbarDisableDrag` и `kbqScrollbarDisableClick` оставляют скролл рабочим, но независимо друг от друга отключают перетаскивание ползунка и клик по треку — например, можно оставить перетаскивание для быстрой прокрутки, но отключить случайные клики по треку, или наоборот. + + diff --git a/packages/components/scrollbar/private/__screenshots__/01-dark.png b/packages/components/scrollbar/private/__screenshots__/01-dark.png new file mode 100644 index 0000000000..0bcd770d81 Binary files /dev/null and b/packages/components/scrollbar/private/__screenshots__/01-dark.png differ diff --git a/packages/components/scrollbar/private/__screenshots__/01-light.png b/packages/components/scrollbar/private/__screenshots__/01-light.png new file mode 100644 index 0000000000..1eea95b593 Binary files /dev/null and b/packages/components/scrollbar/private/__screenshots__/01-light.png differ diff --git a/packages/components/scrollbar/private/e2e.playwright-spec.ts b/packages/components/scrollbar/private/e2e.playwright-spec.ts new file mode 100644 index 0000000000..5cc065df05 --- /dev/null +++ b/packages/components/scrollbar/private/e2e.playwright-spec.ts @@ -0,0 +1,330 @@ +import { expect, Locator, Page, test } from '@playwright/test'; +import { e2eEnableDarkTheme } from 'packages/e2e/utils'; + +test.describe('KbqScrollbar (private)', () => { + test.describe('E2ePrivateScrollbarStateAndStyle', () => { + const getComponent = (page: Page) => page.getByTestId('e2ePrivateScrollbarStateAndStyle'); + + test('states', async ({ page }) => { + await page.goto('/E2ePrivateScrollbarStateAndStyle'); + const component = getComponent(page); + + await expect(component).toHaveScreenshot('01-light.png'); + await e2eEnableDarkTheme(page); + await expect(component).toHaveScreenshot('01-dark.png'); + }); + }); + + test.describe('E2ePrivateScrollbarDrag', () => { + // `KbqScrollbar` moves the host's content into an auto-created + // `.kbq-private-scrollbar-viewport` wrapper so the track/thumb (siblings on the host) stay + // a fixed overlay — real scroll metrics belong on that wrapper, not the host itself. + const getViewport = (container: Locator) => container.locator('.kbq-private-scrollbar-viewport'); + + test('dragging the vertical thumb scrolls the content down', async ({ page }) => { + await page.goto('/E2ePrivateScrollbarDrag'); + const container = page.getByTestId('drag'); + const viewport = getViewport(container); + const thumb = container.locator('.kbq-private-scrollbar-track_vertical .kbq-private-scrollbar-thumb'); + + await expect.poll(() => viewport.evaluate((el) => el.scrollTop)).toBe(0); + + const box = await thumb.boundingBox(); + + if (!box) throw new Error('bounding box is null'); + + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2 + 40, { steps: 5 }); + await page.mouse.up(); + + await expect.poll(() => viewport.evaluate((el) => el.scrollTop)).toBeGreaterThan(0); + }); + + test('clicking the track away from the thumb jumps the scroll position toward the click', async ({ page }) => { + await page.goto('/E2ePrivateScrollbarDrag'); + const container = page.getByTestId('drag'); + const viewport = getViewport(container); + const track = container.locator('.kbq-private-scrollbar-track_vertical'); + + await expect.poll(() => viewport.evaluate((el) => el.scrollTop)).toBe(0); + + const box = await track.boundingBox(); + + if (!box) throw new Error('bounding box is null'); + + // The thumb starts at the track's top — clicking near the bottom lands on the track + // itself, not the thumb, triggering "jump to click" rather than a drag. + await page.mouse.click(box.x + box.width / 2, box.y + box.height - 5); + + await expect.poll(() => viewport.evaluate((el) => el.scrollTop)).toBeGreaterThan(0); + }); + + test('kbqScrollbarDisableDrag blocks a real thumb drag but leaves track click-to-jump working', async ({ + page + }) => { + await page.goto('/E2ePrivateScrollbarDrag'); + const container = page.getByTestId('drag-disabled'); + const viewport = getViewport(container); + const track = container.locator('.kbq-private-scrollbar-track_vertical'); + const thumb = track.locator('.kbq-private-scrollbar-thumb'); + + const thumbBox = await thumb.boundingBox(); + + if (!thumbBox) throw new Error('bounding box is null'); + + await page.mouse.move(thumbBox.x + thumbBox.width / 2, thumbBox.y + thumbBox.height / 2); + await page.mouse.down(); + await page.mouse.move(thumbBox.x + thumbBox.width / 2, thumbBox.y + thumbBox.height / 2 + 40, { + steps: 5 + }); + await page.mouse.up(); + + expect(await viewport.evaluate((el) => el.scrollTop)).toBe(0); + + const trackBox = await track.boundingBox(); + + if (!trackBox) throw new Error('bounding box is null'); + + // The thumb starts at the track's top — pressing down near the bottom lands on the + // track itself, triggering "jump to click", which disableDrag must leave untouched. + await page.mouse.move(trackBox.x + trackBox.width / 2, trackBox.y + trackBox.height - 5); + await page.mouse.down(); + + const afterJump = await viewport.evaluate((el) => el.scrollTop); + + expect(afterJump).toBeGreaterThan(0); + + // But holding the mouse down past the jump and moving it must NOT keep following the + // pointer — that would be a real, continued drag, which disableDrag is supposed to + // block regardless of how the gesture started. + await page.mouse.move(trackBox.x + trackBox.width / 2, trackBox.y + trackBox.height / 2, { steps: 5 }); + await page.mouse.up(); + + expect(await viewport.evaluate((el) => el.scrollTop)).toBe(afterJump); + }); + + test('kbqScrollbarDisableClick blocks track click-to-jump but leaves thumb drag working', async ({ page }) => { + await page.goto('/E2ePrivateScrollbarDrag'); + const container = page.getByTestId('click-disabled'); + const viewport = getViewport(container); + const track = container.locator('.kbq-private-scrollbar-track_vertical'); + const thumb = track.locator('.kbq-private-scrollbar-thumb'); + + const trackBox = await track.boundingBox(); + + if (!trackBox) throw new Error('bounding box is null'); + + await page.mouse.click(trackBox.x + trackBox.width / 2, trackBox.y + trackBox.height - 5); + + expect(await viewport.evaluate((el) => el.scrollTop)).toBe(0); + + const thumbBox = await thumb.boundingBox(); + + if (!thumbBox) throw new Error('bounding box is null'); + + await page.mouse.move(thumbBox.x + thumbBox.width / 2, thumbBox.y + thumbBox.height / 2); + await page.mouse.down(); + await page.mouse.move(thumbBox.x + thumbBox.width / 2, thumbBox.y + thumbBox.height / 2 + 40, { + steps: 5 + }); + await page.mouse.up(); + + await expect.poll(() => viewport.evaluate((el) => el.scrollTop)).toBeGreaterThan(0); + }); + + test('kbqScrollbarDisableDrag + kbqScrollbarDisableClick together make the whole track inert', async ({ + page + }) => { + await page.goto('/E2ePrivateScrollbarDrag'); + const container = page.getByTestId('both-disabled'); + const viewport = getViewport(container); + const track = container.locator('.kbq-private-scrollbar-track_vertical'); + const thumb = track.locator('.kbq-private-scrollbar-thumb'); + + const trackBox = await track.boundingBox(); + + if (!trackBox) throw new Error('bounding box is null'); + + // Click on the track away from the thumb — with only one flag set this would jump-to- + // click ([scrollbar.scss] makes the track `pointer-events: none` entirely once both + // flags are set, not just individually), so nothing should happen here at all. + await page.mouse.click(trackBox.x + trackBox.width / 2, trackBox.y + trackBox.height - 5); + + expect(await viewport.evaluate((el) => el.scrollTop)).toBe(0); + + // A real drag directly on the thumb too — same result: `pointer-events: none` on the + // ancestor track means the thumb never receives the pointerdown either. + const thumbBox = await thumb.boundingBox(); + + if (!thumbBox) throw new Error('bounding box is null'); + + await page.mouse.move(thumbBox.x + thumbBox.width / 2, thumbBox.y + thumbBox.height / 2); + await page.mouse.down(); + await page.mouse.move(thumbBox.x + thumbBox.width / 2, thumbBox.y + thumbBox.height / 2 + 40, { + steps: 5 + }); + await page.mouse.up(); + + expect(await viewport.evaluate((el) => el.scrollTop)).toBe(0); + }); + }); + + test.describe('E2ePrivateScrollbarScrollTo', () => { + test('scrollToBottom/scrollToTop buttons scroll the content and update their own disabled state via isBottomReached/isTopReached', async ({ + page + }) => { + await page.goto('/E2ePrivateScrollbarScrollTo'); + const scrollTopButton = page.getByTestId('scroll-top'); + const scrollBottomButton = page.getByTestId('scroll-bottom'); + + await expect(scrollTopButton).toBeDisabled(); + await expect(scrollBottomButton).toBeEnabled(); + + await scrollBottomButton.click(); + + await expect(scrollTopButton).toBeEnabled(); + await expect(scrollBottomButton).toBeDisabled(); + + await scrollTopButton.click(); + + await expect(scrollTopButton).toBeDisabled(); + await expect(scrollBottomButton).toBeEnabled(); + }); + }); + + test.describe('E2ePrivateScrollbarContentMutation', () => { + test( + "appending content that grows scrollHeight without resizing the scroll element's own " + + 'box does not shrink the thumb until update() is called — there is no MutationObserver ' + + 'watching content changes', + async ({ page }) => { + await page.goto('/E2ePrivateScrollbarContentMutation'); + const container = page.getByTestId('content-mutation'); + const thumb = container.locator('.kbq-private-scrollbar-track_vertical .kbq-private-scrollbar-thumb'); + + const initialHeight = (await thumb.boundingBox())?.height; + + await page.getByTestId('append').click(); + + // Real browser, real ResizeObserver — the scroll element's own clientHeight never + // changed (only its scrollHeight did), so no resize fires, no scroll happened, and + // nothing else re-measures on its own: the thumb stays exactly as it was. + expect((await thumb.boundingBox())?.height).toBe(initialHeight); + + await page.getByTestId('update').click(); + + // Only once update() is explicitly called does the thumb reflect the new, + // much-larger content — proving the staleness above wasn't a fluke of a + // still-settling layout, but the actual absence of auto-detection. + expect((await thumb.boundingBox())?.height).toBeLessThan(initialHeight!); + } + ); + }); + + test.describe('E2ePrivateScrollbarHostPadding', () => { + // Host padding is asymmetric (10/25/15/35, see `E2ePrivateScrollbarHostPadding`'s styles) — + // deliberately, so a bug that swaps two sides wouldn't go unnoticed the way it could with a + // single uniform padding value. Both tracks overflow here, so corner-avoidance is active; + // every assertion below picks the one edge per track corner-avoidance never touches (only + // the vertical track's `bottom` and the horizontal track's `right` — or `left` in RTL — are + // ever adjusted to dodge the other track). + test("insets the vertical and horizontal tracks by the host's own asymmetric padding", async ({ page }) => { + await page.goto('/E2ePrivateScrollbarHostPadding'); + const container = page.getByTestId('host-padding'); + const verticalTrack = container.locator('.kbq-private-scrollbar-track_vertical'); + const horizontalTrack = container.locator('.kbq-private-scrollbar-track_horizontal'); + + const containerBox = await container.boundingBox(); + const verticalBox = await verticalTrack.boundingBox(); + const horizontalBox = await horizontalTrack.boundingBox(); + + if (!containerBox || !verticalBox || !horizontalBox) throw new Error('bounding box is null'); + + expect(verticalBox.y).toBeCloseTo(containerBox.y + 10, 0); + expect(verticalBox.x + verticalBox.width).toBeCloseTo(containerBox.x + containerBox.width - 25, 0); + + expect(horizontalBox.x).toBeCloseTo(containerBox.x + 35, 0); + expect(horizontalBox.y + horizontalBox.height).toBeCloseTo(containerBox.y + containerBox.height - 15, 0); + }); + + test("flips the vertical track to the host's logical-left padding under RTL", async ({ page }) => { + await page.goto('/E2ePrivateScrollbarHostPadding'); + + // `host-padding-rtl` is wrapped in CDK's `[dir]` directive (`
`), which + // provides `Directionality` locally via DI from its own attribute — global + // `document.documentElement.dir` isn't an option here, since `Directionality` only ever + // reads it once, at its own construction (app bootstrap), so setting it later (e.g. via + // `page.evaluate()`, or even `addInitScript`, which this dev server's own bootstrapping + // ends up clobbering before Angular reads it) wouldn't be picked up at all. + const container = page.getByTestId('host-padding-rtl'); + const verticalTrack = container.locator('.kbq-private-scrollbar-track_vertical'); + const horizontalTrack = container.locator('.kbq-private-scrollbar-track_horizontal'); + + const containerBox = await container.boundingBox(); + const verticalBox = await verticalTrack.boundingBox(); + const horizontalBox = await horizontalTrack.boundingBox(); + + if (!containerBox || !verticalBox || !horizontalBox) throw new Error('bounding box is null'); + + // RTL moves the vertical track to the logical-left (host-padding-left) edge instead of + // the right. + expect(verticalBox.x).toBeCloseTo(containerBox.x + 35, 0); + + // The horizontal track's corner-avoidance-adjusted side mirrors too (becomes `left` + // instead of `right`) — its `right`/`bottom` stay the safe, untouched edges here. + expect(horizontalBox.x + horizontalBox.width).toBeCloseTo(containerBox.x + containerBox.width - 25, 0); + expect(horizontalBox.y + horizontalBox.height).toBeCloseTo(containerBox.y + containerBox.height - 15, 0); + }); + }); + + test.describe('E2ePrivateScrollbarHoverVisibility', () => { + test("'hover' mode (the default) reveals the custom track on real mouse hover and hides it again on mouse leave", async ({ + page + }) => { + await page.goto('/E2ePrivateScrollbarHoverVisibility'); + const container = page.getByTestId('hover-visibility'); + const visibleClass = /(?:^|\s)kbq-private-scrollbar_visible(?:\s|$)/; + + await expect(container).not.toHaveClass(visibleClass); + + await container.hover(); + await expect(container).toHaveClass(visibleClass); + + await page.mouse.move(0, 0); + await expect(container).not.toHaveClass(visibleClass); + }); + }); + + test.describe('E2ePrivateScrollbarKeyboard', () => { + // Same reasoning as `getViewport` above: the auto-created `.kbq-private-scrollbar-viewport` + // wrapper is the real scrolling element here (no explicit `kbqScrollbarVirtualViewport`), so + // it's the element that must be focusable and respond to keyboard scrolling. + const getViewport = (container: Locator) => container.locator('.kbq-private-scrollbar-viewport'); + + test('the scroll region is reachable via Tab and operable with the keyboard, like the native scrollbar it replaces', async ({ + page + }) => { + await page.goto('/E2ePrivateScrollbarKeyboard'); + const container = page.getByTestId('keyboard'); + const viewport = getViewport(container); + + // No other focusable element on the page — a single Tab from the top must land here. + await page.keyboard.press('Tab'); + await expect(viewport).toBeFocused(); + + await expect.poll(() => viewport.evaluate((el) => el.scrollTop)).toBe(0); + + await page.keyboard.press('ArrowDown'); + await expect.poll(() => viewport.evaluate((el) => el.scrollTop)).toBeGreaterThan(0); + + await page.keyboard.press('End'); + const maxScrollTop = await viewport.evaluate((el) => el.scrollHeight - el.clientHeight); + + await expect.poll(() => viewport.evaluate((el) => el.scrollTop)).toBe(maxScrollTop); + + await page.keyboard.press('Home'); + await expect.poll(() => viewport.evaluate((el) => el.scrollTop)).toBe(0); + }); + }); +}); diff --git a/packages/components/scrollbar/private/e2e.ts b/packages/components/scrollbar/private/e2e.ts new file mode 100644 index 0000000000..caf9d7195f --- /dev/null +++ b/packages/components/scrollbar/private/e2e.ts @@ -0,0 +1,364 @@ +import { Dir } from '@angular/cdk/bidi'; +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { KbqScrollbar, kbqScrollbarConfigProvider } from './scrollbar'; + +@Component({ + selector: 'e2e-private-scrollbar-state-and-style', + imports: [KbqScrollbar], + template: ` + +
+

basic

+
+ + +
+

hover

+
+ + +
+

active

+
+ `, + styles: ` + :host { + display: inline-grid; + grid-template-columns: repeat(3, 100px); + gap: var(--kbq-size-s); + padding: var(--kbq-size-xs); + } + + .e2e-scrollbar { + width: 100%; + height: 100px; + border-radius: var(--kbq-size-border-radius); + background-color: var(--kbq-background-bg-secondary); + } + + p { + width: 200%; + height: 200%; + margin: var(--kbq-size-l); + } + `, + providers: [kbqScrollbarConfigProvider({ visibility: 'always' })], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2ePrivateScrollbarStateAndStyle' + } +}) +export class E2ePrivateScrollbarStateAndStyle {} + +@Component({ + selector: 'e2e-private-scrollbar-drag', + imports: [KbqScrollbar], + template: ` +
+

{{ content }}

+
+ +
+

{{ content }}

+
+ +
+

{{ content }}

+
+ +
+

{{ content }}

+
+ `, + styles: ` + :host { + display: inline-flex; + gap: var(--kbq-size-s); + padding: var(--kbq-size-xs); + } + + .e2e-scrollbar { + width: 200px; + height: 100px; + border-radius: var(--kbq-size-border-radius); + background-color: var(--kbq-background-bg-secondary); + } + + p { + width: 150%; + margin: var(--kbq-size-l); + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2ePrivateScrollbarDrag' + } +}) +export class E2ePrivateScrollbarDrag { + readonly content = `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 guessing correctly. This strategy can theoretically be used to break any form of encryption that is not information-theoretically secure.[1] However, in a properly designed cryptosystem the chance of successfully guessing the key is negligible.`; +} + +@Component({ + selector: 'e2e-private-scrollbar-scroll-to', + imports: [KbqScrollbar], + template: ` + + + +
+

{{ content }}

+
+ `, + styles: ` + :host { + display: block; + padding: var(--kbq-size-xs); + } + + .e2e-scrollbar { + width: 200px; + height: 100px; + border-radius: var(--kbq-size-border-radius); + background-color: var(--kbq-background-bg-secondary); + } + + p { + width: 150%; + margin: var(--kbq-size-l); + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2ePrivateScrollbarScrollTo' + } +}) +export class E2ePrivateScrollbarScrollTo { + readonly content = `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 guessing correctly. This strategy can theoretically be used to break any form of encryption that is not information-theoretically secure.[1] However, in a properly designed cryptosystem the chance of successfully guessing the key is negligible.`; +} + +@Component({ + selector: 'e2e-private-scrollbar-hover-visibility', + imports: [KbqScrollbar], + template: ` +
+

{{ content }}

+
+ `, + styles: ` + :host { + display: block; + padding: var(--kbq-size-xs); + } + + .e2e-scrollbar { + width: 200px; + height: 100px; + border-radius: var(--kbq-size-border-radius); + background-color: var(--kbq-background-bg-secondary); + } + + p { + width: 150%; + margin: var(--kbq-size-l); + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2ePrivateScrollbarHoverVisibility' + } +}) +export class E2ePrivateScrollbarHoverVisibility { + readonly content = `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 guessing correctly. This strategy can theoretically be used to break any form of encryption that is not information-theoretically secure.[1] However, in a properly designed cryptosystem the chance of successfully guessing the key is negligible.`; +} + +/** + * Demonstrates that appending content which grows `scrollHeight` without changing the scroll + * element's own box size (`clientHeight`) is invisible to `KbqScrollbar` until something calls + * `update()` — there's no `MutationObserver` watching content changes. + */ +@Component({ + selector: 'e2e-private-scrollbar-content-mutation', + imports: [KbqScrollbar], + template: ` + + + +
+ @for (item of items(); track item) { +
{{ item }}
+ } +
+ `, + styles: ` + :host { + display: block; + padding: var(--kbq-size-xs); + } + + .e2e-scrollbar { + width: 200px; + height: 300px; + border-radius: var(--kbq-size-border-radius); + background-color: var(--kbq-background-bg-secondary); + } + + .row { + height: 30px; + margin: 0; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2ePrivateScrollbarContentMutation' + } +}) +export class E2ePrivateScrollbarContentMutation { + // 15 rows * 30px = 450px vs. a 300px-tall viewport — overflows, but the thumb ratio (300/450) + // stays well above the min-thumb-size clamp, so a later shrink is actually observable. + protected readonly items = signal(Array.from({ length: 15 }, (_, i) => i)); + + protected append(): void { + const current = this.items(); + + // Pushes scrollHeight from 450px to 1050px while the container itself stays 300px tall — + // the box size never changes, only the content that overflows it does. + this.items.set([...current, ...Array.from({ length: 20 }, (_, i) => current.length + i)]); + } +} + +/** + * Demonstrates that the track insets by the host's own padding instead of sitting flush with its + * border edge — without `syncHostPadding()`, a host with its own CSS padding would leave the track + * floating over the empty padding area instead of overlaying the real content edge. + */ +@Component({ + selector: 'e2e-private-scrollbar-host-padding', + imports: [KbqScrollbar, Dir], + template: ` +
+

{{ content }}

+
+ + +
+
+

{{ content }}

+
+
+ `, + styles: ` + :host { + display: block; + padding: var(--kbq-size-xs); + } + + .e2e-scrollbar { + box-sizing: border-box; + width: 200px; + height: 100px; + /* Deliberately asymmetric (not a single uniform value) — otherwise a bug that swaps + two sides (e.g. left/right, or physical/logical in RTL) could go unnoticed. */ + padding: 10px 25px 15px 35px; + border-radius: var(--kbq-size-border-radius); + background-color: var(--kbq-background-bg-secondary); + margin-bottom: var(--kbq-size-l); + } + + p { + width: 150%; + margin: var(--kbq-size-l); + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2ePrivateScrollbarHostPadding' + } +}) +export class E2ePrivateScrollbarHostPadding { + readonly content = `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 guessing correctly. This strategy can theoretically be used to break any form of encryption that is not information-theoretically secure.[1] However, in a properly designed cryptosystem the chance of successfully guessing the key is negligible.`; +} + +/** + * No focusable descendants inside the scrollable content — a keyboard-only user must be able to + * reach and operate the scroll region itself (Tab to focus it, arrow/paging keys to scroll it), + * the same way the native scrollbar it replaces would allow. + */ +@Component({ + selector: 'e2e-private-scrollbar-keyboard', + imports: [KbqScrollbar], + template: ` +
+

{{ content }}

+
+ `, + styles: ` + :host { + display: block; + padding: var(--kbq-size-xs); + } + + .e2e-scrollbar { + width: 200px; + height: 100px; + border-radius: var(--kbq-size-border-radius); + background-color: var(--kbq-background-bg-secondary); + } + + p { + width: 150%; + height: 400%; + margin: var(--kbq-size-l); + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2ePrivateScrollbarKeyboard' + } +}) +export class E2ePrivateScrollbarKeyboard { + readonly content = `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 guessing correctly. This strategy can theoretically be used to break any form of encryption that is not information-theoretically secure.[1] However, in a properly designed cryptosystem the chance of successfully guessing the key is negligible.`; +} diff --git a/packages/components/scrollbar/private/index.ts b/packages/components/scrollbar/private/index.ts new file mode 100644 index 0000000000..7e1a213e3e --- /dev/null +++ b/packages/components/scrollbar/private/index.ts @@ -0,0 +1 @@ +export * from './public-api'; diff --git a/packages/components/scrollbar/private/ng-package.json b/packages/components/scrollbar/private/ng-package.json new file mode 100644 index 0000000000..bebf62dcb5 --- /dev/null +++ b/packages/components/scrollbar/private/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "index.ts" + } +} diff --git a/packages/components/scrollbar/private/public-api.ts b/packages/components/scrollbar/private/public-api.ts new file mode 100644 index 0000000000..983c1f6eca --- /dev/null +++ b/packages/components/scrollbar/private/public-api.ts @@ -0,0 +1,2 @@ +export * from './scrollbar'; +export * from './scrollbar.module'; diff --git a/packages/components/scrollbar/private/scrollbar.module.ts b/packages/components/scrollbar/private/scrollbar.module.ts new file mode 100644 index 0000000000..15f2ac37da --- /dev/null +++ b/packages/components/scrollbar/private/scrollbar.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core'; +import { KbqScrollbar, KbqScrollbarVirtualViewport } from './scrollbar'; + +const COMPONENTS = [ + KbqScrollbar, + KbqScrollbarVirtualViewport +]; + +/** NOTE! Intended exclusively for use within this repository. */ +@NgModule({ + imports: COMPONENTS, + exports: COMPONENTS +}) +export class KbqScrollbarModule {} diff --git a/packages/components/scrollbar/private/scrollbar.scss b/packages/components/scrollbar/private/scrollbar.scss new file mode 100644 index 0000000000..571c7673a8 --- /dev/null +++ b/packages/components/scrollbar/private/scrollbar.scss @@ -0,0 +1,226 @@ +:where(.kbq-private-scrollbar) { + // Thumb thickness (width for a vertical thumb, height for horizontal) plus the gap between the + // thumb and the track's own edge on each side — the track's own thickness is these two + // composed, not an independent value, so it can't drift out of sync with either. No + // `--kbq-size-*` token lands on 3px, hence the literal value. + --kbq-private-scrollbar-size-thumb-thickness: var(--kbq-size-s); + --kbq-private-scrollbar-size-track-padding: 3px; + --kbq-private-scrollbar-size-track-dimension: calc( + var(--kbq-private-scrollbar-size-thumb-thickness) + 2 * var(--kbq-private-scrollbar-size-track-padding) + ); + --kbq-private-scrollbar-size-thumb-min-size: var(--kbq-size-3xl); + --kbq-private-scrollbar-size-thumb-border-radius: var(--kbq-size-s); + --kbq-private-scrollbar-thumb-default-background: var(--kbq-semantic-contrast-a3, var(--kbq-palette-grey-50-a32)); + --kbq-private-scrollbar-thumb-hover-background: var(--kbq-semantic-contrast-a4, var(--kbq-palette-grey-50-a48)); + --kbq-private-scrollbar-thumb-active-background: var(--kbq-semantic-contrast-a5, var(--kbq-palette-grey-20-a55)); + --kbq-private-scrollbar-track-default-background: transparent; + --kbq-private-scrollbar-track-hover-background: transparent; + --kbq-private-scrollbar-track-active-background: transparent; + --kbq-private-scrollbar-transition-duration: 0.15s; + + // Overwritten inline by `KbqScrollbar.syncHostPadding()` with the host's own real computed + // padding on every recompute — these `0px` defaults only apply before the first recompute, or + // when `syncHostPadding()` doesn't run at all (`native`/coarse pointer, no track to position). + --kbq-private-scrollbar-host-padding-top: 0px; + --kbq-private-scrollbar-host-padding-right: 0px; + --kbq-private-scrollbar-host-padding-bottom: 0px; + --kbq-private-scrollbar-host-padding-left: 0px; +} + +:where(.kbq-dark .kbq-private-scrollbar) { + --kbq-private-scrollbar-thumb-default-background: var( + --kbq-semantic-dark-contrast-a9, + var(--kbq-palette-grey-50-a48) + ); + --kbq-private-scrollbar-thumb-hover-background: var( + --kbq-semantic-dark-contrast-a10, + var(--kbq-palette-grey-50-a60) + ); + --kbq-private-scrollbar-thumb-active-background: var( + --kbq-semantic-dark-contrast-a11, + var(--kbq-palette-grey-50-a80) + ); +} + +.kbq-private-scrollbar { + position: relative; +} + +// Hides the native scrollbar on the effective scroll element (host, or the delegated +// kbqScrollbarVirtualViewport child) cross-browser. Applied only when overlaying with our own +// track/thumb — i.e. not on coarse pointers and not under the `native` config option. +.kbq-private-scrollbar_hide-native { + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar, + &::-webkit-scrollbar-thumb { + display: none; + } +} + +// Auto-created wrapper for the host's own content when there's no explicit +// kbqScrollbarVirtualViewport — holds `overflow`, so the track/thumb (siblings on the host) stay a +// fixed overlay instead of scrolling away with the content. +.kbq-private-scrollbar-viewport { + // The auto-created wrapper is a real component (`kbq-scrollbar-viewport`, an + // unknown custom element to the browser) so it can carry `hostDirectives: [CdkScrollable]` — + // unlike a plain `div`, it doesn't default to `display: block`, so `width`/`height: 100%` + // wouldn't apply without this. + display: block; + box-sizing: border-box; + width: 100%; + height: 100%; +} + +.kbq-private-scrollbar-track { + position: absolute; + z-index: 1; + box-sizing: border-box; + background: var(--kbq-private-scrollbar-track-default-background); + opacity: 0; + visibility: hidden; + pointer-events: auto; + // Prevents the browser's own touch-scroll/pan gesture recognition from fighting our + // pointer-driven drag on hybrid-input devices (e.g. touchscreen laptops) that can still route + // a touch pointer here despite `KbqScrollbar` building custom UI only when + // `matchMedia('(pointer: coarse)')` is false at init. + touch-action: none; + transition: + opacity var(--kbq-private-scrollbar-transition-duration), + visibility var(--kbq-private-scrollbar-transition-duration), + background-color var(--kbq-private-scrollbar-transition-duration); + + // The synthetic `.kbq-hover`/`.kbq-active` forms match on an ANCESTOR (the host), not on this + // element itself — the track is created via `Renderer2`, not template-authored, so nothing + // outside this directive can put a class directly on it. Matches the same ancestor-class + // pattern already used in `kbq-scrollbar`'s own theme (`_scrollbar-component-theme.scss`). + &:hover, + .kbq-private-scrollbar.kbq-hover & { + background: var(--kbq-private-scrollbar-track-hover-background); + } + + &:active, + .kbq-private-scrollbar.kbq-active & { + background: var(--kbq-private-scrollbar-track-active-background); + } +} + +// Only when BOTH drag and click are disabled does the whole track (and, via inheritance, its +// thumb) go fully inert. Deliberately not split into two single-flag rules: `pointer-events: none` +// on just the thumb makes a click landing on it pass through to the track underneath, which the +// track's own pointerdown listener then reads as `event.target !== thumb` — i.e. an ordinary track +// click — and still runs the full `beginInteraction()`/drag-follow flow through `_disable-click` +// alone, silently un-disabling drag. Same problem mirrored on `_disable-click` alone. With only one +// of the two flags set, blocking is left entirely to the `disableDrag()`/`disableClick()` checks in +// `scrollbar.ts` (`wireTrackInteraction`) instead — the thumb's own listener no-ops, and the +// pointerdown event bubbles to the track listener which excludes `event.target === thumb`, so +// nothing happens on either path once even one side is genuinely disabled. +.kbq-private-scrollbar_disable-drag.kbq-private-scrollbar_disable-click .kbq-private-scrollbar-track { + pointer-events: none; +} + +.kbq-private-scrollbar_visible .kbq-private-scrollbar-track { + opacity: 1; + visibility: visible; +} + +// The custom track/thumb are a purely interactive, screen-only affordance — nothing on a printed +// page can be dragged or clicked, so keep them out of print output entirely rather than rendering +// an inert (and `aria-hidden`) bar of unused affordance space. +@media print { + .kbq-private-scrollbar-track { + display: none; + } +} + +// Inset by the host's own padding (see `--kbq-private-scrollbar-host-padding-*` above) instead of +// flush `0` — the track is `position: absolute` directly on the host, so without this it would +// sit at the host's border edge even when the host's own padding pushes the real content further +// in, leaving the track floating over empty padding instead of overlaying the content it belongs +// to. +.kbq-private-scrollbar-track_vertical { + top: var(--kbq-private-scrollbar-host-padding-top); + right: var(--kbq-private-scrollbar-host-padding-right); + bottom: var(--kbq-private-scrollbar-host-padding-bottom); + width: var(--kbq-private-scrollbar-size-track-dimension); +} + +.kbq-private-scrollbar_rtl .kbq-private-scrollbar-track_vertical { + right: auto; + left: var(--kbq-private-scrollbar-host-padding-left); +} + +.kbq-private-scrollbar-track_horizontal { + left: var(--kbq-private-scrollbar-host-padding-left); + right: var(--kbq-private-scrollbar-host-padding-right); + bottom: var(--kbq-private-scrollbar-host-padding-bottom); + height: var(--kbq-private-scrollbar-size-track-dimension); +} + +// `axis: 'both'`, both tracks visible: each track stops short of the shared corner instead of +// running underneath the other track's thumb — added on top of (not instead of) the host-padding +// inset above. +.kbq-private-scrollbar-track_vertical.kbq-private-scrollbar-track_has-horizontal { + bottom: calc(var(--kbq-private-scrollbar-host-padding-bottom) + var(--kbq-private-scrollbar-size-track-dimension)); +} + +.kbq-private-scrollbar-track_horizontal.kbq-private-scrollbar-track_has-vertical { + right: calc(var(--kbq-private-scrollbar-host-padding-right) + var(--kbq-private-scrollbar-size-track-dimension)); +} + +.kbq-private-scrollbar_rtl .kbq-private-scrollbar-track_horizontal.kbq-private-scrollbar-track_has-vertical { + right: var(--kbq-private-scrollbar-host-padding-right); + left: calc(var(--kbq-private-scrollbar-host-padding-left) + var(--kbq-private-scrollbar-size-track-dimension)); +} + +.kbq-private-scrollbar-thumb { + position: absolute; + box-sizing: border-box; + border-radius: var(--kbq-private-scrollbar-size-thumb-border-radius); + background: var(--kbq-private-scrollbar-thumb-default-background); + cursor: default; + touch-action: none; + transition: background-color var(--kbq-private-scrollbar-transition-duration); + + // Expands the thumb's interactive hit area 3px beyond its rendered box without changing how it + // looks — a pseudo-element isn't a separate event target, so a pointerdown landing in this + // margin still reports `event.target` as the thumb itself, which `KbqScrollbar`'s own + // `pointerdown` listener on the thumb already handles like any other hit. + &::before { + content: ''; + position: absolute; + inset: -3px; + } + + &:hover, + .kbq-private-scrollbar.kbq-hover & { + background: var(--kbq-private-scrollbar-thumb-hover-background); + } + + &:active, + .kbq-private-scrollbar.kbq-active & { + background: var(--kbq-private-scrollbar-thumb-active-background); + } +} + +.kbq-private-scrollbar_dragging { + user-select: none; + + .kbq-private-scrollbar-thumb { + background: var(--kbq-private-scrollbar-thumb-active-background); + } +} + +// Insets (not `width: 100%`) so the thumb is actually narrower than the track by the padding on +// each side — a percentage width on an absolutely positioned element resolves against the +// containing block's padding box, so it would ignore the track's own padding entirely. +.kbq-private-scrollbar-track_vertical .kbq-private-scrollbar-thumb { + left: var(--kbq-private-scrollbar-size-track-padding); + right: var(--kbq-private-scrollbar-size-track-padding); +} + +.kbq-private-scrollbar-track_horizontal .kbq-private-scrollbar-thumb { + top: var(--kbq-private-scrollbar-size-track-padding); + bottom: var(--kbq-private-scrollbar-size-track-padding); +} diff --git a/packages/components/scrollbar/private/scrollbar.spec.ts b/packages/components/scrollbar/private/scrollbar.spec.ts new file mode 100644 index 0000000000..1b63309ff9 --- /dev/null +++ b/packages/components/scrollbar/private/scrollbar.spec.ts @@ -0,0 +1,1864 @@ +import { Directionality } from '@angular/cdk/bidi'; +import { CdkVirtualScrollViewport, ScrollDispatcher, ScrollingModule } from '@angular/cdk/scrolling'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + inject, + Provider, + Type, + viewChild +} from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { KBQ_WINDOW } from '@koobiq/components/core'; +import { Subject } from 'rxjs'; +import { KBQ_SCROLLBAR_CONFIG, KbqScrollbar, KbqScrollbarVirtualViewport, KbqScrollbarVisibility } from './scrollbar'; + +const createComponent = (component: Type, providers: Provider[] = []): ComponentFixture => { + TestBed.configureTestingModule({ imports: [component], providers }); + const fixture = TestBed.createComponent(component); + + fixture.autoDetectChanges(); + + return fixture; +}; + +const setMetrics = (el: HTMLElement, metrics: Record): void => { + for (const [key, value] of Object.entries(metrics)) { + Object.defineProperty(el, key, { configurable: true, writable: true, value }); + } +}; + +/** + * The actual scroll element for the non-delegated case: `KbqScrollbar` moves the host's content + * into an auto-created `.kbq-private-scrollbar-viewport` wrapper so the track/thumb (siblings on the + * host) aren't clipped/scrolled away by the host's own `overflow`. Scroll metrics belong on this + * wrapper, not the host. + */ +const getAutoViewport = (host: HTMLElement): HTMLElement => host.querySelector('.kbq-private-scrollbar-viewport')!; + +class MockDirectionality { + value: 'ltr' | 'rtl' = 'ltr'; + readonly change = new Subject<'ltr' | 'rtl'>(); +} + +const coarsePointerWindowProvider: Provider = { + provide: KBQ_WINDOW, + useValue: { + matchMedia: () => ({ matches: true }) as MediaQueryList, + getComputedStyle: (...args: Parameters) => window.getComputedStyle(...args) + } +}; + +@Component({ + selector: 'test-scrollbar-host', + imports: [KbqScrollbar], + template: ` +
+
content
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +class TestScrollbarHost { + readonly scrollbar = viewChild.required(KbqScrollbar); +} + +@Component({ + selector: 'test-scrollbar-padding-host', + imports: [KbqScrollbar], + template: ` +
+
content
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +class TestScrollbarPaddingHost { + readonly scrollbar = viewChild.required(KbqScrollbar); +} + +@Component({ + selector: 'test-scrollbar-disable-drag-host', + imports: [KbqScrollbar], + template: ` +
+
content
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +class TestScrollbarDisableDragHost { + readonly scrollbar = viewChild.required(KbqScrollbar); +} + +@Component({ + selector: 'test-scrollbar-disable-click-host', + imports: [KbqScrollbar], + template: ` +
+
content
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +class TestScrollbarDisableClickHost { + readonly scrollbar = viewChild.required(KbqScrollbar); +} + +@Component({ + selector: 'test-scrollbar-non-floating-host', + imports: [KbqScrollbar], + template: ` +
+
content
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +class TestScrollbarNonFloatingHost { + readonly scrollbar = viewChild.required(KbqScrollbar); +} + +@Component({ + selector: 'test-scrollbar-dynamic-floating-host', + imports: [KbqScrollbar], + template: ` +
+
content
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +class TestScrollbarDynamicFloatingHost { + // Same `markForCheck()` requirement as `TestScrollbarVisibilityHost` — a plain (non-signal) + // field read through an OnPush host needs it, and `fixture.changeDetectorRef.markForCheck()` + // from outside doesn't mark this view dirty. + private readonly cdr = inject(ChangeDetectorRef); + private _floating = true; + + get floating(): boolean { + return this._floating; + } + + set floating(value: boolean) { + this._floating = value; + this.cdr.markForCheck(); + } + + readonly scrollbar = viewChild.required(KbqScrollbar); +} + +@Component({ + selector: 'test-scrollbar-visibility-host', + imports: [KbqScrollbar], + template: ` +
+
content
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +class TestScrollbarVisibilityHost { + // `visibility` is a plain (non-signal) field read through an OnPush host, so writes need an + // explicit `markForCheck()` — via this component's own injected `ChangeDetectorRef`; + // `fixture.changeDetectorRef.markForCheck()` from outside does not mark this view dirty. + private readonly cdr = inject(ChangeDetectorRef); + private _visibility: KbqScrollbarVisibility = 'hover'; + + get visibility(): KbqScrollbarVisibility { + return this._visibility; + } + + set visibility(value: KbqScrollbarVisibility) { + this._visibility = value; + this.cdr.markForCheck(); + } + + readonly scrollbar = viewChild.required(KbqScrollbar); +} + +@Component({ + selector: 'test-scrollbar-dual-overflow-host', + imports: [KbqScrollbar], + template: ` +
+
content
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +class TestScrollbarDualOverflowHost { + readonly scrollbar = viewChild.required(KbqScrollbar); +} + +@Component({ + selector: 'test-scrollbar-virtual-host', + imports: [KbqScrollbar, KbqScrollbarVirtualViewport, ScrollingModule], + template: ` +
+ +
{{ item }}
+
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +class TestScrollbarVirtualHost { + readonly scrollbar = viewChild.required(KbqScrollbar); + readonly items = Array.from({ length: 100 }, (_, i) => i); +} + +@Component({ + selector: 'test-scrollbar-virtual-horizontal-host', + imports: [KbqScrollbar, KbqScrollbarVirtualViewport, ScrollingModule], + template: ` +
+ +
{{ item }}
+
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +class TestScrollbarVirtualHorizontalHost { + readonly scrollbar = viewChild.required(KbqScrollbar); + readonly items = Array.from({ length: 100 }, (_, i) => i); +} + +@Component({ + selector: 'test-scrollbar-composed-host', + template: ` +
content
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, + hostDirectives: [KbqScrollbar] +}) +class TestScrollbarComposedHost {} + +@Component({ + selector: 'test-scrollbar-initialized-host', + imports: [KbqScrollbar], + template: ` +
+
content
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +class TestScrollbarInitializedHost { + initializedCount = 0; +} + +@Component({ + selector: 'test-scrollbar-scroll-visibility-host', + imports: [KbqScrollbar], + template: ` +
+
content
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +class TestScrollbarScrollVisibilityHost { + readonly scrollbar = viewChild.required(KbqScrollbar); +} + +describe(KbqScrollbar.name, () => { + describe('basic rendering', () => { + it('creates a vertical track and thumb by default; builds but hides the horizontal track since there is no horizontal overflow', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const horizontalTrack = host.querySelector('.kbq-private-scrollbar-track_horizontal') as HTMLElement; + + expect(host.querySelector('.kbq-private-scrollbar-track_vertical')).toBeTruthy(); + expect(host.querySelector('.kbq-private-scrollbar-thumb')).toBeTruthy(); + expect(horizontalTrack).toBeTruthy(); + expect(horizontalTrack.style.display).toBe('none'); + }); + + it('keeps the track as a direct child of the host, not inside the scrolling wrapper — otherwise it would be clipped/scrolled away with the content instead of staying a fixed overlay', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + + expect(track.parentElement).toBe(host); + expect(scrollEl.contains(track)).toBe(false); + expect(scrollEl.style.overflowY).toBe('auto'); + }); + + it('creates both vertical and horizontal tracks by default', () => { + const fixture = createComponent(TestScrollbarDualOverflowHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + + expect(host.querySelector('.kbq-private-scrollbar-track_vertical')).toBeTruthy(); + expect(host.querySelector('.kbq-private-scrollbar-track_horizontal')).toBeTruthy(); + }); + + it('marks both tracks with the corner-avoidance modifier when both actually overflow', () => { + const fixture = createComponent(TestScrollbarDualOverflowHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const verticalTrack = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const horizontalTrack = host.querySelector('.kbq-private-scrollbar-track_horizontal') as HTMLElement; + + setMetrics(scrollEl, { clientHeight: 100, scrollHeight: 500, clientWidth: 100, scrollWidth: 500 }); + setMetrics(verticalTrack, { clientHeight: 100 }); + setMetrics(horizontalTrack, { clientWidth: 100 }); + fixture.componentInstance.scrollbar().update(); + + expect(verticalTrack.classList.contains('kbq-private-scrollbar-track_has-horizontal')).toBe(true); + expect(horizontalTrack.classList.contains('kbq-private-scrollbar-track_has-vertical')).toBe(true); + }); + + it('does not mark the corner-avoidance modifier when only one axis actually overflows', () => { + const fixture = createComponent(TestScrollbarDualOverflowHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const verticalTrack = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const horizontalTrack = host.querySelector('.kbq-private-scrollbar-track_horizontal') as HTMLElement; + + // Only the vertical axis overflows this time — equal client/scroll width. + setMetrics(scrollEl, { clientHeight: 100, scrollHeight: 500, clientWidth: 100, scrollWidth: 100 }); + setMetrics(verticalTrack, { clientHeight: 100 }); + fixture.componentInstance.scrollbar().update(); + + expect(horizontalTrack.style.display).toBe('none'); + expect(verticalTrack.classList.contains('kbq-private-scrollbar-track_has-horizontal')).toBe(false); + }); + + it('sizes the thumb against the track size AFTER corner-avoidance shrinks it, not a stale pre-shrink size', () => { + const fixture = createComponent(TestScrollbarDualOverflowHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const verticalTrack = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const horizontalTrack = host.querySelector('.kbq-private-scrollbar-track_horizontal') as HTMLElement; + const thumb = verticalTrack.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + + // jsdom doesn't run layout, so this getter stands in for what a real browser would do: + // once corner-avoidance's `_has-horizontal` modifier lands, `scrollbar.scss`'s + // `bottom: var(--...)` rule shrinks the track's real clientHeight. + Object.defineProperty(verticalTrack, 'clientHeight', { + configurable: true, + get(this: HTMLElement) { + return this.classList.contains('kbq-private-scrollbar-track_has-horizontal') ? 92 : 106; + } + }); + + setMetrics(scrollEl, { clientHeight: 250, scrollHeight: 500, clientWidth: 100, scrollWidth: 500 }); + setMetrics(horizontalTrack, { clientWidth: 100 }); + fixture.componentInstance.scrollbar().update(); + + expect(verticalTrack.classList.contains('kbq-private-scrollbar-track_has-horizontal')).toBe(true); + // Correct (post-shrink 92px track): travelLength 92-2*3=86, ratio ceil(250/500*100)/100 + // = 0.5, thumbSize max(0.5*86, 32) = 43. A stale pre-shrink 106px track would instead + // give travelLength 100 and thumbSize 50. + expect(thumb.style.height).toBe('43px'); + }); + + it('does not create track/thumb when KBQ_SCROLLBAR_CONFIG sets native: true', () => { + const fixture = createComponent(TestScrollbarHost, [ + { provide: KBQ_SCROLLBAR_CONFIG, useValue: { native: true } } + ]); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + + expect(host.querySelector('.kbq-private-scrollbar-track')).toBeNull(); + }); + + it('still tracks isTopReached/isBottomReached and fires reachTop/reachBottom when native: true, despite having no track/thumb of its own', () => { + const fixture = createComponent(TestScrollbarHost, [ + { provide: KBQ_SCROLLBAR_CONFIG, useValue: { native: true } } + ]); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollbar = fixture.componentInstance.scrollbar(); + const topSpy = jest.fn(); + const bottomSpy = jest.fn(); + + scrollbar.reachTop.subscribe(topSpy); + scrollbar.reachBottom.subscribe(bottomSpy); + + // `native: true` never creates an auto-viewport wrapper — the host itself is the + // effective scroll element. + setMetrics(host, { scrollTop: 0, clientHeight: 100, scrollHeight: 500 }); + scrollbar.update(); + + expect(scrollbar.isTopReached()).toBe(true); + expect(scrollbar.isBottomReached()).toBe(false); + expect(topSpy).toHaveBeenCalled(); + expect(bottomSpy).not.toHaveBeenCalled(); + + setMetrics(host, { scrollTop: 400 }); + scrollbar.update(); + + expect(scrollbar.isTopReached()).toBe(false); + expect(scrollbar.isBottomReached()).toBe(true); + expect(bottomSpy).toHaveBeenCalled(); + }); + + it('adds the disable-drag class when kbqScrollbarDisableDrag is set, independent of disable-click', () => { + const fixture = createComponent(TestScrollbarDisableDragHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + + expect(host.classList.contains('kbq-private-scrollbar_disable-drag')).toBe(true); + expect(host.classList.contains('kbq-private-scrollbar_disable-click')).toBe(false); + }); + + it('adds the disable-click class when kbqScrollbarDisableClick is set, independent of disable-drag', () => { + const fixture = createComponent(TestScrollbarDisableClickHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + + expect(host.classList.contains('kbq-private-scrollbar_disable-click')).toBe(true); + expect(host.classList.contains('kbq-private-scrollbar_disable-drag')).toBe(false); + }); + + it('does not create track/thumb on a coarse pointer', () => { + const fixture = createComponent(TestScrollbarHost, [coarsePointerWindowProvider]); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + + expect(host.querySelector('.kbq-private-scrollbar-track')).toBeNull(); + }); + + it('hides the track when content does not overflow', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + + setMetrics(scrollEl, { clientHeight: 500, scrollHeight: 500 }); + setMetrics(track, { clientHeight: 500 }); + fixture.componentInstance.scrollbar().update(); + + expect(track.style.display).toBe('none'); + }); + + it('recovers when content overflow is detected late instead of getting stuck hidden — e.g. a CdkVirtualScrollViewport whose real scrollHeight is not known on the very first pass', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + + // In a real browser, `clientHeight` is 0 while `display: none` and the host's real + // size otherwise — jsdom doesn't compute layout, so this getter simulates that + // coupling to exercise the exact trap the un-hide-before-measure ordering guards + // against: hiding once must not make every later measurement read 0 forever after. + Object.defineProperty(track, 'clientHeight', { + configurable: true, + get(this: HTMLElement) { + return this.style.display === 'none' ? 0 : 200; + } + }); + + setMetrics(scrollEl, { clientHeight: 200, scrollHeight: 200 }); + fixture.componentInstance.scrollbar().update(); + expect(track.style.display).toBe('none'); + + setMetrics(scrollEl, { clientHeight: 200, scrollHeight: 2000 }); + fixture.componentInstance.scrollbar().update(); + + expect(track.style.display).not.toBe('none'); + }); + + it('sizes the thumb proportionally to the visible ratio, clamped to the min size', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const thumb = host.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + + setMetrics(scrollEl, { scrollTop: 0, clientHeight: 200, scrollHeight: 500 }); + // 206, not 200: the thumb travels within the track minus a 3px gap at each end (jsdom + // can't resolve --kbq-private-scrollbar-size-track-padding, so it falls back to the + // same 3px default scrollbar.scss ships), so travelLength = 206 - 2*3 = 200. + setMetrics(track, { clientHeight: 206 }); + fixture.componentInstance.scrollbar().update(); + + // ratio = ceil(200/500 * 100) / 100 = 0.4; thumbSize = max(0.4 * 200, minThumbSize) = 80 + expect(thumb.style.height).toBe('80px'); + expect(thumb.style.top).toBe('3px'); + }); + + it('clamps the thumb to 0 rather than a negative size when the track is shorter than 2 * cssTrackPadding', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const thumb = host.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + + setMetrics(scrollEl, { scrollTop: 0, clientHeight: 200, scrollHeight: 500 }); + // 4px track: shorter than 2 * the 3px cssTrackPadding default (6px), so travelLength + // would go negative (4 - 6 = -2) without the clamp. + setMetrics(track, { clientHeight: 4 }); + fixture.componentInstance.scrollbar().update(); + + expect(thumb.style.height).toBe('0px'); + }); + + it("keeps the thumb inset from the track's own start/end edges by cssTrackPadding at both scroll extremes", () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const thumb = host.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + + setMetrics(scrollEl, { scrollTop: 0, clientHeight: 100, scrollHeight: 500 }); + setMetrics(track, { clientHeight: 106 }); + fixture.componentInstance.scrollbar().update(); + + expect(thumb.style.top).toBe('3px'); + + setMetrics(scrollEl, { scrollTop: 400 }); + fixture.componentInstance.scrollbar().update(); + + const thumbBottom = parseFloat(thumb.style.top) + parseFloat(thumb.style.height); + + expect(thumbBottom).toBe(103); + }); + + it('establishes native scroll on both axes, not just vertical', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + + expect(scrollEl.style.overflowY).toBe('auto'); + expect(scrollEl.style.overflowX).toBe('auto'); + }); + + it('hides the native scrollbar on the effective scroll element when building the custom UI', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + + expect(scrollEl.classList.contains('kbq-private-scrollbar_hide-native')).toBe(true); + }); + + it('does not hide the native scrollbar when KBQ_SCROLLBAR_CONFIG sets native: true', () => { + const fixture = createComponent(TestScrollbarHost, [ + { provide: KBQ_SCROLLBAR_CONFIG, useValue: { native: true } } + ]); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + + // `native: true` never creates an auto-viewport wrapper — the host itself is the + // effective scroll element. + expect(host.classList.contains('kbq-private-scrollbar_hide-native')).toBe(false); + }); + + it('reads a real, resolvable --kbq-private-scrollbar-size-thumb-min-size CSS token over the hardcoded fallback', () => { + const customTokenProvider: Provider = { + provide: KBQ_WINDOW, + useValue: { + getComputedStyle: () => ({ + getPropertyValue: (property: string) => + property === '--kbq-private-scrollbar-size-thumb-min-size' ? '60px' : '' + }) + } + }; + + const fixture = createComponent(TestScrollbarHost, [customTokenProvider]); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const thumb = host.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + + // A very small viewport ratio so the min-size clamp is what actually determines thumb + // size, not the ratio itself. + setMetrics(scrollEl, { scrollTop: 0, clientHeight: 10, scrollHeight: 1000 }); + setMetrics(track, { clientHeight: 106 }); + fixture.componentInstance.scrollbar().update(); + + expect(thumb.style.height).toBe('60px'); + }); + + it("publishes the host's own computed padding as CSS custom properties consumed by the track positioning rules", () => { + const fixture = createComponent(TestScrollbarPaddingHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + + fixture.componentInstance.scrollbar().update(); + + expect(host.style.getPropertyValue('--kbq-private-scrollbar-host-padding-top')).toBe('10px'); + expect(host.style.getPropertyValue('--kbq-private-scrollbar-host-padding-right')).toBe('20px'); + expect(host.style.getPropertyValue('--kbq-private-scrollbar-host-padding-bottom')).toBe('30px'); + expect(host.style.getPropertyValue('--kbq-private-scrollbar-host-padding-left')).toBe('40px'); + }); + + it('re-reads the host padding on every recompute(), picking up a runtime change with no resize of its own', () => { + const fixture = createComponent(TestScrollbarPaddingHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + + fixture.componentInstance.scrollbar().update(); + expect(host.style.getPropertyValue('--kbq-private-scrollbar-host-padding-top')).toBe('10px'); + + host.style.padding = '5px'; + fixture.componentInstance.scrollbar().update(); + + expect(host.style.getPropertyValue('--kbq-private-scrollbar-host-padding-top')).toBe('5px'); + }); + + it('does not publish host padding CSS custom properties under native: true — there is no track to position', () => { + const fixture = createComponent(TestScrollbarPaddingHost, [ + { provide: KBQ_SCROLLBAR_CONFIG, useValue: { native: true } } + ]); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + + fixture.componentInstance.scrollbar().update(); + + expect(host.style.getPropertyValue('--kbq-private-scrollbar-host-padding-top')).toBe(''); + }); + + it('does not publish host padding CSS custom properties on a coarse pointer — there is no track to position', () => { + const fixture = createComponent(TestScrollbarPaddingHost, [coarsePointerWindowProvider]); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + + fixture.componentInstance.scrollbar().update(); + + expect(host.style.getPropertyValue('--kbq-private-scrollbar-host-padding-top')).toBe(''); + }); + }); + + describe('drag interaction', () => { + const mockRects = (track: HTMLElement, thumb: HTMLElement, axis: 'vertical' | 'horizontal') => { + if (axis === 'vertical') { + jest.spyOn(track, 'getBoundingClientRect').mockReturnValue({ top: 0, height: 106 } as DOMRect); + jest.spyOn(thumb, 'getBoundingClientRect').mockReturnValue({ top: 3, height: 32 } as DOMRect); + } else { + jest.spyOn(track, 'getBoundingClientRect').mockReturnValue({ left: 0, width: 106 } as DOMRect); + jest.spyOn(thumb, 'getBoundingClientRect').mockReturnValue({ left: 3, width: 32 } as DOMRect); + } + }; + + it('dragging the thumb scrolls proportionally and toggles the dragging class', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const thumb = host.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + + setMetrics(scrollEl, { scrollTop: 0, clientHeight: 100, scrollHeight: 500 }); + mockRects(track, thumb, 'vertical'); + + // trackStart = 0 + padding(3) = 3; trackLength = 106 - 2*3 = 100; thumbSize = 32 (mocked + // rect); trackTravel = 100 - 32 = 68. Grabbing exactly the thumb's own top edge (clientY: + // 3) makes grabOffset 0, so pointer position maps 1:1 onto the [trackStart, trackStart + + // trackTravel] range. + thumb.dispatchEvent(new MouseEvent('pointerdown', { clientY: 3, bubbles: true, cancelable: true })); + + expect(host.classList.contains('kbq-private-scrollbar_dragging')).toBe(true); + + document.dispatchEvent(new MouseEvent('pointermove', { clientY: 37, buttons: 1, cancelable: true })); + // ratio = (37 - 3 - 0) / 68 = 0.5; scrollRange = 500 - 100 = 400. + expect(scrollEl.scrollTop).toBe(200); + + document.dispatchEvent(new MouseEvent('pointermove', { clientY: 71, buttons: 1, cancelable: true })); + expect(scrollEl.scrollTop).toBe(400); + + document.dispatchEvent(new MouseEvent('pointerup', { cancelable: true })); + + expect(host.classList.contains('kbq-private-scrollbar_dragging')).toBe(false); + }); + + it('clicking the track (not the thumb) jumps the thumb to the click point and continues as a drag', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const thumb = host.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + + setMetrics(scrollEl, { scrollTop: 0, clientHeight: 100, scrollHeight: 500 }); + mockRects(track, thumb, 'vertical'); + + // grabOffset is hardcoded to thumbSize/2 (16) for a track click. clientY: 53 -> + // pointerRelative = 53 - trackStart(3) = 50; ratio = (50 - 16) / trackTravel(68) = 0.5. + track.dispatchEvent(new MouseEvent('pointerdown', { clientY: 53, bubbles: true, cancelable: true })); + + expect(host.classList.contains('kbq-private-scrollbar_dragging')).toBe(true); + expect(scrollEl.scrollTop).toBe(200); + }); + + it('disableDrag blocks thumb drag but leaves track jump-to-click working as a one-shot, not a continued drag', () => { + const fixture = createComponent(TestScrollbarDisableDragHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const thumb = host.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + + setMetrics(scrollEl, { scrollTop: 0, clientHeight: 100, scrollHeight: 500 }); + mockRects(track, thumb, 'vertical'); + + thumb.dispatchEvent(new MouseEvent('pointerdown', { clientY: 3, bubbles: true, cancelable: true })); + expect(host.classList.contains('kbq-private-scrollbar_dragging')).toBe(false); + expect(scrollEl.scrollTop).toBe(0); + + track.dispatchEvent(new MouseEvent('pointerdown', { clientY: 53, bubbles: true, cancelable: true })); + // The jump itself still happens... + expect(scrollEl.scrollTop).toBe(200); + // ...but, unlike an ordinary track click, it must not arm a continued drag — otherwise + // holding the mouse down past the jump would keep following the pointer exactly like a + // real drag, despite disableDrag. + expect(host.classList.contains('kbq-private-scrollbar_dragging')).toBe(false); + + document.dispatchEvent(new MouseEvent('pointermove', { clientY: 90, buttons: 1, cancelable: true })); + expect(scrollEl.scrollTop).toBe(200); + }); + + it('disableClick blocks track jump-to-click but leaves thumb drag working', () => { + const fixture = createComponent(TestScrollbarDisableClickHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const thumb = host.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + + setMetrics(scrollEl, { scrollTop: 0, clientHeight: 100, scrollHeight: 500 }); + mockRects(track, thumb, 'vertical'); + + track.dispatchEvent(new MouseEvent('pointerdown', { clientY: 53, bubbles: true, cancelable: true })); + expect(host.classList.contains('kbq-private-scrollbar_dragging')).toBe(false); + expect(scrollEl.scrollTop).toBe(0); + + thumb.dispatchEvent(new MouseEvent('pointerdown', { clientY: 3, bubbles: true, cancelable: true })); + expect(host.classList.contains('kbq-private-scrollbar_dragging')).toBe(true); + + document.dispatchEvent(new MouseEvent('pointermove', { clientY: 37, buttons: 1, cancelable: true })); + expect(scrollEl.scrollTop).toBe(200); + }); + + it('ignores pointermove events from a different pointerId than the one that started the drag', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const thumb = host.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + + setMetrics(scrollEl, { scrollTop: 0, clientHeight: 100, scrollHeight: 500 }); + mockRects(track, thumb, 'vertical'); + + const downEvent = new MouseEvent('pointerdown', { clientY: 3, bubbles: true, cancelable: true }); + + Object.defineProperty(downEvent, 'pointerId', { value: 1, configurable: true }); + thumb.dispatchEvent(downEvent); + + const moveEvent = new MouseEvent('pointermove', { clientY: 71, buttons: 1, cancelable: true }); + + Object.defineProperty(moveEvent, 'pointerId', { value: 2, configurable: true }); + document.dispatchEvent(moveEvent); + + expect(scrollEl.scrollTop).toBe(0); + }); + + it('ends the drag if pointermove reports no buttons pressed, instead of continuing to scroll', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const thumb = host.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + + setMetrics(scrollEl, { scrollTop: 0, clientHeight: 100, scrollHeight: 500 }); + mockRects(track, thumb, 'vertical'); + + thumb.dispatchEvent(new MouseEvent('pointerdown', { clientY: 3, bubbles: true, cancelable: true })); + expect(host.classList.contains('kbq-private-scrollbar_dragging')).toBe(true); + + document.dispatchEvent(new MouseEvent('pointermove', { clientY: 71, buttons: 0, cancelable: true })); + + expect(host.classList.contains('kbq-private-scrollbar_dragging')).toBe(false); + expect(scrollEl.scrollTop).toBe(0); + }); + + it('does not attempt to scroll when the thumb fills the entire track travel range', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const thumb = host.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + + setMetrics(scrollEl, { scrollTop: 0, clientHeight: 100, scrollHeight: 500 }); + jest.spyOn(track, 'getBoundingClientRect').mockReturnValue({ top: 0, height: 106 } as DOMRect); + // thumbSize (100) equals trackLength (106 - 2*3), so trackTravel is 0. + jest.spyOn(thumb, 'getBoundingClientRect').mockReturnValue({ top: 3, height: 100 } as DOMRect); + + thumb.dispatchEvent(new MouseEvent('pointerdown', { clientY: 3, bubbles: true, cancelable: true })); + document.dispatchEvent(new MouseEvent('pointermove', { clientY: 71, buttons: 1, cancelable: true })); + + expect(scrollEl.scrollTop).toBe(0); + }); + + it('drags correctly on the horizontal axis in RTL, inverting the physical ratio onto the negative scrollLeft range', () => { + const mockDir = new MockDirectionality(); + + mockDir.value = 'rtl'; + + const fixture = createComponent(TestScrollbarDualOverflowHost, [ + { provide: Directionality, useValue: mockDir } + ]); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_horizontal') as HTMLElement; + const thumb = track.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + + setMetrics(scrollEl, { scrollLeft: 0, clientWidth: 100, scrollWidth: 500, clientHeight: 500 }); + mockRects(track, thumb, 'horizontal'); + + // Dragging all the way to the track's physical left (ratio 0) must land on the most + // negative scrollLeft (RTL's logical end) — not 0, which is what the LTR branch would + // give at the same ratio. + thumb.dispatchEvent(new MouseEvent('pointerdown', { clientX: 3, bubbles: true, cancelable: true })); + document.dispatchEvent(new MouseEvent('pointermove', { clientX: 3, buttons: 1, cancelable: true })); + + expect(scrollEl.scrollLeft).toBe(-400); + }); + + it('ignores pointerdown from a non-primary mouse button (e.g. right-click)', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const thumb = host.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + + setMetrics(scrollEl, { scrollTop: 0, clientHeight: 100, scrollHeight: 500 }); + mockRects(track, thumb, 'vertical'); + + thumb.dispatchEvent( + new MouseEvent('pointerdown', { clientY: 3, button: 2, bubbles: true, cancelable: true }) + ); + + expect(host.classList.contains('kbq-private-scrollbar_dragging')).toBe(false); + }); + + it('ignores a pointerdown that is explicitly not the primary pointer', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const thumb = host.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + + setMetrics(scrollEl, { scrollTop: 0, clientHeight: 100, scrollHeight: 500 }); + mockRects(track, thumb, 'vertical'); + + const event = new MouseEvent('pointerdown', { clientY: 3, bubbles: true, cancelable: true }); + + Object.defineProperty(event, 'isPrimary', { value: false, configurable: true }); + thumb.dispatchEvent(event); + + expect(host.classList.contains('kbq-private-scrollbar_dragging')).toBe(false); + }); + + it('ends the drag on pointercancel, the same as pointerup, instead of leaving it stuck', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const thumb = host.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + + setMetrics(scrollEl, { scrollTop: 0, clientHeight: 100, scrollHeight: 500 }); + mockRects(track, thumb, 'vertical'); + + thumb.dispatchEvent(new MouseEvent('pointerdown', { clientY: 3, bubbles: true, cancelable: true })); + expect(host.classList.contains('kbq-private-scrollbar_dragging')).toBe(true); + + document.dispatchEvent(new MouseEvent('pointercancel', { cancelable: true })); + expect(host.classList.contains('kbq-private-scrollbar_dragging')).toBe(false); + + // A pointermove after the cancel must be a no-op — the drag already ended. + document.dispatchEvent(new MouseEvent('pointermove', { clientY: 71, buttons: 1, cancelable: true })); + expect(scrollEl.scrollTop).toBe(0); + }); + + it("hides the scrollbar once a drag ends in 'hover' mode if the pointer already left the host mid-drag", () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const thumb = host.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + + setMetrics(scrollEl, { scrollTop: 0, clientHeight: 100, scrollHeight: 500 }); + mockRects(track, thumb, 'vertical'); + + host.dispatchEvent(new Event('pointerenter')); + thumb.dispatchEvent(new MouseEvent('pointerdown', { clientY: 3, bubbles: true, cancelable: true })); + expect(host.classList.contains('kbq-private-scrollbar_visible')).toBe(true); + + // Pointer leaves the host while still dragging — must not hide yet, a drag is active. + host.dispatchEvent(new Event('pointerleave')); + expect(host.classList.contains('kbq-private-scrollbar_visible')).toBe(true); + + document.dispatchEvent(new MouseEvent('pointerup', { cancelable: true })); + + expect(host.classList.contains('kbq-private-scrollbar_visible')).toBe(false); + }); + + it("schedules the auto-hide once a drag ends in 'scroll' mode, instead of leaving the scrollbar visible forever", () => { + const fixture = createComponent(TestScrollbarScrollVisibilityHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const thumb = host.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + + setMetrics(scrollEl, { scrollTop: 0, clientHeight: 100, scrollHeight: 500 }); + mockRects(track, thumb, 'vertical'); + + jest.useFakeTimers(); + + try { + // A click-without-movement: `beginInteraction()` shows the scrollbar directly + // (not via `showTemporarily()`), so without the fix nothing would ever schedule + // hiding it. + thumb.dispatchEvent(new MouseEvent('pointerdown', { clientY: 3, bubbles: true, cancelable: true })); + expect(host.classList.contains('kbq-private-scrollbar_visible')).toBe(true); + + document.dispatchEvent(new MouseEvent('pointerup', { cancelable: true })); + + jest.advanceTimersByTime(50); + + expect(host.classList.contains('kbq-private-scrollbar_visible')).toBe(false); + } finally { + jest.useRealTimers(); + } + }); + }); + + describe('floating', () => { + it('reserves no layout space by default — the track/thumb float over the content', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + + expect(scrollEl.style.paddingRight).toBe(''); + expect(scrollEl.style.paddingBottom).toBe(''); + }); + + it('kbqScrollbarFloating="false" reserves a gutter equal to the track dimension instead', () => { + const fixture = createComponent(TestScrollbarNonFloatingHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + + expect(scrollEl.style.paddingRight).toBe('var(--kbq-private-scrollbar-size-track-dimension)'); + expect(scrollEl.style.paddingBottom).toBe('var(--kbq-private-scrollbar-size-track-dimension)'); + }); + + it('reserves the gutter on the left in RTL instead of the right', () => { + const mockDir = new MockDirectionality(); + + mockDir.value = 'rtl'; + + const fixture = createComponent(TestScrollbarNonFloatingHost, [ + { provide: Directionality, useValue: mockDir } + ]); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + + expect(scrollEl.style.paddingLeft).toBe('var(--kbq-private-scrollbar-size-track-dimension)'); + expect(scrollEl.style.paddingRight).toBe(''); + }); + + it('reacts to kbqScrollbarFloating changing at runtime, not just its value at first render', () => { + const fixture = createComponent(TestScrollbarDynamicFloatingHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + + expect(scrollEl.style.paddingRight).toBe(''); + + fixture.componentInstance.floating = false; + fixture.detectChanges(); + TestBed.tick(); + + expect(scrollEl.style.paddingRight).toBe('var(--kbq-private-scrollbar-size-track-dimension)'); + + fixture.componentInstance.floating = true; + fixture.detectChanges(); + TestBed.tick(); + + expect(scrollEl.style.paddingRight).toBe(''); + }); + + it('moves the gutter to the other side when direction flips at runtime, instead of leaving both', () => { + const mockDir = new MockDirectionality(); + const fixture = createComponent(TestScrollbarNonFloatingHost, [ + { provide: Directionality, useValue: mockDir } + ]); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + + expect(scrollEl.style.paddingRight).toBe('var(--kbq-private-scrollbar-size-track-dimension)'); + expect(scrollEl.style.paddingLeft).toBe(''); + + mockDir.value = 'rtl'; + mockDir.change.next('rtl'); + fixture.detectChanges(); + TestBed.tick(); + + expect(scrollEl.style.paddingLeft).toBe('var(--kbq-private-scrollbar-size-track-dimension)'); + expect(scrollEl.style.paddingRight).toBe(''); + }); + }); + + describe('scrollTo / scrollToElement', () => { + it('scrollTo sets scrollTop on the effective scroll element', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + + setMetrics(scrollEl, { scrollTop: 0 }); + fixture.componentInstance.scrollbar().scrollTo({ top: 42 }); + + expect(scrollEl.scrollTop).toBe(42); + }); + + it('scrollToTop / scrollToBottom scroll the vertical axis', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + + setMetrics(scrollEl, { scrollTop: 250, scrollHeight: 500 }); + + fixture.componentInstance.scrollbar().scrollToTop(); + expect(scrollEl.scrollTop).toBe(0); + + fixture.componentInstance.scrollbar().scrollToBottom(); + expect(scrollEl.scrollTop).toBe(500); + }); + + it('scrollToElement computes independent top/left offsets from the target bounding rect', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const target = scrollEl.querySelector('div') as HTMLElement; + + setMetrics(scrollEl, { scrollTop: 0, scrollLeft: 0 }); + jest.spyOn(scrollEl, 'getBoundingClientRect').mockReturnValue({ top: 0, left: 0 } as DOMRect); + jest.spyOn(target, 'getBoundingClientRect').mockReturnValue({ top: 150, left: 200 } as DOMRect); + + // Only a `top` offset is given — `left` must stay unaffected, proving the two axes are + // independent rather than sharing a single offset value. + fixture.componentInstance.scrollbar().scrollToElement(target, { top: 10 }); + + expect(scrollEl.scrollTop).toBe(140); + expect(scrollEl.scrollLeft).toBe(200); + }); + + it('scrollToElement applies an independent left offset', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const target = scrollEl.querySelector('div') as HTMLElement; + + setMetrics(scrollEl, { scrollTop: 0, scrollLeft: 0 }); + jest.spyOn(scrollEl, 'getBoundingClientRect').mockReturnValue({ top: 0, left: 0 } as DOMRect); + jest.spyOn(target, 'getBoundingClientRect').mockReturnValue({ top: 150, left: 200 } as DOMRect); + + fixture.componentInstance.scrollbar().scrollToElement(target, { left: 20 }); + + expect(scrollEl.scrollTop).toBe(150); + expect(scrollEl.scrollLeft).toBe(180); + }); + + it('scrollToElement resolves a string target as a selector against the scroll element', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const target = scrollEl.querySelector('div') as HTMLElement; + + target.setAttribute('data-target', ''); + setMetrics(scrollEl, { scrollTop: 0, scrollLeft: 0 }); + jest.spyOn(scrollEl, 'getBoundingClientRect').mockReturnValue({ top: 0, left: 0 } as DOMRect); + jest.spyOn(target, 'getBoundingClientRect').mockReturnValue({ top: 150, left: 200 } as DOMRect); + + fixture.componentInstance.scrollbar().scrollToElement('[data-target]'); + + expect(scrollEl.scrollTop).toBe(150); + expect(scrollEl.scrollLeft).toBe(200); + }); + + it('scrollToElement does nothing when the string selector matches no element', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + + setMetrics(scrollEl, { scrollTop: 55, scrollLeft: 33 }); + + fixture.componentInstance.scrollbar().scrollToElement('.does-not-exist'); + + expect(scrollEl.scrollTop).toBe(55); + expect(scrollEl.scrollLeft).toBe(33); + }); + + it('scrollStart / scrollEnd scroll the horizontal axis', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + + setMetrics(scrollEl, { scrollLeft: 40, clientWidth: 100, scrollWidth: 500 }); + + fixture.componentInstance.scrollbar().scrollEnd(); + expect(scrollEl.scrollLeft).toBe(400); + + fixture.componentInstance.scrollbar().scrollStart(); + expect(scrollEl.scrollLeft).toBe(0); + }); + + it('routes scrollTo through CdkVirtualScrollViewport.scrollToOffset when delegated', () => { + const fixture = createComponent(TestScrollbarVirtualHost); + const scrollToOffsetSpy = jest + .spyOn(CdkVirtualScrollViewport.prototype, 'scrollToOffset') + .mockImplementation(() => {}); + + fixture.componentInstance.scrollbar().scrollTo({ top: 123 }); + + expect(scrollToOffsetSpy).toHaveBeenCalledWith(123, 'auto'); + }); + + it('scrollStart/scrollEnd do NOT call scrollToOffset on a (default) vertical viewport — `left` is not this axis', () => { + const fixture = createComponent(TestScrollbarVirtualHost); + const scrollToOffsetSpy = jest + .spyOn(CdkVirtualScrollViewport.prototype, 'scrollToOffset') + .mockImplementation(() => {}); + + fixture.componentInstance.scrollbar().scrollStart(); + fixture.componentInstance.scrollbar().scrollEnd(); + + expect(scrollToOffsetSpy).not.toHaveBeenCalled(); + }); + + it('scrollToTop/scrollToBottom do NOT call scrollToOffset on an orientation="horizontal" viewport — `top` is not this axis', () => { + const fixture = createComponent(TestScrollbarVirtualHorizontalHost); + const scrollToOffsetSpy = jest + .spyOn(CdkVirtualScrollViewport.prototype, 'scrollToOffset') + .mockImplementation(() => {}); + + fixture.componentInstance.scrollbar().scrollToTop(); + fixture.componentInstance.scrollbar().scrollToBottom(); + + expect(scrollToOffsetSpy).not.toHaveBeenCalled(); + }); + + it('routes scrollTo({ left }) through scrollToOffset when the delegated viewport is orientation="horizontal"', () => { + const fixture = createComponent(TestScrollbarVirtualHorizontalHost); + const scrollToOffsetSpy = jest + .spyOn(CdkVirtualScrollViewport.prototype, 'scrollToOffset') + .mockImplementation(() => {}); + + fixture.componentInstance.scrollbar().scrollTo({ left: 77 }); + + expect(scrollToOffsetSpy).toHaveBeenCalledWith(77, 'auto'); + }); + + it('scrollTo({ top }) is ignored (not just defaulted to 0) against an orientation="horizontal" viewport', () => { + const fixture = createComponent(TestScrollbarVirtualHorizontalHost); + const scrollToOffsetSpy = jest + .spyOn(CdkVirtualScrollViewport.prototype, 'scrollToOffset') + .mockImplementation(() => {}); + + fixture.componentInstance.scrollbar().scrollTo({ top: 999 }); + + expect(scrollToOffsetSpy).not.toHaveBeenCalled(); + }); + }); + + describe('RTL', () => { + it('toggles the _rtl class when Directionality changes', () => { + const mockDir = new MockDirectionality(); + const fixture = createComponent(TestScrollbarHost, [{ provide: Directionality, useValue: mockDir }]); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + + expect(host.classList.contains('kbq-private-scrollbar_rtl')).toBe(false); + + mockDir.value = 'rtl'; + mockDir.change.next('rtl'); + fixture.detectChanges(); + + expect(host.classList.contains('kbq-private-scrollbar_rtl')).toBe(true); + }); + + it('scrollEnd scrolls to a negative scrollLeft, and scrollStart back to 0, per the CSSOM View spec', () => { + const mockDir = new MockDirectionality(); + + mockDir.value = 'rtl'; + + const fixture = createComponent(TestScrollbarHost, [{ provide: Directionality, useValue: mockDir }]); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + + setMetrics(scrollEl, { clientWidth: 100, scrollWidth: 500 }); + + fixture.componentInstance.scrollbar().scrollEnd(); + expect(scrollEl.scrollLeft).toBe(-400); + + fixture.componentInstance.scrollbar().scrollStart(); + expect(scrollEl.scrollLeft).toBe(0); + }); + + it("positions the horizontal thumb from the physical left, normalizing RTL's negative scrollLeft — at scrollLeft 0 (RTL start) the thumb sits flush with the track's physical right edge", () => { + const mockDir = new MockDirectionality(); + + mockDir.value = 'rtl'; + + const fixture = createComponent(TestScrollbarHost, [{ provide: Directionality, useValue: mockDir }]); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_horizontal') as HTMLElement; + const thumb = track.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + + setMetrics(scrollEl, { + scrollLeft: 0, + clientWidth: 100, + scrollWidth: 500, + clientHeight: 100, + scrollHeight: 100 + }); + setMetrics(track, { clientWidth: 100 }); + fixture.componentInstance.scrollbar().update(); + + expect(thumb.style.width).toBe('32px'); + expect(thumb.style.left).toBe('65px'); + }); + }); + + describe('visibility', () => { + // `TestBed.tick()` is needed on top of `detectChanges()` because the mode-change reaction + // runs in an `effect()`, which doesn't flush synchronously as part of `detectChanges()`. + const setVisibility = ( + fixture: ComponentFixture, + value: KbqScrollbarVisibility + ) => { + fixture.componentInstance.visibility = value; + fixture.detectChanges(); + TestBed.tick(); + }; + + it("reacts to kbqScrollbarVisibility switching to 'always' at runtime, without needing a hover", () => { + const fixture = createComponent(TestScrollbarVisibilityHost); + const visibleSpy = jest.fn(); + + fixture.componentInstance.scrollbar().visibilityChange.subscribe(visibleSpy); + + setVisibility(fixture, 'always'); + + expect(visibleSpy).toHaveBeenCalledWith(true); + }); + + it("hides again when switching away from 'always'", () => { + const fixture = createComponent(TestScrollbarVisibilityHost); + + setVisibility(fixture, 'always'); + + const visibleSpy = jest.fn(); + + fixture.componentInstance.scrollbar().visibilityChange.subscribe(visibleSpy); + + setVisibility(fixture, 'scroll'); + + expect(visibleSpy).toHaveBeenCalledWith(false); + }); + + it("switching to 'hover' while the pointer is already over the host shows it immediately, instead of waiting for the next pointerenter", () => { + const fixture = createComponent(TestScrollbarVisibilityHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + + setVisibility(fixture, 'scroll'); + host.dispatchEvent(new Event('pointerenter')); + + const visibleSpy = jest.fn(); + + fixture.componentInstance.scrollbar().visibilityChange.subscribe(visibleSpy); + + setVisibility(fixture, 'hover'); + + expect(visibleSpy).toHaveBeenCalledWith(true); + }); + + it("stays invisible in 'hidden' mode even when hovered", () => { + const fixture = createComponent(TestScrollbarVisibilityHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + + setVisibility(fixture, 'hidden'); + + const visibleSpy = jest.fn(); + + fixture.componentInstance.scrollbar().visibilityChange.subscribe(visibleSpy); + host.dispatchEvent(new Event('pointerenter')); + + expect(visibleSpy).not.toHaveBeenCalledWith(true); + expect(host.classList.contains('kbq-private-scrollbar_visible')).toBe(false); + }); + + it("'hover' mode (the default) shows on a real pointerenter and hides again on pointerleave", () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + + expect(host.classList.contains('kbq-private-scrollbar_visible')).toBe(false); + + host.dispatchEvent(new Event('pointerenter')); + expect(host.classList.contains('kbq-private-scrollbar_visible')).toBe(true); + + host.dispatchEvent(new Event('pointerleave')); + expect(host.classList.contains('kbq-private-scrollbar_visible')).toBe(false); + }); + + it("'scroll' mode temporarily reveals on a real scroll event and auto-hides after kbqScrollbarAutoHideDelay", () => { + const fixture = createComponent(TestScrollbarScrollVisibilityHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + + jest.useFakeTimers(); + + try { + expect(host.classList.contains('kbq-private-scrollbar_visible')).toBe(false); + + scrollEl.dispatchEvent(new Event('scroll')); + expect(host.classList.contains('kbq-private-scrollbar_visible')).toBe(true); + + jest.advanceTimersByTime(49); + expect(host.classList.contains('kbq-private-scrollbar_visible')).toBe(true); + + jest.advanceTimersByTime(1); + expect(host.classList.contains('kbq-private-scrollbar_visible')).toBe(false); + } finally { + jest.useRealTimers(); + } + }); + + it("'scroll' mode restarts the auto-hide timer on every subsequent scroll instead of hiding on the first timer", () => { + const fixture = createComponent(TestScrollbarScrollVisibilityHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + + jest.useFakeTimers(); + + try { + scrollEl.dispatchEvent(new Event('scroll')); + jest.advanceTimersByTime(30); + + scrollEl.dispatchEvent(new Event('scroll')); + jest.advanceTimersByTime(30); + + // Only 30ms since the second scroll, not yet the full 50ms delay. + expect(host.classList.contains('kbq-private-scrollbar_visible')).toBe(true); + + jest.advanceTimersByTime(20); + expect(host.classList.contains('kbq-private-scrollbar_visible')).toBe(false); + } finally { + jest.useRealTimers(); + } + }); + + it("switching away from 'scroll' clears its pending auto-hide timer, instead of that timer hiding the scrollbar later under the new mode", () => { + const fixture = createComponent(TestScrollbarVisibilityHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + + jest.useFakeTimers(); + + try { + setVisibility(fixture, 'scroll'); + scrollEl.dispatchEvent(new Event('scroll')); + expect(host.classList.contains('kbq-private-scrollbar_visible')).toBe(true); + + setVisibility(fixture, 'always'); + expect(host.classList.contains('kbq-private-scrollbar_visible')).toBe(true); + + // Well past the default autoHideDelay (100ms) — the stale 'scroll'-mode timer must + // not fire and hide a scrollbar that's supposed to stay permanently visible now. + jest.advanceTimersByTime(200); + + expect(host.classList.contains('kbq-private-scrollbar_visible')).toBe(true); + } finally { + jest.useRealTimers(); + } + }); + }); + + describe('kbqScrollbarVirtualViewport delegation', () => { + it('measures the delegated virtual viewport instead of the host', () => { + const fixture = createComponent(TestScrollbarVirtualHost); + const viewport = fixture.debugElement.nativeElement.querySelector( + '[data-testid="viewport"]' + ) as HTMLElement; + + expect(fixture.componentInstance.scrollbar().getScrollElement()).toBe(viewport); + }); + }); + + describe('CdkScrollable / ScrollDispatcher integration', () => { + const registeredElements = (): HTMLElement[] => + Array.from(TestBed.inject(ScrollDispatcher).scrollContainers.keys()).map( + (scrollable) => scrollable.getElementRef().nativeElement + ); + + it('registers the auto-viewport (the real scroll element) with ScrollDispatcher', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + + // `KbqScrollbar`'s own `hostDirectives: [CdkScrollable]` also still registers the host + // itself — harmlessly: the host has no `overflow` of its own in this case (the + // auto-viewport does), so its `CdkScrollable` never actually fires, it just stays + // registered alongside the one that matters. + expect(registeredElements()).toContain(scrollEl); + }); + + it('sees a delegated virtual viewport via its own built-in CdkScrollable', () => { + const fixture = createComponent(TestScrollbarVirtualHost); + const viewport = fixture.debugElement.nativeElement.querySelector( + '[data-testid="viewport"]' + ) as HTMLElement; + + expect(registeredElements()).toContain(viewport); + }); + + it('registers the host itself when it is the real scroll element (native: true)', () => { + const fixture = createComponent(TestScrollbarHost, [ + { provide: KBQ_SCROLLBAR_CONFIG, useValue: { native: true } } + ]); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + + expect(registeredElements()).toContain(host); + }); + + it('de-registers the auto-viewport from ScrollDispatcher on destroy', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + + expect(registeredElements()).toContain(scrollEl); + + fixture.destroy(); + + expect(registeredElements()).not.toContain(scrollEl); + }); + }); + + describe('composed via hostDirectives', () => { + it('initializes when used as hostDirectives: [KbqScrollbar] rather than the [kbqScrollbar] attribute', () => { + const fixture = createComponent(TestScrollbarComposedHost); + const host = fixture.debugElement.nativeElement as HTMLElement; + const scrollbar = fixture.debugElement.injector.get(KbqScrollbar); + + expect(host.classList.contains('kbq-private-scrollbar')).toBe(true); + expect(host.querySelector('.kbq-private-scrollbar-track_vertical')).toBeTruthy(); + + const scrollEl = getAutoViewport(host); + + setMetrics(scrollEl, { scrollTop: 0 }); + scrollbar.scrollTo({ top: 10 }); + + expect(scrollEl.scrollTop).toBe(10); + }); + }); + + describe('reach edges', () => { + it('reachTop / reachBottom fire for the vertical axis', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const topSpy = jest.fn(); + const bottomSpy = jest.fn(); + + fixture.componentInstance.scrollbar().reachTop.subscribe(topSpy); + fixture.componentInstance.scrollbar().reachBottom.subscribe(bottomSpy); + + setMetrics(scrollEl, { scrollTop: 0, clientHeight: 100, scrollHeight: 500 }); + setMetrics(track, { clientHeight: 100 }); + fixture.componentInstance.scrollbar().update(); + + expect(topSpy).toHaveBeenCalled(); + expect(bottomSpy).not.toHaveBeenCalled(); + + setMetrics(scrollEl, { scrollTop: 400 }); + fixture.componentInstance.scrollbar().update(); + + expect(bottomSpy).toHaveBeenCalled(); + }); + + it('fires reachTop and reachBottom together when content shrinks to no longer overflow, mid-scroll', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const topSpy = jest.fn(); + const bottomSpy = jest.fn(); + + fixture.componentInstance.scrollbar().reachTop.subscribe(topSpy); + fixture.componentInstance.scrollbar().reachBottom.subscribe(bottomSpy); + + // Mid-scroll — neither edge reached yet. + setMetrics(scrollEl, { scrollTop: 200, clientHeight: 100, scrollHeight: 500 }); + setMetrics(track, { clientHeight: 100 }); + fixture.componentInstance.scrollbar().update(); + + expect(topSpy).not.toHaveBeenCalled(); + expect(bottomSpy).not.toHaveBeenCalled(); + + // Content shrinks to fit the viewport — nothing left to scroll, trivially "at both + // edges" at once, even though scrollTop itself never went anywhere near 0 or the max. + setMetrics(scrollEl, { scrollHeight: 100 }); + fixture.componentInstance.scrollbar().update(); + + expect(topSpy).toHaveBeenCalled(); + expect(bottomSpy).toHaveBeenCalled(); + }); + + it('does not fire reachTop/reachBottom on construction, before this axis has ever overflowed', () => { + const fixture = createComponent(TestScrollbarHost); + const topSpy = jest.fn(); + const bottomSpy = jest.fn(); + + fixture.componentInstance.scrollbar().reachTop.subscribe(topSpy); + fixture.componentInstance.scrollbar().reachBottom.subscribe(bottomSpy); + + fixture.componentInstance.scrollbar().update(); + + expect(topSpy).not.toHaveBeenCalled(); + expect(bottomSpy).not.toHaveBeenCalled(); + }); + + it('reachStart / reachEnd fire for the horizontal axis in LTR, matching the physical left/right edges', () => { + const fixture = createComponent(TestScrollbarDualOverflowHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const vTrack = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const hTrack = host.querySelector('.kbq-private-scrollbar-track_horizontal') as HTMLElement; + const startSpy = jest.fn(); + const endSpy = jest.fn(); + + fixture.componentInstance.scrollbar().reachStart.subscribe(startSpy); + fixture.componentInstance.scrollbar().reachEnd.subscribe(endSpy); + + setMetrics(scrollEl, { + scrollLeft: 0, + clientWidth: 100, + scrollWidth: 500, + clientHeight: 100, + scrollHeight: 500 + }); + setMetrics(vTrack, { clientHeight: 100 }); + setMetrics(hTrack, { clientWidth: 100 }); + fixture.componentInstance.scrollbar().update(); + + expect(startSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + + setMetrics(scrollEl, { scrollLeft: 400 }); + fixture.componentInstance.scrollbar().update(); + + expect(endSpy).toHaveBeenCalled(); + }); + + it('reachStart / reachEnd flip which physical edge they fire on in RTL, matching scrollStart()/scrollEnd()', () => { + const mockDir = new MockDirectionality(); + + mockDir.value = 'rtl'; + + const fixture = createComponent(TestScrollbarDualOverflowHost, [ + { provide: Directionality, useValue: mockDir } + ]); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const vTrack = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const hTrack = host.querySelector('.kbq-private-scrollbar-track_horizontal') as HTMLElement; + const startSpy = jest.fn(); + const endSpy = jest.fn(); + + fixture.componentInstance.scrollbar().reachStart.subscribe(startSpy); + fixture.componentInstance.scrollbar().reachEnd.subscribe(endSpy); + + // scrollLeft: 0 is RTL's logical start — physically the right edge. + setMetrics(scrollEl, { + scrollLeft: 0, + clientWidth: 100, + scrollWidth: 500, + clientHeight: 100, + scrollHeight: 500 + }); + setMetrics(vTrack, { clientHeight: 100 }); + setMetrics(hTrack, { clientWidth: 100 }); + fixture.componentInstance.scrollbar().update(); + + expect(startSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + + // scrollLeft: -400 is RTL's logical end — physically the left edge. + setMetrics(scrollEl, { scrollLeft: -400 }); + fixture.componentInstance.scrollbar().update(); + + expect(endSpy).toHaveBeenCalled(); + }); + + it('does not re-emit reachTop on subsequent updates while still at the top', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const topSpy = jest.fn(); + + fixture.componentInstance.scrollbar().reachTop.subscribe(topSpy); + + setMetrics(scrollEl, { scrollTop: 0, clientHeight: 100, scrollHeight: 500 }); + setMetrics(track, { clientHeight: 100 }); + fixture.componentInstance.scrollbar().update(); + fixture.componentInstance.scrollbar().update(); + fixture.componentInstance.scrollbar().update(); + + expect(topSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('outputs', () => { + it('emits scrollChange with the current scroll position on a real scroll event', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const spy = jest.fn(); + + fixture.componentInstance.scrollbar().scrollChange.subscribe(spy); + + setMetrics(scrollEl, { scrollTop: 77, scrollLeft: 12 }); + scrollEl.dispatchEvent(new Event('scroll')); + + expect(spy).toHaveBeenCalledWith({ top: 77, left: 12 }); + }); + + it('onScroll (KbqOverflowShadowSource) emits on the same real scroll event as scrollChange', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const spy = jest.fn(); + + fixture.componentInstance.scrollbar().onScroll.subscribe(spy); + expect(fixture.componentInstance.scrollbar().getScrollElement()).toBe(scrollEl); + + scrollEl.dispatchEvent(new Event('scroll')); + + expect(spy).toHaveBeenCalled(); + }); + + it('emits kbqScrollbarInitialized exactly once after initial setup', () => { + const fixture = createComponent(TestScrollbarInitializedHost); + + expect(fixture.componentInstance.initializedCount).toBe(1); + }); + + it('emits kbqScrollbarInitialized even under native: true, where there is no track/thumb of its own to render', () => { + const fixture = createComponent(TestScrollbarInitializedHost, [ + { provide: KBQ_SCROLLBAR_CONFIG, useValue: { native: true } } + ]); + + expect(fixture.componentInstance.initializedCount).toBe(1); + }); + }); + + describe('ngOnDestroy', () => { + it('removes the track/thumb DOM on destroy', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + + expect(host.querySelector('.kbq-private-scrollbar-track')).toBeTruthy(); + + fixture.destroy(); + + expect(host.querySelector('.kbq-private-scrollbar-track')).toBeNull(); + }); + + it('clears a pending auto-hide timeout on destroy', () => { + const fixture = createComponent(TestScrollbarScrollVisibilityHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const clearTimeoutSpy = jest.spyOn(window, 'clearTimeout'); + + scrollEl.dispatchEvent(new Event('scroll')); + fixture.destroy(); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + }); + }); + + describe('isTopReached / isBottomReached / isStartReached / isEndReached', () => { + it('isTopReached / isBottomReached track the current vertical position, not just the moment it was reached', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const scrollbar = fixture.componentInstance.scrollbar(); + + setMetrics(scrollEl, { scrollTop: 0, clientHeight: 100, scrollHeight: 500 }); + setMetrics(track, { clientHeight: 100 }); + scrollbar.update(); + + expect(scrollbar.isTopReached()).toBe(true); + expect(scrollbar.isBottomReached()).toBe(false); + + setMetrics(scrollEl, { scrollTop: 200 }); + scrollbar.update(); + + expect(scrollbar.isTopReached()).toBe(false); + expect(scrollbar.isBottomReached()).toBe(false); + + setMetrics(scrollEl, { scrollTop: 400 }); + scrollbar.update(); + + expect(scrollbar.isBottomReached()).toBe(true); + }); + + it('isStartReached / isEndReached track the current horizontal position in LTR', () => { + const fixture = createComponent(TestScrollbarDualOverflowHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const vTrack = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const hTrack = host.querySelector('.kbq-private-scrollbar-track_horizontal') as HTMLElement; + const scrollbar = fixture.componentInstance.scrollbar(); + + setMetrics(scrollEl, { + scrollLeft: 0, + clientWidth: 100, + scrollWidth: 500, + clientHeight: 100, + scrollHeight: 500 + }); + setMetrics(vTrack, { clientHeight: 100 }); + setMetrics(hTrack, { clientWidth: 100 }); + scrollbar.update(); + + expect(scrollbar.isStartReached()).toBe(true); + expect(scrollbar.isEndReached()).toBe(false); + + setMetrics(scrollEl, { scrollLeft: 400 }); + scrollbar.update(); + + expect(scrollbar.isStartReached()).toBe(false); + expect(scrollbar.isEndReached()).toBe(true); + }); + + it('isStartReached / isEndReached flip which physical edge they track in RTL', () => { + const mockDir = new MockDirectionality(); + + mockDir.value = 'rtl'; + + const fixture = createComponent(TestScrollbarDualOverflowHost, [ + { provide: Directionality, useValue: mockDir } + ]); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const vTrack = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const hTrack = host.querySelector('.kbq-private-scrollbar-track_horizontal') as HTMLElement; + const scrollbar = fixture.componentInstance.scrollbar(); + + // scrollLeft: 0 is RTL's logical start — physically the right edge. + setMetrics(scrollEl, { + scrollLeft: 0, + clientWidth: 100, + scrollWidth: 500, + clientHeight: 100, + scrollHeight: 500 + }); + setMetrics(vTrack, { clientHeight: 100 }); + setMetrics(hTrack, { clientWidth: 100 }); + scrollbar.update(); + + expect(scrollbar.isStartReached()).toBe(true); + expect(scrollbar.isEndReached()).toBe(false); + + // scrollLeft: -400 is RTL's logical end — physically the left edge. + setMetrics(scrollEl, { scrollLeft: -400 }); + scrollbar.update(); + + expect(scrollbar.isStartReached()).toBe(false); + expect(scrollbar.isEndReached()).toBe(true); + }); + + it('reads as both at-start and at-end when there is no overflow to scroll at all', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const scrollbar = fixture.componentInstance.scrollbar(); + + setMetrics(scrollEl, { clientHeight: 500, scrollHeight: 500 }); + scrollbar.update(); + + expect(scrollbar.isTopReached()).toBe(true); + expect(scrollbar.isBottomReached()).toBe(true); + }); + }); + + describe('update()', () => { + it('recomputes and emits kbqScrollbarUpdate', () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const thumb = host.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + const updateSpy = jest.fn(); + + fixture.componentInstance.scrollbar().updated.subscribe(updateSpy); + + setMetrics(scrollEl, { scrollTop: 250, clientHeight: 100, scrollHeight: 500 }); + setMetrics(track, { clientHeight: 100 }); + fixture.componentInstance.scrollbar().update(); + + expect(updateSpy).toHaveBeenCalled(); + expect(thumb.style.top).not.toBe('0px'); + }); + + it( + 'does NOT pick up a content-only growth automatically — this directive only recomputes on ' + + "scroll, on a resize of the scroll element's own box, or on an explicit update() call. " + + 'A `scrollHeight` change with no box-size change (e.g. appending rows to a list) fires ' + + 'neither, so the thumb is left stale until something calls update() for it', + () => { + const fixture = createComponent(TestScrollbarHost); + const host = fixture.debugElement.nativeElement.querySelector('[data-testid="host"]') as HTMLElement; + const scrollEl = getAutoViewport(host); + const track = host.querySelector('.kbq-private-scrollbar-track_vertical') as HTMLElement; + const thumb = host.querySelector('.kbq-private-scrollbar-thumb') as HTMLElement; + + setMetrics(scrollEl, { scrollTop: 0, clientHeight: 200, scrollHeight: 500 }); + setMetrics(track, { clientHeight: 206 }); + fixture.componentInstance.scrollbar().update(); + + // ratio = ceil(200/500*100)/100 = 0.4; thumbSize = max(0.4*200, 32) = 80. + expect(thumb.style.height).toBe('80px'); + + // Simulate content being appended: scrollHeight grows, clientHeight (the scroll + // element's own box size) does not — this is exactly the shape of change a real + // ResizeObserver never reports, since it only fires on box-size changes, not on + // scrollHeight/scrollWidth. No scroll event happens either. + setMetrics(scrollEl, { scrollHeight: 1000 }); + + // Nothing re-measures on its own: no scroll fired, no resize fired, and no manual + // update() was called — by design, there's no MutationObserver watching content + // mutations here, so the thumb stays stale until update() is called explicitly. + expect(thumb.style.height).toBe('80px'); + + fixture.componentInstance.scrollbar().update(); + + // ratio = ceil(200/1000*100)/100 = 0.2; thumbSize = max(0.2*200, 32) = 40 — only + // reached once something explicitly calls update(). + expect(thumb.style.height).toBe('40px'); + } + ); + }); +}); diff --git a/packages/components/scrollbar/private/scrollbar.ts b/packages/components/scrollbar/private/scrollbar.ts new file mode 100644 index 0000000000..fa08124a10 --- /dev/null +++ b/packages/components/scrollbar/private/scrollbar.ts @@ -0,0 +1,1197 @@ +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, CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; +import { DOCUMENT } from '@angular/common'; +import { + afterNextRender, + ApplicationRef, + booleanAttribute, + ChangeDetectionStrategy, + Component, + ComponentRef, + computed, + contentChild, + createComponent, + DestroyRef, + Directive, + effect, + ElementRef, + forwardRef, + inject, + InjectionToken, + Injector, + input, + NgZone, + numberAttribute, + OnDestroy, + output, + OutputEmitterRef, + Provider, + Renderer2, + RendererStyleFlags2, + signal, + ViewEncapsulation, + WritableSignal +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + KBQ_OVERFLOW_SHADOW_SOURCE, + KBQ_WINDOW, + kbqInjectNativeElement, + KbqOverflowShadowSource +} from '@koobiq/components/core'; +import { fromEvent, Observable, Subject } from 'rxjs'; + +/** When the custom track/thumb are shown. `'hidden'` never shows them, but scrolling stays fully functional. */ +export type KbqScrollbarVisibility = 'hover' | 'always' | 'scroll' | 'hidden'; + +/** Payload of `kbqScrollbarScrollChange`. */ +export type KbqScrollbarScrollChangeEvent = { + top: number; + left: number; +}; + +/** Options accepted by `KbqScrollbar.scrollTo`. */ +export type KbqScrollbarScrollToOptions = Partial<{ + top: number; + left: number; + behavior: ScrollBehavior; +}>; + +/** Options accepted by `KbqScrollbar.scrollToElement`. */ +export type KbqScrollbarScrollToElementOptions = Partial<{ + /** Extra gap to leave above the target, in px — e.g. so it doesn't end up under a sticky header. */ + top: number; + /** Extra gap to leave to the left of the target, in px. */ + left: number; + behavior: ScrollBehavior; +}>; + +/** Shape of the values that can be supplied via `KBQ_SCROLLBAR_CONFIG`. */ +export type KbqScrollbarConfig = Partial<{ + /** Default for the `kbqScrollbarVisibility` input — see `KbqScrollbarVisibility`. */ + visibility: KbqScrollbarVisibility; + /** Default for the `kbqScrollbarAutoHideDelay` input. */ + autoHideDelay: number; + /** Default for the `kbqScrollbarFloating` input. */ + floating: boolean; + /** + * Falls back to the native scrollbar entirely, bypassing the custom track/thumb — same as on a + * coarse pointer. App/module-level only, not a per-instance input: it decides whether the + * custom DOM gets built at all, which happens once at directive construction, so a value that + * could change per instance at runtime wouldn't have anything to react to anyway. + */ + native: boolean; + /** Default for the `kbqScrollbarDisableDrag` input. */ + disableDrag: boolean; + /** Default for the `kbqScrollbarDisableClick` input. */ + disableClick: boolean; +}>; + +/** + * Default values for `kbqScrollbar` inputs. `autoHideDelay` mirrors + * `KBQ_SCROLLBAR_OPTIONS_DEFAULT_CONFIG` (`../scrollbar.types.ts`) to keep UX parity with the + * existing `kbq-scrollbar`. + * + * @docs-private + */ +export const KBQ_SCROLLBAR_DEFAULT_CONFIG: Required = { + visibility: 'hover', + autoHideDelay: 100, + floating: true, + native: false, + disableDrag: false, + disableClick: false +}; + +/** + * Injection token for app/module-level `kbqScrollbar` defaults. Resolves to + * `KBQ_SCROLLBAR_DEFAULT_CONFIG` out of the box, so `KbqScrollbar` works without requiring any + * provider to be registered. + * + * @docs-private + */ +export const KBQ_SCROLLBAR_CONFIG = new InjectionToken('KBQ_SCROLLBAR_CONFIG', { + factory: () => KBQ_SCROLLBAR_DEFAULT_CONFIG +}); + +/** Registers app/module-level defaults for `kbqScrollbar` — provide it wherever `KBQ_SCROLLBAR_CONFIG` should resolve to `config` instead of `KBQ_SCROLLBAR_DEFAULT_CONFIG`. */ +export const kbqScrollbarConfigProvider = (config: KbqScrollbarConfig): Provider => ({ + provide: KBQ_SCROLLBAR_CONFIG, + useValue: config +}); + +/** The two axes a `kbqScrollbar` always builds a track for — see `measureAxis`/`paintAxis`. */ +type Axis = 'vertical' | 'horizontal'; + +const AXES: readonly Axis[] = ['vertical', 'horizontal']; + +const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)); + +type DragContext = { + axis: Axis; + pointerId: number; + trackStart: number; + trackTravel: number; + grabOffset: number; + scrollRange: number; +}; + +/** What `measureAxis()` hands `paintAxis()` for one axis — see both. */ +type AxisMeasurement = { + scrollOffset: number; + scrollRange: number; +}; + +/** + * Component used to load the `.kbq-private-scrollbar` styles. + */ +@Component({ + selector: 'scrollbar-style-loader', + template: '', + styleUrl: 'scrollbar.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None +}) +class KbqScrollbarStyleLoader {} + +/** + * Marks a `` nested inside a `
` as the element + * `KbqScrollbar` should actually measure, listen to, and scroll — instead of its own host. + */ +@Directive({ + selector: '[kbqScrollbarVirtualViewport]' +}) +export class KbqScrollbarVirtualViewport { + /** @docs-private */ + readonly elementRef = inject>(ElementRef); + /** @docs-private */ + readonly viewport = inject(CdkVirtualScrollViewport, { self: true }); +} + +/** Auto-created scroll wrapper for `KbqScrollbar` when there's no explicit `kbqScrollbarVirtualViewport`. */ +@Component({ + selector: 'kbq-scrollbar-viewport', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'kbq-private-scrollbar-viewport' }, + hostDirectives: [CdkScrollable] +}) +class KbqScrollbarViewport {} + +/** + * NOTE! Intended exclusively for use within this repository. + * + * Dependency-free custom scrollbar directive. Draws its own track/thumb via `Renderer2` on top of + * the host's content — no external dependency, unlike `kbqScrollbar`/`kbq-scrollbar` + * (`../scrollbar.directive.ts`/`../scrollbar.component.ts`), which wrap an external library. + * + * Usable as a plain attribute (`
`) or composed via `hostDirectives: + * [KbqScrollbar]` in another component. + * + * On coarse-pointer (touch) devices the custom track/thumb are never created — scrolling stays + * native. Bypassed the same way when `KbqScrollbarConfig.native` is set. + */ +@Directive({ + selector: '[kbqScrollbar]', + providers: [{ provide: KBQ_OVERFLOW_SHADOW_SOURCE, useExisting: forwardRef(() => KbqScrollbar) }], + host: { + class: 'kbq-private-scrollbar', + '[class.kbq-private-scrollbar_rtl]': 'rtl()', + '[class.kbq-private-scrollbar_disable-drag]': 'disableDrag()', + '[class.kbq-private-scrollbar_disable-click]': 'disableClick()' + }, + // Redundant (but harmless) whenever `scrollElement` resolves to a `kbqScrollbarVirtualViewport` + // or the auto-created viewport — both already provide their own `CdkScrollable`. Needed here + // for the remaining case: `native`/coarse pointer with no explicit viewport, where neither of + // those exists and the host itself becomes the real, scrolling element. + hostDirectives: [CdkScrollable], + exportAs: 'kbqScrollbar' +}) +export class KbqScrollbar implements KbqOverflowShadowSource, OnDestroy { + private readonly renderer = inject(Renderer2); + private readonly hostElement = kbqInjectNativeElement(); + private readonly document = inject(DOCUMENT); + private readonly window = inject(KBQ_WINDOW); + private readonly sharedResizeObserver = inject(SharedResizeObserver); + private readonly destroyRef = inject(DestroyRef); + private readonly zone = inject(NgZone); + private readonly injector = inject(Injector); + private readonly appRef = inject(ApplicationRef); + private readonly config = inject(KBQ_SCROLLBAR_CONFIG); + private readonly directionality = inject(Directionality, { optional: true }); + + /** + * See `KbqScrollbarConfig.native`. Read once from config rather than an `input()` — whether the + * custom track/thumb DOM gets built at all is decided once in `afterNextRender`, so there's no + * later point at which a per-instance change could take effect. + */ + private readonly native = this.config.native ?? KBQ_SCROLLBAR_DEFAULT_CONFIG.native; + + /** + * Used from the host binding. A signal (not a plain method) so the class + * updates even when the host lives inside an `OnPush` ancestor that isn't otherwise dirty — + * `dir.change` is a plain `EventEmitter`, not something Angular's zone/OnPush tracking picks + * up on its own. Same reasoning as `overflow` in `core/overflow-shadow/overflow-shadow.ts`. + * + * @docs-private + */ + protected readonly rtl = signal(this.directionality?.value === 'rtl'); + + private readonly viewport = contentChild(KbqScrollbarVirtualViewport); + + /** Controls when the custom track/thumb are shown — see `KbqScrollbarVisibility`. */ + readonly visibility = input( + this.config.visibility ?? KBQ_SCROLLBAR_DEFAULT_CONFIG.visibility, + { alias: 'kbqScrollbarVisibility' } + ); + + /** Delay, in ms, before auto-hiding after a scroll in `'scroll'` visibility mode. */ + readonly autoHideDelay = input(this.config.autoHideDelay ?? KBQ_SCROLLBAR_DEFAULT_CONFIG.autoHideDelay, { + alias: 'kbqScrollbarAutoHideDelay', + transform: numberAttribute + }); + + /** `true` (default) floats the track/thumb over the content; `false` reserves layout space like a native scrollbar. */ + readonly floating = input(this.config.floating ?? KBQ_SCROLLBAR_DEFAULT_CONFIG.floating, { + alias: 'kbqScrollbarFloating', + transform: booleanAttribute + }); + + /** Keeps scrolling working but disables drag-on-thumb — click-on-track keeps working independently, see `disableClick`. */ + readonly disableDrag = input(this.config.disableDrag ?? KBQ_SCROLLBAR_DEFAULT_CONFIG.disableDrag, { + alias: 'kbqScrollbarDisableDrag', + transform: booleanAttribute + }); + + /** Keeps scrolling working but disables click-on-track (jump-to-click) — drag-on-thumb keeps working independently, see `disableDrag`. */ + readonly disableClick = input(this.config.disableClick ?? KBQ_SCROLLBAR_DEFAULT_CONFIG.disableClick, { + alias: 'kbqScrollbarDisableClick', + transform: booleanAttribute + }); + + /** Emits the scroll position on every scroll event. */ + readonly scrollChange = output({ alias: 'kbqScrollbarScrollChange' }); + /** Emits when the vertical axis reaches its top. */ + readonly reachTop = output({ alias: 'kbqScrollbarReachTop' }); + /** Emits when the vertical axis reaches its bottom. */ + readonly reachBottom = output({ alias: 'kbqScrollbarReachBottom' }); + /** Emits when the horizontal axis reaches its logical start — the right edge in RTL, left in LTR. */ + readonly reachStart = output({ alias: 'kbqScrollbarReachStart' }); + /** Emits when the horizontal axis reaches its logical end — the left edge in RTL, right in LTR. */ + readonly reachEnd = output({ alias: 'kbqScrollbarReachEnd' }); + /** Emits whenever the custom track/thumb show or hide. */ + readonly visibilityChange = output({ alias: 'kbqScrollbarVisibilityChange' }); + /** + * Emits once, after the directive finishes its initial setup — including the custom + * track/thumb render when one is built, but also under `native`/coarse pointer, where there's + * no track/thumb of its own to render. + */ + readonly initialized = output({ alias: 'kbqScrollbarInitialized' }); + /** + * Emits after every recompute (resize, content change, or manual `update()`) — thumb + * size/position when the custom track/thumb are built, or just the overflow/edge measurement + * behind `isTopReached`-family signals and `reach*` otherwise (`native`, coarse pointer). + */ + readonly updated = output({ alias: 'kbqScrollbarUpdated' }); + + // Separate from the `*At*` signals below: whether an axis overflows at all, so a track with + // nothing to scroll reads as trivially at both of its own edges (see the early return in + // `measureAxis`) without that no-overflow state overwriting — and being mistaken later for — a + // real, measured edge position once content does overflow. + private readonly verticalOverflows = signal(false); + private readonly horizontalOverflows = signal(false); + + private readonly verticalAtTop = signal(false); + private readonly verticalAtBottom = signal(false); + private readonly horizontalAtLeft = signal(false); + private readonly horizontalAtRight = signal(false); + + /** Whether the vertical axis is currently scrolled all the way to the top. */ + readonly isTopReached = computed(() => !this.verticalOverflows() || this.verticalAtTop()); + /** Whether the vertical axis is currently scrolled all the way to the bottom. */ + readonly isBottomReached = computed(() => !this.verticalOverflows() || this.verticalAtBottom()); + /** + * Whether the horizontal axis is currently scrolled to its logical start — the right edge in + * RTL, left in LTR. Matches `scrollStart()`/`reachStart`. + */ + readonly isStartReached = computed( + () => !this.horizontalOverflows() || (this.rtl() ? this.horizontalAtRight() : this.horizontalAtLeft()) + ); + /** + * Whether the horizontal axis is currently scrolled to its logical end — the left edge in + * RTL, right in LTR. Matches `scrollEnd()`/`reachEnd`. + */ + readonly isEndReached = computed( + () => !this.horizontalOverflows() || (this.rtl() ? this.horizontalAtLeft() : this.horizontalAtRight()) + ); + + private readonly scrollSubject = new Subject(); + /** @docs-private Implementation of `KbqOverflowShadowSource`. */ + readonly onScroll: Observable = this.scrollSubject.asObservable(); + + private readonly tracks = new Map(); + private readonly thumbs = new Map(); + + // Fallback values for when `readCssTokens` can't resolve the real custom property (e.g. no + // `document`, or run before the first render) — otherwise overwritten with the real + // `scrollbar.scss` token values below. Deliberately not configurable except via CSS — see + // `--kbq-private-scrollbar-size-thumb-min-size`/`--kbq-private-scrollbar-size-track-padding`. + private cssMinThumbSize = 32; + private cssTrackPadding = 3; + private isCoarsePointer = false; + private isVisible = false; + private isPointerOver = false; + private autoHideTimeoutId: ReturnType | undefined; + // Keyed by axis (not a single field) — the vertical and horizontal thumbs are independent, so + // a two-finger drag (one per axis) must track both without one overwriting the other. + private readonly dragContexts: Record = { vertical: null, horizontal: null }; + + /** + * Auto-created scroll wrapper, only when there's no explicit `kbqScrollbarVirtualViewport` and the + * custom track/thumb are actually being rendered. The track/thumb are `position: absolute` + * siblings of this wrapper on the (non-scrolling) host — if they were children of the element + * that itself has `overflow: auto` (i.e. the host, when the host is also the scroll element), + * they'd be clipped and scrolled away with the rest of the content instead of staying fixed as + * an overlay. + */ + private autoViewport: HTMLElement | null = null; + private autoViewportRef: ComponentRef | null = null; + + constructor() { + inject(_CdkPrivateStyleLoader).load(KbqScrollbarStyleLoader); + + afterNextRender(() => { + this.isCoarsePointer = this.window.matchMedia?.('(pointer: coarse)').matches ?? false; + this.readCssTokens(); + + const buildingCustomUi = !this.native && !this.isCoarsePointer; + + this.zone.runOutsideAngular(() => { + if (buildingCustomUi && !this.viewport()) { + this.autoViewport = this.createAutoViewport(); + } + + this.applyOverflow(); + this.wireScroll(); + this.wireResize(); + this.wireDirectionality(); + + if (buildingCustomUi) { + this.applyNativeHiding(); + this.wireGutterReservation(); + this.buildDom(); + this.wireGlobalDragListeners(); + this.wireVisibility(); + } + + // Runs even under `native`/coarse pointer — `isTopReached`-family signals and `reach*` + // outputs still need an initial measurement, not just the custom track/thumb. + this.recompute(); + + // A `kbqScrollbarVirtualViewport` (or any scroll element whose own layout isn't + // settled yet) can make the very first recompute + // under-measure — a 0-height track, or overflow that hasn't shown up yet. Its outer + // box may never resize again afterwards, so there's no guaranteed later trigger to + // correct it — retry once, next frame. + this.window.requestAnimationFrame?.(() => this.recompute()); + }); + + this.emit(this.initialized, undefined); + }); + } + + /** + * Moves the host's existing content into a new wrapper so the host itself can stay a + * non-scrolling, non-clipping positioning context for the track/thumb overlay. A real + * component (via `createComponent`), not a bare `Renderer2`-created element, specifically so + * `KbqScrollbarViewport`'s `hostDirectives: [CdkScrollable]` registers this element (the + * real scroll element) with `ScrollDispatcher` through Angular's own directive lifecycle. + */ + private createAutoViewport(): HTMLElement { + this.autoViewportRef = createComponent(KbqScrollbarViewport, { + environmentInjector: this.appRef.injector, + elementInjector: this.injector, + projectableNodes: [Array.from(this.hostElement.childNodes)] + }); + + this.appRef.attachView(this.autoViewportRef.hostView); + + const element = this.autoViewportRef.location.nativeElement as HTMLElement; + + this.renderer.appendChild(this.hostElement, element); + + return element; + } + + ngOnDestroy(): void { + clearTimeout(this.autoHideTimeoutId); + this.tracks.forEach((track) => this.renderer.removeChild(this.hostElement, track)); + this.tracks.clear(); + this.thumbs.clear(); + + if (this.autoViewportRef) { + this.appRef.detachView(this.autoViewportRef.hostView); + this.autoViewportRef.destroy(); + } + } + + /** Scrolls the effective scroll element (or delegates to `kbqScrollbarVirtualViewport`'s viewport). */ + scrollTo(options: KbqScrollbarScrollToOptions): void { + const viewport = this.viewport()?.viewport; + + if (viewport) { + // `scrollToOffset()` scrolls along whichever single axis the viewport itself is + // configured for (`orientation`, 'vertical' unless set to 'horizontal') — it has no + // concept of top/left independently. Picking whichever of `options.top`/`options.left` + // matches that axis (not just whichever happens to be set, defaulting to `top`) means a + // horizontal-only caller (`scrollStart()`/`scrollEnd()`) on the far more common default + // vertical viewport genuinely does nothing, instead of misreading `left` as a vertical + // offset and actually scrolling it — and the same the other way for `scrollToTop()`/ + // `scrollToBottom()` against an `orientation="horizontal"` viewport. + const offset = viewport.orientation === 'horizontal' ? options.left : options.top; + + if (offset !== undefined) viewport.scrollToOffset(offset, options.behavior ?? 'auto'); + + return; + } + + const element = this.scrollElement; + + // Feature-detected: some environments (older WebViews, this repo's jsdom-based Jest + // setup) don't implement `Element.scrollTo`. Falling back to direct scrollTop/scrollLeft + // assignment loses `behavior: 'smooth'` there, but stays functionally correct everywhere. + if (typeof element.scrollTo === 'function') { + element.scrollTo({ top: options.top, left: options.left, behavior: options.behavior }); + + return; + } + + if (options.top !== undefined) element.scrollTop = options.top; + if (options.left !== undefined) element.scrollLeft = options.left; + } + + /** Scrolls `target` (an element, or a selector resolved against the scroll element) into view. */ + scrollToElement(target: HTMLElement | string, options?: KbqScrollbarScrollToElementOptions): void { + const element = typeof target === 'string' ? this.scrollElement.querySelector(target) : target; + + if (!element) return; + + const scrollElementRect = this.scrollElement.getBoundingClientRect(); + const targetRect = element.getBoundingClientRect(); + const top = targetRect.top - scrollElementRect.top + this.scrollElement.scrollTop - (options?.top ?? 0); + const left = targetRect.left - scrollElementRect.left + this.scrollElement.scrollLeft - (options?.left ?? 0); + + this.scrollTo({ top, left, behavior: options?.behavior }); + } + + /** Scrolls to the start of the vertical axis. */ + scrollToTop(behavior?: ScrollBehavior): void { + this.scrollTo({ top: 0, behavior }); + } + + /** Scrolls to the end of the vertical axis. */ + scrollToBottom(behavior?: ScrollBehavior): void { + this.scrollTo({ top: this.scrollElement.scrollHeight, behavior }); + } + + /** + * Scrolls to the logical start of the horizontal axis — the right edge in RTL, left in LTR. + * Always `left: 0`: per the CSSOM View spec, `scrollLeft` rests at `0` at the start in both + * directions and moves negative toward the end in RTL, positive in LTR. + */ + scrollStart(behavior?: ScrollBehavior): void { + this.scrollTo({ left: 0, behavior }); + } + + /** Scrolls to the logical end of the horizontal axis — the left edge in RTL, right in LTR. */ + scrollEnd(behavior?: ScrollBehavior): void { + const element = this.scrollElement; + const maxScroll = element.scrollWidth - element.clientWidth; + + this.scrollTo({ left: this.rtl() ? -maxScroll : maxScroll, behavior }); + } + + /** + * Forces a recompute of track/thumb size and position. Scroll and viewport-box-size changes + * are handled automatically via `SharedResizeObserver`; call this manually when content grows + * `scrollHeight`/`scrollWidth` without the scroll element's own box size changing — a resize + * observer can't see that on its own (same caveat as `core/overflow-shadow/overflow-shadow.ts`). + * + * @docs-private + */ + update(): void { + this.recompute(); + } + + /** + * Implementation of `KbqOverflowShadowSource`. + * @docs-private + */ + getScrollElement(): HTMLElement | null { + return this.scrollElement; + } + + private get scrollElement(): HTMLElement { + return this.viewport()?.elementRef.nativeElement ?? this.autoViewport ?? this.hostElement; + } + + private readCssTokens(): void { + this.cssMinThumbSize = this.readCssPx('--kbq-private-scrollbar-size-thumb-min-size', this.cssMinThumbSize); + this.cssTrackPadding = this.readCssPx('--kbq-private-scrollbar-size-track-padding', this.cssTrackPadding); + } + + private readCssPx(property: string, fallback: number): number { + const raw = this.window.getComputedStyle(this.hostElement).getPropertyValue(property); + const parsed = parseFloat(raw); + + return Number.isNaN(parsed) ? fallback : parsed; + } + + private wireScroll(): void { + fromEvent(this.scrollElement, 'scroll', { passive: true }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.scrollSubject.next(); + // `syncPadding: false` — see `recompute()`'s doc comment. `scroll` is this + // directive's highest-frequency, least-throttled event; re-reading the host's own + // (effectively static) padding on every tick isn't worth the forced style read. + this.recompute(false); + this.onUserScroll(); + }); + } + + private wireResize(): void { + this.sharedResizeObserver + .observe(this.scrollElement) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.recompute()); + } + + private wireDirectionality(): void { + this.directionality?.change?.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((direction) => { + this.rtl.set(direction === 'rtl'); + this.recompute(); + }); + } + + private applyNativeHiding(): void { + this.renderer.addClass(this.scrollElement, 'kbq-private-scrollbar_hide-native'); + } + + /** + * Establishes native scroll on both axes — this directive doesn't just decorate an + * already-`overflow: auto` element, it makes the effective scroll element scrollable itself + * (the custom track/thumb are a UI layer on top of real native scrolling, not a replacement + * for it). + */ + private applyOverflow(): void { + this.renderer.setStyle(this.scrollElement, 'overflowY', 'auto'); + this.renderer.setStyle(this.scrollElement, 'overflowX', 'auto'); + } + + /** Reacts to `kbqScrollbarFloating`/direction changing at runtime, not just their value at first render. */ + private wireGutterReservation(): void { + effect(() => this.applyGutterReservation(), { injector: this.injector }); + } + + private applyGutterReservation(): void { + // Always clear the *other* side first — otherwise flipping direction (or `floating` + // itself) can leave a stale gutter behind instead of moving/removing it: this only ever + // sets the side matching the current direction, so the opposite one would never get + // cleared on its own. + const activeSide = this.rtl() ? 'paddingLeft' : 'paddingRight'; + const inactiveSide = this.rtl() ? 'paddingRight' : 'paddingLeft'; + + this.renderer.removeStyle(this.scrollElement, inactiveSide); + + if (this.floating()) { + this.renderer.removeStyle(this.scrollElement, activeSide); + this.renderer.removeStyle(this.scrollElement, 'paddingBottom'); + + return; + } + + const dimension = 'var(--kbq-private-scrollbar-size-track-dimension)'; + + this.renderer.setStyle(this.scrollElement, activeSide, dimension); + this.renderer.setStyle(this.scrollElement, 'paddingBottom', dimension); + } + + private buildDom(): void { + for (const axis of AXES) { + this.buildTrack(axis); + } + } + + private buildTrack(axis: Axis): void { + const track = this.renderer.createElement('div') as HTMLElement; + + this.renderer.addClass(track, 'kbq-private-scrollbar-track'); + this.renderer.addClass(track, `kbq-private-scrollbar-track_${axis}`); + this.renderer.setAttribute(track, 'aria-hidden', 'true'); + + const thumb = this.renderer.createElement('div') as HTMLElement; + + this.renderer.addClass(thumb, 'kbq-private-scrollbar-thumb'); + this.renderer.appendChild(track, thumb); + this.renderer.appendChild(this.hostElement, track); + + this.tracks.set(axis, track); + this.thumbs.set(axis, thumb); + + this.wireTrackInteraction(axis, track, thumb); + } + + /** + * Whether a `pointerdown` should be treated as a drag/track-click start — the primary button + * (left click, or a single touch/pen contact) only. `isPrimary` is checked against `false` + * specifically, not falsiness: a plain `MouseEvent` (as opposed to a real `PointerEvent`) + * doesn't have the property at all, and treating that as "not primary" would reject events + * that never claimed to be secondary in the first place. + */ + private isPrimaryPointerDown(event: PointerEvent): boolean { + return event.button === 0 && event.isPrimary !== false; + } + + private wireTrackInteraction(axis: Axis, track: HTMLElement, thumb: HTMLElement): void { + fromEvent(thumb, 'pointerdown') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => { + if (this.disableDrag() || !this.isPrimaryPointerDown(event)) return; + this.beginInteraction(axis, track, thumb, event, false); + }); + + // "Jump to click": clicking the track (not the thumb itself) re-centers the thumb under + // the pointer and continues as a drag if the button stays down — same math as a real + // drag-start, just with the grab offset hardcoded to the thumb's center. + fromEvent(track, 'pointerdown') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => { + if (this.disableClick() || event.target === thumb || !this.isPrimaryPointerDown(event)) return; + this.beginInteraction(axis, track, thumb, event, true); + }); + } + + // The vertical/horizontal thumbs drag independently, so a pointer event is matched back to + // whichever axis armed it, not just "the" drag. + private axisForPointerId(pointerId: number): Axis | null { + if (this.dragContexts.vertical?.pointerId === pointerId) return 'vertical'; + if (this.dragContexts.horizontal?.pointerId === pointerId) return 'horizontal'; + + return null; + } + + private get isDragging(): boolean { + return !!this.dragContexts.vertical || !!this.dragContexts.horizontal; + } + + private wireGlobalDragListeners(): void { + // None of these three ever call `preventDefault()` (only the `pointerdown` handlers in + // `wireTrackInteraction` do, via `beginInteraction()`) — marking them passive lets the + // browser treat them as non-blocking for compositor/touch-scroll purposes. + fromEvent(this.document, 'pointermove', { passive: true }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => { + const axis = this.axisForPointerId(event.pointerId); + + if (!axis) return; + + if (!event.buttons) { + this.endDrag(axis); + + return; + } + + this.applyDrag(axis, axis === 'vertical' ? event.clientY : event.clientX); + }); + + fromEvent(this.document, 'pointerup', { passive: true }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => { + const axis = this.axisForPointerId(event.pointerId); + + if (axis) this.endDrag(axis); + }); + + // A gesture can be interrupted outside the normal pointerup path — e.g. a touch drag cut + // short by an OS-level gesture, or the browser revoking pointer capture. Without this, + // `dragContexts`/the `_dragging` class/`user-select: none` would be stuck until some later, + // unrelated pointerup happens to carry the same `pointerId`. + fromEvent(this.document, 'pointercancel', { passive: true }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => { + const axis = this.axisForPointerId(event.pointerId); + + if (axis) this.endDrag(axis); + }); + + // Mirrors `pointercancel` above for the rarer case pointer capture itself gets revoked + // mid-gesture (e.g. another element calls `setPointerCapture` for the same pointerId, or + // the browser does so on focus change) without a `pointerup`/`pointercancel` ever firing. + // Bubbles like the others, so `document` still sees it even though capture itself was set + // on the thumb/track (`beginInteraction()`). + fromEvent(this.document, 'lostpointercapture', { passive: true }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => { + const axis = this.axisForPointerId(event.pointerId); + + if (axis) this.endDrag(axis); + }); + } + + private beginInteraction( + axis: Axis, + track: HTMLElement, + thumb: HTMLElement, + event: PointerEvent, + centerOnThumb: boolean + ): void { + event.preventDefault(); + + // Captures all further events for this pointerId to `captureTarget` regardless of where + // the pointer travels afterward (even outside the window, or over an iframe/native + // control) — without this, the pointer leaving the document before it's released can mean + // the eventual `pointerup` never reaches `document` at all, leaving the drag stuck open + // forever. `lostpointercapture` (handled in `wireGlobalDragListeners()`) is the safety net + // for the rarer case capture itself gets revoked mid-gesture. Feature-detected: some + // environments (this repo's jsdom-based Jest setup among them) don't implement + // `Element.setPointerCapture` — same caveat as `scrollTo()`. + const captureTarget = centerOnThumb ? track : thumb; + + if (typeof captureTarget.setPointerCapture === 'function') { + captureTarget.setPointerCapture(event.pointerId); + } + + const isVertical = axis === 'vertical'; + const trackRect = track.getBoundingClientRect(); + const thumbRect = thumb.getBoundingClientRect(); + // Inset by the same `cssTrackPadding` gap `paintAxis` positions the thumb within, so a + // drag maps 1:1 to the thumb's actual travel range instead of the track's full box. + const trackStart = (isVertical ? trackRect.top : trackRect.left) + this.cssTrackPadding; + const trackLength = (isVertical ? trackRect.height : trackRect.width) - 2 * this.cssTrackPadding; + const thumbSize = isVertical ? thumbRect.height : thumbRect.width; + const thumbStart = isVertical ? thumbRect.top : thumbRect.left; + const pointerCoord = isVertical ? event.clientY : event.clientX; + + const scrollEl = this.scrollElement; + const scrollRange = isVertical + ? scrollEl.scrollHeight - scrollEl.clientHeight + : scrollEl.scrollWidth - scrollEl.clientWidth; + + // Author CSS may set `scroll-behavior: smooth` on the scroll element — without overriding + // it here, every `applyDrag()` write below would animate toward the new position instead of + // jumping straight there, so the thumb would visibly lag behind the pointer for the whole + // drag. Only touched on the transition into the very first concurrently-active drag (a + // second axis joining an already-active one must not stomp on it), and restored once + // `endDrag()` sees every axis has ended. + if (!this.isDragging) this.renderer.setStyle(scrollEl, 'scrollBehavior', 'auto'); + + this.dragContexts[axis] = { + axis, + pointerId: event.pointerId, + trackStart, + trackTravel: trackLength - thumbSize, + grabOffset: centerOnThumb ? thumbSize / 2 : pointerCoord - thumbStart, + scrollRange + }; + + this.setVisible(true); + this.applyDrag(axis, pointerCoord); + + // `centerOnThumb` is only ever `true` for a track click (never a thumb grab — the thumb's + // own pointerdown handler already excludes calling this at all when `disableDrag()` is + // set). Without this, a track click while dragging is disabled would still perform its + // one-shot jump above, but then keep this axis' drag context armed — the always-on global + // `pointermove` listener (`wireGlobalDragListeners`) would then keep following the pointer + // for as long as the button stays down, behaving exactly like a real drag despite + // `disableDrag()`. Clearing it back out right after the jump keeps the click a discrete + // action, the same way `disableClick` alone already keeps drag a discrete action. + if (centerOnThumb && this.disableDrag()) { + this.dragContexts[axis] = null; + this.restoreScrollBehaviorIfIdle(); + + return; + } + + this.renderer.addClass(this.hostElement, 'kbq-private-scrollbar_dragging'); + } + + private applyDrag(axis: Axis, pointerCoord: number): void { + const ctx = this.dragContexts[axis]; + + if (!ctx || ctx.trackTravel <= 0) return; + + const pointerRelative = pointerCoord - ctx.trackStart; + // Physical ratio: 0 at the track's physical start (left/top), 1 at its physical end. + const ratio = clamp((pointerRelative - ctx.grabOffset) / ctx.trackTravel, 0, 1); + + if (axis === 'vertical') { + this.scrollElement.scrollTop = ratio * ctx.scrollRange; + } else if (this.rtl()) { + // RTL `scrollLeft` runs 0 (physical right) to -scrollRange (physical left) — invert + // the physical ratio derived above onto that range. + this.scrollElement.scrollLeft = (ratio - 1) * ctx.scrollRange; + } else { + this.scrollElement.scrollLeft = ratio * ctx.scrollRange; + } + } + + private endDrag(axis: Axis): void { + if (!this.dragContexts[axis]) return; + + this.dragContexts[axis] = null; + + // The other axis can still be mid-drag (two-finger drag of both thumbs) — keep the + // `_dragging` state and defer visibility restoration until every axis has ended. + if (this.isDragging) return; + + this.renderer.removeClass(this.hostElement, 'kbq-private-scrollbar_dragging'); + this.restoreScrollBehaviorIfIdle(); + + // `beginInteraction()` force-shows the scrollbar regardless of mode — restore whatever the + // current mode's steady state actually is now that every axis' drag has ended. 'scroll' + // mode gets its usual timed reveal via `showTemporarily()` (not an immediate hide); every + // other mode goes through the same steady-state logic the mode-change effect above uses + // (and skips while `isDragging`). + if (this.visibility() === 'scroll') { + this.showTemporarily(); + } else { + this.applySteadyVisibility(); + } + } + + // Reverts the `scroll-behavior: auto` override `beginInteraction()` applies — a no-op guard + // since this is called from two places (the disableDrag "jump" early-return above, and the + // normal end-of-drag path below), only one of which is guaranteed to be the actual last axis. + private restoreScrollBehaviorIfIdle(): void { + if (this.isDragging) return; + + this.renderer.removeStyle(this.scrollElement, 'scrollBehavior'); + } + + private wireVisibility(): void { + fromEvent(this.hostElement, 'pointerenter') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.isPointerOver = true; + + if (this.visibility() === 'hover') this.setVisible(true); + }); + + fromEvent(this.hostElement, 'pointerleave') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.isPointerOver = false; + + if (this.visibility() === 'hover' && !this.isDragging) this.setVisible(false); + }); + + // Reacts to `kbqScrollbarVisibility` changing at runtime, not just its value at first + // render — sets the new mode's steady state. `isPointerOver` is read (not tracked) so this + // only reruns on an actual mode change, using whatever the live hover state is at that + // moment; ongoing hover-driven toggling still comes from the listeners above. 'scroll' + // mode's temporary reveal-on-scroll keeps coming from onUserScroll()/showTemporarily() — + // 'hidden' starts from the same steady state but never gets revealed from anywhere. + // + // Skipped entirely while a drag is in progress — forcing the new mode's steady state here + // would otherwise yank the track/thumb visibility out from under an active drag (e.g. + // hiding it mid-gesture on a switch to 'hidden') even though the drag itself keeps + // scrolling regardless of visibility. `endDrag()` applies the (possibly now-different) + // steady state once the drag actually ends instead. + effect( + () => { + this.visibility(); + + if (this.isDragging) return; + + this.applySteadyVisibility(); + }, + { injector: this.injector } + ); + } + + // Shared by the mode-change effect above and by `endDrag()` — applies whatever the current + // mode's non-drag, non-transient visibility should be. + private applySteadyVisibility(): void { + const mode = this.visibility(); + + clearTimeout(this.autoHideTimeoutId); + + if (mode === 'always') { + this.setVisible(true); + } else if (mode === 'hover') { + this.setVisible(this.isPointerOver); + } else { + this.setVisible(false); + } + } + + private onUserScroll(): void { + this.emit(this.scrollChange, { top: this.scrollElement.scrollTop, left: this.scrollElement.scrollLeft }); + + if (this.visibility() === 'scroll') this.showTemporarily(); + } + + private showTemporarily(): void { + this.setVisible(true); + clearTimeout(this.autoHideTimeoutId); + this.autoHideTimeoutId = setTimeout(() => { + if (!this.isDragging) this.setVisible(false); + }, this.autoHideDelay()); + } + + private setVisible(value: boolean): void { + if (this.isVisible === value) return; + + this.isVisible = value; + this.renderer[value ? 'addClass' : 'removeClass'](this.hostElement, 'kbq-private-scrollbar_visible'); + this.emit(this.visibilityChange, value); + } + + /** + * @param syncPadding Whether to also re-read the host's own padding into the CSS custom + * properties `scrollbar.scss`'s track positioning rules consume (`syncHostPadding()`). + * Defaults to `true` — the one caller that opts out is `wireScroll()`, since `scroll` is by far + * this directive's highest-frequency, un-throttled event, and the host's own author-set + * padding has no real chance of changing on its own between one scroll tick and the next. + */ + private recompute(syncPadding = true): void { + const measurements = new Map(); + + for (const axis of AXES) { + const measurement = this.measureAxis(axis); + + if (measurement) measurements.set(axis, measurement); + } + + // Both only make sense with a custom track/thumb to position — a no-op anyway without + // them, but skip outright under `native`/coarse pointer. Must run before `paintAxis()` + // below, not after: both can shrink a track's real size via CSS (host padding insets it, + // corner-avoidance shrinks it further), and `paintAxis()` measures that size to place the + // thumb — sizing it first would size/position the thumb against a stale, pre-shrink track + // on the very frame content starts overflowing, or the very frame host padding changes. + if (!this.native && !this.isCoarsePointer) { + if (syncPadding) this.syncHostPadding(); + this.updateCornerAvoidance(); + } + + for (const [axis, measurement] of measurements) { + this.paintAxis(axis, measurement.scrollOffset, measurement.scrollRange); + } + + this.emit(this.updated, undefined); + } + + /** + * Publishes the host's own computed padding as CSS custom properties consumed by + * `scrollbar.scss`'s track positioning rules — the track is `position: absolute` directly on + * the host, so without this it insets by 0 regardless of any padding the host itself carries, + * leaving the track sitting flush with the host's border edge instead of its actual content + * edge (a gap of empty padding between the track and the real content it's supposed to overlay). + * Read fresh on every `recompute()` rather than once at init, since author CSS can change the + * host's padding at runtime (e.g. a responsive class) with no resize of the host's own box to + * otherwise trigger a re-read. + */ + private syncHostPadding(): void { + const style = this.window.getComputedStyle(this.hostElement); + + this.setHostPaddingVar('top', style.paddingTop); + this.setHostPaddingVar('right', style.paddingRight); + this.setHostPaddingVar('bottom', style.paddingBottom); + this.setHostPaddingVar('left', style.paddingLeft); + } + + private setHostPaddingVar(side: 'top' | 'right' | 'bottom' | 'left', value: string): void { + this.renderer.setStyle( + this.hostElement, + `--kbq-private-scrollbar-host-padding-${side}`, + value, + RendererStyleFlags2.DashCase + ); + } + + /** + * Marks each track with whether the other one is currently also visible, so `scrollbar.scss` + * can shorten its length by the other's thickness — otherwise they'd overlap by one + * track-thickness in the shared corner, the way native scrollbars leave an empty square where + * they meet. A no-op whenever content only overflows on one axis. + */ + private updateCornerAvoidance(): void { + const verticalTrack = this.tracks.get('vertical'); + const horizontalTrack = this.tracks.get('horizontal'); + const bothVisible = + !!verticalTrack && + !!horizontalTrack && + verticalTrack.style.display !== 'none' && + horizontalTrack.style.display !== 'none'; + + if (verticalTrack) { + this.renderer[bothVisible ? 'addClass' : 'removeClass']( + verticalTrack, + 'kbq-private-scrollbar-track_has-horizontal' + ); + } + + if (horizontalTrack) { + this.renderer[bothVisible ? 'addClass' : 'removeClass']( + horizontalTrack, + 'kbq-private-scrollbar-track_has-vertical' + ); + } + } + + /** + * Measures overflow/edge state for `isTopReached`-family signals and `reach*` outputs, and shows or + * hides this axis's track accordingly — runs unconditionally, regardless of `native`/coarse + * pointer, since those signals/outputs are meant to keep working even when there's no custom + * UI of our own to show. Returns the scroll offset/range `paintAxis()` needs to size and + * position the thumb, or `null` when this axis doesn't overflow (nothing to paint). + * + * Deliberately doesn't measure/paint the thumb itself — `recompute()` runs + * `updateCornerAvoidance()` between this and `paintAxis()`, since corner avoidance can shrink + * a track's real size via CSS, and the thumb needs to be sized against that final size, not + * whatever it measured before corner avoidance ran. + */ + private measureAxis(axis: Axis): AxisMeasurement | null { + const scrollEl = this.scrollElement; + const isVertical = axis === 'vertical'; + const viewportSize = isVertical ? scrollEl.clientHeight : scrollEl.clientWidth; + const contentSize = isVertical ? scrollEl.scrollHeight : scrollEl.scrollWidth; + const rawScrollOffset = isVertical ? scrollEl.scrollTop : scrollEl.scrollLeft; + const overflows = isVertical ? this.verticalOverflows : this.horizontalOverflows; + const track = this.tracks.get(axis); + + if (contentSize <= viewportSize) { + if (track) this.renderer.setStyle(track, 'display', 'none'); + + // Nothing to scroll on this axis — trivially "at both edges" at once. The public + // `isTopReached()`/`isBottomReached()`-family signals already short-circuit to `true` + // off `overflows` alone regardless of what's passed here, but the discrete `reach*` + // outputs only ever fire from `checkReachedEdges()` — without calling it on a genuine + // overflowing→not-overflowing transition too, a consumer listening to the outputs (not + // the signals) would never hear about content shrinking to fit. Gated on the *previous* + // `overflows()` value specifically: this axis simply never having overflowed yet (its + // state from construction, before any real measurement) must NOT also count as such a + // transition — that would fire `reach*` before anyone's had a chance to subscribe, and + // permanently mark both edges "already reached" so a later, genuine first overflow + // never fires them at all. + if (overflows()) this.checkReachedEdges(axis, 0, 0); + + overflows.set(false); + + return null; + } + + overflows.set(true); + + // Un-hide *before* `paintAxis()` measures, not after: a `CdkVirtualScrollViewport` (or any + // scroll element whose layout isn't settled on the very first pass) can make an earlier + // call land in the branch above and set `display: none`. A hidden element always measures + // a 0 clientHeight/Width, so if we measured first and only unhid on success, a stale + // `display: none` would make every subsequent pass measure 0 and bail out below, trapping + // the track hidden forever even once real content overflow is detected here. Also needs to + // happen before `updateCornerAvoidance()`, which checks each track's current visibility. + if (track) this.renderer.removeStyle(track, 'display'); + + const scrollRange = contentSize - viewportSize; + // Physical distance from the left/top edge, in [0, scrollRange] — normalizes RTL's negative + // `scrollLeft` range (0 at the right edge, down to -scrollRange at the left) onto the same + // physical axis the thumb's CSS position is anchored to. `checkReachedEdges` below + // re-derives the logical start/end from this physical value itself. + const scrollOffset = !isVertical && this.rtl() ? scrollRange + rawScrollOffset : rawScrollOffset; + + this.checkReachedEdges(axis, scrollOffset, scrollRange); + + return { scrollOffset, scrollRange }; + } + + /** Sizes and positions this axis's thumb, given the scroll offset/range `measureAxis()` already computed. */ + private paintAxis(axis: Axis, scrollOffset: number, scrollRange: number): void { + const track = this.tracks.get(axis); + const thumb = this.thumbs.get(axis); + + // No custom track/thumb to paint for this axis — `native`/coarse pointer, or the DOM for + // it hasn't been built. + if (!track || !thumb) return; + + const isVertical = axis === 'vertical'; + const scrollEl = this.scrollElement; + const viewportSize = isVertical ? scrollEl.clientHeight : scrollEl.clientWidth; + const trackLength = isVertical ? track.clientHeight : track.clientWidth; + + // Track not laid out yet (e.g. inserted this same tick) — a later pass will retry. + if (trackLength <= 0) return; + + // The thumb travels within the track minus a `cssTrackPadding` gap at *each* end, so it + // never sits flush against the track's own start/end edge — matching the CSS insets + // already applied on its cross-axis (`scrollbar.scss`'s `left`/`right`/`top`/`bottom` + // rules), just computed here since this axis is otherwise entirely JS-driven. Clamped to + // `0` (not left negative) for a track shorter than `2 * cssTrackPadding` — `Math.min(travelLength, + // ...)` below already collapses `thumbSize` to `0` correctly once this is non-negative; + // left negative, it would instead flow through as a bogus negative thumb size. + const travelLength = Math.max(0, trackLength - 2 * this.cssTrackPadding); + + // Round the ratio up so the thumb never reads as undersized from float error. + const ratio = Math.min(1, Math.ceil((viewportSize / (viewportSize + scrollRange)) * 100) / 100); + const thumbSize = Math.min(travelLength, Math.max(ratio * travelLength, this.cssMinThumbSize)); + const scrollRatio = scrollRange > 0 ? scrollOffset / scrollRange : 0; + const thumbOffset = this.cssTrackPadding + scrollRatio * (travelLength - thumbSize); + + if (isVertical) { + this.renderer.setStyle(thumb, 'height', coerceCssPixelValue(thumbSize)); + this.renderer.setStyle(thumb, 'top', coerceCssPixelValue(thumbOffset)); + } else { + this.renderer.setStyle(thumb, 'width', coerceCssPixelValue(thumbSize)); + this.renderer.setStyle(thumb, 'left', coerceCssPixelValue(thumbOffset)); + } + } + + /** + * The writable `isTopReached`/`isStartReached`-family signals for one physical axis — + * `[start, end]`, both always physically named (top/bottom, left/right); the public + * `isStartReached`/`isEndReached` computed signals are what re-map "left/right" onto + * RTL-aware "start/end". + */ + private edgeSignals(axis: Axis): readonly [WritableSignal, WritableSignal] { + return axis === 'vertical' + ? [this.verticalAtTop, this.verticalAtBottom] + : [this.horizontalAtLeft, this.horizontalAtRight]; + } + + private checkReachedEdges(axis: Axis, scrollOffset: number, scrollRange: number): void { + const atPhysicalStart = scrollOffset <= 0; + // No `scrollRange > 0` guard: the one caller passing `scrollRange: 0` (`measureAxis()`'s + // no-longer-overflowing branch) means "nothing to scroll," which — same as + // `atPhysicalStart` above — is trivially also "at the end." Every other caller only ever + // reaches this with a genuinely positive `scrollRange` already, so this doesn't change + // anything for them. + const atPhysicalEnd = Math.round(scrollOffset) >= Math.round(scrollRange); + const [startState, endState] = this.edgeSignals(axis); + + // Vertical has no RTL concept — top is always physically first. Horizontal's physical + // left/right flips which *logical* edge (reachStart/reachEnd) it maps to in RTL, matching + // scrollStart()/scrollEnd()'s own RTL-aware semantics. + const physicalStartEmitter = axis === 'vertical' ? this.reachTop : this.rtl() ? this.reachEnd : this.reachStart; + const physicalEndEmitter = + axis === 'vertical' ? this.reachBottom : this.rtl() ? this.reachStart : this.reachEnd; + + if (atPhysicalStart && !startState()) { + this.emit(physicalStartEmitter, undefined); + } + + startState.set(atPhysicalStart); + + if (atPhysicalEnd && !endState()) { + this.emit(physicalEndEmitter, undefined); + } + + endState.set(atPhysicalEnd); + } + + private emit(emitter: OutputEmitterRef, value: T): void { + this.zone.run(() => emitter.emit(value)); + } +} diff --git a/packages/components/scrollbar/scrollbar.en.md b/packages/components/scrollbar/scrollbar.en.md index 105897bd94..c9e60dee81 100644 --- a/packages/components/scrollbar/scrollbar.en.md +++ b/packages/components/scrollbar/scrollbar.en.md @@ -35,3 +35,7 @@ npm install overlayscrollbars@2.7.3 ... ``` + +## Programmatic scrollbar control + + diff --git a/packages/components/scrollbar/scrollbar.ru.md b/packages/components/scrollbar/scrollbar.ru.md index 7128e41a89..995e836982 100644 --- a/packages/components/scrollbar/scrollbar.ru.md +++ b/packages/components/scrollbar/scrollbar.ru.md @@ -35,3 +35,7 @@ npm install overlayscrollbars@2.7.3 ... ``` + +## Программное управление скроллом + + diff --git a/packages/docs-examples/components/scrollbar/index.ts b/packages/docs-examples/components/scrollbar/index.ts index 45f5dd136d..32276b3442 100644 --- a/packages/docs-examples/components/scrollbar/index.ts +++ b/packages/docs-examples/components/scrollbar/index.ts @@ -1,15 +1,37 @@ import { NgModule } from '@angular/core'; import { KBQ_SCROLLBAR_OPTIONS_DEFAULT_CONFIG_PROVIDER } from '@koobiq/components/scrollbar'; +import { PrivateScrollbarDisableInteractionExample } from './private-scrollbar-disable-interaction/private-scrollbar-disable-interaction-example'; +import { PrivateScrollbarNativeExample } from './private-scrollbar-native/private-scrollbar-native-example'; +import { PrivateScrollbarRtlExample } from './private-scrollbar-rtl/private-scrollbar-rtl-example'; +import { PrivateScrollbarScrollToExample } from './private-scrollbar-scroll-to/private-scrollbar-scroll-to-example'; +import { PrivateScrollbarVirtualScrollExample } from './private-scrollbar-virtual-scroll/private-scrollbar-virtual-scroll-example'; +import { PrivateScrollbarVisibilityExample } from './private-scrollbar-visibility/private-scrollbar-visibility-example'; import { ScrollbarOverviewExample } from './scrollbar-overview/scrollbar-overview-example'; import { ScrollbarScrollToTopExample } from './scrollbar-scroll-to-top/scrollbar-scroll-to-top-example'; import { ScrollbarWithCustomConfigExample } from './scrollbar-with-custom-config/scrollbar-with-custom-config-example'; -export { ScrollbarOverviewExample, ScrollbarScrollToTopExample, ScrollbarWithCustomConfigExample }; +export { + PrivateScrollbarDisableInteractionExample, + PrivateScrollbarNativeExample, + PrivateScrollbarRtlExample, + PrivateScrollbarScrollToExample, + PrivateScrollbarVirtualScrollExample, + PrivateScrollbarVisibilityExample, + ScrollbarOverviewExample, + ScrollbarScrollToTopExample, + ScrollbarWithCustomConfigExample +}; const EXAMPLES = [ ScrollbarScrollToTopExample, ScrollbarOverviewExample, - ScrollbarWithCustomConfigExample + ScrollbarWithCustomConfigExample, + PrivateScrollbarVisibilityExample, + PrivateScrollbarVirtualScrollExample, + PrivateScrollbarScrollToExample, + PrivateScrollbarRtlExample, + PrivateScrollbarNativeExample, + PrivateScrollbarDisableInteractionExample ]; @NgModule({ diff --git a/packages/docs-examples/components/scrollbar/private-scrollbar-disable-interaction/private-scrollbar-disable-interaction-example.ts b/packages/docs-examples/components/scrollbar/private-scrollbar-disable-interaction/private-scrollbar-disable-interaction-example.ts new file mode 100644 index 0000000000..b610c4c4e2 --- /dev/null +++ b/packages/docs-examples/components/scrollbar/private-scrollbar-disable-interaction/private-scrollbar-disable-interaction-example.ts @@ -0,0 +1,89 @@ +import { ChangeDetectionStrategy, Component, model } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { KbqScrollbar } from '@koobiq/components/scrollbar/private'; +import { KbqToggleModule } from '@koobiq/components/toggle'; + +/** + * @title Scrollbar disable interaction + */ +@Component({ + selector: 'private-scrollbar-disable-interaction-example', + imports: [KbqScrollbar, KbqToggleModule, FormsModule], + template: ` +
+ Disable drag + Disable click +
+ +
+

+ Drag for the scrollbar is {{ disableDrag() ? 'disabled' : 'enabled' }} +

+

+ Click for the scrollbar is {{ disableClick() ? 'disabled' : 'enabled' }} +

+

+ 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 guessing + correctly. This strategy can theoretically be used to break any form of encryption that is not + information-theoretically secure.[1] However, in a properly designed cryptosystem the chance of + successfully guessing the key is negligible. +

+

+ 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 can be made less effective by implementing key stretching techniques making it more + difficult for an attacker to recognize when the code has been cracked or by making the attacker 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] +

+
+ `, + styles: ` + :host { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--kbq-size-l); + overflow: hidden; + padding: var(--kbq-size-l); + } + + .example-toggles { + display: flex; + gap: var(--kbq-size-l); + } + + .example-scrollbar { + overflow: auto; + resize: both; + height: 200px; + min-height: 200px; + max-height: 400px; + width: 100%; + min-width: 200px; + max-width: 100%; + border-radius: var(--kbq-size-border-radius); + background-color: var(--kbq-background-bg-secondary); + } + + p { + width: 150%; + margin: var(--kbq-size-l); + } + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PrivateScrollbarDisableInteractionExample { + protected readonly disableDrag = model(true); + protected readonly disableClick = model(false); +} diff --git a/packages/docs-examples/components/scrollbar/private-scrollbar-native/private-scrollbar-native-example.ts b/packages/docs-examples/components/scrollbar/private-scrollbar-native/private-scrollbar-native-example.ts new file mode 100644 index 0000000000..b83e8e6b0d --- /dev/null +++ b/packages/docs-examples/components/scrollbar/private-scrollbar-native/private-scrollbar-native-example.ts @@ -0,0 +1,96 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { KbqButtonModule } from '@koobiq/components/button'; +import { KbqScrollbar, kbqScrollbarConfigProvider } from '@koobiq/components/scrollbar/private'; + +/** + * @title Scrollbar native fallback + */ +@Component({ + selector: 'private-scrollbar-native-example', + imports: [KbqScrollbar, KbqButtonModule], + template: ` +
+ + + + +
+ +
+

Native fallback scrollbar

+

+ 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 guessing + correctly. This strategy can theoretically be used to break any form of encryption that is not + information-theoretically secure.[1] However, in a properly designed cryptosystem the chance of + successfully guessing the key is negligible. +

+

+ 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] +

+

+ 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] +

+
+ `, + styles: ` + :host { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--kbq-size-l); + overflow: hidden; + padding: var(--kbq-size-l); + } + + .example-buttons { + display: flex; + flex-wrap: wrap; + gap: var(--kbq-size-s); + } + + .example-scrollbar { + overflow: auto; + resize: both; + height: 200px; + min-height: 200px; + max-height: 400px; + width: 100%; + min-width: 200px; + max-width: 100%; + border-radius: var(--kbq-size-border-radius); + background-color: var(--kbq-background-bg-secondary); + } + + p { + margin: var(--kbq-size-l); + width: 150%; + } + `, + providers: [kbqScrollbarConfigProvider({ native: true })], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PrivateScrollbarNativeExample { + protected scrollbarChange(event: any): void { + console.log('scrollbarChange', event); + } +} diff --git a/packages/docs-examples/components/scrollbar/private-scrollbar-rtl/private-scrollbar-rtl-example.ts b/packages/docs-examples/components/scrollbar/private-scrollbar-rtl/private-scrollbar-rtl-example.ts new file mode 100644 index 0000000000..805713e474 --- /dev/null +++ b/packages/docs-examples/components/scrollbar/private-scrollbar-rtl/private-scrollbar-rtl-example.ts @@ -0,0 +1,89 @@ +import { Dir } from '@angular/cdk/bidi'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { KbqButtonModule } from '@koobiq/components/button'; +import { KbqScrollbar } from '@koobiq/components/scrollbar/private'; + +/** + * @title Scrollbar RTL + */ +@Component({ + selector: 'private-scrollbar-rtl-example', + imports: [KbqScrollbar, KbqButtonModule, Dir], + template: ` +
+ + + + +
+ +
+
+

+ 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 + guessing correctly. This strategy can theoretically be used to break any form of encryption that is + not information-theoretically secure.[1] However, in a properly designed cryptosystem the chance of + successfully guessing the key is negligible. +

+

+ 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] +

+

+ 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 + guessing correctly. This strategy can theoretically be used to break any form of encryption that is + not information-theoretically secure.[1] However, in a properly designed cryptosystem the chance of + successfully guessing the key is negligible. +

+
+
+ `, + styles: ` + :host { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--kbq-size-l); + overflow: hidden; + padding: var(--kbq-size-l); + } + + .example-buttons { + display: flex; + flex-wrap: wrap; + gap: var(--kbq-size-s); + } + + .example-scrollbar { + overflow: auto; + resize: both; + height: 200px; + min-height: 200px; + max-height: 400px; + width: 100%; + min-width: 200px; + max-width: 100%; + border-radius: var(--kbq-size-border-radius); + background-color: var(--kbq-background-bg-secondary); + } + + p { + width: 150%; + margin: var(--kbq-size-l); + } + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PrivateScrollbarRtlExample {} diff --git a/packages/docs-examples/components/scrollbar/private-scrollbar-scroll-to/private-scrollbar-scroll-to-example.ts b/packages/docs-examples/components/scrollbar/private-scrollbar-scroll-to/private-scrollbar-scroll-to-example.ts new file mode 100644 index 0000000000..63640eb874 --- /dev/null +++ b/packages/docs-examples/components/scrollbar/private-scrollbar-scroll-to/private-scrollbar-scroll-to-example.ts @@ -0,0 +1,105 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { KbqButtonModule } from '@koobiq/components/button'; +import { KbqScrollbar } from '@koobiq/components/scrollbar/private'; + +/** + * @title Scrollbar scrollTo methods + */ +@Component({ + selector: 'private-scrollbar-scroll-to-example', + imports: [KbqScrollbar, KbqButtonModule], + template: ` +
+ + + + + +
+ +
+

+ 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 guessing + correctly. This strategy can theoretically be used to break any form of encryption that is not + information-theoretically secure.[1] However, in a properly designed cryptosystem the chance of + successfully guessing the key is negligible. +

+

+ Brute-force attacks can be made less effective by implementing key stretching techniques making it more + difficult for an attacker to recognize when the code has been cracked or by making the attacker 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] +

+

+ [Scroll target] + 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 can be made less effective by implementing key stretching techniques making it more + difficult for an attacker to recognize when the code has been cracked or by making the attacker 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] +

+

+ 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 guessing + correctly. This strategy can theoretically be used to break any form of encryption that is not + information-theoretically secure.[1] However, in a properly designed cryptosystem the chance of + successfully guessing the key is negligible. +

+
+ `, + styles: ` + :host { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--kbq-size-l); + overflow: hidden; + padding: var(--kbq-size-l); + } + + .example-buttons { + display: flex; + flex-wrap: wrap; + gap: var(--kbq-size-s); + } + + .example-scrollbar { + overflow: auto; + resize: both; + height: 200px; + min-height: 200px; + max-height: 400px; + width: 100%; + min-width: 200px; + max-width: 100%; + border-radius: var(--kbq-size-border-radius); + background-color: var(--kbq-background-bg-secondary); + } + + p { + width: 150%; + margin: var(--kbq-size-l); + } + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PrivateScrollbarScrollToExample {} diff --git a/packages/docs-examples/components/scrollbar/private-scrollbar-virtual-scroll/private-scrollbar-virtual-scroll-example.ts b/packages/docs-examples/components/scrollbar/private-scrollbar-virtual-scroll/private-scrollbar-virtual-scroll-example.ts new file mode 100644 index 0000000000..3423e5a346 --- /dev/null +++ b/packages/docs-examples/components/scrollbar/private-scrollbar-virtual-scroll/private-scrollbar-virtual-scroll-example.ts @@ -0,0 +1,54 @@ +import { ScrollingModule } from '@angular/cdk/scrolling'; +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { KbqButtonModule } from '@koobiq/components/button'; +import { KbqScrollbar, KbqScrollbarVirtualViewport } from '@koobiq/components/scrollbar/private'; + +/** + * @title Scrollbar with virtual scroll + */ +@Component({ + selector: 'private-scrollbar-virtual-scroll-example', + imports: [KbqScrollbar, KbqScrollbarVirtualViewport, ScrollingModule, KbqButtonModule], + template: ` + + +
+ +
{{ item }}
+
+
+ `, + styles: ` + :host { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--kbq-size-l); + padding: var(--kbq-size-l); + } + + .example-scrollbar { + height: 200px; + width: 200px; + border-radius: var(--kbq-size-border-radius); + background-color: var(--kbq-background-bg-secondary); + } + + .example-item { + padding: 0 var(--kbq-size-s); + line-height: 32px; + height: 32px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PrivateScrollbarVirtualScrollExample { + protected readonly items = signal(Array.from({ length: 1000 }).map((_, i) => `Item #${i}`)); + + protected addItems(): void { + const nextIndex = this.items().length; + const newItems = Array.from({ length: 100 }).map((_, i) => `Item #${nextIndex + i}`); + + this.items.update((items) => [...items, ...newItems]); + } +} diff --git a/packages/docs-examples/components/scrollbar/private-scrollbar-visibility/private-scrollbar-visibility-example.ts b/packages/docs-examples/components/scrollbar/private-scrollbar-visibility/private-scrollbar-visibility-example.ts new file mode 100644 index 0000000000..4f8e1c2f63 --- /dev/null +++ b/packages/docs-examples/components/scrollbar/private-scrollbar-visibility/private-scrollbar-visibility-example.ts @@ -0,0 +1,85 @@ +import { ChangeDetectionStrategy, Component, model } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { KbqScrollbar, KbqScrollbarVisibility } from '@koobiq/components/scrollbar/private'; +import { KbqSelectModule } from '@koobiq/components/select'; + +/** + * @title Scrollbar visibility + */ +@Component({ + selector: 'private-scrollbar-visibility-example', + imports: [KbqScrollbar, KbqSelectModule, FormsModule], + template: ` + + + @for (visibility of visibilities; track visibility) { + {{ visibility }} + } + + + +
+

+ 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 guessing + correctly. This strategy can theoretically be used to break any form of encryption that is not + information-theoretically secure.[1] However, in a properly designed cryptosystem the chance of + successfully guessing the key is negligible. +

+

+ 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 can be made less effective by implementing key stretching techniques making it more + difficult for an attacker to recognize when the code has been cracked or by making the attacker 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] +

+

+ 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] +

+
+ `, + styles: ` + :host { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--kbq-size-l); + overflow: hidden; + padding: var(--kbq-size-l); + } + + .example-form-field { + width: 200px; + } + + .example-scrollbar { + overflow: auto; + resize: both; + height: 200px; + min-height: 200px; + max-height: 400px; + width: 100%; + min-width: 200px; + max-width: 100%; + border-radius: var(--kbq-size-border-radius); + background-color: var(--kbq-background-bg-secondary); + } + + p { + width: 150%; + margin: var(--kbq-size-l); + } + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PrivateScrollbarVisibilityExample { + protected readonly visibilities: KbqScrollbarVisibility[] = ['always', 'hover', 'scroll', 'hidden'] as const; + protected readonly visibility = model('hover'); +} diff --git a/packages/docs-examples/example-module.ts b/packages/docs-examples/example-module.ts index e2923fb713..060dce8e1d 100644 --- a/packages/docs-examples/example-module.ts +++ b/packages/docs-examples/example-module.ts @@ -4676,6 +4676,45 @@ export const EXAMPLE_COMPONENTS: {[id: string]: LiveExample} = { "primaryFile": "resizer-overview-example.ts", "importPath": "components/resizer" }, + "private-scrollbar-scroll-to": { + "packagePath": "components/scrollbar/private-scrollbar-scroll-to", + "title": "Scrollbar scrollTo methods", + "componentName": "PrivateScrollbarScrollToExample", + "files": [ + "private-scrollbar-scroll-to-example.ts" + ], + "localImportFiles": [], + "selector": "private-scrollbar-scroll-to-example", + "additionalComponents": [], + "primaryFile": "private-scrollbar-scroll-to-example.ts", + "importPath": "components/scrollbar" + }, + "private-scrollbar-virtual-scroll": { + "packagePath": "components/scrollbar/private-scrollbar-virtual-scroll", + "title": "Scrollbar with virtual scroll", + "componentName": "PrivateScrollbarVirtualScrollExample", + "files": [ + "private-scrollbar-virtual-scroll-example.ts" + ], + "localImportFiles": [], + "selector": "private-scrollbar-virtual-scroll-example", + "additionalComponents": [], + "primaryFile": "private-scrollbar-virtual-scroll-example.ts", + "importPath": "components/scrollbar" + }, + "private-scrollbar-visibility": { + "packagePath": "components/scrollbar/private-scrollbar-visibility", + "title": "Scrollbar visibility", + "componentName": "PrivateScrollbarVisibilityExample", + "files": [ + "private-scrollbar-visibility-example.ts" + ], + "localImportFiles": [], + "selector": "private-scrollbar-visibility-example", + "additionalComponents": [], + "primaryFile": "private-scrollbar-visibility-example.ts", + "importPath": "components/scrollbar" + }, "scrollbar-overview": { "packagePath": "components/scrollbar/scrollbar-overview", "title": "Scrollbar with options", @@ -8160,6 +8199,12 @@ return import('@koobiq/docs-examples/components/radio'); return import('@koobiq/docs-examples/components/radio'); case 'resizer-overview': return import('@koobiq/docs-examples/components/resizer'); + case 'private-scrollbar-scroll-to': +return import('@koobiq/docs-examples/components/scrollbar'); + case 'private-scrollbar-virtual-scroll': +return import('@koobiq/docs-examples/components/scrollbar'); + case 'private-scrollbar-visibility': +return import('@koobiq/docs-examples/components/scrollbar'); case 'scrollbar-overview': return import('@koobiq/docs-examples/components/scrollbar'); case 'scrollbar-scroll-to-top': diff --git a/packages/e2e/routes.ts b/packages/e2e/routes.ts index a67e1b5f34..c0c67fd8da 100644 --- a/packages/e2e/routes.ts +++ b/packages/e2e/routes.ts @@ -1,6 +1,15 @@ import { Routes } from '@angular/router'; import { E2eFormHorizontal } from 'packages/components/core/forms/e2e'; import { E2eTypographyStyles } from 'packages/components/core/styles/typography/e2e'; +import { + E2ePrivateScrollbarContentMutation, + E2ePrivateScrollbarDrag, + E2ePrivateScrollbarHostPadding, + E2ePrivateScrollbarHoverVisibility, + E2ePrivateScrollbarKeyboard, + E2ePrivateScrollbarScrollTo, + E2ePrivateScrollbarStateAndStyle +} from 'packages/components/scrollbar/private/e2e'; import { E2eAccordionStates } from '../components/accordion/e2e'; import { E2eActionsPanelWithOverlayContainer } from '../components/actions-panel/e2e'; import { E2eAlertStateAndStyle } from '../components/alert/e2e'; @@ -210,7 +219,13 @@ const components = [ E2eMarkdownStates, E2eSearchExpandableStates, E2eInputStateAndStyle, - E2eScrollbarStateAndStyle, + E2ePrivateScrollbarStateAndStyle, + E2ePrivateScrollbarDrag, + E2ePrivateScrollbarScrollTo, + E2ePrivateScrollbarHoverVisibility, + E2ePrivateScrollbarContentMutation, + E2ePrivateScrollbarHostPadding, + E2ePrivateScrollbarKeyboard, E2eRadioStateAndStyle, E2eProgressBarStateAndStyle, E2eProgressSpinnerStates, @@ -273,7 +288,8 @@ const components = [ E2eOverflowItemsHorizontal, E2eOverflowItemsVertical, E2eOverflowItemsOrdered, - E2eOverflowItemsDynamic + E2eOverflowItemsDynamic, + E2eScrollbarStateAndStyle ]; export const e2eRoutes: Routes = components.map((component) => { diff --git a/tools/api-extractor/api-extractor.ts b/tools/api-extractor/api-extractor.ts index deccafaaf5..5f063babb0 100644 --- a/tools/api-extractor/api-extractor.ts +++ b/tools/api-extractor/api-extractor.ts @@ -34,7 +34,13 @@ function runExtractor(folder: string, component: string): ExtractorResult { .replace('components', folder) .replace('button', component); const reportFolder = configObject!.apiReport!.reportFolder!.replace('components', folder); - const reportFileName = configObject!.apiReport!.reportFileName!.replace('', component); + // `reportFileName` must be a plain filename — api-extractor rejects path separators — so a + // nested entry point like "scrollbar/private" flattens to "scrollbar-private.api.md" here, + // while `mainEntryPointFilePath` above keeps the real nested dist path unchanged. + const reportFileName = configObject!.apiReport!.reportFileName!.replace( + '', + component.replace('/', '-') + ); configObject.mainEntryPointFilePath = mainEntryPointFilePath; configObject!.apiReport!.reportFolder = reportFolder; diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index 201c7240f0..fd9a01fd3c 100644 --- a/tools/api-extractor/config.json +++ b/tools/api-extractor/config.json @@ -41,6 +41,7 @@ "radio", "resizer", "scrollbar", + "scrollbar/private", "select", "search-expandable", "sidebar", diff --git a/tools/cspell-locales/ru.json b/tools/cspell-locales/ru.json index 2cd7e24761..e777d81418 100644 --- a/tools/cspell-locales/ru.json +++ b/tools/cspell-locales/ru.json @@ -41,6 +41,7 @@ "виджете", "виджетом", "викисклада", + "вьюпорта", "гггг", "гига", "грид", @@ -187,8 +188,10 @@ "скроллбар", "скроллбара", "скроллбаров", + "скроллбары", "скролле", "скроллеру", + "скроллить", "скроллом", "снапшоты", "спиcка", diff --git a/tools/public_api_guard/components/content-panel.api.md b/tools/public_api_guard/components/content-panel.api.md index 921abdd248..990b6c4305 100644 --- a/tools/public_api_guard/components/content-panel.api.md +++ b/tools/public_api_guard/components/content-panel.api.md @@ -9,7 +9,7 @@ import { KbqButtonStyles } from '@koobiq/components/button'; import { KbqComponentColors } from '@koobiq/components/core'; import { KbqOverflowShadowContainer } from '@koobiq/components/core'; import { KbqResizerSizeChangeEvent } from '@koobiq/components/resizer'; -import { KbqScrollbar } from '@koobiq/components/scrollbar'; +import { KbqScrollbar } from '@koobiq/components/scrollbar/private'; import * as _koobiq_components_core from '@koobiq/components/core'; // @public (undocumented) diff --git a/tools/public_api_guard/components/scrollbar-private.api.md b/tools/public_api_guard/components/scrollbar-private.api.md new file mode 100644 index 0000000000..fd6243a438 --- /dev/null +++ b/tools/public_api_guard/components/scrollbar-private.api.md @@ -0,0 +1,128 @@ +## API Report File for "koobiq" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import * as _angular_core from '@angular/core'; +import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; +import { ElementRef } from '@angular/core'; +import * as i1 from '@angular/cdk/scrolling'; +import { InjectionToken } from '@angular/core'; +import { KbqOverflowShadowSource } from '@koobiq/components/core'; +import { Observable } from 'rxjs'; +import { OnDestroy } from '@angular/core'; +import { OutputEmitterRef } from '@angular/core'; +import { Provider } from '@angular/core'; +import { WritableSignal } from '@angular/core'; + +// @public +export const KBQ_SCROLLBAR_CONFIG: InjectionToken>; + +// @public +export const KBQ_SCROLLBAR_DEFAULT_CONFIG: Required; + +// @public +export class KbqScrollbar implements KbqOverflowShadowSource, OnDestroy { + constructor(); + readonly autoHideDelay: _angular_core.InputSignalWithTransform; + readonly disableClick: _angular_core.InputSignalWithTransform; + readonly disableDrag: _angular_core.InputSignalWithTransform; + readonly floating: _angular_core.InputSignalWithTransform; + getScrollElement(): HTMLElement | null; + readonly initialized: OutputEmitterRef; + readonly isBottomReached: _angular_core.Signal; + readonly isEndReached: _angular_core.Signal; + readonly isStartReached: _angular_core.Signal; + readonly isTopReached: _angular_core.Signal; + // (undocumented) + ngOnDestroy(): void; + readonly onScroll: Observable; + readonly reachBottom: OutputEmitterRef; + readonly reachEnd: OutputEmitterRef; + readonly reachStart: OutputEmitterRef; + readonly reachTop: OutputEmitterRef; + protected readonly rtl: WritableSignal; + readonly scrollChange: OutputEmitterRef; + scrollEnd(behavior?: ScrollBehavior): void; + scrollStart(behavior?: ScrollBehavior): void; + scrollTo(options: KbqScrollbarScrollToOptions): void; + scrollToBottom(behavior?: ScrollBehavior): void; + scrollToElement(target: HTMLElement | string, options?: KbqScrollbarScrollToElementOptions): void; + scrollToTop(behavior?: ScrollBehavior): void; + update(): void; + readonly updated: OutputEmitterRef; + readonly visibility: _angular_core.InputSignal; + readonly visibilityChange: OutputEmitterRef; + // (undocumented) + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + +// @public +export type KbqScrollbarConfig = Partial<{ + visibility: KbqScrollbarVisibility; + autoHideDelay: number; + floating: boolean; + native: boolean; + disableDrag: boolean; + disableClick: boolean; +}>; + +// @public +export const kbqScrollbarConfigProvider: (config: KbqScrollbarConfig) => Provider; + +// @public +export class KbqScrollbarModule { + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; + // (undocumented) + static ɵinj: _angular_core.ɵɵInjectorDeclaration; + // (undocumented) + static ɵmod: _angular_core.ɵɵNgModuleDeclaration; +} + +// @public +export type KbqScrollbarScrollChangeEvent = { + top: number; + left: number; +}; + +// @public +export type KbqScrollbarScrollToElementOptions = Partial<{ + top: number; + left: number; + behavior: ScrollBehavior; +}>; + +// @public +export type KbqScrollbarScrollToOptions = Partial<{ + top: number; + left: number; + behavior: ScrollBehavior; +}>; + +// @public +export class KbqScrollbarVirtualViewport { + readonly elementRef: ElementRef; + readonly viewport: CdkVirtualScrollViewport; + // (undocumented) + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + +// @public +export type KbqScrollbarVisibility = 'hover' | 'always' | 'scroll' | 'hidden'; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/tsconfig.json b/tsconfig.json index 7c213e4df4..ce7f010556 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -71,6 +71,7 @@ "@koobiq/components/sidepanel": ["packages/components/sidepanel/index.ts"], "@koobiq/components/skeleton": ["packages/components/skeleton/index.ts"], "@koobiq/components/scrollbar": ["packages/components/scrollbar/index.ts"], + "@koobiq/components/scrollbar/private": ["packages/components/scrollbar/private/index.ts"], "@koobiq/components/splitter": ["packages/components/splitter/index.ts"], "@koobiq/components/split-button": ["packages/components/split-button/index.ts"], "@koobiq/components/table": ["packages/components/table/index.ts"],