diff --git a/apps/docs/src/app/app.component.html b/apps/docs/src/app/app.component.html index e5a73bee6e..d110f36e0b 100644 --- a/apps/docs/src/app/app.component.html +++ b/apps/docs/src/app/app.component.html @@ -4,7 +4,6 @@ } @else { + -
+
- {{ t('welcomeTitle') }} - +
+
+ {{ t('welcomeTitle') }} +
-
- {{ t('welcomeDescription') }} -
+
+ {{ t('welcomeDescription') }} +
-@for (category of structureCategories; track category) { -
-
{{ category.name[locale()] }}
-
- @let theme = currentTheme(); + @for (category of structureCategories; track category) { +
+
{{ category.name[locale()] }}
+
+ @let theme = currentTheme(); - @for (item of category.items; track item) { - - -
{{ item.name[locale()] }}
-
- } + @for (item of category.items; track item) { + + +
{{ item.name[locale()] }}
+
+ } +
-
-} + } +
diff --git a/apps/docs/src/app/components/welcome/welcome.component.scss b/apps/docs/src/app/components/welcome/welcome.component.scss index 448caf3a06..63ae2aeaed 100644 --- a/apps/docs/src/app/components/welcome/welcome.component.scss +++ b/apps/docs/src/app/components/welcome/welcome.component.scss @@ -9,6 +9,13 @@ scroll-behavior: smooth; min-width: 0; +} + +// Padding lives on the inner wrapper, not on the scroll host, so the custom scrollbar track hugs the +// right edge instead of being pushed inward by the host's horizontal padding. +.docs-welcome__content { + display: flex; + flex-direction: column; padding-bottom: 160px; padding-left: 160px; @@ -50,7 +57,7 @@ } @media (width < 768px) { - .docs-welcome { + .docs-welcome__content { padding: var(--kbq-size-l) var(--kbq-size-l) 160px; } @@ -64,13 +71,13 @@ } @media (768px <= width < 1200px) { - .docs-welcome { + .docs-welcome__content { padding: 0 var(--kbq-size-xxl) 160px var(--kbq-size-xxl); } } @media (1200px <= width < 1920px) { - .docs-welcome { + .docs-welcome__content { padding: 0 var(--kbq-size-7xl) 160px var(--kbq-size-7xl); } } diff --git a/apps/docs/src/app/components/welcome/welcome.component.ts b/apps/docs/src/app/components/welcome/welcome.component.ts index 025879891d..893e75e2a1 100644 --- a/apps/docs/src/app/components/welcome/welcome.component.ts +++ b/apps/docs/src/app/components/welcome/welcome.component.ts @@ -5,6 +5,7 @@ import { RouterLink } from '@angular/router'; import { ThemeService } from '@koobiq/components/core'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqLinkModule } from '@koobiq/components/link'; +import { KbqScrollbarViewport } from '@koobiq/components/scrollbar'; import { fromEvent } from 'rxjs'; import { debounceTime, map } from 'rxjs/operators'; import { DocsDocStates } from 'src/app/services/doc-states'; @@ -26,8 +27,9 @@ import { DocsRegisterHeaderDirective } from '../register-header/register-header. changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { - class: 'docs-welcome kbq-scrollbar' - } + class: 'docs-welcome' + }, + hostDirectives: [KbqScrollbarViewport] }) export class DocsWelcomeComponent extends DocsLocaleState implements OnInit { private readonly themeService = inject(ThemeService); diff --git a/docs/guides/migration.en.md b/docs/guides/migration.en.md index 99a42d591a..85ec9cd936 100644 --- a/docs/guides/migration.en.md +++ b/docs/guides/migration.en.md @@ -16,6 +16,7 @@ New versions include improvements but also contain **breaking changes**; they mu 10. **20.3.0**: button supported colors — a default color of its own per style. 11. **20.3.0**: the button-toggle review — ARIA semantics, keyboard navigation and signal inputs. 12. **20.3.0**: the form-field review — signals, accessibility and the removal of `mixinColor`. +13. **20.3.0**: deprecation of the overlayscrollbars-based Scrollbar implementation. ### 1. Upgrade to 18.5.3 @@ -741,6 +742,50 @@ A receiver is matched by its explicit type annotation (`KbqFormField`, `KbqHint` **Stylesheets that fought `!important`.** `.kbq-form-field_no-borders` and `.kbq-form-field_in-overlay` used `!important` to beat the state theme; they now override the `--kbq-form-field-*` tokens instead. The computed result is the same, but an override written specifically to outrank the old `!important` can be simplified. +### 13. Scrollbar overlayscrollbars implementation deprecation (20.3.0) + +Until 20.3.0, `@koobiq/components/scrollbar` wrapped the third-party `overlayscrollbars` library: the `KbqScrollbar` component (`kbq-scrollbar` / `[kbq-scrollbar]`) and the low-level `KbqScrollbarDirective` (`[kbqScrollbar]`), with `options`, `events`, `defer` inputs and raw access to `scrollbarInstance`. + +As of 20.3.0, `@koobiq/components/scrollbar` provides a new dependency-free `KbqScrollbar` component with the `` selector and a different public API. The previous implementation has not gone away — it moved, unchanged, to `@koobiq/components/scrollbar/deprecated` and will be removed in a future major version. + +#### Running the migration + +The `scrollbar-deprecated-path` schematic runs automatically: + +```bash +ng update @koobiq/components@20 +``` + +Or manually: + +```bash +ng g @koobiq/components:scrollbar-deprecated-path --project +``` + +#### What is fixed automatically + +**The `@koobiq/components/scrollbar` import path is rewritten to `@koobiq/components/scrollbar/deprecated`** — in every `.ts` file, in both single- and double-quoted specifiers. The implementation itself and its public API (`options` / `events` / `defer` / `scrollbarInstance`, the `kbq-scrollbar` / `[kbq-scrollbar]` / `[kbqScrollbar]` selectors) are unchanged — only where you import them from changes. + +```ts +// Before +import { KbqScrollbarModule } from '@koobiq/components/scrollbar'; + +// After +import { KbqScrollbarModule } from '@koobiq/components/scrollbar/deprecated'; +``` + +#### What you need to fix manually + +**Moving to the new implementation** is a separate, manual migration, not just an import path change: the new component uses the `` selector and does not support the `[kbq-scrollbar]` or `[kbqScrollbar]` attribute selectors. Its public API differs from the previous implementation — see the [Scrollbar component documentation](/en/components/scrollbar) for details. + +**Do not import both the old and the new implementation into the same standalone component.** Both use the `kbq-scrollbar` element selector, so Angular cannot choose a component unambiguously. During a gradual manual migration, keep old and new usage in separate components. + ### After the migration +After fully moving to the new component and removing imports from `@koobiq/components/scrollbar/deprecated`, the `overlayscrollbars` dependency is no longer needed and can be removed: + +```bash +npm uninstall overlayscrollbars +``` + The migration is regex-based and does not rewrite aliased imports, local variables, or re-exports — **review the diff before committing**, rebuild the project and run your tests. The full list of breaking changes is on the [Angular 20 breaking changes](https://github.com/koobiq/angular-components/blob/main/docs/guides/angular-20-breaking-changes.en.md) page. diff --git a/docs/guides/migration.ru.md b/docs/guides/migration.ru.md index 9367bf05d6..11c9dc4eab 100644 --- a/docs/guides/migration.ru.md +++ b/docs/guides/migration.ru.md @@ -16,6 +16,7 @@ 10. **20.3.0**: поддерживаемые цвета кнопки — свой дефолтный цвет у каждого стиля. 11. **20.3.0**: ревью группы кнопок — ARIA-семантика, навигация с клавиатуры и сигнальные входы. 12. **20.3.0**: ревью поля формы — сигналы, доступность и удаление `mixinColor`. +13. **20.3.0**: устаревание overlayscrollbars-реализации Scrollbar. ### 1. Обновление до 18.5.3 @@ -741,6 +742,50 @@ if (formField.hasCleaner() && formField.hint().length && hint.fillTextOff()) { **Стили, боровшиеся с `!important`.** `.kbq-form-field_no-borders` и `.kbq-form-field_in-overlay` использовали `!important`, чтобы перебить тему состояний; теперь они переопределяют токены `--kbq-form-field-*`. Итоговое значение то же, но переопределение, написанное специально ради победы над старым `!important`, можно упростить. +### 13. Устаревание overlayscrollbars-реализации Scrollbar (20.3.0) + +До 20.3.0 `@koobiq/components/scrollbar` оборачивал стороннюю библиотеку `overlayscrollbars`: компонент `KbqScrollbar` (`kbq-scrollbar` / `[kbq-scrollbar]`) и низкоуровневая директива `KbqScrollbarDirective` (`[kbqScrollbar]`) со входами `options`, `events`, `defer` и сырым доступом к `scrollbarInstance`. + +В 20.3.0 `@koobiq/components/scrollbar` — это новый, не зависящий от сторонних библиотек компонент `KbqScrollbar` с селектором `` и другим публичным API. Прежняя реализация никуда не делась — она переехала без изменений в `@koobiq/components/scrollbar/deprecated` и будет удалена в одном из будущих мажорных релизов. + +#### Запуск миграции + +Схематик `scrollbar-deprecated-path` запускается автоматически: + +```bash +ng update @koobiq/components@20 +``` + +Или вручную: + +```bash +ng g @koobiq/components:scrollbar-deprecated-path --project +``` + +#### Что исправляется автоматически + +**Путь импорта `@koobiq/components/scrollbar` заменяется на `@koobiq/components/scrollbar/deprecated`** — во всех `.ts`-файлах, в одинарных и двойных кавычках. Сама реализация и её публичный API (`options` / `events` / `defer` / `scrollbarInstance`, селекторы `kbq-scrollbar` / `[kbq-scrollbar]` / `[kbqScrollbar]`) не меняются — меняется только путь, откуда их импортировать. + +```ts +// Было +import { KbqScrollbarModule } from '@koobiq/components/scrollbar'; + +// Стало +import { KbqScrollbarModule } from '@koobiq/components/scrollbar/deprecated'; +``` + +#### Что нужно поправить вручную + +**Переход на новую реализацию** — это отдельная, ручная миграция, а не просто смена пути импорта: новый компонент использует селектор ``, а атрибутные селекторы `[kbq-scrollbar]` и `[kbqScrollbar]` не поддерживает. Его публичный API отличается от прежнего — подробности смотрите в [документации компонента Scrollbar](/ru/components/scrollbar). + +**Не импортируйте старую и новую реализацию в одном standalone-компоненте одновременно.** Обе используют элементный селектор `kbq-scrollbar`, поэтому Angular не сможет однозначно выбрать компонент. При постепенном ручном переходе держите старое и новое использование в разных компонентах. + ### После миграции +После полного перехода на новый компонент и удаления импортов из `@koobiq/components/scrollbar/deprecated` зависимость `overlayscrollbars` больше не нужна — её можно удалить: + +```bash +npm uninstall overlayscrollbars +``` + Миграция работает на регулярных выражениях и не переписывает алиасные импорты, локальные переменные и ре-экспорты — **проверьте диф перед коммитом**, пересоберите проект и прогоните тесты. Полный список ломающих изменений — на странице [Ломающие изменения — Angular 20](https://github.com/koobiq/angular-components/blob/main/docs/guides/angular-20-breaking-changes.ru.md). diff --git a/packages/docs-examples/components/scrollbar/scrollbar-scroll-to-top/scrollbar-scroll-to-top-example.ts b/packages/components-dev/scrollbar/deprecated/scrollbar-scroll-to-top-example.ts similarity index 86% rename from packages/docs-examples/components/scrollbar/scrollbar-scroll-to-top/scrollbar-scroll-to-top-example.ts rename to packages/components-dev/scrollbar/deprecated/scrollbar-scroll-to-top-example.ts index 56ec036eb4..a607033be0 100644 --- a/packages/docs-examples/components/scrollbar/scrollbar-scroll-to-top/scrollbar-scroll-to-top-example.ts +++ b/packages/components-dev/scrollbar/deprecated/scrollbar-scroll-to-top-example.ts @@ -1,12 +1,13 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { KbqButtonModule } from '@koobiq/components/button'; -import { KbqScrollbarModule } from '@koobiq/components/scrollbar'; +import { KbqScrollbarModule } from '@koobiq/components/scrollbar/deprecated'; /** * @title Scrollbar scroll to top + * @deprecated Should be removed in a future major version. */ @Component({ - selector: 'scrollbar-scroll-to-top-example', + selector: 'dev-scrollbar-scroll-to-example', imports: [ KbqScrollbarModule, KbqButtonModule @@ -28,7 +29,7 @@ import { KbqScrollbarModule } from '@koobiq/components/scrollbar'; `, changeDetection: ChangeDetectionStrategy.OnPush }) -export class ScrollbarScrollToTopExample { +export class DevScrollbarScrollToTopExample { readonly items = Array.from({ length: 1000 }).map((_, i) => `Item #${i}`); onScroll(event): void { diff --git a/packages/docs-examples/components/scrollbar/scrollbar-with-custom-config/scrollbar-with-custom-config-example.ts b/packages/components-dev/scrollbar/deprecated/scrollbar-with-custom-config-example.ts similarity index 78% rename from packages/docs-examples/components/scrollbar/scrollbar-with-custom-config/scrollbar-with-custom-config-example.ts rename to packages/components-dev/scrollbar/deprecated/scrollbar-with-custom-config-example.ts index d35b747528..4a3a5fa8ae 100644 --- a/packages/docs-examples/components/scrollbar/scrollbar-with-custom-config/scrollbar-with-custom-config-example.ts +++ b/packages/components-dev/scrollbar/deprecated/scrollbar-with-custom-config-example.ts @@ -1,11 +1,12 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { KBQ_SCROLLBAR_CONFIG, KbqScrollbarModule, KbqScrollbarOptions } from '@koobiq/components/scrollbar'; +import { KBQ_SCROLLBAR_CONFIG, KbqScrollbarModule, KbqScrollbarOptions } from '@koobiq/components/scrollbar/deprecated'; /** * @title Scrollbar with custom KBQ_SCROLLBAR_CONFIG + * @deprecated Should be removed in a future major version. */ @Component({ - selector: 'scrollbar-with-custom-config-example', + selector: 'dev-scrollbar-with-custom-config-example', imports: [KbqScrollbarModule], template: `
@@ -27,6 +28,6 @@ import { KBQ_SCROLLBAR_CONFIG, KbqScrollbarModule, KbqScrollbarOptions } from '@ ], changeDetection: ChangeDetectionStrategy.OnPush }) -export class ScrollbarWithCustomConfigExample { +export class DevScrollbarWithCustomConfigExample { readonly items = Array.from({ length: 1000 }).map((_, i) => `Item #${i}`); } diff --git a/packages/components-dev/scrollbar/module.ts b/packages/components-dev/scrollbar/module.ts index 1de4924ec9..a72c51289a 100644 --- a/packages/components-dev/scrollbar/module.ts +++ b/packages/components-dev/scrollbar/module.ts @@ -1,105 +1,34 @@ import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core'; -import { KbqButtonModule } from '@koobiq/components/button'; import { - KBQ_SCROLLBAR_CONFIG, - KbqScrollbarEvents, - KbqScrollbarModule, - KbqScrollbarOptions -} from '@koobiq/components/scrollbar'; + ScrollbarOverviewExample, + ScrollbarScrollToExample, + ScrollbarVirtualScrollExample +} 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 + ScrollbarOverviewExample, + ScrollbarVirtualScrollExample, + ScrollbarScrollToExample ], 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 +36,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-dev/toast/module.ts b/packages/components-dev/toast/module.ts index 06bbc8bf99..ebc7fa26af 100644 --- a/packages/components-dev/toast/module.ts +++ b/packages/components-dev/toast/module.ts @@ -6,7 +6,7 @@ import { KbqIconModule } from '@koobiq/components/icon'; import { KbqLinkModule } from '@koobiq/components/link'; import { KbqModalModule, KbqModalService } from '@koobiq/components/modal'; import { KbqProgressBarModule } from '@koobiq/components/progress-bar'; -import { KbqScrollbarModule } from '@koobiq/components/scrollbar'; +import { KbqScrollbar } from '@koobiq/components/scrollbar'; import { KbqSidepanelModule, KbqSidepanelPosition, KbqSidepanelService } from '@koobiq/components/sidepanel'; import { KbqToastComponent, @@ -91,7 +91,7 @@ export class DevToastComponent extends KbqToastComponent { KbqDropdownModule, KbqModalModule, KbqSidepanelModule, - KbqScrollbarModule, + KbqScrollbar, DevToastComponent, DevDocsExamples ], diff --git a/packages/components-dev/toast/template.html b/packages/components-dev/toast/template.html index 5b74da437a..c20f643e99 100644 --- a/packages/components-dev/toast/template.html +++ b/packages/components-dev/toast/template.html @@ -1,7 +1,7 @@
-
+ @@ -231,5 +231,5 @@ -
+
diff --git a/packages/components/app-switcher/app-switcher.en.md b/packages/components/app-switcher/app-switcher.en.md index a2bff7d254..5965d659bb 100644 --- a/packages/components/app-switcher/app-switcher.en.md +++ b/packages/components/app-switcher/app-switcher.en.md @@ -1,18 +1,5 @@ A menu for switching between applications and platforms. -
-
Note
-
- -The component uses [Scrollbar](/en/components/scrollbar), so its dependencies must be installed: - -```bash -npm install overlayscrollbars@2.7.3 -``` - -
-
- ### Application icons diff --git a/packages/components/app-switcher/app-switcher.html b/packages/components/app-switcher/app-switcher.html index c717495393..246e63d2a3 100644 --- a/packages/components/app-switcher/app-switcher.html +++ b/packages/components/app-switcher/app-switcher.html @@ -23,7 +23,7 @@ the panel styling) would otherwise break that ownership chain. The scrollbar host cannot carry the menu role for the same reason - it injects its own focusable viewport element in between. --> -
+ @if (!searchControl.getRawValue()) {
@@ -193,4 +193,4 @@
} -
+ diff --git a/packages/components/app-switcher/app-switcher.ru.md b/packages/components/app-switcher/app-switcher.ru.md index 492f9a428f..adc0f82a35 100644 --- a/packages/components/app-switcher/app-switcher.ru.md +++ b/packages/components/app-switcher/app-switcher.ru.md @@ -1,18 +1,5 @@ Меню для переключения между приложениями и площадками. -
-
Обрати внимание
-
- -Компонент использует [Scrollbar](/ru/components/scrollbar), поэтому необходимо установить его зависимости: - -```bash -npm install overlayscrollbars@2.7.3 -``` - -
-
- ### Иконки приложений diff --git a/packages/components/app-switcher/app-switcher.spec.ts b/packages/components/app-switcher/app-switcher.spec.ts index f8fbcc38db..fd8c398310 100644 --- a/packages/components/app-switcher/app-switcher.spec.ts +++ b/packages/components/app-switcher/app-switcher.spec.ts @@ -1812,7 +1812,7 @@ describe('KbqAppSwitcher', () => { afterEach(() => overlayContainer?.ngOnDestroy()); - it('unsubscribes the inner-scroll guard when the host is destroyed while open', fakeAsync(() => { + it('excludes scrolls originating inside the popup from its closing actions', fakeAsync(() => { const fixture = createComponent(AppSwitcherMultiSite); overlayContainer = TestBed.inject(OverlayContainer); @@ -1823,13 +1823,31 @@ describe('KbqAppSwitcher', () => { tick(); fixture.detectChanges(); - const guard = trigger['preventClosingByInnerScrollSubscription']; + // The popup wraps its menu in ``, whose viewport is a registered `CdkScrollable`. + const innerViewport = overlayContainer + .getContainerElement() + .querySelector('.kbq-app-switcher .kbq-scrollbar-viewport') as HTMLElement; - expect(guard.closed).toBe(false); + expect(innerViewport).toBeTruthy(); - fixture.destroy(); + const emissions: unknown[] = []; + const subscription = trigger.closingActions().subscribe((event) => emissions.push(event)); + + // Scrolling the popup's own scrollbar viewport (as keyboard navigation does when it scrolls + // the focused item into view) must not count as a closing action. + innerViewport.dispatchEvent(new Event('scroll')); + tick(50); + + expect(emissions).toHaveLength(0); - expect(guard.closed).toBe(true); + // A window/ancestor scroll — one that could move the popup out of view — still is one. + window.document.dispatchEvent(new Event('scroll')); + tick(50); + + expect(emissions.length).toBeGreaterThan(0); + + subscription.unsubscribe(); + fixture.destroy(); })); it('does not throw when the popup reports hidden before it was ever shown', fakeAsync(() => { diff --git a/packages/components/app-switcher/app-switcher.ts b/packages/components/app-switcher/app-switcher.ts index 52a321fb60..5eb30d4cac 100644 --- a/packages/components/app-switcher/app-switcher.ts +++ b/packages/components/app-switcher/app-switcher.ts @@ -60,9 +60,9 @@ import { KbqDropdown, KbqDropdownItem, KbqDropdownModule } from '@koobiq/compone import { KbqIconModule } from '@koobiq/components/icon'; import { KbqInput, KbqInputModule } from '@koobiq/components/input'; import { defaultOffsetYWithArrow } from '@koobiq/components/popover'; -import { KbqScrollbarModule } from '@koobiq/components/scrollbar'; -import { Subscription, merge } from 'rxjs'; -import { auditTime, distinctUntilChanged, startWith } from 'rxjs/operators'; +import { KbqScrollbar } from '@koobiq/components/scrollbar'; +import { merge } from 'rxjs'; +import { auditTime, distinctUntilChanged, filter, startWith } from 'rxjs/operators'; import { kbqAppSwitcherAnimations } from './app-switcher-animations'; import { KbqAppSwitcherDropdownApp } from './app-switcher-dropdown-app'; import { KbqAppSwitcherDropdownSite } from './app-switcher-dropdown-site'; @@ -243,7 +243,7 @@ export function kbqAppSwitcherProvider(): Provider[] { KbqDividerModule, KbqBadgeModule, KbqDropdownModule, - KbqScrollbarModule, + KbqScrollbar, KbqOptionModule, KbqAppSwitcherDropdownApp, KbqAppSwitcherDropdownSite, @@ -801,9 +801,6 @@ export class KbqAppSwitcherTrigger }; } - /** @docs-private */ - protected preventClosingByInnerScrollSubscription: Subscription; - private readonly originalSitesSignal = signal([]); private readonly groupBySignal = signal(defaultGroupBy); @@ -869,23 +866,11 @@ export class KbqAppSwitcherTrigger } }); + // On close, return focus to the trigger. Inner-scroll close suppression lives in + // `closingActions()` (it filters scrolls originating inside the popup), so no per-visibility + // subscription is needed here. this.visibleChange.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((visible: boolean) => { - if (visible) { - // Scrolling inside the popup reaches the root `ScrollDispatcher` and would otherwise be - // treated as a closing action; flag those events instead of closing. - // `takeUntilDestroyed` covers the case where the host is removed while the popup is open, - // which the `else` branch below never sees. - this.preventClosingByInnerScrollSubscription = this.closingActions() - .pipe(takeUntilDestroyed(this.destroyRef)) - // eslint-disable-next-line rxjs-x/no-nested-subscribe - .subscribe((event) => { - if (event['scrollDispatcher']) { - event['kbqPopoverPreventHide'] = true; - event['type'] = 'click'; - } - }); - } else { - this.preventClosingByInnerScrollSubscription?.unsubscribe(); + if (!visible) { this.focus(); } }); @@ -922,7 +907,19 @@ export class KbqAppSwitcherTrigger return merge( this.overlayRef!.outsidePointerEvents(), this.overlayRef!.backdropClick(), - this.scrollDispatcher.scrolled() + // Only an outer/ancestor scroll that moves the popup out of view should close it. Scrolling + // the popup's own content must not: its `KbqScrollbar` viewport is a `CdkScrollable`, so it + // reaches the root `ScrollDispatcher`, and keyboard navigation scrolls the focused item into + // view through that viewport - without this filter every arrow key would close the panel. + this.scrollDispatcher.scrolled().pipe(filter((scrollable) => !this.isInnerScroll(scrollable))) + ); + } + + /** Whether a `ScrollDispatcher` emission originates from inside this popup's own scrollable content. */ + private isInnerScroll(scrollable: CdkScrollable | void): boolean { + return ( + scrollable instanceof CdkScrollable && + !!scrollable.getElementRef().nativeElement.closest('.kbq-app-switcher, .kbq-app-switcher-sites') ); } 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..8d6822abf7 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'; import { SizeL } from '@koobiq/design-tokens'; const KBQ_CONTENT_PANEL_CONTAINER_CONTENT_ANIMATION = trigger('contentAnimation', [ @@ -139,14 +139,12 @@ export class KbqContentPanelHeader { @Component({ selector: 'kbq-content-panel-body', - imports: [KbqScrollbarModule, KbqOverflowShadowContainer], + imports: [KbqScrollbar, KbqOverflowShadowContainer], template: ` - - + +
+ +
`, styleUrl: './content-panel-body.scss', @@ -206,7 +204,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,10 +215,12 @@ 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-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-scrollbar-viewport').evaluate((el) => { el.scrollTop = Math.floor((el.scrollHeight - el.clientHeight) / 2); }); diff --git a/packages/components/core/styles/_koobiq-theme.scss b/packages/components/core/styles/_koobiq-theme.scss index 1f541c76bb..6383b884c7 100644 --- a/packages/components/core/styles/_koobiq-theme.scss +++ b/packages/components/core/styles/_koobiq-theme.scss @@ -21,6 +21,7 @@ // link is a directive, so styles for it should be applied globally @include link-theme.kbq-link-theme(); @include kbq-markdown-theme(); + // @deprecated Will be removed in next major release. @include kbq-scrollbar-theme(); @include kbq-tabs-theme(); @include kbq-tag-theme(); diff --git a/packages/components/core/styles/theming/_scrollbar-theme.scss b/packages/components/core/styles/theming/_scrollbar-theme.scss index ece1944ca7..966e4f65e5 100644 --- a/packages/components/core/styles/theming/_scrollbar-theme.scss +++ b/packages/components/core/styles/theming/_scrollbar-theme.scss @@ -1,3 +1,6 @@ +// @deprecated The `.kbq-scrollbar` selector and related mixins are deprecated. +// Use the `KbqScrollbar` component instead. Will be removed in the next major version. + /* stylelint-disable selector-pseudo-class-no-unknown */ @use '../common/tokens'; @@ -18,6 +21,8 @@ } @mixin _kbq-scrollbar() { + @warn 'The `.kbq-scrollbar` selector and related mixins/tokens are deprecated. Use the `KbqScrollbar` component instead. Will be removed in the next major version.'; + // For Firefox compatibility @supports not selector(::-webkit-scrollbar) { /* stylelint-disable-next-line no-invalid-position-declaration */ diff --git a/packages/components/core/styles/theming/scrollbar-tokens.scss b/packages/components/core/styles/theming/scrollbar-tokens.scss index 5bb28ff53e..3e57741832 100644 --- a/packages/components/core/styles/theming/scrollbar-tokens.scss +++ b/packages/components/core/styles/theming/scrollbar-tokens.scss @@ -1,3 +1,6 @@ +// @deprecated The `--kbq-scrollbar-*` tokens are deprecated. +// Use the `KbqScrollbar` component instead. Will be removed in the next major version. + :where(.kbq-scrollbar) { --kbq-scrollbar-size-track-dimension: var(--kbq-size-l); --kbq-scrollbar-size-track-padding-vertical: var(--kbq-size-3xs); diff --git a/packages/components/notification-center/notification-center.en.md b/packages/components/notification-center/notification-center.en.md index 60a7205d50..91e8b931af 100644 --- a/packages/components/notification-center/notification-center.en.md +++ b/packages/components/notification-center/notification-center.en.md @@ -1,18 +1,5 @@ Notification center — a panel for application notifications. -
-
Note
-
- -The component uses [Scrollbar](/en/components/scrollbar), so its dependencies must be installed: - -```bash -npm install overlayscrollbars@2.7.3 -``` - -
-
- The notification list opens from the main menu. The menu shows an unread message counter: it is hidden when the count is 0, and displays "99+" when the count exceeds 99. diff --git a/packages/components/notification-center/notification-center.html b/packages/components/notification-center/notification-center.html index 1db6935527..1da7b00196 100644 --- a/packages/components/notification-center/notification-center.html +++ b/packages/components/notification-center/notification-center.html @@ -50,13 +50,13 @@
@if (!service.errorMode.value) { @if (!service.loadingMode.value) { diff --git a/packages/components/notification-center/notification-center.ru.md b/packages/components/notification-center/notification-center.ru.md index 0fd37c35b9..7200df2d1e 100644 --- a/packages/components/notification-center/notification-center.ru.md +++ b/packages/components/notification-center/notification-center.ru.md @@ -1,18 +1,5 @@ Notification center — панель уведомлений о работе приложений -
-
Обрати внимание
-
- -Компонент использует [Scrollbar](/ru/components/scrollbar), поэтому необходимо установить его зависимости: - -```bash -npm install overlayscrollbars@2.7.3 -``` - -
-
- Список уведомлений открывается из главного меню. В меню показывается счётчик непрочитанных сообщений: при 0 он скрыт, при количестве больше 99 отображается «99+». diff --git a/packages/components/notification-center/notification-center.scss b/packages/components/notification-center/notification-center.scss index a4350ebf60..9db72f6b11 100644 --- a/packages/components/notification-center/notification-center.scss +++ b/packages/components/notification-center/notification-center.scss @@ -85,22 +85,25 @@ } .kbq-notification-center-container { + // Overrides the default z-index of .kbq-scrollbar-viewport to ensure the scrollbar track is always above the .kbq-notification-center-sub-header content. + --kbq-scrollbar-track-z-index: 101; + height: 100%; + overflow: auto; + border-radius: inherit; padding-bottom: var(--kbq-size-xl); // Smoothly reveal the just-appended "load more" spinner / error row. The row is scrolled into - // view via KbqScrollbar.scrollTo(), which calls a behavior-less scroll on the overlayscrollbars - // viewport — so `scroll-behavior` on the viewport controls the animation. Disabled for users who - // prefer reduced motion. - & [data-overlayscrollbars-viewport] { - scroll-behavior: smooth; - - @media (prefers-reduced-motion: reduce) { - scroll-behavior: auto; - } + // view via KbqScrollbarViewport.scrollTo(), which calls a behavior-less scroll on this element + // (it's the scroll container itself now that KbqScrollbarViewport is applied to it directly). + // Disabled for users who prefer reduced motion. + scroll-behavior: smooth; + + @media (prefers-reduced-motion: reduce) { + scroll-behavior: auto; } & .kbq-loader-overlay_parent { diff --git a/packages/components/notification-center/notification-center.spec.ts b/packages/components/notification-center/notification-center.spec.ts index 1bf0b09973..8f50d620bd 100644 --- a/packages/components/notification-center/notification-center.spec.ts +++ b/packages/components/notification-center/notification-center.spec.ts @@ -11,7 +11,6 @@ import { KbqNotificationCenterTrigger, KbqNotificationItem } from '@koobiq/components/notification-center'; -import { KbqScrollbarModule } from '@koobiq/components/scrollbar'; import { KbqToastService } from '@koobiq/components/toast'; import { AsyncScheduler } from 'rxjs/internal/scheduler/AsyncScheduler'; import { TestScheduler } from 'rxjs/testing'; @@ -46,7 +45,7 @@ describe('KbqNotificationCenter', () => { const createComponent = (component: Type, providers: Provider[] = []): ComponentFixture => { TestBed.configureTestingModule({ - imports: [component, NoopAnimationsModule, KbqLuxonDateModule, KbqFormattersModule, KbqScrollbarModule], + imports: [component, NoopAnimationsModule, KbqLuxonDateModule, KbqFormattersModule], providers: [ { provide: AsyncScheduler, useValue: testScheduler }, ...providers @@ -60,11 +59,12 @@ describe('KbqNotificationCenter', () => { }; describe('Check test cases', () => { - // jsdom does not implement Element.prototype.scroll; the container reveal calls it via - // KbqScrollbar.scrollTo. Stub it only when it's missing so a real implementation is never shadowed. + // jsdom does not implement Element.prototype.scrollTo; the container reveal calls it via + // KbqScrollbarViewport.scrollTo (CdkScrollable). Stub it only when it's missing so a real + // implementation is never shadowed. beforeAll(() => { - if (!HTMLElement.prototype.scroll) { - Object.defineProperty(HTMLElement.prototype, 'scroll', { configurable: true, value: () => {} }); + if (!HTMLElement.prototype.scrollTo) { + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, value: () => {} }); } }); @@ -115,7 +115,7 @@ describe('KbqNotificationCenter', () => { componentInstance.trigger() as unknown as { instance: { scrollContainer: () => { - contentElement: () => { nativeElement: HTMLElement }; + getNativeElement: () => HTMLElement; scrollTo: (options?: ScrollToOptions) => void; }; onContainerScroll: () => void; @@ -123,10 +123,10 @@ describe('KbqNotificationCenter', () => { } ).instance; - // Fakes the container geometry. Both `scrollContainer` and `contentElement` are signal - // queries, so they must be called to reach the native element. + // Fakes the container geometry. `scrollContainer` is a signal query, so it must be called + // to reach the native element. const setGeometry = (geometry: { scrollHeight: number; clientHeight: number; scrollTop: number }) => { - const element = getCenter().scrollContainer().contentElement().nativeElement; + const element = getCenter().scrollContainer().getNativeElement(); Object.defineProperty(element, 'scrollHeight', { configurable: true, value: geometry.scrollHeight }); Object.defineProperty(element, 'clientHeight', { configurable: true, value: geometry.clientHeight }); diff --git a/packages/components/notification-center/notification-center.ts b/packages/components/notification-center/notification-center.ts index 4c590b7617..585d4aa7a7 100644 --- a/packages/components/notification-center/notification-center.ts +++ b/packages/components/notification-center/notification-center.ts @@ -47,7 +47,7 @@ import { KbqDropdownModule } from '@koobiq/components/dropdown'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqLoaderOverlayModule } from '@koobiq/components/loader-overlay'; import { KbqProgressSpinnerModule } from '@koobiq/components/progress-spinner'; -import { KbqScrollbar, KbqScrollbarModule } from '@koobiq/components/scrollbar'; +import { KbqScrollbarViewport } from '@koobiq/components/scrollbar'; import { KbqToolTipModule } from '@koobiq/components/tooltip'; import { BehaviorSubject, Subject, Subscription, merge } from 'rxjs'; import { auditTime, distinctUntilChanged, filter, map, pairwise } from 'rxjs/operators'; @@ -104,7 +104,7 @@ export const KBQ_NOTIFICATION_CENTER_SCROLL_STRATEGY_FACTORY_PROVIDER = { imports: [ KbqIconModule, KbqBadgeModule, - KbqScrollbarModule, + KbqScrollbarViewport, KbqButtonModule, KbqDividerModule, KbqDropdownModule, @@ -173,7 +173,7 @@ export class KbqNotificationCenterComponent extends KbqPopUp implements AfterVie readonly switcher = viewChild.required('notificationSwitcher'); /** Scrollable list container; used to measure scroll position for infinite scroll. */ - private readonly scrollContainer = viewChild.required(KbqScrollbar); + private readonly scrollContainer = viewChild.required(KbqScrollbarViewport); get popoverHeight(): string { return this._popoverHeight; @@ -275,7 +275,7 @@ export class KbqNotificationCenterComponent extends KbqPopUp implements AfterVie /** Whether the list is scrolled to within `scrolledToBottomOffset` pixels of the bottom. * Sub-pixel measurement error is absorbed by `SCROLLED_TO_BOTTOM_TOLERANCE`. */ private isScrolledToBottom(): boolean { - const { scrollTop, clientHeight, scrollHeight } = this.scrollContainer().contentElement().nativeElement; + const { scrollTop, clientHeight, scrollHeight } = this.scrollContainer().getNativeElement(); return scrollHeight - scrollTop - clientHeight <= this.scrolledToBottomOffset + SCROLLED_TO_BOTTOM_TOLERANCE; } @@ -311,13 +311,13 @@ export class KbqNotificationCenterComponent extends KbqPopUp implements AfterVie /** Scrolls the list container to its bottom so a freshly-appended bottom row becomes visible. */ private scrollToBottom(): void { - const { scrollHeight } = this.scrollContainer().contentElement().nativeElement; + const { scrollHeight } = this.scrollContainer().getNativeElement(); this.scrollContainer().scrollTo({ top: scrollHeight }); } private focusScrollContainer(): void { - const element = this.scrollContainer().contentElement().nativeElement; + const element = this.scrollContainer().getNativeElement(); // tabindex -1 keeps the container out of the Tab order while allowing programmatic focus. element.setAttribute('tabindex', '-1'); diff --git a/packages/components/scrollbar/__screenshots__/01-dark.png b/packages/components/scrollbar/__screenshots__/01-dark.png index a9c36b5a69..62034bac03 100644 Binary files a/packages/components/scrollbar/__screenshots__/01-dark.png and b/packages/components/scrollbar/__screenshots__/01-dark.png differ diff --git a/packages/components/scrollbar/__screenshots__/01-light.png b/packages/components/scrollbar/__screenshots__/01-light.png index 4d96aff3df..51e85b08f2 100644 Binary files a/packages/components/scrollbar/__screenshots__/01-light.png and b/packages/components/scrollbar/__screenshots__/01-light.png differ diff --git a/packages/components/scrollbar/deprecated/__screenshots__/01-dark.png b/packages/components/scrollbar/deprecated/__screenshots__/01-dark.png new file mode 100644 index 0000000000..a9c36b5a69 Binary files /dev/null and b/packages/components/scrollbar/deprecated/__screenshots__/01-dark.png differ diff --git a/packages/components/scrollbar/deprecated/__screenshots__/01-light.png b/packages/components/scrollbar/deprecated/__screenshots__/01-light.png new file mode 100644 index 0000000000..4d96aff3df Binary files /dev/null and b/packages/components/scrollbar/deprecated/__screenshots__/01-light.png differ diff --git a/packages/components/scrollbar/_scrollbar-component-theme.scss b/packages/components/scrollbar/deprecated/_scrollbar-component-theme.scss similarity index 97% rename from packages/components/scrollbar/_scrollbar-component-theme.scss rename to packages/components/scrollbar/deprecated/_scrollbar-component-theme.scss index ec8035ecc6..5aa2d77697 100644 --- a/packages/components/scrollbar/_scrollbar-component-theme.scss +++ b/packages/components/scrollbar/deprecated/_scrollbar-component-theme.scss @@ -1,4 +1,4 @@ -@use '../core/styles/common/tokens' as *; +@use '../../core/styles/common/tokens' as *; @mixin kbq-scrollbar-component-theme() { .kbq-scrollbar-component { diff --git a/packages/components/scrollbar/deprecated/e2e.playwright-spec.ts b/packages/components/scrollbar/deprecated/e2e.playwright-spec.ts new file mode 100644 index 0000000000..c801b06fac --- /dev/null +++ b/packages/components/scrollbar/deprecated/e2e.playwright-spec.ts @@ -0,0 +1,21 @@ +import { expect, Locator, Page, test } from '@playwright/test'; +import { e2eEnableDarkTheme } from '../../../e2e/utils'; + +test.use({ browserName: 'webkit' }); + +test.describe('KbqScrollbar (deprecated)', () => { + test.describe('E2eDeprecatedScrollbarStateAndStyle', () => { + const getComponent = (page: Page) => page.getByTestId('e2eDeprecatedScrollbarStateAndStyle'); + const getTestTable = (locator: Locator) => locator.getByTestId('e2eScrollbarTable'); + + test('states', async ({ page }) => { + await page.goto('/E2eDeprecatedScrollbarStateAndStyle'); + const locator = getComponent(page); + const screenshotTarget = getTestTable(locator); + + await expect(screenshotTarget).toHaveScreenshot('01-light.png'); + await e2eEnableDarkTheme(page); + await expect(screenshotTarget).toHaveScreenshot('01-dark.png'); + }); + }); +}); diff --git a/packages/components/scrollbar/deprecated/e2e.ts b/packages/components/scrollbar/deprecated/e2e.ts new file mode 100644 index 0000000000..c991b7aa03 --- /dev/null +++ b/packages/components/scrollbar/deprecated/e2e.ts @@ -0,0 +1,73 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { KbqScrollbarModule } from './scrollbar.module'; +import { KBQ_SCROLLBAR_CONFIG, KbqScrollbarOptions } from './scrollbar.types'; + +type ScrollbarState = { + state: 'default' | 'disabled' | 'active' | 'hover'; +}; + +@Component({ + selector: 'e2e-deprecated-scrollbar-state-and-style', + imports: [ + KbqScrollbarModule + ], + template: ` +
+ + @for (row of states; track $index) { + + + + + } +
+
+ @for (item of items; track item) { +
{{ item }}
+
+ } +
+
+
+ @for (item of items; track item) { +
{{ item }}
+
+ } +
+
+
+ `, + providers: [ + { + provide: KBQ_SCROLLBAR_CONFIG, + useValue: { + scrollbars: { + autoHide: 'never' + } + } satisfies KbqScrollbarOptions + } + ], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2eDeprecatedScrollbarStateAndStyle' + } +}) +export class E2eDeprecatedScrollbarStateAndStyle { + readonly items = Array.from({ length: 25 }).map((_, i) => `Item #${i}`); + + readonly states: ScrollbarState[] = [ + { state: 'default' }, + { state: 'hover' }, + { state: 'active' } + ]; +} diff --git a/packages/components/scrollbar/deprecated/index.ts b/packages/components/scrollbar/deprecated/index.ts new file mode 100644 index 0000000000..7e1a213e3e --- /dev/null +++ b/packages/components/scrollbar/deprecated/index.ts @@ -0,0 +1 @@ +export * from './public-api'; diff --git a/packages/components/scrollbar/deprecated/ng-package.json b/packages/components/scrollbar/deprecated/ng-package.json new file mode 100644 index 0000000000..bebf62dcb5 --- /dev/null +++ b/packages/components/scrollbar/deprecated/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "index.ts" + } +} diff --git a/packages/components/scrollbar/deprecated/public-api.ts b/packages/components/scrollbar/deprecated/public-api.ts new file mode 100644 index 0000000000..7a9c54992a --- /dev/null +++ b/packages/components/scrollbar/deprecated/public-api.ts @@ -0,0 +1,4 @@ +export * from './scrollbar.component'; +export * from './scrollbar.directive'; +export * from './scrollbar.module'; +export * from './scrollbar.types'; diff --git a/packages/components/scrollbar/scrollbar-tokens.scss b/packages/components/scrollbar/deprecated/scrollbar-tokens.scss similarity index 100% rename from packages/components/scrollbar/scrollbar-tokens.scss rename to packages/components/scrollbar/deprecated/scrollbar-tokens.scss diff --git a/packages/components/scrollbar/scrollbar.component.scss b/packages/components/scrollbar/deprecated/scrollbar.component.scss similarity index 99% rename from packages/components/scrollbar/scrollbar.component.scss rename to packages/components/scrollbar/deprecated/scrollbar.component.scss index 21960db384..0f65225e99 100644 --- a/packages/components/scrollbar/scrollbar.component.scss +++ b/packages/components/scrollbar/deprecated/scrollbar.component.scss @@ -1,4 +1,4 @@ -@use '../core/styles/common/tokens' as *; +@use '../../core/styles/common/tokens' as *; @use './scrollbar-component-theme' as *; .kbq-scrollbar-component { diff --git a/packages/components/scrollbar/scrollbar.component.spec.ts b/packages/components/scrollbar/deprecated/scrollbar.component.spec.ts similarity index 100% rename from packages/components/scrollbar/scrollbar.component.spec.ts rename to packages/components/scrollbar/deprecated/scrollbar.component.spec.ts diff --git a/packages/components/scrollbar/scrollbar.component.ts b/packages/components/scrollbar/deprecated/scrollbar.component.ts similarity index 96% rename from packages/components/scrollbar/scrollbar.component.ts rename to packages/components/scrollbar/deprecated/scrollbar.component.ts index 83d8670da3..114aa8c877 100644 --- a/packages/components/scrollbar/scrollbar.component.ts +++ b/packages/components/scrollbar/deprecated/scrollbar.component.ts @@ -42,6 +42,11 @@ const filterEvents = (emits: KbqScrollbarEvents, events: KbqScrollbarEvents) => ); /** + * @deprecated Wraps `overlayscrollbars`. Superseded by the dependency-free + * `@koobiq/components/scrollbar`. Will be removed in a future major version — see + * `@koobiq/components/scrollbar/deprecated`'s migration path via `ng update`. + * @docs-private + * * The component-wrapper for `overlayscrollbars` library. */ @Component({ diff --git a/packages/components/scrollbar/scrollbar.directive.ts b/packages/components/scrollbar/deprecated/scrollbar.directive.ts similarity index 96% rename from packages/components/scrollbar/scrollbar.directive.ts rename to packages/components/scrollbar/deprecated/scrollbar.directive.ts index 1081574c96..3f13ecc8e3 100644 --- a/packages/components/scrollbar/scrollbar.directive.ts +++ b/packages/components/scrollbar/deprecated/scrollbar.directive.ts @@ -50,6 +50,10 @@ const createDefer = (): Defer => { }; /** + * @deprecated Wraps `overlayscrollbars`. Superseded by the dependency-free + * `@koobiq/components/scrollbar`. Will be removed in a future major version. + * @docs-private + * * A directive for adding `overlayscrollbars` to an element. */ @Directive({ diff --git a/packages/components/scrollbar/deprecated/scrollbar.module.ts b/packages/components/scrollbar/deprecated/scrollbar.module.ts new file mode 100644 index 0000000000..f218886843 --- /dev/null +++ b/packages/components/scrollbar/deprecated/scrollbar.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; +import { KbqScrollbar } from './scrollbar.component'; +import { KbqScrollbarDirective } from './scrollbar.directive'; +import { KBQ_SCROLLBAR_OPTIONS_DEFAULT_CONFIG_PROVIDER } from './scrollbar.types'; + +const COMPONENTS = [ + KbqScrollbar, + KbqScrollbarDirective +]; + +/** + * @deprecated Wraps `overlayscrollbars`. Superseded by the dependency-free + * `@koobiq/components/scrollbar`. Will be removed in a future major version — an `ng update` + * migration rewrites `@koobiq/components/scrollbar` imports of this module to + * `@koobiq/components/scrollbar/deprecated` automatically. + */ +@NgModule({ + imports: COMPONENTS, + providers: [KBQ_SCROLLBAR_OPTIONS_DEFAULT_CONFIG_PROVIDER], + exports: COMPONENTS +}) +export class KbqScrollbarModule {} diff --git a/packages/components/scrollbar/scrollbar.types.ts b/packages/components/scrollbar/deprecated/scrollbar.types.ts similarity index 100% rename from packages/components/scrollbar/scrollbar.types.ts rename to packages/components/scrollbar/deprecated/scrollbar.types.ts diff --git a/packages/components/scrollbar/e2e.playwright-spec.ts b/packages/components/scrollbar/e2e.playwright-spec.ts index f5176eb561..7f04707dfc 100644 --- a/packages/components/scrollbar/e2e.playwright-spec.ts +++ b/packages/components/scrollbar/e2e.playwright-spec.ts @@ -1,21 +1,417 @@ import { expect, Locator, Page, test } from '@playwright/test'; -import { e2eEnableDarkTheme } from '../../e2e/utils'; - -test.use({ browserName: 'webkit' }); +import { e2eEnableDarkTheme } from 'packages/e2e/utils'; test.describe('KbqScrollbar', () => { test.describe('E2eScrollbarStateAndStyle', () => { const getComponent = (page: Page) => page.getByTestId('e2eScrollbarStateAndStyle'); - const getTestTable = (locator: Locator) => locator.getByTestId('e2eScrollbarTable'); + const getScrollbar = (page: Page) => getComponent(page).locator('kbq-scrollbar'); + const getThumb = (page: Page) => + getComponent(page).locator('.kbq-scrollbar-track__bar_vertical .kbq-scrollbar-track__thumb'); + const getHorizontalThumb = (page: Page) => + getComponent(page).locator('.kbq-scrollbar-track__bar_horizontal .kbq-scrollbar-track__thumb'); + + // Resolves a CSS custom property the same way the browser would resolve it as a `background-color` + // (via a disposable probe element in the same cascade scope), rather than comparing against the + // custom property's raw text — which isn't guaranteed to be formatted the same way `getComputedStyle` + // normalizes an actual `background-color` (e.g. `rgba(0,0,0,.5)` vs `rgba(0, 0, 0, 0.5)`). + const resolveBackgroundColorVar = (locator: Locator, variableName: string) => + locator.evaluate((el, name) => { + const probe = document.createElement('div'); + + probe.style.backgroundColor = `var(${name})`; + el.appendChild(probe); + + const color = getComputedStyle(probe).backgroundColor; + + probe.remove(); - test('states', async ({ page }) => { + return color; + }, variableName); + + test.beforeEach(async ({ page }) => { await page.goto('/E2eScrollbarStateAndStyle'); - const locator = getComponent(page); - const screenshotTarget = getTestTable(locator); + }); + + test('shows the track on hover', async ({ page }) => { + const component = getComponent(page); + + await getScrollbar(page).hover(); - await expect(screenshotTarget).toHaveScreenshot('01-light.png'); + await expect(component).toHaveScreenshot('01-light.png'); await e2eEnableDarkTheme(page); - await expect(screenshotTarget).toHaveScreenshot('01-dark.png'); + await expect(component).toHaveScreenshot('01-dark.png'); + }); + + test('hovering the thumb applies --kbq-scrollbar-thumb-hover-background', async ({ page }) => { + const thumb = getThumb(page); + + await getScrollbar(page).hover(); // reveals the track — mode defaults to "hover" + const expected = await resolveBackgroundColorVar(thumb, '--kbq-scrollbar-thumb-hover-background'); + + await thumb.hover(); + + await expect(thumb).toHaveCSS('background-color', expected); + }); + + test('vertical thumb is 14px wide and horizontal thumb is 14px tall', async ({ page }) => { + await getScrollbar(page).hover(); + + const verticalThumb = getThumb(page); + const horizontalThumb = getHorizontalThumb(page); + + await expect(verticalThumb).toBeVisible(); + await expect(horizontalThumb).toBeVisible(); + + expect(await verticalThumb.evaluate((el) => el.getBoundingClientRect().width)).toBe(14); + expect(await horizontalThumb.evaluate((el) => el.getBoundingClientRect().height)).toBe(14); + }); + + test('pressing the thumb applies --kbq-scrollbar-thumb-active-background', async ({ page }) => { + const thumb = getThumb(page); + + await getScrollbar(page).hover(); + const expected = await resolveBackgroundColorVar(thumb, '--kbq-scrollbar-thumb-active-background'); + + await thumb.hover(); + await page.mouse.down(); + + await expect(thumb).toHaveCSS('background-color', expected); + + await page.mouse.up(); + }); + }); + + test.describe('E2eScrollbarTrack', () => { + const getScrollbarY = (page: Page) => page.getByTestId('e2eScrollbarTrackY'); + const getScrollbarX = (page: Page) => page.getByTestId('e2eScrollbarTrackX'); + const getScrollbarXY = (page: Page) => page.getByTestId('e2eScrollbarTrackXY'); + const getVerticalBar = (scrollbar: Locator) => scrollbar.locator('.kbq-scrollbar-track__bar_vertical'); + const getHorizontalBar = (scrollbar: Locator) => scrollbar.locator('.kbq-scrollbar-track__bar_horizontal'); + + // Insets of `bar` relative to `` — the containing block its `position: + // absolute` insets (`inset-block`/`inset-inline`) actually resolve against — not the outer + // `` host, which pads its content instead. + const insetsOf = async (scrollbar: Locator, bar: Locator) => { + const track = scrollbar.locator('kbq-scrollbar-track'); + const [trackRect, barRect] = await Promise.all([ + track.evaluate((el) => el.getBoundingClientRect()), + bar.evaluate((el) => el.getBoundingClientRect()) + ]); + + return { + top: barRect.top - trackRect.top, + bottom: trackRect.bottom - barRect.bottom, + left: barRect.left - trackRect.left, + right: trackRect.right - barRect.right + }; + }; + + test.beforeEach(async ({ page }) => { + await page.goto('/E2eScrollbarTrack'); + }); + + test('a vertical-only track spans the full height, flush with the top and bottom edges', async ({ page }) => { + const scrollbar = getScrollbarY(page); + + await scrollbar.hover(); + + const inset = await insetsOf(scrollbar, getVerticalBar(scrollbar)); + + expect(inset.top).toBe(0); + expect(inset.bottom).toBe(0); + }); + + test('a horizontal-only track spans the full width, flush with the left and right edges', async ({ page }) => { + const scrollbar = getScrollbarX(page); + + await scrollbar.hover(); + + const inset = await insetsOf(scrollbar, getHorizontalBar(scrollbar)); + + expect(inset.left).toBe(0); + expect(inset.right).toBe(0); + }); + + test('the vertical and horizontal tracks meet at the corner without overlapping', async ({ page }) => { + const scrollbar = getScrollbarXY(page); + + await scrollbar.hover(); + + const vertical = await getVerticalBar(scrollbar).evaluate((el) => el.getBoundingClientRect()); + const horizontal = await getHorizontalBar(scrollbar).evaluate((el) => el.getBoundingClientRect()); + + expect(vertical.bottom).toBeLessThanOrEqual(horizontal.top); + expect(horizontal.right).toBeLessThanOrEqual(vertical.left); + }); + + test('when both tracks are visible, each reserves exactly the other track’s thickness at the corner', async ({ + page + }) => { + const scrollbar = getScrollbarXY(page); + + await scrollbar.hover(); + + const vertical = await insetsOf(scrollbar, getVerticalBar(scrollbar)); + const horizontal = await insetsOf(scrollbar, getHorizontalBar(scrollbar)); + + expect(vertical.top).toBe(0); + expect(vertical.bottom).toBe(14); + expect(horizontal.left).toBe(0); + expect(horizontal.right).toBe(14); + }); + }); + + test.describe('E2eScrollbarMode', () => { + const getScrollbar = (page: Page) => page.getByTestId('e2eScrollbarModeTarget'); + const getTrack = (page: Page) => getScrollbar(page).locator('kbq-scrollbar-track'); + const setMode = (page: Page, mode: string) => page.getByTestId(`mode-${mode}`).click(); + + test.beforeEach(async ({ page }) => { + await page.goto('/E2eScrollbarMode'); + // Keep the pointer away from the scrollbar so :hover doesn't mask mode-driven visibility. + await page.mouse.move(0, 0); + }); + + test('hover mode: track exists but stays hidden until hovered', async ({ page }) => { + await setMode(page, 'hover'); + const track = getTrack(page); + + await expect(track).toBeAttached(); + await expect(track).toHaveCSS('opacity', '0'); + + await getScrollbar(page).hover(); + await expect(track).toHaveCSS('opacity', '1'); + }); + + test('always mode: track is visible without hovering', async ({ page }) => { + await setMode(page, 'always'); + const track = getTrack(page); + + await expect(track).toBeAttached(); + await expect(track).toHaveCSS('opacity', '1'); + }); + + test('native mode: no custom track is rendered', async ({ page }) => { + await setMode(page, 'native'); + + await expect(getTrack(page)).not.toBeAttached(); + }); + + test('hidden mode: no custom track is rendered and the native scrollbar is hidden', async ({ page }) => { + await setMode(page, 'hidden'); + + await expect(getTrack(page)).not.toBeAttached(); + await expect(getScrollbar(page)).toHaveClass(/kbq-scrollbar-viewport_native-scrollbar-hidden/); + }); + + test('reacts to repeated mode switches on the same track instance, not just the first one', async ({ + page + }) => { + // `showTrack()` stays true across hover<->always, so is never + // destroyed/recreated here — this is what exposes a stale (non-reactive) mode input, + // unlike switching through native/hidden, which recreates the component either way. + const track = getTrack(page); + + await expect(track).toHaveCSS('opacity', '0'); // starts at the default 'hover' mode + + await setMode(page, 'always'); + await expect(track).toHaveCSS('opacity', '1'); + + await setMode(page, 'hover'); + await expect(track).toHaveCSS('opacity', '0'); + + await setMode(page, 'always'); + await expect(track).toHaveCSS('opacity', '1'); + }); + + test('hover mode: clicking a focusable descendant with the mouse does not keep the track visible after the pointer leaves', async ({ + page + }) => { + // Regression test: the track used to stay visible relying on `:focus-within`, which also + // matches DOM focus left behind by a mouse click (e.g. a dropdown item), not just keyboard + // navigation. Fixed by keying off `.cdk-keyboard-focused` instead. + await setMode(page, 'hover'); + const track = getTrack(page); + + // `force: true`: the horizontal thumb overlaps the button (the shared fixture content + // overflows both axes), which is irrelevant here — only the resulting focus origin matters. + await page.getByTestId('e2eScrollbarModeFocusable').click({ force: true }); + await page.mouse.move(0, 0); + + await expect(track).toHaveCSS('opacity', '0'); + }); + + test('hover mode: keyboard-focusing a plain descendant keeps the track visible', async ({ page }) => { + // The button isn't individually wired up to `FocusMonitor` — `KbqScrollbarViewport` monitors + // its whole subtree, so this works for any projected content, not just components that opt in. + await setMode(page, 'hover'); + const track = getTrack(page); + + // `Locator.focus()` is program-origin, not keyboard-origin — real Tab navigation is required + // so `FocusMonitor` classifies the resulting focus as `cdk-keyboard-focused`. Nothing between + // the last mode button and the target is tabbable, so a single Tab reaches it. + await page.getByTestId('mode-hidden').focus(); + await page.keyboard.press('Tab'); + + await expect(track).toHaveCSS('opacity', '1'); + }); + + test('hover mode: keyboard-scrolling via a focused descendant shows the track', async ({ page }) => { + // macOS-style expectation: the track should appear while the content is actively being + // scrolled from the keyboard. Tabbing to (and, belt-and-suspenders, arrow-key-scrolling past) + // a focused-but-non-scroll-handling element scrolls the nearest scrollable ancestor natively, + // even though DOM focus itself never leaves that element. + await setMode(page, 'hover'); + const track = getTrack(page); + const viewport = getScrollbar(page); + + const before = await viewport.evaluate((el) => el.scrollTop); + + await page.getByTestId('mode-hidden').focus(); + await page.keyboard.press('Tab'); + await expect(page.getByTestId('e2eScrollbarModeFocusable')).toBeFocused(); + await page.keyboard.press('ArrowDown'); + + await expect.poll(() => viewport.evaluate((el) => el.scrollTop)).toBeGreaterThan(before); + + await expect(track).toHaveCSS('opacity', '1'); + }); + }); + + test.describe('E2eScrollbarScrollTo', () => { + const getScrollbar = (page: Page) => page.getByTestId('e2eScrollbarScrollToTarget'); + const scrollTop = (page: Page) => getScrollbar(page).evaluate((el) => el.scrollTop); + const scrollLeft = (page: Page) => getScrollbar(page).evaluate((el) => el.scrollLeft); + + test.beforeEach(async ({ page }) => { + await page.goto('/E2eScrollbarScrollTo'); + }); + + test('scrollToBottom/scrollToTop scroll the vertical axis to the exact edges', async ({ page }) => { + // 5 blocks * 150px = 750px content, 150px viewport => 600px of vertical scroll range. + expect(await scrollTop(page)).toBe(0); + + await page.getByTestId('scroll-bottom').click(); + await expect.poll(() => scrollTop(page)).toBe(600); + + await page.getByTestId('scroll-top').click(); + await expect.poll(() => scrollTop(page)).toBe(0); + }); + + test('scrollEnd/scrollStart scroll the horizontal axis to the exact edges', async ({ page }) => { + // Each block is 300px wide inside a 150px viewport => 150px of horizontal scroll range. + expect(await scrollLeft(page)).toBe(0); + + await page.getByTestId('scroll-end').click(); + await expect.poll(() => scrollLeft(page)).toBe(150); + + await page.getByTestId('scroll-start').click(); + await expect.poll(() => scrollLeft(page)).toBe(0); + }); + + test('scrollToElement scrolls the target to the top of the viewport', async ({ page }) => { + // The target is the 3rd of 5 equal 150px blocks, so its offsetTop is exactly 300px. + await page.getByTestId('scroll-to-element').click(); + + await expect.poll(() => scrollTop(page)).toBe(300); + }); + }); + + test.describe('E2eScrollbarVirtualScroll', () => { + const getViewport = (page: Page) => page.getByTestId('e2eScrollbarVirtualScrollTarget'); + const getThumb = (page: Page) => getViewport(page).locator('.kbq-scrollbar-track__thumb'); + const thumbMetrics = (page: Page) => + getThumb(page).evaluate((el) => ({ top: parseFloat(el.style.top), height: parseFloat(el.style.height) })); + + test.beforeEach(async ({ page }) => { + await page.goto('/E2eScrollbarVirtualScroll'); + }); + + test('thumb shrinks and rises when items are appended while scrolled to the bottom', async ({ page }) => { + const viewport = getViewport(page); + + // Scroll to the bottom of the initial 20 items (20 * 32px = 640px content, 200px viewport). + await viewport.evaluate((el) => el.scrollTo({ top: el.scrollHeight })); + + // The thumb's own top/height are updated via a separately throttled RAF stream, not + // synchronously with the scroll event — wait for it to actually catch up before sampling, + // or `before` can be captured mid-flight and make the assertions below flaky. + await expect.poll(async () => (await thumbMetrics(page)).top).toBeGreaterThan(0); + + const before = await thumbMetrics(page); + + await page.getByTestId('add-items').click(); + + // scrollTop stays put while scrollHeight grows, so both the thumb's proportional size and + // its proportional offset shrink — the thumb visually rises and shrinks, exactly like macOS + // when content is appended below the current scroll position. + await expect.poll(async () => (await thumbMetrics(page)).height).toBeLessThan(before.height); + await expect.poll(async () => (await thumbMetrics(page)).top).toBeLessThan(before.top); + }); + + test('adding items does not shrink the thumb below its minimum size', async ({ page }) => { + await page.getByTestId('add-items').click(); + await page.getByTestId('add-items').click(); + await page.getByTestId('add-items').click(); + + const thumb = getThumb(page); + const boxHeight = () => thumb.evaluate((el) => el.getBoundingClientRect().height); + + // 32px visible min-size + 3px border on each side (--kbq-scrollbar-thumb-gap in scrollbar-viewport.scss). + await expect.poll(boxHeight).toBeGreaterThanOrEqual(38); + }); + + test('the thumb does not overhang the track when its CSS-enforced min size kicks in at the end of the scroll range', async ({ + page + }) => { + // Enough items that the natural (proportional) thumb size falls well under the + // CSS-enforced minimum, so `min-block-size` in scrollbar-track.scss clamps the rendered + // thumb larger than the `top`/`height` percentages alone would produce. + await page.getByTestId('add-items').click(); + await page.getByTestId('add-items').click(); + await page.getByTestId('add-items').click(); + + const viewport = getViewport(page); + + await viewport.evaluate((el) => el.scrollTo({ top: el.scrollHeight })); + await expect.poll(async () => (await thumbMetrics(page)).top).toBeGreaterThan(0); + + const bar = viewport.locator('.kbq-scrollbar-track__bar_vertical'); + const [barRect, thumbRect] = await Promise.all([ + bar.evaluate((el) => el.getBoundingClientRect()), + getThumb(page).evaluate((el) => el.getBoundingClientRect()) + ]); + + // Sub-pixel tolerance only, for the `height` percentage's own rounding (see + // `getViewFraction`) — not the multi-pixel overhang a wrong compensation formula produces. + expect(thumbRect.bottom).toBeLessThanOrEqual(barRect.bottom + 1); + }); + }); + + test.describe('E2eScrollbarNested', () => { + const getOuter = (page: Page) => page.getByTestId('e2eScrollbarNestedOuter'); + const getInner = (page: Page) => page.getByTestId('e2eScrollbarNestedInner'); + const outerScrollTop = (page: Page) => getOuter(page).evaluate((el) => el.scrollTop); + const innerScrollTop = (page: Page) => getInner(page).evaluate((el) => el.scrollTop); + + test.beforeEach(async ({ page }) => { + await page.goto('/E2eScrollbarNested'); + }); + + test('scrolling the outer scrollbar does not move the inner one', async ({ page }) => { + // Outer content: 200px spacer + 150px inner + 200px spacer = 550px in a 200px viewport. + await getOuter(page).evaluate((el) => el.scrollTo({ top: el.scrollHeight })); + + await expect.poll(() => outerScrollTop(page)).toBe(350); + expect(await innerScrollTop(page)).toBe(0); + }); + + test('scrolling the inner scrollbar does not move the outer one', async ({ page }) => { + // Inner content: 5 * 150px blocks = 750px in a 150px viewport. + await getInner(page).evaluate((el) => el.scrollTo({ top: el.scrollHeight })); + + await expect.poll(() => innerScrollTop(page)).toBe(600); + expect(await outerScrollTop(page)).toBe(0); }); }); }); diff --git a/packages/components/scrollbar/e2e.ts b/packages/components/scrollbar/e2e.ts index 063786a815..6508bda843 100644 --- a/packages/components/scrollbar/e2e.ts +++ b/packages/components/scrollbar/e2e.ts @@ -1,73 +1,297 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { KbqScrollbarModule } from './scrollbar.module'; -import { KBQ_SCROLLBAR_CONFIG, KbqScrollbarOptions } from './scrollbar.types'; - -type ScrollbarState = { - state: 'default' | 'disabled' | 'active' | 'hover'; -}; +import { ScrollingModule } from '@angular/cdk/scrolling'; +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { KbqScrollbar, KbqScrollbarMode, KbqScrollbarViewport } from './scrollbar'; @Component({ selector: 'e2e-scrollbar-state-and-style', - imports: [ - KbqScrollbarModule - ], + imports: [KbqScrollbar], template: ` -
- - @for (row of states; track $index) { - - - - - } -
-
- @for (item of items; track item) { -
{{ item }}
-
- } -
-
-
- @for (item of items; track item) { -
{{ item }}
-
- } -
-
-
+ +

content

+
+ `, + styles: ` + :host { + display: inline-block; + padding: var(--kbq-size-xs); + } + + .e2e-scrollbar { + width: 100px; + 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: [ - { - provide: KBQ_SCROLLBAR_CONFIG, - useValue: { - scrollbars: { - autoHide: 'never' - } - } satisfies KbqScrollbarOptions - } - ], changeDetection: ChangeDetectionStrategy.OnPush, host: { 'data-testid': 'e2eScrollbarStateAndStyle' } }) -export class E2eScrollbarStateAndStyle { - readonly items = Array.from({ length: 25 }).map((_, i) => `Item #${i}`); - - readonly states: ScrollbarState[] = [ - { state: 'default' }, - { state: 'hover' }, - { state: 'active' } - ]; +export class E2eScrollbarStateAndStyle {} + +@Component({ + selector: 'e2e-scrollbar-track', + imports: [KbqScrollbar], + template: ` + +

track Y

+
+ + +

track X

+
+ + +

track X and Y

+
+ `, + styles: ` + :host { + display: inline-flex; + gap: var(--kbq-size-m); + padding: var(--kbq-size-xs); + } + + .e2e-scrollbar { + --kbq-scrollbar-track-background: cyan; + --kbq-scrollbar-thumb-default-background: orange; + + width: 125px; + height: 125px; + background-color: var(--kbq-background-bg-secondary); + } + + p { + margin: var(--kbq-size-l); + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2eScrollbarTrack' + } +}) +export class E2eScrollbarTrack {} + +@Component({ + selector: 'e2e-scrollbar-mode', + imports: [KbqScrollbar], + template: ` +
+ @for (m of modes; track m) { + + } +
+ + +

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: 200%; + height: 200%; + margin: var(--kbq-size-l); + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2eScrollbarMode' + } +}) +export class E2eScrollbarMode { + protected readonly modes: KbqScrollbarMode[] = ['hover', 'always', 'native', 'hidden']; + protected readonly mode = signal('hover'); + + protected modeTestId(mode: KbqScrollbarMode): string { + return `mode-${mode}`; + } } + +@Component({ + selector: 'e2e-scrollbar-scroll-to', + imports: [KbqScrollbar], + template: ` +
+ + + + + +
+ + +
1
+
2
+
3
+
4
+
5
+
+ `, + styles: ` + :host { + display: block; + padding: var(--kbq-size-xs); + } + + .e2e-buttons { + display: flex; + gap: var(--kbq-size-s); + } + + .e2e-scrollbar { + width: 150px; + height: 150px; + border-radius: var(--kbq-size-border-radius); + background-color: var(--kbq-background-bg-secondary); + } + + .e2e-block { + box-sizing: border-box; + width: 300px; + height: 150px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2eScrollbarScrollTo' + } +}) +export class E2eScrollbarScrollTo {} + +@Component({ + selector: 'e2e-scrollbar-virtual-scroll', + imports: [KbqScrollbarViewport, ScrollingModule], + template: ` + + + + @for (item of items(); track item) { +
{{ item }}
+ } +
+ `, + styles: ` + :host { + display: block; + padding: var(--kbq-size-xs); + } + + .e2e-scrollbar { + --kbq-scrollbar-track-background: cyan; + --kbq-scrollbar-thumb-default-background: orange; + + width: 200px; + height: 200px; + background-color: var(--kbq-background-bg-secondary); + } + + .e2e-item { + box-sizing: border-box; + height: 32px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2eScrollbarVirtualScroll' + } +}) +export class E2eScrollbarVirtualScroll { + protected readonly items = signal(Array.from({ length: 20 }).map((_, i) => `Item #${i}`)); + + protected addItems(): void { + const nextIndex = this.items().length; + const newItems = Array.from({ length: 50 }).map((_, i) => `Item #${nextIndex + i}`); + + this.items.update((items) => [...items, ...newItems]); + } +} + +@Component({ + selector: 'e2e-scrollbar-nested', + imports: [KbqScrollbar], + template: ` + +
+ + +
1
+
2
+
3
+
4
+
5
+
+ +
+
+ `, + styles: ` + :host { + display: block; + padding: var(--kbq-size-xs); + } + + .e2e-outer { + width: 300px; + height: 200px; + border-radius: var(--kbq-size-border-radius); + background-color: var(--kbq-background-bg-secondary); + } + + .e2e-outer-spacer { + box-sizing: border-box; + height: 200px; + } + + .e2e-inner { + width: 250px; + height: 150px; + background-color: var(--kbq-background-bg-tertiary); + } + + .e2e-inner-block { + box-sizing: border-box; + width: 100%; + height: 150px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2eScrollbarNested' + } +}) +export class E2eScrollbarNested {} diff --git a/packages/components/scrollbar/examples.scrollbar.en.md b/packages/components/scrollbar/examples.scrollbar.en.md index d6f3d9fc4e..2faef3848c 100644 --- a/packages/components/scrollbar/examples.scrollbar.en.md +++ b/packages/components/scrollbar/examples.scrollbar.en.md @@ -1 +1,5 @@ - +🚧 **Documentation in progress** 🚧 + +Unfortunately, the documentation for this section is not ready yet. We are actively working on its creation and plan to add it soon. + +If you would like to contribute to the documentation or have any questions, please feel free to [open an issue](https://github.com/koobiq/angular-components/issues) in our GitHub repository. diff --git a/packages/components/scrollbar/examples.scrollbar.ru.md b/packages/components/scrollbar/examples.scrollbar.ru.md index d6f3d9fc4e..b7a203c6ab 100644 --- a/packages/components/scrollbar/examples.scrollbar.ru.md +++ b/packages/components/scrollbar/examples.scrollbar.ru.md @@ -1 +1,5 @@ - +🚧 **Документация в процессе написания** 🚧 + +К сожалению, документация для этого раздела еще не готова. Мы активно работаем над ее созданием и планируем добавить в ближайшее время. + +Если вы хотите помочь в написании документации или у вас есть вопросы, пожалуйста, [создайте issue](https://github.com/koobiq/angular-components/issues) в нашем репозитории на GitHub. diff --git a/packages/components/scrollbar/index.ts b/packages/components/scrollbar/index.ts index 7e1a213e3e..17863d47fe 100644 --- a/packages/components/scrollbar/index.ts +++ b/packages/components/scrollbar/index.ts @@ -1 +1 @@ -export * from './public-api'; +export * from './scrollbar'; diff --git a/packages/components/scrollbar/public-api.ts b/packages/components/scrollbar/public-api.ts index 7a9c54992a..983c1f6eca 100644 --- a/packages/components/scrollbar/public-api.ts +++ b/packages/components/scrollbar/public-api.ts @@ -1,4 +1,2 @@ -export * from './scrollbar.component'; -export * from './scrollbar.directive'; +export * from './scrollbar'; export * from './scrollbar.module'; -export * from './scrollbar.types'; diff --git a/packages/components/scrollbar/scrollbar-track.scss b/packages/components/scrollbar/scrollbar-track.scss new file mode 100644 index 0000000000..9a0fed3643 --- /dev/null +++ b/packages/components/scrollbar/scrollbar-track.scss @@ -0,0 +1,102 @@ +:host { + position: sticky; + z-index: var(--kbq-scrollbar-track-z-index, 1); + display: block; + // When the viewport host is a flex container, the track is a flex sibling of the (typically tall) + // content. Without this it keeps `flex-shrink: 1` and, having no intrinsic min-height, absorbs all + // the container's negative free space — collapsing to 0 height so the bar/thumb never render. The + // negative `margin-inline-end`/`margin-block-end` already cancels its layout contribution, so + // pinning shrink to 0 is layout-neutral (and ignored entirely in a block container). + flex-shrink: 0; + inset-block-start: 0; + inset-inline-start: 0; + min-inline-size: calc(100% - 1px); + max-inline-size: calc(100% - 1px); + margin-inline-end: calc(-100% + 1px); + pointer-events: none; + overflow: hidden; + // Lets a consumer theme one scrollbar instance without a class hook to target — the track may be + // created dynamically (see KbqScrollbarViewport) rather than declared in a consumer's own template. + color: var(--kbq-scrollbar-color, currentColor); +} + +@keyframes kbq-scrollbar-fade { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.kbq-scrollbar-track__bar { + position: absolute; + inset-inline-end: 0; + background: var(--kbq-scrollbar-track-background, transparent); + pointer-events: auto; +} + +.kbq-scrollbar-track__bar_enter, +.kbq-scrollbar-track__bar_leave { + animation: kbq-scrollbar-fade var(--kbq-scrollbar-transition-duration) ease-in-out; +} + +.kbq-scrollbar-track__bar_leave { + animation-direction: reverse; +} + +.kbq-scrollbar-track__bar_vertical { + inset-block: 0; + inline-size: var(--kbq-scrollbar-track-size); +} + +.kbq-scrollbar-track__bar_horizontal { + inset-block-end: 0; + inset-inline-start: 0; + block-size: var(--kbq-scrollbar-track-size); +} + +.kbq-scrollbar-track__bar_has-horizontal { + inset-block-end: var(--kbq-scrollbar-track-size); +} + +.kbq-scrollbar-track__bar_has-vertical { + inset-inline-end: var(--kbq-scrollbar-track-size); +} + +.kbq-scrollbar-track__thumb { + transition: background-color var(--kbq-scrollbar-transition-duration) ease; + position: absolute; + // The thumb has no explicit cross-axis size of its own: `inset-inline: 0`/`inset-block: 0` below + // stretch it to fill the bar's `--kbq-scrollbar-track-size` cross-axis, border-box included. + // Transparent border (not margin) carves out the gap from the bar's edges — box-sizing: border-box + // keeps the border inside the thumb's own hit area, and background-clip: content-box keeps the + // visible fill out of it, so the painted thumb is smaller than its clickable/draggable box. + border: var(--kbq-scrollbar-thumb-gap) solid transparent; + border-radius: var(--kbq-size-border-radius); + box-sizing: border-box; + background-color: var(--kbq-scrollbar-thumb-default-background); + background-clip: content-box; + cursor: default; + user-select: none; + pointer-events: auto; +} + +.kbq-scrollbar-track__thumb:hover { + background-color: var(--kbq-scrollbar-thumb-hover-background); +} + +.kbq-scrollbar-track__thumb:active { + background-color: var(--kbq-scrollbar-thumb-active-background); +} + +.kbq-scrollbar-track__bar_vertical .kbq-scrollbar-track__thumb { + inset-inline: 0; + min-block-size: calc(var(--kbq-scrollbar-thumb-min-size) + var(--kbq-scrollbar-thumb-gap) * 2); +} + +.kbq-scrollbar-track__bar_horizontal .kbq-scrollbar-track__thumb { + inset-block: 0; + min-inline-size: calc(var(--kbq-scrollbar-thumb-min-size) + var(--kbq-scrollbar-thumb-gap) * 2); +} diff --git a/packages/components/scrollbar/scrollbar-viewport.scss b/packages/components/scrollbar/scrollbar-viewport.scss new file mode 100644 index 0000000000..db515d8056 --- /dev/null +++ b/packages/components/scrollbar/scrollbar-viewport.scss @@ -0,0 +1,43 @@ +:where(.kbq-scrollbar-viewport) { + --kbq-scrollbar-thumb-min-size: var(--kbq-size-3xl); + --kbq-scrollbar-thumb-gap: 3px; + --kbq-scrollbar-track-size: 14px; + --kbq-scrollbar-track-z-index: 1; + --kbq-scrollbar-transition-duration: 0.15s; + --kbq-scrollbar-thumb-default-background: var(--kbq-semantic-contrast-a3, var(--kbq-palette-grey-50-a32)); + --kbq-scrollbar-thumb-hover-background: var(--kbq-semantic-contrast-a4, var(--kbq-palette-grey-50-a48)); + --kbq-scrollbar-thumb-active-background: var(--kbq-semantic-contrast-a5, var(--kbq-palette-grey-20-a55)); + --kbq-scrollbar-track-background: transparent; +} + +:where(.kbq-dark .kbq-scrollbar-viewport) { + --kbq-scrollbar-thumb-default-background: var(--kbq-semantic-dark-contrast-a9, var(--kbq-palette-grey-50-a48)); + --kbq-scrollbar-thumb-hover-background: var(--kbq-semantic-dark-contrast-a10, var(--kbq-palette-grey-50-a60)); + --kbq-scrollbar-thumb-active-background: var(--kbq-semantic-dark-contrast-a11, var(--kbq-palette-grey-50-a80)); +} + +.kbq-scrollbar-viewport_native-scrollbar-hidden { + scrollbar-width: none; + -ms-overflow-style: none; +} + +.kbq-scrollbar-viewport_native-scrollbar-hidden::-webkit-scrollbar, +.kbq-scrollbar-viewport_native-scrollbar-hidden::-webkit-scrollbar-thumb { + display: none; +} + +.kbq-scrollbar-track_hover:not(:active) { + transition: opacity var(--kbq-scrollbar-transition-duration) ease; + opacity: 0; +} + +// `:focus-within` also matches DOM focus a mouse click leaves behind on a descendant (e.g. a dropdown +// item) after the pointer leaves, keeping the track visible indefinitely. `cdk-keyboard-focused` is +// scoped to keyboard-originated focus (`KbqScrollbarViewport` monitors its subtree via +// `cdkMonitorSubtreeFocus`), so the track stays hidden for mouse focus but visible while, e.g., arrow +// keys scroll the content. +.kbq-scrollbar-viewport:hover > .kbq-scrollbar-track_hover, +.kbq-scrollbar-viewport.cdk-keyboard-focused > .kbq-scrollbar-track_hover { + transition: opacity var(--kbq-scrollbar-transition-duration) ease; + opacity: 1; +} diff --git a/packages/components/scrollbar/scrollbar.en.md b/packages/components/scrollbar/scrollbar.en.md index 105897bd94..e3b65f23e1 100644 --- a/packages/components/scrollbar/scrollbar.en.md +++ b/packages/components/scrollbar/scrollbar.en.md @@ -1,37 +1,34 @@ -`` is a component used to configure scrollbar parameters. +`KbqScrollbar` adds a customizable scrollbar to a scrollable content area. Scrolling uses the browser's native mechanism, preserving mouse wheel, touch gesture, and keyboard controls. -
-
Note
-
+## Display mode -For the component to work, the [`overlayscrollbars@2.7.3`](https://github.com/KingSora/OverlayScrollbars/tree/v2.7.0) dependency is required: +The `mode` input controls how the scrollbar is displayed: -```bash -npm install overlayscrollbars@2.7.3 -``` +- `hover` — shows the scrollbar on pointer hover or keyboard focus. This is the default mode. +- `always` — always shows the scrollbar when the content overflows its container. +- `native` — shows the browser's native scrollbar. +- `hidden` — hides the scrollbar while keeping the content scrollable. -
-
+Use `kbqScrollbarOptionsProvider` to change the default mode for the application or a specific dependency injection scope. -## Configuration and passing parameters: + -- for a specific scrollbar, using the `options` attribute: +## Virtual scroll - +Apply the `kbqScrollbarViewport` directive to `cdk-virtual-scroll-viewport` to add a custom scrollbar. The directive supports the same display modes. + + + +## Programmatic scrolling -- for all scrollbars in a module, using _Dependency Injection_ with the `KBQ_SCROLLBAR_CONFIG` token: +Access the component through its `kbqScrollbar` export and use its public methods: - +- `scrollTo` — scrolls to specified coordinates; +- `scrollToTop` and `scrollToBottom` — scroll to the start or end of the vertical axis; +- `scrollStart` and `scrollEnd` — scroll to the logical start or end of the horizontal axis, respecting RTL; +- `scrollToElement` — scrolls to an element or CSS selector with optional offsets; +- `scrollIntoView` — centers an element within the viewport. -### Event handling: +Methods that accept a `behavior` parameter support the native `auto` and `smooth` scrolling behaviors. Scroll events are available through `scrollChanges`. -```ts - - ... - -``` + diff --git a/packages/components/scrollbar/scrollbar.module.ts b/packages/components/scrollbar/scrollbar.module.ts index 31ba23bd7e..ceb1836ba7 100644 --- a/packages/components/scrollbar/scrollbar.module.ts +++ b/packages/components/scrollbar/scrollbar.module.ts @@ -1,16 +1,14 @@ import { NgModule } from '@angular/core'; -import { KbqScrollbar } from './scrollbar.component'; -import { KbqScrollbarDirective } from './scrollbar.directive'; -import { KBQ_SCROLLBAR_OPTIONS_DEFAULT_CONFIG_PROVIDER } from './scrollbar.types'; +import { KbqScrollbar, KbqScrollbarViewport } from './scrollbar'; const COMPONENTS = [ KbqScrollbar, - KbqScrollbarDirective + KbqScrollbarViewport ]; +/** @docs-private */ @NgModule({ imports: COMPONENTS, - providers: [KBQ_SCROLLBAR_OPTIONS_DEFAULT_CONFIG_PROVIDER], exports: COMPONENTS }) export class KbqScrollbarModule {} diff --git a/packages/components/scrollbar/scrollbar.ru.md b/packages/components/scrollbar/scrollbar.ru.md index 7128e41a89..509f3c7ab8 100644 --- a/packages/components/scrollbar/scrollbar.ru.md +++ b/packages/components/scrollbar/scrollbar.ru.md @@ -1,37 +1,34 @@ -`` - это компонент который используется для настройки параметров скроллбара. +`KbqScrollbar` добавляет настраиваемый скроллбар к области с прокручиваемым содержимым. Прокрутка выполняется нативным механизмом браузера, поэтому сохраняется управление колёсиком мыши, жестами и клавиатурой. -
-
Обрати внимание
-
+## Режим отображения -Для работы компонента, необходимо наличие [`overlayscrollbars@2.7.3`](https://github.com/KingSora/OverlayScrollbars/tree/v2.7.0) зависимости: +Режим задаётся входным параметром `mode`: -```bash -npm install overlayscrollbars@2.7.3 -``` +- `hover` — скроллбар появляется при наведении указателя или клавиатурном фокусе. Используется по умолчанию. +- `always` — скроллбар отображается постоянно, если содержимое выходит за границы области. +- `native` — отображается системный скроллбар браузера. +- `hidden` — скроллбар скрыт, но содержимое можно прокручивать. -
-
+Режим по умолчанию для приложения или отдельной области DI можно изменить с помощью `kbqScrollbarOptionsProvider`. -## Настройка и передача параметров: + -- для определенного скроллбара, при помощи атрибута `options`: +## Виртуальный скролл - +Чтобы добавить кастомный скроллбар к `cdk-virtual-scroll-viewport`, примените к нему директиву `kbqScrollbarViewport`. Директива поддерживает те же режимы отображения. + + + +## Программное управление прокруткой -- для всех скроллбаров в модуле, при помощи _Dependency Injection_ c использованием `KBQ_SCROLLBAR_CONFIG` токена: +Получите компонент через экспорт `kbqScrollbar` и используйте его публичные методы: - +- `scrollTo` — прокрутить до заданных координат; +- `scrollToTop` и `scrollToBottom` — прокрутить к началу или концу вертикальной оси; +- `scrollStart` и `scrollEnd` — прокрутить к логическому началу или концу горизонтальной оси с учётом RTL; +- `scrollToElement` — прокрутить до элемента или CSS-селектора с необязательными отступами; +- `scrollIntoView` — расположить элемент в центре области просмотра. -### Обработка событий: +В методах с параметром `behavior` можно выбрать нативное поведение прокрутки `auto` или `smooth`. События прокрутки доступны через `scrollChanges`. -```ts - - ... - -``` + diff --git a/packages/components/scrollbar/scrollbar.scss b/packages/components/scrollbar/scrollbar.scss new file mode 100644 index 0000000000..49019e5589 --- /dev/null +++ b/packages/components/scrollbar/scrollbar.scss @@ -0,0 +1,24 @@ +:host { + position: relative; + display: flex; + // Lets the host fill available space along whichever axis is the parent's main axis + // (cross-axis sizing is already handled by the parent's default `align-items: stretch`), + // so it behaves like a block element regardless of the parent's layout mode. + flex: 1; + min-inline-size: 0; + min-block-size: 0; + max-block-size: 100%; + isolation: isolate; + overflow: auto; +} + +.kbq-scrollbar__content { + isolation: isolate; + flex: 1; + flex-basis: auto; + inline-size: 100%; + // A hard 100% (not `min-block-size`) so a `height: 100%` chain inside projected content (e.g. a + // grid that needs a definite pixel height to render at all) resolves correctly — content taller + // than the host still overflows past it normally and is measured via `scrollHeight` regardless. + block-size: 100%; +} diff --git a/packages/components/scrollbar/scrollbar.spec.ts b/packages/components/scrollbar/scrollbar.spec.ts new file mode 100644 index 0000000000..2a76dc722e --- /dev/null +++ b/packages/components/scrollbar/scrollbar.spec.ts @@ -0,0 +1,945 @@ +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { Component, ElementRef, Provider, Type, viewChild } from '@angular/core'; +import { ComponentFixture, discardPeriodicTasks, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { dispatchMouseEvent, KbqOverflowShadowContainer } from '@koobiq/components/core'; +import { KbqScrollbar, KbqScrollbarMode, kbqScrollbarOptionsProvider, KbqScrollbarViewport } from './scrollbar'; + +const createComponent = (component: Type, providers: Provider[] = []): ComponentFixture => { + TestBed.configureTestingModule({ imports: [component], providers }); + const fixture = TestBed.createComponent(component); + + fixture.autoDetectChanges(); + + return fixture; +}; + +type ElementMetrics = Partial< + Record< + | 'clientHeight' + | 'scrollHeight' + | 'clientWidth' + | 'scrollWidth' + | 'offsetHeight' + | 'offsetWidth' + | 'scrollTop' + | 'scrollLeft', + number + > +>; + +const setMetrics = (el: HTMLElement, metrics: ElementMetrics): void => { + for (const [key, value] of Object.entries(metrics)) { + Object.defineProperty(el, key, { configurable: true, value }); + } +}; + +/** `CdkScrollable` lives on ``'s own element injector (via `KbqScrollbarViewport`'s hostDirectives), not the test host root. */ +const getScrollable = (fixture: ComponentFixture): CdkScrollable => + fixture.debugElement.query(By.directive(KbqScrollbar)).injector.get(CdkScrollable); + +const setRect = (el: HTMLElement, rect: Partial): void => { + jest.spyOn(el, 'getBoundingClientRect').mockReturnValue({ + top: 0, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => ({}), + ...rect + } as DOMRect); +}; + +describe(KbqScrollbar.name, () => { + describe('options', () => { + @Component({ + selector: 'test-scrollbar-default-options', + imports: [KbqScrollbar], + template: ` + content + ` + }) + class TestScrollbarDefaultOptions { + readonly scrollbar = viewChild.required(KbqScrollbar); + } + + it('defaults mode to "hover"', () => { + const fixture = createComponent(TestScrollbarDefaultOptions); + + expect(fixture.componentInstance.scrollbar().mode()).toBe('hover'); + }); + + it('honors kbqScrollbarOptionsProvider at the injector level', () => { + const fixture = createComponent(TestScrollbarDefaultOptions, [ + kbqScrollbarOptionsProvider({ mode: 'always' }) + ]); + + expect(fixture.componentInstance.scrollbar().mode()).toBe('always'); + }); + + it('lets a per-instance [mode] override the injected default', () => { + @Component({ + selector: 'test-scrollbar-mode-override', + imports: [KbqScrollbar], + template: ` + content + ` + }) + class TestScrollbarModeOverride { + readonly scrollbar = viewChild.required(KbqScrollbar); + } + + const fixture = createComponent(TestScrollbarModeOverride, [ + kbqScrollbarOptionsProvider({ mode: 'always' }) + ]); + + expect(fixture.componentInstance.scrollbar().mode()).toBe('native'); + }); + }); + + describe('mode-driven rendering', () => { + @Component({ + selector: 'test-scrollbar-mode', + imports: [KbqScrollbar], + template: ` + content + ` + }) + class TestScrollbarMode { + mode: KbqScrollbarMode = 'hover'; + } + + const getHost = (fixture: ComponentFixture) => + fixture.nativeElement.querySelector('kbq-scrollbar'); + const getTrack = (fixture: ComponentFixture) => + fixture.nativeElement.querySelector('kbq-scrollbar-track'); + + it.each<[KbqScrollbarMode, boolean]>([ + ['hover', true], + ['always', true], + ['native', false], + ['hidden', false] + ])('renders the custom track for mode="%s": %s', (mode, expected) => { + const fixture = createComponent(TestScrollbarMode); + + fixture.componentInstance.mode = mode; + fixture.detectChanges(); + + expect(!!getTrack(fixture)).toBe(expected); + }); + + it.each<[KbqScrollbarMode, boolean]>([ + ['hover', true], + ['always', true], + ['native', false], + ['hidden', true] + ])('sets kbq-scrollbar-viewport_native-scrollbar-hidden for mode="%s": %s', (mode, expected) => { + const fixture = createComponent(TestScrollbarMode); + + fixture.componentInstance.mode = mode; + fixture.detectChanges(); + + expect(getHost(fixture).classList.contains('kbq-scrollbar-viewport_native-scrollbar-hidden')).toBe( + expected + ); + }); + + it('reacts to mode changing at runtime, creating the track once it starts being needed', () => { + const fixture = createComponent(TestScrollbarMode); + + fixture.componentInstance.mode = 'native'; + fixture.detectChanges(); + + expect(getTrack(fixture)).toBeNull(); + + fixture.componentInstance.mode = 'always'; + fixture.detectChanges(); + + expect(getTrack(fixture)).not.toBeNull(); + }); + + it('updates mode on the same track instance rather than recreating it when switching between hover and always', () => { + const fixture = createComponent(TestScrollbarMode); + + fixture.componentInstance.mode = 'hover'; + fixture.detectChanges(); + + const trackBeforeSwitch = getTrack(fixture); + + fixture.componentInstance.mode = 'always'; + fixture.detectChanges(); + + expect(getTrack(fixture)).toBe(trackBeforeSwitch); + }); + + it('destroys and recreates the track when mode leaves and re-enters native/hidden', () => { + const fixture = createComponent(TestScrollbarMode); + + fixture.componentInstance.mode = 'hover'; + fixture.detectChanges(); + + const trackBeforeSwitch = getTrack(fixture); + + fixture.componentInstance.mode = 'hidden'; + fixture.detectChanges(); + + expect(getTrack(fixture)).toBeNull(); + + fixture.componentInstance.mode = 'always'; + fixture.detectChanges(); + + expect(getTrack(fixture)).not.toBeNull(); + expect(getTrack(fixture)).not.toBe(trackBeforeSwitch); + }); + }); + + describe('KbqScrollbarTrack visibility', () => { + @Component({ + selector: 'test-scrollbar-track-visibility', + imports: [KbqScrollbar], + template: ` + content + ` + }) + class TestScrollbarTrackVisibility { + readonly scrollbar = viewChild.required(KbqScrollbar, { read: ElementRef }); + } + + const getViewportEl = (fixture: ComponentFixture): HTMLElement => + fixture.componentInstance.scrollbar().nativeElement; + + it('shows only the vertical bar when content overflows vertically only', fakeAsync(() => { + const fixture = createComponent(TestScrollbarTrackVisibility); + + setMetrics(getViewportEl(fixture), { + clientHeight: 100, + scrollHeight: 500, + clientWidth: 100, + scrollWidth: 100 + }); + + tick(300); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.kbq-scrollbar-track__bar_vertical')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.kbq-scrollbar-track__bar_horizontal')).toBeNull(); + + discardPeriodicTasks(); + })); + + it('shows only the horizontal bar when content overflows horizontally only', fakeAsync(() => { + const fixture = createComponent(TestScrollbarTrackVisibility); + + setMetrics(getViewportEl(fixture), { + clientHeight: 100, + scrollHeight: 100, + clientWidth: 100, + scrollWidth: 500 + }); + + tick(300); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.kbq-scrollbar-track__bar_vertical')).toBeNull(); + expect(fixture.nativeElement.querySelector('.kbq-scrollbar-track__bar_horizontal')).not.toBeNull(); + + discardPeriodicTasks(); + })); + + it('marks both bars _has-horizontal/_has-vertical when both axes overflow', fakeAsync(() => { + const fixture = createComponent(TestScrollbarTrackVisibility); + + setMetrics(getViewportEl(fixture), { + clientHeight: 100, + scrollHeight: 500, + clientWidth: 100, + scrollWidth: 500 + }); + + tick(300); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.kbq-scrollbar-track__bar_has-horizontal')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.kbq-scrollbar-track__bar_has-vertical')).not.toBeNull(); + + discardPeriodicTasks(); + })); + + it('shows no bars when content does not overflow, even in "always" mode', fakeAsync(() => { + const fixture = createComponent(TestScrollbarTrackVisibility); + + setMetrics(getViewportEl(fixture), { + clientHeight: 100, + scrollHeight: 100, + clientWidth: 100, + scrollWidth: 100 + }); + + tick(300); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.kbq-scrollbar-track__bar')).toBeNull(); + + discardPeriodicTasks(); + })); + + it('mirrors the viewport clientHeight into block-size/margin-block-end, one pixel short', fakeAsync(() => { + const fixture = createComponent(TestScrollbarTrackVisibility); + const trackEl: HTMLElement = fixture.nativeElement.querySelector('kbq-scrollbar-track'); + + setMetrics(getViewportEl(fixture), { clientHeight: 50 }); + + tick(300); + fixture.detectChanges(); + + expect(trackEl.style.blockSize).toBe('49px'); + expect(trackEl.style.marginBlockEnd).toBe('-49px'); + + discardPeriodicTasks(); + })); + + it('is inserted as the first child of the scrollable element', () => { + const fixture = createComponent(TestScrollbarTrackVisibility); + + expect(getViewportEl(fixture).firstChild).toBe(fixture.nativeElement.querySelector('kbq-scrollbar-track')); + }); + }); + + describe('KbqScrollbarThumb', () => { + @Component({ + selector: 'test-scrollbar-thumb', + imports: [KbqScrollbarViewport], + template: ` +
+ ` + }) + class TestScrollbarThumb { + readonly viewport = viewChild.required>('viewport'); + } + + type ThumbOrientation = 'vertical' | 'horizontal'; + + const getThumbElements = ( + fixture: ComponentFixture, + orientation: ThumbOrientation + ): { viewport: HTMLElement; bar: HTMLElement; thumb: HTMLElement } => { + const viewport = fixture.componentInstance.viewport().nativeElement; + + setMetrics(viewport, { + clientHeight: 100, + scrollHeight: 300, + clientWidth: 100, + scrollWidth: 300 + }); + + tick(300); + fixture.detectChanges(); + + const bar = (fixture.nativeElement as HTMLElement).querySelector( + `.kbq-scrollbar-track__bar_${orientation}` + ); + const thumb = bar?.querySelector('.kbq-scrollbar-track__thumb'); + + if (!bar || !thumb) { + throw new Error(`Expected the ${orientation} scrollbar thumb to be rendered`); + } + + return { viewport, bar, thumb }; + }; + + it('applies top/height to a vertical thumb, not insetInlineStart/width', fakeAsync(() => { + const fixture = createComponent(TestScrollbarThumb); + const { viewport, thumb } = getThumbElements(fixture, 'vertical'); + + setMetrics(viewport, { scrollTop: 50, scrollHeight: 200, clientHeight: 100 }); + viewport.dispatchEvent(new Event('scroll')); + + expect(thumb.style.top).not.toBe(''); + expect(thumb.style.height).not.toBe(''); + expect(thumb.style.insetInlineStart).toBe(''); + + discardPeriodicTasks(); + })); + + it('applies insetInlineStart/width to a horizontal thumb, not top/height (orientation forwarding)', fakeAsync(() => { + const fixture = createComponent(TestScrollbarThumb); + const { viewport, thumb } = getThumbElements(fixture, 'horizontal'); + + setMetrics(viewport, { scrollLeft: 50, scrollWidth: 200, clientWidth: 100 }); + viewport.dispatchEvent(new Event('scroll')); + + expect(thumb.style.insetInlineStart).not.toBe(''); + expect(thumb.style.width).not.toBe(''); + expect(thumb.style.top).toBe(''); + + discardPeriodicTasks(); + })); + + it('drags the vertical thumb to update the viewport scrollTop, not scrollLeft', fakeAsync(() => { + const fixture = createComponent(TestScrollbarThumb); + const { viewport, bar, thumb } = getThumbElements(fixture, 'vertical'); + + setMetrics(thumb, { offsetHeight: 0, offsetWidth: 0 }); + setRect(thumb, { top: 0, left: 0, height: 1, width: 1 }); + setRect(bar, { top: 0, left: 0, height: 100, width: 100, right: 100, bottom: 100 }); + + dispatchMouseEvent(thumb, 'mousedown', 0, 0); + tick(); + dispatchMouseEvent(document, 'mousemove', 50, 50); + tick(); + dispatchMouseEvent(document, 'mouseup'); + tick(); + + expect(viewport.scrollTop).toBe(100); + expect(viewport.scrollLeft).toBe(0); + + discardPeriodicTasks(); + })); + + it('jumps to the click position when clicking the track, not the thumb', fakeAsync(() => { + const fixture = createComponent(TestScrollbarThumb); + const { viewport, bar, thumb } = getThumbElements(fixture, 'vertical'); + + setMetrics(thumb, { offsetHeight: 0, offsetWidth: 0 }); + setRect(bar, { top: 0, left: 0, height: 100, width: 100, right: 100, bottom: 100 }); + + dispatchMouseEvent(bar, 'mousedown', 50, 50); + tick(); + + expect(viewport.scrollTop).toBe(100); + + discardPeriodicTasks(); + })); + + it('drags the horizontal thumb to update the viewport scrollLeft, not scrollTop', fakeAsync(() => { + const fixture = createComponent(TestScrollbarThumb); + const { viewport, bar, thumb } = getThumbElements(fixture, 'horizontal'); + + setMetrics(thumb, { offsetHeight: 0, offsetWidth: 0 }); + setRect(thumb, { top: 0, left: 0, height: 1, width: 1 }); + setRect(bar, { top: 0, left: 0, height: 100, width: 100, right: 100, bottom: 100 }); + + dispatchMouseEvent(thumb, 'mousedown', 0, 0); + tick(); + dispatchMouseEvent(document, 'mousemove', 50, 50); + tick(); + dispatchMouseEvent(document, 'mouseup'); + tick(); + + expect(viewport.scrollLeft).toBe(100); + expect(viewport.scrollTop).toBe(0); + + discardPeriodicTasks(); + })); + + it('negates the horizontal offset in RTL when clicking the track', fakeAsync(() => { + @Component({ + selector: 'test-scrollbar-thumb-rtl', + imports: [KbqScrollbarViewport], + template: ` +
+ ` + }) + class TestScrollbarThumbRtl extends TestScrollbarThumb {} + + const fixture = createComponent(TestScrollbarThumbRtl); + const { viewport, bar, thumb } = getThumbElements(fixture, 'horizontal'); + + // jsdom's `.matches()` doesn't support `:scope` combined with a descendant combinator + // (confirmed: `el.matches('[dir="rtl"] :scope')` returns false even with a real dir="rtl" + // ancestor, while `el.closest('[dir="rtl"]')` correctly finds it) — so the `dir="rtl"` + // wrapper above only documents intent; the RTL branch itself has to be forced here. + jest.spyOn(thumb, 'matches').mockReturnValue(true); + + setMetrics(thumb, { offsetHeight: 0, offsetWidth: 0 }); + setRect(bar, { top: 0, left: 0, height: 100, width: 100, right: 100, bottom: 100 }); + + dispatchMouseEvent(bar, 'mousedown', 50, 50); + tick(); + + // Mirrors the LTR "jumps to the click position" test's +100, negated: RTL measures the + // click offset from the track's right edge instead of its left. + expect(viewport.scrollLeft).toBe(-100); + + discardPeriodicTasks(); + })); + + it('reserves top-offset room for the CSS-enforced min thumb size on very long content', fakeAsync(() => { + const fixture = createComponent(TestScrollbarThumb); + const { viewport, thumb } = getThumbElements(fixture, 'vertical'); + + // Content is long enough that the natural view fraction (1%) is far below what the + // CSS-enforced min thumb box size (32px min-size + 3px gap on each side = 38px) would + // need — the compensation formula should reserve 38% of top-offset room so the + // min-size thumb still reaches the track's bottom edge instead of overhanging it. + setMetrics(viewport, { scrollTop: 9900, scrollHeight: 10000, clientHeight: 100 }); + viewport.dispatchEvent(new Event('scroll')); + + expect(parseFloat(thumb.style.top)).toBeCloseTo(62, 5); + + discardPeriodicTasks(); + })); + + describe('ARIA', () => { + it('marks the thumb with role="scrollbar"', fakeAsync(() => { + const fixture = createComponent(TestScrollbarThumb); + const { thumb } = getThumbElements(fixture, 'vertical'); + + expect(thumb.getAttribute('role')).toBe('scrollbar'); + + discardPeriodicTasks(); + })); + + it.each<['vertical' | 'horizontal']>([['vertical'], ['horizontal']])( + 'sets aria-orientation to the current orientation: %s', + fakeAsync((orientation) => { + const fixture = createComponent(TestScrollbarThumb); + const { thumb } = getThumbElements(fixture, orientation); + + expect(thumb.getAttribute('aria-orientation')).toBe(orientation); + + discardPeriodicTasks(); + }) + ); + + it('points aria-controls at the viewport element', fakeAsync(() => { + const fixture = createComponent(TestScrollbarThumb); + const { viewport, thumb } = getThumbElements(fixture, 'vertical'); + + expect(viewport.id).not.toBe(''); + expect(thumb.getAttribute('aria-controls')).toBe(viewport.id); + + discardPeriodicTasks(); + })); + + it('sets a fixed 0/100 aria-valuemin/aria-valuemax percentage range', fakeAsync(() => { + const fixture = createComponent(TestScrollbarThumb); + const { thumb } = getThumbElements(fixture, 'vertical'); + + expect(thumb.getAttribute('aria-valuemin')).toBe('0'); + expect(thumb.getAttribute('aria-valuemax')).toBe('100'); + + discardPeriodicTasks(); + })); + + it('sets aria-valuenow when the thumb is created', fakeAsync(() => { + const fixture = createComponent(TestScrollbarThumb); + const { thumb } = getThumbElements(fixture, 'vertical'); + + expect(thumb.getAttribute('aria-valuenow')).not.toBeNull(); + + discardPeriodicTasks(); + })); + + it('reflects the scrolled percentage in aria-valuenow', fakeAsync(() => { + const fixture = createComponent(TestScrollbarThumb); + const { viewport, thumb } = getThumbElements(fixture, 'vertical'); + + setMetrics(viewport, { scrollTop: 0, scrollHeight: 300, clientHeight: 100 }); + viewport.dispatchEvent(new Event('scroll')); + expect(thumb.getAttribute('aria-valuenow')).toBe('0'); + + setMetrics(viewport, { scrollTop: 100, scrollHeight: 300, clientHeight: 100 }); + viewport.dispatchEvent(new Event('scroll')); + expect(thumb.getAttribute('aria-valuenow')).toBe('50'); + + setMetrics(viewport, { scrollTop: 200, scrollHeight: 300, clientHeight: 100 }); + viewport.dispatchEvent(new Event('scroll')); + expect(thumb.getAttribute('aria-valuenow')).toBe('100'); + + discardPeriodicTasks(); + })); + + it('defaults aria-valuenow to 0 rather than NaN when there is nothing to scroll', fakeAsync(() => { + const fixture = createComponent(TestScrollbarThumb); + const { viewport, thumb } = getThumbElements(fixture, 'vertical'); + + setMetrics(viewport, { scrollTop: 0, scrollHeight: 100, clientHeight: 100 }); + viewport.dispatchEvent(new Event('scroll')); + + expect(thumb.getAttribute('aria-valuenow')).toBe('0'); + + discardPeriodicTasks(); + })); + }); + }); + + describe('scroll-to API', () => { + @Component({ + selector: 'test-scrollbar-scroll-to', + imports: [KbqScrollbar], + template: ` + +
target
+
+ ` + }) + class TestScrollbarScrollTo { + readonly scrollbar = viewChild.required(KbqScrollbar); + readonly scrollbarEl = viewChild.required(KbqScrollbar, { read: ElementRef }); + readonly target = viewChild.required>('target'); + } + + const spyOnScrollTo = (fixture: ComponentFixture) => + jest.spyOn(getScrollable(fixture), 'scrollTo').mockImplementation(); + + it('scrollTo delegates to CdkScrollable.scrollTo', () => { + const fixture = createComponent(TestScrollbarScrollTo); + const scrollToSpy = spyOnScrollTo(fixture); + + fixture.componentInstance.scrollbar().scrollTo({ top: 10, left: 20, behavior: 'smooth' }); + + expect(scrollToSpy).toHaveBeenCalledWith({ top: 10, left: 20, behavior: 'smooth' }); + }); + + it('scrollToTop scrolls to top: 0', () => { + const fixture = createComponent(TestScrollbarScrollTo); + const scrollToSpy = spyOnScrollTo(fixture); + + fixture.componentInstance.scrollbar().scrollToTop('smooth'); + + expect(scrollToSpy).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); + }); + + it('scrollToBottom scrolls to top: scrollHeight', () => { + const fixture = createComponent(TestScrollbarScrollTo); + + setMetrics(fixture.componentInstance.scrollbarEl().nativeElement, { scrollHeight: 999 }); + const scrollToSpy = spyOnScrollTo(fixture); + + fixture.componentInstance.scrollbar().scrollToBottom(); + + expect(scrollToSpy).toHaveBeenCalledWith({ top: 999, behavior: undefined }); + }); + + it('scrollStart scrolls to start: 0', () => { + const fixture = createComponent(TestScrollbarScrollTo); + const scrollToSpy = spyOnScrollTo(fixture); + + fixture.componentInstance.scrollbar().scrollStart(); + + expect(scrollToSpy).toHaveBeenCalledWith({ start: 0, behavior: undefined }); + }); + + it('scrollEnd scrolls to end: 0', () => { + const fixture = createComponent(TestScrollbarScrollTo); + const scrollToSpy = spyOnScrollTo(fixture); + + fixture.componentInstance.scrollbar().scrollEnd(); + + expect(scrollToSpy).toHaveBeenCalledWith({ end: 0, behavior: undefined }); + }); + + it('scrollToElement accepts an HTMLElement target', () => { + const fixture = createComponent(TestScrollbarScrollTo); + const scrollToSpy = spyOnScrollTo(fixture); + + fixture.componentInstance.scrollbar().scrollToElement(fixture.componentInstance.target().nativeElement); + + expect(scrollToSpy).toHaveBeenCalled(); + }); + + it('scrollToElement accepts a selector string resolved against the scrollbar', () => { + const fixture = createComponent(TestScrollbarScrollTo); + const scrollToSpy = spyOnScrollTo(fixture); + + fixture.componentInstance.scrollbar().scrollToElement('div'); + + expect(scrollToSpy).toHaveBeenCalled(); + }); + + it('scrollToElement no-ops for an unmatched selector', () => { + const fixture = createComponent(TestScrollbarScrollTo); + const scrollToSpy = spyOnScrollTo(fixture); + + fixture.componentInstance.scrollbar().scrollToElement('.does-not-exist'); + + expect(scrollToSpy).not.toHaveBeenCalled(); + }); + + it('scrollToElement applies top/left gap options', () => { + const fixture = createComponent(TestScrollbarScrollTo); + const target = fixture.componentInstance.target().nativeElement; + + Object.defineProperty(target, 'offsetTop', { configurable: true, value: 100 }); + Object.defineProperty(target, 'offsetLeft', { configurable: true, value: 100 }); + Object.defineProperty(target, 'offsetParent', { configurable: true, value: null }); + + const scrollToSpy = spyOnScrollTo(fixture); + + fixture.componentInstance.scrollbar().scrollToElement(target, { top: 16, left: 16 }); + + expect(scrollToSpy).toHaveBeenCalledWith({ top: 84, left: 84, behavior: undefined }); + }); + + it('scrollIntoView centers the target within the viewport', () => { + const fixture = createComponent(TestScrollbarScrollTo); + const scrollbarEl = fixture.componentInstance.scrollbarEl().nativeElement; + const target = fixture.componentInstance.target().nativeElement; + + setMetrics(scrollbarEl, { clientHeight: 100, clientWidth: 100 }); + Object.defineProperty(target, 'offsetTop', { configurable: true, value: 200 }); + Object.defineProperty(target, 'offsetLeft', { configurable: true, value: 200 }); + Object.defineProperty(target, 'offsetParent', { configurable: true, value: null }); + Object.defineProperty(target, 'offsetHeight', { configurable: true, value: 50 }); + Object.defineProperty(target, 'offsetWidth', { configurable: true, value: 50 }); + + const scrollToSpy = spyOnScrollTo(fixture); + + fixture.componentInstance.scrollbar().scrollIntoView(target); + + expect(scrollToSpy).toHaveBeenCalledWith({ top: 175, left: 175 }); + }); + }); + + describe('scrollChanges', () => { + @Component({ + selector: 'test-scrollbar-scroll-changes', + imports: [KbqScrollbar], + template: ` + content + ` + }) + class TestScrollbarScrollChanges { + readonly scrollbar = viewChild.required(KbqScrollbar); + } + + it('emits on the native scroll event', () => { + const fixture = createComponent(TestScrollbarScrollChanges); + const scrollbar = fixture.componentInstance.scrollbar(); + const handler = jest.fn(); + + scrollbar.scrollChanges.subscribe(handler); + scrollbar.getNativeElement().dispatchEvent(new Event('scroll')); + + expect(handler).toHaveBeenCalledTimes(1); + }); + }); + + describe('kbqOverflowShadowContainer integration', () => { + it('a co-located kbqOverflowShadowContainer tracks scroll via the native fallback (no KBQ_OVERFLOW_SHADOW_SOURCE wiring needed — the scrollbar host is the scroll element itself)', () => { + @Component({ + selector: 'test-scrollbar-overflow-shadow-container', + imports: [KbqScrollbar, KbqOverflowShadowContainer], + template: ` + + content + + ` + }) + class TestScrollbarOverflowShadowContainer { + readonly scrollbar = viewChild.required(KbqScrollbar); + readonly container = viewChild.required(KbqOverflowShadowContainer); + } + + const fixture = createComponent(TestScrollbarOverflowShadowContainer); + const { scrollbar, container } = fixture.componentInstance; + + setMetrics(scrollbar().getNativeElement(), { scrollTop: 0, clientHeight: 100, scrollHeight: 300 }); + container().checkOverflow(); + + expect(container().overflow()).toEqual({ top: false, bottom: true }); + + setMetrics(scrollbar().getNativeElement(), { scrollTop: 50, clientHeight: 100, scrollHeight: 300 }); + scrollbar().getNativeElement().dispatchEvent(new Event('scroll')); + + expect(container().overflow()).toEqual({ top: true, bottom: true }); + }); + }); + + describe(KbqScrollbarViewport.name, () => { + @Component({ + selector: 'test-standalone-viewport', + imports: [KbqScrollbarViewport], + template: ` +
+
target
+
+ ` + }) + class TestStandaloneViewport { + mode: KbqScrollbarMode | undefined; + readonly viewport = viewChild.required(KbqScrollbarViewport); + readonly viewportEl = viewChild.required(KbqScrollbarViewport, { read: ElementRef }); + readonly scrollable = viewChild.required(CdkScrollable); + readonly target = viewChild.required>('target'); + } + + const spyOnScrollTo = (fixture: ComponentFixture) => + jest + .spyOn( + fixture.debugElement.query(By.directive(KbqScrollbarViewport)).injector.get(CdkScrollable), + 'scrollTo' + ) + .mockImplementation(); + + it('exposes CdkScrollable on the same host element when used standalone', () => { + const fixture = createComponent(TestStandaloneViewport); + + expect(fixture.componentInstance.scrollable().getElementRef().nativeElement).toBe( + fixture.componentInstance.viewportEl().nativeElement + ); + }); + + describe('id', () => { + it('generates an id when the host element does not already have one', () => { + const fixture = createComponent(TestStandaloneViewport); + + expect(fixture.componentInstance.viewportEl().nativeElement.id).not.toBe(''); + }); + + it('preserves an id the consumer already set instead of overwriting it', () => { + @Component({ + selector: 'test-standalone-viewport-existing-id', + imports: [KbqScrollbarViewport], + template: ` +
+ ` + }) + class TestStandaloneViewportExistingId { + readonly viewportEl = viewChild.required(KbqScrollbarViewport, { read: ElementRef }); + } + + const fixture = createComponent(TestStandaloneViewportExistingId); + + expect(fixture.componentInstance.viewportEl().nativeElement.id).toBe('consumer-id'); + }); + + it('generates different ids for different instances', () => { + @Component({ + selector: 'test-standalone-viewport-pair', + imports: [KbqScrollbarViewport], + template: ` +
+
+ ` + }) + class TestStandaloneViewportPair {} + + const fixture = createComponent(TestStandaloneViewportPair); + const ids = fixture.debugElement + .queryAll(By.directive(KbqScrollbarViewport)) + .map((debugEl) => debugEl.nativeElement.id); + + expect(ids[0]).not.toBe(ids[1]); + }); + }); + + it('defaults mode from KBQ_SCROLLBAR_OPTIONS when used standalone', () => { + // No [mode] binding at all here — even binding to `undefined` counts as "a value was + // provided" and would bypass input()'s default-value fallback. + @Component({ + selector: 'test-standalone-viewport-default-mode', + imports: [KbqScrollbarViewport], + template: ` +
+ ` + }) + class TestStandaloneViewportDefaultMode { + readonly viewport = viewChild.required(KbqScrollbarViewport); + } + + const fixture = createComponent(TestStandaloneViewportDefaultMode, [ + kbqScrollbarOptionsProvider({ mode: 'always' }) + ]); + + expect(fixture.componentInstance.viewport().mode()).toBe('always'); + }); + + it.each<[KbqScrollbarMode, boolean]>([ + ['hover', true], + ['always', true], + ['native', false], + ['hidden', true] + ])('sets kbq-scrollbar-viewport_native-scrollbar-hidden for mode="%s": %s', (mode, expected) => { + const fixture = createComponent(TestStandaloneViewport); + + fixture.componentInstance.mode = mode; + fixture.detectChanges(); + + expect( + fixture.componentInstance + .viewportEl() + .nativeElement.classList.contains('kbq-scrollbar-viewport_native-scrollbar-hidden') + ).toBe(expected); + }); + + it.each<[KbqScrollbarMode, boolean]>([ + ['hover', true], + ['always', true], + ['native', false], + ['hidden', false] + ])('creates the track for the standalone viewport too, for mode="%s": %s', (mode, expected) => { + const fixture = createComponent(TestStandaloneViewport); + + fixture.componentInstance.mode = mode; + fixture.detectChanges(); + + const trackEl = fixture.nativeElement.querySelector('kbq-scrollbar-track'); + + expect(!!trackEl).toBe(expected); + + if (expected) { + expect(fixture.componentInstance.viewportEl().nativeElement.firstChild).toBe(trackEl); + } + }); + + it('scrollTo delegates to CdkScrollable.scrollTo', () => { + const fixture = createComponent(TestStandaloneViewport); + const scrollToSpy = spyOnScrollTo(fixture); + + fixture.componentInstance.viewport().scrollTo({ top: 10, left: 20, behavior: 'smooth' }); + + expect(scrollToSpy).toHaveBeenCalledWith({ top: 10, left: 20, behavior: 'smooth' }); + }); + + it('scrollToBottom scrolls to top: scrollHeight', () => { + const fixture = createComponent(TestStandaloneViewport); + + setMetrics(fixture.componentInstance.viewportEl().nativeElement, { scrollHeight: 999 }); + const scrollToSpy = spyOnScrollTo(fixture); + + fixture.componentInstance.viewport().scrollToBottom(); + + expect(scrollToSpy).toHaveBeenCalledWith({ top: 999, behavior: undefined }); + }); + + it('scrollToElement accepts an HTMLElement target and applies top/left gap options', () => { + const fixture = createComponent(TestStandaloneViewport); + const target = fixture.componentInstance.target().nativeElement; + + Object.defineProperty(target, 'offsetTop', { configurable: true, value: 100 }); + Object.defineProperty(target, 'offsetLeft', { configurable: true, value: 100 }); + Object.defineProperty(target, 'offsetParent', { configurable: true, value: null }); + + const scrollToSpy = spyOnScrollTo(fixture); + + fixture.componentInstance.viewport().scrollToElement(target, { top: 16, left: 16 }); + + expect(scrollToSpy).toHaveBeenCalledWith({ top: 84, left: 84, behavior: undefined }); + }); + + it('scrollIntoView centers the target within the viewport', () => { + const fixture = createComponent(TestStandaloneViewport); + const viewportEl = fixture.componentInstance.viewportEl().nativeElement; + const target = fixture.componentInstance.target().nativeElement; + + setMetrics(viewportEl, { clientHeight: 100, clientWidth: 100 }); + Object.defineProperty(target, 'offsetTop', { configurable: true, value: 200 }); + Object.defineProperty(target, 'offsetLeft', { configurable: true, value: 200 }); + Object.defineProperty(target, 'offsetParent', { configurable: true, value: null }); + Object.defineProperty(target, 'offsetHeight', { configurable: true, value: 50 }); + Object.defineProperty(target, 'offsetWidth', { configurable: true, value: 50 }); + + const scrollToSpy = spyOnScrollTo(fixture); + + fixture.componentInstance.viewport().scrollIntoView(target); + + // top/left = offset + size/2 - viewportSize/2 = 200 + 25 - 50 = 175 + expect(scrollToSpy).toHaveBeenCalledWith({ top: 175, left: 175 }); + }); + }); +}); diff --git a/packages/components/scrollbar/scrollbar.ts b/packages/components/scrollbar/scrollbar.ts new file mode 100644 index 0000000000..764c4556ce --- /dev/null +++ b/packages/components/scrollbar/scrollbar.ts @@ -0,0 +1,666 @@ +import { _IdGenerator, CdkMonitorFocus } from '@angular/cdk/a11y'; +import { _CdkPrivateStyleLoader } from '@angular/cdk/private'; +import { CdkScrollable, type ExtendedScrollToOptions } from '@angular/cdk/scrolling'; +import { DOCUMENT } from '@angular/common'; +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + Directive, + effect, + ElementRef, + inject, + InjectionToken, + Injector, + input, + NgZone, + ViewContainerRef, + ViewEncapsulation, + type ComponentRef, + type Provider +} from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { KBQ_WINDOW, kbqInjectNativeElement } from '@koobiq/components/core'; +import { + asyncScheduler, + distinctUntilChanged, + filter, + fromEvent, + map, + merge, + Observable, + startWith, + switchMap, + takeUntil, + throttleTime, + type MonoTypeOperatorFunction, + type SchedulerAction, + type SchedulerLike, + type Subscription +} from 'rxjs'; + +/** Emits `requestAnimationFrame` timestamps outside Angular's zone; never emits during SSR, where there's no frame to wait on. */ +function animationFrame(): Observable { + const window = inject(KBQ_WINDOW); + const ngZone = inject(NgZone); + + return new Observable((subscriber) => { + if (typeof window.requestAnimationFrame === 'undefined') { + return undefined; + } + + // `requestAnimationFrame` runs its callback in whatever zone was active when it was scheduled, + // so the recursive re-scheduling below must itself run outside Angular's zone too — otherwise + // this loop (which never stops on its own) keeps `NgZone` perpetually unstable for as long as + // the subscription is alive. + return ngZone.runOutsideAngular(() => { + let frameId = window.requestAnimationFrame(function loop(timestamp) { + subscriber.next(timestamp); + frameId = window.requestAnimationFrame(loop); + }); + + return () => window.cancelAnimationFrame(frameId); + }); + }); +} + +/** Runs the source subscription outside Angular's zone so it doesn't trigger change detection. */ +function zoneFree(): MonoTypeOperatorFunction { + const ngZone = inject(NgZone); + + return (source) => new Observable((subscriber) => ngZone.runOutsideAngular(() => source.subscribe(subscriber))); +} + +/** A scheduler that always schedules its work outside Angular's zone. */ +function zoneFreeScheduler(): SchedulerLike { + const ngZone = inject(NgZone); + + return { + now: () => Date.now(), + schedule(work: (this: SchedulerAction, state?: T) => void, delay?: number, state?: T): Subscription { + let subscription!: Subscription; + + ngZone.runOutsideAngular(() => { + subscription = asyncScheduler.schedule(work, delay, state); + }); + + return subscription; + } + }; +} + +/** Re-enters Angular's zone for every emission so change detection is triggered. */ +function zoneOptimized(): MonoTypeOperatorFunction { + const ngZone = inject(NgZone); + + return (source) => + new Observable((subscriber) => + source.subscribe({ + next: (value) => ngZone.run(() => subscriber.next(value)), + error: (error) => subscriber.error(error), + complete: () => subscriber.complete() + }) + ); +} + +/** Sums `offsetTop`/`offsetLeft` from `element` up to (excluding) `ancestor`. */ +function getElementOffset(ancestor: HTMLElement, element: HTMLElement): { offsetTop: number; offsetLeft: number } { + let offsetTop = 0; + let offsetLeft = 0; + let current: HTMLElement | null = element; + + while (current && current !== ancestor) { + offsetTop += current.offsetTop; + offsetLeft += current.offsetLeft; + current = current.offsetParent as HTMLElement | null; + } + + return { offsetTop, offsetLeft }; +} + +/** + * A DI token pointing to the element whose scroll state the scrollbar tracks and controls. + * By default resolves to {@link KbqScrollbar}'s own host element; place `[kbqScrollbarViewport]` on a + * nested element to delegate to it instead. + */ +export const KBQ_SCROLLBAR_VIEWPORT = new InjectionToken>('KBQ_SCROLLBAR_VIEWPORT', { + factory: () => new ElementRef(inject(DOCUMENT).documentElement) +}); + +/** + * How the scrollbar is presented: + * - `hover` — track appears on pointer hover or keyboard focus (default); + * - `always` — track is always visible while the content overflows; + * - `native` — the browser's native scrollbar is used; + * - `hidden` — no scrollbar is shown, but the content stays scrollable. + */ +export type KbqScrollbarMode = 'always' | 'hidden' | 'hover' | 'native'; + +/** Configuration for {@link KbqScrollbar}. */ +export type KbqScrollbarOptions = { + mode: KbqScrollbarMode; +}; + +const KBQ_SCROLLBAR_DEFAULT_OPTIONS: KbqScrollbarOptions = { + mode: 'hover' +}; + +/** Injection token holding the current {@link KbqScrollbarOptions}. */ +export const KBQ_SCROLLBAR_OPTIONS = new InjectionToken('KBQ_SCROLLBAR_OPTIONS', { + factory: () => KBQ_SCROLLBAR_DEFAULT_OPTIONS +}); + +/** Overrides the default scrollbar options within the given injector scope. */ +export function kbqScrollbarOptionsProvider(options: Partial): Provider { + return { + provide: KBQ_SCROLLBAR_OPTIONS, + useValue: { ...KBQ_SCROLLBAR_DEFAULT_OPTIONS, ...options } + }; +} + +/** The axis a scrollbar thumb/track moves along. */ +type Orientation = 'horizontal' | 'vertical'; + +/** Based on --kbq-scrollbar-thumb-min-size */ +const MIN_THUMB_SIZE = 32; + +/** Based on --kbq-scrollbar-thumb-gap */ +const THUMB_GAP = 3; + +// The CSS-enforced floor on the thumb's own main-axis box size — mirrors `min-block-size`/ +// `min-inline-size` in scrollbar-track.scss (`--kbq-scrollbar-thumb-min-size` plus the transparent +// border on both sides). `getCompensation` below needs this exact box size, not just +// `MIN_THUMB_SIZE`, or the reserved top-offset room falls short of what the CSS actually enforces +// and the thumb overhangs the track's trailing edge at the very end of the scroll range. +const MIN_THUMB_BOX_SIZE = MIN_THUMB_SIZE + THUMB_GAP * 2; + +type Dimension = { + scrollTop: number; + scrollHeight: number; + clientHeight: number; + scrollLeft: number; + scrollWidth: number; + clientWidth: number; +}; + +/** `[vertical, horizontal]` overflow flags. */ +type ScrollbarVisibility = readonly [boolean, boolean]; + +/** Loads the global `.kbq-scrollbar-viewport_native-scrollbar-hidden` utility class once per app (see {@link KbqScrollbarViewport}). */ +@Component({ + selector: 'scrollbar-viewport-style-loader', + template: '', + styleUrl: './scrollbar-viewport.scss', + encapsulation: ViewEncapsulation.None +}) +class ScrollbarViewportStyleLoader {} + +/** Options accepted by {@link KbqScrollbarViewport.scrollTo}/{@link KbqScrollbar.scrollTo} — RTL-normalized, see `CdkScrollable.scrollTo`. */ +export type KbqScrollbarScrollToOptions = ExtendedScrollToOptions; + +/** Options accepted by {@link KbqScrollbarViewport.scrollToElement}/{@link KbqScrollbar.scrollToElement}. */ +export type KbqScrollbarScrollToElementOptions = { + /** 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; +}; + +/** `[top, left]` scroll offsets in pixels. */ +type ScrollPosition = [number, number]; + +/** Marks its host element as the scroll target for {@link KBQ_SCROLLBAR_VIEWPORT} consumers, hiding its native scrollbar whenever a custom scrollbar track replaces it. */ +@Directive({ + selector: '[kbqScrollbarViewport]', + providers: [{ provide: KBQ_SCROLLBAR_VIEWPORT, useExisting: ElementRef }], + host: { + class: 'kbq-scrollbar-viewport', + '[class.kbq-scrollbar-viewport_native-scrollbar-hidden]': 'mode() !== "native"', + // Monitors the whole subtree (not just this element) so `cdk-keyboard-focused` lands on the + // viewport itself whenever ANY descendant is keyboard-focused — projected content doesn't have + // to opt in individually with its own `cdkMonitorElementFocus`. Drives the hover-mode track's + // visibility in scrollbar-viewport.scss. + '[attr.cdkMonitorSubtreeFocus]': 'true', + // A stable id for the thumb's `aria-controls` to point at, preserving one a consumer already + // set rather than clobbering it. + '[attr.id]': 'id' + }, + hostDirectives: [CdkScrollable, CdkMonitorFocus] +}) +export class KbqScrollbarViewport { + private readonly styleLoader = inject(_CdkPrivateStyleLoader); + private readonly scrollable = inject(CdkScrollable); + private readonly viewContainerRef = inject(ViewContainerRef); + private readonly injector = inject(Injector); + private readonly idGenerator = inject(_IdGenerator); + + /** Stable id on the viewport element, used as the `aria-controls` target for the scrollbar thumb. */ + protected readonly id = this.getNativeElement().id || this.idGenerator.getId('kbq-scrollbar-viewport-'); + + /** Visibility mode for this viewport's scrollbar. Defaults to the app-wide {@link KBQ_SCROLLBAR_OPTIONS}. */ + readonly mode = input(inject(KBQ_SCROLLBAR_OPTIONS).mode); + + // Owns the {@link KbqScrollbarTrack} instance instead of requiring consumers to declare + // `` by hand, so `{@link KbqScrollbar}` and standalone + // `[kbqScrollbarViewport]` usage share one creation path. + private trackRef: ComponentRef | null = null; + + constructor() { + this.styleLoader.load(ScrollbarViewportStyleLoader); + + effect(() => { + const mode = this.mode(); + const showTrack = mode !== 'native' && mode !== 'hidden'; + + if (!showTrack) { + this.trackRef?.destroy(); + this.trackRef = null; + + return; + } + + if (!this.trackRef) { + this.trackRef = this.createTrack(); + } + + this.trackRef.setInput('mode', mode); + }); + } + + /** The viewport's native scrollable element — the host this directive is applied to. */ + getNativeElement(): HTMLElement { + return this.scrollable.getElementRef().nativeElement; + } + + /** Emits on every native `scroll` event of the viewport. Emits outside Angular's zone — see `CdkScrollable.elementScrolled`. */ + get scrollChanges(): Observable { + return this.scrollable.elementScrolled(); + } + + /** Scrolls to the specified offsets. RTL-normalized — see `CdkScrollable.scrollTo`. */ + scrollTo(options: KbqScrollbarScrollToOptions): void { + this.scrollable.scrollTo(options); + } + + /** 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.getNativeElement().scrollHeight, behavior }); + } + + /** Scrolls to the logical start of the horizontal axis — the right edge in RTL, left in LTR. */ + scrollStart(behavior?: ScrollBehavior): void { + this.scrollTo({ start: 0, behavior }); + } + + /** Scrolls to the logical end of the horizontal axis — the left edge in RTL, right in LTR. */ + scrollEnd(behavior?: ScrollBehavior): void { + this.scrollTo({ end: 0, behavior }); + } + + /** Scrolls `target` (an element, or a selector resolved against this viewport) into view. */ + scrollToElement(target: HTMLElement | string, options?: KbqScrollbarScrollToElementOptions): void { + const _target = + typeof target === 'string' ? this.getNativeElement().querySelector(target) : target; + + if (!_target) return; + + const { offsetTop, offsetLeft } = getElementOffset(this.getNativeElement(), _target); + + this.scrollTo({ + top: offsetTop - (options?.top ?? 0), + left: offsetLeft - (options?.left ?? 0), + behavior: options?.behavior + }); + } + + /** Scrolls `target` to the center of the viewport. */ + scrollIntoView(target: HTMLElement, behavior?: ScrollBehavior): void { + const { offsetHeight, offsetWidth } = target; + const { offsetTop, offsetLeft } = getElementOffset(this.getNativeElement(), target); + + this.scrollTo({ + top: offsetTop + offsetHeight / 2 - this.getNativeElement().clientHeight / 2, + left: offsetLeft + offsetWidth / 2 - this.getNativeElement().clientWidth / 2, + behavior + }); + } + + private createTrack(): ComponentRef { + const track = this.viewContainerRef.createComponent(KbqScrollbarTrack, { injector: this.injector }); + + // The track needs to be a direct child of the scrollable element itself (for the sticky + // positioning in scrollbar-track.scss to work) regardless of where `createComponent` happens to + // insert its view, so move it there explicitly. Captures `track` itself, not `this.trackRef` — + // by the time this fires the viewport may already have destroyed/replaced it (e.g. mode flipping + // through native/hidden and back before the next render), and relocating a stale, already-detached + // node is harmless, but dereferencing a by-then-cleared `this.trackRef` would throw. + afterNextRender( + () => { + this.getNativeElement().insertBefore(track.location.nativeElement, this.getNativeElement().firstChild); + }, + { injector: this.injector } + ); + + return track; + } +} + +/** + * Draggable thumb element: turns drags/track clicks into scroll positions of + * {@link KBQ_SCROLLBAR_VIEWPORT}, and mirrors its scroll position/size back onto its own CSS position. + */ +@Directive({ + selector: '[kbqScrollbarThumb]', + host: { + role: 'scrollbar', + '[attr.aria-orientation]': 'orientation()', + '[attr.aria-controls]': 'viewport.nativeElement.id', + '[attr.aria-valuemin]': '0', + '[attr.aria-valuemax]': '100' + }, + exportAs: 'kbqScrollbarThumb' +}) +class KbqScrollbarThumb { + /** @docs-private */ + protected readonly viewport = inject(KBQ_SCROLLBAR_VIEWPORT); + private readonly nativeElement = kbqInjectNativeElement(); + private readonly style = this.nativeElement.style; + + /** Axis the thumb scrolls along — `'vertical'` (default) or `'horizontal'`. */ + readonly orientation = input('vertical'); + + constructor() { + merge( + fromEvent(this.nativeElement.parentElement!, 'mousedown').pipe( + filter(({ target }) => target !== this.nativeElement), + map((event) => this.getScrolled(event, 0.5, 0.5)) + ), + fromEvent(this.nativeElement, 'mousedown').pipe( + zoneFree(), + switchMap((event) => { + const { ownerDocument } = this.nativeElement; + const { top, left, height, width } = this.nativeElement.getBoundingClientRect(); + const vertical = (event.clientY - top) / height; + const horizontal = (event.clientX - left) / width; + + return fromEvent(ownerDocument, 'mousemove').pipe( + map((event) => this.getScrolled(event, vertical, horizontal)), + takeUntil(fromEvent(ownerDocument, 'mouseup')) + ); + }) + ) + ) + .pipe(takeUntilDestroyed()) + .subscribe(([top, left]) => { + this.viewport.nativeElement.style.scrollBehavior = 'auto'; + + if (this.orientation() === 'horizontal') { + this.viewport.nativeElement.scrollLeft = left; + } else { + this.viewport.nativeElement.scrollTop = top; + } + + this.viewport.nativeElement.style.scrollBehavior = ''; + }); + + merge( + animationFrame().pipe(throttleTime(100, zoneFreeScheduler())), + fromEvent(this.viewport.nativeElement, 'scroll').pipe(zoneFree()) + ) + .pipe( + zoneFree(), + map(() => this.getDimension()), + takeUntilDestroyed() + ) + .subscribe((dimension) => { + this.applyPosition(this.getPosition(dimension)); + this.applyValueNow(this.getValueNow(dimension)); + }); + + // Not applied directly here: `orientation()` still reads its default value at this point in + // the constructor — Angular hasn't applied the template-bound input yet — so reading it now + // would compute a wrong-axis position for a horizontal thumb. `afterNextRender` runs once the + // input is actually set, while still landing before the first throttled animation-frame/scroll + // update above — `role="scrollbar"` requires `aria-valuenow` to be present from the start, or + // axe (and any screen reader) could observe it missing. + afterNextRender(() => { + const dimension = this.getDimension(); + + this.applyPosition(this.getPosition(dimension)); + this.applyValueNow(this.getValueNow(dimension)); + }); + } + + private getScrolled({ clientY, clientX }: MouseEvent, offsetY: number, offsetX: number): ScrollPosition { + const { offsetHeight, offsetWidth } = this.nativeElement; + const { top, left, right, width, height } = this.nativeElement.parentElement!.getBoundingClientRect(); + const rtl = this.nativeElement.matches('[dir="rtl"] :scope'); + const inline = rtl ? right : left; + const multiplier = rtl ? -1 : 1; + const maxTop = this.viewport.nativeElement.scrollHeight - height; + const maxLeft = this.viewport.nativeElement.scrollWidth - width; + const scrolledTop = (clientY - top - offsetHeight * offsetY) / (height - offsetHeight); + const scrolledLeft = (clientX - inline - offsetWidth * offsetX * multiplier) / (width - offsetWidth); + + return [maxTop * scrolledTop, maxLeft * scrolledLeft]; + } + + private applyPosition(style: Partial): void { + Object.assign(this.style, style); + } + + private applyValueNow(valueNow: number): void { + this.nativeElement.setAttribute('aria-valuenow', `${valueNow}`); + } + + private getDimension(): Dimension { + const { scrollTop, scrollHeight, clientHeight, scrollLeft, scrollWidth, clientWidth } = + this.viewport.nativeElement; + + return { scrollTop, scrollHeight, clientHeight, scrollLeft, scrollWidth, clientWidth }; + } + + private getPosition(dimension: Dimension): Partial { + const thumb = `${this.getThumbFraction(dimension) * 100}%`; + const view = `${this.getViewFraction(dimension) * 100}%`; + + return this.orientation() === 'vertical' + ? { top: thumb, height: view } + : { insetInlineStart: thumb, width: view }; + } + + private getValueNow(dimension: Dimension): number { + const scrolledFraction = this.getScrolledFraction(dimension); + + // `getScrolledFraction` divides by zero (NaN) when there's nothing to scroll — 0% in that case. + return Number.isNaN(scrolledFraction) ? 0 : Math.round(Math.abs(scrolledFraction) * 100); + } + + private getThumbFraction(dimension: Dimension): number { + const compensation = this.getCompensation(dimension) || this.getViewFraction(dimension); + + return Math.abs(this.getScrolledFraction(dimension) * (1 - compensation)); + } + + private getViewFraction(dimension: Dimension): number { + return this.orientation() === 'vertical' + ? Math.ceil((dimension.clientHeight / dimension.scrollHeight) * 100) / 100 + : Math.ceil((dimension.clientWidth / dimension.scrollWidth) * 100) / 100; + } + + private getScrolledFraction({ + scrollTop, + scrollHeight, + clientHeight, + scrollLeft, + scrollWidth, + clientWidth + }: Dimension): number { + return this.orientation() === 'vertical' + ? scrollTop / (scrollHeight - clientHeight) + : scrollLeft / (scrollWidth - clientWidth); + } + + private getCompensation({ clientHeight, clientWidth, scrollWidth, scrollHeight }: Dimension): number { + if ( + ((clientHeight * clientHeight) / scrollHeight > MIN_THUMB_BOX_SIZE && this.orientation() === 'vertical') || + ((clientWidth * clientWidth) / scrollWidth > MIN_THUMB_BOX_SIZE && this.orientation() === 'horizontal') + ) { + return 0; + } + + return this.orientation() === 'vertical' ? MIN_THUMB_BOX_SIZE / clientHeight : MIN_THUMB_BOX_SIZE / clientWidth; + } +} + +/** + * Renders the visual scroll bars/thumbs for {@link KBQ_SCROLLBAR_VIEWPORT}. + * + * Created and positioned exclusively by `KbqScrollbarViewport` — not exported, never place this + * directly in a template. It only ever exists for `mode="hover"`/`"always"` (`KbqScrollbarViewport` + * destroys it instead for `"native"`/`"hidden"`), so it carries no native/hidden handling itself. + */ +@Component({ + selector: 'kbq-scrollbar-track', + imports: [KbqScrollbarThumb], + template: ` + @if (visibility()[0]) { +
+
+
+ } + @if (visibility()[1]) { +
+
+
+ } + `, + styleUrl: './scrollbar-track.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'kbq-scrollbar-track', + '[class.kbq-scrollbar-track_hover]': "mode() === 'hover'", + '[style.block-size.px]': 'hostBlockSize() - 1', + '[style.margin-block-end.px]': '-(hostBlockSize() - 1)' + } +}) +class KbqScrollbarTrack { + private readonly viewport = inject(KBQ_SCROLLBAR_VIEWPORT); + protected readonly visibility = toSignal( + animationFrame().pipe( + throttleTime(300, zoneFreeScheduler()), + map(() => this.scrollbars), + startWith([false, false] as const), + distinctUntilChanged((a, b) => a[0] === b[0] && a[1] === b[1]), + zoneOptimized() + ), + { requireSync: true } + ); + /** The scroll container's own pixel height — percentage-based `margin-block-end` can't cancel a sticky element's height contribution (percentages in the block direction resolve against width, not height, per the CSS spec), so we track and apply the cancellation in exact pixels instead. */ + protected readonly hostBlockSize = toSignal( + animationFrame().pipe( + throttleTime(300, zoneFreeScheduler()), + map(() => this.viewport.nativeElement.clientHeight), + startWith(0), + distinctUntilChanged(), + zoneOptimized() + ), + { requireSync: true } + ); + + /** Visibility mode, forwarded from the owning {@link KbqScrollbarViewport}; only `hover`/`always` reach the track. */ + readonly mode = input.required(); + + private get scrollbars(): ScrollbarVisibility { + const { clientHeight, scrollHeight, clientWidth, scrollWidth } = this.viewport.nativeElement; + + return [ + Math.ceil((clientHeight / scrollHeight) * 100) < 100, + Math.ceil((clientWidth / scrollWidth) * 100) < 100 + ]; + } +} + +/** Custom scrollbar wrapper: projects content and overlays a scrollbar track over it (created by its {@link KbqScrollbarViewport} host directive). */ +@Component({ + selector: 'kbq-scrollbar', + template: ` +
+ +
+ `, + styleUrl: './scrollbar.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + hostDirectives: [{ directive: KbqScrollbarViewport, inputs: ['mode'] }], + exportAs: 'kbqScrollbar' +}) +export class KbqScrollbar { + private readonly options = inject(KBQ_SCROLLBAR_OPTIONS); + private readonly viewport = inject(KbqScrollbarViewport); + + /** Visibility mode for the scrollbar. Defaults to the app-wide {@link KBQ_SCROLLBAR_OPTIONS}. */ + readonly mode = input(this.options.mode); + + /** The scrollbar's native scrollable element. */ + getNativeElement(): HTMLElement { + return this.viewport.getNativeElement(); + } + + /** Scrolls to the specified offsets. RTL-normalized — see `CdkScrollable.scrollTo`. */ + scrollTo(options: KbqScrollbarScrollToOptions): void { + this.viewport.scrollTo(options); + } + + /** Scrolls to the start of the vertical axis. */ + scrollToTop(behavior?: ScrollBehavior): void { + this.viewport.scrollToTop(behavior); + } + + /** Scrolls to the end of the vertical axis. */ + scrollToBottom(behavior?: ScrollBehavior): void { + this.viewport.scrollToBottom(behavior); + } + + /** Scrolls to the logical start of the horizontal axis — the right edge in RTL, left in LTR. */ + scrollStart(behavior?: ScrollBehavior): void { + this.viewport.scrollStart(behavior); + } + + /** Scrolls to the logical end of the horizontal axis — the left edge in RTL, right in LTR. */ + scrollEnd(behavior?: ScrollBehavior): void { + this.viewport.scrollEnd(behavior); + } + + /** Scrolls `target` (an element, or a selector resolved against this scrollbar) into view. */ + scrollToElement(target: HTMLElement | string, options?: KbqScrollbarScrollToElementOptions): void { + this.viewport.scrollToElement(target, options); + } + + /** Scrolls `target` to the center of the viewport. */ + scrollIntoView(target: HTMLElement, behavior?: ScrollBehavior): void { + this.viewport.scrollIntoView(target, behavior); + } + + /** Emits on every native `scroll` event of the viewport. Emits outside Angular's zone — see `CdkScrollable.elementScrolled`. */ + get scrollChanges(): Observable { + return this.viewport.scrollChanges; + } +} diff --git a/packages/docs-examples/components/ag-grid/ag-grid-infinite-selection/ag-grid-infinite-selection-example.ts b/packages/docs-examples/components/ag-grid/ag-grid-infinite-selection/ag-grid-infinite-selection-example.ts index 781964225a..98575a99ef 100644 --- a/packages/docs-examples/components/ag-grid/ag-grid-infinite-selection/ag-grid-infinite-selection-example.ts +++ b/packages/docs-examples/components/ag-grid/ag-grid-infinite-selection/ag-grid-infinite-selection-example.ts @@ -72,7 +72,7 @@ type SelectionDto = { />
DTO sent to the backend: -
{{ selectionDto() | json }}
+
{{ selectionDto() | json }}
`, styles: ` diff --git a/packages/docs-examples/components/list/list-intermediate-state/list-intermediate-state-example.ts b/packages/docs-examples/components/list/list-intermediate-state/list-intermediate-state-example.ts index 052620f03e..24d8f44ee1 100644 --- a/packages/docs-examples/components/list/list-intermediate-state/list-intermediate-state-example.ts +++ b/packages/docs-examples/components/list/list-intermediate-state/list-intermediate-state-example.ts @@ -9,7 +9,7 @@ import { KbqIconModule } from '@koobiq/components/icon'; import { KbqInput, KbqInputModule } from '@koobiq/components/input'; import { KbqListModule, KbqListSelectionChange } from '@koobiq/components/list'; import { KbqPopoverModule, KbqPopoverTrigger } from '@koobiq/components/popover'; -import { KbqScrollbarModule } from '@koobiq/components/scrollbar'; +import { KbqScrollbar } from '@koobiq/components/scrollbar'; import { KbqUsernameModule } from '@koobiq/components/username'; import { merge, Observable, of } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -41,7 +41,7 @@ interface ExampleUser { ReactiveFormsModule, AsyncPipe, KbqEmptyState, - KbqScrollbarModule + KbqScrollbar ], templateUrl: './list-intermediate-state-example.html', styles: ` diff --git a/packages/docs-examples/components/scrollbar/index.ts b/packages/docs-examples/components/scrollbar/index.ts index 45f5dd136d..6f79238fee 100644 --- a/packages/docs-examples/components/scrollbar/index.ts +++ b/packages/docs-examples/components/scrollbar/index.ts @@ -1,20 +1,18 @@ import { NgModule } from '@angular/core'; -import { KBQ_SCROLLBAR_OPTIONS_DEFAULT_CONFIG_PROVIDER } from '@koobiq/components/scrollbar'; 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'; +import { ScrollbarScrollToExample } from './scrollbar-scroll-to/scrollbar-scroll-to-example'; +import { ScrollbarVirtualScrollExample } from './scrollbar-virtual-scroll/scrollbar-virtual-scroll-example'; -export { ScrollbarOverviewExample, ScrollbarScrollToTopExample, ScrollbarWithCustomConfigExample }; +export { ScrollbarOverviewExample, ScrollbarScrollToExample, ScrollbarVirtualScrollExample }; const EXAMPLES = [ - ScrollbarScrollToTopExample, ScrollbarOverviewExample, - ScrollbarWithCustomConfigExample + ScrollbarVirtualScrollExample, + ScrollbarScrollToExample ]; @NgModule({ imports: EXAMPLES, - providers: [KBQ_SCROLLBAR_OPTIONS_DEFAULT_CONFIG_PROVIDER], exports: EXAMPLES }) export class ScrollbarExamplesModule {} diff --git a/packages/docs-examples/components/scrollbar/scrollbar-overview/scrollbar-overview-example.ts b/packages/docs-examples/components/scrollbar/scrollbar-overview/scrollbar-overview-example.ts index 2bdf3303fd..3c195d271e 100644 --- a/packages/docs-examples/components/scrollbar/scrollbar-overview/scrollbar-overview-example.ts +++ b/packages/docs-examples/components/scrollbar/scrollbar-overview/scrollbar-overview-example.ts @@ -1,28 +1,87 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { KbqScrollbarModule, KbqScrollbarOptions } from '@koobiq/components/scrollbar'; +import { ChangeDetectionStrategy, Component, model } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { KbqScrollbar, KbqScrollbarMode } from '@koobiq/components/scrollbar'; +import { KbqSelectModule } from '@koobiq/components/select'; /** - * @title Scrollbar with options + * @title Scrollbar overview example */ @Component({ selector: 'scrollbar-overview-example', - imports: [KbqScrollbarModule], + imports: [KbqScrollbar, KbqSelectModule, FormsModule], template: ` - - @for (item of items; track item) { -
{{ item }}
-
- } -
+ + + @for (mode of modes; track mode) { + {{ mode }} + } + + + +
+ +

+ 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 ScrollbarOverviewExample { - readonly options: KbqScrollbarOptions = { - scrollbars: { - autoHide: 'never' - } - }; - - readonly items = Array.from({ length: 1000 }).map((_, i) => `Item #${i}`); + protected readonly modes: KbqScrollbarMode[] = ['hover', 'always', 'native', 'hidden'] as const; + protected readonly mode = model('hover'); } diff --git a/packages/docs-examples/components/scrollbar/scrollbar-scroll-to/scrollbar-scroll-to-example.ts b/packages/docs-examples/components/scrollbar/scrollbar-scroll-to/scrollbar-scroll-to-example.ts new file mode 100644 index 0000000000..ce42576905 --- /dev/null +++ b/packages/docs-examples/components/scrollbar/scrollbar-scroll-to/scrollbar-scroll-to-example.ts @@ -0,0 +1,126 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { KbqButtonModule } from '@koobiq/components/button'; +import { KbqScrollbar } from '@koobiq/components/scrollbar'; + +/** + * @title Scrollbar scrollTo methods + */ +@Component({ + selector: '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] +

+

+ 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 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 TO ELEMENT] + 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 + [SCROLL INTO VIEW] + 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 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 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 ScrollbarScrollToExample {} diff --git a/packages/docs-examples/components/scrollbar/scrollbar-virtual-scroll/scrollbar-virtual-scroll-example.ts b/packages/docs-examples/components/scrollbar/scrollbar-virtual-scroll/scrollbar-virtual-scroll-example.ts new file mode 100644 index 0000000000..4da0e796cf --- /dev/null +++ b/packages/docs-examples/components/scrollbar/scrollbar-virtual-scroll/scrollbar-virtual-scroll-example.ts @@ -0,0 +1,52 @@ +import { ScrollingModule } from '@angular/cdk/scrolling'; +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { KbqButtonModule } from '@koobiq/components/button'; +import { KbqScrollbarViewport } from '@koobiq/components/scrollbar'; + +/** + * @title Scrollbar with virtual scroll + */ +@Component({ + selector: 'scrollbar-virtual-scroll-example', + imports: [KbqScrollbarViewport, 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: 320px; + width: 320px; + 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 ScrollbarVirtualScrollExample { + protected readonly items = signal(Array.from({ length: 100 }).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/example-module.ts b/packages/docs-examples/example-module.ts index deeb641bc0..23717c0704 100644 --- a/packages/docs-examples/example-module.ts +++ b/packages/docs-examples/example-module.ts @@ -4732,7 +4732,7 @@ export const EXAMPLE_COMPONENTS: {[id: string]: LiveExample} = { }, "scrollbar-overview": { "packagePath": "components/scrollbar/scrollbar-overview", - "title": "Scrollbar with options", + "title": "Scrollbar overview example", "componentName": "ScrollbarOverviewExample", "files": [ "scrollbar-overview-example.ts" @@ -4743,30 +4743,30 @@ export const EXAMPLE_COMPONENTS: {[id: string]: LiveExample} = { "primaryFile": "scrollbar-overview-example.ts", "importPath": "components/scrollbar" }, - "scrollbar-scroll-to-top": { - "packagePath": "components/scrollbar/scrollbar-scroll-to-top", - "title": "Scrollbar scroll to top", - "componentName": "ScrollbarScrollToTopExample", + "scrollbar-scroll-to": { + "packagePath": "components/scrollbar/scrollbar-scroll-to", + "title": "Scrollbar scrollTo methods", + "componentName": "ScrollbarScrollToExample", "files": [ - "scrollbar-scroll-to-top-example.ts" + "scrollbar-scroll-to-example.ts" ], "localImportFiles": [], - "selector": "scrollbar-scroll-to-top-example", + "selector": "scrollbar-scroll-to-example", "additionalComponents": [], - "primaryFile": "scrollbar-scroll-to-top-example.ts", + "primaryFile": "scrollbar-scroll-to-example.ts", "importPath": "components/scrollbar" }, - "scrollbar-with-custom-config": { - "packagePath": "components/scrollbar/scrollbar-with-custom-config", - "title": "Scrollbar with custom KBQ_SCROLLBAR_CONFIG", - "componentName": "ScrollbarWithCustomConfigExample", + "scrollbar-virtual-scroll": { + "packagePath": "components/scrollbar/scrollbar-virtual-scroll", + "title": "Scrollbar with virtual scroll", + "componentName": "ScrollbarVirtualScrollExample", "files": [ - "scrollbar-with-custom-config-example.ts" + "scrollbar-virtual-scroll-example.ts" ], "localImportFiles": [], - "selector": "scrollbar-with-custom-config-example", + "selector": "scrollbar-virtual-scroll-example", "additionalComponents": [], - "primaryFile": "scrollbar-with-custom-config-example.ts", + "primaryFile": "scrollbar-virtual-scroll-example.ts", "importPath": "components/scrollbar" }, "search-expandable-in-header": { @@ -8265,9 +8265,9 @@ return import('@koobiq/docs-examples/components/radio'); return import('@koobiq/docs-examples/components/resizer'); case 'scrollbar-overview': return import('@koobiq/docs-examples/components/scrollbar'); - case 'scrollbar-scroll-to-top': + case 'scrollbar-scroll-to': return import('@koobiq/docs-examples/components/scrollbar'); - case 'scrollbar-with-custom-config': + case 'scrollbar-virtual-scroll': return import('@koobiq/docs-examples/components/scrollbar'); case 'search-expandable-in-header': return import('@koobiq/docs-examples/components/search-expandable'); diff --git a/packages/e2e/routes.ts b/packages/e2e/routes.ts index 3d25457877..fb1a7090f6 100644 --- a/packages/e2e/routes.ts +++ b/packages/e2e/routes.ts @@ -1,6 +1,14 @@ import { Routes } from '@angular/router'; import { E2eFormHorizontal } from 'packages/components/core/forms/e2e'; import { E2eTypographyStyles } from 'packages/components/core/styles/typography/e2e'; +import { + E2eScrollbarMode, + E2eScrollbarNested, + E2eScrollbarScrollTo, + E2eScrollbarStateAndStyle, + E2eScrollbarTrack, + E2eScrollbarVirtualScroll +} from 'packages/components/scrollbar/e2e'; import { E2eAccordionStates } from '../components/accordion/e2e'; import { E2eActionsPanelWithOverlayContainer } from '../components/actions-panel/e2e'; import { E2eAlertStateAndStyle } from '../components/alert/e2e'; @@ -74,7 +82,7 @@ import { E2ePopoverPositioning, E2ePopoverStates, E2ePopoverWithTooltip } from ' import { E2eProgressBarStateAndStyle } from '../components/progress-bar/e2e'; import { E2eProgressSpinnerStates } from '../components/progress-spinner/e2e'; import { E2eRadioStateAndStyle } from '../components/radio/e2e'; -import { E2eScrollbarStateAndStyle } from '../components/scrollbar/e2e'; +import { E2eDeprecatedScrollbarStateAndStyle } from '../components/scrollbar/deprecated/e2e'; import { E2eSearchExpandableStates } from '../components/search-expandable/e2e'; import { E2eMultilineSelectStates, @@ -215,6 +223,10 @@ const components = [ E2eSearchExpandableStates, E2eInputStateAndStyle, E2eScrollbarStateAndStyle, + E2eScrollbarMode, + E2eScrollbarScrollTo, + E2eScrollbarVirtualScroll, + E2eScrollbarNested, E2eRadioStateAndStyle, E2eProgressBarStateAndStyle, E2eProgressSpinnerStates, @@ -279,7 +291,9 @@ const components = [ E2eOverflowItemsHorizontal, E2eOverflowItemsVertical, E2eOverflowItemsOrdered, - E2eOverflowItemsDynamic + E2eOverflowItemsDynamic, + E2eDeprecatedScrollbarStateAndStyle, + E2eScrollbarTrack ]; export const e2eRoutes: Routes = components.map((component) => { diff --git a/packages/schematics/src/collection.json b/packages/schematics/src/collection.json index 37ff7cdaab..85eb7959f6 100644 --- a/packages/schematics/src/collection.json +++ b/packages/schematics/src/collection.json @@ -96,6 +96,11 @@ "description": "Migrates KbqButtonToggleGroup consumers to its signal inputs (vertical/multiple reads become calls) and reports the ARIA, tab-order and keyboard changes of the button-toggle review, including icon-only toggles left without an accessible name", "factory": "./migrations/button-toggle-signals-and-aria/index", "schema": "./migrations/button-toggle-signals-and-aria/schema.json" + }, + "scrollbar-deprecated-path": { + "description": "Rewrites @koobiq/components/scrollbar imports to @koobiq/components/scrollbar/deprecated — the overlayscrollbars-based implementation moved there when the dependency-free implementation was promoted to @koobiq/components/scrollbar", + "factory": "./migrations/scrollbar-deprecated-path/index", + "schema": "./migrations/scrollbar-deprecated-path/schema.json" } } } diff --git a/packages/schematics/src/migrations.json b/packages/schematics/src/migrations.json index 29a7405feb..0f723e878d 100644 --- a/packages/schematics/src/migrations.json +++ b/packages/schematics/src/migrations.json @@ -60,6 +60,11 @@ "version": "20.3.0-0", "description": "Migrates consumers to the narrowed button color set. `color` on KbqButton, KbqButtonGroupRoot and KbqSplitButton (and `kbqOkType` on KbqModalComponent / ModalOptions) now accepts theme, theme-fade, contrast and contrast-fade only — every other value matched no rule in kbq-button-theme() and rendered the button as a native one. Removes such a color written as a literal from templates, which is appearance-preserving now that each style falls back to its own default color, and warns about the ones it cannot resolve (enum members, programmatic assignment, members still typed KbqComponentColors / ThemePalette). Also flags the silent part: a transparent button with no explicit color now defaults to contrast instead of contrast-fade, so a `.kbq-button_transparent.kbq-contrast-fade` override stops matching.", "factory": "./migrations/button-supported-colors/index" + }, + "scrollbar-deprecated-path": { + "version": "20.3.0-0", + "description": "Rewrites `@koobiq/components/scrollbar` imports to `@koobiq/components/scrollbar/deprecated`. `@koobiq/components/scrollbar` now resolves to a new, dependency-free scrollbar directive; the previous `overlayscrollbars`-based component/directive (`options`/`events`/`defer`/`scrollbarInstance`, the `kbq-scrollbar` element selector) moved to `@koobiq/components/scrollbar/deprecated` unchanged and will be removed in a future major version.", + "factory": "./migrations/scrollbar-deprecated-path/index" } } } diff --git a/packages/schematics/src/migrations/scrollbar-deprecated-path/index.spec.ts b/packages/schematics/src/migrations/scrollbar-deprecated-path/index.spec.ts new file mode 100644 index 0000000000..d292937040 --- /dev/null +++ b/packages/schematics/src/migrations/scrollbar-deprecated-path/index.spec.ts @@ -0,0 +1,183 @@ +import { workspaces } from '@angular-devkit/core'; +import { Tree } from '@angular-devkit/schematics'; +import { SchematicTestRunner } from '@angular-devkit/schematics/testing'; +import { getWorkspace } from '@schematics/angular/utility/workspace'; +import * as path from 'path'; +import { createTestApp } from '../../utils/testing'; +import { Schema } from './schema'; + +const collectionPath = path.join(__dirname, '../../collection.json'); +const migrationsPath = path.join(__dirname, '../../migrations.json'); +const SCHEMATIC_NAME = 'scrollbar-deprecated-path'; + +describe(SCHEMATIC_NAME, () => { + let runner: SchematicTestRunner; + let appTree: Tree; + let projects: workspaces.ProjectDefinitionCollection; + + beforeEach(async () => { + runner = new SchematicTestRunner('schematics', collectionPath); + appTree = await createTestApp(runner, { style: 'scss' }); + const workspace = await getWorkspace(appTree); + + projects = workspace.projects as unknown as workspaces.ProjectDefinitionCollection; + }); + + function paths(project: workspaces.ProjectDefinition) { + // The exact file names from @schematics/angular:application vary across versions + // (app.ts vs app.component.ts), so discover them from the tree. + const root = `/${project.root}/src/app`; + + return { ts: appTree.exists(`${root}/app.ts`) ? `${root}/app.ts` : `${root}/app.component.ts` }; + } + + function run(project: string, fix = true) { + return runner.runSchematic(SCHEMATIC_NAME, { project, fix } satisfies Schema, appTree); + } + + function collectLogs(): string[] { + const messages: string[] = []; + + runner.logger.subscribe((entry) => messages.push(entry.message)); + + return messages; + } + + it('rewrites a bare @koobiq/components/scrollbar import to /deprecated', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + + appTree.overwrite( + ts, + "import { KbqScrollbarModule } from '@koobiq/components/scrollbar';\n" + + 'const x: any = KbqScrollbarModule;\n' + ); + + const updated = (await run(first)).readText(ts); + + expect(updated).toContain("from '@koobiq/components/scrollbar/deprecated'"); + expect(updated).not.toMatch(/from '@koobiq\/components\/scrollbar';/); + }); + + it('rewrites both single- and double-quoted specifiers', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + + appTree.overwrite( + ts, + 'import { KbqScrollbarModule } from "@koobiq/components/scrollbar";\n' + + 'const x: any = KbqScrollbarModule;\n' + ); + + const updated = (await run(first)).readText(ts); + + expect(updated).toContain('from "@koobiq/components/scrollbar/deprecated"'); + }); + + it('does not touch an already-migrated /deprecated import (idempotent)', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + const original = + "import { KbqScrollbarModule } from '@koobiq/components/scrollbar/deprecated';\n" + + 'const x: any = KbqScrollbarModule;\n'; + + appTree.overwrite(ts, original); + + expect((await run(first)).readText(ts)).toBe(original); + }); + + it('running the migration twice does not double-append /deprecated', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + + appTree.overwrite( + ts, + "import { KbqScrollbarModule } from '@koobiq/components/scrollbar';\n" + + 'const x: any = KbqScrollbarModule;\n' + ); + + const once = (await run(first)).readText(ts); + + appTree.overwrite(ts, once); + + const twice = (await run(first)).readText(ts); + + expect(twice).toBe(once); + expect(twice).not.toContain('/deprecated/deprecated'); + }); + + it('leaves the new @koobiq/components/scrollbar/private path untouched (already internal-only)', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + const original = + "import { KbqScrollbar } from '@koobiq/components/scrollbar/private';\nconst x: any = KbqScrollbar;\n"; + + appTree.overwrite(ts, original); + + expect((await run(first)).readText(ts)).toBe(original); + }); + + it('does not touch an unrelated sibling package whose name merely starts with "scrollbar"', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + const original = "import { Whatever } from '@koobiq/components/scrollbar-x';\nconst x: any = Whatever;\n"; + + appTree.overwrite(ts, original); + + expect((await run(first)).readText(ts)).toBe(original); + }); + + describe('ng update entry point', () => { + it('applies the fix when invoked without options', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + + appTree.overwrite( + ts, + "import { KbqScrollbarModule } from '@koobiq/components/scrollbar';\n" + + 'const x: any = KbqScrollbarModule;\n' + ); + + // `ng update` passes no options, and migrations.json declares no schema, so the + // schema default never reaches the rule — it has to default `fix` itself. + const runnerFromMigrations = new SchematicTestRunner('migrations', migrationsPath); + const result = await runnerFromMigrations.runSchematic(SCHEMATIC_NAME, {}, appTree); + + expect(result.readText(ts)).toContain("from '@koobiq/components/scrollbar/deprecated'"); + }); + }); + + describe('dry run', () => { + it('reports without writing when fix is false', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + const original = + "import { KbqScrollbarModule } from '@koobiq/components/scrollbar';\n" + + 'const x: any = KbqScrollbarModule;\n'; + const messages = collectLogs(); + + appTree.overwrite(ts, original); + + const result = await run(first, false); + + expect(result.readText(ts)).toBe(original); + expect(messages.join('\n')).toContain('would update'); + }); + }); + + it('leaves the second project untouched when scoped to the first', async () => { + const [first, second] = projects.keys(); + const { ts: firstTs } = paths(projects.get(first)!); + const { ts: secondTs } = paths(projects.get(second)!); + const original = + "import { KbqScrollbarModule } from '@koobiq/components/scrollbar';\nconst x: any = KbqScrollbarModule;\n"; + + appTree.overwrite(firstTs, original); + appTree.overwrite(secondTs, original); + + const result = await run(first); + + expect(result.readText(firstTs)).toContain('/deprecated'); + expect(result.readText(secondTs)).toBe(original); + }); +}); diff --git a/packages/schematics/src/migrations/scrollbar-deprecated-path/index.ts b/packages/schematics/src/migrations/scrollbar-deprecated-path/index.ts new file mode 100644 index 0000000000..3538cb1058 --- /dev/null +++ b/packages/schematics/src/migrations/scrollbar-deprecated-path/index.ts @@ -0,0 +1,68 @@ +import { Path } from '@angular-devkit/core'; +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { logMessage } from '../../utils/messages'; +import { setupOptions } from '../../utils/package-config'; +import { Schema } from './schema'; + +const TS_EXT = '.ts'; +const LABEL = '[scrollbar-deprecated-path]'; + +/** + * `@koobiq/components/scrollbar` now resolves to the new, dependency-free directive — + * the `overlayscrollbars`-based component/directive it used to export moved to + * `@koobiq/components/scrollbar/deprecated`. This rewrites the import specifier only; + * the API itself (`options`/`events`/`defer`/`scrollbarInstance`, the `kbq-scrollbar` + * element selector) is unchanged at its new path. + * + * Quote-anchored (`(['"])@koobiq/components/scrollbar\1`) so it matches only the exact, + * bare module specifier — never a prefix of an already-migrated `/deprecated` import, nor + * an unrelated sibling package whose name merely starts with "scrollbar". + */ +const FROM = `(['"])@koobiq/components/scrollbar\\1`; +const TO = `$1@koobiq/components/scrollbar/deprecated$1`; + +function migrate(content: string): { content: string; changed: boolean } { + const migrated = content.replace(new RegExp(FROM, 'g'), TO); + + return { content: migrated, changed: migrated !== content }; +} + +export default function scrollbarDeprecatedPath(options: Schema): Rule { + return async (tree: Tree, context: SchematicContext) => { + const { project } = options; + // `ng update` invokes migrations with no options at all, and migrations.json + // declares no schema, so the schema default never reaches us — applying the + // fix is the intended behaviour there. + const fix = options.fix ?? true; + const projectDefinition = await setupOptions(project, tree); + const root = projectDefinition?.root ?? ''; + const rootDir = root ? tree.getDir(root as Path) : tree.root; + let touched = 0; + + rootDir.visit((filePath: Path, entry) => { + if (filePath.includes('node_modules') || filePath.includes('/dist/')) return; + if (!filePath.endsWith(TS_EXT)) return; + + const originalContent = entry?.content.toString(); + + if (!originalContent || !originalContent.includes('@koobiq/components/scrollbar')) return; + + const { content, changed } = migrate(originalContent); + + if (!changed) return; + + touched++; + + if (fix) { + tree.overwrite(filePath, content); + } else { + logMessage(context.logger, [`${LABEL} would update ${filePath} (run with --fix to apply)`]); + } + }); + + logMessage(context.logger, [ + `${LABEL} processed tree under "${root || ''}", ` + + `${fix ? 'updated' : 'would update'} ${touched} file(s).` + ]); + }; +} diff --git a/packages/schematics/src/migrations/scrollbar-deprecated-path/schema.json b/packages/schematics/src/migrations/scrollbar-deprecated-path/schema.json new file mode 100644 index 0000000000..714bafb758 --- /dev/null +++ b/packages/schematics/src/migrations/scrollbar-deprecated-path/schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "koobiq-components-scrollbar-deprecated-path", + "title": "Koobiq components scrollbar deprecated-path rewrite", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "Name of the project to migrate. If omitted, the migration runs over the whole tree.", + "$default": { + "$source": "projectName" + } + }, + "fix": { + "type": "boolean", + "default": true, + "description": "When true, applies all auto-fix replacements. When false, prints what would change without writing." + } + } +} diff --git a/packages/schematics/src/migrations/scrollbar-deprecated-path/schema.ts b/packages/schematics/src/migrations/scrollbar-deprecated-path/schema.ts new file mode 100644 index 0000000000..fefc5cd6b3 --- /dev/null +++ b/packages/schematics/src/migrations/scrollbar-deprecated-path/schema.ts @@ -0,0 +1,6 @@ +export interface Schema { + /** Name of the project to migrate. */ + project?: string; + /** When true, applies replacements; when false, only logs what would change. Defaults to true. */ + fix?: boolean; +} diff --git a/tools/api-extractor/api-extractor.ts b/tools/api-extractor/api-extractor.ts index deccafaaf5..ada2e85465 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/deprecated" flattens to "scrollbar-deprecated.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/cspell-locales/ru.json b/tools/cspell-locales/ru.json index bb1362f8e4..80680b73be 100644 --- a/tools/cspell-locales/ru.json +++ b/tools/cspell-locales/ru.json @@ -42,6 +42,7 @@ "виджете", "виджетом", "викисклада", + "вьюпорта", "гггг", "гига", "грид", @@ -192,8 +193,10 @@ "скроллбар", "скроллбара", "скроллбаров", + "скроллбары", "скролле", "скроллеру", + "скроллить", "скроллом", "снапшоты", "спиcка", diff --git a/tools/public_api_guard/components/app-switcher.api.md b/tools/public_api_guard/components/app-switcher.api.md index 52c493690a..674f7c14c5 100644 --- a/tools/public_api_guard/components/app-switcher.api.md +++ b/tools/public_api_guard/components/app-switcher.api.md @@ -32,7 +32,6 @@ import { QueryList } from '@angular/core'; import * as rxjs from 'rxjs'; import { SafeHtml } from '@angular/platform-browser'; import { ScrollStrategy } from '@angular/cdk/overlay'; -import { Subscription } from 'rxjs'; import { TemplateRef } from '@angular/core'; import { Type } from '@angular/core'; @@ -225,7 +224,6 @@ export class KbqAppSwitcherTrigger extends KbqPopUpTrigger; placement: KbqPopUpPlacementValues; readonly placementChange: EventEmitter; - protected preventClosingByInnerScrollSubscription: Subscription; protected scrollStrategy: () => ScrollStrategy; readonly selectedApp: i0.ModelSignal; readonly selectedSite: i0.ModelSignal; diff --git a/tools/public_api_guard/components/scrollbar-deprecated.api.md b/tools/public_api_guard/components/scrollbar-deprecated.api.md new file mode 100644 index 0000000000..fdbafb91fd --- /dev/null +++ b/tools/public_api_guard/components/scrollbar-deprecated.api.md @@ -0,0 +1,158 @@ +## API Report File for "koobiq" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { AfterViewInit } from '@angular/core'; +import { ElementRef } from '@angular/core'; +import { EventEmitter } from '@angular/core'; +import { EventListenerArgs } from 'overlayscrollbars'; +import { EventListeners } from 'overlayscrollbars'; +import * as i0 from '@angular/core'; +import * as i1 from '@angular/cdk/overlay'; +import { InitializationTarget } from 'overlayscrollbars'; +import { InjectionToken } from '@angular/core'; +import { KbqOverflowShadowSource } from '@koobiq/components/core'; +import { OnDestroy } from '@angular/core'; +import { OverlayScrollbars } from 'overlayscrollbars'; +import * as overlayscrollbars from 'overlayscrollbars'; +import { PartialOptions } from 'overlayscrollbars'; +import { Provider } from '@angular/core'; + +// @public (undocumented) +export const KBQ_SCROLLBAR_CONFIG: InjectionToken<{ + paddingAbsolute?: boolean | undefined; + showNativeOverlaidScrollbars?: boolean | undefined; + update?: { + elementEvents?: [elementSelector: string, eventNames: string][] | null | undefined; + debounce?: number | [timeout: number, maxWait: number] | null | undefined; + attributes?: string[] | null | undefined; + ignoreMutation?: ((mutation: MutationRecord) => any) | null | undefined; + } | undefined; + overflow?: { + x?: overlayscrollbars.OverflowBehavior | undefined; + y?: overlayscrollbars.OverflowBehavior | undefined; + } | undefined; + scrollbars?: { + theme?: string | null | undefined; + visibility?: overlayscrollbars.ScrollbarsVisibilityBehavior | undefined; + autoHide?: overlayscrollbars.ScrollbarsAutoHideBehavior | undefined; + autoHideDelay?: number | undefined; + autoHideSuspend?: boolean | undefined; + dragScroll?: boolean | undefined; + clickScroll?: boolean | undefined; + pointers?: string[] | null | undefined; + } | undefined; +}>; + +// @public (undocumented) +export const KBQ_SCROLLBAR_OPTIONS_DEFAULT_CONFIG: KbqScrollbarOptions; + +// @public +export const KBQ_SCROLLBAR_OPTIONS_DEFAULT_CONFIG_PROVIDER: Provider; + +// @public @deprecated (undocumented) +export class KbqScrollbar implements AfterViewInit, OnDestroy, KbqOverflowShadowSource { + readonly contentElement: i0.Signal>; + readonly defer: i0.InputSignal; + // (undocumented) + get element(): HTMLElement; + // (undocumented) + readonly events: i0.InputSignal<{ + initialized?: ((instance: overlayscrollbars.OverlayScrollbars) => void) | ((instance: overlayscrollbars.OverlayScrollbars) => void)[] | null | undefined; + updated?: ((instance: overlayscrollbars.OverlayScrollbars, onUpdatedArgs: overlayscrollbars.OnUpdatedEventListenerArgs) => void) | ((instance: overlayscrollbars.OverlayScrollbars, onUpdatedArgs: overlayscrollbars.OnUpdatedEventListenerArgs) => void)[] | null | undefined; + destroyed?: ((instance: overlayscrollbars.OverlayScrollbars, canceled: boolean) => void) | ((instance: overlayscrollbars.OverlayScrollbars, canceled: boolean) => void)[] | null | undefined; + scroll?: ((instance: overlayscrollbars.OverlayScrollbars, event: Event) => void) | ((instance: overlayscrollbars.OverlayScrollbars, event: Event) => void)[] | null | undefined; + }>; + getScrollElement(): HTMLElement | null; + readonly initializationTarget: i0.InputSignal; + // (undocumented) + mergeEvents(): KbqScrollbarEvents; + // (undocumented) + ngAfterViewInit(): void; + // (undocumented) + ngOnDestroy(): void; + readonly onDestroy: EventEmitter<[instance: overlayscrollbars.OverlayScrollbars, canceled: boolean]>; + // (undocumented) + readonly onInitialize: EventEmitter<[instance: overlayscrollbars.OverlayScrollbars]>; + // (undocumented) + readonly onScroll: EventEmitter<[instance: overlayscrollbars.OverlayScrollbars, event: Event]>; + readonly onUpdate: EventEmitter<[instance: overlayscrollbars.OverlayScrollbars, onUpdatedArgs: overlayscrollbars.OnUpdatedEventListenerArgs]>; + readonly options: i0.InputSignal<{ + paddingAbsolute?: boolean | undefined; + showNativeOverlaidScrollbars?: boolean | undefined; + update?: { + elementEvents?: [elementSelector: string, eventNames: string][] | null | undefined; + debounce?: number | [timeout: number, maxWait: number] | null | undefined; + attributes?: string[] | null | undefined; + ignoreMutation?: ((mutation: MutationRecord) => any) | null | undefined; + } | undefined; + overflow?: { + x?: overlayscrollbars.OverflowBehavior | undefined; + y?: overlayscrollbars.OverflowBehavior | undefined; + } | undefined; + scrollbars?: { + theme?: string | null | undefined; + visibility?: overlayscrollbars.ScrollbarsVisibilityBehavior | undefined; + autoHide?: overlayscrollbars.ScrollbarsAutoHideBehavior | undefined; + autoHideDelay?: number | undefined; + autoHideSuspend?: boolean | undefined; + dragScroll?: boolean | undefined; + clickScroll?: boolean | undefined; + pointers?: string[] | null | undefined; + } | undefined; + }>; + scrollTo(options?: ScrollToOptions): void; + // (undocumented) + static ɵcmp: i0.ɵɵComponentDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + +// @public @deprecated (undocumented) +export class KbqScrollbarDirective implements OnDestroy { + constructor(); + readonly defer: i0.InputSignal; + set events(value: KbqScrollbarEvents); + // (undocumented) + get events(): KbqScrollbarEvents | undefined; + // (undocumented) + initialize(target: KbqScrollbarTarget): void; + // (undocumented) + ngOnDestroy(): void; + set options(value: KbqScrollbarOptions); + get options(): KbqScrollbarOptions | undefined; + // (undocumented) + scrollbarInstance?: OverlayScrollbars; + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + +// @public (undocumented) +export type KbqScrollbarEventListenerArgs = EventListenerArgs; + +// @public (undocumented) +export type KbqScrollbarEvents = EventListeners; + +// @public @deprecated (undocumented) +export class KbqScrollbarModule { + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; + // (undocumented) + static ɵinj: i0.ɵɵInjectorDeclaration; + // (undocumented) + static ɵmod: i0.ɵɵNgModuleDeclaration; +} + +// @public (undocumented) +export type KbqScrollbarOptions = PartialOptions; + +// @public (undocumented) +export type KbqScrollbarTarget = InitializationTarget; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/tools/public_api_guard/components/scrollbar.api.md b/tools/public_api_guard/components/scrollbar.api.md index 29910ddfe7..6bd3b06e44 100644 --- a/tools/public_api_guard/components/scrollbar.api.md +++ b/tools/public_api_guard/components/scrollbar.api.md @@ -4,154 +4,79 @@ ```ts -import { AfterViewInit } from '@angular/core'; import { ElementRef } from '@angular/core'; -import { EventEmitter } from '@angular/core'; -import { EventListenerArgs } from 'overlayscrollbars'; -import { EventListeners } from 'overlayscrollbars'; +import { ExtendedScrollToOptions } from '@angular/cdk/scrolling'; import * as i0 from '@angular/core'; -import * as i1 from '@angular/cdk/overlay'; -import { InitializationTarget } from 'overlayscrollbars'; +import * as i1 from '@angular/cdk/scrolling'; +import * as i2 from '@angular/cdk/a11y'; import { InjectionToken } from '@angular/core'; -import { KbqOverflowShadowSource } from '@koobiq/components/core'; -import { OnDestroy } from '@angular/core'; -import { OverlayScrollbars } from 'overlayscrollbars'; -import * as overlayscrollbars from 'overlayscrollbars'; -import { PartialOptions } from 'overlayscrollbars'; +import { Observable } from 'rxjs'; import { Provider } from '@angular/core'; -// @public (undocumented) -export const KBQ_SCROLLBAR_CONFIG: InjectionToken<{ - paddingAbsolute?: boolean | undefined; - showNativeOverlaidScrollbars?: boolean | undefined; - update?: { - elementEvents?: [elementSelector: string, eventNames: string][] | null | undefined; - debounce?: number | [timeout: number, maxWait: number] | null | undefined; - attributes?: string[] | null | undefined; - ignoreMutation?: ((mutation: MutationRecord) => any) | null | undefined; - } | undefined; - overflow?: { - x?: overlayscrollbars.OverflowBehavior | undefined; - y?: overlayscrollbars.OverflowBehavior | undefined; - } | undefined; - scrollbars?: { - theme?: string | null | undefined; - visibility?: overlayscrollbars.ScrollbarsVisibilityBehavior | undefined; - autoHide?: overlayscrollbars.ScrollbarsAutoHideBehavior | undefined; - autoHideDelay?: number | undefined; - autoHideSuspend?: boolean | undefined; - dragScroll?: boolean | undefined; - clickScroll?: boolean | undefined; - pointers?: string[] | null | undefined; - } | undefined; -}>; - -// @public (undocumented) -export const KBQ_SCROLLBAR_OPTIONS_DEFAULT_CONFIG: KbqScrollbarOptions; +// @public +export const KBQ_SCROLLBAR_OPTIONS: InjectionToken; // @public -export const KBQ_SCROLLBAR_OPTIONS_DEFAULT_CONFIG_PROVIDER: Provider; +export const KBQ_SCROLLBAR_VIEWPORT: InjectionToken>; // @public -export class KbqScrollbar implements AfterViewInit, OnDestroy, KbqOverflowShadowSource { - readonly contentElement: i0.Signal>; - readonly defer: i0.InputSignal; - // (undocumented) - get element(): HTMLElement; - // (undocumented) - readonly events: i0.InputSignal<{ - initialized?: ((instance: overlayscrollbars.OverlayScrollbars) => void) | ((instance: overlayscrollbars.OverlayScrollbars) => void)[] | null | undefined; - updated?: ((instance: overlayscrollbars.OverlayScrollbars, onUpdatedArgs: overlayscrollbars.OnUpdatedEventListenerArgs) => void) | ((instance: overlayscrollbars.OverlayScrollbars, onUpdatedArgs: overlayscrollbars.OnUpdatedEventListenerArgs) => void)[] | null | undefined; - destroyed?: ((instance: overlayscrollbars.OverlayScrollbars, canceled: boolean) => void) | ((instance: overlayscrollbars.OverlayScrollbars, canceled: boolean) => void)[] | null | undefined; - scroll?: ((instance: overlayscrollbars.OverlayScrollbars, event: Event) => void) | ((instance: overlayscrollbars.OverlayScrollbars, event: Event) => void)[] | null | undefined; - }>; - getScrollElement(): HTMLElement | null; - readonly initializationTarget: i0.InputSignal; - // (undocumented) - mergeEvents(): KbqScrollbarEvents; - // (undocumented) - ngAfterViewInit(): void; - // (undocumented) - ngOnDestroy(): void; - readonly onDestroy: EventEmitter<[instance: overlayscrollbars.OverlayScrollbars, canceled: boolean]>; - // (undocumented) - readonly onInitialize: EventEmitter<[instance: overlayscrollbars.OverlayScrollbars]>; - // (undocumented) - readonly onScroll: EventEmitter<[instance: overlayscrollbars.OverlayScrollbars, event: Event]>; - readonly onUpdate: EventEmitter<[instance: overlayscrollbars.OverlayScrollbars, onUpdatedArgs: overlayscrollbars.OnUpdatedEventListenerArgs]>; - readonly options: i0.InputSignal<{ - paddingAbsolute?: boolean | undefined; - showNativeOverlaidScrollbars?: boolean | undefined; - update?: { - elementEvents?: [elementSelector: string, eventNames: string][] | null | undefined; - debounce?: number | [timeout: number, maxWait: number] | null | undefined; - attributes?: string[] | null | undefined; - ignoreMutation?: ((mutation: MutationRecord) => any) | null | undefined; - } | undefined; - overflow?: { - x?: overlayscrollbars.OverflowBehavior | undefined; - y?: overlayscrollbars.OverflowBehavior | undefined; - } | undefined; - scrollbars?: { - theme?: string | null | undefined; - visibility?: overlayscrollbars.ScrollbarsVisibilityBehavior | undefined; - autoHide?: overlayscrollbars.ScrollbarsAutoHideBehavior | undefined; - autoHideDelay?: number | undefined; - autoHideSuspend?: boolean | undefined; - dragScroll?: boolean | undefined; - clickScroll?: boolean | undefined; - pointers?: string[] | null | undefined; - } | undefined; - }>; - scrollTo(options?: ScrollToOptions): void; - // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; +export class KbqScrollbar { + getNativeElement(): HTMLElement; + readonly mode: i0.InputSignal; + get scrollChanges(): Observable; + scrollEnd(behavior?: ScrollBehavior): void; + scrollIntoView(target: HTMLElement, 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; + // (undocumented) + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } // @public -export class KbqScrollbarDirective implements OnDestroy { - constructor(); - readonly defer: i0.InputSignal; - set events(value: KbqScrollbarEvents); - // (undocumented) - get events(): KbqScrollbarEvents | undefined; - // (undocumented) - initialize(target: KbqScrollbarTarget): void; - // (undocumented) - ngOnDestroy(): void; - set options(value: KbqScrollbarOptions); - get options(): KbqScrollbarOptions | undefined; - // (undocumented) - scrollbarInstance?: OverlayScrollbars; - // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration; - // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; -} +export type KbqScrollbarMode = 'always' | 'hidden' | 'hover' | 'native'; -// @public (undocumented) -export type KbqScrollbarEventListenerArgs = EventListenerArgs; +// @public +export type KbqScrollbarOptions = { + mode: KbqScrollbarMode; +}; -// @public (undocumented) -export type KbqScrollbarEvents = EventListeners; +// @public +export function kbqScrollbarOptionsProvider(options: Partial): Provider; -// @public (undocumented) -export class KbqScrollbarModule { - // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; - // (undocumented) - static ɵinj: i0.ɵɵInjectorDeclaration; - // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; -} +// @public +export type KbqScrollbarScrollToElementOptions = { + top?: number; + left?: number; + behavior?: ScrollBehavior; +}; -// @public (undocumented) -export type KbqScrollbarOptions = PartialOptions; +// @public +export type KbqScrollbarScrollToOptions = ExtendedScrollToOptions; -// @public (undocumented) -export type KbqScrollbarTarget = InitializationTarget; +// @public +export class KbqScrollbarViewport { + constructor(); + getNativeElement(): HTMLElement; + protected readonly id: string; + readonly mode: i0.InputSignal; + get scrollChanges(): Observable; + scrollEnd(behavior?: ScrollBehavior): void; + scrollIntoView(target: HTMLElement, 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; + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} // (No @packageDocumentation comment for this package) diff --git a/tsconfig.json b/tsconfig.json index 7c213e4df4..8435523fec 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/deprecated": ["packages/components/scrollbar/deprecated/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"],