После вступления 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'))
      );
    })
  );

Обновлённый пример тут. Там же можно увидеть методы, в которые убрана вся математика из кода выше.