После вступления Edge в доблестные ряды Chromium-браузеров кастомизация скроллбаров через CSS отсутствует только в Firefox. Это здорово, но кроме Firefox у CSS-решения есть масса ограничений. Посмотрите, какую черную магию приходится применять для плавного исчезновения. Чтобы получить полный контроль над внешним видом, по-прежнему нужно прибегать к JavaScript. Давайте разберемся, как это по-хорошему сделать через компонент Angular.

Магия CSS
Без нее, конечно, не обойтись. Во-первых, нам нужно скрыть нативные скроллбары. Это уже можно сделать во всех браузерах. Для этого есть стандартное правило scrollbar-width, которое на данный момент работает как раз только в Firefox. Для старых версий Edge и IE есть его аналог -ms-overflow-style. Однако IE мы поддерживать не будем, так как нам понадобится position: sticky. В Chrome и Safari уже давно можно использовать псевдоселектор ::-webkit-scrollbar.
Во-вторых, чтобы не нарушать нативную работу и скроллить сам контейнер, нам нужно обойтись без вложенной скроллящейся обертки. А значит, необходимо реализовать что-то вроде локального position: fixed для ползунков. Абсолютное позиционирование не поможет: при прокрутке ползунки будут исчезать из виду вместе с содержимым.
Этого мы сможем добиться с помощью хитрой комбинации position: sticky у ползунков и display: flex у самого компонента. Внутри нам понадобятся два контейнера:
<div class="bars">...</div>
<div class="content"><ng-content></ng-content></div>Для начала мы изолируем контекст наложения. Не все про это знают, из-за чего на проектах часто можно встретить z-index: 1000, 1001, 9999. Чтобы ничто из контента не смогло перекрыть ползунки, мы повесим на него position: relative, z-index: 0. Это создаст в его рамках новый контекст наложения и не позволит внутренним элементам перекрыть что-то снаружи.
Ползункам зададим z-index: 1, чтобы поднять их выше контента, а также минимальную ширину 100%. Получим следующую картину:

Ползунки заняли все место, сдвинув содержимое вправо. При этом прокрутка работает и ползунки никуда не деваются. Остается добавить им margin-right: -100%, чтобы они «подвинулись» и освободили место под содержимое компонента.
В принципе, этого можно добиться и без флекса, используяfloat, но высоту обертки для ползунков не удастся сделать на 100%, если высота самого скроллбара задана неявно (max-height, flex: 1и так далее).
Angular
Если вы читали другие мои статьи про Angular, то знаете, что я большой любитель декларативного подхода. Этот случай не станет исключением. Постараемся написать код максимально аккуратно. В качестве примера возьмем вертикальную прокрутку, для горизонтальной все будет идентично. Добавим в шаблон ползунок:
<div class="bars">
<div *ngIf="hasVerticalBar" class="bar">
<div
class="thumb"
[class.thumb_active]="verticalThumbActive"
[style.height.%]="verticalView"
[style.top.%]="verticalThumb"
></div>
</div>
</div>Для задания его внешнего вида используются геттеры:
// На сколько процентов компонент проскроллили
get verticalScrolled(): number {
const {
scrollTop,
scrollHeight,
clientHeight
} = this.elementRef.nativeElement;
return scrollTop / (scrollHeight - clientHeight);
}
// Какой процент содержимого виден
get verticalSize(): number {
const { clientHeight, scrollHeight } = this.elementRef.nativeElement;
return Math.ceil(clientHeight / scrollHeight * 100);
}
// На сколько процентов сдвинут ползунок
get verticalPosition(): number {
return this.verticalScrolled * (100 - this.verticalSize);
}
// Содержимое не уместилось, виден ползунок
get hasVerticalBar(): boolean {
return this.verticalSize < 100;
}Теперь нам нужно просто слушать скролл, чтобы запускать проверку изменений, — и наш компонент начнет работать с колесиком или трекпадом. Осталось научиться двигать ползунки мышкой. Эту логику стоит вынести в отдельную директиву, но для прозрачности сейчас мы это делать не будем.
Перемещение ползунка начинается с событияmousedownна нем, выполняется с событиемmousemoveна документе и завершается событиемmouseupна документе.
Добавим обработчики на ползунок:
<div
#vertical
class="thumb"
[class.thumb_active]="verticalThumbActive"
[style.height.%]="verticalSize"
[style.top.%]="verticalPosition"
(mousedown)="onVerticalStart($event)"
(document:mousemove)="onVerticalMove($event, vertical)"
></div>А в коде компонента будем обрабатывать эти события и слушать mouseup:
@HostListener('document:mouseup)
onDragEnd() {
this.verticalThumbActive = false;
}
onVerticalStart(event: MouseEvent) {
event.preventDefault();
const { target, clientY } = event;
const { top, height } = target.getBoundingClientRect();
this.verticalThumbDragOffset = (clientY - top) / height;
this.verticalThumbActive = true;
}
onVerticalMove(
{ clientY }: MouseEvent,
{ offsetHeight }: HTMLElement
) {
if (!this.verticalThumbActive) {
return;
}
const { nativeElement } = this.elementRef;
const { top, height } = nativeElement.getBoundingClientRect();
const maxScrollTop = nativeElement.scrollHeight - height;
const scrolled =
(clientY - top - offsetHeight * this.verticalThumbDragOffset) /
(height - offsetHeight);
nativeElement.scrollTop = maxScrollTop * scrolled;
}Теперь ползунки тоже работают.
Магия Angular
Бывалый ангулярщик может заметить, что у этого решения крайне низкая производительность. На каждый экземпляр скроллбара мы слушаем все события mousemove на документе и каждый раз запускаем проверку изменений. Так дело не пойдет. К счастью, Angular позволяет работать с событиями иначе, о чем я писал ранее. Воспользовавшись библиотекой @tinkoff/ng-event-plugins, мы избавимся от лишних вызовов проверки изменений. Для этого добавим модификатор .silent к подписке и декоратор @shouldCall к обработчику:
(document:mousemove.silent)="onVerticalMove($event, vertical)" @shouldCall(isActive)
@HostListener('init.end', ['$event'])
@HostListener('document:mouseup.silent')
onDragEnd() {
this.verticalThumbActive = false;
}
@shouldCall(isActive)
@HostListener('init.move', ['$event'])
onVerticalMove(
{ clientY }: MouseEvent,
{ offsetHeight }: HTMLElement
) {
const { nativeElement } = this.elementRef;
const { top, height } = nativeElement.getBoundingClientRect();
const maxScrollTop = nativeElement.scrollHeight - height;
const scrolled =
(clientY - top - offsetHeight * this.verticalThumbDragOffset) /
(height - offsetHeight);
nativeElement.scrollTop = maxScrollTop * scrolled;
}Примечание: до выхода Angular 10 с методомmarkDirty(this)вместе с@shouldCallприходится использовать специальный декоратор@HostListener(‘init.xxx’, [‘$event’]), чтобы запустить проверку изменений, подробнее — в упомянутой статье.
Теперь проверка изменений и обработчики событий будут запускаться, только когда мы действительно тянем ползунок. Наш скроллбар готов к использованию. В качестве доработки еще можно следить за изменениями размера самого контейнера, чтобы пересчитать размер ползунков. Для этого отлично подойдет ResizeObserver и наша библиотека @ng-web-apis/resize-observer, о которой вы можете почитать тут. Полноценное демо скроллбара доступно на Stackblitz.
Edit: Директива
Как справедливо заметили в комментариях, получилось не так декларативно, как хотелось бы. Попробуем уложить логику ползунков в директиву. Для этого будет достаточно одного Output объявленного через fromEvent, что избавляет код от состояний и лишней императивности:
@Output()
dragged = fromEvent(
this.elementRef.nativeElement,
'mousedown'
).pipe(
switchMap(event => {
event.preventDefault();
const clientRect = event.target.getBoundingClientRect();
const offsetVertical = getOffsetVertical(event, clientRect);
const offsetHorizontal = getOffsetHorizontal(event, clientRect);
return fromEvent(this.documentRef, 'mousemove').pipe(
map(event => this.getScrolled(event, offsetVertical, offsetHorizontal)),
takeUntil(fromEvent(this.documentRef, 'mouseup'))
);
})
);Обновлённый пример тут. Там же можно увидеть методы, в которые убрана вся математика из кода выше.
pepelsbey
Обожаю современный веб: пишешь не на Angular? Проходи мимо, код для тебя бесполезен. Отсюда и ценность статьи — чуть выше, чем «как сложить 2+2 на jQuery».